commit
451dc57a69
21
.github/workflows/bundlewatch.yml
vendored
21
.github/workflows/bundlewatch.yml
vendored
|
@ -1,21 +0,0 @@
|
||||||
name: BundleWatch
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
bundle:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
npx bundlewatch --max-size 100kb ./dist/**/*.js
|
|
||||||
env:
|
|
||||||
BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
|
|
||||||
CI_REPO_OWNER: cheeaun
|
|
||||||
CI_REPO_NAME: phanpy
|
|
||||||
CI_BRANCH_BASE: main
|
|
||||||
CI_BRANCH: main
|
|
15
PRIVACY.MD
Normal file
15
PRIVACY.MD
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Phanpy does not collect or process any personal information from its users. The website is used to connect to third-party Mastodon servers that may or may not collect personal information and are not covered by this privacy policy. Each third-party Mastodon server comes equipped with its own privacy policy that can be viewed through that server's website.
|
||||||
|
|
||||||
|
## Hosting
|
||||||
|
|
||||||
|
Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/).
|
||||||
|
|
||||||
|
## Error logging
|
||||||
|
|
||||||
|
Phanpy dev site (*dev.phanpy.social*) uses [Rollbar](https://rollbar.com/) to log errors for debugging purposes. Read more about [Rollbar's privacy policy](https://rollbar.com/privacy/). The production site (*phanpy.social*) does not use error logging.
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Phanpy uses [Cloudflare Web Analytics](https://www.cloudflare.com/web-analytics/) to collect anonymous usage statistics. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/).
|
|
@ -21,6 +21,8 @@ This is an alternative web client for [Mastodon](https://joinmastodon.org/).
|
||||||
- may break more often
|
- may break more often
|
||||||
- may be fixed much faster too
|
- may be fixed much faster too
|
||||||
|
|
||||||
|
🐘 Follow [@phanpy on Mastodon](https://hachyderm.io/@phanpy) for updates ✨
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
||||||
|
@ -62,6 +64,7 @@ Prerequisites: Node.js 18+
|
||||||
- [Vite](https://vitejs.dev/) - Build tool
|
- [Vite](https://vitejs.dev/) - Build tool
|
||||||
- [Preact](https://preactjs.com/) - UI library
|
- [Preact](https://preactjs.com/) - UI library
|
||||||
- [Valtio](https://valtio.pmnd.rs/) - State management
|
- [Valtio](https://valtio.pmnd.rs/) - State management
|
||||||
|
- [React Router](https://reactrouter.com/) - Routing
|
||||||
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||||
- [Iconify](https://iconify.design/) - Icon library
|
- [Iconify](https://iconify.design/) - Icon library
|
||||||
- Vanilla CSS - *Yes, I'm old school.*
|
- Vanilla CSS - *Yes, I'm old school.*
|
||||||
|
@ -90,14 +93,14 @@ And here I am. Building a Mastodon web client.
|
||||||
|
|
||||||
## Alternative web clients
|
## Alternative web clients
|
||||||
|
|
||||||
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/))
|
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) → [Semaphore](https://semaphore.social/)
|
||||||
- [Cuckoo+](https://www.cuckoo.social/)
|
- [Cuckoo+](https://www.cuckoo.social/)
|
||||||
- [Sengi](https://nicolasconstant.github.io/sengi/)
|
- [Sengi](https://nicolasconstant.github.io/sengi/)
|
||||||
- [Soapbox](https://fe.soapbox.pub/)
|
- [Soapbox](https://fe.soapbox.pub/)
|
||||||
- [Elk](https://elk.zone/)
|
- [Elk](https://elk.zone/)
|
||||||
- [Mastodeck](https://mastodeck.com/)
|
- [Mastodeck](https://mastodeck.com/)
|
||||||
- [Tooty](https://github.com/n1k0/tooty)
|
- [Tooty](https://github.com/n1k0/tooty)
|
||||||
- [More...](https://github.com/tleb/awesome-mastodon#clients)
|
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
57
index.html
57
index.html
|
@ -45,62 +45,5 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style="position: absolute; width: 0; height: 0"
|
|
||||||
>
|
|
||||||
<filter id="spoiler" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feColorMatrix
|
|
||||||
type="matrix"
|
|
||||||
values="0.01 0 0 0 0
|
|
||||||
0.01 0 0 0 0
|
|
||||||
0.01 0 0 0 0
|
|
||||||
0 0 0 .5 0"
|
|
||||||
in="SourceGraphic"
|
|
||||||
result="colormatrix"
|
|
||||||
/>
|
|
||||||
<feTurbulence
|
|
||||||
type="turbulence"
|
|
||||||
baseFrequency=".5 .5"
|
|
||||||
numOctaves="10"
|
|
||||||
seed="1"
|
|
||||||
result="turbulence"
|
|
||||||
/>
|
|
||||||
<feDisplacementMap
|
|
||||||
in="colormatrix"
|
|
||||||
in2="turbulence"
|
|
||||||
scale="20"
|
|
||||||
xChannelSelector="R"
|
|
||||||
yChannelSelector="B"
|
|
||||||
result="displacementMap"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
<filter id="spoiler-dark" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feColorMatrix
|
|
||||||
type="matrix"
|
|
||||||
values="1 0 0 .8 0
|
|
||||||
1 0 0 .8 0
|
|
||||||
1 0 0 .8 0
|
|
||||||
0 0 0 .5 0"
|
|
||||||
in="SourceGraphic"
|
|
||||||
result="colormatrix"
|
|
||||||
/>
|
|
||||||
<feTurbulence
|
|
||||||
type="turbulence"
|
|
||||||
baseFrequency=".5 .5"
|
|
||||||
numOctaves="10"
|
|
||||||
seed="1"
|
|
||||||
result="turbulence"
|
|
||||||
/>
|
|
||||||
<feDisplacementMap
|
|
||||||
in="colormatrix"
|
|
||||||
in2="turbulence"
|
|
||||||
scale="20"
|
|
||||||
xChannelSelector="R"
|
|
||||||
yChannelSelector="B"
|
|
||||||
result="displacementMap"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
</svg>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
1929
package-lock.json
generated
1929
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
@ -12,32 +12,34 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@github/text-expander-element": "~2.3.0",
|
"@github/text-expander-element": "~2.3.0",
|
||||||
"@iconify-icons/mingcute": "~1.2.3",
|
"@iconify-icons/mingcute": "~1.2.3",
|
||||||
|
"@szhsin/react-menu": "~3.4.0",
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
"fast-deep-equal": "~3.1.3",
|
"fast-deep-equal": "~3.1.3",
|
||||||
"history": "~5.3.0",
|
|
||||||
"idb-keyval": "~6.2.0",
|
"idb-keyval": "~6.2.0",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~5.5.0",
|
"masto": "~5.7.0",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
|
"p-retry": "~5.1.2",
|
||||||
"preact": "~10.11.3",
|
"preact": "~10.11.3",
|
||||||
"preact-router": "~4.1.0",
|
"react-hotkeys-hook": "~4.3.3",
|
||||||
"react-hotkeys-hook": "~4.3.2",
|
|
||||||
"react-intersection-observer": "~9.4.1",
|
"react-intersection-observer": "~9.4.1",
|
||||||
|
"react-router-dom": "6.6.2",
|
||||||
"string-length": "~5.0.1",
|
"string-length": "~5.0.1",
|
||||||
"swiped-events": "~1.1.7",
|
"swiped-events": "~1.1.7",
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.1",
|
"uid": "~2.0.1",
|
||||||
|
"use-debounce": "~9.0.3",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "~1.9.0"
|
"valtio": "~1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "~2.5.0",
|
"@preact/preset-vite": "~2.5.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
||||||
"autoprefixer": "~10.4.13",
|
|
||||||
"postcss": "~8.4.21",
|
"postcss": "~8.4.21",
|
||||||
"postcss-dark-theme-class": "~0.7.3",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
|
"postcss-preset-env": "~8.0.1",
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~4.0.4",
|
"vite": "~4.0.4",
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
|
@ -52,7 +54,7 @@
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"postcss-dark-theme-class": {},
|
"postcss-dark-theme-class": {},
|
||||||
"autoprefixer": {}
|
"postcss-preset-env": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
|
531
src/app.css
531
src/app.css
|
@ -6,6 +6,9 @@ body {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
body {
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
@ -46,6 +49,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
transition: opacity 0.1s ease-in-out;
|
transition: opacity 0.1s ease-in-out;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
.deck-container[hidden] {
|
.deck-container[hidden] {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -66,7 +70,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
width: 40em;
|
width: 40em;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
|
@ -114,6 +118,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.deck > header h1:first-child {
|
.deck > header h1:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
@ -146,17 +151,26 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline.contextual {
|
||||||
|
--thread-start: 40px;
|
||||||
|
--line-start: 40px;
|
||||||
|
--line-width: 3px;
|
||||||
|
--line-end: calc(var(--line-start) + var(--line-width));
|
||||||
|
--line-margin-end: 16px;
|
||||||
|
--line-radius: 10px;
|
||||||
|
--line-diameter: calc(var(--line-radius) * 2);
|
||||||
|
--avatar-size: 50px;
|
||||||
|
--avatar-margin-start: 16px;
|
||||||
|
--avatar-margin-end: 12px;
|
||||||
|
}
|
||||||
.timeline.contextual > li {
|
.timeline.contextual > li {
|
||||||
--width: 3px;
|
|
||||||
--left: 40px;
|
|
||||||
--right: calc(var(--left) + var(--width));
|
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent,
|
transparent,
|
||||||
transparent var(--left),
|
transparent var(--line-start),
|
||||||
var(--comment-line-color) var(--left),
|
var(--comment-line-color) var(--line-start),
|
||||||
var(--comment-line-color) var(--right),
|
var(--comment-line-color) var(--line-end),
|
||||||
transparent var(--right),
|
transparent var(--line-end),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
@ -182,41 +196,127 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
> summary {
|
> summary {
|
||||||
margin-left: calc(50px + 16px + 12px);
|
margin-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant.thread
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
> summary {
|
||||||
|
margin-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant.thread
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.replies
|
||||||
|
> summary {
|
||||||
|
margin-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
(var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant.thread
|
> li.descendant.thread
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
.status-link {
|
.status-link {
|
||||||
padding-left: calc(50px + 16px + 12px);
|
padding-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant.thread
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.status-link {
|
||||||
|
padding-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant.thread
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.replies
|
||||||
|
.status-link {
|
||||||
|
padding-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
(var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant:not(.thread)
|
> li.descendant:not(.thread)
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
> summary {
|
> summary {
|
||||||
margin-left: calc(40px + 16px);
|
margin-left: calc(var(--thread-start) + var(--line-margin-end));
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant:not(.thread)
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
> summary {
|
||||||
|
margin-left: calc(
|
||||||
|
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant:not(.thread)
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.replies
|
||||||
|
> summary {
|
||||||
|
margin-left: calc(
|
||||||
|
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant:not(.thread)
|
> li.descendant:not(.thread)
|
||||||
> .status-link
|
> .status-link
|
||||||
+ .replies
|
+ .replies
|
||||||
.status-link {
|
.status-link {
|
||||||
padding-left: calc(40px + 16px);
|
padding-left: calc(var(--thread-start) + var(--line-margin-end));
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant:not(.thread)
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.status-link {
|
||||||
|
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 2));
|
||||||
|
}
|
||||||
|
.timeline.contextual
|
||||||
|
> li.descendant:not(.thread)
|
||||||
|
> .status-link
|
||||||
|
+ .replies
|
||||||
|
.replies
|
||||||
|
.replies
|
||||||
|
.status-link {
|
||||||
|
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 3));
|
||||||
}
|
}
|
||||||
.timeline.contextual > li.descendant:not(.thread):before {
|
.timeline.contextual > li.descendant:not(.thread):before {
|
||||||
--radius: 10px;
|
|
||||||
--diameter: calc(var(--radius) * 2);
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 40px;
|
left: var(--line-start);
|
||||||
width: var(--diameter);
|
width: var(--line-diameter);
|
||||||
height: var(--diameter);
|
height: var(--line-diameter);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--line-radius);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: var(--width);
|
border-width: var(--line-width);
|
||||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
@ -228,7 +328,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li.thread > .status-link .replies-link {
|
.timeline.contextual > li.thread > .status-link .replies-link {
|
||||||
margin-left: calc(50px + 16px + 12px);
|
margin-left: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies-link * {
|
.timeline.contextual > li .replies-link * {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
@ -241,22 +343,33 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies summary {
|
.timeline.contextual > li .replies > summary {
|
||||||
padding: 8px 16px;
|
padding: 8px;
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: bold;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
box-shadow: 0 0 0 2px var(--bg-color);
|
box-shadow: 0 0 0 2px var(--bg-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies summary:active,
|
.timeline.contextual > li .replies > summary::-webkit-details-marker {
|
||||||
.timeline.contextual > li .replies[open] summary {
|
display: none;
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies > summary > * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies > summary .avatars {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies > summary:active,
|
||||||
|
.timeline.contextual > li .replies[open] > summary {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--comment-line-color);
|
background-color: var(--comment-line-color);
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
|
@ -265,7 +378,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
var(--bg-faded-color)
|
var(--bg-faded-color)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies[open] summary {
|
.timeline.contextual > li .replies[open] > summary {
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies summary[hidden] {
|
.timeline.contextual > li .replies summary[hidden] {
|
||||||
|
@ -275,52 +388,89 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li {
|
.timeline.contextual > li .replies li {
|
||||||
--width: 3px;
|
--line-start: calc(var(--thread-start) + var(--line-margin-end));
|
||||||
--left: calc(40px + 16px);
|
--line-end: calc(var(--line-start) + var(--line-width));
|
||||||
--right: calc(var(--left) + var(--width));
|
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent,
|
transparent,
|
||||||
transparent var(--left),
|
transparent var(--line-start),
|
||||||
var(--comment-line-color) var(--left),
|
var(--comment-line-color) var(--line-start),
|
||||||
var(--comment-line-color) var(--right),
|
var(--comment-line-color) var(--line-end),
|
||||||
transparent var(--right),
|
transparent var(--line-end),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
.timeline.contextual > li .replies .replies li {
|
||||||
|
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 2));
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies .replies .replies li {
|
||||||
|
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 3));
|
||||||
|
}
|
||||||
.timeline.contextual > li.thread .replies li {
|
.timeline.contextual > li.thread .replies li {
|
||||||
--left: calc(50px + 16px + 12px);
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li.thread .replies .replies li {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li.thread .replies .replies .replies li {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
(var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li:last-child {
|
.timeline.contextual > li .replies li:last-child {
|
||||||
background-size: 100% 20px;
|
background-size: 100% 20px;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li:before {
|
.timeline.contextual > li .replies li:before {
|
||||||
--radius: 10px;
|
|
||||||
--diameter: calc(var(--radius) * 2);
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: calc(40px + 16px);
|
left: var(--line-start);
|
||||||
width: var(--diameter);
|
width: var(--line-diameter);
|
||||||
height: var(--diameter);
|
height: var(--line-diameter);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--line-radius);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: var(--width);
|
border-width: var(--line-width);
|
||||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
.timeline.contextual > li .replies .replies li:before {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li .replies .replies .replies li:before {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
.timeline.contextual > li.thread .replies li:before {
|
.timeline.contextual > li.thread .replies li:before {
|
||||||
left: calc(50px + 16px + 12px);
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li.thread .replies .replies li:before {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
var(--line-margin-end)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.timeline.contextual > li.thread .replies .replies .replies li:before {
|
||||||
|
--line-start: calc(
|
||||||
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
|
(var(--line-margin-end) * 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.timeline.contextual.loading > li:not(.hero) {
|
.timeline.contextual.loading > li:not(.hero) {
|
||||||
opacity: 0.5;
|
/* opacity: 0.5; */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* background-image: none !important; */
|
|
||||||
}
|
}
|
||||||
/* .timeline.contextual.loading > li:not(.hero):before {
|
|
||||||
content: none !important;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.timeline-deck.compact .status {
|
.timeline-deck.compact .status {
|
||||||
max-height: max(25vh, 160px);
|
max-height: max(25vh, 160px);
|
||||||
|
@ -364,10 +514,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
animation: appear 0.2s ease-out;
|
animation: appear 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.status-link:is(:hover, :focus) {
|
:is(.status-link, .status-focus):is(:focus, .is-active) {
|
||||||
background-color: var(--link-bg-hover-color);
|
background-color: var(--link-bg-hover-color);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.status-link:hover {
|
||||||
|
background-color: var(--link-bg-hover-color);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.status-link:active:not(:has(:is(.media, button):active)) {
|
.status-link:active:not(:has(:is(.media, button):active)) {
|
||||||
filter: brightness(0.95);
|
filter: brightness(0.95);
|
||||||
}
|
}
|
||||||
|
@ -467,9 +623,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px var(--bg-color);
|
box-shadow: 0 1px var(--bg-color);
|
||||||
}
|
}
|
||||||
.status-boost-link:is(:hover, :focus) {
|
.status-boost-link::focus {
|
||||||
background-color: var(--link-bg-hover-color);
|
background-color: var(--link-bg-hover-color);
|
||||||
}
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.status-boost-link:hover {
|
||||||
|
background-color: var(--link-bg-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
.status-boost-link:active:not(:has(:is(.media, button):active)) {
|
.status-boost-link:active:not(:has(:is(.media, button):active)) {
|
||||||
filter: brightness(0.95);
|
filter: brightness(0.95);
|
||||||
}
|
}
|
||||||
|
@ -508,11 +669,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.decks {
|
|
||||||
flex-grow: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-close {
|
.deck-close {
|
||||||
color: var(--text-insignificant-color) !important;
|
color: var(--text-insignificant-color) !important;
|
||||||
}
|
}
|
||||||
|
@ -582,6 +738,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
touch-action: pan-x;
|
touch-action: pan-x;
|
||||||
|
user-select: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.carousel::-webkit-scrollbar {
|
.carousel::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -593,7 +751,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
background-color: var(--average-color-alpha);
|
background-color: var(--average-color-alpha);
|
||||||
|
@ -606,7 +764,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
}
|
}
|
||||||
.carousel > * :is(img, video) {
|
.carousel > * :is(img, video) {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
|
@ -621,10 +779,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
top: 0;
|
top: 0;
|
||||||
top: env(safe-area-inset-top, 0);
|
top: env(safe-area-inset-top, 0);
|
||||||
}
|
}
|
||||||
.carousel-controls {
|
|
||||||
bottom: 0;
|
|
||||||
bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
}
|
|
||||||
:is(.carousel-top-controls, .carousel-controls) {
|
:is(.carousel-top-controls, .carousel-controls) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -654,26 +808,23 @@ button.carousel-dot {
|
||||||
}
|
}
|
||||||
.carousel-dots {
|
.carousel-dots {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
|
backdrop-filter: blur(12px) invert(0.25);
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.carousel-dots {
|
|
||||||
backdrop-filter: blur(12px) brightness(0.5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
button.carousel-dot {
|
button.carousel-dot {
|
||||||
color: var(--text-insignificant-color) !important;
|
color: var(--text-insignificant-color) !important;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
button.carousel-dot:is(:hover, :focus) button.carousel-dot.active,
|
button.carousel-dot[disabled] {
|
||||||
button.carousel-dot[disabled].active {
|
pointer-events: none;
|
||||||
color: var(--link-color) !important;
|
|
||||||
}
|
}
|
||||||
button.carousel-dot.active,
|
button.carousel-dot:is(:hover, :focus, .active, [disabled].active) {
|
||||||
button.carousel-dot[disabled].active {
|
color: var(--button-text-color) !important;
|
||||||
|
}
|
||||||
|
button.carousel-dot:is(.active, [disabled].active) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(2) translateY(-0.5px);
|
transform: scale(2.2) translateY(-0.5px);
|
||||||
}
|
}
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.carousel-top-controls {
|
.carousel-top-controls {
|
||||||
|
@ -681,8 +832,8 @@ button.carousel-dot[disabled].active {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
.carousel-controls {
|
.carousel-controls {
|
||||||
transform: translateY(100%);
|
transform: scale(0);
|
||||||
transition: transform 0.2s ease-in-out;
|
/* transition: transform 0.2s ease-in-out; */
|
||||||
}
|
}
|
||||||
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
@ -696,10 +847,33 @@ button.carousel-dot[disabled].active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.carousel :is(img, video) {
|
/* CAROUSEL + STATUS PAGE COMBO */
|
||||||
/* No need fade out if inside carousel */
|
|
||||||
filter: none;
|
.media-post-link .button-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: calc(40em + 350px)) {
|
||||||
|
.media-post-link .button-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
#modal-container > div,
|
||||||
|
.status-deck {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
/* Don't do this if there's a modal sheet (.sheet) */
|
||||||
|
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
|
||||||
|
.status-deck {
|
||||||
|
width: 350px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
|
||||||
|
#modal-container
|
||||||
|
> div {
|
||||||
|
left: 0;
|
||||||
|
right: 350px;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -806,54 +980,30 @@ button.carousel-dot[disabled].active {
|
||||||
|
|
||||||
/* MENU POPUP */
|
/* MENU POPUP */
|
||||||
|
|
||||||
.menu-container {
|
.szh-menu {
|
||||||
position: relative;
|
padding: 8px 0 !important;
|
||||||
}
|
|
||||||
.menu-container button {
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
.menu-container button:is(:hover, :active, :focus) {
|
|
||||||
background-color: var(--button-plain-bg-hover-color);
|
|
||||||
}
|
|
||||||
.menu-container menu {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
transform: translateY(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
padding: 8px 0;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color) !important;
|
||||||
width: 10em;
|
border: 1px solid var(--outline-color) !important;
|
||||||
list-style: none;
|
|
||||||
z-index: 100;
|
|
||||||
border: 1px solid var(--outline-color);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease-in-out;
|
box-shadow: 0 3px 6px var(--drop-shadow-color);
|
||||||
box-shadow: 0 0 8px var(--bg-faded-color), 0 4px 8px var(--bg-faded-color),
|
|
||||||
0 2px 4px var(--bg-faded-color);
|
|
||||||
}
|
|
||||||
.menu-container menu li {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.menu-container > button:is(:hover, :active, :focus) + menu,
|
|
||||||
.menu-container menu:is(:hover, :active) {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.menu-container menu button {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--text-color) !important;
|
animation: appear 0.15s ease-in-out;
|
||||||
border-radius: 0;
|
|
||||||
}
|
}
|
||||||
.menu-container menu button:is(:hover, :focus) {
|
.szh-menu .szh-menu__item {
|
||||||
color: var(--bg-color) !important;
|
padding: 8px 16px !important;
|
||||||
background-color: var(--link-color);
|
}
|
||||||
|
.szh-menu .szh-menu__item * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.szh-menu
|
||||||
|
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.szh-menu .szh-menu__item--hover {
|
||||||
|
color: var(--button-text-color);
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DONUT METER */
|
/* DONUT METER */
|
||||||
|
@ -943,19 +1093,109 @@ meter.donut:is(.danger, .explode):after {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
/* I'm just feeling bored, so having fun here */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.avatars-stack > * {
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.avatars-stack:hover > *:nth-of-type(odd) {
|
||||||
|
transform: rotate(15deg);
|
||||||
|
}
|
||||||
|
.avatars-stack:hover > *:nth-of-type(even) {
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-container {
|
||||||
|
width: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
#home-page ~ .deck-container {
|
||||||
|
z-index: 10;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
#home-page:has(~ .deck-container) {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
content-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TAB BAR */
|
||||||
|
|
||||||
|
#tab-bar:not([hidden]) {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: calc(40em - 32px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
backdrop-filter: blur(16px) saturate(3);
|
||||||
|
border: var(--hairline-width) solid var(--outline-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px var(--outline-color);
|
||||||
|
}
|
||||||
|
#tab-bar li {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#tab-bar li a {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 404 */
|
||||||
|
|
||||||
|
#not-found-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
var(--bg-color) 25%,
|
||||||
|
var(--bg-faded-color)
|
||||||
|
);
|
||||||
|
text-shadow: 0 1px var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ACCOUNT STATUSES */
|
||||||
|
|
||||||
|
.header-account {
|
||||||
|
font-size: 90% !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.header-account div {
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
}
|
}
|
||||||
|
.deck-container {
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
}
|
||||||
#app {
|
#app {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.decks {
|
.deck-container {
|
||||||
transition: transform 0.4s var(--timing-function);
|
transition: transform 0.4s var(--timing-function);
|
||||||
}
|
}
|
||||||
.decks:has(~ .deck-backdrop) {
|
.deck-container:has(~ .deck-backdrop) {
|
||||||
transition: transform 0.4s ease-out;
|
transition: transform 0.4s ease-out;
|
||||||
transform: translate3d(-5vw, 0, 0);
|
transform: translate3d(-5vw, 0, 0);
|
||||||
}
|
}
|
||||||
|
@ -969,25 +1209,25 @@ meter.donut:is(.danger, .explode):after {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.timeline-deck > header {
|
.timeline-deck > header {
|
||||||
min-height: 6em;
|
--margin-top: 8px;
|
||||||
|
min-height: 4em;
|
||||||
|
top: var(--margin-top);
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
background-color: var(--bg-faded-blur-color);
|
background-color: var(--bg-faded-blur-color);
|
||||||
background-image: linear-gradient(
|
background-image: none;
|
||||||
to bottom,
|
|
||||||
var(--bg-faded-color),
|
|
||||||
transparent 50%
|
|
||||||
);
|
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
mask-image: linear-gradient(
|
border-radius: 16px;
|
||||||
rgba(0, 0, 0, 1) 50%,
|
margin-inline: 8px;
|
||||||
rgba(0, 0, 0, 0.7) 80%,
|
}
|
||||||
rgba(0, 0, 0, 0.5) 90%,
|
.timeline-deck > header[hidden] {
|
||||||
transparent
|
transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
.deck > header h1 {
|
.deck > header h1 {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
.updates-button {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
.timeline-deck .timeline:not(.flat) > li {
|
.timeline-deck .timeline:not(.flat) > li {
|
||||||
border: 1px solid var(--divider-color);
|
border: 1px solid var(--divider-color);
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
|
@ -995,15 +1235,32 @@ meter.donut:is(.danger, .explode):after {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0px 1px var(--bg-blur-color);
|
box-shadow: 0px 1px var(--bg-blur-color);
|
||||||
|
transition: transform 0.4s var(--timing-function);
|
||||||
|
--back-transition: transform 0.4s ease-out;
|
||||||
|
}
|
||||||
|
.timeline-deck .timeline:not(.flat) > li:has(.status-link.is-active) {
|
||||||
|
transition: var(--back-transition);
|
||||||
|
transform: translate3d(-2.5vw, 0, 0);
|
||||||
|
}
|
||||||
|
.timeline-deck
|
||||||
|
.timeline:not(.flat)
|
||||||
|
> li:not(:has(.boost-carousel)):has(+ li .status-link.is-active),
|
||||||
|
.timeline-deck
|
||||||
|
.timeline:not(.flat)
|
||||||
|
> li:not(:has(.boost-carousel)):has(.status-link.is-active)
|
||||||
|
+ li {
|
||||||
|
transition: var(--back-transition);
|
||||||
|
transform: translate3d(-1.25vw, 0, 0);
|
||||||
}
|
}
|
||||||
.box {
|
.box {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
:is(.carousel-top-controls, .carousel-controls) {
|
/* :is(.carousel-top-controls, .carousel-controls) {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
} */
|
||||||
li:has(.boost-carousel) {
|
li:has(.boost-carousel) {
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
|
max-width: calc(320px * 3.3);
|
||||||
transform: translateX(calc(-50% + 20em));
|
transform: translateX(calc(-50% + 20em));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
399
src/app.jsx
399
src/app.jsx
|
@ -1,22 +1,37 @@
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import 'toastify-js/src/toastify.css';
|
import 'toastify-js/src/toastify.css';
|
||||||
|
|
||||||
import { createHashHistory } from 'history';
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { login } from 'masto';
|
import { createClient } from 'masto';
|
||||||
import Router, { route } from 'preact-router';
|
import {
|
||||||
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'preact/hooks';
|
||||||
|
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import Toastify from 'toastify-js';
|
import Toastify from 'toastify-js';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Account from './components/account';
|
import Account from './components/account';
|
||||||
import Compose from './components/compose';
|
import Compose from './components/compose';
|
||||||
import Drafts from './components/drafts';
|
import Drafts from './components/drafts';
|
||||||
|
import Icon from './components/icon';
|
||||||
|
import Link from './components/link';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
|
import MediaModal from './components/media-modal';
|
||||||
import Modal from './components/modal';
|
import Modal from './components/modal';
|
||||||
|
import NotFound from './pages/404';
|
||||||
|
import AccountStatuses from './pages/account-statuses';
|
||||||
|
import Bookmarks from './pages/bookmarks';
|
||||||
|
import Favourites from './pages/favourites';
|
||||||
|
import Hashtags from './pages/hashtags';
|
||||||
import Home from './pages/home';
|
import Home from './pages/home';
|
||||||
|
import Lists from './pages/lists';
|
||||||
import Login from './pages/login';
|
import Login from './pages/login';
|
||||||
import Notifications from './pages/notifications';
|
import Notifications from './pages/notifications';
|
||||||
|
import Public from './pages/public';
|
||||||
import Settings from './pages/settings';
|
import Settings from './pages/settings';
|
||||||
import Status from './pages/status';
|
import Status from './pages/status';
|
||||||
import Welcome from './pages/welcome';
|
import Welcome from './pages/welcome';
|
||||||
|
@ -24,14 +39,13 @@ import { getAccessToken } from './utils/auth';
|
||||||
import states, { saveStatus } from './utils/states';
|
import states, { saveStatus } from './utils/states';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
|
|
||||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
|
||||||
|
|
||||||
window.__STATES__ = states;
|
window.__STATES__ = states;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [uiState, setUIState] = useState('loading');
|
const [uiState, setUIState] = useState('loading');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const theme = store.local.get('theme');
|
const theme = store.local.get('theme');
|
||||||
|
@ -67,11 +81,9 @@ function App() {
|
||||||
const { access_token: accessToken } = tokenJSON;
|
const { access_token: accessToken } = tokenJSON;
|
||||||
store.session.set('accessToken', accessToken);
|
store.session.set('accessToken', accessToken);
|
||||||
|
|
||||||
window.masto = await login({
|
initMasto({
|
||||||
url: `https://${instanceURL}`,
|
url: `https://${instanceURL}`,
|
||||||
accessToken,
|
accessToken,
|
||||||
disableVersionCheck: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
||||||
|
@ -105,41 +117,35 @@ function App() {
|
||||||
const instanceURL = account.instanceURL;
|
const instanceURL = account.instanceURL;
|
||||||
const accessToken = account.accessToken;
|
const accessToken = account.accessToken;
|
||||||
store.session.set('currentAccount', account.info.id);
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
if (accessToken) setIsLoggedIn(true);
|
||||||
|
|
||||||
(async () => {
|
initMasto({
|
||||||
try {
|
url: `https://${instanceURL}`,
|
||||||
setUIState('loading');
|
accessToken,
|
||||||
window.masto = await login({
|
});
|
||||||
url: `https://${instanceURL}`,
|
|
||||||
accessToken,
|
|
||||||
disableVersionCheck: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
} catch (e) {
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
}
|
|
||||||
setUIState('default');
|
|
||||||
})();
|
|
||||||
} else {
|
} else {
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [currentDeck, setCurrentDeck] = useState('home');
|
let location = useLocation();
|
||||||
const [currentModal, setCurrentModal] = useState(null);
|
states.currentLocation = location.pathname;
|
||||||
|
|
||||||
|
const locationDeckMap = {
|
||||||
|
'/': 'home-page',
|
||||||
|
'/notifications': 'notifications-page',
|
||||||
|
};
|
||||||
const focusDeck = () => {
|
const focusDeck = () => {
|
||||||
if (currentModal) return;
|
|
||||||
let timer = setTimeout(() => {
|
let timer = setTimeout(() => {
|
||||||
const page = document.getElementById(`${currentDeck}-page`);
|
const page = document.getElementById(locationDeckMap[location.pathname]);
|
||||||
console.debug('FOCUS', currentDeck, page);
|
console.debug('FOCUS', location.pathname, page);
|
||||||
if (page) {
|
if (page) {
|
||||||
page.focus();
|
page.focus();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
};
|
};
|
||||||
useEffect(focusDeck, [currentDeck, currentModal]);
|
useEffect(focusDeck, [location]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!snapStates.showCompose &&
|
!snapStates.showCompose &&
|
||||||
|
@ -153,64 +159,80 @@ function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// HACK: prevent this from running again due to HMR
|
// HACK: prevent this from running again due to HMR
|
||||||
if (states.init) return;
|
if (states.init) return;
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(startVisibility);
|
||||||
startStream();
|
|
||||||
startVisibility();
|
|
||||||
|
|
||||||
// Collect instance info
|
|
||||||
(async () => {
|
|
||||||
const info = await masto.v1.instances.fetch();
|
|
||||||
console.log(info);
|
|
||||||
const { uri, domain } = info;
|
|
||||||
const instances = store.local.getJSON('instances') || {};
|
|
||||||
instances[(domain || uri).toLowerCase()] = info;
|
|
||||||
store.local.setJSON('instances', instances);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
states.init = true;
|
states.init = true;
|
||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
const { prevLocation } = snapStates;
|
||||||
|
const backgroundLocation = useRef(prevLocation || null);
|
||||||
|
const isModalPage = /^\/s\//i.test(location.pathname);
|
||||||
|
if (isModalPage) {
|
||||||
|
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
|
||||||
|
} else {
|
||||||
|
backgroundLocation.current = null;
|
||||||
|
}
|
||||||
|
console.debug({
|
||||||
|
backgroundLocation: backgroundLocation.current,
|
||||||
|
location,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonRootLocation = useMemo(() => {
|
||||||
|
const { pathname } = location;
|
||||||
|
return !/^\/(login|welcome|p)/.test(pathname);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoggedIn && currentDeck && (
|
<Routes location={nonRootLocation || location}>
|
||||||
<div class="decks">
|
<Route
|
||||||
{/* Home will never be unmounted */}
|
path="/"
|
||||||
<Home hidden={currentDeck !== 'home'} />
|
element={
|
||||||
{/* Notifications can be unmounted */}
|
isLoggedIn ? (
|
||||||
{currentDeck === 'notifications' && <Notifications />}
|
<Home />
|
||||||
</div>
|
) : uiState === 'loading' ? (
|
||||||
)}
|
<Loader />
|
||||||
{!isLoggedIn && uiState === 'loading' && <Loader />}
|
) : (
|
||||||
<Router
|
<Welcome />
|
||||||
history={createHashHistory()}
|
)
|
||||||
onChange={(e) => {
|
|
||||||
console.debug('ROUTER onChange', e);
|
|
||||||
// Special handling for Home and Notifications
|
|
||||||
const { url } = e;
|
|
||||||
if (/notifications/i.test(url)) {
|
|
||||||
setCurrentDeck('notifications');
|
|
||||||
setCurrentModal(null);
|
|
||||||
} else if (url === '/') {
|
|
||||||
setCurrentDeck('home');
|
|
||||||
document.title = `Home / ${CLIENT_NAME}`;
|
|
||||||
setCurrentModal(null);
|
|
||||||
} else if (/^\/s\//i.test(url)) {
|
|
||||||
setCurrentModal('status');
|
|
||||||
} else {
|
|
||||||
setCurrentModal(null);
|
|
||||||
setCurrentDeck(null);
|
|
||||||
}
|
}
|
||||||
states.history.push(url);
|
/>
|
||||||
}}
|
<Route path="/login" element={<Login />} />
|
||||||
>
|
<Route path="/welcome" element={<Welcome />} />
|
||||||
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />}
|
</Routes>
|
||||||
<Welcome path="/welcome" />
|
<Routes location={backgroundLocation.current || location}>
|
||||||
{isLoggedIn && <Status path="/s/:id" />}
|
{isLoggedIn && (
|
||||||
<Login path="/login" />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
</Router>
|
)}
|
||||||
|
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
|
||||||
|
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
|
||||||
|
{isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
|
||||||
|
{isLoggedIn && <Route path="/t/:hashtag" element={<Hashtags />} />}
|
||||||
|
{isLoggedIn && <Route path="/a/:id" element={<AccountStatuses />} />}
|
||||||
|
<Route path="/p/l?/:instance" element={<Public />} />
|
||||||
|
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||||
|
</Routes>
|
||||||
|
<Routes>
|
||||||
|
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
|
||||||
|
</Routes>
|
||||||
|
<nav id="tab-bar" hidden>
|
||||||
|
<li>
|
||||||
|
<Link to="/">
|
||||||
|
<Icon icon="home" alt="Home" size="xl" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/notifications">
|
||||||
|
<Icon icon="notification" alt="Notifications" size="xl" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/bookmarks">
|
||||||
|
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</nav>
|
||||||
{!!snapStates.showCompose && (
|
{!!snapStates.showCompose && (
|
||||||
<Modal>
|
<Modal>
|
||||||
<Compose
|
<Compose
|
||||||
|
@ -244,7 +266,8 @@ function App() {
|
||||||
// destination: `/#/s/${newStatus.id}`,
|
// destination: `/#/s/${newStatus.id}`,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
toast.hideToast();
|
toast.hideToast();
|
||||||
route(`/s/${newStatus.id}`);
|
states.prevLocation = location;
|
||||||
|
navigate(`/s/${newStatus.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.showToast();
|
toast.showToast();
|
||||||
|
@ -278,7 +301,12 @@ function App() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Account account={snapStates.showAccount} />
|
<Account
|
||||||
|
account={snapStates.showAccount}
|
||||||
|
onClose={() => {
|
||||||
|
states.showAccount = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{!!snapStates.showDrafts && (
|
{!!snapStates.showDrafts && (
|
||||||
|
@ -292,24 +320,109 @@ function App() {
|
||||||
<Drafts />
|
<Drafts />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!snapStates.showMediaModal && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
e.target === e.currentTarget ||
|
||||||
|
e.target.classList.contains('media')
|
||||||
|
) {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaModal
|
||||||
|
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
|
||||||
|
index={snapStates.showMediaModal.index}
|
||||||
|
statusID={snapStates.showMediaModal.statusID}
|
||||||
|
onClose={() => {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initMasto(params) {
|
||||||
|
const clientParams = {
|
||||||
|
url: params.url || 'https://mastodon.social',
|
||||||
|
accessToken: params.accessToken || null,
|
||||||
|
disableVersionCheck: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
};
|
||||||
|
window.masto = createClient(clientParams);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Request v2, fallback to v1 if fail
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await masto.v2.instance.fetch();
|
||||||
|
} catch (e) {}
|
||||||
|
if (!info) {
|
||||||
|
try {
|
||||||
|
info = await masto.v1.instances.fetch();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (!info) return;
|
||||||
|
console.log(info);
|
||||||
|
const {
|
||||||
|
// v1
|
||||||
|
uri,
|
||||||
|
urls: { streamingApi } = {},
|
||||||
|
// v2
|
||||||
|
domain,
|
||||||
|
configuration: { urls: { streaming } = {} } = {},
|
||||||
|
} = info;
|
||||||
|
if (uri || domain) {
|
||||||
|
const instances = store.local.getJSON('instances') || {};
|
||||||
|
instances[
|
||||||
|
(domain || uri)
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.toLowerCase()
|
||||||
|
] = info;
|
||||||
|
store.local.setJSON('instances', instances);
|
||||||
|
}
|
||||||
|
if (streamingApi || streaming) {
|
||||||
|
window.masto = createClient({
|
||||||
|
...clientParams,
|
||||||
|
streamingApiUrl: streaming || streamingApi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws;
|
||||||
async function startStream() {
|
async function startStream() {
|
||||||
|
if (
|
||||||
|
ws &&
|
||||||
|
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const stream = await masto.v1.stream.streamUser();
|
const stream = await masto.v1.stream.streamUser();
|
||||||
console.log('STREAM START', { stream });
|
console.log('STREAM START', { stream });
|
||||||
|
ws = stream.ws;
|
||||||
|
|
||||||
const handleNewStatus = debounce((status) => {
|
const handleNewStatus = debounce((status) => {
|
||||||
console.log('UPDATE', status);
|
console.log('UPDATE', status);
|
||||||
|
|
||||||
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
||||||
const inHome = states.home.find((s) => s.id === status.id);
|
const inHome = status.id === states.homeLast?.id;
|
||||||
if (!inHomeNew && !inHome) {
|
if (!inHomeNew && !inHome) {
|
||||||
states.homeNew.unshift({
|
if (states.settings.boostsCarousel && status.reblog) {
|
||||||
id: status.id,
|
// do nothing
|
||||||
reblog: status.reblog?.id,
|
} else {
|
||||||
reply: !!status.inReplyToAccountId,
|
states.homeNew.unshift({
|
||||||
});
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
});
|
||||||
|
console.log('homeNew 1', [...states.homeNew]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveStatus(status);
|
saveStatus(status);
|
||||||
|
@ -331,9 +444,7 @@ async function startStream() {
|
||||||
const inNotificationsNew = states.notificationsNew.find(
|
const inNotificationsNew = states.notificationsNew.find(
|
||||||
(n) => n.id === notification.id,
|
(n) => n.id === notification.id,
|
||||||
);
|
);
|
||||||
const inNotifications = states.notifications.find(
|
const inNotifications = notification.id === states.notificationLast?.id;
|
||||||
(n) => n.id === notification.id,
|
|
||||||
);
|
|
||||||
if (!inNotificationsNew && !inNotifications) {
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
states.notificationsNew.unshift(notification);
|
states.notificationsNew.unshift(notification);
|
||||||
}
|
}
|
||||||
|
@ -343,10 +454,9 @@ async function startStream() {
|
||||||
|
|
||||||
stream.ws.onclose = () => {
|
stream.ws.onclose = () => {
|
||||||
console.log('STREAM CLOSED!');
|
console.log('STREAM CLOSED!');
|
||||||
|
if (document.visibilityState !== 'hidden') {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
startStream();
|
startStream();
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -357,38 +467,38 @@ async function startStream() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastHidden;
|
||||||
function startVisibility() {
|
function startVisibility() {
|
||||||
const handleVisibilityChange = () => {
|
const handleVisible = (visible) => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (!visible) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
store.session.set('lastHidden', timestamp);
|
lastHidden = timestamp;
|
||||||
} else {
|
} else {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const lastHidden = store.session.get('lastHidden');
|
|
||||||
const diff = timestamp - lastHidden;
|
const diff = timestamp - lastHidden;
|
||||||
const diffMins = Math.round(diff / 1000 / 60);
|
const diffMins = Math.round(diff / 1000 / 60);
|
||||||
if (diffMins > 1) {
|
console.log(`visible: ${visible}`, { lastHidden, diffMins });
|
||||||
console.log('visible', { lastHidden, diffMins });
|
if (!lastHidden || diffMins > 1) {
|
||||||
setTimeout(() => {
|
(async () => {
|
||||||
// Buffer for WS reconnect
|
try {
|
||||||
(async () => {
|
const firstStatusID = states.homeLast?.id;
|
||||||
try {
|
const firstNotificationID = states.notificationsLast?.id;
|
||||||
const firstStatusID = states.home[0]?.id;
|
const fetchHome = masto.v1.timelines.listHome({
|
||||||
const firstNotificationID = states.notifications[0]?.id;
|
limit: 5,
|
||||||
const fetchHome = masto.v1.timelines.listHome({
|
...(firstStatusID && { sinceId: firstStatusID }),
|
||||||
limit: 1,
|
});
|
||||||
...(firstStatusID && { sinceId: firstStatusID }),
|
const fetchNotifications = masto.v1.notifications.list({
|
||||||
});
|
limit: 1,
|
||||||
const fetchNotifications = masto.v1.notifications.list({
|
...(firstNotificationID && { sinceId: firstNotificationID }),
|
||||||
limit: 1,
|
});
|
||||||
...(firstNotificationID && { sinceId: firstNotificationID }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const newStatuses = await fetchHome;
|
const newStatuses = await fetchHome;
|
||||||
if (
|
const hasOneAndReblog =
|
||||||
newStatuses.length &&
|
newStatuses.length === 1 && newStatuses?.[0]?.reblog;
|
||||||
newStatuses[0].id !== states.home[0].id
|
if (newStatuses.length) {
|
||||||
) {
|
if (states.settings.boostsCarousel && hasOneAndReblog) {
|
||||||
|
// do nothing
|
||||||
|
} else {
|
||||||
states.homeNew = newStatuses.map((status) => {
|
states.homeNew = newStatuses.map((status) => {
|
||||||
saveStatus(status);
|
saveStatus(status);
|
||||||
return {
|
return {
|
||||||
|
@ -397,33 +507,42 @@ function startVisibility() {
|
||||||
reply: !!status.inReplyToAccountId,
|
reply: !!status.inReplyToAccountId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
console.log('homeNew 2', [...states.homeNew]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNotifications = await fetchNotifications;
|
|
||||||
if (newNotifications.length) {
|
|
||||||
const notification = newNotifications[0];
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveStatus(notification.status, { override: false });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silently fail
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
}, 100);
|
const newNotifications = await fetchNotifications;
|
||||||
|
if (newNotifications.length) {
|
||||||
|
const notification = newNotifications[0];
|
||||||
|
const inNotificationsNew = states.notificationsNew.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
const inNotifications =
|
||||||
|
notification.id === states.notificationLast?.id;
|
||||||
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
|
states.notificationsNew.unshift(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStatus(notification.status, { override: false });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
startStream();
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
const hidden = document.visibilityState === 'hidden';
|
||||||
|
handleVisible(!hidden);
|
||||||
|
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
|
||||||
|
};
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
requestAnimationFrame(handleVisibilityChange);
|
||||||
return {
|
return {
|
||||||
stop: () => {
|
stop: () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
|
@ -20,10 +20,20 @@
|
||||||
#account-container .stats {
|
#account-container .stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
column-gap: 16px;
|
justify-content: space-around;
|
||||||
row-gap: 4px;
|
gap: 16px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
#account-container .stats > * {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#account-container .stats a {
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
#account-container .actions {
|
#account-container .actions {
|
||||||
|
@ -70,3 +80,12 @@
|
||||||
#account-container .profile-field p {
|
#account-container .profile-field p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#account-container .common-followers {
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
border-bottom: 1px solid var(--outline-color);
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 90%;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
|
@ -2,15 +2,19 @@ import './account.css';
|
||||||
|
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import emojifyText from '../utils/emojify-text';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
import NameText from './name-text';
|
import NameText from './name-text';
|
||||||
|
|
||||||
function Account({ account }) {
|
function Account({ account, onClose }) {
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const isString = typeof account === 'string';
|
const isString = typeof account === 'string';
|
||||||
const [info, setInfo] = useState(isString ? null : account);
|
const [info, setInfo] = useState(isString ? null : account);
|
||||||
|
@ -27,12 +31,29 @@ function Account({ account }) {
|
||||||
setInfo(info);
|
setInfo(info);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
try {
|
||||||
setUIState('error');
|
const result = await masto.v2.search({
|
||||||
|
q: account,
|
||||||
|
type: 'accounts',
|
||||||
|
limit: 1,
|
||||||
|
resolve: true,
|
||||||
|
});
|
||||||
|
if (result.accounts.length) {
|
||||||
|
setInfo(result.accounts[0]);
|
||||||
|
setUIState('default');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUIState('error');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
} else {
|
||||||
|
setInfo(account);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [account]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
acct,
|
acct,
|
||||||
|
@ -59,6 +80,7 @@ function Account({ account }) {
|
||||||
|
|
||||||
const [relationshipUIState, setRelationshipUIState] = useState('default');
|
const [relationshipUIState, setRelationshipUIState] = useState('default');
|
||||||
const [relationship, setRelationship] = useState(null);
|
const [relationship, setRelationship] = useState(null);
|
||||||
|
const [familiarFollowers, setFamiliarFollowers] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info) {
|
if (info) {
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
@ -67,14 +89,29 @@ function Account({ account }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRelationshipUIState('loading');
|
setRelationshipUIState('loading');
|
||||||
|
setFamiliarFollowers([]);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const fetchRelationships = masto.v1.accounts.fetchRelationships([id]);
|
||||||
|
const fetchFamiliarFollowers =
|
||||||
|
masto.v1.accounts.fetchFamiliarFollowers(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relationships = await masto.v1.accounts.fetchRelationships([
|
const relationships = await fetchRelationships;
|
||||||
id,
|
|
||||||
]);
|
|
||||||
console.log('fetched relationship', relationships);
|
console.log('fetched relationship', relationships);
|
||||||
if (relationships.length) {
|
if (relationships.length) {
|
||||||
setRelationship(relationships[0]);
|
const relationship = relationships[0];
|
||||||
|
setRelationship(relationship);
|
||||||
|
|
||||||
|
if (!relationship.following) {
|
||||||
|
try {
|
||||||
|
const followers = await fetchFamiliarFollowers;
|
||||||
|
console.log('fetched familiar followers', followers);
|
||||||
|
setFamiliarFollowers(followers[0].accounts.slice(0, 10));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setRelationshipUIState('default');
|
setRelationshipUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -104,7 +141,17 @@ function Account({ account }) {
|
||||||
id="account-container"
|
id="account-container"
|
||||||
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
|
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
|
||||||
>
|
>
|
||||||
{!info || uiState === 'loading' ? (
|
{uiState === 'error' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<p>Unable to load account.</p>
|
||||||
|
<p>
|
||||||
|
<a href={account} target="_blank">
|
||||||
|
Go to account page <Icon icon="external" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uiState === 'loading' ? (
|
||||||
<>
|
<>
|
||||||
<header>
|
<header>
|
||||||
<Avatar size="xxxl" />
|
<Avatar size="xxxl" />
|
||||||
|
@ -123,133 +170,174 @@ function Account({ account }) {
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
info && (
|
||||||
<header>
|
<>
|
||||||
<Avatar url={avatar} size="xxxl" />
|
<header>
|
||||||
<NameText account={info} showAcct external />
|
<Avatar url={avatar} size="xxxl" />
|
||||||
</header>
|
<NameText account={info} showAcct external />
|
||||||
<main tabIndex="-1">
|
</header>
|
||||||
{bot && (
|
<main tabIndex="-1">
|
||||||
<>
|
{bot && (
|
||||||
<span class="tag">
|
<>
|
||||||
<Icon icon="bot" /> Automated
|
<span class="tag">
|
||||||
</span>
|
<Icon icon="bot" /> Automated
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
<div
|
|
||||||
class="note"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: enhanceContent(note, { emojis }),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fields?.length > 0 && (
|
|
||||||
<div class="profile-metadata">
|
|
||||||
{fields.map(({ name, value, verifiedAt }) => (
|
|
||||||
<div
|
|
||||||
class={`profile-field ${
|
|
||||||
verifiedAt ? 'profile-verified' : ''
|
|
||||||
}`}
|
|
||||||
key={name}
|
|
||||||
>
|
|
||||||
<b>
|
|
||||||
{name}{' '}
|
|
||||||
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
|
|
||||||
</b>
|
|
||||||
<p
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: value,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
{!!createdAt && (
|
|
||||||
<span>
|
|
||||||
Joined:{' '}
|
|
||||||
<b>
|
|
||||||
<time datetime={createdAt}>
|
|
||||||
{Intl.DateTimeFormat('en', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(new Date(createdAt))}
|
|
||||||
</time>
|
|
||||||
</b>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
<div
|
||||||
<p class="actions">
|
class="note"
|
||||||
{followedBy ? <span class="tag">Following you</span> : <span />}{' '}
|
onClick={handleContentLinks()}
|
||||||
{relationshipUIState !== 'loading' && relationship && (
|
dangerouslySetInnerHTML={{
|
||||||
<button
|
__html: enhanceContent(note, { emojis }),
|
||||||
type="button"
|
}}
|
||||||
class={`${following || requested ? 'light swap' : ''}`}
|
/>
|
||||||
data-swap-state={following || requested ? 'danger' : ''}
|
{fields?.length > 0 && (
|
||||||
disabled={relationshipUIState === 'loading'}
|
<div class="profile-metadata">
|
||||||
onClick={() => {
|
{fields.map(({ name, value, verifiedAt }) => (
|
||||||
setRelationshipUIState('loading');
|
<div
|
||||||
(async () => {
|
class={`profile-field ${
|
||||||
try {
|
verifiedAt ? 'profile-verified' : ''
|
||||||
let newRelationship;
|
}`}
|
||||||
if (following || requested) {
|
key={name}
|
||||||
const yes = confirm(
|
>
|
||||||
requested
|
<b>
|
||||||
? 'Are you sure that you want to withdraw follow request?'
|
<span
|
||||||
: 'Are you sure that you want to unfollow this account?',
|
dangerouslySetInnerHTML={{
|
||||||
);
|
__html: emojifyText(name, emojis),
|
||||||
if (yes) {
|
}}
|
||||||
newRelationship = await masto.v1.accounts.unfollow(
|
/>{' '}
|
||||||
|
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
|
||||||
|
</b>
|
||||||
|
<p
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: enhanceContent(value, { emojis }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p class="stats">
|
||||||
|
<Link to={`/a/${id}`} onClick={onClose}>
|
||||||
|
Posts
|
||||||
|
<br />
|
||||||
|
<b title={statusesCount}>
|
||||||
|
{shortenNumber(statusesCount)}
|
||||||
|
</b>{' '}
|
||||||
|
</Link>
|
||||||
|
<span>
|
||||||
|
Following
|
||||||
|
<br />
|
||||||
|
<b title={followingCount}>
|
||||||
|
{shortenNumber(followingCount)}
|
||||||
|
</b>{' '}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Followers
|
||||||
|
<br />
|
||||||
|
<b title={followersCount}>
|
||||||
|
{shortenNumber(followersCount)}
|
||||||
|
</b>{' '}
|
||||||
|
</span>
|
||||||
|
{!!createdAt && (
|
||||||
|
<span>
|
||||||
|
Joined
|
||||||
|
<br />
|
||||||
|
<b>
|
||||||
|
<time datetime={createdAt}>
|
||||||
|
{Intl.DateTimeFormat('en', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(createdAt))}
|
||||||
|
</time>
|
||||||
|
</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{familiarFollowers?.length > 0 && (
|
||||||
|
<p class="common-followers">
|
||||||
|
Common followers{' '}
|
||||||
|
<span class="ib">
|
||||||
|
{familiarFollowers.map((follower) => (
|
||||||
|
<a
|
||||||
|
href={follower.url}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
states.showAccount = follower;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
url={follower.avatarStatic}
|
||||||
|
size="l"
|
||||||
|
alt={`${follower.displayName} @${follower.acct}`}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p class="actions">
|
||||||
|
{followedBy ? <span class="tag">Following you</span> : <span />}{' '}
|
||||||
|
{relationshipUIState !== 'loading' && relationship && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${following || requested ? 'light swap' : ''}`}
|
||||||
|
data-swap-state={following || requested ? 'danger' : ''}
|
||||||
|
disabled={relationshipUIState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let newRelationship;
|
||||||
|
if (following || requested) {
|
||||||
|
const yes = confirm(
|
||||||
|
requested
|
||||||
|
? 'Are you sure that you want to withdraw follow request?'
|
||||||
|
: 'Are you sure that you want to unfollow this account?',
|
||||||
|
);
|
||||||
|
if (yes) {
|
||||||
|
newRelationship =
|
||||||
|
await masto.v1.accounts.unfollow(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newRelationship = await masto.v1.accounts.follow(
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
if (newRelationship) setRelationship(newRelationship);
|
||||||
newRelationship = await masto.v1.accounts.follow(id);
|
setRelationshipUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
}
|
}
|
||||||
if (newRelationship) setRelationship(newRelationship);
|
})();
|
||||||
setRelationshipUIState('default');
|
}}
|
||||||
} catch (e) {
|
>
|
||||||
alert(e);
|
{following ? (
|
||||||
setRelationshipUIState('error');
|
<>
|
||||||
}
|
<span>Following</span>
|
||||||
})();
|
<span>Unfollow…</span>
|
||||||
}}
|
</>
|
||||||
>
|
) : requested ? (
|
||||||
{following ? (
|
<>
|
||||||
<>
|
<span>Requested</span>
|
||||||
<span>Following</span>
|
<span>Withdraw…</span>
|
||||||
<span>Unfollow…</span>
|
</>
|
||||||
</>
|
) : locked ? (
|
||||||
) : requested ? (
|
<>
|
||||||
<>
|
<Icon icon="lock" /> <span>Follow</span>
|
||||||
<span>Requested</span>
|
</>
|
||||||
<span>Withdraw…</span>
|
) : (
|
||||||
</>
|
'Follow'
|
||||||
) : locked ? (
|
)}
|
||||||
<>
|
</button>
|
||||||
<Icon icon="lock" /> <span>Follow</span>
|
)}
|
||||||
</>
|
</p>
|
||||||
) : (
|
</main>
|
||||||
'Follow'
|
</>
|
||||||
)}
|
)
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,7 +9,7 @@ const SIZES = {
|
||||||
xxxl: 64,
|
xxxl: 64,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar({ url, size, alt = '' }) {
|
function Avatar({ url, size, alt = '', ...props }) {
|
||||||
size = SIZES[size] || size || SIZES.m;
|
size = SIZES[size] || size || SIZES.m;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
@ -19,6 +19,7 @@ function Avatar({ url, size, alt = '' }) {
|
||||||
height: size,
|
height: size,
|
||||||
}}
|
}}
|
||||||
title={alt}
|
title={alt}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{!!url && (
|
{!!url && (
|
||||||
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
||||||
|
|
|
@ -199,21 +199,22 @@
|
||||||
|
|
||||||
#compose-container text-expander {
|
#compose-container text-expander {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
#compose-container .text-expander-menu {
|
#compose-container .text-expander-menu {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
margin: 0 0 0 -8px;
|
margin-top: 2em;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
/* box-shadow: 0 0 12px var(--outline-color); */
|
box-shadow: 0 4px 24px var(--drop-shadow-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
top: 0 !important;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-width: 50vw;
|
min-width: 10em;
|
||||||
|
max-width: 90vw;
|
||||||
}
|
}
|
||||||
#compose-container .text-expander-menu li {
|
#compose-container .text-expander-menu li {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -235,10 +236,16 @@
|
||||||
width: 2.2em;
|
width: 2.2em;
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
}
|
}
|
||||||
#compose-container .text-expander-menu li:is(:hover, :focus) {
|
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
|
||||||
color: var(--bg-color);
|
color: var(--bg-color);
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
#compose-container
|
||||||
|
.text-expander-menu:hover
|
||||||
|
li[aria-selected]:not(:hover, :focus) {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
#compose-container .media-attachments {
|
#compose-container .media-attachments {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
|
@ -324,6 +331,16 @@
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#compose-container .media-sensitive {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#compose-container .media-sensitive > * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
#compose-container form .poll {
|
#compose-container form .poll {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import stringLength from 'string-length';
|
import stringLength from 'string-length';
|
||||||
import { uid } from 'uid/single';
|
import { uid } from 'uid/single';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import supportedLanguages from '../data/status-supported-languages';
|
import supportedLanguages from '../data/status-supported-languages';
|
||||||
|
@ -17,7 +18,6 @@ import openCompose from '../utils/open-compose';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
|
import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
|
||||||
import useInterval from '../utils/useInterval';
|
import useInterval from '../utils/useInterval';
|
||||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ const expiryOptions = {
|
||||||
'30 minutes': 30 * 60,
|
'30 minutes': 30 * 60,
|
||||||
'1 hour': 60 * 60,
|
'1 hour': 60 * 60,
|
||||||
'6 hours': 6 * 60 * 60,
|
'6 hours': 6 * 60 * 60,
|
||||||
|
'12 hours': 12 * 60 * 60,
|
||||||
'1 day': 24 * 60 * 60,
|
'1 day': 24 * 60 * 60,
|
||||||
'3 days': 3 * 24 * 60 * 60,
|
'3 days': 3 * 24 * 60 * 60,
|
||||||
'7 days': 7 * 24 * 60 * 60,
|
'7 days': 7 * 24 * 60 * 60,
|
||||||
|
@ -62,6 +63,21 @@ const menu = document.createElement('ul');
|
||||||
menu.role = 'listbox';
|
menu.role = 'listbox';
|
||||||
menu.className = 'text-expander-menu';
|
menu.className = 'text-expander-menu';
|
||||||
|
|
||||||
|
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
|
||||||
|
const windowMargin = 16;
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const { left, width } = entry.boundingClientRect;
|
||||||
|
const { innerWidth } = window;
|
||||||
|
if (left + width > innerWidth) {
|
||||||
|
menu.style.left = innerWidth - width - windowMargin + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(menu);
|
||||||
|
|
||||||
const DEFAULT_LANG = 'en';
|
const DEFAULT_LANG = 'en';
|
||||||
|
|
||||||
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
|
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
|
||||||
|
@ -779,9 +795,10 @@ function Compose({
|
||||||
ref={spoilerTextRef}
|
ref={spoilerTextRef}
|
||||||
type="text"
|
type="text"
|
||||||
name="spoilerText"
|
name="spoilerText"
|
||||||
placeholder="Spoiler text"
|
placeholder="Content warning"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
class="spoiler-text-field"
|
class="spoiler-text-field"
|
||||||
|
lang={language}
|
||||||
style={{
|
style={{
|
||||||
opacity: sensitive ? 1 : 0,
|
opacity: sensitive ? 1 : 0,
|
||||||
pointerEvents: sensitive ? 'auto' : 'none',
|
pointerEvents: sensitive ? 'auto' : 'none',
|
||||||
|
@ -846,6 +863,7 @@ function Compose({
|
||||||
}
|
}
|
||||||
required={mediaAttachments.length === 0}
|
required={mediaAttachments.length === 0}
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
|
lang={language}
|
||||||
onInput={() => {
|
onInput={() => {
|
||||||
updateCharCount();
|
updateCharCount();
|
||||||
}}
|
}}
|
||||||
|
@ -861,6 +879,7 @@ function Compose({
|
||||||
key={id || fileID || i}
|
key={id || fileID || i}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
|
lang={language}
|
||||||
onDescriptionChange={(value) => {
|
onDescriptionChange={(value) => {
|
||||||
setMediaAttachments((attachments) => {
|
setMediaAttachments((attachments) => {
|
||||||
const newAttachments = [...attachments];
|
const newAttachments = [...attachments];
|
||||||
|
@ -876,10 +895,25 @@ function Compose({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<label class="media-sensitive">
|
||||||
|
<input
|
||||||
|
name="sensitive"
|
||||||
|
type="checkbox"
|
||||||
|
checked={sensitive}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sensitive = e.target.checked;
|
||||||
|
setSensitive(sensitive);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<span>Mark media as sensitive</span>{' '}
|
||||||
|
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!poll && (
|
{!!poll && (
|
||||||
<Poll
|
<Poll
|
||||||
|
lang={language}
|
||||||
maxOptions={maxOptions}
|
maxOptions={maxOptions}
|
||||||
maxExpiration={maxExpiration}
|
maxExpiration={maxExpiration}
|
||||||
minExpiration={minExpiration}
|
minExpiration={minExpiration}
|
||||||
|
@ -934,6 +968,8 @@ function Compose({
|
||||||
return attachments.concat(mediaFiles);
|
return attachments.concat(mediaFiles);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Reset
|
||||||
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Icon icon="attachment" />
|
<Icon icon="attachment" />
|
||||||
|
@ -1218,6 +1254,7 @@ function CharCountMeter({ maxCharacters = 500 }) {
|
||||||
function MediaAttachment({
|
function MediaAttachment({
|
||||||
attachment,
|
attachment,
|
||||||
disabled,
|
disabled,
|
||||||
|
lang,
|
||||||
onDescriptionChange = () => {},
|
onDescriptionChange = () => {},
|
||||||
onRemove = () => {},
|
onRemove = () => {},
|
||||||
}) {
|
}) {
|
||||||
|
@ -1257,6 +1294,7 @@ function MediaAttachment({
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={description || ''}
|
value={description || ''}
|
||||||
|
lang={lang}
|
||||||
placeholder={
|
placeholder={
|
||||||
{
|
{
|
||||||
image: 'Image description',
|
image: 'Image description',
|
||||||
|
@ -1351,6 +1389,7 @@ function MediaAttachment({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Poll({
|
function Poll({
|
||||||
|
lang,
|
||||||
poll,
|
poll,
|
||||||
disabled,
|
disabled,
|
||||||
onInput = () => {},
|
onInput = () => {},
|
||||||
|
@ -1373,6 +1412,7 @@ function Poll({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
maxlength={maxCharactersPerOption}
|
maxlength={maxCharactersPerOption}
|
||||||
placeholder={`Choice ${i + 1}`}
|
placeholder={`Choice ${i + 1}`}
|
||||||
|
lang={lang}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const { value } = e.target;
|
const { value } = e.target;
|
||||||
options[i] = value;
|
options[i] = value;
|
||||||
|
|
35
src/components/link.jsx
Normal file
35
src/components/link.jsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import states from '../utils/states';
|
||||||
|
|
||||||
|
/* NOTES
|
||||||
|
=====
|
||||||
|
Initially this uses <NavLink> from react-router-dom, but it doesn't work:
|
||||||
|
1. It interferes with nested <a> inside <a> and it's difficult to preventDefault/stopPropagation from the nested <a>
|
||||||
|
2. isActive doesn't work properly with the weird routes that's set up in this app, due to the faux "location" to make the modals work and prevent unmounting
|
||||||
|
3. Not using <Link state/> because it modifies history.state that *persists* across page reloads. I don't need that, so using valtio's states instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Link = (props) => {
|
||||||
|
let routerLocation;
|
||||||
|
try {
|
||||||
|
routerLocation = useLocation();
|
||||||
|
} catch (e) {}
|
||||||
|
let hash = (location.hash || '').replace(/^#/, '').trim();
|
||||||
|
if (hash === '') hash = '/';
|
||||||
|
const { to, ...restProps } = props;
|
||||||
|
const isActive = hash === to;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`#${to}`}
|
||||||
|
{...restProps}
|
||||||
|
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (routerLocation) states.prevLocation = routerLocation;
|
||||||
|
props.onClick?.(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Link;
|
285
src/components/media-modal.jsx
Normal file
285
src/components/media-modal.jsx
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
|
import Media from './media';
|
||||||
|
import Modal from './modal';
|
||||||
|
|
||||||
|
function MediaModal({
|
||||||
|
mediaAttachments,
|
||||||
|
statusID,
|
||||||
|
index = 0,
|
||||||
|
onClose = () => {},
|
||||||
|
}) {
|
||||||
|
const carouselRef = useRef(null);
|
||||||
|
const isStatusLocation = useMatch('/s/:id');
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(index);
|
||||||
|
const carouselFocusItem = useRef(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
carouselFocusItem.current?.scrollIntoView();
|
||||||
|
}, []);
|
||||||
|
const prevStatusID = useRef(statusID);
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollLeft = index * carouselRef.current.clientWidth;
|
||||||
|
const differentStatusID = prevStatusID.current !== statusID;
|
||||||
|
if (differentStatusID) prevStatusID.current = statusID;
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: scrollLeft,
|
||||||
|
behavior: differentStatusID ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
}, [index, statusID]);
|
||||||
|
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let handleSwipe = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.addEventListener('swiped-down', handleSwipe);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useHotkeys('esc', onClose, [onClose]);
|
||||||
|
|
||||||
|
const [showMediaAlt, setShowMediaAlt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let handleScroll = () => {
|
||||||
|
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||||
|
const index = Math.round(scrollLeft / clientWidth);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
};
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (carouselRef.current) {
|
||||||
|
carouselRef.current.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
data-swipe-threshold="44"
|
||||||
|
class="carousel"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.classList.contains('carousel-item') ||
|
||||||
|
e.target.classList.contains('media')
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaAttachments?.map((media, i) => {
|
||||||
|
const { blurhash } = media;
|
||||||
|
const rgbAverageColor = blurhash
|
||||||
|
? getBlurHashAverageColor(blurhash)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="carousel-item"
|
||||||
|
style={{
|
||||||
|
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
||||||
|
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
||||||
|
',',
|
||||||
|
)}, .5)`,
|
||||||
|
}}
|
||||||
|
tabindex="0"
|
||||||
|
key={media.id}
|
||||||
|
ref={i === currentIndex ? carouselFocusItem : null}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!!media.description && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain2 media-alt"
|
||||||
|
hidden={!showControls}
|
||||||
|
onClick={() => {
|
||||||
|
setShowMediaAlt(media.description);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="tag">ALT</span>{' '}
|
||||||
|
<span class="media-alt-desc">{media.description}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Media media={media} showOriginal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-top-controls" hidden={!showControls}>
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{mediaAttachments?.length > 1 ? (
|
||||||
|
<span class="carousel-dots">
|
||||||
|
{mediaAttachments?.map((media, i) => (
|
||||||
|
<button
|
||||||
|
key={media.id}
|
||||||
|
type="button"
|
||||||
|
disabled={i === currentIndex}
|
||||||
|
class={`plain carousel-dot ${
|
||||||
|
i === currentIndex ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * i,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{!isStatusLocation && (
|
||||||
|
<Link
|
||||||
|
to={`/s/${statusID}`}
|
||||||
|
class="button carousel-button media-post-link plain3"
|
||||||
|
onClick={() => {
|
||||||
|
// if small screen (not media query min-width 40em + 350px), run onClose
|
||||||
|
if (
|
||||||
|
!window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="button-label">See post </span>»
|
||||||
|
</Link>
|
||||||
|
)}{' '}
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||||
|
mediaAttachments[currentIndex]?.url
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
class="button carousel-button plain3"
|
||||||
|
title="Open original media in new window"
|
||||||
|
>
|
||||||
|
<Icon icon="popout" alt="Open original media in new window" />
|
||||||
|
</a>{' '}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mediaAttachments?.length > 1 && (
|
||||||
|
<div class="carousel-controls" hidden={!showControls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
hidden={currentIndex === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
hidden={currentIndex === mediaAttachments.length - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!showMediaAlt && (
|
||||||
|
<Modal
|
||||||
|
class="light"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowMediaAlt(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<h2>Media description</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showMediaAlt}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{!!showMediaAlt && (
|
||||||
|
<Modal
|
||||||
|
class="light"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowMediaAlt(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<h2>Media description</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showMediaAlt}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaModal;
|
198
src/components/media.jsx
Normal file
198
src/components/media.jsx
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { formatDuration } from './status';
|
||||||
|
|
||||||
|
/*
|
||||||
|
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, autoAnimate, 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' || (type === 'unknown' && previewUrl && url)) {
|
||||||
|
// Note: type: unknown might not have width/height
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`media media-image`}
|
||||||
|
onClick={onClick}
|
||||||
|
style={
|
||||||
|
showOriginal && {
|
||||||
|
backgroundImage: `url(${previewUrl})`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: '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}
|
||||||
|
loading={showOriginal ? 'eager' : 'lazy'}
|
||||||
|
style={
|
||||||
|
!showOriginal && {
|
||||||
|
backgroundColor:
|
||||||
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (type === 'gifv' || type === 'video') {
|
||||||
|
const shortDuration = original.duration < 31;
|
||||||
|
const isGIF = type === 'gifv' && shortDuration;
|
||||||
|
// If GIF is too long, treat it as a video
|
||||||
|
const loopable = original.duration < 61;
|
||||||
|
const formattedDuration = formatDuration(original.duration);
|
||||||
|
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
||||||
|
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||||
|
autoGIFAnimate ? 'media-contain' : ''
|
||||||
|
}`}
|
||||||
|
data-formatted-duration={formattedDuration}
|
||||||
|
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (hoverAnimate) {
|
||||||
|
try {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
onClick(e);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hoverAnimate) {
|
||||||
|
try {
|
||||||
|
videoRef.current.play();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (hoverAnimate) {
|
||||||
|
try {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showOriginal || autoGIFAnimate ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
<video
|
||||||
|
src="${url}"
|
||||||
|
poster="${previewUrl}"
|
||||||
|
width="${width}"
|
||||||
|
height="${height}"
|
||||||
|
preload="auto"
|
||||||
|
autoplay
|
||||||
|
muted="${isGIF}"
|
||||||
|
${isGIF ? '' : 'controls'}
|
||||||
|
playsinline
|
||||||
|
loop="${loopable}"
|
||||||
|
${
|
||||||
|
isGIF
|
||||||
|
? 'ondblclick="this.paused ? this.play() : this.pause()"'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
></video>
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : isGIF ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={url}
|
||||||
|
poster={previewUrl}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
preload="auto"
|
||||||
|
// controls
|
||||||
|
playsinline
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (type === 'audio') {
|
||||||
|
const formattedDuration = formatDuration(original.duration);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="media media-audio"
|
||||||
|
data-formatted-duration={formattedDuration}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{showOriginal ? (
|
||||||
|
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||||
|
) : previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Media;
|
|
@ -2,23 +2,23 @@
|
||||||
|
|
||||||
.status-reblog {
|
.status-reblog {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom right,
|
160deg,
|
||||||
var(--reblog-faded-color),
|
var(--reblog-faded-color),
|
||||||
transparent 160px
|
transparent min(160px, 50%)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.status-reply-to {
|
.status-reply-to {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom right,
|
160deg,
|
||||||
var(--reply-to-faded-color),
|
var(--reply-to-faded-color),
|
||||||
transparent 160px
|
transparent min(160px, 50%)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.status-reblog .status-reply-to {
|
.status-reblog .status-reply-to {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to top left,
|
-20deg,
|
||||||
var(--reply-to-faded-color),
|
var(--reply-to-faded-color),
|
||||||
transparent 160px
|
transparent min(160px, 50%)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.visibility-direct {
|
.visibility-direct {
|
||||||
|
@ -79,12 +79,26 @@
|
||||||
.status.large.visibility-direct {
|
.status.large.visibility-direct {
|
||||||
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
|
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-breathe {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
.status.skeleton {
|
.status.skeleton {
|
||||||
color: var(--outline-color);
|
color: var(--outline-color);
|
||||||
|
animation: skeleton-breathe 6s linear infinite;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
contain: layout;
|
||||||
|
text-rendering: optimizeSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.skeleton > .avatar {
|
.status.skeleton > .avatar {
|
||||||
background-color: var(--outline-color);
|
background-color: var(--outline-color);
|
||||||
}
|
}
|
||||||
|
@ -188,11 +202,8 @@
|
||||||
~ *:not(.media-container, .card),
|
~ *:not(.media-container, .card),
|
||||||
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
|
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
|
||||||
filter: blur(5px) invert(0.5);
|
filter: blur(5px) invert(0.5);
|
||||||
/* filter: url(#spoiler); */
|
|
||||||
text-rendering: optimizeSpeed;
|
|
||||||
image-rendering: crisp-edges;
|
image-rendering: crisp-edges;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
/* transform: translate3d(-5px, -5px, 0); */
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
contain: layout;
|
contain: layout;
|
||||||
|
@ -206,15 +217,6 @@
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
/* @media (prefers-color-scheme: dark) {
|
|
||||||
.status
|
|
||||||
.content-container.has-spoiler
|
|
||||||
.spoiler
|
|
||||||
~ *:not(.media-container, .card),
|
|
||||||
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
|
|
||||||
filter: url(#spoiler-dark);
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
.status .content-container.show-spoiler .spoiler {
|
.status .content-container.show-spoiler .spoiler {
|
||||||
border-style: dotted;
|
border-style: dotted;
|
||||||
}
|
}
|
||||||
|
@ -325,12 +327,41 @@
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
.status .media {
|
.status .media-container .media {
|
||||||
border-radius: 8px;
|
--media-radius: 16px;
|
||||||
|
border-radius: var(--media-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
}
|
}
|
||||||
|
/* Special media borders */
|
||||||
|
.status .media-container.media-eq2 .media:first-of-type {
|
||||||
|
border-radius: var(--media-radius) 0 0 var(--media-radius);
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq2 .media:last-of-type {
|
||||||
|
border-radius: 0 var(--media-radius) var(--media-radius) 0;
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq3 .media:first-of-type {
|
||||||
|
border-radius: var(--media-radius) 0 0 var(--media-radius);
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq3 .media:nth-of-type(2) {
|
||||||
|
border-radius: 0 var(--media-radius) 0 0;
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq3 .media:last-of-type {
|
||||||
|
border-radius: 0 0 var(--media-radius) 0;
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq4 .media:first-of-type {
|
||||||
|
border-radius: var(--media-radius) 0 0 0;
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq4 .media:nth-of-type(2) {
|
||||||
|
border-radius: 0 var(--media-radius) 0 0;
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq4 .media:nth-of-type(3) {
|
||||||
|
border-radius: 0 0 0 var(--media-radius);
|
||||||
|
}
|
||||||
|
.status .media-container.media-eq4 .media:last-of-type {
|
||||||
|
border-radius: 0 0 var(--media-radius) 0;
|
||||||
|
}
|
||||||
.status .media:only-child {
|
.status .media:only-child {
|
||||||
grid-area: span 2 / span 2;
|
grid-area: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
@ -488,6 +519,63 @@
|
||||||
background-blend-mode: multiply;
|
background-blend-mode: multiply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.carousel-item button.media-alt {
|
||||||
|
position: absolute;
|
||||||
|
--bottom: 16px;
|
||||||
|
bottom: var(--bottom);
|
||||||
|
bottom: calc(var(--bottom) + env(safe-area-inset-bottom));
|
||||||
|
left: 16px;
|
||||||
|
left: calc(16px + env(safe-area-inset-left));
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 4px 8px 4px 4px;
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
box-shadow: 0 4px 16px var(--outline-color);
|
||||||
|
max-width: min(40em, calc(100% - 32px));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
.carousel-item button.media-alt .media-alt-desc {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
.carousel-item button.media-alt .media-alt-desc {
|
||||||
|
white-space: normal;
|
||||||
|
display: -webkit-box;
|
||||||
|
display: box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.carousel-item button.media-alt[hidden] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.carousel-item button.media-alt {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(var(--bottom)) scale(0.95);
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.carousel-item:hover button.media-alt {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
.carousel-item button.media-alt[hidden] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* CARD */
|
/* CARD */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import './status.css';
|
import './status.css';
|
||||||
|
|
||||||
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import mem from 'mem';
|
import mem from 'mem';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
@ -11,7 +12,6 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
|
||||||
import 'swiped-events';
|
import 'swiped-events';
|
||||||
import useResizeObserver from 'use-resize-observer';
|
import useResizeObserver from 'use-resize-observer';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
@ -20,19 +20,25 @@ import Loader from '../components/loader';
|
||||||
import Modal from '../components/modal';
|
import Modal from '../components/modal';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
import htmlContentLength from '../utils/html-content-length';
|
import htmlContentLength from '../utils/html-content-length';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
|
||||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
|
import Media from './media';
|
||||||
import RelativeTime from './relative-time';
|
import RelativeTime from './relative-time';
|
||||||
|
|
||||||
function fetchAccount(id) {
|
function fetchAccount(id) {
|
||||||
return masto.v1.accounts.fetch(id);
|
try {
|
||||||
|
return masto.v1.accounts.fetch(id);
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const memFetchAccount = mem(fetchAccount);
|
const memFetchAccount = mem(fetchAccount);
|
||||||
|
|
||||||
|
@ -146,8 +152,6 @@ function Status({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showMediaModal, setShowMediaModal] = useState(false);
|
|
||||||
|
|
||||||
if (reblog) {
|
if (reblog) {
|
||||||
return (
|
return (
|
||||||
<div class="status-reblog" onMouseEnter={debugHover}>
|
<div class="status-reblog" onMouseEnter={debugHover}>
|
||||||
|
@ -251,18 +255,14 @@ function Status({
|
||||||
{/* </span> */}{' '}
|
{/* </span> */}{' '}
|
||||||
{size !== 'l' &&
|
{size !== 'l' &&
|
||||||
(uri ? (
|
(uri ? (
|
||||||
<a
|
<Link to={`/s/${id}`} class="time">
|
||||||
href={`#/s/${id}
|
|
||||||
`}
|
|
||||||
class="time"
|
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
icon={visibilityIconsMap[visibility]}
|
icon={visibilityIconsMap[visibility]}
|
||||||
alt={visibility}
|
alt={visibility}
|
||||||
size="s"
|
size="s"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
<RelativeTime datetime={createdAtDate} format="micro" />
|
<RelativeTime datetime={createdAtDate} format="micro" />
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span class="time">
|
<span class="time">
|
||||||
<Icon
|
<Icon
|
||||||
|
@ -274,7 +274,7 @@ function Status({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!withinContext && size !== 's' && (
|
{!withinContext && (
|
||||||
<>
|
<>
|
||||||
{inReplyToAccountId === status.account?.id ||
|
{inReplyToAccountId === status.account?.id ||
|
||||||
!!snapStates.statusThreadNumber[id] ? (
|
!!snapStates.statusThreadNumber[id] ? (
|
||||||
|
@ -324,7 +324,7 @@ function Status({
|
||||||
<p>{spoilerText}</p>
|
<p>{spoilerText}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="light spoiler"
|
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -346,37 +346,7 @@ function Status({
|
||||||
lang={language}
|
lang={language}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
data-read-more={readMoreText}
|
data-read-more={readMoreText}
|
||||||
onClick={(e) => {
|
onClick={handleContentLinks({ mentions })}
|
||||||
let { target } = e;
|
|
||||||
if (target.parentNode.tagName.toLowerCase() === 'a') {
|
|
||||||
target = target.parentNode;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
target.tagName.toLowerCase() === 'a' &&
|
|
||||||
target.classList.contains('u-url')
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const username = (
|
|
||||||
target.querySelector('span') || target
|
|
||||||
).innerText
|
|
||||||
.trim()
|
|
||||||
.replace(/^@/, '');
|
|
||||||
const url = target.getAttribute('href');
|
|
||||||
const mention = mentions.find(
|
|
||||||
(mention) =>
|
|
||||||
mention.username === username ||
|
|
||||||
mention.acct === username ||
|
|
||||||
mention.url === url,
|
|
||||||
);
|
|
||||||
if (mention) {
|
|
||||||
states.showAccount = mention.acct;
|
|
||||||
} else {
|
|
||||||
const href = target.getAttribute('href');
|
|
||||||
states.showAccount = href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: enhanceContent(content, {
|
__html: enhanceContent(content, {
|
||||||
emojis,
|
emojis,
|
||||||
|
@ -385,7 +355,9 @@ function Status({
|
||||||
.querySelectorAll('a.u-url[target="_blank"]')
|
.querySelectorAll('a.u-url[target="_blank"]')
|
||||||
.forEach((a) => {
|
.forEach((a) => {
|
||||||
// Remove target="_blank" from links
|
// Remove target="_blank" from links
|
||||||
a.removeAttribute('target');
|
if (!/http/i.test(a.innerText.trim())) {
|
||||||
|
a.removeAttribute('target');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -403,7 +375,7 @@ function Status({
|
||||||
)}
|
)}
|
||||||
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
||||||
<button
|
<button
|
||||||
class="plain spoiler"
|
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -421,7 +393,7 @@ function Status({
|
||||||
)}
|
)}
|
||||||
{!!mediaAttachments.length && (
|
{!!mediaAttachments.length && (
|
||||||
<div
|
<div
|
||||||
class={`media-container ${
|
class={`media-container media-eq${mediaAttachments.length} ${
|
||||||
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
||||||
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
||||||
>
|
>
|
||||||
|
@ -435,7 +407,11 @@ function Status({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowMediaModal(i);
|
states.showMediaModal = {
|
||||||
|
mediaAttachments,
|
||||||
|
index: i,
|
||||||
|
statusID: readOnly ? null : id,
|
||||||
|
};
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -617,46 +593,37 @@ function Status({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<span class="menu-container">
|
<Menu
|
||||||
<button type="button" title="More" class="plain more-button">
|
align="end"
|
||||||
<Icon icon="more" size="l" alt="More" />
|
menuButton={
|
||||||
</button>
|
<div class="action">
|
||||||
<menu>
|
<button
|
||||||
{isSelf && (
|
type="button"
|
||||||
<li>
|
title="More"
|
||||||
<button
|
class="plain more-button"
|
||||||
type="button"
|
>
|
||||||
class="plain"
|
<Icon icon="more" size="l" alt="More" />
|
||||||
onClick={(e) => {
|
</button>
|
||||||
e.preventDefault();
|
</div>
|
||||||
e.stopPropagation();
|
}
|
||||||
states.showCompose = {
|
>
|
||||||
editStatus: status,
|
{isSelf && (
|
||||||
};
|
<MenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
states.showCompose = {
|
||||||
Edit…
|
editStatus: status,
|
||||||
</button>
|
};
|
||||||
</li>
|
}}
|
||||||
)}
|
>
|
||||||
</menu>
|
Edit…
|
||||||
</span>
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showMediaModal !== false && (
|
|
||||||
<Modal>
|
|
||||||
<Carousel
|
|
||||||
mediaAttachments={mediaAttachments}
|
|
||||||
index={showMediaModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowMediaModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{!!showEdited && (
|
{!!showEdited && (
|
||||||
<Modal
|
<Modal
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -679,193 +646,6 @@ function Status({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
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, autoAnimate, 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' || (type === 'unknown' && previewUrl && url)) {
|
|
||||||
// Note: type: unknown might not have width/height
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`media media-image`}
|
|
||||||
onClick={onClick}
|
|
||||||
style={
|
|
||||||
showOriginal && {
|
|
||||||
backgroundImage: `url(${previewUrl})`,
|
|
||||||
backgroundSize: 'contain',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundPosition: '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}
|
|
||||||
loading={showOriginal ? 'eager' : 'lazy'}
|
|
||||||
style={
|
|
||||||
!showOriginal && {
|
|
||||||
backgroundColor:
|
|
||||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
||||||
backgroundPosition: focalBackgroundPosition || 'center',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'gifv' || type === 'video') {
|
|
||||||
const shortDuration = original.duration < 31;
|
|
||||||
const isGIF = type === 'gifv' && shortDuration;
|
|
||||||
// If GIF is too long, treat it as a video
|
|
||||||
const loopable = original.duration < 61;
|
|
||||||
const formattedDuration = formatDuration(original.duration);
|
|
||||||
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
|
||||||
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
|
||||||
autoGIFAnimate ? 'media-contain' : ''
|
|
||||||
}`}
|
|
||||||
data-formatted-duration={formattedDuration}
|
|
||||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (hoverAnimate) {
|
|
||||||
try {
|
|
||||||
videoRef.current.pause();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
onClick(e);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (hoverAnimate) {
|
|
||||||
try {
|
|
||||||
videoRef.current.play();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (hoverAnimate) {
|
|
||||||
try {
|
|
||||||
videoRef.current.pause();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showOriginal || autoGIFAnimate ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
<video
|
|
||||||
src="${url}"
|
|
||||||
poster="${previewUrl}"
|
|
||||||
width="${width}"
|
|
||||||
height="${height}"
|
|
||||||
preload="auto"
|
|
||||||
autoplay
|
|
||||||
muted="${isGIF}"
|
|
||||||
${isGIF ? '' : 'controls'}
|
|
||||||
playsinline
|
|
||||||
loop="${loopable}"
|
|
||||||
></video>
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : isGIF ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={url}
|
|
||||||
poster={previewUrl}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
preload="auto"
|
|
||||||
// controls
|
|
||||||
playsinline
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={description}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'audio') {
|
|
||||||
const formattedDuration = formatDuration(original.duration);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="media media-audio"
|
|
||||||
data-formatted-duration={formattedDuration}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{showOriginal ? (
|
|
||||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
|
||||||
) : previewUrl ? (
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={description}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ card }) {
|
function Card({ card }) {
|
||||||
const {
|
const {
|
||||||
blurhash,
|
blurhash,
|
||||||
|
@ -1281,169 +1061,7 @@ function StatusButton({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
export function formatDuration(time) {
|
||||||
const carouselRef = useRef(null);
|
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(index);
|
|
||||||
const carouselFocusItem = useRef(null);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
carouselFocusItem.current?.node?.scrollIntoView();
|
|
||||||
}, []);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
carouselFocusItem.current?.node?.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}, [currentIndex]);
|
|
||||||
|
|
||||||
const onSnap = useDebouncedCallback((inView, i) => {
|
|
||||||
if (inView) {
|
|
||||||
setCurrentIndex(i);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let handleSwipe = () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
if (carouselRef.current) {
|
|
||||||
carouselRef.current.addEventListener('swiped-down', handleSwipe);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (carouselRef.current) {
|
|
||||||
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useHotkeys('esc', onClose, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={carouselRef}
|
|
||||||
tabIndex="-1"
|
|
||||||
data-swipe-threshold="44"
|
|
||||||
class="carousel"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (
|
|
||||||
e.target.classList.contains('carousel-item') ||
|
|
||||||
e.target.classList.contains('media')
|
|
||||||
) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mediaAttachments?.map((media, i) => {
|
|
||||||
const { blurhash } = media;
|
|
||||||
const rgbAverageColor = blurhash
|
|
||||||
? getBlurHashAverageColor(blurhash)
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<InView
|
|
||||||
class="carousel-item"
|
|
||||||
style={{
|
|
||||||
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
|
||||||
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
|
||||||
',',
|
|
||||||
)}, .5)`,
|
|
||||||
}}
|
|
||||||
tabindex="0"
|
|
||||||
key={media.id}
|
|
||||||
ref={i === currentIndex ? carouselFocusItem : null} // InView options
|
|
||||||
root={carouselRef.current}
|
|
||||||
threshold={1}
|
|
||||||
onChange={(inView) => onSnap(inView, i)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target !== e.currentTarget) {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Media media={media} showOriginal />
|
|
||||||
</InView>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div class="carousel-top-controls" hidden={!showControls}>
|
|
||||||
<span />
|
|
||||||
<span>
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
mediaAttachments[currentIndex]?.remoteUrl ||
|
|
||||||
mediaAttachments[currentIndex]?.url
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
class="button carousel-button plain2"
|
|
||||||
title="Open original media in new window"
|
|
||||||
>
|
|
||||||
<Icon icon="popout" alt="Open original media in new window" />
|
|
||||||
</a>{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain2"
|
|
||||||
onClick={() => onClose()}
|
|
||||||
>
|
|
||||||
<Icon icon="x" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{mediaAttachments?.length > 1 && (
|
|
||||||
<div class="carousel-controls" hidden={!showControls}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain2"
|
|
||||||
hidden={currentIndex === 0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setCurrentIndex(
|
|
||||||
(currentIndex - 1 + mediaAttachments.length) %
|
|
||||||
mediaAttachments.length,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="arrow-left" />
|
|
||||||
</button>
|
|
||||||
<span class="carousel-dots">
|
|
||||||
{mediaAttachments?.map((media, i) => (
|
|
||||||
<button
|
|
||||||
key={media.id}
|
|
||||||
type="button"
|
|
||||||
disabled={i === currentIndex}
|
|
||||||
class={`plain carousel-dot ${
|
|
||||||
i === currentIndex ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setCurrentIndex(i);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain2"
|
|
||||||
hidden={currentIndex === mediaAttachments.length - 1}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setCurrentIndex((currentIndex + 1) % mediaAttachments.length);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="arrow-right" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(time) {
|
|
||||||
if (!time) return;
|
if (!time) return;
|
||||||
let hours = Math.floor(time / 3600);
|
let hours = Math.floor(time / 3600);
|
||||||
let minutes = Math.floor((time % 3600) / 60);
|
let minutes = Math.floor((time % 3600) / 60);
|
||||||
|
|
163
src/components/timeline.jsx
Normal file
163
src/components/timeline.jsx
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import useScroll from '../utils/useScroll';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
|
import Loader from './loader';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
|
function Timeline({
|
||||||
|
title,
|
||||||
|
titleComponent,
|
||||||
|
path,
|
||||||
|
id,
|
||||||
|
emptyText,
|
||||||
|
errorText,
|
||||||
|
fetchItems = () => {},
|
||||||
|
}) {
|
||||||
|
if (title) {
|
||||||
|
useTitle(title, path);
|
||||||
|
}
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
const scrollableRef = useRef(null);
|
||||||
|
const { nearReachEnd, reachStart } = useScroll({
|
||||||
|
scrollableElement: scrollableRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadItems = (firstLoad) => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { done, value } = await fetchItems(firstLoad);
|
||||||
|
if (value?.length) {
|
||||||
|
if (firstLoad) {
|
||||||
|
setItems(value);
|
||||||
|
} else {
|
||||||
|
setItems([...items, ...value]);
|
||||||
|
}
|
||||||
|
setShowMore(!done);
|
||||||
|
} else {
|
||||||
|
setShowMore(false);
|
||||||
|
}
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0 });
|
||||||
|
loadItems(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reachStart) {
|
||||||
|
loadItems(true);
|
||||||
|
}
|
||||||
|
}, [reachStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nearReachEnd && showMore) {
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
|
}, [nearReachEnd, showMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`${id}-page`}
|
||||||
|
class="deck-container"
|
||||||
|
ref={scrollableRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
scrollableRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="header-side">
|
||||||
|
<Link to="/" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{!!items.length ? (
|
||||||
|
<>
|
||||||
|
<ul class="timeline">
|
||||||
|
{items.map((status) => {
|
||||||
|
const { id: statusID, reblog } = status;
|
||||||
|
const actualStatusID = reblog?.id || statusID;
|
||||||
|
return (
|
||||||
|
<li key={`timeline-${statusID}`}>
|
||||||
|
<Link class="status-link" to={`/s/${actualStatusID}`}>
|
||||||
|
<Status status={status} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{showMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain block"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => loadItems()}
|
||||||
|
style={{ marginBlockEnd: '6em' }}
|
||||||
|
>
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<Loader abrupt />
|
||||||
|
) : (
|
||||||
|
<>Show more…</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<ul class="timeline">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
uiState !== 'loading' && <p class="ui-state">{emptyText}</p>
|
||||||
|
)}
|
||||||
|
{uiState === 'error' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
{errorText}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
class="button plain"
|
||||||
|
onClick={() => loadItems(!items.length)}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
uiState !== 'loading' &&
|
||||||
|
!!items.length &&
|
||||||
|
!showMore && <p class="ui-state insignificant">The end.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timeline;
|
|
@ -2,7 +2,7 @@ import './index.css';
|
||||||
|
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import { login } from 'masto';
|
import { createClient } from 'masto';
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
@ -14,12 +14,12 @@ if (window.opener) {
|
||||||
console = window.opener.console;
|
console = window.opener.console;
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(() => {
|
||||||
if (window.masto) return;
|
if (window.masto) return;
|
||||||
console.warn('window.masto not found. Trying to log in...');
|
console.warn('window.masto not found. Trying to log in...');
|
||||||
try {
|
try {
|
||||||
const { instanceURL, accessToken } = getCurrentAccount();
|
const { instanceURL, accessToken } = getCurrentAccount();
|
||||||
window.masto = await login({
|
window.masto = createClient({
|
||||||
url: `https://${instanceURL}`,
|
url: `https://${instanceURL}`,
|
||||||
accessToken,
|
accessToken,
|
||||||
disableVersionCheck: true,
|
disableVersionCheck: true,
|
||||||
|
|
|
@ -28,14 +28,15 @@
|
||||||
--reply-to-color: var(--orange-color);
|
--reply-to-color: var(--orange-color);
|
||||||
--reply-to-text-color: #b36200;
|
--reply-to-text-color: #b36200;
|
||||||
--favourite-color: var(--red-color);
|
--favourite-color: var(--red-color);
|
||||||
--reply-to-faded-color: #ffa6001a;
|
--reply-to-faded-color: #ffa60030;
|
||||||
--outline-color: rgba(128, 128, 128, 0.2);
|
--outline-color: rgba(128, 128, 128, 0.2);
|
||||||
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
||||||
--divider-color: rgba(0, 0, 0, 0.1);
|
--divider-color: rgba(0, 0, 0, 0.1);
|
||||||
--backdrop-color: rgba(255, 255, 255, 0.5);
|
--backdrop-color: rgba(0, 0, 0, 0.05);
|
||||||
--img-bg-color: rgba(128, 128, 128, 0.2);
|
--img-bg-color: rgba(128, 128, 128, 0.2);
|
||||||
--loader-color: #1c1e2199;
|
--loader-color: #1c1e2199;
|
||||||
--comment-line-color: #e5e5e5;
|
--comment-line-color: #e5e5e5;
|
||||||
|
--drop-shadow-color: rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||||
}
|
}
|
||||||
|
@ -49,10 +50,9 @@
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--blue-color: CornflowerBlue;
|
--blue-color: CornflowerBlue;
|
||||||
--purple-color: mediumpurple;
|
--purple-color: #b190f1;
|
||||||
--green-color: lightgreen;
|
--green-color: lightgreen;
|
||||||
--orange-color: orange;
|
--orange-color: orange;
|
||||||
--reply-to-text-color: var(--reply-to-color);
|
|
||||||
--bg-color: #242526;
|
--bg-color: #242526;
|
||||||
--bg-faded-color: #18191a;
|
--bg-faded-color: #18191a;
|
||||||
--bg-blur-color: #0009;
|
--bg-blur-color: #0009;
|
||||||
|
@ -62,11 +62,15 @@
|
||||||
--link-light-color: #6494ed99;
|
--link-light-color: #6494ed99;
|
||||||
--link-faded-color: #6494ed88;
|
--link-faded-color: #6494ed88;
|
||||||
--link-bg-hover-color: #34353799;
|
--link-bg-hover-color: #34353799;
|
||||||
|
--reblog-faded-color: #b190f141;
|
||||||
|
--reply-to-text-color: var(--reply-to-color);
|
||||||
|
--reply-to-faded-color: #ffa60027;
|
||||||
--divider-color: rgba(255, 255, 255, 0.1);
|
--divider-color: rgba(255, 255, 255, 0.1);
|
||||||
--bg-blur-color: #24252699;
|
--bg-blur-color: #24252699;
|
||||||
--backdrop-color: rgba(0, 0, 0, 0.5);
|
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||||
--loader-color: #f0f2f599;
|
--loader-color: #f0f2f599;
|
||||||
--comment-line-color: #565656;
|
--comment-line-color: #565656;
|
||||||
|
--drop-shadow-color: rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +164,18 @@ button,
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
|
backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
|
||||||
}
|
}
|
||||||
|
:is(button, .button).plain3 {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--button-text-color);
|
||||||
|
backdrop-filter: blur(12px) invert(0.25);
|
||||||
|
}
|
||||||
|
:is(button, .button).plain4 {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
:is(button, .button).plain4:not(:disabled, .disabled):is(:hover, :focus) {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
:is(button, .button).light {
|
:is(button, .button).light {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|
10
src/main.jsx
10
src/main.jsx
|
@ -1,6 +1,9 @@
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
import '@szhsin/react-menu/dist/core.css';
|
||||||
|
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
|
import { HashRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
|
@ -8,7 +11,12 @@ if (import.meta.env.DEV) {
|
||||||
import('preact/debug');
|
import('preact/debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<App />, document.getElementById('app'));
|
render(
|
||||||
|
<HashRouter>
|
||||||
|
<App />
|
||||||
|
</HashRouter>,
|
||||||
|
document.getElementById('app'),
|
||||||
|
);
|
||||||
|
|
||||||
// Clean up iconify localStorage
|
// Clean up iconify localStorage
|
||||||
// TODO: Remove this after few weeks?
|
// TODO: Remove this after few weeks?
|
||||||
|
|
15
src/pages/404.jsx
Normal file
15
src/pages/404.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import Link from '../components/link';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div id="not-found-page" className="deck-container" tabIndex="-1">
|
||||||
|
<div>
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Page not found.</p>
|
||||||
|
<p>
|
||||||
|
<Link to="/">Go home</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
60
src/pages/account-statuses.jsx
Normal file
60
src/pages/account-statuses.jsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
import states from '../utils/states';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function AccountStatuses() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const accountStatusesIterator = useRef();
|
||||||
|
async function fetchAccountStatuses(firstLoad) {
|
||||||
|
if (firstLoad || !accountStatusesIterator.current) {
|
||||||
|
accountStatusesIterator.current = masto.v1.accounts.listStatuses(id, {
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await accountStatusesIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [account, setAccount] = useState({});
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const acc = await masto.v1.accounts.fetch(id);
|
||||||
|
console.log(acc);
|
||||||
|
setAccount(acc);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
key={id}
|
||||||
|
title={`${account?.acct ? '@' + account.acct : 'Posts'}`}
|
||||||
|
titleComponent={
|
||||||
|
<h1
|
||||||
|
class="header-account"
|
||||||
|
onClick={() => {
|
||||||
|
states.showAccount = account;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{account?.displayName}
|
||||||
|
<div>
|
||||||
|
<span>@{account?.acct}</span>
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
path="/a/:id"
|
||||||
|
id="account_statuses"
|
||||||
|
emptyText="Nothing to see here yet."
|
||||||
|
errorText="Unable to load statuses"
|
||||||
|
fetchItems={fetchAccountStatuses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountStatuses;
|
27
src/pages/bookmarks.jsx
Normal file
27
src/pages/bookmarks.jsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Bookmarks() {
|
||||||
|
const bookmarksIterator = useRef();
|
||||||
|
async function fetchBookmarks(firstLoad) {
|
||||||
|
if (firstLoad || !bookmarksIterator.current) {
|
||||||
|
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
|
||||||
|
}
|
||||||
|
return await bookmarksIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
title="Bookmarks"
|
||||||
|
id="bookmarks"
|
||||||
|
emptyText="No bookmarks yet. Go bookmark something!"
|
||||||
|
errorText="Unable to load bookmarks"
|
||||||
|
fetchItems={fetchBookmarks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Bookmarks;
|
27
src/pages/favourites.jsx
Normal file
27
src/pages/favourites.jsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Favourites() {
|
||||||
|
const favouritesIterator = useRef();
|
||||||
|
async function fetchFavourites(firstLoad) {
|
||||||
|
if (firstLoad || !favouritesIterator.current) {
|
||||||
|
favouritesIterator.current = masto.v1.favourites.list({ limit: LIMIT });
|
||||||
|
}
|
||||||
|
return await favouritesIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
title="Favourites"
|
||||||
|
id="favourites"
|
||||||
|
emptyText="No favourites yet. Go favourite something!"
|
||||||
|
errorText="Unable to load favourites"
|
||||||
|
fetchItems={fetchFavourites}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Favourites;
|
32
src/pages/hashtags.jsx
Normal file
32
src/pages/hashtags.jsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Hashtags() {
|
||||||
|
const { hashtag } = useParams();
|
||||||
|
const hashtagsIterator = useRef();
|
||||||
|
async function fetchHashtags(firstLoad) {
|
||||||
|
if (firstLoad || !hashtagsIterator.current) {
|
||||||
|
hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await hashtagsIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
key={hashtag}
|
||||||
|
title={`#${hashtag}`}
|
||||||
|
id="hashtags"
|
||||||
|
emptyText="No one has posted anything with this tag yet."
|
||||||
|
errorText="Unable to load posts with this tag"
|
||||||
|
fetchItems={fetchHashtags}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Hashtags;
|
|
@ -1,32 +1,31 @@
|
||||||
import { Link } from 'preact-router/match';
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Status from '../components/status';
|
import Status from '../components/status';
|
||||||
import db from '../utils/db';
|
import db from '../utils/db';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { getCurrentAccountNS } from '../utils/store-utils';
|
import { getCurrentAccountNS } from '../utils/store-utils';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
|
||||||
import useScroll from '../utils/useScroll';
|
import useScroll from '../utils/useScroll';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
|
||||||
function Home({ hidden }) {
|
function Home({ hidden }) {
|
||||||
|
useTitle('Home', '/');
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
|
const isHomeLocation = snapStates.currentLocation === '/';
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
|
||||||
console.debug('RENDER Home');
|
console.debug('RENDER Home');
|
||||||
|
|
||||||
const homeIterator = useRef(
|
const homeIterator = useRef();
|
||||||
masto.v1.timelines.listHome({
|
|
||||||
limit: LIMIT,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
async function fetchStatuses(firstLoad) {
|
async function fetchStatuses(firstLoad) {
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
// Reset iterator
|
// Reset iterator
|
||||||
|
@ -36,102 +35,111 @@ function Home({ hidden }) {
|
||||||
states.homeNew = [];
|
states.homeNew = [];
|
||||||
}
|
}
|
||||||
const allStatuses = await homeIterator.current.next();
|
const allStatuses = await homeIterator.current.next();
|
||||||
if (allStatuses.value <= 0) {
|
if (allStatuses.value?.length) {
|
||||||
return { done: true };
|
// ENFORCE sort by datetime (Latest first)
|
||||||
}
|
allStatuses.value.sort((a, b) => {
|
||||||
const homeValues = allStatuses.value.map((status) => {
|
const aDate = new Date(a.createdAt);
|
||||||
saveStatus(status);
|
const bDate = new Date(b.createdAt);
|
||||||
return {
|
return bDate - aDate;
|
||||||
id: status.id,
|
});
|
||||||
reblog: status.reblog?.id,
|
const homeValues = allStatuses.value.map((status) => {
|
||||||
reply: !!status.inReplyToAccountId,
|
saveStatus(status);
|
||||||
};
|
return {
|
||||||
});
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// BOOSTS CAROUSEL
|
// BOOSTS CAROUSEL
|
||||||
if (snapStates.settings.boostsCarousel) {
|
if (snapStates.settings.boostsCarousel) {
|
||||||
let specialHome = [];
|
let specialHome = [];
|
||||||
let boostStash = [];
|
let boostStash = [];
|
||||||
let serialBoosts = 0;
|
let serialBoosts = 0;
|
||||||
for (let i = 0; i < homeValues.length; i++) {
|
for (let i = 0; i < homeValues.length; i++) {
|
||||||
const status = homeValues[i];
|
const status = homeValues[i];
|
||||||
if (status.reblog) {
|
if (status.reblog) {
|
||||||
boostStash.push(status);
|
boostStash.push(status);
|
||||||
serialBoosts++;
|
serialBoosts++;
|
||||||
} else {
|
} else {
|
||||||
specialHome.push(status);
|
specialHome.push(status);
|
||||||
if (serialBoosts < 3) {
|
if (serialBoosts < 3) {
|
||||||
serialBoosts = 0;
|
serialBoosts = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// if boostStash is more than quarter of homeValues
|
||||||
// if boostStash is more than quarter of homeValues
|
// or if there are 3 or more boosts in a row
|
||||||
// or if there are 3 or more boosts in a row
|
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
|
||||||
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
|
// if boostStash is more than 3 quarter of homeValues
|
||||||
// if boostStash is more than 3 quarter of homeValues
|
const boostStashID = boostStash.map((status) => status.id);
|
||||||
const boostStashID = boostStash.map((status) => status.id);
|
if (boostStash.length > (homeValues.length * 3) / 4) {
|
||||||
if (boostStash.length > (homeValues.length * 3) / 4) {
|
// insert boost array at the end of specialHome list
|
||||||
// insert boost array at the end of specialHome list
|
specialHome = [
|
||||||
specialHome = [
|
...specialHome,
|
||||||
...specialHome,
|
{ id: boostStashID, boosts: boostStash },
|
||||||
{ id: boostStashID, boosts: boostStash },
|
];
|
||||||
];
|
} else {
|
||||||
|
// insert boosts array in the middle of specialHome list
|
||||||
|
const half = Math.floor(specialHome.length / 2);
|
||||||
|
specialHome = [
|
||||||
|
...specialHome.slice(0, half),
|
||||||
|
{
|
||||||
|
id: boostStashID,
|
||||||
|
boosts: boostStash,
|
||||||
|
},
|
||||||
|
...specialHome.slice(half),
|
||||||
|
];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// insert boosts array in the middle of specialHome list
|
// Untouched, this is fine
|
||||||
const half = Math.floor(specialHome.length / 2);
|
specialHome = homeValues;
|
||||||
specialHome = [
|
}
|
||||||
...specialHome.slice(0, half),
|
console.log({
|
||||||
{
|
specialHome,
|
||||||
id: boostStashID,
|
});
|
||||||
boosts: boostStash,
|
if (firstLoad) {
|
||||||
},
|
states.homeLast = specialHome[0];
|
||||||
...specialHome.slice(half),
|
states.home = specialHome;
|
||||||
];
|
} else {
|
||||||
|
states.home.push(...specialHome);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Untouched, this is fine
|
if (firstLoad) {
|
||||||
specialHome = homeValues;
|
states.homeLast = homeValues[0];
|
||||||
}
|
states.home = homeValues;
|
||||||
console.log({
|
} else {
|
||||||
specialHome,
|
states.home.push(...homeValues);
|
||||||
});
|
}
|
||||||
if (firstLoad) {
|
|
||||||
states.home = specialHome;
|
|
||||||
} else {
|
|
||||||
states.home.push(...specialHome);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (firstLoad) {
|
|
||||||
states.home = homeValues;
|
|
||||||
} else {
|
|
||||||
states.home.push(...homeValues);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
states.homeLastFetchTime = Date.now();
|
states.homeLastFetchTime = Date.now();
|
||||||
return {
|
return allStatuses;
|
||||||
done: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingStatuses = useRef(false);
|
const loadingStatuses = useRef(false);
|
||||||
const loadStatuses = useDebouncedCallback((firstLoad) => {
|
const loadStatuses = useDebouncedCallback(
|
||||||
if (loadingStatuses.current) return;
|
(firstLoad) => {
|
||||||
loadingStatuses.current = true;
|
if (loadingStatuses.current) return;
|
||||||
setUIState('loading');
|
loadingStatuses.current = true;
|
||||||
(async () => {
|
setUIState('loading');
|
||||||
try {
|
(async () => {
|
||||||
const { done } = await fetchStatuses(firstLoad);
|
try {
|
||||||
setShowMore(!done);
|
const { done } = await fetchStatuses(firstLoad);
|
||||||
setUIState('default');
|
setShowMore(!done);
|
||||||
} catch (e) {
|
setUIState('default');
|
||||||
console.warn(e);
|
} catch (e) {
|
||||||
setUIState('error');
|
console.warn(e);
|
||||||
} finally {
|
setUIState('error');
|
||||||
loadingStatuses.current = false;
|
} finally {
|
||||||
}
|
loadingStatuses.current = false;
|
||||||
})();
|
}
|
||||||
}, 1000);
|
})();
|
||||||
|
},
|
||||||
|
3000,
|
||||||
|
{ leading: true, trailing: false },
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStatuses(true);
|
loadStatuses(true);
|
||||||
|
@ -139,103 +147,121 @@ function Home({ hidden }) {
|
||||||
|
|
||||||
const scrollableRef = useRef();
|
const scrollableRef = useRef();
|
||||||
|
|
||||||
useHotkeys('j, shift+j', (_, handler) => {
|
useHotkeys(
|
||||||
// focus on next status after active status
|
'j, shift+j',
|
||||||
// Traverses .timeline li .status-link, focus on .status-link
|
(_, handler) => {
|
||||||
const activeStatus = document.activeElement.closest(
|
// focus on next status after active status
|
||||||
'.status-link, .status-boost-link',
|
// Traverses .timeline li .status-link, focus on .status-link
|
||||||
);
|
const activeStatus = document.activeElement.closest(
|
||||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
|
||||||
const allStatusLinks = Array.from(
|
|
||||||
scrollableRef.current.querySelectorAll(
|
|
||||||
'.status-link, .status-boost-link',
|
'.status-link, .status-boost-link',
|
||||||
),
|
);
|
||||||
);
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
if (
|
const allStatusLinks = Array.from(
|
||||||
activeStatus &&
|
scrollableRef.current.querySelectorAll(
|
||||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
'.status-link, .status-boost-link',
|
||||||
activeStatusRect.bottom > 0
|
),
|
||||||
) {
|
);
|
||||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
if (
|
||||||
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
activeStatus &&
|
||||||
if (handler.shift) {
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
// get next status that's not .status-boost-link
|
activeStatusRect.bottom > 0
|
||||||
nextStatus = allStatusLinks.find(
|
) {
|
||||||
(statusLink, index) =>
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||||
index > activeStatusIndex &&
|
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
||||||
!statusLink.classList.contains('status-boost-link'),
|
if (handler.shift) {
|
||||||
);
|
// get next status that's not .status-boost-link
|
||||||
|
nextStatus = allStatusLinks.find(
|
||||||
|
(statusLink, index) =>
|
||||||
|
index > activeStatusIndex &&
|
||||||
|
!statusLink.classList.contains('status-boost-link'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nextStatus) {
|
||||||
|
nextStatus.focus();
|
||||||
|
nextStatus.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
||||||
|
});
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (nextStatus) {
|
},
|
||||||
nextStatus.focus();
|
{
|
||||||
nextStatus.scrollIntoViewIfNeeded?.();
|
enabled: isHomeLocation,
|
||||||
}
|
},
|
||||||
} else {
|
);
|
||||||
// If active status is not in viewport, get the topmost status-link in viewport
|
|
||||||
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
|
||||||
const statusLinkRect = statusLink.getBoundingClientRect();
|
|
||||||
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
|
||||||
});
|
|
||||||
if (topmostStatusLink) {
|
|
||||||
topmostStatusLink.focus();
|
|
||||||
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useHotkeys('k. shift+k', () => {
|
useHotkeys(
|
||||||
// focus on previous status after active status
|
'k, shift+k',
|
||||||
// Traverses .timeline li .status-link, focus on .status-link
|
(_, handler) => {
|
||||||
const activeStatus = document.activeElement.closest(
|
// focus on previous status after active status
|
||||||
'.status-link, .status-boost-link',
|
// Traverses .timeline li .status-link, focus on .status-link
|
||||||
);
|
const activeStatus = document.activeElement.closest(
|
||||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
|
||||||
const allStatusLinks = Array.from(
|
|
||||||
scrollableRef.current.querySelectorAll(
|
|
||||||
'.status-link, .status-boost-link',
|
'.status-link, .status-boost-link',
|
||||||
),
|
);
|
||||||
);
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
if (
|
const allStatusLinks = Array.from(
|
||||||
activeStatus &&
|
scrollableRef.current.querySelectorAll(
|
||||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
'.status-link, .status-boost-link',
|
||||||
activeStatusRect.bottom > 0
|
),
|
||||||
) {
|
);
|
||||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
if (
|
||||||
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
activeStatus &&
|
||||||
if (handler.shift) {
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
// get prev status that's not .status-boost-link
|
activeStatusRect.bottom > 0
|
||||||
prevStatus = allStatusLinks.find(
|
) {
|
||||||
(statusLink, index) =>
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||||
index < activeStatusIndex &&
|
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
||||||
!statusLink.classList.contains('status-boost-link'),
|
if (handler.shift) {
|
||||||
);
|
// get prev status that's not .status-boost-link
|
||||||
|
prevStatus = allStatusLinks.findLast(
|
||||||
|
(statusLink, index) =>
|
||||||
|
index < activeStatusIndex &&
|
||||||
|
!statusLink.classList.contains('status-boost-link'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (prevStatus) {
|
||||||
|
prevStatus.focus();
|
||||||
|
prevStatus.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
||||||
|
});
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (prevStatus) {
|
},
|
||||||
prevStatus.focus();
|
{
|
||||||
prevStatus.scrollIntoViewIfNeeded?.();
|
enabled: isHomeLocation,
|
||||||
}
|
},
|
||||||
} else {
|
);
|
||||||
// If active status is not in viewport, get the topmost status-link in viewport
|
|
||||||
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
|
||||||
const statusLinkRect = statusLink.getBoundingClientRect();
|
|
||||||
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
|
||||||
});
|
|
||||||
if (topmostStatusLink) {
|
|
||||||
topmostStatusLink.focus();
|
|
||||||
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useHotkeys(['enter', 'o'], () => {
|
useHotkeys(
|
||||||
// open active status
|
['enter', 'o'],
|
||||||
const activeStatus = document.activeElement.closest(
|
() => {
|
||||||
'.status-link, .status-boost-link',
|
// open active status
|
||||||
);
|
const activeStatus = document.activeElement.closest(
|
||||||
if (activeStatus) {
|
'.status-link, .status-boost-link',
|
||||||
activeStatus.click();
|
);
|
||||||
}
|
if (activeStatus) {
|
||||||
});
|
activeStatus.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isHomeLocation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scrollDirection,
|
scrollDirection,
|
||||||
|
@ -275,14 +301,159 @@ function Home({ hidden }) {
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
|
||||||
|
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
|
||||||
|
setShowUpdatesButton(isNewAndTop);
|
||||||
|
}, [snapStates.homeNew.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
id="home-page"
|
<div
|
||||||
class="deck-container"
|
id="home-page"
|
||||||
hidden={hidden}
|
class="deck-container"
|
||||||
ref={scrollableRef}
|
hidden={hidden}
|
||||||
tabIndex="-1"
|
ref={scrollableRef}
|
||||||
>
|
tabIndex="-1"
|
||||||
|
>
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header
|
||||||
|
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||||
|
onClick={() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
onDblClick={() => {
|
||||||
|
loadStatuses(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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'} />{' '}
|
||||||
|
<Link
|
||||||
|
to="/notifications"
|
||||||
|
class={`button plain ${
|
||||||
|
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" size="l" alt="Notifications" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{snapStates.homeNew.length > 0 &&
|
||||||
|
uiState !== 'loading' &&
|
||||||
|
((scrollDirection === 'start' &&
|
||||||
|
!nearReachStart &&
|
||||||
|
!nearReachEnd) ||
|
||||||
|
showUpdatesButton) && (
|
||||||
|
<button
|
||||||
|
class="updates-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!snapStates.settings.boostsCarousel) {
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{snapStates.home.length ? (
|
||||||
|
<>
|
||||||
|
<ul class="timeline">
|
||||||
|
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
|
||||||
|
const actualStatusID = reblog || statusID;
|
||||||
|
if (boosts) {
|
||||||
|
return (
|
||||||
|
<li key={statusID}>
|
||||||
|
<BoostsCarousel boosts={boosts} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={statusID}>
|
||||||
|
<Link class="status-link" to={`/s/${actualStatusID}`}>
|
||||||
|
<Status statusID={statusID} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showMore && (
|
||||||
|
<>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
height: '20vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
height: '25vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
Unable to load statuses
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
loadStatuses(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -301,157 +472,7 @@ function Home({ hidden }) {
|
||||||
>
|
>
|
||||||
<Icon icon="quill" size="xxl" alt="Compose" />
|
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||||
</button>
|
</button>
|
||||||
<div class="timeline-deck deck">
|
</>
|
||||||
<header
|
|
||||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
|
||||||
onClick={() => {
|
|
||||||
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
onDblClick={() => {
|
|
||||||
loadStatuses(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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' : ''
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="notification" size="l" alt="Notifications" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{snapStates.homeNew.length > 0 &&
|
|
||||||
scrollDirection === 'start' &&
|
|
||||||
!nearReachStart &&
|
|
||||||
!nearReachEnd && (
|
|
||||||
<button
|
|
||||||
class="updates-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!snapStates.settings.boostsCarousel) {
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{snapStates.home.length ? (
|
|
||||||
<>
|
|
||||||
<ul class="timeline">
|
|
||||||
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
|
|
||||||
const actualStatusID = reblog || statusID;
|
|
||||||
if (boosts) {
|
|
||||||
return (
|
|
||||||
<li key={statusID}>
|
|
||||||
<BoostsCarousel boosts={boosts} />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<li key={statusID}>
|
|
||||||
<Link
|
|
||||||
activeClassName="active"
|
|
||||||
class="status-link"
|
|
||||||
href={`#/s/${actualStatusID}`}
|
|
||||||
>
|
|
||||||
<Status statusID={statusID} />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{showMore && (
|
|
||||||
<>
|
|
||||||
{/* <InView
|
|
||||||
as="li"
|
|
||||||
style={{
|
|
||||||
height: '20vh',
|
|
||||||
}}
|
|
||||||
onChange={(inView) => {
|
|
||||||
if (inView) loadStatuses();
|
|
||||||
}}
|
|
||||||
root={scrollableRef.current}
|
|
||||||
rootMargin="100px 0px"
|
|
||||||
> */}
|
|
||||||
<li
|
|
||||||
style={{
|
|
||||||
height: '20vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Status skeleton />
|
|
||||||
</li>
|
|
||||||
{/* </InView> */}
|
|
||||||
<li
|
|
||||||
style={{
|
|
||||||
height: '25vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Status skeleton />
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
Unable to load statuses
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
loadStatuses(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -503,10 +524,10 @@ function BoostsCarousel({ boosts }) {
|
||||||
const { id: statusID, reblog } = boost;
|
const { id: statusID, reblog } = boost;
|
||||||
const actualStatusID = reblog || statusID;
|
const actualStatusID = reblog || statusID;
|
||||||
return (
|
return (
|
||||||
<li>
|
<li key={statusID}>
|
||||||
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
|
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
|
||||||
<Status statusID={statusID} size="s" />
|
<Status statusID={statusID} size="s" />
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
43
src/pages/lists.jsx
Normal file
43
src/pages/lists.jsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Lists() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const listsIterator = useRef();
|
||||||
|
async function fetchLists(firstLoad) {
|
||||||
|
if (firstLoad || !listsIterator.current) {
|
||||||
|
listsIterator.current = masto.v1.timelines.listList(id, {
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await listsIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(`List ${id}`);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const list = await masto.v1.lists.fetch(id);
|
||||||
|
setTitle(list.title);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
title={title}
|
||||||
|
id="lists"
|
||||||
|
emptyText="Nothing yet."
|
||||||
|
errorText="Unable to load posts."
|
||||||
|
fetchItems={fetchLists}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lists;
|
|
@ -2,6 +2,7 @@ import './login.css';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Link from '../components/link';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import instancesListURL from '../data/instances.json?url';
|
import instancesListURL from '../data/instances.json?url';
|
||||||
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
||||||
|
@ -111,7 +112,7 @@ function Login() {
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="/#">Go home</a>
|
<Link to="/">Go home</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px !important;
|
padding: 16px !important;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
animation: appear 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.notification.mention {
|
.notification.mention {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import './notifications.css';
|
import './notifications.css';
|
||||||
|
|
||||||
import { Link } from 'preact-router/match';
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Avatar from '../components/avatar';
|
import Avatar from '../components/avatar';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import RelativeTime from '../components/relative-time';
|
import RelativeTime from '../components/relative-time';
|
||||||
import Status from '../components/status';
|
import Status from '../components/status';
|
||||||
import states from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import useScroll from '../utils/useScroll';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -45,6 +46,228 @@ const contentText = {
|
||||||
|
|
||||||
const LIMIT = 30; // 30 is the maximum limit :(
|
const LIMIT = 30; // 30 is the maximum limit :(
|
||||||
|
|
||||||
|
function Notifications() {
|
||||||
|
useTitle('Notifications', '/notifications');
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
const [onlyMentions, setOnlyMentions] = useState(false);
|
||||||
|
const scrollableRef = useRef();
|
||||||
|
const { nearReachEnd, reachStart } = useScroll({
|
||||||
|
scrollableElement: scrollableRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug('RENDER Notifications');
|
||||||
|
|
||||||
|
const notificationsIterator = useRef();
|
||||||
|
async function fetchNotifications(firstLoad) {
|
||||||
|
if (firstLoad) {
|
||||||
|
// Reset iterator
|
||||||
|
notificationsIterator.current = masto.v1.notifications.list({
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
states.notificationsNew = [];
|
||||||
|
}
|
||||||
|
const allNotifications = await notificationsIterator.current.next();
|
||||||
|
if (allNotifications.value?.length) {
|
||||||
|
const notificationsValues = allNotifications.value.map((notification) => {
|
||||||
|
saveStatus(notification.status, {
|
||||||
|
skipThreading: true,
|
||||||
|
override: false,
|
||||||
|
});
|
||||||
|
return notification;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedNotifications = groupNotifications(notificationsValues);
|
||||||
|
|
||||||
|
if (firstLoad) {
|
||||||
|
states.notificationLast = notificationsValues[0];
|
||||||
|
states.notifications = groupedNotifications;
|
||||||
|
} else {
|
||||||
|
states.notifications.push(...groupedNotifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
states.notificationsLastFetchTime = Date.now();
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (reachStart) {
|
||||||
|
loadNotifications(true);
|
||||||
|
}
|
||||||
|
}, [reachStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nearReachEnd && showMore) {
|
||||||
|
loadNotifications();
|
||||||
|
}
|
||||||
|
}, [nearReachEnd, showMore]);
|
||||||
|
|
||||||
|
const todayDate = new Date();
|
||||||
|
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
||||||
|
let currentDay = new Date();
|
||||||
|
const showTodayEmpty = !snapStates.notifications.some(
|
||||||
|
(notification) =>
|
||||||
|
new Date(notification.createdAt).toDateString() ===
|
||||||
|
todayDate.toDateString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="notifications-page"
|
||||||
|
class="deck-container"
|
||||||
|
ref={scrollableRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
|
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||||
|
<header
|
||||||
|
onClick={() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="header-side">
|
||||||
|
<Link to="/" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{snapStates.notificationsNew.length > 0 && uiState !== 'loading' && (
|
||||||
|
<button
|
||||||
|
class="updates-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
loadNotifications(true);
|
||||||
|
states.notificationsNew = [];
|
||||||
|
|
||||||
|
scrollableRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-up" /> New notifications
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div id="mentions-option">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyMentions}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOnlyMentions(e.target.checked);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
Only mentions
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<h2 class="timeline-header">Today</h2>
|
||||||
|
{showTodayEmpty && !!snapStates.notifications.length && (
|
||||||
|
<p class="ui-state insignificant">
|
||||||
|
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{snapStates.notifications.length ? (
|
||||||
|
<>
|
||||||
|
{snapStates.notifications.map((notification) => {
|
||||||
|
if (onlyMentions && notification.type !== 'mention') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const notificationDay = new Date(notification.createdAt);
|
||||||
|
const differentDay =
|
||||||
|
notificationDay.toDateString() !== currentDay.toDateString();
|
||||||
|
if (differentDay) {
|
||||||
|
currentDay = notificationDay;
|
||||||
|
}
|
||||||
|
// if notificationDay is yesterday, show "Yesterday"
|
||||||
|
// if notificationDay is before yesterday, show date
|
||||||
|
const heading =
|
||||||
|
notificationDay.toDateString() === yesterdayDate.toDateString()
|
||||||
|
? 'Yesterday'
|
||||||
|
: Intl.DateTimeFormat('en', {
|
||||||
|
// Show year if not current year
|
||||||
|
year:
|
||||||
|
currentDay.getFullYear() === todayDate.getFullYear()
|
||||||
|
? undefined
|
||||||
|
: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(currentDay);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{differentDay && <h2 class="timeline-header">{heading}</h2>}
|
||||||
|
<Notification
|
||||||
|
notification={notification}
|
||||||
|
key={notification.id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{uiState === 'loading' && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Unable to load notifications
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button type="button" onClick={() => loadNotifications(true)}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain block"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => loadNotifications()}
|
||||||
|
style={{ marginBlockEnd: '6em' }}
|
||||||
|
>
|
||||||
|
{uiState === 'loading' ? <Loader abrupt /> : <>Show more…</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
function Notification({ notification }) {
|
function Notification({ notification }) {
|
||||||
const { id, type, status, account, _accounts } = notification;
|
const { id, type, status, account, _accounts } = notification;
|
||||||
|
|
||||||
|
@ -61,7 +284,7 @@ function Notification({ notification }) {
|
||||||
: contentText[type];
|
: contentText[type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div class={`notification ${type}`} tabIndex="0">
|
||||||
<div
|
<div
|
||||||
class={`notification-type notification-${type}`}
|
class={`notification-type notification-${type}`}
|
||||||
title={new Date(notification.createdAt).toLocaleString()}
|
title={new Date(notification.createdAt).toLocaleString()}
|
||||||
|
@ -137,11 +360,13 @@ function Notification({ notification }) {
|
||||||
<Avatar
|
<Avatar
|
||||||
url={account.avatarStatic}
|
url={account.avatarStatic}
|
||||||
size={
|
size={
|
||||||
_accounts.length < 30
|
_accounts.length <= 10
|
||||||
? 'xl'
|
? 'xxl'
|
||||||
: _accounts.length < 100
|
: _accounts.length < 100
|
||||||
? 'l'
|
? 'xl'
|
||||||
: _accounts.length < 1000
|
: _accounts.length < 1000
|
||||||
|
? 'l'
|
||||||
|
: _accounts.length < 2000
|
||||||
? 'm'
|
? 'm'
|
||||||
: 's' // My god, this person is popular!
|
: 's' // My god, this person is popular!
|
||||||
}
|
}
|
||||||
|
@ -156,270 +381,12 @@ function Notification({ notification }) {
|
||||||
{status && (
|
{status && (
|
||||||
<Link
|
<Link
|
||||||
class={`status-link status-type-${type}`}
|
class={`status-link status-type-${type}`}
|
||||||
href={`#/s/${actualStatusID}`}
|
to={`/s/${actualStatusID}`}
|
||||||
>
|
>
|
||||||
<Status status={status} size="s" />
|
<Status status={status} size="s" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationsList({ notifications, emptyCopy }) {
|
|
||||||
if (!notifications.length && emptyCopy) {
|
|
||||||
return <p class="timeline-empty">{emptyCopy}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new flat list of notifications
|
|
||||||
// Combine sibling notifications based on type and status id, ignore the id
|
|
||||||
// Concat all notification.account into an array of _accounts
|
|
||||||
const notificationsMap = {};
|
|
||||||
const cleanNotifications = [];
|
|
||||||
for (let i = 0, j = 0; i < notifications.length; i++) {
|
|
||||||
const notification = notifications[i];
|
|
||||||
// const cleanNotification = cleanNotifications[j];
|
|
||||||
const { status, account, type, created_at } = notification;
|
|
||||||
const createdAt = new Date(created_at).toLocaleDateString();
|
|
||||||
const key = `${status?.id}-${type}-${createdAt}`;
|
|
||||||
const mappedNotification = notificationsMap[key];
|
|
||||||
if (mappedNotification?.account) {
|
|
||||||
mappedNotification._accounts.push(account);
|
|
||||||
} else {
|
|
||||||
let n = (notificationsMap[key] = {
|
|
||||||
...notification,
|
|
||||||
_accounts: [account],
|
|
||||||
});
|
|
||||||
cleanNotifications[j++] = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// console.log({ notifications, cleanNotifications });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul class="timeline flat">
|
|
||||||
{cleanNotifications.map((notification, i) => {
|
|
||||||
const { id, type } = notification;
|
|
||||||
return (
|
|
||||||
<li key={id} class={`notification ${type}`} tabIndex="0">
|
|
||||||
<Notification notification={notification} />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Notifications() {
|
|
||||||
useTitle('Notifications');
|
|
||||||
const snapStates = useSnapshot(states);
|
|
||||||
const [uiState, setUIState] = useState('default');
|
|
||||||
const [showMore, setShowMore] = useState(false);
|
|
||||||
const [onlyMentions, setOnlyMentions] = useState(false);
|
|
||||||
|
|
||||||
console.debug('RENDER Notifications');
|
|
||||||
|
|
||||||
const notificationsIterator = useRef(
|
|
||||||
masto.v1.notifications.list({
|
|
||||||
limit: LIMIT,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
async function fetchNotifications(firstLoad) {
|
|
||||||
if (firstLoad) {
|
|
||||||
// Reset iterator
|
|
||||||
notificationsIterator.current = masto.v1.notifications.list({
|
|
||||||
limit: LIMIT,
|
|
||||||
});
|
|
||||||
states.notificationsNew = [];
|
|
||||||
}
|
|
||||||
const allNotifications = await notificationsIterator.current.next();
|
|
||||||
if (allNotifications.value <= 0) {
|
|
||||||
return { done: true };
|
|
||||||
}
|
|
||||||
const notificationsValues = allNotifications.value.map((notification) => {
|
|
||||||
if (notification.status) {
|
|
||||||
states.statuses[notification.status.id] = notification.status;
|
|
||||||
}
|
|
||||||
return notification;
|
|
||||||
});
|
|
||||||
if (firstLoad) {
|
|
||||||
states.notifications = notificationsValues;
|
|
||||||
} else {
|
|
||||||
states.notifications.push(...notificationsValues);
|
|
||||||
}
|
|
||||||
states.notificationsLastFetchTime = Date.now();
|
|
||||||
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.createdAt);
|
|
||||||
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
|
|
||||||
id="notifications-page"
|
|
||||||
class="deck-container"
|
|
||||||
ref={scrollableRef}
|
|
||||||
tabIndex="-1"
|
|
||||||
>
|
|
||||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
|
||||||
<header
|
|
||||||
onClick={() => {
|
|
||||||
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</header>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<div id="mentions-option">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={onlyMentions}
|
|
||||||
onChange={(e) => {
|
|
||||||
setOnlyMentions(e.target.checked);
|
|
||||||
}}
|
|
||||||
/>{' '}
|
|
||||||
Only mentions
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{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()}
|
|
||||||
style={{ marginBlockEnd: '6em' }}
|
|
||||||
>
|
|
||||||
{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">
|
|
||||||
Unable to load notifications
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<button type="button" onClick={() => loadNotifications(true)}>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -470,4 +437,29 @@ function FollowRequestButtons({ accountID, onChange }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupNotifications(notifications) {
|
||||||
|
// Create new flat list of notifications
|
||||||
|
// Combine sibling notifications based on type and status id
|
||||||
|
// Concat all notification.account into an array of _accounts
|
||||||
|
const notificationsMap = {};
|
||||||
|
const cleanNotifications = [];
|
||||||
|
for (let i = 0, j = 0; i < notifications.length; i++) {
|
||||||
|
const notification = notifications[i];
|
||||||
|
const { status, account, type, created_at } = notification;
|
||||||
|
const createdAt = new Date(created_at).toLocaleDateString();
|
||||||
|
const key = `${status?.id}-${type}-${createdAt}`;
|
||||||
|
const mappedNotification = notificationsMap[key];
|
||||||
|
if (mappedNotification?.account) {
|
||||||
|
mappedNotification._accounts.push(account);
|
||||||
|
} else {
|
||||||
|
let n = (notificationsMap[key] = {
|
||||||
|
...notification,
|
||||||
|
_accounts: [account],
|
||||||
|
});
|
||||||
|
cleanNotifications[j++] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(Notifications);
|
export default memo(Notifications);
|
||||||
|
|
76
src/pages/public.jsx
Normal file
76
src/pages/public.jsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
// EXPERIMENTAL: This is a work in progress and may not work as expected.
|
||||||
|
import { useMatch, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
let nextUrl = null;
|
||||||
|
|
||||||
|
function Public() {
|
||||||
|
const isLocal = !!useMatch('/p/l/:instance');
|
||||||
|
const params = useParams();
|
||||||
|
const { instance = '' } = params;
|
||||||
|
async function fetchPublic(firstLoad) {
|
||||||
|
const url = firstLoad
|
||||||
|
? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}`
|
||||||
|
: nextUrl;
|
||||||
|
if (!url) return { values: [], done: true };
|
||||||
|
const response = await fetch(url);
|
||||||
|
let value = await response.json();
|
||||||
|
if (value) {
|
||||||
|
value = camelCaseKeys(value);
|
||||||
|
}
|
||||||
|
const done = !response.headers.has('link');
|
||||||
|
nextUrl = done
|
||||||
|
? null
|
||||||
|
: response.headers.get('link').match(/<(.+?)>; rel="next"/)?.[1];
|
||||||
|
console.debug({
|
||||||
|
url,
|
||||||
|
value,
|
||||||
|
done,
|
||||||
|
nextUrl,
|
||||||
|
});
|
||||||
|
return { value, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
key={instance + isLocal}
|
||||||
|
title={`${instance} (${isLocal ? 'local' : 'federated'})`}
|
||||||
|
id="public"
|
||||||
|
emptyText="No one has posted anything yet."
|
||||||
|
errorText="Unable to load posts"
|
||||||
|
fetchItems={fetchPublic}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelCaseKeys(obj) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => camelCaseKeys(item));
|
||||||
|
}
|
||||||
|
return new Proxy(obj, {
|
||||||
|
get(target, prop) {
|
||||||
|
let value = undefined;
|
||||||
|
if (prop in target) {
|
||||||
|
value = target[prop];
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
const snakeCaseProp = prop.replace(
|
||||||
|
/([A-Z])/g,
|
||||||
|
(g) => `_${g.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
if (snakeCaseProp in target) {
|
||||||
|
value = target[snakeCaseProp];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return camelCaseKeys(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Public;
|
|
@ -14,13 +14,14 @@
|
||||||
|
|
||||||
#settings-container :is(section, .section) {
|
#settings-container :is(section, .section) {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
margin: 0 -16px;
|
margin: 0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-top: var(--hairline-width) solid var(--outline-color);
|
border-top: var(--hairline-width) solid var(--outline-color);
|
||||||
border-bottom: var(--hairline-width) solid var(--outline-color);
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
#settings-container :is(section, .section) > li + li {
|
#settings-container :is(section, .section) > li:last-child {
|
||||||
border-top: var(--hairline-width) solid var(--outline-color);
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-container ul {
|
#settings-container ul {
|
||||||
|
@ -28,33 +29,34 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
#settings-container ul li {
|
#settings-container ul:not([role='menu']) > li {
|
||||||
padding: 8px 0;
|
padding: 8px 0 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
}
|
}
|
||||||
#settings-container ul li .current {
|
#settings-container ul:not([role='menu']) > li .current {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
color: var(--green-color);
|
color: var(--green-color);
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
#settings-container ul li .current.is-current {
|
#settings-container ul:not([role='menu']) > li .current.is-current {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
#settings-container ul li .current.is-current + .avatar {
|
#settings-container ul:not([role='menu']) > li .current.is-current + .avatar {
|
||||||
box-shadow: 0 0 0 1.5px var(--green-color);
|
box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color);
|
||||||
}
|
}
|
||||||
#settings-container ul li > div {
|
#settings-container ul:not([role='menu']) > li > div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
#settings-container ul li > div.actions {
|
#settings-container ul:not([role='menu']) > li > div.actions {
|
||||||
flex-basis: min-content;
|
flex-basis: fit-content;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
#settings-container ul li > div:last-child {
|
#settings-container ul:not([role='menu']) > li > div:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
#settings-container div,
|
#settings-container div,
|
||||||
|
@ -98,10 +100,3 @@
|
||||||
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
|
||||||
#settings-container :is(section, .section) {
|
|
||||||
margin-inline: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import './settings.css';
|
import './settings.css';
|
||||||
|
|
||||||
import { useRef, useState } from 'preact/hooks';
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
|
import { useReducer, useRef, useState } from 'preact/hooks';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import logo from '../assets/logo.svg';
|
||||||
import Avatar from '../components/avatar';
|
import Avatar from '../components/avatar';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import RelativeTime from '../components/relative-time';
|
import RelativeTime from '../components/relative-time';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
@ -26,6 +29,8 @@ function Settings({ onClose }) {
|
||||||
const moreThanOneAccount = accounts.length > 1;
|
const moreThanOneAccount = accounts.length > 1;
|
||||||
const [currentDefault, setCurrentDefault] = useState(0);
|
const [currentDefault, setCurrentDefault] = useState(0);
|
||||||
|
|
||||||
|
const [_, reload] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||||
<main>
|
<main>
|
||||||
|
@ -39,14 +44,30 @@ function Settings({ onClose }) {
|
||||||
const isCurrent = account.info.id === currentAccount;
|
const isCurrent = account.info.id === currentAccount;
|
||||||
const isDefault = i === (currentDefault || 0);
|
const isDefault = i === (currentDefault || 0);
|
||||||
return (
|
return (
|
||||||
<li>
|
<li key={i + account.id}>
|
||||||
<div>
|
<div>
|
||||||
{moreThanOneAccount && (
|
{moreThanOneAccount && (
|
||||||
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||||
<Icon icon="check-circle" alt="Current" />
|
<Icon icon="check-circle" alt="Current" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Avatar url={account.info.avatarStatic} size="xxl" />
|
<Avatar
|
||||||
|
url={account.info.avatarStatic}
|
||||||
|
size="xxl"
|
||||||
|
onDblClick={async () => {
|
||||||
|
if (isCurrent) {
|
||||||
|
try {
|
||||||
|
const info = await masto.v1.accounts.fetch(
|
||||||
|
account.info.id,
|
||||||
|
);
|
||||||
|
console.log('fetched account info', info);
|
||||||
|
account.info = info;
|
||||||
|
store.local.setJSON('accounts', accounts);
|
||||||
|
reload();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<NameText
|
<NameText
|
||||||
account={account.info}
|
account={account.info}
|
||||||
showAcct
|
showAcct
|
||||||
|
@ -73,11 +94,21 @@ function Settings({ onClose }) {
|
||||||
<Icon icon="transfer" /> Switch
|
<Icon icon="transfer" /> Switch
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div>
|
<Menu
|
||||||
{!isDefault && moreThanOneAccount && (
|
align="end"
|
||||||
|
menuButton={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plain small"
|
title="More"
|
||||||
|
class="plain more-button"
|
||||||
|
>
|
||||||
|
<Icon icon="more" size="l" alt="More" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{moreThanOneAccount && (
|
||||||
|
<MenuItem
|
||||||
|
disabled={isDefault}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Move account to the top of the list
|
// Move account to the top of the list
|
||||||
accounts.splice(i, 1);
|
accounts.splice(i, 1);
|
||||||
|
@ -87,29 +118,23 @@ function Settings({ onClose }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Set as default
|
Set as default
|
||||||
</button>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{isCurrent && (
|
<MenuItem
|
||||||
<>
|
disabled={!isCurrent}
|
||||||
{' '}
|
onClick={() => {
|
||||||
<button
|
const yes = confirm(
|
||||||
type="button"
|
'Are you sure you want to log out?',
|
||||||
class="plain small"
|
);
|
||||||
onClick={() => {
|
if (!yes) return;
|
||||||
const yes = confirm(
|
accounts.splice(i, 1);
|
||||||
'Are you sure you want to log out?',
|
store.local.setJSON('accounts', accounts);
|
||||||
);
|
location.reload();
|
||||||
if (!yes) return;
|
}}
|
||||||
accounts.splice(i, 1);
|
>
|
||||||
store.local.setJSON('accounts', accounts);
|
Log out
|
||||||
location.reload();
|
</MenuItem>
|
||||||
}}
|
</Menu>
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -124,9 +149,9 @@ function Settings({ onClose }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p style={{ textAlign: 'end' }}>
|
<p style={{ textAlign: 'end' }}>
|
||||||
<a href="/#/login" class="button" onClick={onClose}>
|
<Link to="/login" class="button" onClick={onClose}>
|
||||||
Add new account
|
Add new account
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
@ -226,6 +251,29 @@ function Settings({ onClose }) {
|
||||||
</section>
|
</section>
|
||||||
<h2>About</h2>
|
<h2>About</h2>
|
||||||
<section>
|
<section>
|
||||||
|
<p>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<a
|
||||||
|
href="https://hachyderm.io/@phanpy"
|
||||||
|
// target="_blank"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
states.showAccount = 'phanpy@hachyderm.io';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@phanpy
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||||
Built
|
Built
|
||||||
|
@ -241,6 +289,13 @@ function Settings({ onClose }) {
|
||||||
>
|
>
|
||||||
@cheeaun
|
@cheeaun
|
||||||
</a>
|
</a>
|
||||||
|
.{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
{__BUILD_TIME__ && (
|
{__BUILD_TIME__ && (
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
|
|
||||||
.hero-heading {
|
.hero-heading {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
pointer-events: none;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
import './status.css';
|
import './status.css';
|
||||||
|
|
||||||
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { Link } from 'preact-router/match';
|
import pRetry from 'p-retry';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import Avatar from '../components/avatar';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import RelativeTime from '../components/relative-time';
|
import RelativeTime from '../components/relative-time';
|
||||||
|
@ -15,15 +20,23 @@ import Status from '../components/status';
|
||||||
import htmlContentLength from '../utils/html-content-length';
|
import htmlContentLength from '../utils/html-content-length';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states, { saveStatus, threadifyStatus } from '../utils/states';
|
import states, { saveStatus, threadifyStatus } from '../utils/states';
|
||||||
import store from '../utils/store';
|
|
||||||
import { getCurrentAccount } from '../utils/store-utils';
|
import { getCurrentAccount } from '../utils/store-utils';
|
||||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
|
||||||
import useScroll from '../utils/useScroll';
|
import useScroll from '../utils/useScroll';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 40;
|
const LIMIT = 40;
|
||||||
|
const THREAD_LIMIT = 20;
|
||||||
|
|
||||||
function StatusPage({ id }) {
|
let cachedStatusesMap = {};
|
||||||
|
function resetScrollPosition(id) {
|
||||||
|
delete cachedStatusesMap[id];
|
||||||
|
delete states.scrollPositions[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [statuses, setStatuses] = useState([]);
|
const [statuses, setStatuses] = useState([]);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
@ -47,19 +60,18 @@ function StatusPage({ id }) {
|
||||||
});
|
});
|
||||||
onScroll();
|
onScroll();
|
||||||
return () => {
|
return () => {
|
||||||
onScroll.cancel();
|
onScroll.flush();
|
||||||
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
||||||
};
|
};
|
||||||
}, [id, uiState !== 'loading']);
|
}, [id, uiState !== 'loading']);
|
||||||
|
|
||||||
const scrollOffsets = useRef();
|
const scrollOffsets = useRef();
|
||||||
const cachedStatusesMap = useRef({});
|
|
||||||
const initContext = () => {
|
const initContext = () => {
|
||||||
console.debug('initContext', id);
|
console.debug('initContext', id);
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
let heroTimer;
|
let heroTimer;
|
||||||
|
|
||||||
const cachedStatuses = cachedStatusesMap.current[id];
|
const cachedStatuses = cachedStatusesMap[id];
|
||||||
if (cachedStatuses) {
|
if (cachedStatuses) {
|
||||||
// Case 1: It's cached, let's restore them to make it snappy
|
// Case 1: It's cached, let's restore them to make it snappy
|
||||||
const reallyCachedStatuses = cachedStatuses.filter(
|
const reallyCachedStatuses = cachedStatuses.filter(
|
||||||
|
@ -80,8 +92,13 @@ function StatusPage({ id }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const heroFetch = () => masto.v1.statuses.fetch(id);
|
const heroFetch = () =>
|
||||||
const contextFetch = masto.v1.statuses.fetchContext(id);
|
pRetry(() => masto.v1.statuses.fetch(id), {
|
||||||
|
retries: 4,
|
||||||
|
});
|
||||||
|
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
|
||||||
|
retries: 8,
|
||||||
|
});
|
||||||
|
|
||||||
const hasStatus = !!snapStates.statuses[id];
|
const hasStatus = !!snapStates.statuses[id];
|
||||||
let heroStatus = snapStates.statuses[id];
|
let heroStatus = snapStates.statuses[id];
|
||||||
|
@ -127,8 +144,8 @@ function StatusPage({ id }) {
|
||||||
}
|
}
|
||||||
parent.__replies.push(status);
|
parent.__replies.push(status);
|
||||||
} else {
|
} else {
|
||||||
// If no parent, it's probably a reply to a reply to a reply, level 3
|
// If no parent, something is wrong
|
||||||
console.warn('[LEVEL 3] No parent found for', status);
|
console.warn('No parent found for', status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -149,8 +166,23 @@ function StatusPage({ id }) {
|
||||||
thread: s.account.id === heroStatus.account.id,
|
thread: s.account.id === heroStatus.account.id,
|
||||||
replies: s.__replies?.map((r) => ({
|
replies: s.__replies?.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
account: r.account,
|
||||||
repliesCount: r.repliesCount,
|
repliesCount: r.repliesCount,
|
||||||
content: r.content,
|
content: r.content,
|
||||||
|
replies: r.__replies?.map((r2) => ({
|
||||||
|
// Level 3
|
||||||
|
id: r2.id,
|
||||||
|
account: r2.account,
|
||||||
|
repliesCount: r2.repliesCount,
|
||||||
|
content: r2.content,
|
||||||
|
replies: r2.__replies?.map((r3) => ({
|
||||||
|
// Level 4
|
||||||
|
id: r3.id,
|
||||||
|
account: r3.account,
|
||||||
|
repliesCount: r3.repliesCount,
|
||||||
|
content: r3.content,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
@ -162,7 +194,7 @@ function StatusPage({ id }) {
|
||||||
};
|
};
|
||||||
console.log({ allStatuses });
|
console.log({ allStatuses });
|
||||||
setStatuses(allStatuses);
|
setStatuses(allStatuses);
|
||||||
cachedStatusesMap.current[id] = allStatuses;
|
cachedStatusesMap[id] = allStatuses;
|
||||||
|
|
||||||
// Let's threadify this one
|
// Let's threadify this one
|
||||||
// Note that all non-hero statuses will trigger saveStatus which will threadify them too
|
// Note that all non-hero statuses will trigger saveStatus which will threadify them too
|
||||||
|
@ -187,6 +219,7 @@ function StatusPage({ id }) {
|
||||||
console.debug('scrollPosition', scrollPosition);
|
console.debug('scrollPosition', scrollPosition);
|
||||||
if (!!scrollPosition) {
|
if (!!scrollPosition) {
|
||||||
console.debug('Case 1', {
|
console.debug('Case 1', {
|
||||||
|
id,
|
||||||
scrollPosition,
|
scrollPosition,
|
||||||
});
|
});
|
||||||
scrollableRef.current.scrollTop = scrollPosition;
|
scrollableRef.current.scrollTop = scrollPosition;
|
||||||
|
@ -196,7 +229,9 @@ function StatusPage({ id }) {
|
||||||
scrollTop: scrollableRef.current?.scrollTop,
|
scrollTop: scrollableRef.current?.scrollTop,
|
||||||
};
|
};
|
||||||
const newScrollTop =
|
const newScrollTop =
|
||||||
newScrollOffsets.offsetTop - scrollOffsets.current.offsetTop;
|
newScrollOffsets.offsetTop -
|
||||||
|
scrollOffsets.current.offsetTop +
|
||||||
|
newScrollOffsets.scrollTop;
|
||||||
console.debug('Case 2', {
|
console.debug('Case 2', {
|
||||||
scrollOffsets: scrollOffsets.current,
|
scrollOffsets: scrollOffsets.current,
|
||||||
newScrollOffsets,
|
newScrollOffsets,
|
||||||
|
@ -204,6 +239,11 @@ function StatusPage({ id }) {
|
||||||
statuses: [...statuses],
|
statuses: [...statuses],
|
||||||
});
|
});
|
||||||
scrollableRef.current.scrollTop = newScrollTop;
|
scrollableRef.current.scrollTop = newScrollTop;
|
||||||
|
} else if (statuses.length === 1) {
|
||||||
|
console.debug('Case 3', {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
scrollableRef.current.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RESET
|
// RESET
|
||||||
|
@ -233,7 +273,7 @@ function StatusPage({ id }) {
|
||||||
// RESET
|
// RESET
|
||||||
states.scrollPositions = {};
|
states.scrollPositions = {};
|
||||||
states.reloadStatusPage = 0;
|
states.reloadStatusPage = 0;
|
||||||
cachedStatusesMap.current = {};
|
cachedStatusesMap = {};
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -268,12 +308,17 @@ function StatusPage({ id }) {
|
||||||
heroDisplayName && heroContentText
|
heroDisplayName && heroContentText
|
||||||
? `${heroDisplayName}: "${heroContentText}"`
|
? `${heroDisplayName}: "${heroContentText}"`
|
||||||
: 'Status',
|
: 'Status',
|
||||||
|
'/s/:id',
|
||||||
);
|
);
|
||||||
|
|
||||||
const prevRoute = states.history.findLast((h) => {
|
const closeLink = useMemo(() => {
|
||||||
return h === '/' || /notifications/i.test(h);
|
const pathname = snapStates.prevLocation?.pathname;
|
||||||
});
|
if (!pathname || pathname.startsWith('/s/')) return '/';
|
||||||
const closeLink = `#${prevRoute || '/'}`;
|
return pathname;
|
||||||
|
}, []);
|
||||||
|
const onClose = () => {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
};
|
||||||
|
|
||||||
const [limit, setLimit] = useState(LIMIT);
|
const [limit, setLimit] = useState(LIMIT);
|
||||||
const showMore = useMemo(() => {
|
const showMore = useMemo(() => {
|
||||||
|
@ -281,7 +326,7 @@ function StatusPage({ id }) {
|
||||||
return statuses.length - limit;
|
return statuses.length - limit;
|
||||||
}, [statuses.length, limit]);
|
}, [statuses.length, limit]);
|
||||||
|
|
||||||
const hasManyStatuses = statuses.length > LIMIT;
|
const hasManyStatuses = statuses.length > THREAD_LIMIT;
|
||||||
const hasDescendants = statuses.some((s) => s.descendant);
|
const hasDescendants = statuses.some((s) => s.descendant);
|
||||||
const ancestors = statuses.filter((s) => s.ancestor);
|
const ancestors = statuses.filter((s) => s.ancestor);
|
||||||
|
|
||||||
|
@ -295,17 +340,105 @@ function StatusPage({ id }) {
|
||||||
}, [heroInView]);
|
}, [heroInView]);
|
||||||
|
|
||||||
useHotkeys(['esc', 'backspace'], () => {
|
useHotkeys(['esc', 'backspace'], () => {
|
||||||
location.hash = closeLink;
|
// location.hash = closeLink;
|
||||||
|
onClose();
|
||||||
|
navigate(closeLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
useHotkeys('j', () => {
|
||||||
|
const activeStatus = document.activeElement.closest(
|
||||||
|
'.status-link, .status-focus',
|
||||||
|
);
|
||||||
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
|
const allStatusLinks = Array.from(
|
||||||
|
// Select all statuses except those inside collapsed details/summary
|
||||||
|
// Hat-tip to @AmeliaBR@front-end.social
|
||||||
|
// https://front-end.social/@AmeliaBR/109784776146144471
|
||||||
|
scrollableRef.current.querySelectorAll(
|
||||||
|
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log({ allStatusLinks });
|
||||||
|
if (
|
||||||
|
activeStatus &&
|
||||||
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeStatusRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||||
|
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
||||||
|
if (nextStatus) {
|
||||||
|
nextStatus.focus();
|
||||||
|
nextStatus.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
||||||
|
});
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useHotkeys('k', () => {
|
||||||
|
const activeStatus = document.activeElement.closest(
|
||||||
|
'.status-link, .status-focus',
|
||||||
|
);
|
||||||
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
|
const allStatusLinks = Array.from(
|
||||||
|
scrollableRef.current.querySelectorAll(
|
||||||
|
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
activeStatus &&
|
||||||
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeStatusRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||||
|
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
||||||
|
if (prevStatus) {
|
||||||
|
prevStatus.focus();
|
||||||
|
prevStatus.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const topmostStatusLink = allStatusLinks.find((statusLink) => {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
|
||||||
|
});
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
topmostStatusLink.scrollIntoViewIfNeeded?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: I'm not sure if 'x' is the best shortcut for this, might change it later
|
||||||
|
// IDEA: x is for expand
|
||||||
|
useHotkeys('x', () => {
|
||||||
|
const activeStatus = document.activeElement.closest(
|
||||||
|
'.status-link, .status-focus',
|
||||||
|
);
|
||||||
|
if (activeStatus) {
|
||||||
|
const details = activeStatus.nextElementSibling;
|
||||||
|
if (details && details.tagName.toLowerCase() === 'details') {
|
||||||
|
details.open = !details.open;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { nearReachStart } = useScroll({
|
const { nearReachStart } = useScroll({
|
||||||
scrollableElement: scrollableRef.current,
|
scrollableElement: scrollableRef.current,
|
||||||
distanceFromStart: 0.5,
|
distanceFromStart: 0.2,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
<Link href={closeLink}></Link>
|
<Link to={closeLink} onClick={onClose}></Link>
|
||||||
<div
|
<div
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
ref={scrollableRef}
|
ref={scrollableRef}
|
||||||
|
@ -315,17 +448,6 @@ function StatusPage({ id }) {
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
class={`${heroInView ? 'inview' : ''}`}
|
class={`${heroInView ? 'inview' : ''}`}
|
||||||
onClick={(e) => {
|
|
||||||
if (
|
|
||||||
!/^(a|button)$/i.test(e.target.tagName) &&
|
|
||||||
heroStatusRef.current
|
|
||||||
) {
|
|
||||||
heroStatusRef.current.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDblClick={(e) => {
|
onDblClick={(e) => {
|
||||||
// reload statuses
|
// reload statuses
|
||||||
states.reloadStatusPage++;
|
states.reloadStatusPage++;
|
||||||
|
@ -338,23 +460,34 @@ function StatusPage({ id }) {
|
||||||
</div> */}
|
</div> */}
|
||||||
<h1>
|
<h1>
|
||||||
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
||||||
<span class="hero-heading">
|
<>
|
||||||
{!!heroPointer && (
|
<span class="hero-heading">
|
||||||
<>
|
<NameText showAvatar account={heroStatus.account} short />{' '}
|
||||||
<Icon
|
<span class="insignificant">
|
||||||
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
•{' '}
|
||||||
/>{' '}
|
<RelativeTime
|
||||||
</>
|
datetime={heroStatus.createdAt}
|
||||||
)}
|
format="micro"
|
||||||
<NameText showAvatar account={heroStatus.account} short />{' '}
|
/>
|
||||||
<span class="insignificant">
|
</span>
|
||||||
•{' '}
|
</span>{' '}
|
||||||
<RelativeTime
|
<button
|
||||||
datetime={heroStatus.createdAt}
|
type="button"
|
||||||
format="micro"
|
class="ancestors-indicator light small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
heroStatusRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||||||
/>
|
/>
|
||||||
</span>
|
</button>
|
||||||
</span>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Status{' '}
|
Status{' '}
|
||||||
|
@ -383,7 +516,39 @@ function StatusPage({ id }) {
|
||||||
</h1>
|
</h1>
|
||||||
<div class="header-side">
|
<div class="header-side">
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
<Link class="button plain deck-close" href={closeLink}>
|
<Menu
|
||||||
|
align="end"
|
||||||
|
portal={{
|
||||||
|
// Need this, else the menu click will cause scroll jump
|
||||||
|
target: scrollableRef.current,
|
||||||
|
}}
|
||||||
|
menuButton={
|
||||||
|
<button type="button" class="button plain4">
|
||||||
|
<Icon icon="more" alt="Actions" size="xl" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
// Click all buttons with class .spoiler but not .spoiling
|
||||||
|
const buttons = Array.from(
|
||||||
|
scrollableRef.current.querySelectorAll(
|
||||||
|
'button.spoiler:not(.spoiling)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
<Link
|
||||||
|
class="button plain deck-close"
|
||||||
|
to={closeLink}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<Icon icon="x" size="xl" />
|
<Icon icon="x" size="xl" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -412,39 +577,43 @@ function StatusPage({ id }) {
|
||||||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||||
>
|
>
|
||||||
{isHero ? (
|
{isHero ? (
|
||||||
<InView threshold={0.1} onChange={onView}>
|
<InView
|
||||||
|
threshold={0.1}
|
||||||
|
onChange={onView}
|
||||||
|
class="status-focus"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<Status statusID={statusID} withinContext size="l" />
|
<Status statusID={statusID} withinContext size="l" />
|
||||||
</InView>
|
</InView>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
class="
|
class="status-link"
|
||||||
status-link
|
to={`/s/${statusID}`}
|
||||||
"
|
onClick={() => {
|
||||||
href={`#/s/${statusID}`}
|
resetScrollPosition(statusID);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Status
|
<Status
|
||||||
statusID={statusID}
|
statusID={statusID}
|
||||||
withinContext
|
withinContext
|
||||||
size={thread || ancestor ? 'm' : 's'}
|
size={thread || ancestor ? 'm' : 's'}
|
||||||
/>
|
/>
|
||||||
{replies?.length > LIMIT && (
|
{/* {replies?.length > LIMIT && (
|
||||||
<div class="replies-link">
|
<div class="replies-link">
|
||||||
<Icon icon="comment" />{' '}
|
<Icon icon="comment" />{' '}
|
||||||
<span title={replies.length}>
|
<span title={replies.length}>
|
||||||
{shortenNumber(replies.length)}
|
{shortenNumber(replies.length)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{descendant &&
|
{descendant && replies?.length > 0 && (
|
||||||
replies?.length > 0 &&
|
<SubComments
|
||||||
replies?.length <= LIMIT && (
|
hasManyStatuses={hasManyStatuses}
|
||||||
<SubComments
|
replies={replies}
|
||||||
hasManyStatuses={hasManyStatuses}
|
/>
|
||||||
replies={replies}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{uiState === 'loading' &&
|
{uiState === 'loading' &&
|
||||||
isHero &&
|
isHero &&
|
||||||
!!heroStatus?.repliesCount &&
|
!!heroStatus?.repliesCount &&
|
||||||
|
@ -522,40 +691,86 @@ function StatusPage({ id }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubComments({
|
function SubComments({ hasManyStatuses, replies }) {
|
||||||
hasManyStatuses,
|
// Set isBrief = true:
|
||||||
replies,
|
// - if less than or 2 replies
|
||||||
onStatusLinkClick = () => {},
|
// - if replies have no sub-replies
|
||||||
}) {
|
// - if total number of characters of content from replies is less than 500
|
||||||
// If less than or 2 replies and total number of characters of content from replies is less than 500
|
|
||||||
let isBrief = false;
|
let isBrief = false;
|
||||||
if (replies.length <= 2) {
|
if (replies.length <= 2) {
|
||||||
let totalLength = replies.reduce((acc, reply) => {
|
const containsSubReplies = replies.some(
|
||||||
const { content } = reply;
|
(r) => r.repliesCount > 0 || r.replies?.length > 0,
|
||||||
const length = htmlContentLength(content);
|
);
|
||||||
return acc + length;
|
if (!containsSubReplies) {
|
||||||
}, 0);
|
let totalLength = replies.reduce((acc, reply) => {
|
||||||
isBrief = totalLength < 500;
|
const { content } = reply;
|
||||||
|
const length = htmlContentLength(content);
|
||||||
|
return acc + length;
|
||||||
|
}, 0);
|
||||||
|
isBrief = totalLength < 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Total comments count, including sub-replies
|
||||||
|
const diveDeep = (replies) => {
|
||||||
|
return replies.reduce((acc, reply) => {
|
||||||
|
const { repliesCount, replies } = reply;
|
||||||
|
const count = replies?.length || repliesCount;
|
||||||
|
return acc + count + diveDeep(replies || []);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
const totalComments = replies.length + diveDeep(replies);
|
||||||
|
const sameCount = replies.length === totalComments;
|
||||||
|
|
||||||
|
// Get the first 3 accounts, unique by id
|
||||||
|
const accounts = replies
|
||||||
|
.map((r) => r.account)
|
||||||
|
.filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
const open = isBrief || !hasManyStatuses;
|
const open = isBrief || !hasManyStatuses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details class="replies" open={open}>
|
<details class="replies" open={open}>
|
||||||
<summary hidden={open}>
|
<summary hidden={open}>
|
||||||
<span title={replies.length}>{shortenNumber(replies.length)}</span> repl
|
<span class="avatars">
|
||||||
{replies.length === 1 ? 'y' : 'ies'}
|
{accounts.map((a) => (
|
||||||
|
<Avatar
|
||||||
|
key={a.id}
|
||||||
|
url={a.avatarStatic}
|
||||||
|
title={`${a.displayName} @${a.username}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
|
||||||
|
repl
|
||||||
|
{replies.length === 1 ? 'y' : 'ies'}
|
||||||
|
</span>
|
||||||
|
{!sameCount && totalComments > 1 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
·{' '}
|
||||||
|
<span>
|
||||||
|
<span title={totalComments}>{shortenNumber(totalComments)}</span>{' '}
|
||||||
|
comment
|
||||||
|
{totalComments === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{replies.map((r) => (
|
{replies.map((r) => (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link
|
<Link
|
||||||
class="status-link"
|
class="status-link"
|
||||||
href={`#/s/${r.id}`}
|
to={`/s/${r.id}`}
|
||||||
onClick={onStatusLinkClick}
|
onClick={() => {
|
||||||
|
resetScrollPosition(r.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Status statusID={r.id} withinContext size="s" />
|
<Status statusID={r.id} withinContext size="s" />
|
||||||
{r.repliesCount > 0 && (
|
{!r.replies?.length && r.repliesCount > 0 && (
|
||||||
<div class="replies-link">
|
<div class="replies-link">
|
||||||
<Icon icon="comment" />{' '}
|
<Icon icon="comment" />{' '}
|
||||||
<span title={r.repliesCount}>
|
<span title={r.repliesCount}>
|
||||||
|
@ -564,6 +779,12 @@ function SubComments({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{r.replies?.length && (
|
||||||
|
<SubComments
|
||||||
|
hasManyStatuses={hasManyStatuses}
|
||||||
|
replies={r.replies}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -38,9 +38,9 @@
|
||||||
filter: hue-rotate(0deg);
|
filter: hue-rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
filter: hue-rotate(360deg);
|
filter: hue-rotate(-90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#welcome:hover h2 {
|
#welcome:hover h2 {
|
||||||
animation: psychedelic 60s infinite;
|
animation: psychedelic 10s infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import './welcome.css';
|
import './welcome.css';
|
||||||
|
|
||||||
import logo from '../assets/logo.svg';
|
import logo from '../assets/logo.svg';
|
||||||
|
import Link from '../components/link';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function Welcome() {
|
function Welcome() {
|
||||||
|
@ -28,9 +29,9 @@ function Welcome() {
|
||||||
<p>
|
<p>
|
||||||
<big>
|
<big>
|
||||||
<b>
|
<b>
|
||||||
<a href="#/login" class="button">
|
<Link to="/login" class="button">
|
||||||
Log in
|
Log in
|
||||||
</a>
|
</Link>
|
||||||
</b>
|
</b>
|
||||||
</big>
|
</big>
|
||||||
</p>
|
</p>
|
||||||
|
@ -43,6 +44,13 @@ function Welcome() {
|
||||||
<a href="https://mastodon.social/@cheeaun" target="_blank">
|
<a href="https://mastodon.social/@cheeaun" target="_blank">
|
||||||
@cheeaun
|
@cheeaun
|
||||||
</a>
|
</a>
|
||||||
|
.{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
function emojifyText(text, emojis = []) {
|
function emojifyText(text, emojis = []) {
|
||||||
|
if (!text) return '';
|
||||||
if (!emojis.length) return text;
|
if (!emojis.length) return text;
|
||||||
// Replace shortcodes in text with emoji
|
// Replace shortcodes in text with emoji
|
||||||
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
||||||
|
|
48
src/utils/handle-content-links.js
Normal file
48
src/utils/handle-content-links.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import states from './states';
|
||||||
|
|
||||||
|
function handleContentLinks(opts) {
|
||||||
|
const { mentions = [] } = opts || {};
|
||||||
|
return (e) => {
|
||||||
|
let { target } = e;
|
||||||
|
if (target.parentNode.tagName.toLowerCase() === 'a') {
|
||||||
|
target = target.parentNode;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target.tagName.toLowerCase() === 'a' &&
|
||||||
|
target.classList.contains('u-url')
|
||||||
|
) {
|
||||||
|
const targetText = (
|
||||||
|
target.querySelector('span') || target
|
||||||
|
).innerText.trim();
|
||||||
|
const username = targetText.replace(/^@/, '');
|
||||||
|
const url = target.getAttribute('href');
|
||||||
|
const mention = mentions.find(
|
||||||
|
(mention) =>
|
||||||
|
mention.username === username ||
|
||||||
|
mention.acct === username ||
|
||||||
|
mention.url === url,
|
||||||
|
);
|
||||||
|
if (mention) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
states.showAccount = mention.acct;
|
||||||
|
} else if (!/^http/i.test(targetText)) {
|
||||||
|
console.log('mention not found', targetText);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const href = target.getAttribute('href');
|
||||||
|
states.showAccount = href;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
target.tagName.toLowerCase() === 'a' &&
|
||||||
|
target.classList.contains('hashtag')
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const tag = target.innerText.replace(/^#/, '').trim();
|
||||||
|
location.hash = `#/t/${tag}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default handleContentLinks;
|
|
@ -3,14 +3,18 @@ import { proxy, subscribe } from 'valtio';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
const states = proxy({
|
const states = proxy({
|
||||||
history: [],
|
// history: [],
|
||||||
|
prevLocation: null,
|
||||||
|
currentLocation: null,
|
||||||
statuses: {},
|
statuses: {},
|
||||||
statusThreadNumber: {},
|
statusThreadNumber: {},
|
||||||
home: [],
|
home: [],
|
||||||
specialHome: [],
|
// specialHome: [],
|
||||||
homeNew: [],
|
homeNew: [],
|
||||||
|
homeLast: null, // Last item in 'home' list
|
||||||
homeLastFetchTime: null,
|
homeLastFetchTime: null,
|
||||||
notifications: [],
|
notifications: [],
|
||||||
|
notificationLast: null, // Last item in 'notifications' list
|
||||||
notificationsNew: [],
|
notificationsNew: [],
|
||||||
notificationsLastFetchTime: null,
|
notificationsLastFetchTime: null,
|
||||||
accounts: {},
|
accounts: {},
|
||||||
|
@ -22,10 +26,11 @@ const states = proxy({
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAccount: false,
|
showAccount: false,
|
||||||
showDrafts: false,
|
showDrafts: false,
|
||||||
|
showMediaModal: false,
|
||||||
composeCharacterCount: 0,
|
composeCharacterCount: 0,
|
||||||
settings: {
|
settings: {
|
||||||
boostsCarousel: store.local.get('settings:boostsCarousel')
|
boostsCarousel: store.local.get('settings:boostsCarousel')
|
||||||
? store.local.get('settings:boostsCarousel')
|
? store.local.get('settings:boostsCarousel') === '1'
|
||||||
: true,
|
: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { useCallback, useRef } from 'preact/hooks';
|
|
||||||
|
|
||||||
export default function useDebouncedCallback(
|
|
||||||
callback,
|
|
||||||
delay,
|
|
||||||
dependencies = [],
|
|
||||||
) {
|
|
||||||
const timeout = useRef();
|
|
||||||
|
|
||||||
const comboDeps = dependencies
|
|
||||||
? [callback, delay, ...dependencies]
|
|
||||||
: [callback, delay];
|
|
||||||
|
|
||||||
return useCallback((...args) => {
|
|
||||||
if (timeout.current != null) {
|
|
||||||
clearTimeout(timeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout.current = setTimeout(() => {
|
|
||||||
callback(...args);
|
|
||||||
}, delay);
|
|
||||||
}, comboDeps);
|
|
||||||
}
|
|
|
@ -51,7 +51,7 @@ export default function useScroll({
|
||||||
previousScrollStart = scrollStart;
|
previousScrollStart = scrollStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
setReachStart(scrollStart === 0);
|
setReachStart(scrollStart <= 0);
|
||||||
setReachEnd(scrollStart + clientDimension >= scrollDimension);
|
setReachEnd(scrollStart + clientDimension >= scrollDimension);
|
||||||
setNearReachStart(scrollStart <= distanceFromStartPx);
|
setNearReachStart(scrollStart <= distanceFromStartPx);
|
||||||
setNearReachEnd(
|
setNearReachEnd(
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
import { matchPath } from 'react-router-dom';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
import states from './states';
|
||||||
|
|
||||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||||
|
|
||||||
export default function useTitle(title) {
|
export default function useTitle(title, path) {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (path && !matchPath(path, snapStates.currentLocation)) return;
|
||||||
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
|
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
|
||||||
}, [title]);
|
}, [title, snapStates.currentLocation]);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue