federating my verse (iceshrimp & mastodon API compat, read-only)

This commit is contained in:
ari melody 2024-06-17 21:17:27 +01:00
parent 8dc8190cdf
commit da93978820
Signed by: ari
GPG key ID: CF99829C92678188
67 changed files with 2743 additions and 649 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
**/.DS_Store
node_modules/
dist/

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>space social</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1185
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "spacesocial-client",
"version": "0.1.0",
"description": "social media for the galaxy-wide-web! 🌌",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"svelte": "^4.2.18",
"vite": "^5.3.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View file

@ -1,240 +0,0 @@
:root {
--fg0: #eee;
--bg0: #080808;
--bg1: #101010;
--bg2: #121212;
--accent: #b7fd49;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg0);
color: var(--fg0);
font-family: "Inter", sans-serif;
}
#feed {
width: 720px;
margin: 0 auto;
background-color: var(--bg1);
}
.post-container {
padding: 20px 32px;
border-bottom: 1px solid #8884;
transition: background-color .1s;
}
.post-container:hover {
background-color: var(--bg2);
}
.post-context {
margin-bottom: 8px;
padding-left: 58px;
display: flex;
flex-direction: row;
align-items: center;
color: var(--accent);
opacity: .8;
transition: opacity .1s;
}
.post-container:hover .post-context {
opacity: 1;
}
.post-context-icon {
margin-right: 4px;
}
.post-context a,
.post-context a:visited,
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-context a:hover,
.post-header-container a:hover {
text-decoration: underline;
}
.post-context-time {
margin-left: auto;
}
article.post { /* ... */ }
.post-header-container {
display: flex;
flex-direction: row;
}
.post-avatar-container {
margin-right: 12px;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
/* transition: transform .2s ease-out; */
}
/* .post-avatar:hover { */
/* transform: scale(1.1); */
/* } */
.post-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info a {
display: block;
}
.post-body {
margin-top: 8px;
}
.post-media-container {
margin-top: 8px;
display: grid;
grid-gap: 8px;
}
.post-media-container[data-count="1"] {
grid-template-rows: 1fr;
}
.post-media-container[data-count="2"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
.post-media-container[data-count="3"] {
grid-template-columns: 1fr .5fr;
grid-template-rows: 1fr 1fr;
}
.post-media-container[data-count="4"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.post-media {
border-radius: 12px;
background-color: #000;
overflow: hidden;
}
.post-media a {
width: 100%;
height: 100%;
display: block;
cursor: zoom-in;
}
.post-media a img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.post-media-container > :nth-child(1) {
grid-column: 1/2;
grid-row: 1/2;
}
.post-media-container[data-count="3"] > :nth-child(1) {
grid-row: 1/3;
}
.post-media-container > :nth-child(2) {
grid-column: 2/2;
grid-row: 1/2;
}
.post-media-container > :nth-child(3) {
grid-column: 1/2;
grid-row: 2/2;
}
.post-media-container[data-count="3"] > :nth-child(3) {
grid-column: 2/2;
grid-row: 2/2;
}
.post-media-container > :nth-child(4) {
grid-column: 2/2;
grid-row: 2/2;
}
.post-container footer {
opacity: .8;
transition: opacity .1s;
}
.post-container:hover footer {
opacity: 1;
}
.post-reactions {
margin-top: 8px;
}
button.reaction {
padding: 6px 8px;
font-size: 1em;
background: none;
color: inherit;
border: none;
border-radius: 8px;
/* transition: transform .1s ease-out; */
}
button.reaction:hover,
.post-actions button:hover {
/* transform: scale(1.1); */
background: #8881;
}
button.reaction:active,
.post-actions button:active {
/* transform: scale(.95); */
background: #0001;
}
button.reaction.active,
.post-actions button.active {
background: var(--accent);
color: var(--bg0);
}
.post-actions {
margin-top: 8px;
}
.post-actions button {
padding: 6px 8px;
font-size: 1em;
background: none;
color: inherit;
border: none;
border-radius: 8px;
transition: transform .1s ease-out;
}
.post-actions button .count {
opacity: .5;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,57 @@
/* Variable fonts usage:
:root { font-family: "Inter", sans-serif; }
@supports (font-variation-settings: normal) {
:root { font-family: "InterVariable", sans-serif; font-optical-sizing: auto; }
} */
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable.woff2") format("woff2");
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable-Italic.woff2") format("woff2");
}
/* static fonts */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 100; font-display: swap; src: url("Inter-Thin.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 100; font-display: swap; src: url("Inter-ThinItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 200; font-display: swap; src: url("Inter-ExtraLight.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 200; font-display: swap; src: url("Inter-ExtraLightItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 300; font-display: swap; src: url("Inter-Light.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 300; font-display: swap; src: url("Inter-LightItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("Inter-Regular.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 400; font-display: swap; src: url("Inter-Italic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("Inter-Medium.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 500; font-display: swap; src: url("Inter-MediumItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("Inter-SemiBold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 600; font-display: swap; src: url("Inter-SemiBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("Inter-Bold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 700; font-display: swap; src: url("Inter-BoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 800; font-display: swap; src: url("Inter-ExtraBold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 800; font-display: swap; src: url("Inter-ExtraBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 900; font-display: swap; src: url("Inter-Black.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 900; font-display: swap; src: url("Inter-BlackItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 100; font-display: swap; src: url("InterDisplay-Thin.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 100; font-display: swap; src: url("InterDisplay-ThinItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLight.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLightItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 300; font-display: swap; src: url("InterDisplay-Light.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 300; font-display: swap; src: url("InterDisplay-LightItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 400; font-display: swap; src: url("InterDisplay-Regular.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 400; font-display: swap; src: url("InterDisplay-Italic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 500; font-display: swap; src: url("InterDisplay-Medium.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 500; font-display: swap; src: url("InterDisplay-MediumItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 700; font-display: swap; src: url("InterDisplay-Bold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 700; font-display: swap; src: url("InterDisplay-BoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 900; font-display: swap; src: url("InterDisplay-Black.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 900; font-display: swap; src: url("InterDisplay-BlackItalic.woff2") format("woff2"); }

View file

@ -1,107 +0,0 @@
<!DOCTYPE html>
<!--
experimenting with post layouts here!
don't expect anything too flashy ;3
ari melody, 2024
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link href="css/style.css" rel="stylesheet">
<script type="application/javascript" src="script/main.mjs" defer></script>
</head>
<body>
<audio id="sound-success" src="sound/success.wav"></audio>
<header>
</header>
<main>
<div id="feed">
<!--
<div class="post-container" aria-label="ari; hello world!~; 02:12:06">
<div class="post-context">
<span class="post-context-icon">🔁</span>
<span class="post-context-action">
<a href="/@ari">ari 💫</a> boosted this post.
</span>
<span class="post-context-time">
<time title="6/3/2024, 2:12:06 AM">2m ago</time>
</span>
</div>
<article class="post">
<div class="post-header-container">
<a href="/@ari" class="post-avatar-container">
<img src="avatar/ari.jpg" alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<header class="post-header">
<div class="post-user-info">
<a href="/@ari" class="name">ari 💫</a>
<span class="username">@ari</span>
</div>
<div class="post-info">
<a href="/post/21c892b23701" class="created-at">
<time title="6/3/2024, 2:11:58 AM">10m ago</time>
</a>
</div>
</header>
</div>
<div class="post-body">
<span class="post-content">hello world!~</span>
<div class="post-media-container" data-count="3">
<div class="post-media image">
<a href="media/ariyeah-button.png">
<img src="media/ariyeah-button.png" alt="custom miiverse &quot;yeah!&quot; button" loading="lazy" decoding="async">
</a>
</div>
<div class="post-media image">
<a href="media/beer.jpg">
<img src="media/beer.jpg" alt="barney calhoun with beer" loading="lazy" decoding="async">
</a>
</div>
<div class="post-media image">
<a href="media/duck.jpg">
<img src="media/duck.jpg" alt="big rubber duck" loading="lazy" decoding="async">
</a>
</div>
</div>
</div>
<footer class="post-footer">
<div class="post-reactions">
<button type="button" class="reaction">
<span></span>
<span class="count">52</span>
</button>
</div>
<div class="post-actions">
<button type="button" class="reply" aria-label="Reply" title="Reply">
<span>🗨️</span>
<span class="count">7</span>
</button>
<button type="button" class="boost" aria-label="Boost" title="Boost">
<span>🔁</span>
<span class="count">13</span>
</button>
<button type="button" class="favourite" aria-label="Favourite" title="Favourite">
<span></span>
</button>
<button type="button" class="react" aria-label="React" title="React">
<span>😃</span>
</button>
<button type="button" class="quote" aria-label="Quote" title="Quote">
<span>🗣️</span>
</button>
<button type="button" class="more" aria-label="More" title="More">
<span>🛠️</span>
</button>
</div>
</footer>
</article>
</div>
-->
</div>
</main>
</body>
</html>

View file

@ -1,302 +0,0 @@
const aria_safe_regex = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF]|[\r])/g;
const INSTANCE_URL = "soc.arimelody.me";
const sounds = {
"default": new Audio("sound/log.ogg"),
"post": new Audio("sound/success.ogg"),
"boost": new Audio("sound/hello.ogg"),
};
const actors = {
"@ari": {
"url": "https://soc.arimelody.me/@ari",
"name": "ari 💫",
"avatar": "avatar/ari.jpg",
}
};
const test_post = {
"context": {
"type": "boost",
"by": "@ari",
"at": 1718513838624,
},
"author": "@ari",
"url": "/post/21c892b23701",
"at": 1718513988384,
"content": "hello world!~",
"media": [
{ "url": "media/ariyeah-button.png", "alt": "custom miiverse \"yeah!\" button" },
{ "url": "media/beer.jpg", "alt": "barney calhoun with beer" },
{ "url": "media/duck.jpg", "alt": "big rubber duck" },
],
"replies": 7,
"boosts": 13,
"reactions": [
{ "react": "⭐", "count": "52" },
{ "react": "❤️", "count": "9" },
],
};
const feed = document.getElementById("feed");
function render_post(data) {
// TODO: please god just use or make a library to build this
const actor = actors[data.author];
if (!actor) return;
const date = new Date(data.at);
const post = document.createElement("article");
post.classList.add("post-container");
post.ariaLabel = actor.name.replace(aria_safe_regex, "").trim() + "; " + data.content + "; " + date.toLocaleTimeString();
if (data.context && data.context.by && actors[data.context.by]) {
// post context
const post_context = document.createElement("div");
post_context.classList.add("post-context");
if (data.context.type == "boost") {
const post_context_icon = document.createElement("span");
post_context_icon.classList.add("post-context-icon");
post_context_icon.innerText = "🔁";
post_context.appendChild(post_context_icon);
const post_context_action = document.createElement("span");
post_context_action.classList.add("post-context-action");
const actor = actors[data.context.by];
post_context_action.innerHTML = `boosted by <a href="${actor.url}">${actor.name}</a>`;
post_context.appendChild(post_context_action);
const post_context_time = document.createElement("span");
post_context_time.classList.add("post-context-time");
post_context_time.innerHTML = `<time>${new Date(data.context.at).toLocaleString()}</time>`;
post_context.appendChild(post_context_time);
}
post.appendChild(post_context);
}
// the actual post
// article.post
const post_article = document.createElement("article");
const post_header_container = document.createElement("div");
post_header_container.classList.add("post-header-container");
const post_avatar_container = document.createElement("a");
post_avatar_container.classList.add("post-avatar-container");
post_avatar_container.href = actor.url;
const post_avatar = document.createElement("img");
post_avatar.classList.add("post-avatar");
post_avatar.src = actor.avatar;
post_avatar.alt = "";
post_avatar.width = 48;
post_avatar.height = 48;
post_avatar.loading = "lazy";
post_avatar.decoding = "async";
post_avatar_container.appendChild(post_avatar);
post_header_container.appendChild(post_avatar_container);
const post_header = document.createElement("header");
post_header.classList.add("post-header");
const post_user_info = document.createElement("div");
post_user_info.classList.add("post-user-info");
const post_user_info_name = document.createElement("a");
post_user_info_name.classList.add("name");
post_user_info_name.href = actor.url;
post_user_info_name.innerText = actor.name
post_user_info.appendChild(post_user_info_name);
const post_user_info_username = document.createElement("span");
post_user_info_username.classList.add("username");
post_user_info_username.href = actor.url;
post_user_info_username.innerText = data.author
post_user_info.appendChild(post_user_info_username);
post_header.appendChild(post_user_info);
const post_info = document.createElement("div");
post_info.classList.add("post-info");
const post_info_time = document.createElement("a");
post_info_time.classList.add("created-at");
const post_date = new Date(data.at);
post_info_time.innerHTML = `<time title=${post_date.toLocaleString()}>${post_date.toLocaleString()}</time>`;
post_info_time.href = post.url;
post_info.appendChild(post_info_time);
post_header.appendChild(post_info);
post_header_container.appendChild(post_header);
post_article.appendChild(post_header_container);
const post_body = document.createElement("div");
post_body.classList.add("post-body");
const post_content = document.createElement("span");
post_content.classList.add("post-content");
post_content.innerText = data.content;
post_body.appendChild(post_content);
const media_container = document.createElement("div");
media_container.classList.add("post-media-container");
media_container.dataset.count = data.media.length;
data.media.forEach(media => {
const media_item = document.createElement("div");
media_item.classList.add("post-media");
const link = document.createElement("a");
link.href = media.url;
const source = document.createElement("img");
source.src = media.url;
source.alt = media.alt;
source.loading = "lazy";
source.decoding = "async";
link.appendChild(source);
media_item.appendChild(link);
media_container.appendChild(media_item);
});
post_body.appendChild(media_container);
post_article.appendChild(post_body);
const post_footer = document.createElement("footer");
post_footer.classList.add("post-footer");
const post_reactions = document.createElement("div");
post_reactions.classList.add("post-reactions");
data.reactions.forEach(reaction => {
const btn = document.createElement("button");
btn.classList.add("reaction");
btn.type = "button";
const emote = document.createElement("span");
emote.innerText = reaction.react;
btn.appendChild(emote);
const count = document.createElement("span");
count.classList.add("count");
count.innerText = reaction.count;
btn.appendChild(count);
post_reactions.appendChild(btn);
});
post_footer.appendChild(post_reactions);
const post_actions = document.createElement("div");
post_actions.classList.add("post-actions");
const reply_button = document.createElement("button");
reply_button.type = "button";
reply_button.ariaLabel = "Reply";
reply_button.title = "Reply";
reply_button.innerHTML = `<span>🗨️</span><span class="count">${data.replies}</count>`;
post_actions.appendChild(reply_button);
const boost_button = document.createElement("button");
boost_button.type = "button";
boost_button.ariaLabel = "Boost";
boost_button.title = "Boost";
boost_button.innerHTML = `<span>🔁</span><span class="count">${data.boosts}</count>`;
post_actions.appendChild(boost_button);
const fav_button = document.createElement("button");
fav_button.type = "button";
fav_button.ariaLabel = "Favourite";
fav_button.title = "Favourite";
fav_button.innerText = "⭐";
post_actions.appendChild(fav_button);
const react_button = document.createElement("button");
react_button.type = "button";
react_button.ariaLabel = "React";
react_button.title = "React";
react_button.innerText = "😃";
post_actions.appendChild(react_button);
const quote_button = document.createElement("button");
quote_button.type = "button";
quote_button.ariaLabel = "Quote";
quote_button.title = "Quote";
quote_button.innerText = "🗣️";
post_actions.appendChild(quote_button);
const more_button = document.createElement("button");
more_button.type = "button";
more_button.ariaLabel = "More";
more_button.title = "More";
more_button.innerText = "⚒️";
post_actions.appendChild(more_button);
post_footer.appendChild(post_actions);
post_article.appendChild(post_footer);
post.appendChild(post_article);
return post;
};
function hook_post_listeners(post) {
post.querySelectorAll("button").forEach(button => {
button.addEventListener("click", () => {
if (button.classList.contains("reaction")) {
toggle_reaction(button);
}
switch (button.ariaLabel) {
case "Reply":
play_sound("post");
break;
case "Boost":
play_sound("boost");
break;
case "Favourite":
post.querySelectorAll("button.reaction").forEach(reaction => {
if (!reaction.innerText.startsWith("⭐")) return;
toggle_reaction(reaction);
});
play_sound();
break;
default:
play_sound();
break;
}
});
});
}
function toggle_reaction(reaction) {
const was_active = reaction.classList.contains("active");
reaction.classList.toggle("active");
const count = reaction.querySelector(".count");
count.innerText = Number(count.innerText) + (was_active ? -1 : 1);
}
function load_content() {
for (let i = 0; i < 10; i++) {
const post = render_post(test_post);
feed.appendChild(post);
hook_post_listeners(post);
}
}
function play_sound(name) {
if (!name) name = "default";
const sound = sounds[name];
if (!sound) {
console.warn(`Attempted to play sound "${name}", which does not exist!`);
return;
}
sound.pause();
sound.currentTime = 0;
sound.play();
}
feed.querySelectorAll(".post-container").forEach(post => {
hook_post_listeners(post);
});
load_content();
document.addEventListener("scroll", event => {
while (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) {
load_content();
}
});

154
src/App.svelte Normal file
View file

@ -0,0 +1,154 @@
<script>
import Feed from './Feed.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
let ready = false;
if (localStorage.getItem("fedi_host") && localStorage.getItem("fedi_token")) {
Instance.setup(
localStorage.getItem("fedi_host"),
localStorage.getItem("fedi_token"),
true
).then(() => {
ready = true;
});
}
function log_in(event) {
event.preventDefault();
localStorage.setItem("fedi_host", event.target.instance_host.value);
localStorage.setItem("fedi_token", event.target.session_token.value);
location = location;
}
function log_out() {
localStorage.removeItem("fedi_host");
localStorage.removeItem("fedi_token");
location = location;
}
</script>
<header>
<h1>space social</h1>
<p>social media for the galaxy-wide-web! 🌌</p>
<button id="log-out" on:click={log_out}>log out</button>
</header>
<main>
{#if ready}
<Feed />
{:else if !Instance.get_instance().ok}
<Error>
<p>this app requires a <strong>instance host</strong> and <strong>session token</strong> to work! you may enter these below:</p>
<form on:submit={data => (log_in(data))}>
<label for="instance host">instance host: </label>
<input type="text" id="instance_host">
<br>
<label for="session token">session token: </label>
<input type="password" id="session_token">
<br>
<button type="submit" id="log-in">log in</button>
</form>
<hr>
<h4>how do i get these?</h4>
<ul>
<li>
<strong>instance host</strong> refers to the domain of your fediverse instance. i.e. <code>ice.arimelody.me</code>.
</li>
<li>
a <strong>token</strong> is a unique code that grants applications permission to act on your behalf.
you can find it in your browser's cookies for your instance.
(instructions for <a href="https://support.mozilla.org/en-US/questions/1219653">firefox</a>
and <a href="https://superuser.com/questions/1715037/how-can-i-view-the-content-of-cookies-in-chrome">chrome</a>)
</li>
</ul>
<p><small>
your login credentials will not be saved to an external server.
they are required for communication with the fediverse instance, and are saved entirely within your browser.
a cleaner login flow will be built in the future.
</small></p>
<p><small>
oh yeah i should also probably mention this is <strong><em>extremely experimental software</em></strong>;
even if you use the exact same instance as me, you may encounter problems.
if that's all cool with you, welcome aboard!
</small></p>
<p>made with ❤️ by <a href="https://arimelody.me">ari melody</a>, 2024</p>
</Error>
{/if}
</main>
<footer>
</footer>
<style>
header {
width: min(768px, calc(100vw - 32px));
margin: 16px auto;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
}
h1 {
color: var(--accent);
margin: 0 16px 0 0;
}
main {
width: min(800px, calc(100vw - 16px));
margin: 0 auto;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
input[type="text"], input[type="password"] {
margin-bottom: 8px;
padding: 4px 6px;
font-family: inherit;
border: none;
border-radius: 8px;
}
button#log-in, button#log-out {
margin-left: auto;
padding: 8px 12px;
font-size: 1em;
background-color: var(--bg2);
color: inherit;
border: none;
border-radius: 16px;
cursor: pointer;
transition: color .1s, background-color .1s;
}
button#log-in.active, button#log-out.active {
background: var(--accent);
color: var(--bg0);
}
button#log-in:hover, button#log-out:hover {
color: var(--bg0);
background: var(--fg0);
}
button#log-in:active, button#log-out:active {
background: #0001;
}
code {
font-size: 1.2em;
}
</style>

26
src/Error.svelte Normal file
View file

@ -0,0 +1,26 @@
<script>
export let msg = "";
export let trace = "";
</script>
<div class="error">
{#if msg}
<p class="msg">{@html msg}</p>
{/if}
{#if trace}
<pre class="trace">{trace}</pre>
{/if}
<slot></slot>
</div>
<style>
.error {
margin-top: 16px;
padding: 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
}
</style>

49
src/Feed.svelte Normal file
View file

@ -0,0 +1,49 @@
<script>
import Post from './post/Post.svelte';
import Error from './Error.svelte';
import Instance from './instance.js';
let posts = [];
let loading = false;
let error;
async function load_posts() {
if (loading) return; // no spamming!!
loading = true;
let new_posts = [];
if (posts.length === 0) new_posts = await Instance.get_timeline()
else new_posts = await Instance.get_timeline(posts[posts.length - 1].id);
if (!new_posts) {
error = `sorry! the frontend is unable to communicate with your server.
this app is still in very early development, and is currently only built to support iceshrimp.
for more information, please consult the developer console.`;
loading = false;
return;
}
posts = [...posts, ...new_posts];
loading = false;
}
load_posts();
document.addEventListener("scroll", event => {
if (!loading && window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
load_posts();
}
});
</script>
<div id="feed">
{#if error}
<Error msg={error.replaceAll('\n', '<br>')} />
{/if}
{#each posts as post}
<Post post={post} />
{/each}
</div>

20
src/app.css Normal file
View file

@ -0,0 +1,20 @@
@import url("/font/inter/inter.css");
:root {
--fg0: #eee;
--bg0: #080808;
--bg1: #101010;
--bg2: #121212;
--accent: #b7fd49;
--accent-bg: #242b1a;
}
body {
margin: 0;
padding: 0;
color: var(--fg0);
background-color: var(--bg0);
font-family: "Inter", sans-serif;
}

93
src/emoji.js Normal file
View file

@ -0,0 +1,93 @@
import Instance from './instance.js';
const EMOJI_REGEX = /:[a-z0-9_\-]+:/g;
let emoji_cache = [];
export default class Emoji {
name;
host;
url;
width;
height;
static parse(data, host) {
const instance = Instance.get_instance();
let emoji = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
emoji = Emoji.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
emoji = Emoji.#parse_mastodon(data);
break;
default:
break;
}
if (emoji !== null) emoji_cache.push(emoji);
return emoji;
}
static #parse_iceshrimp(data, host) {
let emoji = new Emoji()
emoji.name = data.name.substring(1, data.name.search('@'));
emoji.host = host;
emoji.url = data.url;
emoji.width = data.width;
emoji.height = data.height;
return emoji;
}
static #parse_mastodon(data, host) {
let emoji = new Emoji()
emoji.name = data.shortcode;
emoji.host = host;
emoji.url = data.url;
emoji.width = data.width;
emoji.height = data.height;
return emoji;
}
get id() {
return this.name + '@' + this.host;
}
}
export function parse_text(text, ignore_instance) {
if (!text) return text;
let index = text.search(EMOJI_REGEX);
if (index === -1) return text;
index++;
// find the emoji name
let length = 0;
while (index + length < text.length && text[index + length] !== ':') length++;
let emoji_name = ':' + text.substring(index, index + length) + ':';
// does this emoji exist?
let emoji;
for (let cached in emoji_cache) {
if (cached.id === emoji_name) {
emoji = cached;
break;
}
}
if (!emoji) return text.substring(0, index + length) + parse_text(text.substring(index + length));
// replace emoji code with <img>
const img = `<img src="${emoji.url}" class="emoji" width="26" height="26" title=":${emoji_name}:" alt="${emoji_name}">`;
return text.substring(0, index - 1) + img +
parse(text.substring(index + length + 1), emojis, ignore_instance);
}
export function parse_one(reaction, emojis) {
if (reaction == '❤') return '❤️'; // stupid heart unicode
if (!reaction.startsWith(':') || !reaction.endsWith(':')) return reaction;
for (let i = 0; i < emojis.length; i++) {
if (emojis[i].name == reaction.substring(1, reaction.length - 1))
return `<img src="${emojis[i].url}" class="emoji" width="26" height="26" title="${reaction}" alt="${emojis[i].name}">`;
}
return reaction;
}

107
src/instance.js Normal file
View file

@ -0,0 +1,107 @@
import Post from './post/post.js';
let instance;
const ERR_UNSUPPORTED = "Unsupported server";
const ERR_SERVER_RESPONSE = "Unsupported response from the server";
export default class Instance {
#host;
#token;
#type;
#secure;
static types = {
ICESHRIMP: "iceshrimp",
MASTODON: "mastodon",
MISSKEY: "misskey",
AKKOMA: "akkoma",
};
static get_instance() {
if (!instance) instance = new Instance();
return instance;
}
static async setup(host, token, secure) {
instance = Instance.get_instance();
instance.host = host;
instance.token = token;
instance.secure = secure;
await instance.#guess_type();
}
async #guess_type() {
const url = instance.#proto + instance.host + "/api/v1/instance";
console.log("Snooping for instance information at " + url + "...");
const res = await fetch(url);
const data = await res.json();
const version = data.version.toLowerCase();
instance.type = Instance.types.MASTODON;
if (version.search("iceshrimp") !== -1) instance.type = Instance.types.ICESHRIMP;
if (version.search("misskey") !== -1) instance.type = Instance.types.MISSKEY;
if (version.search("akkoma") !== -1) instance.type = Instance.types.AKKOMA;
console.log("Assumed server type to be \"" + instance.type + "\".");
}
static async get_timeline(last_post_id) {
let data = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
data = await instance.#get_timeline_iceshrimp(last_post_id);
break;
case Instance.types.MASTODON:
data = await instance.#get_timeline_mastodon(last_post_id);
break;
default:
console.error(ERR_UNSUPPORTED);
return null;
}
if (data.constructor != Array) {
console.error(ERR_SERVER_RESPONSE);
return null;
}
let posts = [];
data.forEach(post_data => {
const post = Post.parse(post_data);
if (!post) return;
posts = [...posts, post];
});
return posts;
}
async #get_timeline_iceshrimp(last_post_id) {
let body = Object;
if (last_post_id) body.untilId = last_post_id;
const res = await fetch(this.#proto + this.host + "/api/notes/timeline", {
method: 'POST',
headers: { "Authorization": "Bearer " + this.token },
body: JSON.stringify(body)
});
return await res.json();
}
async #get_timeline_mastodon(last_post_id) {
let url = this.#proto + this.host + "/api/v1/timelines/home";
if (last_post_id) url += "?max_id=" + last_post_id;
const res = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + this.token }
});
return await res.json();
}
get #proto() {
if (this.secure) return "https://";
return "http://";
}
static get ok() {
if (!instance) return false;
if (!instance.host) return false;
if (!instance.token) return false;
return true;
}
}

9
src/main.js Normal file
View file

@ -0,0 +1,9 @@
import './app.css';
import App from './App.svelte';
import Instance from './instance.js';
const app = new App({
target: document.getElementById('app')
});
export default app;

166
src/post/Body.svelte Normal file
View file

@ -0,0 +1,166 @@
<script>
export let post;
</script>
<div class="post-body">
{#if post.warning}
<p class="post-warning"><strong>{post.warning}</strong></p>
{/if}
{#if post.text}
<span class="post-text">{@html post.rich_text}</span>
{/if}
<div class="post-media-container" data-count={post.files.length}>
{#each post.files as file}
<div class="post-media image">
<a href={file.url}>
<img src={file.url} alt={file.alt} height="200" loading="lazy" decoding="async">
</a>
</div>
{/each}
</div>
{#if post.boost && post.text}
<p class="post-warning"><strong>this is quoting a post! quotes are not supported yet.</strong></p>
<!-- TODO: quotes support -->
{/if}
</div>
<style>
.post-body {
margin-top: 8px;
}
.post-warning {
padding: 4px 8px;
--warn-bg: rgba(255,220,30,.2);
background-image: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
border-radius: 8px;
}
.post-text {
word-wrap: break-word;
}
.post-text :global(code) {
font-size: 1.2em;
}
.post-text :global(code:has(pre)) {
margin: 8px 0;
padding: 8px;
display: block;
overflow-x: scroll;
border-radius: 8px;
background-color: #080808;
color: var(--accent);
}
.post-text :global(code pre) {
margin: 0;
}
.post-text :global(a) {
color: var(--accent);
}
.post-text :global(a.mention) {
color: var(--accent);
padding: 6px 6px;
margin: -6px 0;
background: var(--accent-bg);
border-radius: 6px;
text-decoration: none;
}
.post-text :global(a.mention:hover) {
text-decoration: underline;
}
.post-text :global(a.hashtag) {
background-color: transparent;
padding: 0;
font-style: italic;
}
.post-text :global(.mention-avatar) {
position: relative;
top: 4px;
height: 20px;
margin-right: 4px;
border-radius: 4px;
}
.post-media-container {
max-height: 540px;
margin-top: 8px;
display: grid;
grid-gap: 8px;
}
.post-media-container[data-count="1"] {
grid-template-rows: 1fr;
}
.post-media-container[data-count="2"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
.post-media-container[data-count="3"] {
grid-template-columns: 1fr .5fr;
grid-template-rows: 1fr 1fr;
}
.post-media-container[data-count="4"] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.post-media {
border-radius: 12px;
background-color: #000;
overflow: hidden;
}
.post-media a {
width: 100%;
height: 100%;
display: block;
cursor: zoom-in;
}
.post-media a img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.post-media-container > :nth-child(1) {
grid-column: 1/2;
grid-row: 1/2;
}
.post-media-container[data-count="3"] > :nth-child(1) {
grid-row: 1/3;
}
.post-media-container > :nth-child(2) {
grid-column: 2/2;
grid-row: 1/2;
}
.post-media-container > :nth-child(3) {
grid-column: 1/2;
grid-row: 2/2;
}
.post-media-container[data-count="3"] > :nth-child(3) {
grid-column: 2/2;
grid-row: 2/2;
}
.post-media-container > :nth-child(4) {
grid-column: 2/2;
grid-row: 2/2;
}
</style>

View file

@ -0,0 +1,52 @@
<script>
import { parse_text as parse_emojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<div class="post-context">
<span class="post-context-icon">🔁</span>
<span class="post-context-action">
<a href="/{post.user.mention}">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a> boosted this post.
</span>
<span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time>
</span>
</div>
<style>
.post-context {
margin-bottom: 8px;
padding-left: 58px;
display: flex;
flex-direction: row;
align-items: center;
color: var(--accent);
opacity: .8;
transition: opacity .1s;
}
.post-container:hover .post-context {
opacity: 1;
}
.post-context-icon {
margin-right: 4px;
}
.post-context a,
.post-context a:visited {
color: inherit;
text-decoration: none;
}
.post-context a:hover {
text-decoration: underline;
}
.post-context-time {
margin-left: auto;
}
</style>

View file

@ -0,0 +1,54 @@
<script>
import { play_sound } from '../sound.js';
export let icon = "🔧";
export let type = "action";
export let label = "Action";
export let title = label;
export let count = 0;
export let sound = "default";
</script>
<button
type="button"
class="{type}"
aria-label="{label}"
title="{title}"
on:click={() => (play_sound(sound))}>
<span>{@html icon}</span>
{#if count}
<span class="count">{count}</span>
{/if}
</button>
<style>
button {
padding: 6px 8px;
font-size: 1em;
background: none;
color: inherit;
border: none;
border-radius: 8px;
}
button.active {
background: var(--accent);
color: var(--bg0);
}
button:hover {
background: #8881;
}
button:active {
background: #0001;
}
.count {
opacity: .5;
}
button:hover .count {
opacity: 1;
}
</style>

79
src/post/Header.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import { parse_text as parse_emojis } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<div class="post-header-container">
<a href="/{post.user.mention}" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<header class="post-header">
<div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
<a href={post.url} class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
</a>
</div>
</header>
</div>
<style>
.post-header-container {
display: flex;
flex-direction: row;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-header-container a:hover {
text-decoration: underline;
}
.post-avatar-container {
margin-right: 12px;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info a {
display: block;
}
.post-user-info .name :global(.emoji) {
position: relative;
top: 4px;
height: 26px;
}
.post-user-info .username {
opacity: .5;
font-size: .9em;
}
.post-info .created-at {
font-size: .8em;
}
</style>

79
src/post/Post.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte';
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import { parse_one as parse_reaction } from '../emoji.js';
import { play_sound } from '../sound.js';
export let post;
let post_context = undefined;
let _post = post;
let is_boost = false;
if (_post.boost) {
is_boost = true;
post_context = _post;
_post = _post.boost;
}
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at;
</script>
<div class="post-container" aria-label={aria_label}>
{#if _post.reply}
<ReplyContext post={_post.reply} />
{/if}
{#if is_boost && !post_context.text}
<BoostContext post={post_context} />
{/if}
<article class="post">
<Header post={_post} />
<Body post={_post} />
<footer class="post-footer">
<div class="post-reactions">
{#each Object.keys(_post.reactions) as reaction}
<FooterButton icon={parse_reaction(reaction, _post.emojis)} type="reaction" bind:count={_post.reactions[reaction]} title={reaction} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={_post.reply_count} sound="post" />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={_post.boost_count} sound="boost" />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</article>
</div>
<style>
.post-container {
margin-top: 16px;
padding: 28px 32px 20px 32px;
border: 1px solid #8884;
border-radius: 16px;
background-color: var(--bg1);
transition: background-color .1s;
}
.post-container:hover {
background-color: var(--bg2);
}
.post-reactions {
margin-top: 8px;
}
.post-actions {
margin-top: 8px;
}
.post-container :global(.emoji) {
position: relative;
top: 6px;
height: 26px;
}
</style>

View file

@ -0,0 +1,130 @@
<script>
import Header from './Header.svelte';
import Body from './Body.svelte';
import FooterButton from './FooterButton.svelte';
import Post from './Post.svelte';
import { parse_text as parse_emojis, parse_one as parse_reaction } from '../emoji.js';
import { shorthand as short_time } from '../time.js';
export let post;
let time_string = post.created_at.toLocaleString();
</script>
<article class="post-reply">
<div class="post-reply-avatar-container">
<a href="/{post.user.mention}" class="post-avatar-container">
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<div class="line">
</div>
</div>
<div class="post-reply-main">
<div class="post-header-container">
<header class="post-header">
<div class="post-user-info">
<a href="/{post.user.mention}" class="name">{@html parse_emojis(post.user.name, post.user.emojis, true)}</a>
<span class="username">{post.user.mention}</span>
</div>
<div class="post-info">
<a href={post.url} class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
</a>
</div>
</header>
</div>
<Body post={post} />
<footer class="post-footer">
<div class="post-reactions">
{#each Object.keys(post.reactions) as reaction}
<FooterButton icon={parse_reaction(reaction, post.emojis)} type="reaction" bind:count={post.reactions[reaction]} title={reaction} label="" />
{/each}
</div>
<div class="post-actions">
<FooterButton icon="🗨️" type="reply" label="Reply" bind:count={post.reply_count} />
<FooterButton icon="🔁" type="boost" label="Boost" bind:count={post.boost_count} />
<FooterButton icon="⭐" type="favourite" label="Favourite" />
<FooterButton icon="😃" type="react" label="React" />
<FooterButton icon="🗣️" type="quote" label="Quote" />
<FooterButton icon="🛠️" type="more" label="More" />
</div>
</footer>
</div>
</article>
<style>
.post-reply {
padding-bottom: 24px;
display: flex;
flex-direction: row;
}
.post-reply-avatar-container {
margin-right: 12px;
margin-bottom: -24px;
}
.post-reply-avatar-container .line {
position: relative;
top: -4px;
left: -1px;
width: 50%;
height: calc(100% - 48px);
border-right: 2px solid #8888;
}
.post-reply-main {
flex-grow: 1;
}
.post-header-container {
display: flex;
flex-direction: row;
}
.post-header-container a,
.post-header-container a:visited {
color: inherit;
text-decoration: none;
}
.post-header-container a:hover {
text-decoration: underline;
}
.post-avatar {
border-radius: 8px;
box-shadow: 2px 2px #0004;
}
.post-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.post-info {
margin-left: auto;
}
.post-user-info a {
display: block;
}
.post-user-info .name :global(.emoji) {
position: relative;
top: 4px;
max-height: 1.25em;
}
.post-user-info .username {
opacity: .5;
font-size: .9em;
}
.post-info .created-at {
font-size: .8em;
}
</style>

220
src/post/post.js Normal file
View file

@ -0,0 +1,220 @@
import Instance from '../instance.js';
import User from '../user/user.js';
import { parse_one as parse_emoji } from '../emoji.js';
let post_cache = Object;
export default class Post {
id;
created_at;
user;
text;
warning;
boost_count;
reply_count;
mentions;
reactions;
emojis;
files;
url;
reply;
boost;
static resolve_id(id) {
return post_cache[id] || null;
}
static parse(data) {
const instance = Instance.get_instance();
let post = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
post = Post.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
post = Post.#parse_mastodon(data);
break;
default:
break;
}
if (!post) {
console.error("Error while parsing post data");
return null;
}
post_cache[post.id] = post;
return post;
}
static #parse_iceshrimp(data) {
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.createdAt);
post.user = User.parse(data.user);
post.text = data.text;
post.warning = data.cw;
post.boost_count = data.renoteCount;
post.reply_count = data.repliesCount;
post.mentions = data.mentions;
post.reactions = data.reactions;
post.emojis = data.emojis;
post.files = data.files;
post.url = data.url;
post.boost = data.renote ? Post.parse(data.renote) : null;
post.reply = data.reply ? Post.parse(data.reply) : null;
return post;
}
static #parse_mastodon(data) {
let post = new Post()
post.id = data.id;
post.created_at = new Date(data.created_at);
post.user = User.parse(data.account);
post.text = data.content;
post.warning = data.spoiler_text;
post.boost_count = data.reblogs_count;
post.reply_count = data.replies_count;
post.mentions = data.mentions;
post.reactions = data.reactions;
post.emojis = data.emojis;
post.files = data.media_attachments;
post.url = data.url;
post.boost = data.reblog ? Post.parse(data.reblog) : null;
post.reply = data.in_reply_to_id ? Post.resolve_id(data.in_reply_to_id) : null;
return post;
}
get rich_text() {
let text = this.text;
if (!text) return text;
const markdown_tokens = [
{ tag: "pre", token: "```" },
{ tag: "code", token: "`" },
{ tag: "strong", token: "**", regex: /\*{2}/g },
{ tag: "strong", token: "__" },
{ tag: "em", token: "*", regex: /\*/g },
{ tag: "em", token: "_" },
];
let response = "";
let current;
let index = 0;
while (index < text.length) {
let sample = text.substring(index);
let allow_new = !current || !current.nostack;
// handle newlines
if (allow_new && sample.startsWith('\n')) {
response += "<br>";
index++;
continue;
}
// handle mentions
if (allow_new && sample.match(/@[a-z0-9-_.]+@[a-z0-9-_.]+/g)) {
// find end of the mention
let length = 1;
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
length++; // skim the middle @
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
let mention = text.substring(index, index + length);
// attempt to resolve mention to a user
let user = User.resolve_mention(mention);
if (user) {
const out = `<a href="/${user.mention}" class="mention">` +
`<img src="${user.avatar_url}" class="mention-avatar" width="20" height="20">` +
"@" + user.name + "</a>";
if (current) current.text += out;
else response += out;
} else {
response += mention;
}
index += mention.length;
continue;
}
if (Instance.get_instance().type !== Instance.types.MASTODON) {
// handle links
if (allow_new && sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)) {
// get length of link
let length = text.substring(index).search(/\s|$/g);
let url = text.substring(index, index + length);
let out = `<a href="${url}">${url}</a>`;
if (current) current.text += out;
else response += out;
index += length;
continue;
}
}
// handle emojis
if (allow_new && sample.startsWith(':')) {
// lookahead to next invalid emoji character
let look = sample.substring(1).search(/[^a-zA-Z0-9-_.]/g) + 1;
// if it's ':', we can parse it
if (look !== 0 && sample[look] === ':') {
let emoji_code = sample.substring(0, look + 1);
let out = parse_emoji(emoji_code, this.emojis);
if (current) current.text += out;
else response += out;
index += emoji_code.length;
continue;
}
}
// handle markdown
// TODO: handle misskey-flavoured markdown
if (current) {
// try to pop stack
if (sample.startsWith(current.token)) {
index += current.token.length;
let out = `<${current.tag}>${current.text}</${current.tag}>`;
if (current.token === '```')
out = `<code><pre>${current.text}</pre></code>`;
if (current.parent) current.parent.text += out;
else response += out;
current = current.parent;
} else {
current.text += sample[0];
index++;
}
} else if (allow_new) {
// can we add to stack?
let pushed = false;
for (let i = 0; i < markdown_tokens.length; i++) {
let item = markdown_tokens[i];
if (sample.startsWith(item.token)) {
let new_current = {
token: item.token,
tag: item.tag,
text: "",
parent: current,
};
if (item.token === '```' || item.token === '`') new_current.nostack = true;
current = new_current;
pushed = true;
index += current.token.length;
break;
}
}
if (!pushed) {
response += sample[0];
index++;
}
}
}
// destroy the remaining stack
while (current) {
let out = current.token + current.text;
if (current.parent) current.parent.text += out;
else response += out;
current = current.parent;
}
return response;
}
}

17
src/sound.js Normal file
View file

@ -0,0 +1,17 @@
const sounds = {
"default": new Audio("sound/log.ogg"),
"post": new Audio("sound/success.ogg"),
"boost": new Audio("sound/hello.ogg"),
};
export function play_sound(name) {
if (!name) name = "default";
const sound = sounds[name];
if (!sound) {
console.warn(`Attempted to play sound "${name}", which does not exist!`);
return;
}
sound.pause();
sound.currentTime = 0;
sound.play();
}

23
src/time.js Normal file
View file

@ -0,0 +1,23 @@
const denoms = [
{ unit: 's', min: 0 },
{ unit: 'm', min: 60 },
{ unit: 'h', min: 60 },
{ unit: 'd', min: 24 },
{ unit: 'w', min: 7 },
{ unit: 'y', min: 52 },
];
export function shorthand(date) {
let value = (new Date() - date) / 1000;
let unit = 's';
let index = 0;
while (index < denoms.length - 1) {
if (value < denoms[index + 1].min) break;
index++
value /= denoms[index].min;
unit = denoms[index].unit;
}
if (value > 0)
return Math.floor(value) + unit + " ago";
return "in " + Math.floor(value) + unit;
}

83
src/user/user.js Normal file
View file

@ -0,0 +1,83 @@
import Instance from '../instance.js';
import Emoji from '../emoji.js';
let user_cache = Object;
export default class User {
id;
nickname;
username;
host;
avatar_url;
emojis;
static resolve_id(id) {
return user_cache[id];
}
static resolve_mention(mention) {
for (let i = 0; i < Object.keys(user_cache).length; i++) {
let user = user_cache[Object.keys(user_cache)[i]];
if (user.mention === mention) return user;
}
}
static parse(data) {
const instance = Instance.get_instance();
let user = null;
switch (instance.type) {
case Instance.types.ICESHRIMP:
user = User.#parse_iceshrimp(data);
break;
case Instance.types.MASTODON:
user = User.#parse_mastodon(data);
break;
default:
break;
}
if (!user) {
console.error("Error while parsing user data");
return null;
}
user_cache[user.id] = user;
return user;
}
static #parse_iceshrimp(data) {
let user = new User();
user.id = data.id;
user.nickname = data.name;
user.username = data.username;
user.host = data.host || Instance.get_instance().host;
user.avatar_url = data.avatarUrl;
user.emojis = [];
data.emojis.forEach(emoji => {
user.emojis.push(Emoji.parse(emoji, user.host));
});
return user;
}
static #parse_mastodon(data) {
let user = new User();
user.id = data.id;
user.nickname = data.display_name;
user.username = data.username;
user.host = data.acct.search('@') ? data.acct.substring(data.acct.search('@') + 1) : instance.host;
user.avatar_url = data.avatar;
user.emojis = [];
data.emojis.forEach(emoji => {
user.emojis.push(Emoji.parse(emoji, user.host));
});
return user;
}
get name() {
return this.nickname || this.username;
}
get mention() {
let res = "@" + this.username;
if (this.host) res += "@" + this.host;
return res;
}
}

7
svelte.config.js Normal file
View file

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

7
vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
})