version 0.3.0 #1

Merged
ari merged 22 commits from dev into main 2024-07-02 19:39:03 +00:00
17 changed files with 442 additions and 52 deletions
Showing only changes of commit 998e8f2517 - Show all commits

View file

@ -219,6 +219,7 @@ export async function parsePost(data, ancestor_count) {
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 ((data.in_reply_to_id || data.reply) && if ((data.in_reply_to_id || data.reply) &&
@ -278,7 +279,7 @@ export async function parseUser(data) {
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;

View file

@ -1,6 +1,8 @@
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 } from '$lib/stores/user.js';
export const client = writable(false); export const client = writable(false);
@ -177,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,
})); }));
} }
@ -191,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;

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 = await api.parsePost(notif.status, 0, false);
notifications.update(notifications => [...notifications, notif]);
}
last_read_notif_id.set(data[0].id);
unread_notif_count.set(0);
get(client).save();
loading = false;
});
}

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

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export let user = writable(0);
export let logged_in = writable(false);

View file

@ -2,7 +2,7 @@ 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;
@ -11,8 +11,8 @@ export async function getTimeline(clean) {
loading = true; loading = true;
let timeline_data; let timeline_data;
if (clean || get(posts).length === 0) timeline_data = await get(client).getTimeline() if (clean || get(timeline).length === 0) timeline_data = await get(client).getTimeline()
else timeline_data = await get(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.`);
@ -20,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];
@ -36,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,10 +14,6 @@
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 (disabled) return;
@ -26,6 +24,14 @@
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,14 +1,9 @@
<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 { posts, getTimeline } from '$lib/timeline.js'; import { 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> <header>
@ -26,7 +21,7 @@
<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>
@ -34,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;

View file

@ -5,9 +5,12 @@
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 { 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 { onMount } from 'svelte'; 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';
@ -21,27 +24,26 @@
import SettingsIcon from '../../img/icons/settings.svg'; import SettingsIcon from '../../img/icons/settings.svg';
import LogoutIcon from '../../img/icons/logout.svg'; import LogoutIcon from '../../img/icons/logout.svg';
export let path;
const VERSION = APP_VERSION; const VERSION = APP_VERSION;
let notification_count = 0;
if (notification_count > 99) notification_count = "99+";
function handle_btn(name) { function handle_btn(name) {
if (!get(logged_in)) return;
let route; let route;
switch (name) { switch (name) {
case "timeline": case "timeline":
if (!get(client).user) break;
route = "/"; route = "/";
getTimeline(true); getTimeline(true);
break; break;
case "notifcations": case "notifications":
route = "/notifications";
getNotifications();
break;
case "explore": case "explore":
case "lists": case "lists":
case "favourites": case "favourites":
case "bookmarks": case "bookmarks":
case "hashtags": case "hashtags":
default:
return; return;
} }
if (!route) return; if (!route) return;
@ -66,11 +68,11 @@
</div> </div>
</header> </header>
{#if $logged_in}
<div id="nav-items"> <div id="nav-items">
<Button label="Timeline" <Button label="Timeline"
on:click={() => handle_btn("timeline")} on:click={() => handle_btn("timeline")}
active={path == "/" && $client.user} active={$page.url.pathname === "/"}>
disabled={!$client.user}>
<svelte:fragment slot="icon"> <svelte:fragment slot="icon">
<TimelineIcon/> <TimelineIcon/>
</svelte:fragment> </svelte:fragment>
@ -78,14 +80,15 @@
</Button> </Button>
<Button label="Notifications" <Button label="Notifications"
on:click={() => handle_btn("notifications")} on:click={() => handle_btn("notifications")}
active={path == "/notifications"} active={$page.url.pathname === "/notifications"}>
disabled>
<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>
@ -127,7 +130,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>
@ -222,6 +224,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;

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={`/post/${data.status.id}`} 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: 8px 0;
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

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

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

@ -4,12 +4,12 @@
import Widgets from '$lib/ui/Widgets.svelte'; import Widgets from '$lib/ui/Widgets.svelte';
import { client, Client } from '$lib/client/client.js'; import { client, Client } from '$lib/client/client.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { logged_in } from '$lib/stores/user.js';
export let data; import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
$: path = data.path || "/";
let ready = new Promise(resolve => { let ready = new Promise(resolve => {
if (get(client)) { if (get(client)) {
if (get(client).user) logged_in.set(true);
return resolve(); return resolve();
} }
let new_client = new Client(); let new_client = new Client();
@ -21,8 +21,18 @@
client.set(new_client); client.set(new_client);
return resolve(); return resolve();
} }
if (user) logged_in.set(true);
new_client.user = user; new_client.user = user;
window.peekie = new_client; window.peekie = new_client;
// 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);
});
client.update(client => { client.update(client => {
client.user = user; client.user = user;
return client; return client;
@ -35,7 +45,7 @@
<div id="app"> <div id="app">
<header> <header>
<Navigation path={path} /> <Navigation />
</header> </header>
<main> <main>

View file

@ -1,6 +1,2 @@
export const prerender = true; export const prerender = true;
export const ssr = false; export const ssr = false;
export async function load({ url }) {
return { path: url.pathname };
}

View file

@ -1,14 +1,25 @@
<script> <script>
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { client } from '$lib/client/client.js'; import { client } from '$lib/client/client.js';
import { timeline, getTimeline } from '$lib/timeline.js';
import LoginForm from '$lib/ui/LoginForm.svelte'; import LoginForm from '$lib/ui/LoginForm.svelte';
import Feed from '$lib/ui/Feed.svelte'; import Feed from '$lib/ui/Feed.svelte';
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';
getTimeline();
document.addEventListener("scroll", event => {
if (get(page).url.pathname !== "/") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline();
}
});
</script> </script>
{#if $client.user} {#if $client.user}
<Feed /> <Feed posts={$timeline} />
{:else} {:else}
<LoginForm /> <LoginForm />
{/if} {/if}

View file

@ -28,8 +28,22 @@
client.user = user client.user = user
return client; return client;
}); });
return get(client).getNotifications(
get(last_read_notification_id)
).then(notif_data => {
client.update(client => {
// we've just logged in, so assume all past notifications are read.
// i *would* just use the mastodon marker API to get the last read
// notification, but this does not appear to be widely supported.
if (notif_data.constructor === Array && notif_data.length > 0)
last_read_notification_id.set(notif_data[0].id);
client.save();
return client;
});
goto("/"); goto("/");
}); });
}); });
});
} }
</script> </script>

View file

@ -0,0 +1,57 @@
<script>
import { notifications, getNotifications } from '$lib/notifications.js';
import Notification from '$lib/ui/Notification.svelte';
getNotifications();
/*
document.addEventListener("scroll", event => {
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;
}
.notifications {
margin: 16px 0;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -2,6 +2,8 @@
import { client } from '$lib/client/client.js'; import { client } from '$lib/client/client.js';
import * as api from '$lib/client/api.js'; import * as api from '$lib/client/api.js';
import { get } from 'svelte/store'; 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';
@ -13,6 +15,12 @@
goto("/"); goto("/");
} }
let previous_page = base;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page
})
$: post = (async resolve => { $: post = (async resolve => {
const post_data = await get(client).getPost(data.post_id, 0, false); const post_data = await get(client).getPost(data.post_id, 0, false);
if (!post_data) { if (!post_data) {
@ -49,16 +57,19 @@
{#if !error} {#if !error}
<header> <header>
{#await post then post} {#await post then post}
<h1>Post by {@html post.user.rich_name}</h1>
{/await}
<nav> <nav>
<Button centered>Back</Button> <Button centered on:click={() => {goto(previous_page)}}>Back</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">
{#await post} {#await post}
<div class="throb"> <div class="loading throb">
<span>loading post...</span> <span>loading post...</span>
</div> </div>
{:then post} {:then post}
@ -78,11 +89,19 @@
<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; margin: auto auto auto 8px;
font-size: 1.5em; font-size: 1.5em;
@ -92,7 +111,7 @@
} }
header nav { header nav {
margin-left: auto; margin-right: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
@ -102,7 +121,7 @@
margin-bottom: 20vh; margin-bottom: 20vh;
} }
.throb { .loading {
width: 100%; width: 100%;
height: 80vh; height: 80vh;
display: flex; display: flex;