Merge pull request 'version 0.3.0' (#1) from dev into main

Reviewed-on: #1
This commit is contained in:
ari melody 2024-07-02 19:39:03 +00:00
commit ac1212720d
32 changed files with 970 additions and 493 deletions

10
CHANGELOGS.md Normal file
View file

@ -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

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "campfire-client", "name": "campfire-client",
"version": "0.2.0_rev3", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "campfire-client", "name": "campfire-client",
"version": "0.2.0_rev3", "version": "0.3.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",

View file

@ -1,6 +1,6 @@
{ {
"name": "campfire-client", "name": "campfire-client",
"version": "0.2.0", "version": "0.3.0",
"description": "social media for the galaxy-wide-web! 🌌", "description": "social media for the galaxy-wide-web! 🌌",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -12,14 +12,14 @@
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Campfire"> <meta property="og:title" content="Campfire">
<meta property="og:description" content="Social media for the galaxy-wide-web!"> <meta property="og:description" content="Social media for the galaxy-wide-web!">
<meta property="og:image" content="https://campfire.bliss.town/favicon.png"> <meta property="og:image" content="https://campfire.bliss.town/icon/campfire-icon.png">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="campfire.bliss.town"> <meta property="twitter:domain" content="campfire.bliss.town">
<meta property="twitter:url" content="https://campfire.bliss.town"> <meta property="twitter:url" content="https://campfire.bliss.town">
<meta name="twitter:title" content="Campfire"> <meta name="twitter:title" content="Campfire">
<meta name="twitter:description" content="Social media for the galaxy-wide-web!"> <meta name="twitter:description" content="Social media for the galaxy-wide-web!">
<meta name="twitter:image" content="https://campfire.bliss.town/favicon.png"> <meta name="twitter:image" content="https://campfire.bliss.town/icon/campfire-icon.png">
%sveltekit.head% %sveltekit.head%
</head> </head>

View file

@ -1,3 +1,4 @@
<svg viewBox="0 0 128 128" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 128 128" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.5 94V79H31C24.373 79 19 73.627 19 67V40C19 33.373 24.373 28 31 28H49.75C56.377 28 61.75 33.373 61.75 40V73C61.75 75.07 61.162 77.76 60.261 80.421C59.396 82.977 58.159 85.8 56.647 88.446C55.093 91.166 53.166 93.828 50.962 95.865C48.479 98.157 45.232 100 41.5 100C38.186 100 35.5 97.314 35.5 94ZM41.5 94C43.654 94 45.458 92.78 46.891 91.457C48.664 89.819 50.187 87.657 51.437 85.469C52.751 83.17 53.826 80.717 54.578 78.496C55.247 76.52 55.75 74.537 55.75 73V40C55.75 36.686 53.064 34 49.75 34H31C27.686 34 25 36.686 25 40V67C25 70.314 27.686 73 31 73H38.5C40.157 73 41.5 74.343 41.5 76V94ZM82.25 94V79H77.75C71.123 79 65.75 73.627 65.75 67V40C65.75 33.373 71.123 28 77.75 28H96.5C103.127 28 108.5 33.373 108.5 40V73C108.5 75.07 107.912 77.76 107.011 80.421C106.146 82.977 104.909 85.8 103.397 88.446C101.843 91.166 99.916 93.828 97.712 95.865C95.229 98.157 91.982 100 88.25 100C84.936 100 82.25 97.314 82.25 94ZM88.25 94C90.404 94 92.208 92.78 93.641 91.457C95.414 89.819 96.937 87.657 98.187 85.469C99.501 83.17 100.576 80.717 101.328 78.496C101.997 76.52 102.5 74.537 102.5 73V40C102.5 36.686 99.814 34 96.5 34H77.75C74.436 34 71.75 36.686 71.75 40V67C71.75 70.314 74.436 73 77.75 73H85.25C86.907 73 88.25 74.343 88.25 76V94Z"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M41.25 102C36.8317 102 33.25 98.4183 33.25 94V81H30.75C23.018 81 16.75 74.732 16.75 67V40C16.75 32.268 23.018 26 30.75 26H49.5C57.232 26 63.5 32.268 63.5 40V73C63.5 75.6212 62.7488 78.5715 61.9055 81.062C60.9986 83.7402 59.7131 86.6735 58.1334 89.4379C56.5813 92.154 54.5609 95.032 52.0686 97.3339C49.6611 99.5574 45.9736 102 41.25 102ZM41.25 73V94C44.1431 94 46.9245 91.6565 49.25 88.4761C50.8958 86.2252 52.3132 83.5551 53.3802 81C54.7139 77.806 55.5 74.7916 55.5 73V40C55.5 36.6863 52.8137 34 49.5 34H30.75C27.4363 34 24.75 36.6863 24.75 40V67C24.75 70.3137 27.4363 73 30.75 73H41.25ZM40.5032 86.1254C40.5032 86.1249 40.512 86.1202 40.5295 86.1129C40.5119 86.1223 40.5032 86.126 40.5032 86.1254Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88 102C83.5817 102 80 98.4183 80 94V81H77.5C69.768 81 63.5 74.732 63.5 67V40C63.5 32.268 69.768 26 77.5 26H96.25C103.982 26 110.25 32.268 110.25 40V73C110.25 75.6212 109.499 78.5715 108.655 81.062C107.749 83.7402 106.463 86.6735 104.883 89.4379C103.331 92.154 101.311 95.032 98.8186 97.3339C96.4111 99.5574 92.7236 102 88 102ZM88 73V94C90.8931 94 93.6745 91.6565 96 88.4761C97.6458 86.2252 99.0632 83.5551 100.13 81C101.464 77.806 102.25 74.7916 102.25 73V40C102.25 36.6863 99.5637 34 96.25 34H77.5C74.1863 34 71.5 36.6863 71.5 40V67C71.5 70.3137 74.1863 73 77.5 73H88ZM87.2532 86.1254C87.2532 86.1249 87.262 86.1202 87.2795 86.1129C87.2619 86.1223 87.2532 86.126 87.2532 86.1254Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -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 { capabilities } from '../client/instance.js';
import Post from '../post.js'; import Post from '$lib/post.js';
import User from '../user/user.js'; import User from '$lib/user/user.js';
import Emoji from '../emoji.js'; import Emoji from '$lib/emoji.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
export async function createApp(host) { export async function createApp(host) {
@ -31,25 +32,23 @@ export async function createApp(host) {
} }
export function getOAuthUrl() { export function getOAuthUrl() {
let client = get(Client.get()); return `https://${get(client).instance.host}/oauth/authorize` +
return `https://${client.instance.host}/oauth/authorize` + `?client_id=${get(client).app.id}` +
`?client_id=${client.app.id}` +
"&scope=read+write+push" + "&scope=read+write+push" +
`&redirect_uri=${location.origin}/callback` + `&redirect_uri=${location.origin}/callback` +
"&response_type=code"; "&response_type=code";
} }
export async function getToken(code) { export async function getToken(code) {
let client = get(Client.get());
let form = new FormData(); let form = new FormData();
form.append("client_id", client.app.id); form.append("client_id", get(client).app.id);
form.append("client_secret", client.app.secret); form.append("client_secret", get(client).app.secret);
form.append("redirect_uri", `${location.origin}/callback`); form.append("redirect_uri", `${location.origin}/callback`);
form.append("grant_type", "authorization_code"); form.append("grant_type", "authorization_code");
form.append("code", code); form.append("code", code);
form.append("scope", "read write push"); 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", method: "POST",
body: form, body: form,
}) })
@ -65,13 +64,12 @@ export async function getToken(code) {
} }
export async function revokeToken() { export async function revokeToken() {
let client = get(Client.get());
let form = new FormData(); let form = new FormData();
form.append("client_id", client.app.id); form.append("client_id", get(client).app.id);
form.append("client_secret", client.app.secret); form.append("client_secret", get(client).app.secret);
form.append("token", client.app.token); 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", method: "POST",
body: form, body: form,
}) })
@ -85,34 +83,52 @@ export async function revokeToken() {
} }
export async function verifyCredentials() { export async function verifyCredentials() {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/accounts/verify_credentials`;
let url = `https://${client.instance.host}/api/v1/accounts/verify_credentials`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', 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()); }).then(res => res.json());
return data; return data;
} }
export async function getTimeline(last_post_id) { export async function getTimeline(last_post_id) {
let client = get(Client.get()); if (!get(user)) return false;
let url = `https://${client.instance.host}/api/v1/timelines/home`; let url = `https://${get(client).instance.host}/api/v1/timelines/home`;
if (last_post_id) url += "?max_id=" + last_post_id; if (last_post_id) url += "?max_id=" + last_post_id;
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json()); }).then(res => res.json());
return data; return data;
} }
export async function getPost(post_id, ancestor_count) { export async function getPost(post_id, ancestor_count) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -120,11 +136,10 @@ export async function getPost(post_id, ancestor_count) {
} }
export async function getPostContext(post_id) { export async function getPostContext(post_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/context`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/context`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -132,11 +147,10 @@ export async function getPostContext(post_id) {
} }
export async function boostPost(post_id) { export async function boostPost(post_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/reblog`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/reblog`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -144,11 +158,10 @@ export async function boostPost(post_id) {
} }
export async function unboostPost(post_id) { export async function unboostPost(post_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreblog`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreblog`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -156,11 +169,10 @@ export async function unboostPost(post_id) {
} }
export async function favouritePost(post_id) { export async function favouritePost(post_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/favourite`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/favourite`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -168,11 +180,10 @@ export async function favouritePost(post_id) {
} }
export async function unfavouritePost(post_id) { export async function unfavouritePost(post_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unfavourite`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unfavourite`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -185,11 +196,10 @@ export async function reactPost(post_id, shortcode) {
// to the default like emote. // to the default like emote.
// identical api calls on chuckya instances do not display // identical api calls on chuckya instances do not display
// this behaviour. // this behaviour.
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
@ -197,26 +207,24 @@ export async function reactPost(post_id, shortcode) {
} }
export async function unreactPost(post_id, shortcode) { export async function unreactPost(post_id, shortcode) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`;
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'POST', method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false }); }).then(res => { return res.ok ? res.json() : false });
if (data === false) return false; if (data === false) return false;
return data; return data;
} }
export async function parsePost(data, ancestor_count, with_context) { export async function parsePost(data, ancestor_count) {
let client = get(Client.get());
let post = new Post(); let post = new Post();
post.text = data.content; post.text = data.content;
post.html = data.content;
post.reply = null; post.reply = null;
if (!with_context && // ancestor replies are handled in full later if ((data.in_reply_to_id || data.reply) &&
(data.in_reply_to_id || data.reply) &&
ancestor_count !== 0 ancestor_count !== 0
) { ) {
const reply_data = data.reply || await getPost(data.in_reply_to_id, ancestor_count - 1); 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; if (!reply_data) return false;
post.reply = await parsePost(reply_data, ancestor_count - 1, false); post.reply = await parsePost(reply_data, ancestor_count - 1, false);
} }
post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
post.replies = []; post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
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.id = data.id; post.id = data.id;
post.created_at = new Date(data.created_at); 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); post.reactions = parseReactions(data.reactions);
} }
return post; return post;
@ -286,15 +274,14 @@ export async function parseUser(data) {
console.error("Attempted to parse user data but no data was provided"); console.error("Attempted to parse user data but no data was provided");
return null; return null;
} }
let client = get(Client.get()); let user = await get(client).getCacheUser(data.id);
let user = await client.getCacheUser(data.id);
if (user) return user; if (user) return user;
// cache miss! // cache miss!
user = new User(); user = new User();
user.id = data.id; user.id = data.id;
user.nickname = data.display_name; user.nickname = data.display_name.trim();
user.username = data.username; user.username = data.username;
user.avatar_url = data.avatar; user.avatar_url = data.avatar;
user.url = data.url; user.url = data.url;
@ -302,7 +289,7 @@ export async function parseUser(data) {
if (data.acct.includes('@')) if (data.acct.includes('@'))
user.host = data.acct.split('@')[1]; user.host = data.acct.split('@')[1];
else else
user.host = client.instance.host; user.host = get(client).instance.host;
user.emojis = []; user.emojis = [];
data.emojis.forEach(emoji_data => { data.emojis.forEach(emoji_data => {
@ -312,12 +299,11 @@ export async function parseUser(data) {
user.emojis.push(parseEmoji(emoji_data)); user.emojis.push(parseEmoji(emoji_data));
}); });
client.putCacheUser(user); get(client).putCacheUser(user);
return user; return user;
} }
export function parseReactions(data) { export function parseReactions(data) {
let client = get(Client.get());
let reactions = []; let reactions = [];
data.forEach(reaction_data => { data.forEach(reaction_data => {
let reaction = { let reaction = {
@ -338,27 +324,16 @@ export function parseEmoji(data) {
data.host, data.host,
data.url, data.url,
); );
get(Client.get()).putCacheEmoji(emoji); get(client).putCacheEmoji(emoji);
return emoji; return emoji;
} }
export async function getUser(user_id) { export async function getUser(user_id) {
let client = get(Client.get()); let url = `https://${get(client).instance.host}/api/v1/accounts/${user_id}`;
let url = `https://${client.instance.host}/api/v1/accounts/${user_id}`;
const data = await fetch(url, { const data = await fetch(url, {
method: 'GET', method: 'GET',
headers: { "Authorization": "Bearer " + client.app.token } headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json()); }).then(res => res.json());
const user = await parseUser(data); return 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,8 +1,10 @@
import { Instance, server_types } from './instance.js'; import { Instance, server_types } from './instance.js';
import * as api from './api.js'; import * as api from './api.js';
import { get, writable } from 'svelte/store'; 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"; 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) { 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`;
@ -76,30 +69,34 @@ export class Client {
console.error("Failed to obtain access token"); console.error("Failed to obtain access token");
return false; return false;
} }
this.app.token = token; return token;
client.set(this);
} }
async revokeToken() { async revokeToken() {
return await api.revokeToken(); return await api.revokeToken();
} }
async verifyCredentials() { async getClientUser() {
// already known
if (this.user) return this.user; if (this.user) return this.user;
// cannot provide- not logged in
if (!this.app || !this.app.token) { if (!this.app || !this.app.token) {
this.user = false;
return false; return false;
} }
// logged in- attempt to retrieve using token
const data = await api.verifyCredentials(); const data = await api.verifyCredentials();
if (!data) { if (!data) {
this.user = false;
return false; return false;
} }
await client.update(async c => { const user = await api.parseUser(data);
c.user = await api.parseUser(data); console.log(`Logged in as @${user.username}@${user.host}`);
console.log(`Logged in as @${c.user.username}@${c.user.host}`); return user;
}); }
return this.user;
async getNotifications(since_id, limit, types) {
return await api.getNotifications(since_id, limit, types);
} }
async getTimeline(last_post_id) { async getTimeline(last_post_id) {
@ -110,6 +107,10 @@ export class Client {
return await api.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) { async boostPost(post_id) {
return await api.boostPost(post_id); return await api.boostPost(post_id);
} }
@ -166,6 +167,10 @@ export class Client {
return emoji; return emoji;
} }
async getUser(user_id) {
return await api.getUser(user_id);
}
save() { save() {
if (typeof localStorage === typeof undefined) return; if (typeof localStorage === typeof undefined) return;
localStorage.setItem(save_name, JSON.stringify({ localStorage.setItem(save_name, JSON.stringify({
@ -174,6 +179,7 @@ export class Client {
host: this.instance.host, host: this.instance.host,
version: this.instance.version, version: this.instance.version,
}, },
last_read_notif_id: get(last_read_notif_id),
app: this.app, app: this.app,
})); }));
} }
@ -188,6 +194,7 @@ export class Client {
return false; return false;
} }
this.instance = new Instance(saved.instance.host, saved.instance.version); 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; this.app = saved.app;
client.set(this); client.set(this);
return true; return true;
@ -199,7 +206,8 @@ export class Client {
console.warn("Failed to log out correctly; ditching the old tokens anyways."); console.warn("Failed to log out correctly; ditching the old tokens anyways.");
} }
localStorage.removeItem(save_name); localStorage.removeItem(save_name);
client.set(false); logged_in.set(false);
client.set(new Client());
console.log("Logged out successfully."); console.log("Logged out successfully.");
} }
} }

