post interactions!

This commit is contained in:
ari melody 2024-06-28 08:43:12 +01:00
parent 648f53f40c
commit 681ef74f95
11 changed files with 354 additions and 75 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "spacesocial-client", "name": "spacesocial-client",
"version": "0.2.0_rev1", "version": "0.2.0_rev2",
"description": "social media for the galaxy-wide-web! 🌌", "description": "social media for the galaxy-wide-web! 🌌",
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -20,6 +20,7 @@
} }
if (client.app && client.app.token) { if (client.app && client.app.token) {
// this triggers the client actually getting the authenticated user's data.
client.verifyCredentials().then(res => { client.verifyCredentials().then(res => {
if (res) { if (res) {
console.log(`Logged in as @${client.user.username}@${client.user.host}`); console.log(`Logged in as @${client.user.username}@${client.user.host}`);

View file

@ -131,6 +131,83 @@ export async function getPostContext(post_id) {
return data; return data;
} }
export async function boostPost(post_id) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/reblog`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unboostPost(post_id) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreblog`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function favouritePost(post_id) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/favourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unfavouritePost(post_id) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unfavourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function reactPost(post_id, shortcode) {
// for whatever reason (at least in my testing on iceshrimp)
// using shortcodes for external emoji results in a fallback
// to the default like emote.
// identical api calls on chuckya instances do not display
// this behaviour.
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unreactPost(post_id, shortcode) {
let client = get(Client.get());
let url = `https://${client.instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + client.app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function parsePost(data, parent_replies, child_replies) { export async function parsePost(data, parent_replies, child_replies) {
let client = get(Client.get()); let client = get(Client.get());
let post = new Post(); let post = new Post();
@ -166,6 +243,9 @@ export async function parsePost(data, parent_replies, child_replies) {
post.warning = data.spoiler_text; post.warning = data.spoiler_text;
post.boost_count = data.reblogs_count; post.boost_count = data.reblogs_count;
post.reply_count = data.replies_count; post.reply_count = data.replies_count;
post.favourite_count = data.favourites_count;
post.favourited = data.favourited;
post.boosted = data.boosted;
post.mentions = data.mentions; post.mentions = data.mentions;
post.files = data.media_attachments; post.files = data.media_attachments;
post.url = data.url; post.url = data.url;
@ -185,33 +265,7 @@ export async function parsePost(data, parent_replies, child_replies) {
} }
if (data.reactions && client.instance.capabilities.includes(capabilities.REACTIONS)) { if (data.reactions && client.instance.capabilities.includes(capabilities.REACTIONS)) {
post.reactions = []; post.reactions = parseReactions(data.reactions);
data.reactions.forEach(reaction_data => {
if (/^[\w\-.@]+$/g.exec(reaction_data.name)) {
let name = reaction_data.name.split('@')[0];
let host = reaction_data.name.includes('@') ? reaction_data.name.split('@')[1] : client.instance.host;
post.reactions.push({
count: reaction_data.count,
emoji: parseEmoji({
id: name + '@' + host,
name: name,
host: host,
url: reaction_data.url,
}),
me: reaction_data.me,
});
} else {
if (reaction_data.name == '❤') reaction_data.name = '❤️'; // stupid heart unicode
post.reactions.push({
count: reaction_data.count,
emoji: {
html: reaction_data.name,
name: reaction_data.name,
},
me: reaction_data.me,
});
}
});
} }
return post; return post;
} }
@ -251,6 +305,21 @@ export async function parseUser(data) {
return user; return user;
} }
export function parseReactions(data) {
let client = get(Client.get());
let reactions = [];
data.forEach(reaction_data => {
let reaction = {
count: reaction_data.count,
name: reaction_data.name,
me: reaction_data.me,
};
if (reaction_data.url) reaction.url = reaction_data.url;
reactions.push(reaction);
});
return reactions;
}
export function parseEmoji(data) { export function parseEmoji(data) {
let emoji = new Emoji( let emoji = new Emoji(
data.id, data.id,

View file

@ -100,6 +100,30 @@ export class Client {
return await api.getPost(post_id, parent_replies, child_replies); return await api.getPost(post_id, parent_replies, child_replies);
} }
async boostPost(post_id) {
return await api.boostPost(post_id);
}
async unboostPost(post_id) {
return await api.unboostPost(post_id);
}
async favouritePost(post_id) {
return await api.favouritePost(post_id);
}
async unfavouritePost(post_id) {
return await api.unfavouritePost(post_id);
}
async reactPost(post_id, shortcode) {
return await api.reactPost(post_id, shortcode);
}
async unreactPost(post_id, shortcode) {
return await api.unreactPost(post_id, shortcode);
}
putCacheUser(user) { putCacheUser(user) {
this.cache.users[user.id] = user; this.cache.users[user.id] = user;
client.set(this); client.set(this);
@ -148,7 +172,6 @@ export class Client {
if (!json) return false; if (!json) return false;
let saved = JSON.parse(json); let saved = JSON.parse(json);
if (!saved.version || saved.version !== APP_VERSION) { if (!saved.version || saved.version !== APP_VERSION) {
localStorage.setItem(save_name + '-backup', json);
localStorage.removeItem(save_name); localStorage.removeItem(save_name);
return false; return false;
} }

View file

@ -5,9 +5,7 @@ export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g;
export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g; export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g;
export default class Emoji { export default class Emoji {
id;
name; name;
host;
url; url;
constructor(id, name, host, url) { constructor(id, name, host, url) {
@ -18,7 +16,10 @@ export default class Emoji {
} }
get html() { get html() {
return `<img src="${this.url}" class="emoji" height="20" title="${this.name}" alt="${this.name}">`; if (this.url)
return `<img src="${this.url}" class="emoji" height="20" title="${this.name}" alt="${this.name}">`;
else
return `${this.name}`;
} }
} }

View file

@ -8,6 +8,9 @@ export default class Post {
warning; warning;
boost_count; boost_count;
reply_count; reply_count;
favourite_count;
favourited;
boosted;
mentions; mentions;
reactions; reactions;
emojis; emojis;

View file

@ -1,10 +1,11 @@
const sounds = { const sounds = {
"default": new Audio("sound/log.ogg"), "default": new Audio("/sound/log.ogg"),
"post": new Audio("sound/success.ogg"), "post": new Audio("/sound/success.ogg"),
"boost": new Audio("sound/hello.ogg"), "boost": new Audio("/sound/hello.ogg"),
}; };
export function play_sound(name) { export function play_sound(name) {
if (name === false) return;
if (!name) name = "default"; if (!name) name = "default";
const sound = sounds[name]; const sound = sounds[name];
if (!sound) { if (!sound) {

View file

@ -1,21 +1,36 @@
<script> <script>
import { play_sound } from '../../sound.js'; import { play_sound } from '../../sound.js';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let icon = "🔧";
export let type = "action"; export let type = "action";
export let label = "Action"; export let label = "Action";
export let title = label; export let title = label;
export let count = 0; export let count = 0;
export let active = false;
export let disabled = false;
export let sound = "default"; export let sound = "default";
function click() {
if (disabled) return;
play_sound(sound);
dispatch('click');
}
</script> </script>
<button <button
type="button" type="button"
class="{type}" class={[
type,
active ? "active" : "",
disabled ? "disabled" : "",
].join(' ')}
aria-label="{label}" aria-label="{label}"
title="{title}" title="{title}"
on:click|stopPropagation={() => (play_sound(sound))}> on:click={click}>
<span class="icon">{@html icon}</span> <span class="icon">
<slot/>
</span>
{#if count} {#if count}
<span class="count">{count}</span> <span class="count">{count}</span>
{/if} {/if}
@ -28,24 +43,34 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 4px; gap: 4px;
font-family: inherit;
font-size: 1em; font-size: 1em;
background: none; background: none;
color: inherit; color: inherit;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
transition: background-color .1s, color .1s;
cursor: pointer;
} }
button.active { button.active {
background: var(--accent); background-color: color-mix(in srgb, transparent, var(--accent) 50%);
color: var(--bg0); color: var(--bg-1000);
} }
button:hover { button:not(.disabled):hover {
background: #8881; background-color: var(--bg-600);
color: var(--text);
} }
button:active { button:not(.disabled):active {
background: #0001; background-color: var(--bg-1000);
color: var(--text);
}
button.disabled {
opacity: .5;
cursor: initial;
} }
.icon { .icon {

View file

@ -8,6 +8,9 @@
import { parseOne as parseEmoji } from '../../emoji.js'; import { parseOne as parseEmoji } from '../../emoji.js';
import { play_sound } from '../../sound.js'; import { play_sound } from '../../sound.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { Client } from '../../client/client.js';
import * as api from '../../client/api.js';
export let post_data; export let post_data;
export let focused = false; export let focused = false;
@ -25,6 +28,55 @@
location = `/post/${post.id}`; location = `/post/${post.id}`;
} }
async function toggleBoost() {
let client = get(Client.get());
let data;
if (post.boosted)
data = await client.unboostPost(post.id);
else
data = await client.boostPost(post.id);
if (!data) {
console.error(`Failed to boost post ${post.id}`);
return;
}
post.boosted = data.boosted;
post.boost_count = data.reblogs_count;
}
async function toggleFavourite() {
let client = get(Client.get());
let data;
if (post.favourited)
data = await client.unfavouritePost(post.id);
else
data = await client.favouritePost(post.id);
if (!data) {
console.error(`Failed to favourite post ${post.id}`);
return;
}
post.favourited = data.favourited;
post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = api.parseReactions(data.reactions);
}
async function toggleReaction(reaction) {
if (reaction.name.includes('@')) return;
let client = get(Client.get());
let data;
if (reaction.me)
data = await client.unreactPost(post.id, reaction.name);
else
data = await client.reactPost(post.id, reaction.name);
if (!data) {
console.error(`Failed to favourite post ${post.id}`);
return;
}
post.favourited = data.favourited;
post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = api.parseReactions(data.reactions);
}
let el; let el;
onMount(() => { onMount(() => {
if (focused) { if (focused) {
@ -46,18 +98,31 @@
<PostHeader post={post} /> <PostHeader post={post} />
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions"> <div class="post-reactions" on:click|stopPropagation>
{#each post.reactions as reaction} {#each post.reactions as reaction}
<ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> <ReactionButton
type="reaction"
on:click={() => toggleReaction(reaction)}
bind:active={reaction.me}
bind:count={reaction.count}
disabled={reaction.name.includes('@')}
title={reaction.name}
label="">
{#if reaction.url}
<img src={reaction.url} class="emoji" height="20" title={reaction.name} alt={reaction.name}>
{:else}
{reaction.name}
{/if}
</ReactionButton>
{/each} {/each}
</div> </div>
<div class="post-actions"> <div class="post-actions" on:click|stopPropagation>
<ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" /> <ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton>
<ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" /> <ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton>
<ActionButton icon="⭐" type="favourite" label="Favourite" /> <ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton>
<ActionButton icon="😃" type="react" label="React" /> <ActionButton type="react" label="React" disabled>😃</ActionButton>
<ActionButton icon="🗣️" type="quote" label="Quote" /> <ActionButton type="quote" label="Quote" disabled>🗣️</ActionButton>
<ActionButton icon="🛠️" type="more" label="More" /> <ActionButton type="more" label="More" disabled>🛠️</ActionButton>
</div> </div>
</footer> </footer>
</article> </article>
@ -97,14 +162,18 @@
} }
:global(.post-reactions) { :global(.post-reactions) {
width: fit-content;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 4px;
} }
:global(.post-actions) { :global(.post-actions) {
width: fit-content;
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 2px;
} }
.post-container :global(.emoji) { .post-container :global(.emoji) {

View file

@ -1,21 +1,35 @@
<script> <script>
import { play_sound } from '../../sound.js'; import { play_sound } from '../../sound.js';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let icon = "🔧"; export let type = "react";
export let type = "action"; export let label = "React";
export let label = "Action";
export let title = label; export let title = label;
export let count = 0; export let count = 0;
export let active = false;
export let disabled = false;
export let sound = "default"; export let sound = "default";
function click() {
play_sound(sound);
dispatch('click');
}
</script> </script>
<button <button
type="button" type="button"
class="{type}" class={[
type,
active ? "active" : "",
disabled ? "disabled" : "",
].join(' ')}
aria-label="{label}" aria-label="{label}"
title="{title}" title="{title}"
on:click|stopPropagation={() => (play_sound(sound))}> on:click={click}>
<span class="icon">{@html icon}</span> <span class="icon">
<slot/>
</span>
{#if count} {#if count}
<span class="count">{count}</span> <span class="count">{count}</span>
{/if} {/if}
@ -33,19 +47,27 @@
color: inherit; color: inherit;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
transition: background-color .1s, color .1s;
cursor: pointer;
} }
button.active { button.active {
background: var(--accent); background-color: color-mix(in srgb, transparent, var(--accent) 50%);
color: var(--bg0); color: var(--bg-1000);
} }
button:hover { button:not(.disabled):hover {
background: #8881; background-color: var(--bg-600);
color: var(--text);
} }
button:active { button:not(.disabled):active {
background: #0001; background-color: var(--bg-1000);
color: var(--text);
}
button.disabled {
cursor: initial;
} }
.icon { .icon {

View file

@ -6,6 +6,9 @@
import Post from './Post.svelte'; import Post from './Post.svelte';
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';
export let post; export let post;
let time_string = post.created_at.toLocaleString(); let time_string = post.created_at.toLocaleString();
@ -13,6 +16,55 @@
function gotoPost() { function gotoPost() {
location = `/post/${post.id}`; location = `/post/${post.id}`;
} }
async function toggleBoost() {
let client = get(Client.get());
let data;
if (post.boosted)
data = await client.unboostPost(post.id);
else
data = await client.boostPost(post.id);
if (!data) {
console.error(`Failed to boost post ${post.id}`);
return;
}
post.boosted = data.boosted;
post.boost_count = data.reblogs_count;
}
async function toggleFavourite() {
let client = get(Client.get());
let data;
if (post.favourited)
data = await client.unfavouritePost(post.id);
else
data = await client.favouritePost(post.id);
if (!data) {
console.error(`Failed to favourite post ${post.id}`);
return;
}
post.favourited = data.favourited;
post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = api.parseReactions(data.reactions);
}
async function toggleReaction(reaction) {
if (reaction.name.includes('@')) return;
let client = get(Client.get());
let data;
if (reaction.me)
data = await client.unreactPost(post.id, reaction.name);
else
data = await client.reactPost(post.id, reaction.name);
if (!data) {
console.error(`Failed to favourite post ${post.id}`);
return;
}
post.favourited = data.favourited;
post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = api.parseReactions(data.reactions);
}
</script> </script>
{#if post.reply} {#if post.reply}
@ -28,18 +80,31 @@
<Body post={post} /> <Body post={post} />
<footer class="post-footer"> <footer class="post-footer">
<div class="post-reactions"> <div class="post-reactions" on:click|stopPropagation>
{#each post.reactions as reaction} {#each post.reactions as reaction}
<ReactionButton icon={reaction.emoji.html} type="reaction" bind:count={reaction.count} title={reaction.emoji.id} label="" /> <ReactionButton
type="reaction"
on:click={() => toggleReaction(reaction)}
bind:active={reaction.me}
bind:count={reaction.count}
disabled={reaction.name.includes('@')}
title={reaction.name}
label="">
{#if reaction.url}
<img src={reaction.url} class="emoji" height="20" title={reaction.name} alt={reaction.name}>
{:else}
{reaction.name}
{/if}
</ReactionButton>
{/each} {/each}
</div> </div>
<div class="post-actions"> <div class="post-actions" on:click|stopPropagation>
<ActionButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} sound="post" /> <ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>🗨️</ActionButton>
<ActionButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} sound="boost" /> <ActionButton type="boost" label="Boost" on:click={() => toggleBoost()} bind:active={post.boosted} bind:count={post.boost_count} sound="boost">🔁</ActionButton>
<ActionButton icon="⭐" type="favourite" label="Favourite" /> <ActionButton type="favourite" label="Favourite" on:click={() => toggleFavourite()} bind:active={post.favourited} bind:count={post.favourite_count}>⭐</ActionButton>
<ActionButton icon="😃" type="react" label="React" /> <ActionButton type="react" label="React" disabled>😃</ActionButton>
<ActionButton icon="🗣️" type="quote" label="Quote" /> <ActionButton type="quote" label="Quote" disabled>🗣️</ActionButton>
<ActionButton icon="🛠️" type="more" label="More" /> <ActionButton type="more" label="More" disabled>🛠️</ActionButton>
</div> </div>
</footer> </footer>
</div> </div>