federating my verse (iceshrimp & mastodon API compat, read-only)
This commit is contained in:
parent
8dc8190cdf
commit
da93978820
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
**/.DS_Store
|
||||||
|
node_modules/
|
||||||
|
dist/
|
13
index.html
Normal file
13
index.html
Normal 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
1185
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal 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 |
|
@ -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;
|
|
||||||
}
|
|
BIN
public/font/inter/Inter-Black.woff2
Normal file
BIN
public/font/inter/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-BlackItalic.woff2
Normal file
BIN
public/font/inter/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Bold.woff2
Normal file
BIN
public/font/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-BoldItalic.woff2
Normal file
BIN
public/font/inter/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-ExtraBold.woff2
Normal file
BIN
public/font/inter/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-ExtraBoldItalic.woff2
Normal file
BIN
public/font/inter/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-ExtraLight.woff2
Normal file
BIN
public/font/inter/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-ExtraLightItalic.woff2
Normal file
BIN
public/font/inter/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Italic.woff2
Normal file
BIN
public/font/inter/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Light.woff2
Normal file
BIN
public/font/inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-LightItalic.woff2
Normal file
BIN
public/font/inter/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Medium.woff2
Normal file
BIN
public/font/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-MediumItalic.woff2
Normal file
BIN
public/font/inter/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Regular.woff2
Normal file
BIN
public/font/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-SemiBold.woff2
Normal file
BIN
public/font/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-SemiBoldItalic.woff2
Normal file
BIN
public/font/inter/Inter-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-Thin.woff2
Normal file
BIN
public/font/inter/Inter-Thin.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/Inter-ThinItalic.woff2
Normal file
BIN
public/font/inter/Inter-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Black.woff2
Normal file
BIN
public/font/inter/InterDisplay-Black.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-BlackItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Bold.woff2
Normal file
BIN
public/font/inter/InterDisplay-Bold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-BoldItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-ExtraBold.woff2
Normal file
BIN
public/font/inter/InterDisplay-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-ExtraBoldItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-ExtraLight.woff2
Normal file
BIN
public/font/inter/InterDisplay-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-ExtraLightItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Italic.woff2
Normal file
BIN
public/font/inter/InterDisplay-Italic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Light.woff2
Normal file
BIN
public/font/inter/InterDisplay-Light.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-LightItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-LightItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Medium.woff2
Normal file
BIN
public/font/inter/InterDisplay-Medium.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-MediumItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Regular.woff2
Normal file
BIN
public/font/inter/InterDisplay-Regular.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-SemiBold.woff2
Normal file
BIN
public/font/inter/InterDisplay-SemiBold.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-SemiBoldItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-Thin.woff2
Normal file
BIN
public/font/inter/InterDisplay-Thin.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterDisplay-ThinItalic.woff2
Normal file
BIN
public/font/inter/InterDisplay-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterVariable-Italic.woff2
Normal file
BIN
public/font/inter/InterVariable-Italic.woff2
Normal file
Binary file not shown.
BIN
public/font/inter/InterVariable.woff2
Normal file
BIN
public/font/inter/InterVariable.woff2
Normal file
Binary file not shown.
92
public/font/inter/LICENSE.txt
Normal file
92
public/font/inter/LICENSE.txt
Normal 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.
|
57
public/font/inter/inter.css
Normal file
57
public/font/inter/inter.css
Normal 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"); }
|
|
@ -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 "yeah!" 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>
|
|
|
@ -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
154
src/App.svelte
Normal 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
26
src/Error.svelte
Normal 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
49
src/Feed.svelte
Normal 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
20
src/app.css
Normal 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
93
src/emoji.js
Normal 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
107
src/instance.js
Normal 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
9
src/main.js
Normal 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
166
src/post/Body.svelte
Normal 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>
|
52
src/post/BoostContext.svelte
Normal file
52
src/post/BoostContext.svelte
Normal 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>
|
54
src/post/FooterButton.svelte
Normal file
54
src/post/FooterButton.svelte
Normal 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
79
src/post/Header.svelte
Normal 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
79
src/post/Post.svelte
Normal 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>
|
130
src/post/ReplyContext.svelte
Normal file
130
src/post/ReplyContext.svelte
Normal 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
220
src/post/post.js
Normal 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
17
src/sound.js
Normal 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
23
src/time.js
Normal 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
83
src/user/user.js
Normal 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
7
svelte.config.js
Normal 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
7
vite.config.js
Normal 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()],
|
||||||
|
})
|
Loading…
Reference in a new issue