View file

@ -1,4 +1,4 @@
import { Client } from './client/client.js'; import { client } from './client/client.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g; 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(':'); 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 = get(Client.get()).getEmoji(emoji_name + '@' + host); let emoji = get(client).getEmoji(emoji_name + '@' + host);
if (emoji) { if (emoji) {
return text.substring(0, index) + emoji.html + return text.substring(0, index) + emoji.html +
@ -46,7 +46,7 @@ export function parseText(text, host) {
export function parseOne(emoji_id) { export function parseOne(emoji_id) {
if (emoji_id == '❤') return '❤️'; // stupid heart unicode if (emoji_id == '❤') return '❤️'; // stupid heart unicode
if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id; 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; if (!cached_emoji) return emoji_id;
return cached_emoji.html; return cached_emoji.html;
} }

40
src/lib/notifications.js Normal file
View file

@ -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;
});
}

22
src/lib/stores/user.js Normal file
View file

@ -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);
}

View file

@ -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 { get, writable } from 'svelte/store';
import { parsePost } from '$lib/client/api.js'; import { parsePost } from '$lib/client/api.js';
export let posts = writable([]); export let timeline = writable([]);
let loading = false; let loading = false;
@ -10,11 +10,9 @@ export async function getTimeline(clean) {
if (loading) return; // no spamming!! if (loading) return; // no spamming!!
loading = true; loading = true;
let client = get(Client.get());
let timeline_data; let timeline_data;
if (clean || get(posts).length === 0) timeline_data = await client.getTimeline() if (clean || get(timeline).length === 0) timeline_data = await get(client).getTimeline()
else timeline_data = await client.getTimeline(get(posts)[get(posts).length - 1].id); else timeline_data = await get(client).getTimeline(get(timeline)[get(timeline).length - 1].id);
if (!timeline_data) { if (!timeline_data) {
console.error(`Failed to retrieve timeline.`); console.error(`Failed to retrieve timeline.`);
@ -22,7 +20,7 @@ export async function getTimeline(clean) {
return; return;
} }
if (clean) posts.set([]); if (clean) timeline.set([]);
for (let i in timeline_data) { for (let i in timeline_data) {
const post_data = timeline_data[i]; const post_data = timeline_data[i];
@ -38,7 +36,7 @@ export async function getTimeline(clean) {
} }
continue; continue;
} }
posts.update(current => [...current, post]); timeline.update(current => [...current, post]);
} }
loading = false; loading = false;
} }

