Compare commits

...

3 commits

20 changed files with 424 additions and 426 deletions

View file

@ -1,4 +1,4 @@
import { Client } from '../client/client.js'; import { client } from '../client/client.js';
import { capabilities } from '../client/instance.js'; import { capabilities } from '../client/instance.js';
import Post from '../post.js'; import Post from '../post.js';
import User from '../user/user.js'; import User from '../user/user.js';
@ -31,25 +31,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 +63,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 +82,32 @@ 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()); }).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(client).instance || !get(client).app) 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 +115,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 +126,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 +137,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 +148,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 +159,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 +175,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 +186,23 @@ 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.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 +211,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 +241,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,8 +252,7 @@ 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!
@ -302,7 +267,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 +277,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,16 +302,15 @@ 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); const user = await parseUser(data);

View file

@ -2,7 +2,7 @@ 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';
let client = writable(false); export const client = writable(false);
const save_name = "campfire"; const save_name = "campfire";
@ -22,15 +22,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 +67,30 @@ 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 getUser() {
// 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 getTimeline(last_post_id) { async getTimeline(last_post_id) {
@ -110,6 +101,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);
} }
@ -199,7 +194,7 @@ 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); 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;
} }

View file

@ -1,4 +1,4 @@
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';
@ -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(posts).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(posts)[get(posts).length - 1].id);
if (!timeline_data) { if (!timeline_data) {
console.error(`Failed to retrieve timeline.`); console.error(`Failed to retrieve timeline.`);

View file

@ -1,10 +1,6 @@
<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 { 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'; import { posts, getTimeline } from '$lib/timeline.js';
getTimeline(); getTimeline();
@ -15,6 +11,15 @@
}); });
</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">

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,7 +2,7 @@
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 { 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 { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -22,11 +22,6 @@
const VERSION = APP_VERSION; const VERSION = APP_VERSION;
let client = false;
Client.get().subscribe(c => {
client = c;
});
let notification_count = 0; let notification_count = 0;
if (notification_count > 99) notification_count = "99+"; if (notification_count > 99) notification_count = "99+";
@ -44,26 +39,20 @@
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}
<div id="nav-items"> <div id="nav-items">
<Button label="Timeline" on:click={() => goTimeline()} active={client.user}> <Button label="Timeline" on:click={() => goTimeline()} active={!!$client.user}>
<svelte:fragment slot="icon"> <svelte:fragment slot="icon">
<TimelineIcon/> <TimelineIcon/>
</svelte:fragment> </svelte:fragment>
@ -117,7 +106,7 @@
</Button> </Button>
</div> </div>
{#if (client.user)} {#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 +127,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={$client.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> <span class="nickname" title={$client.user.nickname}>{$client.user.nickname}</span>
<span class="username" title={`@${client.user.username}@${client.user.host}`}> <span class="username" title={`@${$client.user.username}@${$client.user.host}`}>
{`@${client.user.username}@${client.user.host}`} {`@${$client.user.username}@${$client.user.host}`}
</span> </span>
</div> </div>
</div> </div>

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

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

@ -50,7 +50,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} />
@ -65,7 +67,9 @@
<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

@ -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';
@ -20,19 +18,20 @@
function gotoPost() { function gotoPost() {
if (event && event.key && event.key !== "Enter") return; if (event && event.key && event.key !== "Enter") return;
console.log(`/post/${post.id}`);
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()}}
on:keydown={gotoPost}> on:keydown={gotoPost}>
<div class="line"></div> <div class="line"></div>
@ -43,7 +42,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,26 @@
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 { get } from 'svelte/store'; import { get } from 'svelte/store';
let client = get(Client.get()); let ready = new Promise(resolve => {
if (get(client)) {
return resolve();
}
let new_client = new Client();
new_client.load();
return new_client.getUser().then(user => {
if (!user) {
client.set(new_client);
return resolve();
}
new_client.user = user;
client.set(new_client);
return resolve();
});
});
</script> </script>
<div id="app"> <div id="app">
@ -15,7 +31,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 +45,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,19 @@
<script> <script>
import Logo from '$lib/../img/campfire-logo.svg'; import { client } from '$lib/client/client.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;
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;
});
}
</script> </script>
{#if logged_in} {#if $client.user}
<header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<Feed /> <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 +23,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,35 @@
<script>
import { client } from '$lib/client/client.js';
import { goto } from '$app/navigation';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
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;
});
get(client).getUser().then(user => {
if (user) client.update(client => {
client.user = user
return client;
});
goto("/");
});
});
}
</script>

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,37 +1,80 @@
<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 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("/");
}
$: 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)
);
}
console.log(post);
return post;
})();
</script> </script>
{#if !error}
<header> <header>
<h1>Home</h1> {#await post then post}
<h1>Post by {@html post.user.rich_name}</h1>
{/await}
<nav> <nav>
<Button centered active>Home</Button> <Button centered>Back</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav> </nav>
</header> </header>
<div id="feed" role="feed"> <div id="feed" role="feed">
{#if data.posts.length <= 0} {#await post}
<div class="throb"> <div class="throb">
<span>just a moment...</span> <span>loading post...</span>
</div> </div>
{:else} {:then post}
{#key data} <Post post_data={post} focused />
<Post post_data={main_post} focused />
<br> <br>
{#each replies as post} {#each post.replies as reply}
<Post post_data={post} /> {#await reply then reply}
<Post post_data={reply} />
{/await}
{/each} {/each}
{/key} {/await}
{/if}
</div> </div>
{:else}
<p>{@html error}</p>
{/if}
<style> <style>
header { header {
@ -42,7 +85,11 @@
} }
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 {