Compare commits

..

6 commits

12 changed files with 315 additions and 56 deletions

3
src/img/icons/cross.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M20.763 7.627a2 2 0 1 1 2.908 2.746l-5.236 5.545 5.547 5.236.141.149a2 2 0 0 1-2.887 2.76l-5.546-5.237-5.236 5.547a2 2 0 1 1-2.908-2.746l5.236-5.546-5.546-5.235-.142-.149a2 2 0 0 1 2.731-2.893l.157.133 5.545 5.236 5.236-5.546Z"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

3
src/img/icons/tick.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M24.546 8.627a2 2 0 0 1 2.908 2.746l-.03.032-.03.032-.03.032-.03.032-.06.064-.06.063-.12.126c-.036.04-.02.023-.058.062l-.03.032-.059.062-.029.03-.059.063-.058.062-.03.03-.028.032-.116.122-.028.03-.03.032c-.097.103.04-.044-.057.06l-.453.48-.223.236-.11.117-.11.116-.856.907-.052.055-.053.056-.31.328-.051.055-.026.027-.026.027c-.114.122.064-.067-.05.054l-.026.027-.025.027-.051.055-.026.026-.025.027-.102.108-.05.052-.026.028-.024.026-.1.107-.051.052-.15.159-.024.026-.026.026-.049.053-.1.105-.023.026-.025.026-.025.027-.025.025-.097.104-.099.104-.024.026-.025.026-.146.155-.048.052-.049.05-.097.103-.049.052-.047.05-.097.103-.096.102c-.09.095.042-.045-.049.05l-.023.026-.024.025-.192.203-.023.026-.024.024-.024.026-.024.025-.38.403-.095.1-.095.1c-.04.043-.006.008-.047.05l-.023.026-.025.025-.093.1-.19.2-.023.025-.024.025-.046.05-.19.2-.047.05-.047.05-.19.2-.023.025-.023.025c-.084.089.036-.038-.048.05l-.047.05-.048.05a59.55 59.55 0 0 1-.094.1c-.104.11.057-.059-.047.051l-.048.05-.047.05-.023.025-.025.025c-.058.062.011-.01-.047.051-.042.046-.005.005-.048.05l-.094.1-.025.026-.023.025-.048.051-.048.05-.335.355-1.572 1.666-.102.107-.025.027-.025.027-.026.027-.025.026-.102.109-.026.027-.05.055-.027.026-.051.055-.026.027-.026.027-.103.11c-.033.035-.02.02-.052.056l-.053.054-.025.028-.053.055-.026.029-.105.11c-.052.056 0 .002-.053.056l-.026.029-.026.027-.027.028-.027.029-.053.056-.106.113c-.066.07.012-.014-.054.056l-.026.029-.028.028-.107.114c-.076.08-.032.035-.108.116l-.11.115-.11.116-.027.03-.083.087-.222.236-.226.238-.113.12-.03.031-.084.091-.03.03-.028.03-.03.03-.027.032-.06.06c-.123.132.067-.069-.057.062l-.029.031-.03.03-.028.032-.058.062-.03.03-.029.032-.03.031-.059.063-.06.062c-.09.097-.026.03-.117.126l-.24.255a2.002 2.002 0 0 1-2.91 0l-5.782-6.125-.133-.156a2 2 0 0 1 3.041-2.59l4.328 4.584.164-.173c.057-.06 0 .002.056-.058l.055-.06.054-.057.029-.029.027-.03.108-.114.028-.029.027-.03.027-.027.027-.03.054-.056.027-.029.027-.029.027-.028.027-.029.053-.056.027-.029.026-.028.054-.057.053-.055c.095-.1-.042.044.053-.057l.21-.223.21-.22.103-.11.05-.055.027-.027.05-.054.027-.028.051-.054.103-.108.203-.216.2-.212.1-.106.026-.026.024-.027.026-.026.024-.027.025-.026.05-.053.025-.026.098-.105.05-.052.049-.052.099-.104.39-.414c.107-.113-.01.012.097-.102.098-.103-.05.052.048-.052l.049-.05.095-.103.097-.102.048-.05.023-.026.025-.025.191-.203.024-.026.024-.024.023-.026.025-.025.38-.403.094-.1.095-.1.047-.05.024-.026.024-.025.047-.05.048-.05.188-.2.023-.025.025-.025.047-.05.047-.05.047-.05.094-.1.048-.05.047-.05.188-.2.025-.025.023-.025.047-.05.048-.05.047-.05.095-.1c.067-.072-.02.02.046-.051.05-.052 0 .002.048-.05l.048-.05.024-.025.023-.025c.054-.058.04-.044.095-.101l.096-.1.023-.026.024-.025.047-.051.025-.025.023-.025.096-.101.023-.026.025-.025.191-.203 1.572-1.666.026-.027.025-.026.152-.161.103-.109.05-.054.027-.028.026-.026.206-.22.052-.055.052-.054.052-.056.264-.278.212-.226.053-.056.054-.057.055-.058.053-.056.109-.115.109-.116.055-.058.027-.029.027-.029.11-.117.223-.236.454-.48c.046-.05.01-.011.057-.06l.058-.062.115-.122.03-.031.029-.03.087-.094.03-.03.117-.126.06-.062.029-.031.03-.033.06-.062.06-.064.03-.032.029-.031.06-.064.06-.064Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -23,6 +23,7 @@
"navigation": {
"timeline": "Timeline",
"notifications": "Notifications",
"follow_requests": "Follow requests",
"explore": "Explore",
"lists": "Lists",
@ -37,6 +38,10 @@
"back": "Back"
},
"follow_requests": {
"none": "no follow requests to action right now!"
},
"timeline": {
"home": "Home",
"local": "Local",

View file

@ -176,6 +176,66 @@ export async function getNotifications(host, token, min_id, max_id, limit, types
return data;
}
/**
* GET /api/v1/follow_requests
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} min_id - If provided, only shows follow requests since this ID.
* @param {string} max_id - If provided, only shows follow requests before this ID.
* @param {string} limit - The maximum number of follow requests to retrieve (default 40, max 80).
*/
export async function getFollowRequests(host, token, since_id, max_id, limit) {
let url = `https://${host}/api/v1/follow_requests`;
let params = new URLSearchParams();
if (since_id) params.append("since_id", since_id);
if (max_id) params.append("max_id", max_id);
if (limit) params.append("limit", limit);
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/follow_requests/:account_id/authorize
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} account_id - The account ID of the follow request to accept
*/
export async function acceptFollowRequest(host, token, account_id) {
let url = `https://${host}/api/v1/follow_requests/${account_id}/authorize`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/follow_requests/:account_id/reject
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} account_id - The account ID of the follow request to reject
*/
export async function rejectFollowRequest(host, token, account_id) {
let url = `https://${host}/api/v1/follow_requests/${account_id}/reject`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/timelines/{timeline}
* @param {string} host - The domain of the target server.

28
src/lib/followRequests.js Normal file
View file

@ -0,0 +1,28 @@
import { server } from './client/server.js';
import { writable } from "svelte/store";
import * as api from "./api.js";
import { app } from './client/app.js';
import { get } from 'svelte/store';
import { parseAccount } from './account.js';
// Cache for all requests
export let followRequests = writable();
/**
* Gets all follow requests
* @param {boolean} force
*/
export async function fetchFollowRequests(force) {
// if already cached, return for now
if(!get(followRequests) && !force) return;
let newReqs = await api.getFollowRequests(
get(server).host,
get(app).token
);
// parse accounts
newReqs = newReqs.map((r) => parseAccount(r));
followRequests.set(newReqs);
}

View file

@ -154,8 +154,7 @@
a.disabled,
button.disabled {
color: var(--text);
opacity: .5;
background: transparent;
opacity: .35;
border-color: transparent;
cursor: not-allowed;
}

View file

@ -6,8 +6,9 @@
import { playSound } from '$lib/sound.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { unread_notif_count } from '$lib/notifications.js';
import { fetchFollowRequests, followRequests } from '$lib/followRequests.js'
import Lang from '$lib/lang';
import Logo from '$lib/../img/campfire-logo.svg';
@ -24,6 +25,7 @@
import InfoIcon from '../../img/icons/info.svg';
import SettingsIcon from '../../img/icons/settings.svg';
import LogoutIcon from '../../img/icons/logout.svg';
import FollowersIcon from '../../img/icons/followers.svg';
const VERSION = APP_VERSION;
const lang = Lang('en_GB');
@ -40,7 +42,7 @@
goto(`/${$server.host}/${$account.username}`);
}
async function log_out() {
async function logOut() {
if (!confirm("This will log you out. Are you sure?")) return;
const res = await api.revokeToken(
@ -59,6 +61,10 @@
goto("/");
}
onMount(async () => {
await fetchFollowRequests(true)
})
</script>
<div id="navigation">
@ -91,6 +97,19 @@
</span>
{/if}
</Button>
{#if $followRequests.length > 0}
<Button label="Follow requests"
href="/follow-requests"}
active={$page.url.pathname === "/follow-requests"}>
<svelte:fragment slot="icon">
<FollowersIcon/>
</svelte:fragment>
{lang.string('navigation.follow_requests')}
<span class="notification-count">
{$followRequests.length}
</span>
</Button>
{/if}
<Button label="Explore" disabled>
<svelte:fragment slot="icon">
<ExploreIcon height="auto"/>
@ -142,7 +161,7 @@
<SettingsIcon/>
</svelte:fragment>
</Button>
<Button centered label="{lang.string('navigation.log_out')}" on:click={() => log_out()}>
<Button centered label="{lang.string('navigation.log_out')}" on:click={() => logOut()}>
<svelte:fragment slot="icon">
<LogoutIcon/>
</svelte:fragment>

View file

@ -144,19 +144,22 @@
<style>
.notification {
display: block;
border-top: 1px solid color-mix(in srgb, transparent, var(--text) 50%);
border-top: 1px solid color-mix(in srgb, transparent, var(--text) 25%);
padding: 16px;
text-decoration: inherit;
color: inherit;
transition: background-color .1s;
cursor: pointer;
background-color: var(--bg-900);
}
.notification:first-of-type {
border-top: none;
}
.notification:hover {
background-color: color-mix(in srgb, var(--bg-800), transparent 50%);
background-color: color-mix(in srgb, var(--bg-800), transparent 35%);
}
header {

View file

@ -0,0 +1,36 @@
<script>
export let title;
</script>
<header>
<h1>{title}</h1>
<div class="header-items">
<slot/>
</div>
</header>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
user-select: none;
box-sizing: border-box;
}
header h1 {
font-size: 1.5em;
}
header .header-items {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
</style>

View file

@ -7,6 +7,7 @@
import LoginForm from '$lib/ui/LoginForm.svelte';
import Button from '$lib/ui/Button.svelte';
import Post from '$lib/ui/post/Post.svelte';
import PageHeader from '../lib/ui/core/PageHeader.svelte';
const lang = Lang('en_GB');
@ -23,14 +24,11 @@
</script>
{#if $account}
<header>
<h1>{lang.string('timeline.home')}</h1>
<nav>
<Button centered active>{lang.string('timeline.home')}</Button>
<Button centered disabled>{lang.string('timeline.local')}</Button>
<Button centered disabled>{lang.string('timeline.federated')}</Button>
</nav>
</header>
<PageHeader title={lang.string('timeline.home')}>
<Button centered active>{lang.string('timeline.home')}</Button>
<Button centered disabled>{lang.string('timeline.local')}</Button>
<Button centered disabled>{lang.string('timeline.federated')}</Button>
</PageHeader>
<div id="feed" role="feed">
{#if $timeline.length <= 0}
@ -47,30 +45,6 @@
{/if}
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
user-select: none;
box-sizing: border-box;
}
header h1 {
font-size: 1.5em;
}
nav {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}

View file

@ -0,0 +1,144 @@
<script>
import { followRequests } from '$lib/followRequests.js';
import PageHeader from '../../lib/ui/core/PageHeader.svelte';
import Lang from '$lib/lang';
import {server} from '$lib/client/server';
import {app} from '$lib/client/app';
import Button from '../../lib/ui/Button.svelte';
import * as api from '$lib/api'
import TickIcon from '../../img/icons/tick.svg'
import CrossIcon from '../../img/icons/cross.svg'
import { get } from 'svelte/store';
const lang = Lang('en_GB');
async function actionRequest(account_id, approved) {
// remove item from array first - this updates the ui and
// makes the interaction more seamless
$followRequests.splice(
$followRequests.indexOf(
$followRequests.find(r => r.id)
),
1
);
// hack: force the state to update now that we just spliced the array
$followRequests = $followRequests
if(approved) {
await api.acceptFollowRequest(
get(server).host,
get(app).token,
account_id
)
} else {
await api.rejectFollowRequest(
get(server).host,
get(app).token,
account_id
)
}
}
// aliases
const acceptRequest = (id) => actionRequest(id, true);
const denyRequest = (id) => actionRequest(id, false);
</script>
<PageHeader title={lang.string('navigation.follow_requests')}/>
{#if $followRequests.length < 1}
<p class="request-zero">{lang.string('follow_requests.none')}</p>
{/if}
{#each $followRequests as req}
<div class="request">
<a href="/{$server.host}/@{req.fqn}" class="request-avatar-container" on:mouseup|stopPropagation>
<img src={req.avatar_url} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<div class="info">
<div class="request-user-info">
<a href="/{$server.host}/@{req.fqn}" class="name">{@html req.rich_name}</a>
<span class="username">{req.mention}</span>
</div>
</div>
<div class="request-options">
<Button filled title="Yes" on:click={() => acceptRequest(req.id)}>
<TickIcon width="24px"/>
</Button>
<Button title="No" on:click={() => denyRequest(req.id)}>
<CrossIcon width="24px"/>
</Button>
</div>
</div>
{/each}
<style>
.request {
width: 100%;
display: flex;
flex-direction: row;
background: var(--bg-900);
padding: .5rem;
border-radius: 8px;
}
.request a,
.request a:visited {
color: inherit;
text-decoration: none;
}
.request a:hover {
text-decoration: underline;
}
.request-avatar-container {
margin-right: 12px;
display: flex;
}
.post-avatar {
border-radius: 8px;
}
.info {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.request-user-info {
margin-top: -2px;
display: flex;
flex-direction: column;
justify-content: center;
}
.request-user-info a {
display: block;
}
.request-user-info .username {
opacity: .8;
font-size: .9em;
}
.request-options {
display: flex;
gap: 8px;
}
.request-options :global(button) {
width: fit-content;
height: 100%;
}
.request-zero {
opacity: 0.8;
font-size: 0.95rem;
text-align: center;
}
</style>

View file

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Notification from '$lib/ui/Notification.svelte';
import PageHeader from '../../lib/ui/core/PageHeader.svelte';
import Lang from '$lib/lang';
const lang = Lang('en_GB');
@ -33,9 +34,7 @@
});
</script>
<header>
<h1>{lang.string('navigation.notifications')}</h1>
</header>
<PageHeader title={lang.string('navigation.notifications')}/>
<div class="notifications">
{#if $notifications.length === 0}
@ -50,20 +49,6 @@
</div>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
box-sizing: border-box;
}
h1 {
font-size: 1.5em;
}
.loading {
width: 100%;
height: 80vh;