View file

@ -1,6 +1,8 @@
<script> <script>
import { play_sound } from '../sound.js'; import { play_sound } from '../sound.js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { afterUpdate } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let active = false; export let active = false;
@ -12,20 +14,24 @@
export let href = false; export let href = false;
let classes = []; let classes = [];
if (active) classes = ["active"];
if (filled) classes = ["filled"];
if (disabled) classes = ["disabled"];
if (centered) classes.push("centered");
function click() { function click() {
if (disabled) return;
if (href) { if (href) {
location = href; location = href;
return; return;
} }
if (disabled) return;
play_sound(sound); play_sound(sound);
dispatch('click'); dispatch('click');
} }
afterUpdate(() => {
classes = [];
if (active) classes = ["active"];
if (filled) classes = ["filled"];
if (disabled) classes = ["disabled"];
if (centered) classes.push("centered");
});
</script> </script>
<button <button

View file

@ -1,27 +1,27 @@
<script> <script>
import Button from './Button.svelte'; import Button from './Button.svelte';
import Post from './post/Post.svelte'; import Post from './post/Post.svelte';
import Error from './Error.svelte'; import { getTimeline } from '$lib/timeline.js';
import { Client } from '$lib/client/client.js';
import { parsePost } from '$lib/client/api.js';
import { get } from 'svelte/store';
import { posts, getTimeline } from '$lib/timeline.js';
getTimeline(); export let posts = [];
document.addEventListener("scroll", event => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline();
}
});
</script> </script>
<header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<div id="feed" role="feed"> <div id="feed" role="feed">
{#if posts.length <= 0} {#if posts.length <= 0}
<div class="loading throb"> <div class="loading throb">
<span>getting the feed...</span> <span>getting the feed...</span>
</div> </div>
{/if} {/if}
{#each $posts as post} {#each posts as post}
<Post post_data={post} /> <Post post_data={post} />
{/each} {/each}
</div> </div>
@ -29,6 +29,7 @@
<style> <style>
header { header {
width: 100%; width: 100%;
height: 64px;
margin: 16px 0 8px 0; margin: 16px 0 8px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;

162
src/lib/ui/LoginForm.svelte Normal file
View file

@ -0,0 +1,162 @@
<script>
import { client } from '$lib/client/client.js';
import { get } from 'svelte/store';
import Logo from '$lib/../img/campfire-logo.svg';
let instance_url_error = false;
let logging_in = false;
function log_in(event) {
event.preventDefault();
instance_url_error = false;
logging_in = true;
const host = event.target.host.value;
if (!host || host === "") {
instance_url_error = "Please enter an instance domain.";
logging_in = false;
return;
}
console.log(client);
get(client).init(host).then(res => {
logging_in = false;
if (!res) return;
if (res.constructor === String) {
instance_url_error = res;
return;
};
let oauth_url = get(client).getOAuthUrl();
location = oauth_url;
});
}
</script>
<form on:submit={log_in} id="login-form">
<div class="app-logo">
<Logo />
</div>
<p>Welcome, fediverse user!</p>
<p>Please enter your instance domain to log in.</p>
<div class="input-wrapper">
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}>
{#if instance_url_error}
<p class="error">{instance_url_error}</p>
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<p class="form-footer">made with ❤ by <a href="https://bliss.town">bliss town</a>, 2024</p>
</form>
<style>
form#login-form {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.input-wrapper {
width: 360px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
input[type=text] {
width: 100%;
padding: 12px;
display: block;
border-radius: 8px;
border: 1px solid var(--accent);
background-color: var(--bg-800);
font-family: inherit;
font-weight: bold;
font-size: inherit;
color: var(--text);
transition: box-shadow .2s;
}
input[type=text]::placeholder {
opacity: .8;
}
input[type=text]:focus {
outline: none;
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
.error {
margin: 6px;
font-style: italic;
font-size: .9em;
color: red;
opacity: .7;
}
button#login {
margin: 8px auto;
padding: 12px 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
text-align: left;
border-radius: 8px;
border-width: 2px;
border-style: solid;
background-color: var(--bg-700);
color: var(--text);
border-color: transparent;
transition-property: border-color, background-color, color;
transition-timing-function: ease-out;
transition-duration: .1s;
cursor: pointer;
text-align: center;
justify-content: center;
}
button#login:hover {
background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%);
border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%);
}
button#login:active {
background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%);
border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%);
}
button#login.disabled {
opacity: .5;
cursor: initial;
}
.form-footer {
opacity: .7;
}
</style>

