mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-21 16:55:25 +03:00
Initial commit
This commit is contained in:
commit
2b9390a0a1
53 changed files with 9135 additions and 0 deletions
2
.env
Normal file
2
.env
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_CLIENT_NAME=Phanpy
|
||||||
|
VITE_WEBSITE=https://phanpy.social
|
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Custom
|
||||||
|
.env.dev
|
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"importOrder": [".css$", "<THIRD_PARTY_MODULES>", "^../", "^[./]"],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true,
|
||||||
|
"importOrderGroupNamespaceSpecifiers": true,
|
||||||
|
"importOrderCaseInsensitive": true
|
||||||
|
}
|
85
README.md
Normal file
85
README.md
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
<div align="center">
|
||||||
|
<img src="design/logo-2.svg" width="128" height="128" alt="">
|
||||||
|
|
||||||
|
Phanpy
|
||||||
|
===
|
||||||
|
|
||||||
|
**Minimalistic opinionated Mastodon web client.**
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
This is an alternative web client for [Mastodon](https://joinmastodon.org/).
|
||||||
|
|
||||||
|
Everything is designed and engineered for my own use case, following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas.
|
||||||
|
|
||||||
|
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
||||||
|
|
||||||
|
**🐘 This is an early ALPHA project. Many features are missing, many bugs are present. Please report issues as detailed as possible. Thanks 🙏**
|
||||||
|
|
||||||
|
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Minimalistic UI
|
||||||
|
- Accounts switching
|
||||||
|
- Theme switching (light, dark, auto)
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here.
|
||||||
|
- **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple.
|
||||||
|
- **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.
|
||||||
|
- **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Prerequisites: Node.js 18+
|
||||||
|
|
||||||
|
- `npm install` - Install dependencies
|
||||||
|
- `npm run dev` - Start development server
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run preview` - Preview the production build
|
||||||
|
- `npm run fetch-instances` - Fetch instances list from [instances.social](https://instances.social/), save it to `src/data/instances.json`
|
||||||
|
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- [Vite](https://vitejs.dev/) - Build tool
|
||||||
|
- [Preact](https://preactjs.com/) - UI library
|
||||||
|
- [Valtio](https://valtio.pmnd.rs/) - State management
|
||||||
|
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||||
|
- [Iconify](https://iconify.design/) - Icon library
|
||||||
|
- Vanilla CSS - *Yes, I'm old school.*
|
||||||
|
|
||||||
|
Some of these may change in the future. The front-end world is ever-changing.
|
||||||
|
|
||||||
|
## Mascot
|
||||||
|
|
||||||
|
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
|
||||||
|
|
||||||
|
## Maintainers
|
||||||
|
|
||||||
|
- [Chee Aun](https://github.com/cheeaun) ([Mastodon](https://mastodon.social/@cheeaun)) ([Twitter](https://twitter.com/cheeaun))
|
||||||
|
|
||||||
|
## Backstory
|
||||||
|
|
||||||
|
I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006.
|
||||||
|
|
||||||
|
I know how early Twitter looks like. It was fun.
|
||||||
|
|
||||||
|
Back then, I [made a Twitter clone](https://twitter.com/cheeaun/status/789031599) called "Twig" written in Python and Google App Engine. I almost made my own [Twitter desktop client](https://github.com/cheeaun/chidori) written in Appcelerator Titanium. I [gave one of my best talks about the Twitter client](https://www.slideshare.net/cheeaun/story-of-a-thousand-birds) in a mini-conference. I built this thing called "Twitter [Columns](https://twitter.com/columns)", a web app that shows your list of followings, your followings' followings, your followers, your followers' followers and so on. In 2009, I wrote a blog post titled ["How I got started with Twitter"](https://cheeaun.com/blog/2009/04/how-i-got-started-with-twitter/). I created [two](https://twitter.com/cheeaun/status/1273422454) [themes](https://twitter.com/cheeaun/status/1487781343) for DestroyTwitter (a desktop client made with Adobe Air by Jonnie Hallman) and one of them is called ["Vimeo"](https://dribbble.com/shots/31624). In 2013, I wrote [my own tweets backup site](https://github.com/cheeaun/tweets) with a front-end to view my tweets and a [CouchDB backend](https://github.com/cheeaun/tweet-couch) to store them.
|
||||||
|
|
||||||
|
It's been **more than 15 years**.
|
||||||
|
|
||||||
|
And here I am. Building a Mastodon web client.
|
||||||
|
|
||||||
|
## Alternative clients
|
||||||
|
|
||||||
|
- [Pinafore](https://pinafore.social/)
|
||||||
|
- [Elk](https://m.webtoo.ls/@elk)
|
||||||
|
- [More...](https://github.com/tleb/awesome-mastodon#clients)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT]([LICENSE](https://cheeaun.mit-license.org/)).
|
BIN
design/logo-2-avatar.png
Normal file
BIN
design/logo-2-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
BIN
design/logo-2.png
Normal file
BIN
design/logo-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
1
design/logo-2.svg
Normal file
1
design/logo-2.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 6.7 KiB |
BIN
design/logo.afdesign
Normal file
BIN
design/logo.afdesign
Normal file
Binary file not shown.
BIN
design/logo.png
Normal file
BIN
design/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
39
design/logo.svg
Normal file
39
design/logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 191 KiB |
15
index.html
Normal file
15
index.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Phanpy</title>
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="modal-container"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
3785
package-lock.json
generated
Normal file
3785
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "phanpy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"fetch-instances": "env $(cat .env.dev | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@github/text-expander-element": "~2.3.0",
|
||||||
|
"@github/time-elements": "~4.0.0",
|
||||||
|
"autoprefixer": "~10.4.13",
|
||||||
|
"fast-blurhash": "~1.1.2",
|
||||||
|
"history": "~5.3.0",
|
||||||
|
"iconify-icon": "~1.0.2",
|
||||||
|
"masto": "~4.9.1",
|
||||||
|
"mem": "~9.0.2",
|
||||||
|
"preact": "~10.11.3",
|
||||||
|
"preact-router": "~4.1.0",
|
||||||
|
"react-intersection-observer": "~9.4.1",
|
||||||
|
"valtio": "~1.7.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "~2.4.0",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
||||||
|
"postcss": "~8.4.19",
|
||||||
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
|
"vite": "3.2.5"
|
||||||
|
},
|
||||||
|
"postcss": {
|
||||||
|
"plugins": {
|
||||||
|
"postcss-dark-theme-class": {},
|
||||||
|
"autoprefixer": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 8 8" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><rect id="Artboard1" x="0" y="0" width="8" height="8" style="fill:none;"/><g id="Artboard11" serif:id="Artboard1"><path d="M7,2.5c-0.227,-0.681 -0.839,-1.28 -1.5,-1.5c-0.75,-0.25 -2.25,-0.25 -3,0c-0.661,0.22 -1.273,0.819 -1.5,1.5c-0.25,0.75 -0.25,2.25 -0,3c0.227,0.681 0.839,1.28 1.5,1.5c0.75,0.25 2.25,0.25 3,0c0.661,-0.22 1.273,-0.819 1.5,-1.5c0.25,-0.75 0.25,-2.25 0,-3Z" style="fill:#d8e7fe;stroke:#a4bff7;stroke-width:1px;"/><path d="M4.766,3c0.154,0.522 0.292,1.28 0.307,2" style="fill:none;stroke:#6892e2;stroke-width:1px;"/><path d="M3.27,3c-0.154,0.522 -0.292,1.28 -0.307,2" style="fill:none;stroke:#6892e2;stroke-width:1px;"/></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
23
scripts/fetch-instances-list.js
Normal file
23
scripts/fetch-instances-list.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
count: 200,
|
||||||
|
sort_by: 'active_users',
|
||||||
|
sort_order: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://instances.social/api/1.0/instances/list?${params.toString()}`;
|
||||||
|
const results = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${INSTANCES_SOCIAL_SECRET_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await results.json();
|
||||||
|
const names = json.instances.map((instance) => instance.name);
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
const path = './src/data/instances.json';
|
||||||
|
fs.writeFileSync(path, JSON.stringify(names, null, '\t'), 'utf8');
|
433
src/app.css
Normal file
433
src/app.css
Normal file
|
@ -0,0 +1,433 @@
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
transition: opacity 1s ease-in-out;
|
||||||
|
}
|
||||||
|
.deck-container[hidden] {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
content-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-container,
|
||||||
|
.deck.contained {
|
||||||
|
scroll-padding-top: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: auto;
|
||||||
|
width: 40em;
|
||||||
|
max-width: 100vw;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
.deck.contained {
|
||||||
|
overflow: auto;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck header {
|
||||||
|
min-height: 4em;
|
||||||
|
padding: 0 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.deck header > *:not(button) {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
.deck header > .header-side:last-of-type {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.deck header h1 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.deck header h1:first-child {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
.deck h2 {
|
||||||
|
font-size: 1.45em;
|
||||||
|
}
|
||||||
|
.deck.padded-bottom .timeline li:last-child {
|
||||||
|
padding-bottom: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.timeline li {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
.timeline.flat li {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
/* .timeline li.insignificant {
|
||||||
|
filter: opacity(0.5);
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
}
|
||||||
|
.timeline li.insignificant > * {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.timeline li.insignificant:hover > * {
|
||||||
|
opacity: 1;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.timeline.contextual li {
|
||||||
|
--width: 3px;
|
||||||
|
--left: 40px;
|
||||||
|
--right: calc(var(--left) + var(--width));
|
||||||
|
background-image: linear-gradient(to right, transparent, transparent var(--left), var(--comment-line-color) var(--left), var(--comment-line-color) var(--right), transparent var(--right), transparent);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.timeline.contextual li:first-child {
|
||||||
|
background-position: 0 16px;
|
||||||
|
}
|
||||||
|
.timeline.contextual li:last-child {
|
||||||
|
background-size: 100% 20px;
|
||||||
|
}
|
||||||
|
.timeline.contextual li.descendant {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.timeline.contextual li.descendant.indirect:before {
|
||||||
|
--radius: 10px;
|
||||||
|
--diameter: calc(var(--radius) * 2);
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 40px;
|
||||||
|
width: var(--diameter);
|
||||||
|
height: var(--diameter);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: var(--width);
|
||||||
|
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.timeline.contextual li.descendant.indirect .status-link {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-deck {
|
||||||
|
padding-bottom: 100px; /* faux nav height */
|
||||||
|
}
|
||||||
|
.timeline-deck.compact .status {
|
||||||
|
max-height: max(25vh, 160px);
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 80%, transparent 95%);
|
||||||
|
}
|
||||||
|
.timeline-deck.compact .status .meta ~ * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
padding: 0 16px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-empty {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
padding: 0 16px;
|
||||||
|
margin-bottom: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration-line: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background-color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.status-link:hover {
|
||||||
|
background-color: var(--link-bg-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-state {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--backdrop-color);
|
||||||
|
}
|
||||||
|
.deck-backdrop > a {
|
||||||
|
flex-grow: 1;
|
||||||
|
backdrop-filter: saturate(.75);
|
||||||
|
}
|
||||||
|
@keyframes slide-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.deck-backdrop .deck {
|
||||||
|
width: 40em;
|
||||||
|
max-width: 100vw;
|
||||||
|
border-left: 1px solid var(--divider-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
animation: slide-in 0.2s ease-out;
|
||||||
|
box-shadow: -1px 0 var(--bg-color);
|
||||||
|
}
|
||||||
|
.deck-backdrop .deck .status {
|
||||||
|
max-width: 40em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decks {
|
||||||
|
flex-grow: 1;
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-close {
|
||||||
|
color: var(--text-insignificant-color) !important;
|
||||||
|
}
|
||||||
|
.deck-close:hover {
|
||||||
|
color: var(--text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(button, .button).plain.has-badge:after {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--link-color);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-from-top {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.updates-button {
|
||||||
|
position: absolute;
|
||||||
|
animation: fade-from-top 2s ease-out;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOX */
|
||||||
|
|
||||||
|
.box {
|
||||||
|
width: 40em;
|
||||||
|
max-width: 100vw;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-shadow {
|
||||||
|
box-shadow: 0px 36px 89px rgb(0 0 0 / 4%), 0px 23.3333px 52.1227px rgb(0 0 0 / 3%), 0px 13.8667px 28.3481px rgb(0 0 0 / 2%), 0px 7.2px 14.4625px rgb(0 0 0 / 2%), 0px 2.93333px 7.25185px rgb(0 0 0 / 2%), 0px 0.666667px 3.50231px rgb(0 0 0 / 1%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CAROUSEL */
|
||||||
|
/* use snap, center children, max width viewport */
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
/* scroll-behavior: smooth; */
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.carousel::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.carousel > * {
|
||||||
|
scroll-snap-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.carousel > * :is(img, video) {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-controls {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
button.carousel-button {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
button.carousel-button[hidden] {
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.carousel-dots {
|
||||||
|
border-radius: 999px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
button.carousel-dot {
|
||||||
|
color: var(--text-insignificant-color) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
button.carousel-dot:hover,
|
||||||
|
button.carousel-dot.active {
|
||||||
|
color: var(--link-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* COMPOSE BUTTON */
|
||||||
|
|
||||||
|
#compose-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 0 32px var(--bg-color);
|
||||||
|
z-index: 1;
|
||||||
|
outline: 1px solid var(--bg-color);
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SHEET */
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet {
|
||||||
|
align-self: flex-end;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(40em - 50px - 16px);
|
||||||
|
border-radius: 36px 36px 0 0;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 -1px 32px var(--divider-color);
|
||||||
|
animation: slide-up 0.2s ease-out;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TAG */
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: smaller;
|
||||||
|
color: var(--bg-faded-color);
|
||||||
|
background-color: var(--text-insignificant-color);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.decks:has(~ .deck-backdrop) {
|
||||||
|
transform: translateX(-5vw);
|
||||||
|
}
|
||||||
|
.deck-backdrop .deck {
|
||||||
|
width: 50%;
|
||||||
|
min-width: 40em;
|
||||||
|
}
|
||||||
|
.timeline-deck {
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.timeline-deck header {
|
||||||
|
min-height: 6em;
|
||||||
|
border-bottom: 0;
|
||||||
|
background-color: var(--bg-faded-blur-color);
|
||||||
|
border-bottom: 0;
|
||||||
|
mask-image: linear-gradient(rgba(0, 0, 0, 1) 50%, rgba(0, 0, 0, .7) 80%, rgba(0, 0, 0, .5) 90%, transparent);
|
||||||
|
}
|
||||||
|
.deck header h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.timeline-deck .timeline:not(.flat) li {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
margin: 16px 0;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0px 1px var(--bg-blur-color);
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
}
|
306
src/app.jsx
Normal file
306
src/app.jsx
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
|
import { createHashHistory } from 'history';
|
||||||
|
import { login } from 'masto/fetch';
|
||||||
|
import Router from 'preact-router';
|
||||||
|
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Account from './components/account';
|
||||||
|
import Compose from './components/compose';
|
||||||
|
import Icon from './components/icon';
|
||||||
|
import Loader from './components/loader';
|
||||||
|
import Modal from './components/modal';
|
||||||
|
import Home from './pages/home';
|
||||||
|
import Login from './pages/login';
|
||||||
|
import Notifications from './pages/notifications';
|
||||||
|
import Settings from './pages/settings';
|
||||||
|
import Status from './pages/status';
|
||||||
|
import Welcome from './pages/welcome';
|
||||||
|
import { getAccessToken } from './utils/auth';
|
||||||
|
import states from './utils/states';
|
||||||
|
import store from './utils/store';
|
||||||
|
|
||||||
|
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||||
|
|
||||||
|
window._STATES = states;
|
||||||
|
|
||||||
|
async function startStream() {
|
||||||
|
const stream = await masto.stream.streamUser();
|
||||||
|
console.log('STREAM START', { stream });
|
||||||
|
stream.on('update', (status) => {
|
||||||
|
console.log('UPDATE', status);
|
||||||
|
|
||||||
|
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
||||||
|
const inHome = states.home.find((s) => s.id === status.id);
|
||||||
|
if (!inHomeNew && !inHome) {
|
||||||
|
states.homeNew.unshift({
|
||||||
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on('status.update', (status) => {
|
||||||
|
console.log('STATUS.UPDATE', status);
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on('delete', (statusID) => {
|
||||||
|
console.log('DELETE', statusID);
|
||||||
|
states.statuses.delete(statusID);
|
||||||
|
});
|
||||||
|
stream.on('notification', (notification) => {
|
||||||
|
console.log('NOTIFICATION', notification);
|
||||||
|
|
||||||
|
const inNotificationsNew = states.notificationsNew.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
const inNotifications = states.notifications.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
|
states.notificationsNew.unshift(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.status && !states.statuses.has(notification.status.id)) {
|
||||||
|
states.statuses.set(notification.status.id, notification.status);
|
||||||
|
if (
|
||||||
|
notification.status.reblog &&
|
||||||
|
!states.statuses.has(notification.status.reblog.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.reblog.id,
|
||||||
|
notification.status.reblog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.ws.onclose = () => {
|
||||||
|
console.log('STREAM CLOSED!');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
startStream();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
stopStream: () => {
|
||||||
|
stream.ws.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const theme = store.local.get('theme');
|
||||||
|
if (theme) {
|
||||||
|
document.documentElement.classList.add(`is-${theme}`);
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="color-scheme"]')
|
||||||
|
.setAttribute('content', theme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const instanceURL = store.local.get('instanceURL');
|
||||||
|
const accounts = store.local.getJSON('accounts') || [];
|
||||||
|
const code = (window.location.search.match(/code=([^&]+)/) || [])[1];
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
console.log({ code });
|
||||||
|
// Clear the code from the URL
|
||||||
|
window.history.replaceState({}, document.title, '/');
|
||||||
|
|
||||||
|
const clientID = store.session.get('clientID');
|
||||||
|
const clientSecret = store.session.get('clientSecret');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setUIState('loading');
|
||||||
|
const tokenJSON = await getAccessToken({
|
||||||
|
instanceURL,
|
||||||
|
client_id: clientID,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
const { access_token: accessToken } = tokenJSON;
|
||||||
|
store.session.set('accessToken', accessToken);
|
||||||
|
|
||||||
|
window.masto = await login({
|
||||||
|
url: `https://${instanceURL}`,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mastoAccount = await masto.accounts.verifyCredentials();
|
||||||
|
|
||||||
|
console.log({ tokenJSON, mastoAccount });
|
||||||
|
|
||||||
|
let account = accounts.find((a) => a.info.id === mastoAccount.id);
|
||||||
|
if (account) {
|
||||||
|
account.info = mastoAccount;
|
||||||
|
account.instanceURL = instanceURL;
|
||||||
|
account.accessToken = accessToken;
|
||||||
|
} else {
|
||||||
|
account = {
|
||||||
|
info: mastoAccount,
|
||||||
|
instanceURL,
|
||||||
|
accessToken,
|
||||||
|
};
|
||||||
|
accounts.push(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
setUIState('default');
|
||||||
|
})();
|
||||||
|
} else if (accounts.length) {
|
||||||
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
const account =
|
||||||
|
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
|
||||||
|
const instanceURL = account.instanceURL;
|
||||||
|
const accessToken = account.accessToken;
|
||||||
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setUIState('loading');
|
||||||
|
window.masto = await login({
|
||||||
|
url: `https://${instanceURL}`,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
}
|
||||||
|
setUIState('default');
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [currentDeck, setCurrentDeck] = useState('home');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// HACK: prevent this from running again due to HMR
|
||||||
|
if (states.init) return;
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
startStream();
|
||||||
|
|
||||||
|
// Collect instance info
|
||||||
|
(async () => {
|
||||||
|
const info = await masto.instances.fetch();
|
||||||
|
console.log(info);
|
||||||
|
const { uri } = info;
|
||||||
|
const instances = store.local.getJSON('instances') || {};
|
||||||
|
instances[uri] = info;
|
||||||
|
store.local.setJSON('instances', instances);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
states.init = true;
|
||||||
|
}
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoggedIn && currentDeck && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="compose-button"
|
||||||
|
onClick={() => (states.showCompose = true)}
|
||||||
|
>
|
||||||
|
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||||
|
</button>
|
||||||
|
<div class="decks">
|
||||||
|
{/* Home will never be unmounted */}
|
||||||
|
<Home hidden={currentDeck !== 'home'} />
|
||||||
|
{/* Notifications can be unmounted */}
|
||||||
|
{currentDeck === 'notifications' && <Notifications />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isLoggedIn && uiState === 'loading' && <Loader />}
|
||||||
|
<Router
|
||||||
|
history={createHashHistory()}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Special handling for Home and Notifications
|
||||||
|
const { url } = e;
|
||||||
|
if (/notifications/i.test(url)) {
|
||||||
|
setCurrentDeck('notifications');
|
||||||
|
} else if (url === '/') {
|
||||||
|
setCurrentDeck('home');
|
||||||
|
document.title = `Home / ${CLIENT_NAME}}`;
|
||||||
|
} else if (url === '/login' || url === '/welcome') {
|
||||||
|
setCurrentDeck(null);
|
||||||
|
}
|
||||||
|
states.history.push(url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />}
|
||||||
|
<Welcome path="/welcome" />
|
||||||
|
{isLoggedIn && <Status path="/s/:id" />}
|
||||||
|
<Login path="/login" />
|
||||||
|
</Router>
|
||||||
|
{!!snapStates.showCompose && (
|
||||||
|
<Modal>
|
||||||
|
<Compose
|
||||||
|
replyToStatus={
|
||||||
|
typeof snapStates.showCompose !== 'boolean'
|
||||||
|
? snapStates.showCompose.replyToStatus
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={(result) => {
|
||||||
|
states.showCompose = false;
|
||||||
|
if (result) {
|
||||||
|
states.reloadStatusPage++;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{!!snapStates.showSettings && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
states.showSettings = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings
|
||||||
|
onClose={() => {
|
||||||
|
states.showSettings = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{!!snapStates.showAccount && (
|
||||||
|
<Modal
|
||||||
|
class="light"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
states.showAccount = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Account account={snapStates.showAccount} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
src/components/account.css
Normal file
30
src/components/account.css
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
#account-container.skeleton {
|
||||||
|
color: var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-container header{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-container .note {
|
||||||
|
font-size: 95%;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-container .stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-container .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 2.5em;
|
||||||
|
}
|
||||||
|
#account-container .actions button {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
167
src/components/account.jsx
Normal file
167
src/components/account.jsx
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import './account.css';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
|
||||||
|
import Avatar from './avatar';
|
||||||
|
import NameText from './name-text';
|
||||||
|
|
||||||
|
export default ({ account }) => {
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const isString = typeof account === 'string';
|
||||||
|
const [info, setInfo] = useState(isString ? null : account);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isString) {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const info = await masto.accounts.lookup({
|
||||||
|
acct: account,
|
||||||
|
skip_webfinger: false,
|
||||||
|
});
|
||||||
|
setInfo(info);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
acct,
|
||||||
|
avatar,
|
||||||
|
avatarStatic,
|
||||||
|
bot,
|
||||||
|
createdAt,
|
||||||
|
displayName,
|
||||||
|
emojis,
|
||||||
|
fields,
|
||||||
|
followersCount,
|
||||||
|
followingCount,
|
||||||
|
group,
|
||||||
|
header,
|
||||||
|
headerStatic,
|
||||||
|
id,
|
||||||
|
lastStatusAt,
|
||||||
|
locked,
|
||||||
|
note,
|
||||||
|
statusesCount,
|
||||||
|
url,
|
||||||
|
username,
|
||||||
|
} = info || {};
|
||||||
|
|
||||||
|
const [relationshipUIState, setRelationshipUIState] = useState('default');
|
||||||
|
const [relationship, setRelationship] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (info) {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const relationships = await masto.accounts.fetchRelationships([id]);
|
||||||
|
console.log('fetched relationship', relationships);
|
||||||
|
if (relationships.length) {
|
||||||
|
setRelationship(relationships[0]);
|
||||||
|
}
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [info]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
following,
|
||||||
|
showingReblogs,
|
||||||
|
notifying,
|
||||||
|
followedBy,
|
||||||
|
blocking,
|
||||||
|
blockedBy,
|
||||||
|
muting,
|
||||||
|
mutingNotifications,
|
||||||
|
requested,
|
||||||
|
domainBlocking,
|
||||||
|
endorsed,
|
||||||
|
} = relationship || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="account-container"
|
||||||
|
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
|
||||||
|
>
|
||||||
|
{!info || uiState === 'loading' ? (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<Avatar size="xxl" />
|
||||||
|
███ ████████████
|
||||||
|
</header>
|
||||||
|
<div class="note">
|
||||||
|
<p>████████ ███████</p>
|
||||||
|
<p>███████████████ ███████████████</p>
|
||||||
|
</div>
|
||||||
|
<p class="stats">
|
||||||
|
<span>██ Posts</span>
|
||||||
|
<span>██ Following</span>
|
||||||
|
<span>██ Followers</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<Avatar url={avatar} size="xxl" />
|
||||||
|
<NameText account={info} showAcct external />
|
||||||
|
</header>
|
||||||
|
<div class="note" dangerouslySetInnerHTML={{ __html: note }} />
|
||||||
|
<p class="stats">
|
||||||
|
<span>
|
||||||
|
<b title={statusesCount}>{shortenNumber(statusesCount)}</b> Posts
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b title={followingCount}>{shortenNumber(followingCount)}</b>{' '}
|
||||||
|
Following
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b title={followersCount}>{shortenNumber(followersCount)}</b>{' '}
|
||||||
|
Followers
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="actions">
|
||||||
|
{followedBy ? <span class="tag">Following you</span> : <span />}{' '}
|
||||||
|
{relationshipUIState !== 'loading' && relationship && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={following ? 'light' : ''}
|
||||||
|
disabled={relationshipUIState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let newRelationship;
|
||||||
|
if (following) {
|
||||||
|
newRelationship = await masto.accounts.unfollow(id);
|
||||||
|
} else {
|
||||||
|
newRelationship = await masto.accounts.follow(id);
|
||||||
|
}
|
||||||
|
setRelationship(newRelationship);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{following ? 'Unfollow' : 'Follow'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
17
src/components/avatar.css
Normal file
17
src/components/avatar.css
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
.avatar {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: var(--img-bg-color);
|
||||||
|
}
|
26
src/components/avatar.jsx
Normal file
26
src/components/avatar.jsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import './avatar.css';
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
s: 16,
|
||||||
|
m: 20,
|
||||||
|
l: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ url, size, alt = '' }) => {
|
||||||
|
size = SIZES[size] || size || SIZES.m;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class="avatar"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!!url && (
|
||||||
|
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
232
src/components/compose.css
Normal file
232
src/components/compose.css
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
#compose-container {
|
||||||
|
width: 40em;
|
||||||
|
max-width: 100vw;
|
||||||
|
align-self: stretch;
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container .compose-top {
|
||||||
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container .close-button {
|
||||||
|
padding: 6px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container textarea{
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 3em;
|
||||||
|
min-height: 3em;
|
||||||
|
max-height: 10em;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
@keyframes appear-up {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#compose-container .reply-to {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
max-height: 160px;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: saturate(0.25) opacity(0.75);
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
margin: 0 12px;
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
border-bottom: 0;
|
||||||
|
/* box-shadow: 0 0 12px var(--divider-color); */
|
||||||
|
/* mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 90%, transparent); */
|
||||||
|
animation: appear-up 1s ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes appear-down {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-2em);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#compose-container form {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-image: linear-gradient(var(--bg-color) 75%, transparent);
|
||||||
|
/* outline: 1px solid var(--outline-color); */
|
||||||
|
/* box-shadow: 0 0 12px var(--divider-color); */
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
#compose-container .reply-to ~ form {
|
||||||
|
animation: appear-down 1s ease-in-out;
|
||||||
|
box-shadow: 0 -12px 12px -12px var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container .toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar.stretch {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar .spoiler-text-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 2.5em;
|
||||||
|
line-height: 2.5em;
|
||||||
|
min-width: 2.5em;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button > * {
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:has([disabled]) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:has([disabled]) > * {
|
||||||
|
filter: opacity(0.3);
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:not(.show-field) :is(input[type="checkbox"], select, input[type="file"]) {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button input[type="file"] {
|
||||||
|
/* Move this out of the way, to fix cursor: pointer bug */
|
||||||
|
left: -100vw !important;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button select {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:not(.show-field) select {
|
||||||
|
right: 0;
|
||||||
|
left: auto !important;
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:hover {
|
||||||
|
outline: 2px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
#compose-container .toolbar-button:active {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container text-expander {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#compose-container .text-expander-menu {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
position: absolute;
|
||||||
|
margin: 0 0 0 -8px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
/* box-shadow: 0 0 12px var(--outline-color); */
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
top: 0 !important;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 50vw;
|
||||||
|
}
|
||||||
|
#compose-container .text-expander-menu li {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
#compose-container .text-expander-menu li img {
|
||||||
|
/* The shortcode emojis */
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
#compose-container .text-expander-menu li .avatar {
|
||||||
|
width: 2.2em;
|
||||||
|
height: 2.2em;
|
||||||
|
}
|
||||||
|
#compose-container .text-expander-menu li:hover {
|
||||||
|
color: var(--bg-color);
|
||||||
|
background-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#compose-container .media-attachments {
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
#compose-container .media-attachment {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
#compose-container .media-preview {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
#compose-container .media-preview > *{
|
||||||
|
min-width: 80px;
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
background-color: var(--img-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
#compose-container .media-attachment textarea {
|
||||||
|
height: 80px;
|
||||||
|
flex-grow: 1;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
#compose-container .media-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#compose-container .media-aside .close-button {
|
||||||
|
padding: 4px;
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
#compose-container .media-aside .uploaded {
|
||||||
|
color: var(--green-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
527
src/components/compose.jsx
Normal file
527
src/components/compose.jsx
Normal file
|
@ -0,0 +1,527 @@
|
||||||
|
import './compose.css';
|
||||||
|
|
||||||
|
import '@github/text-expander-element';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import emojifyText from '../utils/emojify-text';
|
||||||
|
import store from '../utils/store';
|
||||||
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
|
|
||||||
|
import Avatar from './avatar';
|
||||||
|
import Icon from './icon';
|
||||||
|
import Loader from './loader';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
|
/* NOTES:
|
||||||
|
- Max character limit includes BOTH status text and Content Warning text
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default ({ onClose, replyToStatus }) => {
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
const accounts = store.local.getJSON('accounts');
|
||||||
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
const currentAccountInfo = accounts.find(
|
||||||
|
(a) => a.info.id === currentAccount,
|
||||||
|
).info;
|
||||||
|
|
||||||
|
const configuration = useMemo(() => {
|
||||||
|
const instances = store.local.getJSON('instances');
|
||||||
|
const currentInstance = accounts.find(
|
||||||
|
(a) => a.info.id === currentAccount,
|
||||||
|
).instanceURL;
|
||||||
|
const config = instances[currentInstance].configuration;
|
||||||
|
console.log(config);
|
||||||
|
return config;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
statuses: { maxCharacters, maxMediaAttachments, charactersReservedPerUrl },
|
||||||
|
mediaAttachments: {
|
||||||
|
supportedMimeTypes,
|
||||||
|
imageSizeLimit,
|
||||||
|
imageMatrixLimit,
|
||||||
|
videoSizeLimit,
|
||||||
|
videoMatrixLimit,
|
||||||
|
videoFrameRateLimit,
|
||||||
|
},
|
||||||
|
polls: { maxOptions, maxCharactersPerOption, maxExpiration, minExpiration },
|
||||||
|
} = configuration;
|
||||||
|
|
||||||
|
const textareaRef = useRef();
|
||||||
|
|
||||||
|
const [visibility, setVisibility] = useState(
|
||||||
|
replyToStatus?.visibility || 'public',
|
||||||
|
);
|
||||||
|
const [sensitive, setSensitive] = useState(replyToStatus?.sensitive || false);
|
||||||
|
const spoilerTextRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
const spoilerText = replyToStatus?.spoilerText;
|
||||||
|
if (spoilerText && spoilerTextRef.current) {
|
||||||
|
spoilerTextRef.current.value = spoilerText;
|
||||||
|
spoilerTextRef.current.focus();
|
||||||
|
} else {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const textExpanderRef = useRef();
|
||||||
|
const textExpanderTextRef = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (textExpanderRef.current) {
|
||||||
|
const handleChange = (e) => {
|
||||||
|
console.log('text-expander-change', e);
|
||||||
|
const { key, provide, text } = e.detail;
|
||||||
|
textExpanderTextRef.current = text;
|
||||||
|
if (text === '') {
|
||||||
|
provide(
|
||||||
|
Promise.resolve({
|
||||||
|
matched: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = {
|
||||||
|
'@': 'accounts',
|
||||||
|
'#': 'hashtags',
|
||||||
|
}[key];
|
||||||
|
provide(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const resultsIterator = masto.search({
|
||||||
|
type,
|
||||||
|
q: text,
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
resultsIterator.next().then(({ value }) => {
|
||||||
|
if (text !== textExpanderTextRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = value[type];
|
||||||
|
console.log('RESULTS', value, results);
|
||||||
|
const menu = document.createElement('ul');
|
||||||
|
menu.role = 'listbox';
|
||||||
|
menu.className = 'text-expander-menu';
|
||||||
|
results.forEach((result) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
avatarStatic,
|
||||||
|
displayName,
|
||||||
|
username,
|
||||||
|
acct,
|
||||||
|
emojis,
|
||||||
|
} = result;
|
||||||
|
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
if (acct) {
|
||||||
|
item.dataset.value = acct;
|
||||||
|
// Want to use <Avatar /> here, but will need to render to string 😅
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="avatar">
|
||||||
|
<img src="${avatarStatic}" width="16" height="16" alt="" loading="lazy" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b>${displayNameWithEmoji || username}</b>
|
||||||
|
<br>@${acct}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
item.dataset.value = name;
|
||||||
|
item.innerHTML = `
|
||||||
|
<span>#<b>${name}</b></span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
console.log('MENU', results, menu);
|
||||||
|
resolve({
|
||||||
|
matched: results.length > 0,
|
||||||
|
fragment: menu,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
textExpanderRef.current.addEventListener(
|
||||||
|
'text-expander-change',
|
||||||
|
handleChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
|
||||||
|
const { key, item } = e.detail;
|
||||||
|
e.detail.value = key + item.dataset.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||||||
|
|
||||||
|
const formRef = useRef();
|
||||||
|
|
||||||
|
const beforeUnloadCopy =
|
||||||
|
'You have unsaved changes. Are you sure you want to discard this post?';
|
||||||
|
const canClose = () => {
|
||||||
|
// check for status or mediaAttachments
|
||||||
|
if (textareaRef.current.value || mediaAttachments.length > 0) {
|
||||||
|
const yes = confirm(beforeUnloadCopy);
|
||||||
|
return yes;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Show warning if user tries to close window with unsaved changes
|
||||||
|
const handleBeforeUnload = (e) => {
|
||||||
|
if (!canClose()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = beforeUnloadCopy;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="compose-container">
|
||||||
|
<div class="compose-top">
|
||||||
|
{currentAccountInfo?.avatarStatic && (
|
||||||
|
<Avatar
|
||||||
|
url={currentAccountInfo.avatarStatic}
|
||||||
|
size="l"
|
||||||
|
alt={currentAccountInfo.username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light close-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (canClose()) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!!replyToStatus && (
|
||||||
|
<div class="reply-to">
|
||||||
|
<Status status={replyToStatus} size="s" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
style={{
|
||||||
|
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
||||||
|
opacity: uiState === 'loading' ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const entries = Object.fromEntries(formData.entries());
|
||||||
|
console.log('ENTRIES', entries);
|
||||||
|
let { status, visibility, sensitive, spoilerText } = entries;
|
||||||
|
|
||||||
|
// Pre-cleanup
|
||||||
|
sensitive = sensitive === 'on'; // checkboxes return "on" if checked
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (status.length > maxCharacters) {
|
||||||
|
alert(`Status is too long! Max characters: ${maxCharacters}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sensitive && status.length + spoilerText.length > maxCharacters) {
|
||||||
|
alert(
|
||||||
|
`Status and content warning is too long! Max characters: ${maxCharacters}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
|
||||||
|
|
||||||
|
// Post-cleanup
|
||||||
|
spoilerText = (sensitive && spoilerText) || undefined;
|
||||||
|
status = status === '' ? undefined : status;
|
||||||
|
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log('MEDIA ATTACHMENTS', mediaAttachments);
|
||||||
|
if (mediaAttachments.length > 0) {
|
||||||
|
// Upload media attachments first
|
||||||
|
const mediaPromises = mediaAttachments.map((attachment) => {
|
||||||
|
const params = {
|
||||||
|
file: attachment.file,
|
||||||
|
description: attachment.description || undefined,
|
||||||
|
};
|
||||||
|
return masto.mediaAttachments.create(params).then((res) => {
|
||||||
|
// Update media attachment with ID
|
||||||
|
if (res.id) {
|
||||||
|
attachment.id = res.id;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(mediaPromises);
|
||||||
|
|
||||||
|
// If any failed, return
|
||||||
|
if (
|
||||||
|
results.some(
|
||||||
|
(result) =>
|
||||||
|
result.status === 'rejected' || !result.value.id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setUIState('error');
|
||||||
|
// Alert all the reasons
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
alert(result.reason || `Attachment #${i} failed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({ results, mediaAttachments });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
status,
|
||||||
|
visibility,
|
||||||
|
sensitive,
|
||||||
|
spoilerText,
|
||||||
|
inReplyToId: replyToStatus?.id || undefined,
|
||||||
|
mediaIds: mediaAttachments.map((attachment) => attachment.id),
|
||||||
|
};
|
||||||
|
console.log('POST', params);
|
||||||
|
const newStatus = await masto.statuses.create(params);
|
||||||
|
setUIState('default');
|
||||||
|
|
||||||
|
// Close
|
||||||
|
onClose({
|
||||||
|
newStatus,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="toolbar stretch">
|
||||||
|
<input
|
||||||
|
ref={spoilerTextRef}
|
||||||
|
type="text"
|
||||||
|
name="spoilerText"
|
||||||
|
placeholder="Spoiler text"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
class="spoiler-text-field"
|
||||||
|
style={{
|
||||||
|
opacity: sensitive ? 1 : 0,
|
||||||
|
pointerEvents: sensitive ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="toolbar-button"
|
||||||
|
title="Content warning or sensitive media"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="sensitive"
|
||||||
|
type="checkbox"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sensitive = e.target.checked;
|
||||||
|
setSensitive(sensitive);
|
||||||
|
if (sensitive) {
|
||||||
|
spoilerTextRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
|
||||||
|
</label>{' '}
|
||||||
|
<label
|
||||||
|
class={`toolbar-button ${
|
||||||
|
visibility !== 'public' && !sensitive ? 'show-field' : ''
|
||||||
|
}`}
|
||||||
|
title={`Visibility: ${visibility}`}
|
||||||
|
>
|
||||||
|
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
|
||||||
|
<select
|
||||||
|
name="visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVisibility(e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
<option value="public">
|
||||||
|
Public <Icon icon="earth" />
|
||||||
|
</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
<option value="private">Followers only</option>
|
||||||
|
<option value="direct">Direct</option>
|
||||||
|
</select>
|
||||||
|
</label>{' '}
|
||||||
|
</div>
|
||||||
|
<text-expander ref={textExpanderRef} keys="@ #">
|
||||||
|
<textarea
|
||||||
|
class="large"
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder={
|
||||||
|
replyToStatus ? 'Post your reply' : 'What are you doing?'
|
||||||
|
}
|
||||||
|
required={mediaAttachments.length === 0}
|
||||||
|
autoCapitalize="sentences"
|
||||||
|
autoComplete="on"
|
||||||
|
autoCorrect="on"
|
||||||
|
spellCheck="true"
|
||||||
|
dir="auto"
|
||||||
|
rows="6"
|
||||||
|
cols="50"
|
||||||
|
name="status"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onInput={(e) => {
|
||||||
|
const { scrollHeight, offsetHeight, clientHeight, value } =
|
||||||
|
e.target;
|
||||||
|
const offset = offsetHeight - clientHeight;
|
||||||
|
e.target.style.height = value
|
||||||
|
? scrollHeight + offset + 'px'
|
||||||
|
: null;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
maxHeight: `${maxCharacters / 50}em`,
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
</text-expander>
|
||||||
|
{mediaAttachments.length > 0 && (
|
||||||
|
<div class="media-attachments">
|
||||||
|
{mediaAttachments.map((attachment, i) => {
|
||||||
|
const { url, type, id } = attachment;
|
||||||
|
const suffixType = type.split('/')[0];
|
||||||
|
return (
|
||||||
|
<div class="media-attachment" key={i + id}>
|
||||||
|
<div class="media-preview">
|
||||||
|
{suffixType === 'image' ? (
|
||||||
|
<img src={url} alt="" />
|
||||||
|
) : suffixType === 'video' ? (
|
||||||
|
<video src={url} playsinline muted />
|
||||||
|
) : suffixType === 'audio' ? (
|
||||||
|
<audio src={url} controls />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
placeholder={
|
||||||
|
{
|
||||||
|
image: 'Image description',
|
||||||
|
video: 'Video description',
|
||||||
|
audio: 'Audio description',
|
||||||
|
}[suffixType]
|
||||||
|
}
|
||||||
|
autoCapitalize="sentences"
|
||||||
|
autoComplete="on"
|
||||||
|
autoCorrect="on"
|
||||||
|
spellCheck="true"
|
||||||
|
dir="auto"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
maxlength="1500"
|
||||||
|
// TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39
|
||||||
|
onInput={(e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
// Modify `description` in media attachment
|
||||||
|
setMediaAttachments((attachments) => {
|
||||||
|
const newAttachments = [...attachments];
|
||||||
|
newAttachments[i].description = value;
|
||||||
|
return newAttachments;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<div class="media-aside">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain close-button"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setMediaAttachments((attachments) => {
|
||||||
|
return attachments.filter((_, j) => j !== i);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
{!!id && (
|
||||||
|
<Icon icon="upload" title="Uploaded" class="uploaded" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<label class="toolbar-button">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={supportedMimeTypes.join(',')}
|
||||||
|
multiple={mediaAttachments.length < maxMediaAttachments - 1}
|
||||||
|
disabled={
|
||||||
|
uiState === 'loading' ||
|
||||||
|
mediaAttachments.length >= maxMediaAttachments
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
const mediaFiles = Array.from(files).map((file) => ({
|
||||||
|
file,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
id: null, // indicate uploaded state
|
||||||
|
description: null,
|
||||||
|
}));
|
||||||
|
console.log('MEDIA ATTACHMENTS', files, mediaFiles);
|
||||||
|
|
||||||
|
// Validate max media attachments
|
||||||
|
if (
|
||||||
|
mediaAttachments.length + mediaFiles.length >
|
||||||
|
maxMediaAttachments
|
||||||
|
) {
|
||||||
|
alert(
|
||||||
|
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setMediaAttachments((attachments) => {
|
||||||
|
return attachments.concat(mediaFiles);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Icon icon="attachment" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{uiState === 'loading' && <Loader />}{' '}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="large"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
{replyToStatus ? 'Reply' : 'Post'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
59
src/components/icon.jsx
Normal file
59
src/components/icon.jsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
const SIZES = {
|
||||||
|
s: 12,
|
||||||
|
m: 16,
|
||||||
|
l: 20,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
x: 'mingcute:close-line',
|
||||||
|
heart: 'mingcute:heart-line',
|
||||||
|
bookmark: 'mingcute:bookmark-line',
|
||||||
|
'check-circle': 'mingcute:check-circle-line',
|
||||||
|
transfer: 'mingcute:transfer-4-line',
|
||||||
|
rocket: 'mingcute:rocket-line',
|
||||||
|
'arrow-left': 'mingcute:arrow-left-line',
|
||||||
|
'arrow-right': 'mingcute:arrow-right-line',
|
||||||
|
'arrow-up': 'mingcute:arrow-up-line',
|
||||||
|
earth: 'mingcute:earth-line',
|
||||||
|
lock: 'mingcute:lock-line',
|
||||||
|
unlock: 'mingcute:unlock-line',
|
||||||
|
'eye-close': 'mingcute:eye-close-line',
|
||||||
|
'eye-open': 'mingcute:eye-2-line',
|
||||||
|
message: 'mingcute:mail-line',
|
||||||
|
comment: 'mingcute:chat-3-line',
|
||||||
|
home: 'mingcute:home-5-line',
|
||||||
|
notification: 'mingcute:notification-line',
|
||||||
|
follow: 'mingcute:user-follow-line',
|
||||||
|
'follow-add': 'mingcute:user-add-line',
|
||||||
|
poll: 'mingcute:chart-bar-line',
|
||||||
|
pencil: 'mingcute:pencil-line',
|
||||||
|
quill: 'mingcute:quill-pen-line',
|
||||||
|
at: 'mingcute:at-line',
|
||||||
|
attachment: 'mingcute:attachment-line',
|
||||||
|
upload: 'mingcute:upload-3-line',
|
||||||
|
gear: 'mingcute:settings-3-line',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
|
||||||
|
const iconSize = SIZES[size];
|
||||||
|
const iconName = ICONS[icon];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`icon ${className}`}
|
||||||
|
title={title || alt}
|
||||||
|
style={{
|
||||||
|
width: `${iconSize}px`,
|
||||||
|
height: `${iconSize}px`,
|
||||||
|
display: 'inline-block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iconify-icon width={iconSize} height={iconSize} icon={iconName}>
|
||||||
|
{alt}
|
||||||
|
</iconify-icon>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
43
src/components/loader.css
Normal file
43
src/components/loader.css
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
.loader-container {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: slow-appear .3s ease-in-out 1s both;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
@keyframes slow-appear {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.loader-container.abrupt {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.loader-container.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid;
|
||||||
|
border-color: var(--loader-color) var(--loader-color) transparent transparent;
|
||||||
|
animation: loader 1s infinite linear;
|
||||||
|
}
|
||||||
|
@keyframes loader {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.loader-container.hidden .loader {
|
||||||
|
animation: none;
|
||||||
|
}
|
11
src/components/loader.jsx
Normal file
11
src/components/loader.jsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import './loader.css';
|
||||||
|
|
||||||
|
export default ({ abrupt, hidden }) => (
|
||||||
|
<div
|
||||||
|
class={`loader-container ${abrupt ? 'abrupt' : ''} ${
|
||||||
|
hidden ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div class="loader" />
|
||||||
|
</div>
|
||||||
|
);
|
17
src/components/modal.css
Normal file
17
src/components/modal.css
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#modal-container > div {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--backdrop-color);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-container > .light {
|
||||||
|
backdrop-filter: saturate(.75);
|
||||||
|
}
|
20
src/components/modal.jsx
Normal file
20
src/components/modal.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import './modal.css';
|
||||||
|
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
const $modalContainer = document.getElementById('modal-container');
|
||||||
|
|
||||||
|
export default ({ children, onClick, class: className }) => {
|
||||||
|
if (!children) return null;
|
||||||
|
|
||||||
|
const Modal = (
|
||||||
|
<div className={className} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(Modal, $modalContainer);
|
||||||
|
|
||||||
|
// return createPortal(children, $modalContainer);
|
||||||
|
};
|
19
src/components/name-text.css
Normal file
19
src/components/name-text.css
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
.name-text {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
a.name-text:hover b,
|
||||||
|
a.name-text.short:hover i {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
.name-text i {
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text .avatar {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
58
src/components/name-text.jsx
Normal file
58
src/components/name-text.jsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import './name-text.css';
|
||||||
|
|
||||||
|
import emojifyText from '../utils/emojify-text';
|
||||||
|
import states from '../utils/states';
|
||||||
|
|
||||||
|
import Avatar from './avatar';
|
||||||
|
|
||||||
|
export default ({ account, showAvatar, showAcct, short, external }) => {
|
||||||
|
const { acct, avatar, avatarStatic, id, url, displayName, username, emojis } =
|
||||||
|
account;
|
||||||
|
|
||||||
|
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
class={`name-text ${short ? 'short' : ''}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
title={`@${acct}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (external) return;
|
||||||
|
e.preventDefault();
|
||||||
|
states.showAccount = account;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAvatar && (
|
||||||
|
<>
|
||||||
|
<Avatar url={avatar} />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{displayName && !short ? (
|
||||||
|
<>
|
||||||
|
<b
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: displayNameWithEmoji,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!showAcct && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<i>@{username}</i>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : short ? (
|
||||||
|
<i>@{username}</i>
|
||||||
|
) : (
|
||||||
|
<b>@{username}</b>
|
||||||
|
)}
|
||||||
|
{showAcct && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<i>@{acct}</i>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
438
src/components/status.css
Normal file
438
src/components/status.css
Normal file
|
@ -0,0 +1,438 @@
|
||||||
|
/* REBLOG + REPLY-TO */
|
||||||
|
|
||||||
|
.status-reblog {
|
||||||
|
background: linear-gradient(to bottom right, var(
|
||||||
|
--reblog-faded-color
|
||||||
|
), transparent 160px);
|
||||||
|
}
|
||||||
|
.status-reply-to {
|
||||||
|
background: linear-gradient(to bottom right, var(
|
||||||
|
--reply-to-faded-color
|
||||||
|
), transparent 160px);
|
||||||
|
}
|
||||||
|
.status-reblog .status-reply-to {
|
||||||
|
background: linear-gradient(to top left, var(
|
||||||
|
--reply-to-faded-color
|
||||||
|
), transparent 160px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* STATUS PRE META */
|
||||||
|
|
||||||
|
.status-pre-meta {
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: smaller;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-pre-meta .icon {
|
||||||
|
color: var(--reblog-color);
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* STATUS */
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px 16px 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.status.large {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.status-pre-meta + .status {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.status.small {
|
||||||
|
font-size: 95%;
|
||||||
|
}
|
||||||
|
.status.skeleton {
|
||||||
|
color: var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.skeleton > .avatar {
|
||||||
|
background-color: var(--outline-color);
|
||||||
|
}
|
||||||
|
.indirect .status {
|
||||||
|
padding-left: 57px;
|
||||||
|
}
|
||||||
|
.indirect .status > .avatar {
|
||||||
|
width: 25px !important;
|
||||||
|
height: 25px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status:not(.small) .container {
|
||||||
|
padding-left: 16px;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.large > .container > .meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 50px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.status > .container > .meta .arrow {
|
||||||
|
color: var(--reply-to-color);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status > .container > .meta .time {
|
||||||
|
color: inherit;
|
||||||
|
text-align: end;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status > .container > .meta a.time:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.status > .container > .meta .reply-to {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.large .content-container {
|
||||||
|
margin-left: calc(-50px - 16px);
|
||||||
|
background-image: linear-gradient(to bottom, transparent, var(--bg-color) 10px, var(--bg-color));
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status .content-container.has-spoiler .spoiler {
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 90%;
|
||||||
|
outline: 1px dashed var(--button-bg-color);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.status .content-container.has-spoiler .spoiler ~ * {
|
||||||
|
filter: blur(6px) invert(.5);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: filter .5s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
.status .content-container.show-spoiler .spoiler {
|
||||||
|
outline-style: dotted;
|
||||||
|
}
|
||||||
|
.status .content-container.show-spoiler .spoiler ~ * {
|
||||||
|
filter: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
user-select: auto;
|
||||||
|
}
|
||||||
|
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status .content {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.status .content p {
|
||||||
|
margin-block: .75em;
|
||||||
|
}
|
||||||
|
.status .content p:first-child {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
.status .content p:last-child {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
.status .content .invisible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status .content .ellipsis::after {
|
||||||
|
content: '…';
|
||||||
|
}
|
||||||
|
.status:hover .content a {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
.status .content a.mention {
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
|
.status .content a.mention span {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: inherit;
|
||||||
|
}
|
||||||
|
.status .content a.hashtag {
|
||||||
|
color: var(--link-light-color);
|
||||||
|
}
|
||||||
|
.status .content a.hashtag span{
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.status .content a.u-url span{
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.status.large .content {
|
||||||
|
font-size: 150%;
|
||||||
|
}
|
||||||
|
.status.large .poll,
|
||||||
|
.status.large .actions {
|
||||||
|
font-size: 125%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MEDIA */
|
||||||
|
|
||||||
|
.status .media-container {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.status .media {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: calc(50% - 8px);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 160px;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
.status .media:hover {
|
||||||
|
outline-color: var(--outline-hover-color);
|
||||||
|
}
|
||||||
|
.status .media.block {
|
||||||
|
flex-basis: 100%;
|
||||||
|
max-height: auto;
|
||||||
|
}
|
||||||
|
.status .media :is(img, video) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@keyframes position-object {
|
||||||
|
0% {
|
||||||
|
object-position: 50% 50%;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
object-position: 0% 0%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
object-position: 100% 100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
object-position: 50% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.status .media img:hover {
|
||||||
|
animation: position-object 5s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
.status .media video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.status .media-video {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.status .media-video:after {
|
||||||
|
/* show play icon */
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 15px 0 15px 26.0px;
|
||||||
|
border-color: transparent transparent transparent var(--text-color);
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 0 10px var(--bg-color)) drop-shadow(0 0 10px var(--bg-color)) drop-shadow(0 0 10px var(--bg-color)) drop-shadow(0 0 10px var(--bg-color)) drop-shadow(0 0 10px var(--bg-color));
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.status .media-video:hover:after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.status .media-gif video {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.status .media-audio {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.status .media-audio audio {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CARD */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 8px;
|
||||||
|
color: inherit;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
.card .image {
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 160px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
.card:hover .image {
|
||||||
|
animation: position-object 5s ease-in-out 1s infinite;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card .meta-container {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.card .title {
|
||||||
|
font-weight: normal;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
display: box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
}
|
||||||
|
.card .meta {
|
||||||
|
font-size: smaller;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card .meta.domain {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
a.card {
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
a.card:hover {
|
||||||
|
outline: 1px solid var(--outline-hover-color);
|
||||||
|
}
|
||||||
|
.card.video {
|
||||||
|
max-width: 320px;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
.card.video iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POLLS */
|
||||||
|
|
||||||
|
.poll-option {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-image: linear-gradient(to right, var(--link-faded-color), var(--link-faded-color) var(--percentage), transparent var(--percentage), transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 1px solid rgba(128, 128, 128, .1);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.poll-label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.poll-vote-button {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.poll-meta {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ACTIONS */
|
||||||
|
|
||||||
|
.status .actions {
|
||||||
|
margin-left: -8px; /* visual balance */
|
||||||
|
padding-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.status.large .actions {
|
||||||
|
/* margin-left: -12px; */
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-left: calc(-50px - 16px);
|
||||||
|
background-image: linear-gradient(to bottom, var(--bg-color), var(--bg-color) calc(100% - 10px), transparent);
|
||||||
|
}
|
||||||
|
.status .actions > * {
|
||||||
|
opacity: .5;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.status:hover .actions > * {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.status .actions > button {
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.status .actions > button.plain {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain:hover {
|
||||||
|
color: var(--link-color);
|
||||||
|
background-color: var(--button-plain-bg-hover-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain.reblog-button:hover {
|
||||||
|
color: var(--reblog-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain.reblog-button.reblogged {
|
||||||
|
color: var(--reblog-color);
|
||||||
|
outline: 1.5px solid var(--reblog-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain.favourite-button:hover {
|
||||||
|
color: var(--favourite-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain.favourite-button.favourited {
|
||||||
|
color: var(--favourite-color);
|
||||||
|
outline: 1.5px solid var(--favourite-color);
|
||||||
|
}
|
||||||
|
.status .actions > button.plain.bookmark-button.bookmarked {
|
||||||
|
color: var(--link-color);
|
||||||
|
outline: 1.5px solid var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ENHANCED CONTENT */
|
||||||
|
|
||||||
|
.status .content pre {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 90%;
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
background: linear-gradient(to bottom right, var(
|
||||||
|
--bg-faded-color
|
||||||
|
), transparent 160px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MISC */
|
||||||
|
|
||||||
|
.status-aside {
|
||||||
|
padding: 0 16px 16px 80px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcode-emoji {
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
835
src/components/status.jsx
Normal file
835
src/components/status.jsx
Normal file
|
@ -0,0 +1,835 @@
|
||||||
|
import './status.css';
|
||||||
|
|
||||||
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
|
import mem from 'mem';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Modal from '../components/modal';
|
||||||
|
import NameText from '../components/name-text';
|
||||||
|
import enhanceContent from '../utils/enhance-content';
|
||||||
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
import states from '../utils/states';
|
||||||
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
|
|
||||||
|
import Avatar from './avatar';
|
||||||
|
import Icon from './icon';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Media type
|
||||||
|
===
|
||||||
|
unknown = unsupported or unrecognized file type
|
||||||
|
image = Static image
|
||||||
|
gifv = Looping, soundless animation
|
||||||
|
video = Video clip
|
||||||
|
audio = Audio track
|
||||||
|
*/
|
||||||
|
|
||||||
|
function Media({ media, showOriginal, onClick }) {
|
||||||
|
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
|
||||||
|
media;
|
||||||
|
const { original, small, focus } = meta || {};
|
||||||
|
|
||||||
|
const width = showOriginal ? original?.width : small?.width;
|
||||||
|
const height = showOriginal ? original?.height : small?.height;
|
||||||
|
const mediaURL = showOriginal ? url : previewUrl;
|
||||||
|
|
||||||
|
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||||
|
|
||||||
|
const videoRef = useRef();
|
||||||
|
|
||||||
|
let focalBackgroundPosition;
|
||||||
|
if (focus) {
|
||||||
|
// Convert focal point to CSS background position
|
||||||
|
// Formula from jquery-focuspoint
|
||||||
|
// x = -1, y = 1 => 0% 0%
|
||||||
|
// x = 0, y = 0 => 50% 50%
|
||||||
|
// x = 1, y = -1 => 100% 100%
|
||||||
|
const x = ((focus.x + 1) / 2) * 100;
|
||||||
|
const y = ((1 - focus.y) / 2) * 100;
|
||||||
|
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`media media-image`}
|
||||||
|
onClick={onClick}
|
||||||
|
style={
|
||||||
|
showOriginal && {
|
||||||
|
backgroundImage: `url(${previewUrl})`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
|
aspectRatio: `${width}/${height}`,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={mediaURL}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={
|
||||||
|
!showOriginal && {
|
||||||
|
backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (type === 'gifv' || type === 'video') {
|
||||||
|
// 20 seconds, treat as a gif
|
||||||
|
const isGIF = type === 'gifv' && original.duration <= 20;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`media media-${isGIF ? 'gif' : 'video'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isGIF) {
|
||||||
|
try {
|
||||||
|
videoRef.current?.play();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (isGIF) {
|
||||||
|
try {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showOriginal ? (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
poster={previewUrl}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
preload
|
||||||
|
autoplay
|
||||||
|
controls={!isGIF}
|
||||||
|
playsinline
|
||||||
|
loop
|
||||||
|
></video>
|
||||||
|
) : isGIF ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={url}
|
||||||
|
poster={previewUrl}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
preload
|
||||||
|
// controls
|
||||||
|
playsinline
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (type === 'audio') {
|
||||||
|
return (
|
||||||
|
<div class="media media-audio">
|
||||||
|
<audio src={remoteUrl || url} preload="none" controls />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ card }) {
|
||||||
|
const {
|
||||||
|
blurhash,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
html,
|
||||||
|
providerName,
|
||||||
|
authorName,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
type,
|
||||||
|
embedUrl,
|
||||||
|
} = card;
|
||||||
|
|
||||||
|
/* type
|
||||||
|
link = Link OEmbed
|
||||||
|
photo = Photo OEmbed
|
||||||
|
video = Video OEmbed
|
||||||
|
rich = iframe OEmbed. Not currently accepted, so won’t show up in practice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const hasText = title || providerName || authorName;
|
||||||
|
|
||||||
|
if (hasText && image) {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="nofollow noopener noreferrer"
|
||||||
|
class="card link"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="image"
|
||||||
|
src={image}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
alt=""
|
||||||
|
onError={() => {
|
||||||
|
this.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="meta-container">
|
||||||
|
<p
|
||||||
|
class="title"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: title,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p class="meta">{providerName || authorName}</p>
|
||||||
|
<p class="meta domain">{domain}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else if (type === 'photo') {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="nofollow noopener noreferrer"
|
||||||
|
class="card photo"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={embedUrl}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
alt={title || description}
|
||||||
|
style={{
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: `${width}/${height}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else if (type === 'video') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="card video"
|
||||||
|
style={{
|
||||||
|
aspectRatio: `${width}/${height}`,
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Poll({ poll }) {
|
||||||
|
const [pollSnapshot, setPollSnapshot] = useState(poll);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
const {
|
||||||
|
expired,
|
||||||
|
expiresAt,
|
||||||
|
id,
|
||||||
|
multiple,
|
||||||
|
options,
|
||||||
|
ownVotes,
|
||||||
|
voted,
|
||||||
|
votersCount,
|
||||||
|
votesCount,
|
||||||
|
} = pollSnapshot;
|
||||||
|
|
||||||
|
const expiresAtDate = new Date(expiresAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="poll">
|
||||||
|
{voted || expired ? (
|
||||||
|
options.map((option, i) => {
|
||||||
|
const { title, votesCount: optionVotesCount } = option;
|
||||||
|
const percentage = Math.round((optionVotesCount / votesCount) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="poll-option"
|
||||||
|
style={{
|
||||||
|
'--percentage': `${percentage}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="poll-option-title">
|
||||||
|
{title}
|
||||||
|
{voted && ownVotes.includes(i) && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<Icon icon="check" size="s" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="poll-option-votes"
|
||||||
|
title={`${optionVotesCount} vote${
|
||||||
|
optionVotesCount === 1 ? '' : 's'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const votes = [];
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key === 'poll') {
|
||||||
|
votes.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(votes);
|
||||||
|
setUIState('loading');
|
||||||
|
const pollResponse = await masto.poll.vote(id, {
|
||||||
|
choices: votes,
|
||||||
|
});
|
||||||
|
console.log(pollResponse);
|
||||||
|
setPollSnapshot(pollResponse);
|
||||||
|
setUIState('default');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
||||||
|
opacity: uiState === 'loading' ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option, i) => {
|
||||||
|
const { title } = option;
|
||||||
|
return (
|
||||||
|
<div class="poll-option">
|
||||||
|
<label class="poll-label">
|
||||||
|
<input
|
||||||
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
|
name="poll"
|
||||||
|
value={i}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>
|
||||||
|
<span class="poll-option-title">{title}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
class="poll-vote-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
Vote
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p class="poll-meta">
|
||||||
|
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||||
|
{votersCount === 1 ? 'voter' : 'voters'}
|
||||||
|
{votersCount !== votesCount && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
• <span title={votesCount}>
|
||||||
|
{shortenNumber(votesCount)}
|
||||||
|
</span>{' '}
|
||||||
|
vote
|
||||||
|
{votesCount === 1 ? '' : 's'}
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||||
|
<relative-time datetime={expiresAtDate.toISOString()} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAccount(id) {
|
||||||
|
return masto.accounts.fetch(id);
|
||||||
|
}
|
||||||
|
const memFetchAccount = mem(fetchAccount);
|
||||||
|
|
||||||
|
function Status({ statusID, status, withinContext, size = 'm', skeleton }) {
|
||||||
|
if (skeleton) {
|
||||||
|
return (
|
||||||
|
<div class="status skeleton">
|
||||||
|
<Avatar size="xxl" />
|
||||||
|
<div class="container">
|
||||||
|
<div class="meta">███ ████████████</div>
|
||||||
|
<div class="content-container">
|
||||||
|
<div class="content">
|
||||||
|
<p>████ ████████████</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
if (!status) {
|
||||||
|
status = snapStates.statuses.get(statusID);
|
||||||
|
}
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
account: {
|
||||||
|
acct,
|
||||||
|
avatar,
|
||||||
|
avatarStatic,
|
||||||
|
id: accountId,
|
||||||
|
url,
|
||||||
|
displayName,
|
||||||
|
username,
|
||||||
|
emojis: accountEmojis,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
repliesCount,
|
||||||
|
reblogged,
|
||||||
|
reblogsCount,
|
||||||
|
favourited,
|
||||||
|
favouritesCount,
|
||||||
|
bookmarked,
|
||||||
|
poll,
|
||||||
|
muted,
|
||||||
|
sensitive,
|
||||||
|
spoilerText,
|
||||||
|
visibility, // public, unlisted, private, direct
|
||||||
|
language,
|
||||||
|
editedAt,
|
||||||
|
filtered,
|
||||||
|
card,
|
||||||
|
createdAt,
|
||||||
|
inReplyToAccountId,
|
||||||
|
content,
|
||||||
|
mentions,
|
||||||
|
mediaAttachments,
|
||||||
|
reblog,
|
||||||
|
uri,
|
||||||
|
emojis,
|
||||||
|
} = status;
|
||||||
|
|
||||||
|
const createdAtDate = new Date(createdAt);
|
||||||
|
|
||||||
|
let inReplyToAccountRef = mentions.find(
|
||||||
|
(mention) => mention.id === inReplyToAccountId,
|
||||||
|
);
|
||||||
|
if (!inReplyToAccountRef && inReplyToAccountId === id) {
|
||||||
|
inReplyToAccountRef = { url, username, displayName };
|
||||||
|
}
|
||||||
|
const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef);
|
||||||
|
if (!withinContext && !inReplyToAccount && inReplyToAccountId) {
|
||||||
|
const account = states.accounts.get(inReplyToAccountId);
|
||||||
|
if (account) {
|
||||||
|
setInReplyToAccount(account);
|
||||||
|
} else {
|
||||||
|
memFetchAccount(inReplyToAccountId)
|
||||||
|
.then((account) => {
|
||||||
|
setInReplyToAccount(account);
|
||||||
|
states.accounts.set(account.id, account);
|
||||||
|
})
|
||||||
|
.catch((e) => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [showSpoiler, setShowSpoiler] = useState(false);
|
||||||
|
|
||||||
|
const debugHover = (e) => {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
console.log(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showMediaModal, setShowMediaModal] = useState(false);
|
||||||
|
const carouselFocusItem = useRef(null);
|
||||||
|
const prevShowMediaModal = useRef(showMediaModal);
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMediaModal !== false) {
|
||||||
|
carouselFocusItem.current?.scrollIntoView({
|
||||||
|
behavior: prevShowMediaModal.current === false ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevShowMediaModal.current = showMediaModal;
|
||||||
|
}, [showMediaModal]);
|
||||||
|
|
||||||
|
if (reblog) {
|
||||||
|
return (
|
||||||
|
<div class="status-reblog" onMouseEnter={debugHover}>
|
||||||
|
<div class="status-pre-meta">
|
||||||
|
<Icon icon="rocket" size="l" />{' '}
|
||||||
|
<NameText account={status.account} showAvatar /> boosted
|
||||||
|
</div>
|
||||||
|
<Status status={reblog} size={size} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`status ${
|
||||||
|
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
|
||||||
|
} ${
|
||||||
|
{
|
||||||
|
s: 'small',
|
||||||
|
m: 'medium',
|
||||||
|
l: 'large',
|
||||||
|
}[size]
|
||||||
|
}`}
|
||||||
|
onMouseEnter={debugHover}
|
||||||
|
>
|
||||||
|
{size !== 's' && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
title={`@${acct}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
states.showAccount = status.account;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar url={avatarStatic} size="xxl" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div class="container">
|
||||||
|
<div class="meta">
|
||||||
|
<span>
|
||||||
|
<NameText
|
||||||
|
account={status.account}
|
||||||
|
showAvatar={size === 's'}
|
||||||
|
showAcct={size === 'l'}
|
||||||
|
/>
|
||||||
|
{inReplyToAccount && !withinContext && size !== 's' && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span class="ib">
|
||||||
|
<Icon icon="arrow-right" class="arrow" />{' '}
|
||||||
|
<NameText account={inReplyToAccount} short />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>{' '}
|
||||||
|
<a href={uri} target="_blank" class="time">
|
||||||
|
<Icon
|
||||||
|
icon={visibilityIconsMap[visibility]}
|
||||||
|
alt={visibility}
|
||||||
|
size="s"
|
||||||
|
/>{' '}
|
||||||
|
<relative-time
|
||||||
|
datetime={createdAtDate.toISOString()}
|
||||||
|
format="micro"
|
||||||
|
threshold="P1D"
|
||||||
|
prefix=""
|
||||||
|
>
|
||||||
|
{createdAtDate.toLocaleString()}
|
||||||
|
</relative-time>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`content-container ${
|
||||||
|
sensitive || spoilerText ? 'has-spoiler' : ''
|
||||||
|
} ${showSpoiler ? 'show-spoiler' : ''}`}
|
||||||
|
>
|
||||||
|
{!!spoilerText && sensitive && (
|
||||||
|
<>
|
||||||
|
<div class="content">
|
||||||
|
<p>{spoilerText}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="light spoiler"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSpoiler(!showSpoiler);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
||||||
|
{showSpoiler ? 'Show less' : 'Show more'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
class="content"
|
||||||
|
onClick={(e) => {
|
||||||
|
let { target } = e;
|
||||||
|
if (target.parentNode.tagName.toLowerCase() === 'a') {
|
||||||
|
target = target.parentNode;
|
||||||
|
}
|
||||||
|
console.log('click', target, e);
|
||||||
|
if (
|
||||||
|
target.tagName.toLowerCase() === 'a' &&
|
||||||
|
target.classList.contains('mention')
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const username = target.querySelector('span');
|
||||||
|
const mention = mentions.find(
|
||||||
|
(mention) => mention.username === username?.innerText.trim(),
|
||||||
|
);
|
||||||
|
if (mention) {
|
||||||
|
states.showAccount = mention.acct;
|
||||||
|
} else {
|
||||||
|
const href = target.getAttribute('href');
|
||||||
|
states.showAccount = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: enhanceContent(content, {
|
||||||
|
emojis,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!poll && <Poll poll={poll} />}
|
||||||
|
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
||||||
|
<button
|
||||||
|
class="plain spoiler"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSpoiler(!showSpoiler);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
|
||||||
|
content
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!!mediaAttachments.length && size !== 's' && (
|
||||||
|
<div class="media-container">
|
||||||
|
{mediaAttachments.map((media, i) => (
|
||||||
|
<Media
|
||||||
|
media={media}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMediaModal(i);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!card &&
|
||||||
|
(size === 'l' ||
|
||||||
|
(size === 'm' && !poll && !mediaAttachments.length)) && (
|
||||||
|
<Card card={card} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{size === 'l' && (
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Comment"
|
||||||
|
class="plain reply-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
states.showCompose = {
|
||||||
|
replyToStatus: status,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="comment" size="l" alt="Reply" />
|
||||||
|
{!!repliesCount && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<small>{shortenNumber(repliesCount)}</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* TODO: if visibility = private, only can reblog own statuses */}
|
||||||
|
{visibility !== 'direct' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={reblogged ? 'Unboost' : 'Boost'}
|
||||||
|
class={`plain reblog-button ${reblogged ? 'reblogged' : ''}`}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const yes = confirm(
|
||||||
|
reblogged ? 'Unboost this status?' : 'Boost this status?',
|
||||||
|
);
|
||||||
|
if (!yes) return;
|
||||||
|
if (reblogged) {
|
||||||
|
const newStatus = await masto.statuses.unreblog(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
} else {
|
||||||
|
const newStatus = await masto.statuses.reblog(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
states.statuses.set(newStatus.reblog.id, newStatus.reblog);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="rocket"
|
||||||
|
size="l"
|
||||||
|
alt={reblogged ? 'Boosted' : 'Boost'}
|
||||||
|
/>
|
||||||
|
{!!reblogsCount && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<small>{shortenNumber(reblogsCount)}</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={favourited ? 'Unfavourite' : 'Favourite'}
|
||||||
|
class={`plain favourite-button ${favourited ? 'favourited' : ''}`}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (favourited) {
|
||||||
|
const newStatus = await masto.statuses.unfavourite(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
} else {
|
||||||
|
const newStatus = await masto.statuses.favourite(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="heart"
|
||||||
|
size="l"
|
||||||
|
alt={favourited ? 'Favourited' : 'Favourite'}
|
||||||
|
/>
|
||||||
|
{!!favouritesCount && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<small>{shortenNumber(favouritesCount)}</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={bookmarked ? 'Unbookmark' : 'Bookmark'}
|
||||||
|
class={`plain bookmark-button ${bookmarked ? 'bookmarked' : ''}`}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (bookmarked) {
|
||||||
|
const newStatus = await masto.statuses.unbookmark(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
} else {
|
||||||
|
const newStatus = await masto.statuses.bookmark(id);
|
||||||
|
states.statuses.set(newStatus.id, newStatus);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="bookmark"
|
||||||
|
size="l"
|
||||||
|
alt={bookmarked ? 'Bookmarked' : 'Bookmark'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showMediaModal !== false && (
|
||||||
|
<Modal>
|
||||||
|
<div
|
||||||
|
class="carousel"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMediaModal(false);
|
||||||
|
}}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{mediaAttachments?.map((media, i) => (
|
||||||
|
<div
|
||||||
|
class="carousel-item"
|
||||||
|
tabindex="0"
|
||||||
|
key={media.id}
|
||||||
|
ref={i === showMediaModal ? carouselFocusItem : null}
|
||||||
|
>
|
||||||
|
<Media media={media} showOriginal />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mediaAttachments?.length > 1 && (
|
||||||
|
<div class="carousel-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain"
|
||||||
|
hidden={showMediaModal === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMediaModal(
|
||||||
|
(showMediaModal - 1 + mediaAttachments.length) %
|
||||||
|
mediaAttachments.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" />
|
||||||
|
</button>
|
||||||
|
<span class="carousel-dots">
|
||||||
|
{mediaAttachments?.map((media, i) => (
|
||||||
|
<button
|
||||||
|
key={media.id}
|
||||||
|
type="button"
|
||||||
|
class={`plain carousel-dot ${
|
||||||
|
i === showMediaModal ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMediaModal(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain"
|
||||||
|
hidden={showMediaModal === mediaAttachments.length - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowMediaModal(
|
||||||
|
(showMediaModal + 1) % mediaAttachments.length,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Status;
|
202
src/data/instances.json
Normal file
202
src/data/instances.json
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
[
|
||||||
|
"mastodon.social",
|
||||||
|
"mastodon.world",
|
||||||
|
"mas.to",
|
||||||
|
"pawoo.net",
|
||||||
|
"mastodon.online",
|
||||||
|
"mstdn.jp",
|
||||||
|
"universeodon.com",
|
||||||
|
"mastodon.lol",
|
||||||
|
"mastodonapp.uk",
|
||||||
|
"infosec.exchange",
|
||||||
|
"mastodon.uno",
|
||||||
|
"techhub.social",
|
||||||
|
"mastodon.sdf.org",
|
||||||
|
"fosstodon.org",
|
||||||
|
"troet.cafe",
|
||||||
|
"masto.ai",
|
||||||
|
"mstdn.party",
|
||||||
|
"c.im",
|
||||||
|
"hachyderm.io",
|
||||||
|
"m.cmx.im",
|
||||||
|
"mstdn.ca",
|
||||||
|
"sfba.social",
|
||||||
|
"kolektiva.social",
|
||||||
|
"mastodon.scot",
|
||||||
|
"ohai.social",
|
||||||
|
"fedibird.com",
|
||||||
|
"piaille.fr",
|
||||||
|
"home.social",
|
||||||
|
"mindly.social",
|
||||||
|
"mastodon.nl",
|
||||||
|
"toot.community",
|
||||||
|
"aus.social",
|
||||||
|
"thu.closed.social",
|
||||||
|
"mastodon.gamedev.place",
|
||||||
|
"nerdculture.de",
|
||||||
|
"mastodon.cloud",
|
||||||
|
"mastodon.ie",
|
||||||
|
"det.social",
|
||||||
|
"mastodon.au",
|
||||||
|
"nrw.social",
|
||||||
|
"mastodon.art",
|
||||||
|
"chaos.social",
|
||||||
|
"norden.social",
|
||||||
|
"ioc.exchange",
|
||||||
|
"alive.bar",
|
||||||
|
"tkz.one",
|
||||||
|
"sueden.social",
|
||||||
|
"mastodon.nu",
|
||||||
|
"mastodon.top",
|
||||||
|
"mastouille.fr",
|
||||||
|
"mastodontech.de",
|
||||||
|
"o3o.ca",
|
||||||
|
"social.tchncs.de",
|
||||||
|
"noagendasocial.com",
|
||||||
|
"newsie.social",
|
||||||
|
"masto.es",
|
||||||
|
"planet.moe",
|
||||||
|
"social.vivaldi.net",
|
||||||
|
"ravenation.club",
|
||||||
|
"wxw.moe",
|
||||||
|
"mathstodon.xyz",
|
||||||
|
"social.cologne",
|
||||||
|
"mastodon.nz",
|
||||||
|
"qoto.org",
|
||||||
|
"hessen.social",
|
||||||
|
"mastodon.com.tr",
|
||||||
|
"ruhr.social",
|
||||||
|
"muenchen.social",
|
||||||
|
"mamot.fr",
|
||||||
|
"twit.social",
|
||||||
|
"dice.camp",
|
||||||
|
"meow.social",
|
||||||
|
"www.masto.pt",
|
||||||
|
"social.anoxinon.de",
|
||||||
|
"www.sociale.network",
|
||||||
|
"tech.lgbt",
|
||||||
|
"econtwitter.net",
|
||||||
|
"masthead.social",
|
||||||
|
"glasgow.social",
|
||||||
|
"ieji.de",
|
||||||
|
"toot.wales",
|
||||||
|
"ecoevo.social",
|
||||||
|
"ro-mastodon.puyo.jp",
|
||||||
|
"noc.social",
|
||||||
|
"indieweb.social",
|
||||||
|
"zirk.us",
|
||||||
|
"twingyeo.kr",
|
||||||
|
"social.linux.pizza",
|
||||||
|
"mastodont.cat",
|
||||||
|
"social.dev-wiki.de",
|
||||||
|
"mastodonczech.cz",
|
||||||
|
"climatejustice.social",
|
||||||
|
"eldritch.cafe",
|
||||||
|
"g0v.social",
|
||||||
|
"socel.net",
|
||||||
|
"dju.social",
|
||||||
|
"mastodontti.fi",
|
||||||
|
"101010.pl",
|
||||||
|
"framapiaf.org",
|
||||||
|
"wien.rocks",
|
||||||
|
"botsin.space",
|
||||||
|
"mastodon.bida.im",
|
||||||
|
"bildung.social",
|
||||||
|
"pouet.chapril.org",
|
||||||
|
"urbanists.social",
|
||||||
|
"wandering.shop",
|
||||||
|
"masto.pt",
|
||||||
|
"union.place",
|
||||||
|
"metalhead.club",
|
||||||
|
"ruby.social",
|
||||||
|
"hiveway.net",
|
||||||
|
"h4.io",
|
||||||
|
"genomic.social",
|
||||||
|
"mastodon-belgium.be",
|
||||||
|
"mastodon.xyz",
|
||||||
|
"octodon.social",
|
||||||
|
"pol.social",
|
||||||
|
"tooot.im",
|
||||||
|
"berlin.social",
|
||||||
|
"sciences.social",
|
||||||
|
"mstdn.guru",
|
||||||
|
"qdon.space",
|
||||||
|
"mastodon.radio",
|
||||||
|
"lile.cl",
|
||||||
|
"masto.nu",
|
||||||
|
"witches.live",
|
||||||
|
"mastodonners.nl",
|
||||||
|
"muenster.im",
|
||||||
|
"lor.sh",
|
||||||
|
"phpc.social",
|
||||||
|
"pewtix.com",
|
||||||
|
"social.librem.one",
|
||||||
|
"rollenspiel.social",
|
||||||
|
"peoplemaking.games",
|
||||||
|
"kinky.business",
|
||||||
|
"mastodon.fun",
|
||||||
|
"me.ns.ci",
|
||||||
|
"mastodon.eus",
|
||||||
|
"dresden.network",
|
||||||
|
"hostux.social",
|
||||||
|
"scholar.social",
|
||||||
|
"freiburg.social",
|
||||||
|
"todon.eu",
|
||||||
|
"writing.exchange",
|
||||||
|
"toot.aquilenet.fr",
|
||||||
|
"digitalcourage.social",
|
||||||
|
"rheinneckar.social",
|
||||||
|
"discuss.systems",
|
||||||
|
"defcon.social",
|
||||||
|
"snabelen.no",
|
||||||
|
"mastodon.se",
|
||||||
|
"rubber.social",
|
||||||
|
"fulda.social",
|
||||||
|
"vis.social",
|
||||||
|
"toot.funami.tech",
|
||||||
|
"mast.dragon-fly.club",
|
||||||
|
"disabled.social",
|
||||||
|
"medibubble.org",
|
||||||
|
"mastodon.technology",
|
||||||
|
"vmst.io",
|
||||||
|
"mstdn.io",
|
||||||
|
"equestria.social",
|
||||||
|
"vocalodon.net",
|
||||||
|
"mastodon.ml",
|
||||||
|
"libretooth.gr",
|
||||||
|
"tooting.ch",
|
||||||
|
"dizl.de",
|
||||||
|
"best-friends.chat",
|
||||||
|
"romancelandia.club",
|
||||||
|
"queer.party",
|
||||||
|
"tilde.zone",
|
||||||
|
"xarxa.cloud",
|
||||||
|
"abdl.link",
|
||||||
|
"bitcoinhackers.org",
|
||||||
|
"photog.social",
|
||||||
|
"macaw.social",
|
||||||
|
"yiff.life",
|
||||||
|
"sociale.network",
|
||||||
|
"ursal.zone",
|
||||||
|
"eupolicy.social",
|
||||||
|
"gruene.social",
|
||||||
|
"artisan.chat",
|
||||||
|
"graz.social",
|
||||||
|
"social.coop",
|
||||||
|
"mstdn.id",
|
||||||
|
"social.sciences.re",
|
||||||
|
"ludosphere.fr",
|
||||||
|
"social.politicaconciencia.org",
|
||||||
|
"oslo.town",
|
||||||
|
"scicomm.xyz",
|
||||||
|
"floss.social",
|
||||||
|
"creators.social",
|
||||||
|
"tabletop.social",
|
||||||
|
"bonn.social",
|
||||||
|
"openbiblio.social",
|
||||||
|
"mastodon.la",
|
||||||
|
"halifaxsocial.ca",
|
||||||
|
"freeradical.zone",
|
||||||
|
"kfem.cat",
|
||||||
|
"federated.press"
|
||||||
|
]
|
191
src/index.css
Normal file
191
src/index.css
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
:root {
|
||||||
|
--blue-color: royalblue;
|
||||||
|
--purple-color: blueviolet;
|
||||||
|
--green-color: green;
|
||||||
|
--orange-color: orange;
|
||||||
|
--red-color: orangered;
|
||||||
|
--bg-color: #fff;
|
||||||
|
--bg-faded-color: #f0f2f5;
|
||||||
|
--bg-blur-color: #fff9;
|
||||||
|
--bg-faded-blur-color: #f0f2f599;
|
||||||
|
--text-color: #1c1e21;
|
||||||
|
--text-insignificant-color: #1c1e2199;
|
||||||
|
--link-color: var(--blue-color);
|
||||||
|
--link-light-color: #4169e199;
|
||||||
|
--link-faded-color: #4169e122;
|
||||||
|
--link-bg-hover-color: #f0f2f599;
|
||||||
|
--button-bg-color: var(--blue-color);
|
||||||
|
--button-bg-blur-color: #4169e1aa;
|
||||||
|
--button-text-color: white;
|
||||||
|
--button-plain-bg-hover-color: rgba(128, 128, 128, 0.1);
|
||||||
|
--reblog-color: var(--purple-color);
|
||||||
|
--reblog-faded-color: #892be220;
|
||||||
|
--reply-to-color: var(--orange-color);
|
||||||
|
--favourite-color: var(--red-color);
|
||||||
|
--reply-to-faded-color: #ffa6001a;
|
||||||
|
--outline-color: rgba(128, 128, 128, .2);
|
||||||
|
--outline-hover-color: rgba(128, 128, 128, .7);
|
||||||
|
--divider-color: rgba(0, 0, 0, 0.1);
|
||||||
|
--backdrop-color: rgba(255, 255, 255, 0.5);
|
||||||
|
--img-bg-color: rgba(128, 128, 128, 0.2);
|
||||||
|
--loader-color: #1c1e2199;
|
||||||
|
--comment-line-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--blue-color: CornflowerBlue;
|
||||||
|
--purple-color: mediumpurple;
|
||||||
|
--green-color: MediumSeaGreen;
|
||||||
|
--bg-color: #242526;
|
||||||
|
--bg-faded-color: #18191a;
|
||||||
|
--bg-blur-color: #0009;
|
||||||
|
--bg-faded-blur-color: #18191a99;
|
||||||
|
--text-color: #f0f2f5;
|
||||||
|
--text-insignificant-color: #f0f2f599;
|
||||||
|
--link-bg-hover-color: #34353799;
|
||||||
|
--divider-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--bg-blur-color: #24252699;
|
||||||
|
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||||
|
--comment-line-color: #565656;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration-color: var(--link-faded-color);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration-color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 2px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 16px 0;
|
||||||
|
background-image: linear-gradient(to right, transparent, var(--divider-color), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .button {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 2.5em;
|
||||||
|
border: 0;
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
|
color: var(--button-text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
button > * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
:is(button, .button):not([disabled]):hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
:is(button, .button):active {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
:is(button, .button)[disabled] {
|
||||||
|
cursor: auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(button, .button).plain {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--link-color);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
:is(button, .button).light {
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(button, .button).block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], textarea, select {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 2px solid var(--divider-color);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus, textarea:focus, select:focus {
|
||||||
|
border-color: var(--outline-color);
|
||||||
|
}
|
||||||
|
input[type="text"].large, textarea.large, select.large, button.large {
|
||||||
|
font-size: 125%;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.small {
|
||||||
|
font-size: 90%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.plain {
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
img, video {
|
||||||
|
filter: brightness(0.7);
|
||||||
|
transition: filter 0.3s ease-out;
|
||||||
|
}
|
||||||
|
img:hover, video:hover {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UTILS */
|
||||||
|
|
||||||
|
.ib {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KEYFRAMES */
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
9
src/main.jsx
Normal file
9
src/main.jsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
import 'iconify-icon';
|
||||||
|
import '@github/time-elements';
|
||||||
|
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app'));
|
217
src/pages/home.jsx
Normal file
217
src/pages/home.jsx
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import { Link } from 'preact-router/match';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { InView } from 'react-intersection-observer';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import Status from '../components/status';
|
||||||
|
import states from '../utils/states';
|
||||||
|
import store from '../utils/store';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
export default ({ hidden }) => {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
|
||||||
|
const statusIterator = useRef(
|
||||||
|
masto.timelines.getHomeIterable({
|
||||||
|
limit: LIMIT,
|
||||||
|
}),
|
||||||
|
).current;
|
||||||
|
async function fetchStatuses(firstLoad) {
|
||||||
|
const allStatuses = await statusIterator.next(
|
||||||
|
firstLoad
|
||||||
|
? {
|
||||||
|
limit: LIMIT,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
if (allStatuses.value <= 0) {
|
||||||
|
return { done: true };
|
||||||
|
}
|
||||||
|
const homeValues = allStatuses.value.map((status) => {
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (firstLoad) {
|
||||||
|
states.home = homeValues;
|
||||||
|
} else {
|
||||||
|
states.home.push(...homeValues);
|
||||||
|
}
|
||||||
|
states.homeLastFetchTime = Date.now();
|
||||||
|
console.log(allStatuses);
|
||||||
|
return allStatuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStatuses = (firstLoad) => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { done } = await fetchStatuses(firstLoad);
|
||||||
|
setShowMore(!done);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatuses(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
store.session.set('lastHidden', timestamp);
|
||||||
|
console.log('hidden', timestamp);
|
||||||
|
} else {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const lastHidden = store.session.get('lastHidden');
|
||||||
|
const diff = timestamp - lastHidden;
|
||||||
|
const diffMins = Math.round(diff / 1000 / 60);
|
||||||
|
console.log('visible', { timestamp, diff, diffMins });
|
||||||
|
if (diffMins > 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
loadStatuses(true);
|
||||||
|
states.homeNew = [];
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollableRef = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="deck-container" hidden={hidden} ref={scrollableRef}>
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header
|
||||||
|
onClick={() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="header-side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
states.showSettings = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="gear" size="l" alt="Settings" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />{' '}
|
||||||
|
<a
|
||||||
|
href="#/notifications"
|
||||||
|
class={`button plain ${
|
||||||
|
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" size="l" alt="Notifications" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{snapStates.homeNew.length > 1 && (
|
||||||
|
<button
|
||||||
|
class="updates-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const uniqueHomeNew = snapStates.homeNew.filter(
|
||||||
|
(status) => !states.home.some((s) => s.id === status.id),
|
||||||
|
);
|
||||||
|
states.home.unshift(...uniqueHomeNew);
|
||||||
|
loadStatuses(true);
|
||||||
|
states.homeNew = [];
|
||||||
|
|
||||||
|
scrollableRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-up" /> New posts
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{snapStates.home.length ? (
|
||||||
|
<>
|
||||||
|
<ul class="timeline">
|
||||||
|
{snapStates.home.map(({ id: statusID, reblog }) => {
|
||||||
|
const actualStatusID = reblog || statusID;
|
||||||
|
return (
|
||||||
|
<li key={statusID}>
|
||||||
|
<Link
|
||||||
|
activeClassName="active"
|
||||||
|
class="status-link"
|
||||||
|
href={`#/s/${actualStatusID}`}
|
||||||
|
>
|
||||||
|
<Status statusID={statusID} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showMore && (
|
||||||
|
<InView
|
||||||
|
onChange={(inView) => {
|
||||||
|
if (inView) loadStatuses();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
height: '25vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
height: '25vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
</InView>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{uiState === 'loading' && (
|
||||||
|
<ul class="timeline">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{uiState === 'error' && (
|
||||||
|
<p class="ui-state">Error loading statuses</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
99
src/pages/login.jsx
Normal file
99
src/pages/login.jsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import instancesList from '../data/instances.json';
|
||||||
|
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
||||||
|
import store from '../utils/store';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
useTitle('Log in');
|
||||||
|
const instanceURLRef = useRef();
|
||||||
|
const cachedInstanceURL = store.local.get('instanceURL');
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cachedInstanceURL) {
|
||||||
|
instanceURLRef.current.value = cachedInstanceURL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { elements } = e.target;
|
||||||
|
let instanceURL = elements.instanceURL.value;
|
||||||
|
// Remove protocol from instance URL
|
||||||
|
instanceURL = instanceURL.replace(/(^\w+:|^)\/\//, '');
|
||||||
|
store.local.set('instanceURL', instanceURL);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setUIState('loading');
|
||||||
|
try {
|
||||||
|
const { client_id, client_secret } = await registerApplication({
|
||||||
|
instanceURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (client_id && client_secret) {
|
||||||
|
store.session.set('clientID', client_id);
|
||||||
|
store.session.set('clientSecret', client_secret);
|
||||||
|
|
||||||
|
location.href = await getAuthorizationURL({
|
||||||
|
instanceURL,
|
||||||
|
client_id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Failed to register application');
|
||||||
|
}
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="box">
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<h1>Log in</h1>
|
||||||
|
<label>
|
||||||
|
<p>Instance</p>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
class="large"
|
||||||
|
id="instanceURL"
|
||||||
|
ref={instanceURLRef}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
list="instances-list"
|
||||||
|
/>
|
||||||
|
<datalist id="instances-list">
|
||||||
|
{instancesList.map((instance) => (
|
||||||
|
<option value={instance} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
{uiState === 'error' && (
|
||||||
|
<p class="error">
|
||||||
|
Failed to log in. Please try again or another instance.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<button class="large" disabled={uiState === 'loading'}>
|
||||||
|
Log in
|
||||||
|
</button>{' '}
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
<a href="https://joinmastodon.org/servers" target="_blank">
|
||||||
|
Don't have an account? Create one!
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="/#">Go home</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
49
src/pages/notifications.css
Normal file
49
src/pages/notifications.css
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px !important;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.notification.skeleton {
|
||||||
|
color: var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type {
|
||||||
|
width: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
.notification-type.favourite {
|
||||||
|
color: var(--favourite-color);
|
||||||
|
}
|
||||||
|
.notification-type.reblog {
|
||||||
|
color: var(--reblog-color);
|
||||||
|
}
|
||||||
|
.notification-type.poll,
|
||||||
|
.notification-type.mention {
|
||||||
|
color: var(--link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification .status-link {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
max-height: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* fade out mask gradient bottom */
|
||||||
|
mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 50%, transparent);
|
||||||
|
filter: saturate(0.25);
|
||||||
|
}
|
||||||
|
.notification .status-link:hover {
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
filter: saturate(1);
|
||||||
|
}
|
||||||
|
.notification .status-link > * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.notification-content p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
292
src/pages/notifications.jsx
Normal file
292
src/pages/notifications.jsx
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
import './notifications.css';
|
||||||
|
|
||||||
|
import { Link } from 'preact-router/match';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import NameText from '../components/name-text';
|
||||||
|
import Status from '../components/status';
|
||||||
|
import states from '../utils/states';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Notification types
|
||||||
|
==================
|
||||||
|
mention = Someone mentioned you in their status
|
||||||
|
status = Someone you enabled notifications for has posted a status
|
||||||
|
reblog = Someone boosted one of your statuses
|
||||||
|
follow = Someone followed you
|
||||||
|
follow_request = Someone requested to follow you
|
||||||
|
favourite = Someone favourited one of your statuses
|
||||||
|
poll = A poll you have voted in or created has ended
|
||||||
|
update = A status you interacted with has been edited
|
||||||
|
admin.sign_up = Someone signed up (optionally sent to admins)
|
||||||
|
admin.report = A new report has been filed
|
||||||
|
*/
|
||||||
|
|
||||||
|
const contentText = {
|
||||||
|
mention: 'mentioned you in their status.',
|
||||||
|
status: 'posted a status.',
|
||||||
|
reblog: 'boosted your status.',
|
||||||
|
follow: 'followed you.',
|
||||||
|
follow_request: 'requested to follow you.',
|
||||||
|
favourite: 'favourited your status.',
|
||||||
|
poll: 'A poll you have voted in or created has ended.',
|
||||||
|
update: 'A status you interacted with has been edited.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Notification({ notification }) {
|
||||||
|
const { id, type, status, account } = notification;
|
||||||
|
|
||||||
|
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||||
|
const actualStatusID = status?.reblog?.id || status?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
class={`notification-type ${type}`}
|
||||||
|
title={new Date(notification.createdAt).toLocaleString()}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
{
|
||||||
|
mention: 'comment',
|
||||||
|
status: 'notification',
|
||||||
|
reblog: 'reblog',
|
||||||
|
follow: 'follow',
|
||||||
|
follow_request: 'follow-add',
|
||||||
|
favourite: 'heart',
|
||||||
|
poll: 'poll',
|
||||||
|
update: 'pencil',
|
||||||
|
}[type] || 'notification'
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
alt={type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<p>
|
||||||
|
{!/poll|update/i.test(type) && (
|
||||||
|
<>
|
||||||
|
<NameText account={account} showAvatar />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contentText[type]}
|
||||||
|
</p>
|
||||||
|
{status && (
|
||||||
|
<Link class="status-link" href={`#/s/${actualStatusID}`}>
|
||||||
|
<Status status={status} size="s" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsList({ notifications, emptyCopy }) {
|
||||||
|
if (!notifications.length && emptyCopy) {
|
||||||
|
return <p class="timeline-empty">{emptyCopy}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul class="timeline flat">
|
||||||
|
{notifications.map((notification) => {
|
||||||
|
const { id, type } = notification;
|
||||||
|
return (
|
||||||
|
<li key={id} class={`notification ${type}`}>
|
||||||
|
<Notification notification={notification} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
useTitle('Notifications');
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
|
||||||
|
const notificationsIterator = useRef(
|
||||||
|
masto.notifications.getIterator({
|
||||||
|
limit: LIMIT,
|
||||||
|
}),
|
||||||
|
).current;
|
||||||
|
async function fetchNotifications(firstLoad) {
|
||||||
|
const allNotifications = await notificationsIterator.next(
|
||||||
|
firstLoad
|
||||||
|
? {
|
||||||
|
limit: LIMIT,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
if (allNotifications.value <= 0) {
|
||||||
|
return { done: true };
|
||||||
|
}
|
||||||
|
const notificationsValues = allNotifications.value.map((notification) => {
|
||||||
|
if (notification.status) {
|
||||||
|
states.statuses.set(notification.status.id, notification.status);
|
||||||
|
}
|
||||||
|
return notification;
|
||||||
|
});
|
||||||
|
if (firstLoad) {
|
||||||
|
states.notifications = notificationsValues;
|
||||||
|
} else {
|
||||||
|
states.notifications.push(...notificationsValues);
|
||||||
|
}
|
||||||
|
states.notificationsLastFetchTime = Date.now();
|
||||||
|
console.log(allNotifications);
|
||||||
|
return allNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNotifications = (firstLoad) => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { done } = await fetchNotifications(firstLoad);
|
||||||
|
setShowMore(!done);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotifications(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollableRef = useRef();
|
||||||
|
|
||||||
|
// Group notifications by today, yesterday, and older
|
||||||
|
const groupedNotifications = snapStates.notifications.reduce(
|
||||||
|
(acc, notification) => {
|
||||||
|
const date = new Date(notification.created_at);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
if (
|
||||||
|
date.getDate() === today.getDate() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getFullYear() === today.getFullYear()
|
||||||
|
) {
|
||||||
|
acc.today.push(notification);
|
||||||
|
} else if (
|
||||||
|
date.getDate() === yesterday.getDate() &&
|
||||||
|
date.getMonth() === yesterday.getMonth() &&
|
||||||
|
date.getFullYear() === yesterday.getFullYear()
|
||||||
|
) {
|
||||||
|
acc.yesterday.push(notification);
|
||||||
|
} else {
|
||||||
|
acc.older.push(notification);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ today: [], yesterday: [], older: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(groupedNotifications);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="deck-container" ref={scrollableRef}>
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header>
|
||||||
|
<div class="header-side">
|
||||||
|
<a href="#" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</div>
|
||||||
|
{snapStates.notificationsNew.length > 0 && (
|
||||||
|
<button
|
||||||
|
class="updates-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const uniqueNotificationsNew =
|
||||||
|
snapStates.notificationsNew.filter(
|
||||||
|
(notification) =>
|
||||||
|
!snapStates.notifications.some(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
states.notifications.unshift(...uniqueNotificationsNew);
|
||||||
|
loadNotifications(true);
|
||||||
|
states.notificationsNew = [];
|
||||||
|
|
||||||
|
scrollableRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-up" /> New notifications
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{snapStates.notifications.length ? (
|
||||||
|
<>
|
||||||
|
<h2 class="timeline-header">Today</h2>
|
||||||
|
<NotificationsList
|
||||||
|
notifications={groupedNotifications.today}
|
||||||
|
emptyCopy="You're all caught up."
|
||||||
|
/>
|
||||||
|
{groupedNotifications.yesterday.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 class="timeline-header">Yesterday</h2>
|
||||||
|
<NotificationsList
|
||||||
|
notifications={groupedNotifications.yesterday}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{groupedNotifications.older.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 class="timeline-header">Older</h2>
|
||||||
|
<NotificationsList notifications={groupedNotifications.older} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain block"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => loadNotifications()}
|
||||||
|
>
|
||||||
|
{uiState === 'loading' ? <Loader /> : <>Show more…</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{uiState === 'loading' && (
|
||||||
|
<>
|
||||||
|
<h2 class="timeline-header">Today</h2>
|
||||||
|
<ul class="timeline flat">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li class="notification skeleton">
|
||||||
|
<div class="notification-type">
|
||||||
|
<Icon icon="notification" size="xl" />
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<p>███████████ ████</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{uiState === 'error' && (
|
||||||
|
<p class="ui-state">Error loading notifications</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
95
src/pages/settings.css
Normal file
95
src/pages/settings.css
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
#settings-container {
|
||||||
|
text-align: left;
|
||||||
|
padding-bottom: 3em;
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-container .close-button {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-container h2 {
|
||||||
|
margin: 3em 0 1em;
|
||||||
|
font-size: .9em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-container ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#settings-container ul li {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#settings-container ul li .current {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--green-color);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
#settings-container ul li .current.is-current {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#settings-container ul li .current.is-current + .avatar {
|
||||||
|
outline-color: var(--green-color);
|
||||||
|
outline-width: 2px;
|
||||||
|
}
|
||||||
|
#settings-container ul li > div {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
#settings-container ul li > div.actions {
|
||||||
|
flex-basis: min-content;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
#settings-container ul li > div:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
#settings-container div,
|
||||||
|
#settings-container div > *{
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
#settings-container .avatar {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-container .radio-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--button-bg-color);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
#settings-container .radio-group input[type="radio"] {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#settings-container .radio-group label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#settings-container .radio-group label input:checked + span {
|
||||||
|
color: var(--link-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#settings-container .radio-group label:hover {
|
||||||
|
color: var(--button-bg-color);
|
||||||
|
}
|
||||||
|
#settings-container .radio-group label:has(input:checked) {
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--button-text-color);
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
|
}
|
||||||
|
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
||||||
|
color: inherit;
|
||||||
|
}
|
186
src/pages/settings.jsx
Normal file
186
src/pages/settings.jsx
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import './settings.css';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Avatar from '../components/avatar';
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import NameText from '../components/name-text';
|
||||||
|
import store from '../utils/store';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Settings component that shows these settings:
|
||||||
|
- Accounts list for switching
|
||||||
|
- Dark/light/auto theme switch (done with adding/removing 'is-light' or 'is-dark' class on the body)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default ({ onClose }) => {
|
||||||
|
// Accounts
|
||||||
|
const accounts = store.local.getJSON('accounts');
|
||||||
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
const currentTheme = store.local.get('theme') || 'auto';
|
||||||
|
const themeFormRef = useRef();
|
||||||
|
const moreThanOneAccount = accounts.length > 1;
|
||||||
|
const [currentDefault, setCurrentDefault] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="settings-container" class="box">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="close-button plain" onClick={onClose}>
|
||||||
|
<Icon icon="x" alt="Close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Accounts</h2>
|
||||||
|
<ul class="accounts-list">
|
||||||
|
{accounts.map((account, i) => {
|
||||||
|
const isCurrent = account.info.id === currentAccount;
|
||||||
|
const isDefault = i === (currentDefault || 0);
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||||
|
<Icon icon="check-circle" alt="Current" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Avatar url={account.info.avatarStatic} size="xxl" />
|
||||||
|
<NameText account={account.info} showAcct />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
{isDefault && moreThanOneAccount && (
|
||||||
|
<>
|
||||||
|
<span class="tag">Default</span>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="transfer" /> Switch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{!isDefault && moreThanOneAccount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain small"
|
||||||
|
onClick={() => {
|
||||||
|
// Move account to the top of the list
|
||||||
|
accounts.splice(i, 1);
|
||||||
|
accounts.unshift(account);
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
setCurrentDefault(i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set as default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isCurrent && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain small"
|
||||||
|
onClick={() => {
|
||||||
|
const yes = confirm(
|
||||||
|
'Are you sure you want to log out?',
|
||||||
|
);
|
||||||
|
if (!yes) return;
|
||||||
|
accounts.splice(i, 1);
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
Note: <i>Default</i> account will always be used for first load.
|
||||||
|
Switched accounts will persist during the session.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p style={{ textAlign: 'end' }}>
|
||||||
|
<a href="/#/login" class="button" onClick={onClose}>
|
||||||
|
Add new account
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Theme</h2>
|
||||||
|
<form
|
||||||
|
ref={themeFormRef}
|
||||||
|
onInput={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(themeFormRef.current);
|
||||||
|
const theme = formData.get('theme');
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
if (theme === 'auto') {
|
||||||
|
html.classList.remove('is-light', 'is-dark');
|
||||||
|
} else {
|
||||||
|
html.classList.toggle('is-light', theme === 'light');
|
||||||
|
html.classList.toggle('is-dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="color-scheme"]')
|
||||||
|
.setAttribute('content', theme);
|
||||||
|
|
||||||
|
if (theme === 'auto') {
|
||||||
|
store.local.del('theme');
|
||||||
|
} else {
|
||||||
|
store.local.set('theme', theme);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme"
|
||||||
|
value="light"
|
||||||
|
defaultChecked={currentTheme === 'light'}
|
||||||
|
/>
|
||||||
|
<span>Light</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme"
|
||||||
|
value="dark"
|
||||||
|
defaultChecked={currentTheme === 'dark'}
|
||||||
|
/>
|
||||||
|
<span>Dark</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme"
|
||||||
|
value="auto"
|
||||||
|
defaultChecked={
|
||||||
|
currentTheme !== 'light' && currentTheme !== 'dark'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Auto</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
210
src/pages/status.jsx
Normal file
210
src/pages/status.jsx
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
import { Link } from 'preact-router/match';
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'preact/hooks';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import Status from '../components/status';
|
||||||
|
import states from '../utils/states';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
export default ({ id }) => {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [statuses, setStatuses] = useState([{ id }]);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const heroStatusRef = useRef();
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
// If id is completely new, reset the whole list
|
||||||
|
if (!statuses.find((s) => s.id === id)) {
|
||||||
|
setStatuses([{ id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIState('loading');
|
||||||
|
|
||||||
|
if (!states.statuses.has(id)) {
|
||||||
|
try {
|
||||||
|
const status = await masto.statuses.fetch(id);
|
||||||
|
states.statuses.set(id, status);
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
alert('Error fetching status');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await masto.statuses.fetchContext(id);
|
||||||
|
const { ancestors, descendants } = context;
|
||||||
|
|
||||||
|
ancestors.forEach((status) => {
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
});
|
||||||
|
const directReplies = [];
|
||||||
|
descendants.forEach((status) => {
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.inReplyToId === id) {
|
||||||
|
directReplies.push(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log({ ancestors, descendants, directReplies });
|
||||||
|
|
||||||
|
if (directReplies.length) {
|
||||||
|
const heroStatus = states.statuses.get(id);
|
||||||
|
const heroStatusRepliesCount = heroStatus.repliesCount;
|
||||||
|
if (heroStatusRepliesCount != directReplies.length) {
|
||||||
|
// If replies count doesn't match, refetch the status
|
||||||
|
const status = await masto.statuses.fetch(id);
|
||||||
|
states.statuses.set(id, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStatuses = [
|
||||||
|
...ancestors.map((s) => ({ id: s.id, ancestor: true })),
|
||||||
|
{ id },
|
||||||
|
...descendants.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
descendant: true,
|
||||||
|
directReply:
|
||||||
|
s.inReplyToId === id || s.inReplyToAccountId === s.account.id,
|
||||||
|
// I can assume if the reply is to the same account, it's a direct reply. In other words, it's a thread?!?
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
setStatuses(allStatuses);
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIState('default');
|
||||||
|
}, [id, snapStates.reloadStatusPage]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (heroStatusRef.current && statuses.length > 1) {
|
||||||
|
heroStatusRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
heroStatusRef.current?.scrollIntoView({
|
||||||
|
// behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}, [statuses]);
|
||||||
|
|
||||||
|
const heroStatus = states.statuses.get(id);
|
||||||
|
const heroDisplayName = useMemo(() => {
|
||||||
|
// Remove shortcodes from display name
|
||||||
|
if (!heroStatus) return '';
|
||||||
|
const { account } = heroStatus;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = account.displayName;
|
||||||
|
return div.innerText.trim();
|
||||||
|
}, [heroStatus]);
|
||||||
|
const heroContentText = useMemo(() => {
|
||||||
|
if (!heroStatus) return '';
|
||||||
|
const { content } = heroStatus;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = content;
|
||||||
|
let text = div.innerText.trim();
|
||||||
|
if (text.length > 64) {
|
||||||
|
text = text.slice(0, 64) + '…';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}, [heroStatus]);
|
||||||
|
useTitle(
|
||||||
|
heroDisplayName && heroContentText
|
||||||
|
? `${heroDisplayName}: ${heroContentText}`
|
||||||
|
: 'Status',
|
||||||
|
);
|
||||||
|
|
||||||
|
const comments = statuses.filter((s) => s.descendant);
|
||||||
|
const replies = comments.filter((s) => s.directReply);
|
||||||
|
|
||||||
|
const prevRoute = states.history.findLast((h) => {
|
||||||
|
return h === '/' || /notifications/i.test(h);
|
||||||
|
});
|
||||||
|
const closeLink = `#${prevRoute || '/'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="deck-backdrop">
|
||||||
|
<Link href={closeLink}></Link>
|
||||||
|
<div
|
||||||
|
class={`status-deck deck contained ${
|
||||||
|
statuses.length > 1 ? 'padded-bottom' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h1>Status</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
<Link class="button plain deck-close" href={closeLink}>
|
||||||
|
<Icon icon="x" size="xl" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul class="timeline flat contextual">
|
||||||
|
{statuses.map((status) => {
|
||||||
|
const { id: statusID, ancestor, descendant, directReply } = status;
|
||||||
|
const isHero = statusID === id;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={statusID}
|
||||||
|
ref={isHero ? heroStatusRef : null}
|
||||||
|
class={`${isHero ? '' : 'insignificant'} ${
|
||||||
|
ancestor ? 'ancestor' : ''
|
||||||
|
} ${descendant ? 'descendant' : ''} ${
|
||||||
|
descendant && !directReply ? 'indirect' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isHero ? (
|
||||||
|
<Status statusID={statusID} withinContext size="l" />
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
class="
|
||||||
|
status-link
|
||||||
|
"
|
||||||
|
href={`#/s/${statusID}`}
|
||||||
|
>
|
||||||
|
<Status statusID={statusID} withinContext />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{uiState === 'loading' &&
|
||||||
|
isHero &&
|
||||||
|
!!heroStatus?.repliesCount && (
|
||||||
|
<div class="status-loading">
|
||||||
|
<Loader />{' '}
|
||||||
|
<span>
|
||||||
|
{!!replies.length &&
|
||||||
|
replies.length !== comments.length && (
|
||||||
|
<>
|
||||||
|
{replies.length} repl
|
||||||
|
{replies.length > 1 ? 'ies' : 'y'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!!comments.length && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
• {comments.length} comment
|
||||||
|
{comments.length > 1 ? 's' : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
40
src/pages/welcome.css
Normal file
40
src/pages/welcome.css
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
#welcome {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome img {
|
||||||
|
margin-top: 16px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
@keyframes dance {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#welcome:hover img {
|
||||||
|
animation: dance 2s infinite 15s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome .warning {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 16px;
|
||||||
|
background: lemonchiffon;
|
||||||
|
color: chocolate;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
35
src/pages/welcome.jsx
Normal file
35
src/pages/welcome.jsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import './welcome.css';
|
||||||
|
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
useTitle();
|
||||||
|
return (
|
||||||
|
<main id="welcome" class="box">
|
||||||
|
<img
|
||||||
|
src="../design/logo-2.svg"
|
||||||
|
alt=""
|
||||||
|
width="140"
|
||||||
|
height="140"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p>Phanpy is a minimalistic opinionated Mastodon web client.</p>
|
||||||
|
<p class="warning">
|
||||||
|
🚧 This is an early ALPHA project. Many features are missing, many bugs
|
||||||
|
are present. Please report issues as detailed as possible. Thanks 🙏
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<big>
|
||||||
|
<b>
|
||||||
|
<a href="#/login" class="button">
|
||||||
|
Log in
|
||||||
|
</a>
|
||||||
|
</b>
|
||||||
|
</big>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
62
src/utils/auth.js
Normal file
62
src/utils/auth.js
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta
|
||||||
|
.env;
|
||||||
|
|
||||||
|
export async function registerApplication({ instanceURL }) {
|
||||||
|
const registrationParams = new URLSearchParams({
|
||||||
|
client_name: CLIENT_NAME,
|
||||||
|
redirect_uris: location.origin,
|
||||||
|
scopes: 'read write follow',
|
||||||
|
website: WEBSITE,
|
||||||
|
});
|
||||||
|
const registrationResponse = await fetch(
|
||||||
|
`https://${instanceURL}/api/v1/apps`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: registrationParams.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const registrationJSON = await registrationResponse.json();
|
||||||
|
console.log({ registrationJSON });
|
||||||
|
return registrationJSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthorizationURL({ instanceURL, client_id }) {
|
||||||
|
const authorizationParams = new URLSearchParams({
|
||||||
|
client_id,
|
||||||
|
scope: 'read write follow',
|
||||||
|
redirect_uri: location.origin,
|
||||||
|
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||||
|
response_type: 'code',
|
||||||
|
});
|
||||||
|
const authorizationURL = `https://${instanceURL}/oauth/authorize?${authorizationParams.toString()}`;
|
||||||
|
return authorizationURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessToken({
|
||||||
|
instanceURL,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
code,
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri: location.origin,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
scope: 'read write follow',
|
||||||
|
});
|
||||||
|
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
const tokenJSON = await tokenResponse.json();
|
||||||
|
console.log({ tokenJSON });
|
||||||
|
return tokenJSON;
|
||||||
|
}
|
16
src/utils/emojify-text.js
Normal file
16
src/utils/emojify-text.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
function emojifyText(text, emojis = []) {
|
||||||
|
if (!emojis.length) return text;
|
||||||
|
// Replace shortcodes in text with emoji
|
||||||
|
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const { shortcode, staticUrl, url } = emoji;
|
||||||
|
text = text.replace(
|
||||||
|
new RegExp(`:${shortcode}:`, 'g'),
|
||||||
|
`<img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="12" height="12" loading="lazy" />`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// console.log(text, emojis);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default emojifyText;
|
34
src/utils/enhance-content.js
Normal file
34
src/utils/enhance-content.js
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import emojifyText from './emojify-text';
|
||||||
|
|
||||||
|
export default (content, { emojis }) => {
|
||||||
|
// 1. Emojis
|
||||||
|
let enhancedContent = content;
|
||||||
|
|
||||||
|
if (emojis) {
|
||||||
|
enhancedContent = emojifyText(enhancedContent, emojis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Code blocks
|
||||||
|
const dom = document.createElement('div');
|
||||||
|
dom.innerHTML = enhancedContent;
|
||||||
|
// Check for <p> with markdown-like content "```"
|
||||||
|
const blocks = Array.from(dom.querySelectorAll('p')).filter((p) =>
|
||||||
|
/^```[^]+```$/g.test(p.innerText.trim()),
|
||||||
|
);
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
const code = document.createElement('code');
|
||||||
|
const breaks = block.querySelectorAll('br');
|
||||||
|
Array.from(breaks).forEach((br) => br.replaceWith('\n'));
|
||||||
|
code.innerHTML = block.innerText
|
||||||
|
.trim()
|
||||||
|
// .replace(/^```/g, '')
|
||||||
|
// .replace(/```$/g, '')
|
||||||
|
.replace(/^[\n\r]+/, '');
|
||||||
|
pre.appendChild(code);
|
||||||
|
block.replaceWith(pre);
|
||||||
|
});
|
||||||
|
enhancedContent = dom.innerHTML;
|
||||||
|
|
||||||
|
return enhancedContent;
|
||||||
|
};
|
5
src/utils/shorten-number.jsx
Normal file
5
src/utils/shorten-number.jsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export default function shortenNumber(num) {
|
||||||
|
return Intl.NumberFormat('en-US', {
|
||||||
|
notation: 'compact',
|
||||||
|
}).format(num);
|
||||||
|
}
|
19
src/utils/states.js
Normal file
19
src/utils/states.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { proxy } from 'valtio';
|
||||||
|
import { proxyMap } from 'valtio/utils';
|
||||||
|
|
||||||
|
export default proxy({
|
||||||
|
history: [],
|
||||||
|
statuses: proxyMap([]),
|
||||||
|
home: [],
|
||||||
|
homeNew: [],
|
||||||
|
homeLastFetchTime: null,
|
||||||
|
notifications: [],
|
||||||
|
notificationsNew: [],
|
||||||
|
notificationsLastFetchTime: null,
|
||||||
|
accounts: new WeakMap(),
|
||||||
|
reloadStatusPage: 0,
|
||||||
|
// Modals
|
||||||
|
showCompose: false,
|
||||||
|
showSettings: false,
|
||||||
|
showAccount: false,
|
||||||
|
});
|
87
src/utils/store.js
Normal file
87
src/utils/store.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
const local = {
|
||||||
|
get: (key) => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getJSON: (key) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(local.get(key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (key, value) => {
|
||||||
|
try {
|
||||||
|
return localStorage.setItem(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setJSON: (key, value) => {
|
||||||
|
try {
|
||||||
|
return local.set(key, JSON.stringify(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
del: (key) => {
|
||||||
|
try {
|
||||||
|
return localStorage.removeItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
get: (key) => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getJSON: (key) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(session.get(key));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (key, value) => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.setItem(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setJSON: (key, value) => {
|
||||||
|
try {
|
||||||
|
return session.set(key, JSON.stringify(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
del: (key) => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.removeItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { local, session };
|
9
src/utils/useTitle.js
Normal file
9
src/utils/useTitle.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||||
|
|
||||||
|
export default (title) => {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title ? `${title} - ${CLIENT_NAME}` : CLIENT_NAME;
|
||||||
|
}, [title]);
|
||||||
|
};
|
6
src/utils/visibility-icons-map.js
Normal file
6
src/utils/visibility-icons-map.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
public: 'earth',
|
||||||
|
unlisted: 'unlock',
|
||||||
|
private: 'lock',
|
||||||
|
direct: 'at',
|
||||||
|
};
|
7
vite.config.js
Normal file
7
vite.config.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
});
|
Loading…
Reference in a new issue