View file

@ -2,11 +2,16 @@
import Logo from '$lib/../img/campfire-logo.svg'; import Logo from '$lib/../img/campfire-logo.svg';
import Button from './Button.svelte'; import Button from './Button.svelte';
import Feed from './Feed.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 { play_sound } from '$lib/sound.js';
import { getTimeline } from '$lib/timeline.js'; import { getTimeline } from '$lib/timeline.js';
import { getNotifications } from '$lib/notifications.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store'; 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 TimelineIcon from '../../img/icons/timeline.svg';
import NotificationsIcon from '../../img/icons/notifications.svg'; import NotificationsIcon from '../../img/icons/notifications.svg';
@ -22,60 +27,69 @@
const VERSION = APP_VERSION; const VERSION = APP_VERSION;
let client = false; function handle_btn(name) {
Client.get().subscribe(c => { if (!get(logged_in)) return;
client = c; let route;
}); switch (name) {
case "timeline":
let notification_count = 0; route = "/";
if (notification_count > 99) notification_count = "99+";
function goTimeline() {
if (location.pathname === "/") {
getTimeline(true); getTimeline(true);
break;
case "notifications":
route = "/notifications";
getNotifications();
break;
case "explore":
case "lists":
case "favourites":
case "bookmarks":
case "hashtags":
default:
return;
}
if (!route) return;
window.scrollTo({ window.scrollTo({
top: 0, top: 0,
behavior: "smooth" behavior: "smooth"
}); });
return; goto(route);
}
goto("/");
} }
async function log_out() { async function log_out() {
if (!confirm("This will log you out. Are you sure?")) return; if (!confirm("This will log you out. Are you sure?")) return;
await get(Client.get()).logout(); await get(client).logout();
goto("/"); goto("/");
} }
</script> </script>
<div id="navigation"> <div id="navigation">
{#if client.instance && client.instance.icon_url && client.instance.banner_url}
<header class="instance-header" style="background-image: url({client.instance.banner_url})">
<img src={client.instance.icon_url} class="instance-icon" height="92px" aria-hidden="true">
</header>
{:else}
<header class="instance-header"> <header class="instance-header">
<div class="app-logo"> <div class="app-logo">
<Logo /> <Logo />
</div> </div>
</header> </header>
{/if}
{#if $logged_in}
<div id="nav-items"> <div id="nav-items">
<Button label="Timeline" on:click={() => goTimeline()} active={client.user}> <Button label="Timeline"
on:click={() => handle_btn("timeline")}
active={$page.url.pathname === "/"}>
<svelte:fragment slot="icon"> <svelte:fragment slot="icon">
<TimelineIcon/> <TimelineIcon/>
</svelte:fragment> </svelte:fragment>
Timeline Timeline
</Button> </Button>
<Button label="Notifications" disabled> <Button label="Notifications"
on:click={() => handle_btn("notifications")}
active={$page.url.pathname === "/notifications"}>
<svelte:fragment slot="icon"> <svelte:fragment slot="icon">
<NotificationsIcon/> <NotificationsIcon/>
</svelte:fragment> </svelte:fragment>
Notifications Notifications
{#if notification_count} {#if $unread_notif_count}
<span class="notification-count">{notification_count}</span> <span class="notification-count">
{$unread_notif_count <= 99 ? $unread_notif_count : "99+"}
</span>
{/if} {/if}
</Button> </Button>
<Button label="Explore" disabled> <Button label="Explore" disabled>
@ -117,7 +131,6 @@
</Button> </Button>
</div> </div>
{#if (client.user)}
<div id="account-items"> <div id="account-items">
<div class="flex-row"> <div class="flex-row">
<Button centered label="Profile information" disabled> <Button centered label="Profile information" disabled>
@ -138,11 +151,11 @@
</div> </div>
<div id="account-button"> <div id="account-button">
<img src={client.user.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => play_sound()}> <img src={$user.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => play_sound()}>
<div class="account-name" aria-hidden="true"> <div class="account-name" aria-hidden="true">
<span class="nickname" title={client.user.nickname}>{client.user.nickname}</span> <a href={$user.url} class="nickname" title={$user.nickname}>{$user.nickname}</a>
<span class="username" title={`@${client.user.username}@${client.user.host}`}> <span class="username" title={`@${$user.username}@${$user.host}`}>
{`@${client.user.username}@${client.user.host}`} {`@${$user.username}@${$user.host}`}
</span> </span>
</div> </div>
</div> </div>
@ -212,6 +225,7 @@
transform: translate(22px, -16px); transform: translate(22px, -16px);
min-width: 12px; min-width: 12px;
height: 28px; height: 28px;
margin-left: auto;
padding: 0 8px; padding: 0 8px;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -323,6 +337,7 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
font-size: .8em; font-size: .8em;
color: inherit;
} }
.username { .username {

View file

@ -0,0 +1,227 @@
<script>
import * as api from '$lib/client/api.js';
import ReplyIcon from '$lib/../img/icons/reply.svg';
import RepostIcon from '$lib/../img/icons/repost.svg';
import FavouriteIcon from '$lib/../img/icons/like.svg';
import ReactIcon from '$lib/../img/icons/react.svg';
import QuoteIcon from '$lib/../img/icons/quote.svg';
import ReactionBar from '$lib/ui/post/ReactionBar.svelte';
import ActionBar from '$lib/ui/post/ActionBar.svelte';
let mention = (accounts) => {
let res = `<a href=${account.url}>${account.rich_name}</a>`;
if (accounts.length > 1) res += ` and <strong>${accounts.length - 1}</strong> others`;
return res;
};
export let data;
let activity_text = function (type) {
switch (type) {
case "mention":
return `%1 mentioned you.`;
case "reblog":
return `%1 boosted your post.`;
case "follow":
return `%1 followed you.`;
case "follow_request":
return `%1 requested to follow you.`;
case "favourite":
return `%1 favourited your post.`;
case "poll":
return `%1's poll as ended.`;
case "update":
return `%1 updated their post.`;
default:
return `%1 poked you!`;
}
}(data.type);
let account = data.accounts[0];
$: accounts_short = data.accounts.slice(0, 3).reverse();
let aria_label = function () {
if (accounts.length == 1)
return activity_text.replace("%1", account.username) + ' ' + new Date(data.created_at);
else
return activity_text.replace("%1", `${account.username} and ${accounts.length - 1} others`) + ' ' + new Date(data.created_at);
}
</script>
<a class="notification" href={data.status ? `/post/${data.status.id}` : null} aria-label={aria_label}>
<header aria-hidden>
<span class="notif-icon">
{#if data.type === "favourite"}
<FavouriteIcon />
{:else if data.type === "reblog"}
<RepostIcon />
{:else if data.type === "react"}
<ReactIcon />
{:else if data.type === "mention"}
<ReplyIcon />
{:else}
<ReactIcon />
{/if}
</span>
<span class="notif-avatars">
{#if data.accounts.length == 1}
<a href={data.accounts[0].url} class="notif-avatar">
<img src={data.accounts[0].avatar_url} alt="" width="28" height="28" />
</a>
{:else}
{#each accounts_short as account}
<img src={account.avatar_url} alt="" width="28" height="28" />
{/each}
{/if}
</span>
<span class="notif-activity">{@html activity_text.replace("%1", mention(data.accounts))}</span>
</header>
{#if data.status}
<div class="notif-content">
{@html data.status.html}
</div>
{#if data.type === "mention"}
{#if data.status.reactions}
<ReactionBar post={data.status} />
{/if}
<ActionBar post={data.status} />
{/if}
{/if}
</a>
<style>
.notification {
display: block;
margin-bottom: 8px;
padding: 16px;
border-radius: 8px;
background: var(--bg-800);
text-decoration: inherit;
color: inherit;
transition: background-color .1s;
}
.notification:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
}
header {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
}
header .notif-icon {
width: 28px;
height: 28px;
display: inline-flex;
}
header .notif-avatars {
display: inline-flex;
flex-direction: row-reverse;
}
header .notif-avatar {
line-height: 0;
}
header .notif-avatars img {
border-radius: 4px;
}
header .notif-avatars img:not(:first-child) {
box-shadow: 4px 0 8px -2px rgba(0,0,0,.33);
}
header .notif-avatars img:not(:last-child) {
margin-left: -8px;
}
header .notif-activity {
width: 100%;
}
header :global(a) {
font-weight: bold;
color: var(--text);
}
header :global(.emoji) {
margin: -.2em 0;
}
.notif-content {
margin: 16px 0 4px 0;
font-size: 14px;
line-height: 1.45em;
}
.notif-content :global(p) {
margin: 0;
}
.notif-content :global(.emoji) {
position: relative;
top: 6px;
margin-top: -10px;
height: 24px!important;
}
.notif-content :global(blockquote) {
margin: .4em 0;
padding: .1em 0 .1em 1em;
border-left: 4px solid #8888;
}
.notif-content :global(blockquote span) {
opacity: .5;
}
.notif-content :global(code) {
font-size: 1.2em;
}
.notif-content :global(pre:has(code)) {
margin: 8px 0;
padding: 8px;
display: block;
overflow-x: scroll;
border-radius: 8px;
background-color: #080808;
color: var(--accent);
}
.notif-content :global(pre code) {
margin: 0;
}
.notif-content :global(a) {
color: var(--accent);
}
.notif-content :global(a.mention) {
color: inherit;
font-weight: 600;
padding: 3px 6px;
background: var(--bg-700);
border-radius: 6px;
text-decoration: none;
}
.notif-content :global(a.mention:hover) {
text-decoration: underline;
}
.notif-content :global(a.hashtag) {
background-color: transparent;
padding: 0;
font-style: italic;
}
.notif-content :global(.mention-avatar) {
position: relative;
top: 4px;
height: 20px;
margin-right: 4px;
border-radius: 4px;
}
</style>

View file

@ -1,5 +1,5 @@
<script> <script>
import { Client } from '../../client/client.js'; import { client } from '../../client/client.js';
import * as api from '../../client/api.js'; import * as api from '../../client/api.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -16,12 +16,11 @@
export let post; export let post;
async function toggleBoost() { async function toggleBoost() {
let client = get(Client.get());
let data; let data;
if (post.boosted) if (post.boosted)
data = await client.unboostPost(post.id); data = await get(client).unboostPost(post.id);
else else
data = await client.boostPost(post.id); data = await get(client).boostPost(post.id);
if (!data) { if (!data) {
console.error(`Failed to boost post ${post.id}`); console.error(`Failed to boost post ${post.id}`);
return; return;
@ -31,12 +30,11 @@
} }
async function toggleFavourite() { async function toggleFavourite() {
let client = get(Client.get());
let data; let data;
if (post.favourited) if (post.favourited)
data = await client.unfavouritePost(post.id); data = await get(client).unfavouritePost(post.id);
else else
data = await client.favouritePost(post.id); data = await get(client).favouritePost(post.id);
if (!data) { if (!data) {
console.error(`Failed to favourite post ${post.id}`); console.error(`Failed to favourite post ${post.id}`);
return; return;
@ -48,13 +46,12 @@
async function toggleReaction(reaction) { async function toggleReaction(reaction) {
if (reaction.name.includes('@')) return; if (reaction.name.includes('@')) return;
let client = get(Client.get());
let data; let data;
if (reaction.me) if (reaction.me)
data = await client.unreactPost(post.id, reaction.name); data = await get(client).unreactPost(post.id, reaction.name);
else else
data = await client.reactPost(post.id, reaction.name); data = await get(client).reactPost(post.id, reaction.name);
if (!data) { if (!data) {
console.error(`Failed to favourite post ${post.id}`); console.error(`Failed to favourite post ${post.id}`);
return; return;

View file

@ -29,7 +29,7 @@
<div class="post-media-container" data-count={post.files.length}> <div class="post-media-container" data-count={post.files.length}>
{#each post.files as file} {#each post.files as file}
<div class="post-media {file.type}" on:click|stopPropagation on:mouseup|stopPropagation> <div class="post-media {file.type}" on:click|stopPropagation on:mouseup|stopPropagation>
{#if file.type === "image"} {#if ["image", "gifv", "gif"].includes(file.type)}
<a href={file.url} target="_blank"> <a href={file.url} target="_blank">
<img src={file.url} alt={file.description} title={file.description} height="200" loading="lazy" decoding="async"> <img src={file.url} alt={file.description} title={file.description} height="200" loading="lazy" decoding="async">
</a> </a>
@ -84,7 +84,8 @@
} }
.post-text { .post-text {
line-height: 1.2em; font-size: .9em;
line-height: 1.45em;
word-wrap: break-word; word-wrap: break-word;
} }

View file

@ -10,7 +10,10 @@
<div class="post-context"> <div class="post-context">
<span class="post-context-icon">🔁</span> <span class="post-context-icon">🔁</span>
<span class="post-context-action"> <span class="post-context-action">
<a href={post.user.url} target="_blank">{@html parseEmojis(post.user.rich_name)}</a> boosted this post. <a href={post.user.url} target="_blank"><span class="name">
{@html parseEmojis(post.user.rich_name)}</span>
</a>
boosted this post.
</span> </span>
<span class="post-context-time"> <span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time> <time title="{time_string}">{short_time(post.created_at)}</time>
@ -48,6 +51,12 @@
text-decoration: underline; text-decoration: underline;
} }
.post-context .name :global(.emoji) {
position: relative;
top: .2em;
height: 1.2em;
}
.post-context-time { .post-context-time {
margin-left: auto; margin-left: auto;
} }

View file

@ -32,10 +32,15 @@
let mouse_pos = { top: 0, left: 0 }; let mouse_pos = { top: 0, left: 0 };
function gotoPost() { function gotoPost(event) {
if (focused) return; if (focused) return;
if (event && event.key && event.key !== "Enter") return; if (event) {
console.log(`/post/${post.id}`); if (event.type == "mouseup" && (
event.button !== 0 ||
event.shiftKey ||
event.ctrlKey)) return;
if (event.key && event.key !== "Enter") return;
}
goto(`/post/${post.id}`); goto(`/post/${post.id}`);
} }
@ -51,7 +56,9 @@
<div class="post-container"> <div class="post-container">
{#if post.reply} {#if post.reply}
<ReplyContext post={post.reply} /> {#await post.reply then reply}
<ReplyContext post={reply} />
{/await}
{/if} {/if}
{#if is_boost && !post_context.text} {#if is_boost && !post_context.text}
<BoostContext post={post_context} /> <BoostContext post={post_context} />
@ -60,13 +67,15 @@
class={"post" + (focused ? " focused" : "")} class={"post" + (focused ? " focused" : "")}
aria-label={aria_label} aria-label={aria_label}
bind:this={el} bind:this={el}
on:mousedown={e => {mouse_pos.left = e.pageX; mouse_pos.top = e.pageY; console.log(mouse_pos)}} on:mousedown={e => {mouse_pos.left = e.pageX; mouse_pos.top = e.pageY}}
on:mouseup={e => {if (e.pageX == mouse_pos.left && e.pageY == mouse_pos.top) gotoPost()}} on:mouseup={e => {if (e.pageX == mouse_pos.left && e.pageY == mouse_pos.top) gotoPost(e)}}
on:keydown={gotoPost}> on:keydown={gotoPost}>
<PostHeader post={post} /> <PostHeader post={post} />
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
{#if post.reactions}
<ReactionBar post={post} /> <ReactionBar post={post} />
{/if}
<ActionBar post={post} /> <ActionBar post={post} />
</footer> </footer>
</article> </article>

View file

@ -8,16 +8,16 @@
let time_string = post.created_at.toLocaleString(); let time_string = post.created_at.toLocaleString();
</script> </script>
<div class={"post-header-container" + (reply ? " reply" : "")} on:mouseup|stopPropagation> <div class={"post-header-container" + (reply ? " reply" : "")}>
<a href={post.user.url} target="_blank" class="post-avatar-container"> <a href={post.user.url} target="_blank" class="post-avatar-container" on:mouseup|stopPropagation>
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async"> <img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a> </a>
<header class="post-header"> <header class="post-header">
<div class="post-user-info"> <div class="post-user-info" on:mouseup|stopPropagation>
<a href={post.user.url} target="_blank" class="name">{@html post.user.rich_name}</a> <a href={post.user.url} target="_blank" class="name">{@html post.user.rich_name}</a>
<span class="username">{post.user.mention}</span> <span class="username">{post.user.mention}</span>
</div> </div>
<div class="post-info"> <div class="post-info" on:mouseup|stopPropagation>
<a href={post.url} target="_blank" class="created-at"> <a href={post.url} target="_blank" class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time> <time title={time_string}>{short_time(post.created_at)}</time>
{#if post.visibility !== "public"} {#if post.visibility !== "public"}
@ -82,8 +82,8 @@
.post-user-info .name :global(.emoji) { .post-user-info .name :global(.emoji) {
position: relative; position: relative;
top: 4px; top: .2em;
height: 20px; height: 1.2em;
} }
.post-user-info .username { .post-user-info .username {

View file

@ -34,10 +34,11 @@
<style> <style>
.post-reactions { .post-reactions {
width: fit-content; width: fit-content;
height: 32px; min-height: 32px;
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
gap: 2px; gap: 2px;
} }
</style> </style>

View file

@ -1,8 +1,6 @@
<script> <script>
import { parseText as parseEmojis, parseOne as parseEmoji } from '../../emoji.js'; import { parseText as parseEmojis, parseOne as parseEmoji } from '../../emoji.js';
import { shorthand as short_time } from '../../time.js'; import { shorthand as short_time } from '../../time.js';
import { get } from 'svelte/store';
import { Client } from '../../client/client.js';
import * as api from '../../client/api.js'; import * as api from '../../client/api.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -18,22 +16,30 @@
let mouse_pos = { top: 0, left: 0 }; let mouse_pos = { top: 0, left: 0 };
function gotoPost() { function gotoPost(event) {
if (event && event.key && event.key !== "Enter") return; if (event) {
console.log(`/post/${post.id}`);
if (event.type == "mouseup" && (
event.button !== 0 ||
event.shiftKey ||
event.ctrlKey)) return;
if (event.key && event.key !== "Enter") return;
}
goto(`/post/${post.id}`); goto(`/post/${post.id}`);
} }
</script> </script>
{#if post.reply} {#if post.reply}
<svelte:self post={post.reply} /> {#await post.reply then reply}
<svelte:self post={reply} />
{/await}
{/if} {/if}
<article <article
class="post-reply" class="post-reply"
aria-label={aria_label} aria-label={aria_label}
on:mousedown={e => {mouse_pos.left = e.pageX; mouse_pos.top = e.pageY; console.log(mouse_pos)}} on:mousedown={e => {mouse_pos.left = e.pageX; mouse_pos.top = e.pageY}}
on:mouseup={e => {if (e.pageX == mouse_pos.left && e.pageY == mouse_pos.top) gotoPost()}} on:mouseup={e => {if (e.pageX == mouse_pos.left && e.pageY == mouse_pos.top) gotoPost(e)}}
on:keydown={gotoPost}> on:keydown={gotoPost}>
<div class="line"></div> <div class="line"></div>
@ -43,7 +49,9 @@
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
{#if post.reactions}
<ReactionBar post={post} /> <ReactionBar post={post} />
{/if}
<ActionBar post={post} /> <ActionBar post={post} />
</footer> </footer>
</div> </div>

View file

@ -1,4 +1,4 @@
import { Client } from '../client/client.js'; import { client } from '../client/client.js';
import { parseText as parseEmojis } from '../emoji.js'; import { parseText as parseEmojis } from '../emoji.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -17,7 +17,7 @@ export default class User {
get mention() { get mention() {
let res = "@" + this.username; let res = "@" + this.username;
if (this.host != get(Client.get()).instance.host) if (this.host != get(client).instance.host)
res += "@" + this.host; res += "@" + this.host;
return res; return res;
} }

View file

@ -2,10 +2,38 @@
import '$lib/app.css'; import '$lib/app.css';
import Navigation from '$lib/ui/Navigation.svelte'; import Navigation from '$lib/ui/Navigation.svelte';
import Widgets from '$lib/ui/Widgets.svelte'; import Widgets from '$lib/ui/Widgets.svelte';
import { Client } from '$lib/client/client.js'; import { client, Client } from '$lib/client/client.js';
import { user, getUser } from '$lib/stores/user.js';
import { get } from 'svelte/store'; 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';
let client = get(Client.get()); let ready = new Promise(resolve => {
if (get(client)) {
if (get(user)) logged_in.set(true);
return resolve();
}
let new_client = new Client();
new_client.load();
client.set(new_client);
return getUser().then(new_user => {
if (!new_user) return resolve();
logged_in.set(true);
user.set(new_user);
// spin up async task to fetch notifications
get(client).getNotifications(
get(last_read_notif_id)
).then(notif_data => {
if (!notif_data) return;
unread_notif_count.set(notif_data.length);
});
return resolve();
});
});
</script> </script>
<div id="app"> <div id="app">
@ -15,7 +43,7 @@
</header> </header>
<main> <main>
{#await client.verifyCredentials()} {#await ready}
<div class="loading throb"> <div class="loading throb">
<span>just a moment...</span> <span>just a moment...</span>
</div> </div>
@ -29,3 +57,15 @@
</div> </div>
</div> </div>
<style>
.loading {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -1,15 +1,2 @@
import Feed from '$lib/ui/Feed.svelte';
import { Client } from '$lib/client/client.js';
import Button from '$lib/ui/Button.svelte';
import { get } from 'svelte/store';
export const prerender = true; export const prerender = true;
export const ssr = false; export const ssr = false;
export async function load() {
let client = get(Client.get());
await client.verifyCredentials();
return {
client: client
};
}

View file

@ -1,92 +1,32 @@
<script> <script>
import Logo from '$lib/../img/campfire-logo.svg'; import { page } from '$app/stores';
import { get } from 'svelte/store';
import { logged_in } from '$lib/stores/user.js';
import { timeline, getTimeline } from '$lib/timeline.js';
import LoginForm from '$lib/ui/LoginForm.svelte';
import Feed from '$lib/ui/Feed.svelte'; import Feed from '$lib/ui/Feed.svelte';
import { Client } from '$lib/client/client.js';
import User from '$lib/user/user.js'; import User from '$lib/user/user.js';
import Button from '$lib/ui/Button.svelte'; import Button from '$lib/ui/Button.svelte';
import { get } from 'svelte/store';
export let data; logged_in.subscribe(logged_in => {
if (logged_in) getTimeline();
let client = data.client;
let logged_in = client.user && client.user.constructor === User;
let instance_url_error = false;
let logging_in = false;
function log_in(event) {
event.preventDefault();
instance_url_error = false;
logging_in = true;
const host = event.target.host.value;
if (!host || host === "") {
instance_url_error = "Please enter an instance domain.";
logging_in = false;
return;
}
client.init(host).then(res => {
logging_in = false;
if (!res) return;
if (res.constructor === String) {
instance_url_error = res;
return;
};
let oauth_url = client.getOAuthUrl();
location = oauth_url;
}); });
document.addEventListener("scroll", event => {
if (get(logged_in) && get(page).url.pathname !== "/") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline();
} }
});
</script> </script>
{#if logged_in} {#if $logged_in}
<header> <Feed posts={$timeline} />
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<Feed />
{:else} {:else}
<form on:submit={log_in} id="login-form"> <LoginForm />
<div class="app-logo">
<Logo />
</div>
<p>Welcome, fediverse user!</p>
<p>Please enter your instance domain to log in.</p>
<div class="input-wrapper">
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}>
{#if instance_url_error}
<p class="error">{instance_url_error}</p>
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<p class="form-footer">made with ❤ by <a href="https://bliss.town">bliss town</a>, 2024</p>
</form>
{/if} {/if}
<style> <style>
form#login-form {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
a { a {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
@ -96,106 +36,6 @@
text-decoration: underline; text-decoration: underline;
} }
.input-wrapper {
width: 360px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
input[type=text] {
width: 100%;
padding: 12px;
display: block;
border-radius: 8px;
border: 1px solid var(--accent);
background-color: var(--bg-800);
font-family: inherit;
font-weight: bold;
font-size: inherit;
color: var(--text);
transition: box-shadow .2s;
}
input[type=text]::placeholder {
opacity: .8;
}
input[type=text]:focus {
outline: none;
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
.error {
margin: 6px;
font-style: italic;
font-size: .9em;
color: red;
opacity: .7;
}
button#login {
margin: 8px auto;
padding: 12px 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
text-align: left;
border-radius: 8px;
border-width: 2px;
border-style: solid;
background-color: var(--bg-700);
color: var(--text);
border-color: transparent;
transition-property: border-color, background-color, color;
transition-timing-function: ease-out;
transition-duration: .1s;
cursor: pointer;
text-align: center;
justify-content: center;
}
button#login:hover {
background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%);
border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%);
}
button#login:active {
background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%);
border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%);
}
button#login.disabled {
opacity: .5;
cursor: initial;
}
.form-footer {
opacity: .7;
}
.loading {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
header { header {
width: 100%; width: 100%;
margin: 16px 0 8px 0; margin: 16px 0 8px 0;

View file

@ -1,20 +1,5 @@
import { Client } from '$lib/client/client.js'; export async function load({ url }) {
import { goto } from '$app/navigation'; return {
import { error } from '@sveltejs/kit'; code: url.searchParams.get("code") || false
import { get } from 'svelte/store'; };
export const ssr = false;
export async function load({ params, url }) {
const client = get(Client.get());
let auth_code = url.searchParams.get("code");
if (auth_code) {
client.getToken(auth_code).then(() => {
client.save();
goto("/");
});
}
error(400, {
message: "Bad request"
});
} }

View file

@ -0,0 +1,45 @@
<script>
import { client } from '$lib/client/client.js';
import { goto } from '$app/navigation';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { last_read_notif_id } from '$lib/notifications.js';
import { logged_in, user, getUser } from '$lib/stores/user.js';
export let data;
let auth_code = data.code;
if (!auth_code) {
error(400, { message: "Bad request" });
} else {
get(client).getToken(auth_code).then(token => {
if (!token) {
error(400, { message: "Invalid auth code provided" });
return;
}
client.update(c => {
c.app.token = token;
c.save();
return c;
});
getUser().then(new_user => {
if (!new_user) return;
logged_in.set(true);
user.set(new_user);
return get(client).getNotifications(
get(last_read_notif_id)
).then(notif_data => {
if (notif_data.constructor === Array && notif_data.length > 0)
last_read_notif_id.set(notif_data[0].id);
get(client).save();
goto("/");
});
});
});
}
</script>

View file

@ -0,0 +1,59 @@
<script>
import { notifications, getNotifications } from '$lib/notifications.js';
import { logged_in } from '$lib/stores/user.js';
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import Notification from '$lib/ui/Notification.svelte';
if (!get(logged_in)) goto("/");
getNotifications();
/*
document.addEventListener("scroll", event => {
if (get(logged_in) && get(page).url.pathname !== "/") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getNotifications();
}
});
*/
</script>
<header>
<h1>Notifications</h1>
</header>
<div class="notifications">
{#if $notifications.length === 0}
<div class="loading throb">
<span>fetching notifications...</span>
</div>
{:else}
{#each $notifications as notif}
<Notification data={notif} />
{/each}
{/if}
</div>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
h1 {
font-size: 1.5em;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -1,39 +1,5 @@
import Post from '$lib/ui/post/Post.svelte';
import { Client } from '$lib/client/client.js';
import { parsePost } from '$lib/client/api.js';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
export const ssr = false;
export async function load({ params }) { export async function load({ params }) {
let client = get(Client.get());
if (!client.instance || !client.user) {
goto("/");
}
const post_id = params.id;
const post_data = await client.getPost(post_id);
if (!post_data) {
console.error(`Failed to retrieve post ${post_id}.`);
return null;
}
const post = await parsePost(post_data, 10, true);
let posts = [post];
for (let i in post.replies) {
const reply = post.replies[i];
// if (i > 1 && reply.reply_id === post.replies[i - 1].id) {
// let reply_head = posts.pop();
// reply.reply = reply_head;
// }
posts.push(reply);
// console.log(reply);
}
return { return {
posts: posts post_id: params.id
}; };
} }

View file

@ -1,52 +1,117 @@
<script> <script>
import '$lib/app.css'; import { client } from '$lib/client/client.js';
import * as api from '$lib/client/api.js';
import { get } from 'svelte/store';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths'
import Post from '$lib/ui/post/Post.svelte'; import Post from '$lib/ui/post/Post.svelte';
import Button from '$lib/ui/Button.svelte'; import Button from '$lib/ui/Button.svelte';
export let data; export let data;
$: main_post = data.posts[0]; let error = false;
$: replies = data.posts.slice(1);
if (!get(client).instance || !get(client).user) {
goto("/");
}
let previous_page = base;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page
})
$: post = (async resolve => {
const post_data = await get(client).getPost(data.post_id, 0, false);
if (!post_data) {
error = `Failed to retrieve post <code>${data.post_id}</code>.`;
console.error(`Failed to retrieve post ${data.post_id}.`);
return;
}
let post = await api.parsePost(post_data, 0, false);
const post_context = await get(client).getPostContext(data.post_id);
if (!post_context || !post_context.ancestors || !post_context.descendants)
return post;
// handle ancestors (above post)
let thread_top = post;
while (post_context.ancestors.length > 0) {
thread_top.reply = await api.parsePost(post_context.ancestors.pop(), 0, false);
thread_top = thread_top.reply;
}
// handle descendants (below post)
post.replies = [];
for (let i in post_context.descendants) {
post.replies.push(
api.parsePost(post_context.descendants[i], 0, false)
);
}
return post;
})();
</script> </script>
{#if !error}
<header> <header>
<h1>Home</h1> {#await post then post}
<nav> <nav>
<Button centered active>Home</Button> <Button centered on:click={() => {goto(previous_page)}}>Back</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav> </nav>
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="40" height="40" class="header-avatar" loading="lazy" decoding="async">
<h1>
Post by {@html post.user.rich_name}
</h1>
{/await}
</header> </header>
<div id="feed" role="feed"> <div id="feed" role="feed">
{#if data.posts.length <= 0} {#await post}
<div class="throb"> <div class="loading throb">
<span>just a moment...</span> <span>loading post...</span>
</div>
{:then post}
<Post post_data={post} focused />
<br>
{#each post.replies as reply}
{#await reply then reply}
<Post post_data={reply} />
{/await}
{/each}
{/await}
</div> </div>
{:else} {:else}
{#key data} <p>{@html error}</p>
<Post post_data={main_post} focused />
<br>
{#each replies as post}
<Post post_data={post} />
{/each}
{/key}
{/if} {/if}
</div>
<style> <style>
header { header {
width: 100%; width: 100%;
height: 64px;
margin: 16px 0 8px 0; margin: 16px 0 8px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
header .header-avatar {
width: 40px;
height: 40px;
margin: auto 0;
border-radius: 4px;
}
header h1 { header h1 {
margin: auto auto auto 8px;
font-size: 1.5em; font-size: 1.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
} }
header nav { header nav {
margin-left: auto; margin-right: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
@ -56,7 +121,7 @@
margin-bottom: 20vh; margin-bottom: 20vh;
} }
.throb { .loading {
width: 100%; width: 100%;
height: 80vh; height: 80vh;
display: flex; display: flex;