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 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.
|
||||
|
||||
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
||||
|
@ -62,6 +64,7 @@ Prerequisites: Node.js 18+
|
|||
- [Vite](https://vitejs.dev/) - Build tool
|
||||
- [Preact](https://preactjs.com/) - UI library
|
||||
- [Valtio](https://valtio.pmnd.rs/) - State management
|
||||
- [React Router](https://reactrouter.com/) - Routing
|
||||
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||
- [Iconify](https://iconify.design/) - Icon library
|
||||
- Vanilla CSS - *Yes, I'm old school.*
|
||||
|
@ -90,14 +93,14 @@ And here I am. Building a Mastodon web client.
|
|||
|
||||
## 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/)
|
||||
- [Sengi](https://nicolasconstant.github.io/sengi/)
|
||||
- [Soapbox](https://fe.soapbox.pub/)
|
||||
- [Elk](https://elk.zone/)
|
||||
- [Mastodeck](https://mastodeck.com/)
|
||||
- [Tooty](https://github.com/n1k0/tooty)
|
||||
- [More...](https://github.com/tleb/awesome-mastodon#clients)
|
||||
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||
|
||||
## License
|
||||
|
||||
|
|
57
index.html
57
index.html
|
@ -45,62 +45,5 @@
|
|||
<div id="app"></div>
|
||||
<div id="modal-container"></div>
|
||||
<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>
|
||||
</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": {
|
||||
"@github/text-expander-element": "~2.3.0",
|
||||
"@iconify-icons/mingcute": "~1.2.3",
|
||||
"@szhsin/react-menu": "~3.4.0",
|
||||
"dayjs": "~1.11.7",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
"history": "~5.3.0",
|
||||
"idb-keyval": "~6.2.0",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~5.5.0",
|
||||
"masto": "~5.7.0",
|
||||
"mem": "~9.0.2",
|
||||
"p-retry": "~5.1.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
"react-hotkeys-hook": "~4.3.2",
|
||||
"react-hotkeys-hook": "~4.3.3",
|
||||
"react-intersection-observer": "~9.4.1",
|
||||
"react-router-dom": "6.6.2",
|
||||
"string-length": "~5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.1",
|
||||
"use-debounce": "~9.0.3",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "~1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
||||
"autoprefixer": "~10.4.13",
|
||||
"postcss": "~8.4.21",
|
||||
"postcss-dark-theme-class": "~0.7.3",
|
||||
"postcss-preset-env": "~8.0.1",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~4.0.4",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
|
@ -52,7 +54,7 @@
|
|||
"postcss": {
|
||||
"plugins": {
|
||||
"postcss-dark-theme-class": {},
|
||||
"autoprefixer": {}
|
||||
"postcss-preset-env": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
531
src/app.css
531
src/app.css
|
@ -6,6 +6,9 @@ body {
|
|||
color: var(--text-color);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
|
@ -46,6 +49,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transition: opacity 0.1s ease-in-out;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
.deck-container[hidden] {
|
||||
display: block;
|
||||
|
@ -66,7 +70,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
min-height: 100dvh;
|
||||
margin: auto;
|
||||
width: 40em;
|
||||
max-width: 100vw;
|
||||
max-width: 100%;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--bg-color);
|
||||
|
@ -114,6 +118,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck > header h1:first-child {
|
||||
text-align: left;
|
||||
|
@ -146,17 +151,26 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
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 {
|
||||
--width: 3px;
|
||||
--left: 40px;
|
||||
--right: calc(var(--left) + var(--width));
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
transparent var(--left),
|
||||
var(--comment-line-color) var(--left),
|
||||
var(--comment-line-color) var(--right),
|
||||
transparent var(--right),
|
||||
transparent var(--line-start),
|
||||
var(--comment-line-color) var(--line-start),
|
||||
var(--comment-line-color) var(--line-end),
|
||||
transparent var(--line-end),
|
||||
transparent
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
|
@ -182,41 +196,127 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
> .status-link
|
||||
+ .replies
|
||||
> 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
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.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
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
> 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
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
.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 {
|
||||
--radius: 10px;
|
||||
--diameter: calc(var(--radius) * 2);
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 40px;
|
||||
width: var(--diameter);
|
||||
height: var(--diameter);
|
||||
border-radius: var(--radius);
|
||||
left: var(--line-start);
|
||||
width: var(--line-diameter);
|
||||
height: var(--line-diameter);
|
||||
border-radius: var(--line-radius);
|
||||
border-style: solid;
|
||||
border-width: var(--width);
|
||||
border-width: var(--line-width);
|
||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
@ -228,7 +328,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
font-size: 90%;
|
||||
}
|
||||
.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 * {
|
||||
vertical-align: middle;
|
||||
|
@ -241,22 +343,33 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.timeline.contextual > li .replies summary {
|
||||
padding: 8px 16px;
|
||||
.timeline.contextual > li .replies > summary {
|
||||
padding: 8px;
|
||||
background-color: var(--bg-faded-color);
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--text-insignificant-color);
|
||||
user-select: none;
|
||||
box-shadow: 0 0 0 2px var(--bg-color);
|
||||
position: relative;
|
||||
list-style: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline.contextual > li .replies summary:active,
|
||||
.timeline.contextual > li .replies[open] summary {
|
||||
.timeline.contextual > li .replies > summary::-webkit-details-marker {
|
||||
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);
|
||||
background-color: var(--comment-line-color);
|
||||
background-image: linear-gradient(
|
||||
|
@ -265,7 +378,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
var(--bg-faded-color)
|
||||
);
|
||||
}
|
||||
.timeline.contextual > li .replies[open] summary {
|
||||
.timeline.contextual > li .replies[open] > summary {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.timeline.contextual > li .replies summary[hidden] {
|
||||
|
@ -275,52 +388,89 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
position: relative;
|
||||
}
|
||||
.timeline.contextual > li .replies li {
|
||||
--width: 3px;
|
||||
--left: calc(40px + 16px);
|
||||
--right: calc(var(--left) + var(--width));
|
||||
--line-start: calc(var(--thread-start) + var(--line-margin-end));
|
||||
--line-end: calc(var(--line-start) + var(--line-width));
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
transparent var(--left),
|
||||
var(--comment-line-color) var(--left),
|
||||
var(--comment-line-color) var(--right),
|
||||
transparent var(--right),
|
||||
transparent var(--line-start),
|
||||
var(--comment-line-color) var(--line-start),
|
||||
var(--comment-line-color) var(--line-end),
|
||||
transparent var(--line-end),
|
||||
transparent
|
||||
);
|
||||
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 {
|
||||
--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 {
|
||||
background-size: 100% 20px;
|
||||
}
|
||||
.timeline.contextual > li .replies li:before {
|
||||
--radius: 10px;
|
||||
--diameter: calc(var(--radius) * 2);
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: calc(40px + 16px);
|
||||
width: var(--diameter);
|
||||
height: var(--diameter);
|
||||
border-radius: var(--radius);
|
||||
left: var(--line-start);
|
||||
width: var(--line-diameter);
|
||||
height: var(--line-diameter);
|
||||
border-radius: var(--line-radius);
|
||||
border-style: solid;
|
||||
border-width: var(--width);
|
||||
border-width: var(--line-width);
|
||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
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 {
|
||||
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) {
|
||||
opacity: 0.5;
|
||||
/* opacity: 0.5; */
|
||||
pointer-events: none;
|
||||
/* background-image: none !important; */
|
||||
}
|
||||
/* .timeline.contextual.loading > li:not(.hero):before {
|
||||
content: none !important;
|
||||
} */
|
||||
|
||||
.timeline-deck.compact .status {
|
||||
max-height: max(25vh, 160px);
|
||||
|
@ -364,10 +514,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
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);
|
||||
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)) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
@ -467,9 +623,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
overflow: hidden;
|
||||
box-shadow: 0 1px var(--bg-color);
|
||||
}
|
||||
.status-boost-link:is(:hover, :focus) {
|
||||
.status-boost-link::focus {
|
||||
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)) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
@ -508,11 +669,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
max-width: 40em;
|
||||
}
|
||||
|
||||
.decks {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deck-close {
|
||||
color: var(--text-insignificant-color) !important;
|
||||
}
|
||||
|
@ -582,6 +738,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
scrollbar-width: none;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-x;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
.carousel::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
@ -593,7 +751,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background-color: var(--average-color-alpha);
|
||||
|
@ -606,7 +764,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
.carousel > * :is(img, video) {
|
||||
width: auto;
|
||||
max-width: 100vw;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
|
@ -621,10 +779,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
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) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
@ -654,26 +808,23 @@ button.carousel-dot {
|
|||
}
|
||||
.carousel-dots {
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.carousel-dots {
|
||||
backdrop-filter: blur(12px) brightness(0.5);
|
||||
}
|
||||
backdrop-filter: blur(12px) invert(0.25);
|
||||
}
|
||||
button.carousel-dot {
|
||||
color: var(--text-insignificant-color) !important;
|
||||
font-weight: bold;
|
||||
backdrop-filter: none !important;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button.carousel-dot:is(:hover, :focus) button.carousel-dot.active,
|
||||
button.carousel-dot[disabled].active {
|
||||
color: var(--link-color) !important;
|
||||
button.carousel-dot[disabled] {
|
||||
pointer-events: none;
|
||||
}
|
||||
button.carousel-dot.active,
|
||||
button.carousel-dot[disabled].active {
|
||||
button.carousel-dot:is(:hover, :focus, .active, [disabled].active) {
|
||||
color: var(--button-text-color) !important;
|
||||
}
|
||||
button.carousel-dot:is(.active, [disabled].active) {
|
||||
opacity: 1;
|
||||
transform: scale(2) translateY(-0.5px);
|
||||
transform: scale(2.2) translateY(-0.5px);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.carousel-top-controls {
|
||||
|
@ -681,8 +832,8 @@ button.carousel-dot[disabled].active {
|
|||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
.carousel-controls {
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
transform: scale(0);
|
||||
/* transition: transform 0.2s ease-in-out; */
|
||||
}
|
||||
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
||||
opacity: 1;
|
||||
|
@ -696,10 +847,33 @@ button.carousel-dot[disabled].active {
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.carousel :is(img, video) {
|
||||
/* No need fade out if inside carousel */
|
||||
filter: none;
|
||||
|
||||
/* CAROUSEL + STATUS PAGE COMBO */
|
||||
|
||||
.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-container {
|
||||
position: relative;
|
||||
}
|
||||
.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;
|
||||
.szh-menu {
|
||||
padding: 8px 0 !important;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
background-color: var(--bg-color);
|
||||
width: 10em;
|
||||
list-style: none;
|
||||
z-index: 100;
|
||||
border: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-color) !important;
|
||||
border: 1px solid var(--outline-color) !important;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
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%;
|
||||
box-shadow: 0 3px 6px var(--drop-shadow-color);
|
||||
text-align: left;
|
||||
color: var(--text-color) !important;
|
||||
border-radius: 0;
|
||||
animation: appear 0.15s ease-in-out;
|
||||
}
|
||||
.menu-container menu button:is(:hover, :focus) {
|
||||
color: var(--bg-color) !important;
|
||||
background-color: var(--link-color);
|
||||
.szh-menu .szh-menu__item {
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
.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 */
|
||||
|
@ -943,19 +1093,109 @@ meter.donut:is(.danger, .explode):after {
|
|||
flex-wrap: wrap;
|
||||
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) {
|
||||
html,
|
||||
body {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
.deck-container {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
}
|
||||
.decks {
|
||||
.deck-container {
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
}
|
||||
.decks:has(~ .deck-backdrop) {
|
||||
.deck-container:has(~ .deck-backdrop) {
|
||||
transition: transform 0.4s ease-out;
|
||||
transform: translate3d(-5vw, 0, 0);
|
||||
}
|
||||
|
@ -969,25 +1209,25 @@ meter.donut:is(.danger, .explode):after {
|
|||
background-color: transparent;
|
||||
}
|
||||
.timeline-deck > header {
|
||||
min-height: 6em;
|
||||
--margin-top: 8px;
|
||||
min-height: 4em;
|
||||
top: var(--margin-top);
|
||||
border-bottom: 0;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-faded-color),
|
||||
transparent 50%
|
||||
);
|
||||
background-image: none;
|
||||
border-bottom: 0;
|
||||
mask-image: linear-gradient(
|
||||
rgba(0, 0, 0, 1) 50%,
|
||||
rgba(0, 0, 0, 0.7) 80%,
|
||||
rgba(0, 0, 0, 0.5) 90%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 16px;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
.timeline-deck > header[hidden] {
|
||||
transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
|
||||
}
|
||||
.deck > header h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.updates-button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.timeline-deck .timeline:not(.flat) > li {
|
||||
border: 1px solid var(--divider-color);
|
||||
margin: 16px 0;
|
||||
|
@ -995,15 +1235,32 @@ meter.donut:is(.danger, .explode):after {
|
|||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
padding: 32px;
|
||||
}
|
||||
:is(.carousel-top-controls, .carousel-controls) {
|
||||
/* :is(.carousel-top-controls, .carousel-controls) {
|
||||
padding: 32px;
|
||||
}
|
||||
} */
|
||||
li:has(.boost-carousel) {
|
||||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + 20em));
|
||||
}
|
||||
}
|
||||
|
|
399
src/app.jsx
399
src/app.jsx
|
@ -1,22 +1,37 @@
|
|||
import './app.css';
|
||||
import 'toastify-js/src/toastify.css';
|
||||
|
||||
import { createHashHistory } from 'history';
|
||||
import debounce from 'just-debounce-it';
|
||||
import { login } from 'masto';
|
||||
import Router, { route } from 'preact-router';
|
||||
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { createClient } from 'masto';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Toastify from 'toastify-js';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Account from './components/account';
|
||||
import Compose from './components/compose';
|
||||
import Drafts from './components/drafts';
|
||||
import Icon from './components/icon';
|
||||
import Link from './components/link';
|
||||
import Loader from './components/loader';
|
||||
import MediaModal from './components/media-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 Lists from './pages/lists';
|
||||
import Login from './pages/login';
|
||||
import Notifications from './pages/notifications';
|
||||
import Public from './pages/public';
|
||||
import Settings from './pages/settings';
|
||||
import Status from './pages/status';
|
||||
import Welcome from './pages/welcome';
|
||||
|
@ -24,14 +39,13 @@ import { getAccessToken } from './utils/auth';
|
|||
import states, { saveStatus } from './utils/states';
|
||||
import store from './utils/store';
|
||||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
||||
window.__STATES__ = states;
|
||||
|
||||
function App() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const theme = store.local.get('theme');
|
||||
|
@ -67,11 +81,9 @@ function App() {
|
|||
const { access_token: accessToken } = tokenJSON;
|
||||
store.session.set('accessToken', accessToken);
|
||||
|
||||
window.masto = await login({
|
||||
initMasto({
|
||||
url: `https://${instanceURL}`,
|
||||
accessToken,
|
||||
disableVersionCheck: true,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
||||
|
@ -105,41 +117,35 @@ function App() {
|
|||
const instanceURL = account.instanceURL;
|
||||
const accessToken = account.accessToken;
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
if (accessToken) setIsLoggedIn(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setUIState('loading');
|
||||
window.masto = await login({
|
||||
url: `https://${instanceURL}`,
|
||||
accessToken,
|
||||
disableVersionCheck: true,
|
||||
timeout: 30_000,
|
||||
});
|
||||
setIsLoggedIn(true);
|
||||
} catch (e) {
|
||||
setIsLoggedIn(false);
|
||||
}
|
||||
setUIState('default');
|
||||
})();
|
||||
initMasto({
|
||||
url: `https://${instanceURL}`,
|
||||
accessToken,
|
||||
});
|
||||
} else {
|
||||
setUIState('default');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [currentDeck, setCurrentDeck] = useState('home');
|
||||
const [currentModal, setCurrentModal] = useState(null);
|
||||
let location = useLocation();
|
||||
states.currentLocation = location.pathname;
|
||||
|
||||
const locationDeckMap = {
|
||||
'/': 'home-page',
|
||||
'/notifications': 'notifications-page',
|
||||
};
|
||||
const focusDeck = () => {
|
||||
if (currentModal) return;
|
||||
let timer = setTimeout(() => {
|
||||
const page = document.getElementById(`${currentDeck}-page`);
|
||||
console.debug('FOCUS', currentDeck, page);
|
||||
const page = document.getElementById(locationDeckMap[location.pathname]);
|
||||
console.debug('FOCUS', location.pathname, page);
|
||||
if (page) {
|
||||
page.focus();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
useEffect(focusDeck, [currentDeck, currentModal]);
|
||||
useEffect(focusDeck, [location]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!snapStates.showCompose &&
|
||||
|
@ -153,64 +159,80 @@ function App() {
|
|||
useEffect(() => {
|
||||
// HACK: prevent this from running again due to HMR
|
||||
if (states.init) return;
|
||||
|
||||
if (isLoggedIn) {
|
||||
requestAnimationFrame(() => {
|
||||
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);
|
||||
})();
|
||||
});
|
||||
requestAnimationFrame(startVisibility);
|
||||
states.init = true;
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
{isLoggedIn && currentDeck && (
|
||||
<div class="decks">
|
||||
{/* Home will never be unmounted */}
|
||||
<Home hidden={currentDeck !== 'home'} />
|
||||
{/* Notifications can be unmounted */}
|
||||
{currentDeck === 'notifications' && <Notifications />}
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn && uiState === 'loading' && <Loader />}
|
||||
<Router
|
||||
history={createHashHistory()}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
<Routes location={nonRootLocation || location}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
<Home />
|
||||
) : uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<Welcome />
|
||||
)
|
||||
}
|
||||
states.history.push(url);
|
||||
}}
|
||||
>
|
||||
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />}
|
||||
<Welcome path="/welcome" />
|
||||
{isLoggedIn && <Status path="/s/:id" />}
|
||||
<Login path="/login" />
|
||||
</Router>
|
||||
/>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
</Routes>
|
||||
<Routes location={backgroundLocation.current || location}>
|
||||
{isLoggedIn && (
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
)}
|
||||
{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 && (
|
||||
<Modal>
|
||||
<Compose
|
||||
|
@ -244,7 +266,8 @@ function App() {
|
|||
// destination: `/#/s/${newStatus.id}`,
|
||||
onClick: () => {
|
||||
toast.hideToast();
|
||||
route(`/s/${newStatus.id}`);
|
||||
states.prevLocation = location;
|
||||
navigate(`/s/${newStatus.id}`);
|
||||
},
|
||||
});
|
||||
toast.showToast();
|
||||
|
@ -278,7 +301,12 @@ function App() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Account account={snapStates.showAccount} />
|
||||
<Account
|
||||
account={snapStates.showAccount}
|
||||
onClose={() => {
|
||||
states.showAccount = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showDrafts && (
|
||||
|
@ -292,24 +320,109 @@ function App() {
|
|||
<Drafts />
|
||||
</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() {
|
||||
if (
|
||||
ws &&
|
||||
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await masto.v1.stream.streamUser();
|
||||
console.log('STREAM START', { stream });
|
||||
ws = stream.ws;
|
||||
|
||||
const handleNewStatus = debounce((status) => {
|
||||
console.log('UPDATE', status);
|
||||
|
||||
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
||||
const inHome = states.home.find((s) => s.id === status.id);
|
||||
const inHome = status.id === states.homeLast?.id;
|
||||
if (!inHomeNew && !inHome) {
|
||||
states.homeNew.unshift({
|
||||
id: status.id,
|
||||
reblog: status.reblog?.id,
|
||||
reply: !!status.inReplyToAccountId,
|
||||
});
|
||||
if (states.settings.boostsCarousel && status.reblog) {
|
||||
// do nothing
|
||||
} else {
|
||||
states.homeNew.unshift({
|
||||
id: status.id,
|
||||
reblog: status.reblog?.id,
|
||||
reply: !!status.inReplyToAccountId,
|
||||
});
|
||||
console.log('homeNew 1', [...states.homeNew]);
|
||||
}
|
||||
}
|
||||
|
||||
saveStatus(status);
|
||||
|
@ -331,9 +444,7 @@ async function startStream() {
|
|||
const inNotificationsNew = states.notificationsNew.find(
|
||||
(n) => n.id === notification.id,
|
||||
);
|
||||
const inNotifications = states.notifications.find(
|
||||
(n) => n.id === notification.id,
|
||||
);
|
||||
const inNotifications = notification.id === states.notificationLast?.id;
|
||||
if (!inNotificationsNew && !inNotifications) {
|
||||
states.notificationsNew.unshift(notification);
|
||||
}
|
||||
|
@ -343,10 +454,9 @@ async function startStream() {
|
|||
|
||||
stream.ws.onclose = () => {
|
||||
console.log('STREAM CLOSED!');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
startStream();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -357,38 +467,38 @@ async function startStream() {
|
|||
};
|
||||
}
|
||||
|
||||
let lastHidden;
|
||||
function startVisibility() {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
const handleVisible = (visible) => {
|
||||
if (!visible) {
|
||||
const timestamp = Date.now();
|
||||
store.session.set('lastHidden', timestamp);
|
||||
lastHidden = timestamp;
|
||||
} else {
|
||||
const timestamp = Date.now();
|
||||
const lastHidden = store.session.get('lastHidden');
|
||||
const diff = timestamp - lastHidden;
|
||||
const diffMins = Math.round(diff / 1000 / 60);
|
||||
if (diffMins > 1) {
|
||||
console.log('visible', { lastHidden, diffMins });
|
||||
setTimeout(() => {
|
||||
// Buffer for WS reconnect
|
||||
(async () => {
|
||||
try {
|
||||
const firstStatusID = states.home[0]?.id;
|
||||
const firstNotificationID = states.notifications[0]?.id;
|
||||
const fetchHome = masto.v1.timelines.listHome({
|
||||
limit: 1,
|
||||
...(firstStatusID && { sinceId: firstStatusID }),
|
||||
});
|
||||
const fetchNotifications = masto.v1.notifications.list({
|
||||
limit: 1,
|
||||
...(firstNotificationID && { sinceId: firstNotificationID }),
|
||||
});
|
||||
console.log(`visible: ${visible}`, { lastHidden, diffMins });
|
||||
if (!lastHidden || diffMins > 1) {
|
||||
(async () => {
|
||||
try {
|
||||
const firstStatusID = states.homeLast?.id;
|
||||
const firstNotificationID = states.notificationsLast?.id;
|
||||
const fetchHome = masto.v1.timelines.listHome({
|
||||
limit: 5,
|
||||
...(firstStatusID && { sinceId: firstStatusID }),
|
||||
});
|
||||
const fetchNotifications = masto.v1.notifications.list({
|
||||
limit: 1,
|
||||
...(firstNotificationID && { sinceId: firstNotificationID }),
|
||||
});
|
||||
|
||||
const newStatuses = await fetchHome;
|
||||
if (
|
||||
newStatuses.length &&
|
||||
newStatuses[0].id !== states.home[0].id
|
||||
) {
|
||||
const newStatuses = await fetchHome;
|
||||
const hasOneAndReblog =
|
||||
newStatuses.length === 1 && newStatuses?.[0]?.reblog;
|
||||
if (newStatuses.length) {
|
||||
if (states.settings.boostsCarousel && hasOneAndReblog) {
|
||||
// do nothing
|
||||
} else {
|
||||
states.homeNew = newStatuses.map((status) => {
|
||||
saveStatus(status);
|
||||
return {
|
||||
|
@ -397,33 +507,42 @@ function startVisibility() {
|
|||
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);
|
||||
requestAnimationFrame(handleVisibilityChange);
|
||||
return {
|
||||
stop: () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
|
|
@ -20,10 +20,20 @@
|
|||
#account-container .stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 16px;
|
||||
row-gap: 4px;
|
||||
justify-content: space-around;
|
||||
gap: 16px;
|
||||
opacity: 0.75;
|
||||
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 {
|
||||
|
@ -70,3 +80,12 @@
|
|||
#account-container .profile-field p {
|
||||
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 emojifyText from '../utils/emojify-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import handleContentLinks from '../utils/handle-content-links';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import NameText from './name-text';
|
||||
|
||||
function Account({ account }) {
|
||||
function Account({ account, onClose }) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const isString = typeof account === 'string';
|
||||
const [info, setInfo] = useState(isString ? null : account);
|
||||
|
@ -27,12 +31,29 @@ function Account({ account }) {
|
|||
setInfo(info);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
setUIState('error');
|
||||
try {
|
||||
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 {
|
||||
acct,
|
||||
|
@ -59,6 +80,7 @@ function Account({ account }) {
|
|||
|
||||
const [relationshipUIState, setRelationshipUIState] = useState('default');
|
||||
const [relationship, setRelationship] = useState(null);
|
||||
const [familiarFollowers, setFamiliarFollowers] = useState([]);
|
||||
useEffect(() => {
|
||||
if (info) {
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
|
@ -67,14 +89,29 @@ function Account({ account }) {
|
|||
return;
|
||||
}
|
||||
setRelationshipUIState('loading');
|
||||
setFamiliarFollowers([]);
|
||||
|
||||
(async () => {
|
||||
const fetchRelationships = masto.v1.accounts.fetchRelationships([id]);
|
||||
const fetchFamiliarFollowers =
|
||||
masto.v1.accounts.fetchFamiliarFollowers(id);
|
||||
|
||||
try {
|
||||
const relationships = await masto.v1.accounts.fetchRelationships([
|
||||
id,
|
||||
]);
|
||||
const relationships = await fetchRelationships;
|
||||
console.log('fetched relationship', relationships);
|
||||
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');
|
||||
} catch (e) {
|
||||
|
@ -104,7 +141,17 @@ function Account({ account }) {
|
|||
id="account-container"
|
||||
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>
|
||||
<Avatar size="xxxl" />
|
||||
|
@ -123,133 +170,174 @@ function Account({ account }) {
|
|||
</main>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<header>
|
||||
<Avatar url={avatar} size="xxxl" />
|
||||
<NameText account={info} showAcct external />
|
||||
</header>
|
||||
<main tabIndex="-1">
|
||||
{bot && (
|
||||
<>
|
||||
<span class="tag">
|
||||
<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>
|
||||
info && (
|
||||
<>
|
||||
<header>
|
||||
<Avatar url={avatar} size="xxxl" />
|
||||
<NameText account={info} showAcct external />
|
||||
</header>
|
||||
<main tabIndex="-1">
|
||||
{bot && (
|
||||
<>
|
||||
<span class="tag">
|
||||
<Icon icon="bot" /> Automated
|
||||
</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(
|
||||
<div
|
||||
class="note"
|
||||
onClick={handleContentLinks()}
|
||||
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>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(name, emojis),
|
||||
}}
|
||||
/>{' '}
|
||||
{!!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,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newRelationship = await masto.v1.accounts.follow(id);
|
||||
if (newRelationship) setRelationship(newRelationship);
|
||||
setRelationshipUIState('default');
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
if (newRelationship) setRelationship(newRelationship);
|
||||
setRelationshipUIState('default');
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<span>Following</span>
|
||||
<span>Unfollow…</span>
|
||||
</>
|
||||
) : requested ? (
|
||||
<>
|
||||
<span>Requested</span>
|
||||
<span>Withdraw…</span>
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Icon icon="lock" /> <span>Follow</span>
|
||||
</>
|
||||
) : (
|
||||
'Follow'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<span>Following</span>
|
||||
<span>Unfollow…</span>
|
||||
</>
|
||||
) : requested ? (
|
||||
<>
|
||||
<span>Requested</span>
|
||||
<span>Withdraw…</span>
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Icon icon="lock" /> <span>Follow</span>
|
||||
</>
|
||||
) : (
|
||||
'Follow'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -9,7 +9,7 @@ const SIZES = {
|
|||
xxxl: 64,
|
||||
};
|
||||
|
||||
function Avatar({ url, size, alt = '' }) {
|
||||
function Avatar({ url, size, alt = '', ...props }) {
|
||||
size = SIZES[size] || size || SIZES.m;
|
||||
return (
|
||||
<span
|
||||
|
@ -19,6 +19,7 @@ function Avatar({ url, size, alt = '' }) {
|
|||
height: size,
|
||||
}}
|
||||
title={alt}
|
||||
{...props}
|
||||
>
|
||||
{!!url && (
|
||||
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
||||
|
|
|
@ -199,21 +199,22 @@
|
|||
|
||||
#compose-container text-expander {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
#compose-container .text-expander-menu {
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
position: absolute;
|
||||
margin: 0 0 0 -8px;
|
||||
margin-top: 2em;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
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;
|
||||
overflow: hidden;
|
||||
top: 0 !important;
|
||||
z-index: 100;
|
||||
min-width: 50vw;
|
||||
min-width: 10em;
|
||||
max-width: 90vw;
|
||||
}
|
||||
#compose-container .text-expander-menu li {
|
||||
white-space: nowrap;
|
||||
|
@ -235,10 +236,16 @@
|
|||
width: 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);
|
||||
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 {
|
||||
background-color: var(--bg-faded-color);
|
||||
|
@ -324,6 +331,16 @@
|
|||
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 {
|
||||
background-color: var(--bg-faded-color);
|
||||
border-radius: 8px;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import stringLength from 'string-length';
|
||||
import { uid } from 'uid/single';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import supportedLanguages from '../data/status-supported-languages';
|
||||
|
@ -17,7 +18,6 @@ import openCompose from '../utils/open-compose';
|
|||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
|
||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||
import useInterval from '../utils/useInterval';
|
||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||
|
||||
|
@ -45,6 +45,7 @@ const expiryOptions = {
|
|||
'30 minutes': 30 * 60,
|
||||
'1 hour': 60 * 60,
|
||||
'6 hours': 6 * 60 * 60,
|
||||
'12 hours': 12 * 60 * 60,
|
||||
'1 day': 24 * 60 * 60,
|
||||
'3 days': 3 * 24 * 60 * 60,
|
||||
'7 days': 7 * 24 * 60 * 60,
|
||||
|
@ -62,6 +63,21 @@ const menu = document.createElement('ul');
|
|||
menu.role = 'listbox';
|
||||
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';
|
||||
|
||||
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
|
||||
|
@ -779,9 +795,10 @@ function Compose({
|
|||
ref={spoilerTextRef}
|
||||
type="text"
|
||||
name="spoilerText"
|
||||
placeholder="Spoiler text"
|
||||
placeholder="Content warning"
|
||||
disabled={uiState === 'loading'}
|
||||
class="spoiler-text-field"
|
||||
lang={language}
|
||||
style={{
|
||||
opacity: sensitive ? 1 : 0,
|
||||
pointerEvents: sensitive ? 'auto' : 'none',
|
||||
|
@ -846,6 +863,7 @@ function Compose({
|
|||
}
|
||||
required={mediaAttachments.length === 0}
|
||||
disabled={uiState === 'loading'}
|
||||
lang={language}
|
||||
onInput={() => {
|
||||
updateCharCount();
|
||||
}}
|
||||
|
@ -861,6 +879,7 @@ function Compose({
|
|||
key={id || fileID || i}
|
||||
attachment={attachment}
|
||||
disabled={uiState === 'loading'}
|
||||
lang={language}
|
||||
onDescriptionChange={(value) => {
|
||||
setMediaAttachments((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>
|
||||
)}
|
||||
{!!poll && (
|
||||
<Poll
|
||||
lang={language}
|
||||
maxOptions={maxOptions}
|
||||
maxExpiration={maxExpiration}
|
||||
minExpiration={minExpiration}
|
||||
|
@ -934,6 +968,8 @@ function Compose({
|
|||
return attachments.concat(mediaFiles);
|
||||
});
|
||||
}
|
||||
// Reset
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Icon icon="attachment" />
|
||||
|
@ -1218,6 +1254,7 @@ function CharCountMeter({ maxCharacters = 500 }) {
|
|||
function MediaAttachment({
|
||||
attachment,
|
||||
disabled,
|
||||
lang,
|
||||
onDescriptionChange = () => {},
|
||||
onRemove = () => {},
|
||||
}) {
|
||||
|
@ -1257,6 +1294,7 @@ function MediaAttachment({
|
|||
<textarea
|
||||
ref={textareaRef}
|
||||
value={description || ''}
|
||||
lang={lang}
|
||||
placeholder={
|
||||
{
|
||||
image: 'Image description',
|
||||
|
@ -1351,6 +1389,7 @@ function MediaAttachment({
|
|||
}
|
||||
|
||||
function Poll({
|
||||
lang,
|
||||
poll,
|
||||
disabled,
|
||||
onInput = () => {},
|
||||
|
@ -1373,6 +1412,7 @@ function Poll({
|
|||
disabled={disabled}
|
||||
maxlength={maxCharactersPerOption}
|
||||
placeholder={`Choice ${i + 1}`}
|
||||
lang={lang}
|
||||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
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 {
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
160deg,
|
||||
var(--reblog-faded-color),
|
||||
transparent 160px
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
.status-reply-to {
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
160deg,
|
||||
var(--reply-to-faded-color),
|
||||
transparent 160px
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
.status-reblog .status-reply-to {
|
||||
background: linear-gradient(
|
||||
to top left,
|
||||
-20deg,
|
||||
var(--reply-to-faded-color),
|
||||
transparent 160px
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
.visibility-direct {
|
||||
|
@ -79,12 +79,26 @@
|
|||
.status.large.visibility-direct {
|
||||
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 {
|
||||
color: var(--outline-color);
|
||||
animation: skeleton-breathe 6s linear infinite;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
contain: layout;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
.status.skeleton > .avatar {
|
||||
background-color: var(--outline-color);
|
||||
}
|
||||
|
@ -188,11 +202,8 @@
|
|||
~ *:not(.media-container, .card),
|
||||
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
|
||||
filter: blur(5px) invert(0.5);
|
||||
/* filter: url(#spoiler); */
|
||||
text-rendering: optimizeSpeed;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
/* transform: translate3d(-5px, -5px, 0); */
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
contain: layout;
|
||||
|
@ -206,15 +217,6 @@
|
|||
image-rendering: pixelated;
|
||||
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 {
|
||||
border-style: dotted;
|
||||
}
|
||||
|
@ -325,12 +327,41 @@
|
|||
min-height: 160px;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.status .media {
|
||||
border-radius: 8px;
|
||||
.status .media-container .media {
|
||||
--media-radius: 16px;
|
||||
border-radius: var(--media-radius);
|
||||
overflow: hidden;
|
||||
min-height: 80px;
|
||||
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 {
|
||||
grid-area: span 2 / span 2;
|
||||
}
|
||||
|
@ -488,6 +519,63 @@
|
|||
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 {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './status.css';
|
||||
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import mem from 'mem';
|
||||
import { memo } from 'preact/compat';
|
||||
|
@ -11,7 +12,6 @@ import {
|
|||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import 'swiped-events';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -20,19 +20,25 @@ import Loader from '../components/loader';
|
|||
import Modal from '../components/modal';
|
||||
import NameText from '../components/name-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import handleContentLinks from '../utils/handle-content-links';
|
||||
import htmlContentLength from '../utils/html-content-length';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Media from './media';
|
||||
import RelativeTime from './relative-time';
|
||||
|
||||
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);
|
||||
|
||||
|
@ -146,8 +152,6 @@ function Status({
|
|||
}
|
||||
};
|
||||
|
||||
const [showMediaModal, setShowMediaModal] = useState(false);
|
||||
|
||||
if (reblog) {
|
||||
return (
|
||||
<div class="status-reblog" onMouseEnter={debugHover}>
|
||||
|
@ -251,18 +255,14 @@ function Status({
|
|||
{/* </span> */}{' '}
|
||||
{size !== 'l' &&
|
||||
(uri ? (
|
||||
<a
|
||||
href={`#/s/${id}
|
||||
`}
|
||||
class="time"
|
||||
>
|
||||
<Link to={`/s/${id}`} class="time">
|
||||
<Icon
|
||||
icon={visibilityIconsMap[visibility]}
|
||||
alt={visibility}
|
||||
size="s"
|
||||
/>{' '}
|
||||
<RelativeTime datetime={createdAtDate} format="micro" />
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span class="time">
|
||||
<Icon
|
||||
|
@ -274,7 +274,7 @@ function Status({
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!withinContext && size !== 's' && (
|
||||
{!withinContext && (
|
||||
<>
|
||||
{inReplyToAccountId === status.account?.id ||
|
||||
!!snapStates.statusThreadNumber[id] ? (
|
||||
|
@ -324,7 +324,7 @@ function Status({
|
|||
<p>{spoilerText}</p>
|
||||
</div>
|
||||
<button
|
||||
class="light spoiler"
|
||||
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -346,37 +346,7 @@ function Status({
|
|||
lang={language}
|
||||
ref={contentRef}
|
||||
data-read-more={readMoreText}
|
||||
onClick={(e) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={handleContentLinks({ mentions })}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: enhanceContent(content, {
|
||||
emojis,
|
||||
|
@ -385,7 +355,9 @@ function Status({
|
|||
.querySelectorAll('a.u-url[target="_blank"]')
|
||||
.forEach((a) => {
|
||||
// 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 && (
|
||||
<button
|
||||
class="plain spoiler"
|
||||
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -421,7 +393,7 @@ function Status({
|
|||
)}
|
||||
{!!mediaAttachments.length && (
|
||||
<div
|
||||
class={`media-container ${
|
||||
class={`media-container media-eq${mediaAttachments.length} ${
|
||||
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
||||
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
||||
>
|
||||
|
@ -435,7 +407,11 @@ function Status({
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowMediaModal(i);
|
||||
states.showMediaModal = {
|
||||
mediaAttachments,
|
||||
index: i,
|
||||
statusID: readOnly ? null : id,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
@ -617,46 +593,37 @@ function Status({
|
|||
/>
|
||||
</div>
|
||||
{isSelf && (
|
||||
<span class="menu-container">
|
||||
<button type="button" title="More" class="plain more-button">
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
</button>
|
||||
<menu>
|
||||
{isSelf && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.showCompose = {
|
||||
editStatus: status,
|
||||
};
|
||||
}}
|
||||
>
|
||||
Edit…
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</menu>
|
||||
</span>
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<div class="action">
|
||||
<button
|
||||
type="button"
|
||||
title="More"
|
||||
class="plain more-button"
|
||||
>
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isSelf && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
editStatus: status,
|
||||
};
|
||||
}}
|
||||
>
|
||||
Edit…
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showMediaModal !== false && (
|
||||
<Modal>
|
||||
<Carousel
|
||||
mediaAttachments={mediaAttachments}
|
||||
index={showMediaModal}
|
||||
onClose={() => {
|
||||
setShowMediaModal(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showEdited && (
|
||||
<Modal
|
||||
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 }) {
|
||||
const {
|
||||
blurhash,
|
||||
|
@ -1281,169 +1061,7 @@ function StatusButton({
|
|||
);
|
||||
}
|
||||
|
||||
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
||||
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) {
|
||||
export function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
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 { login } from 'masto';
|
||||
import { createClient } from 'masto';
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
|
@ -14,12 +14,12 @@ if (window.opener) {
|
|||
console = window.opener.console;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
(() => {
|
||||
if (window.masto) return;
|
||||
console.warn('window.masto not found. Trying to log in...');
|
||||
try {
|
||||
const { instanceURL, accessToken } = getCurrentAccount();
|
||||
window.masto = await login({
|
||||
window.masto = createClient({
|
||||
url: `https://${instanceURL}`,
|
||||
accessToken,
|
||||
disableVersionCheck: true,
|
||||
|
|
|
@ -28,14 +28,15 @@
|
|||
--reply-to-color: var(--orange-color);
|
||||
--reply-to-text-color: #b36200;
|
||||
--favourite-color: var(--red-color);
|
||||
--reply-to-faded-color: #ffa6001a;
|
||||
--reply-to-faded-color: #ffa60030;
|
||||
--outline-color: rgba(128, 128, 128, 0.2);
|
||||
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
||||
--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);
|
||||
--loader-color: #1c1e2199;
|
||||
--comment-line-color: #e5e5e5;
|
||||
--drop-shadow-color: rgba(0, 0, 0, 0.15);
|
||||
|
||||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||
}
|
||||
|
@ -49,10 +50,9 @@
|
|||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--blue-color: CornflowerBlue;
|
||||
--purple-color: mediumpurple;
|
||||
--purple-color: #b190f1;
|
||||
--green-color: lightgreen;
|
||||
--orange-color: orange;
|
||||
--reply-to-text-color: var(--reply-to-color);
|
||||
--bg-color: #242526;
|
||||
--bg-faded-color: #18191a;
|
||||
--bg-blur-color: #0009;
|
||||
|
@ -62,11 +62,15 @@
|
|||
--link-light-color: #6494ed99;
|
||||
--link-faded-color: #6494ed88;
|
||||
--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);
|
||||
--bg-blur-color: #24252699;
|
||||
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||
--loader-color: #f0f2f599;
|
||||
--comment-line-color: #565656;
|
||||
--drop-shadow-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,6 +164,18 @@ button,
|
|||
color: var(--link-color);
|
||||
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 {
|
||||
background-color: var(--bg-faded-color);
|
||||
color: var(--text-color);
|
||||
|
|
10
src/main.jsx
10
src/main.jsx
|
@ -1,6 +1,9 @@
|
|||
import './index.css';
|
||||
|
||||
import '@szhsin/react-menu/dist/core.css';
|
||||
|
||||
import { render } from 'preact';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
|
@ -8,7 +11,12 @@ if (import.meta.env.DEV) {
|
|||
import('preact/debug');
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app'));
|
||||
render(
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>,
|
||||
document.getElementById('app'),
|
||||
);
|
||||
|
||||
// Clean up iconify localStorage
|
||||
// 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 { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import Status from '../components/status';
|
||||
import db from '../utils/db';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import { getCurrentAccountNS } from '../utils/store-utils';
|
||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
function Home({ hidden }) {
|
||||
useTitle('Home', '/');
|
||||
const snapStates = useSnapshot(states);
|
||||
const isHomeLocation = snapStates.currentLocation === '/';
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
console.debug('RENDER Home');
|
||||
|
||||
const homeIterator = useRef(
|
||||
masto.v1.timelines.listHome({
|
||||
limit: LIMIT,
|
||||
}),
|
||||
);
|
||||
const homeIterator = useRef();
|
||||
async function fetchStatuses(firstLoad) {
|
||||
if (firstLoad) {
|
||||
// Reset iterator
|
||||
|
@ -36,102 +35,111 @@ function Home({ hidden }) {
|
|||
states.homeNew = [];
|
||||
}
|
||||
const allStatuses = await homeIterator.current.next();
|
||||
if (allStatuses.value <= 0) {
|
||||
return { done: true };
|
||||
}
|
||||
const homeValues = allStatuses.value.map((status) => {
|
||||
saveStatus(status);
|
||||
return {
|
||||
id: status.id,
|
||||
reblog: status.reblog?.id,
|
||||
reply: !!status.inReplyToAccountId,
|
||||
};
|
||||
});
|
||||
if (allStatuses.value?.length) {
|
||||
// ENFORCE sort by datetime (Latest first)
|
||||
allStatuses.value.sort((a, b) => {
|
||||
const aDate = new Date(a.createdAt);
|
||||
const bDate = new Date(b.createdAt);
|
||||
return bDate - aDate;
|
||||
});
|
||||
const homeValues = allStatuses.value.map((status) => {
|
||||
saveStatus(status);
|
||||
return {
|
||||
id: status.id,
|
||||
reblog: status.reblog?.id,
|
||||
reply: !!status.inReplyToAccountId,
|
||||
};
|
||||
});
|
||||
|
||||
// BOOSTS CAROUSEL
|
||||
if (snapStates.settings.boostsCarousel) {
|
||||
let specialHome = [];
|
||||
let boostStash = [];
|
||||
let serialBoosts = 0;
|
||||
for (let i = 0; i < homeValues.length; i++) {
|
||||
const status = homeValues[i];
|
||||
if (status.reblog) {
|
||||
boostStash.push(status);
|
||||
serialBoosts++;
|
||||
} else {
|
||||
specialHome.push(status);
|
||||
if (serialBoosts < 3) {
|
||||
serialBoosts = 0;
|
||||
// BOOSTS CAROUSEL
|
||||
if (snapStates.settings.boostsCarousel) {
|
||||
let specialHome = [];
|
||||
let boostStash = [];
|
||||
let serialBoosts = 0;
|
||||
for (let i = 0; i < homeValues.length; i++) {
|
||||
const status = homeValues[i];
|
||||
if (status.reblog) {
|
||||
boostStash.push(status);
|
||||
serialBoosts++;
|
||||
} else {
|
||||
specialHome.push(status);
|
||||
if (serialBoosts < 3) {
|
||||
serialBoosts = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// if boostStash is more than quarter of homeValues
|
||||
// or if there are 3 or more boosts in a row
|
||||
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
|
||||
// if boostStash is more than 3 quarter of homeValues
|
||||
const boostStashID = boostStash.map((status) => status.id);
|
||||
if (boostStash.length > (homeValues.length * 3) / 4) {
|
||||
// insert boost array at the end of specialHome list
|
||||
specialHome = [
|
||||
...specialHome,
|
||||
{ id: boostStashID, boosts: boostStash },
|
||||
];
|
||||
// if boostStash is more than quarter of homeValues
|
||||
// or if there are 3 or more boosts in a row
|
||||
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
|
||||
// if boostStash is more than 3 quarter of homeValues
|
||||
const boostStashID = boostStash.map((status) => status.id);
|
||||
if (boostStash.length > (homeValues.length * 3) / 4) {
|
||||
// insert boost array at the end of specialHome list
|
||||
specialHome = [
|
||||
...specialHome,
|
||||
{ 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 {
|
||||
// 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),
|
||||
];
|
||||
// Untouched, this is fine
|
||||
specialHome = homeValues;
|
||||
}
|
||||
console.log({
|
||||
specialHome,
|
||||
});
|
||||
if (firstLoad) {
|
||||
states.homeLast = specialHome[0];
|
||||
states.home = specialHome;
|
||||
} else {
|
||||
states.home.push(...specialHome);
|
||||
}
|
||||
} else {
|
||||
// Untouched, this is fine
|
||||
specialHome = homeValues;
|
||||
}
|
||||
console.log({
|
||||
specialHome,
|
||||
});
|
||||
if (firstLoad) {
|
||||
states.home = specialHome;
|
||||
} else {
|
||||
states.home.push(...specialHome);
|
||||
}
|
||||
} else {
|
||||
if (firstLoad) {
|
||||
states.home = homeValues;
|
||||
} else {
|
||||
states.home.push(...homeValues);
|
||||
if (firstLoad) {
|
||||
states.homeLast = homeValues[0];
|
||||
states.home = homeValues;
|
||||
} else {
|
||||
states.home.push(...homeValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
states.homeLastFetchTime = Date.now();
|
||||
return {
|
||||
done: false,
|
||||
};
|
||||
return allStatuses;
|
||||
}
|
||||
|
||||
const loadingStatuses = useRef(false);
|
||||
const loadStatuses = useDebouncedCallback((firstLoad) => {
|
||||
if (loadingStatuses.current) return;
|
||||
loadingStatuses.current = true;
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const { done } = await fetchStatuses(firstLoad);
|
||||
setShowMore(!done);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setUIState('error');
|
||||
} finally {
|
||||
loadingStatuses.current = false;
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
const loadStatuses = useDebouncedCallback(
|
||||
(firstLoad) => {
|
||||
if (loadingStatuses.current) return;
|
||||
loadingStatuses.current = true;
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const { done } = await fetchStatuses(firstLoad);
|
||||
setShowMore(!done);
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setUIState('error');
|
||||
} finally {
|
||||
loadingStatuses.current = false;
|
||||
}
|
||||
})();
|
||||
},
|
||||
3000,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatuses(true);
|
||||
|
@ -139,103 +147,121 @@ function Home({ hidden }) {
|
|||
|
||||
const scrollableRef = useRef();
|
||||
|
||||
useHotkeys('j, shift+j', (_, handler) => {
|
||||
// focus on next status after active status
|
||||
// Traverses .timeline li .status-link, focus on .status-link
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
);
|
||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||
const allStatusLinks = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
useHotkeys(
|
||||
'j, shift+j',
|
||||
(_, handler) => {
|
||||
// focus on next status after active status
|
||||
// Traverses .timeline li .status-link, focus on .status-link
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
),
|
||||
);
|
||||
if (
|
||||
activeStatus &&
|
||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||
activeStatusRect.bottom > 0
|
||||
) {
|
||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
||||
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'),
|
||||
);
|
||||
);
|
||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||
const allStatusLinks = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
'.status-link, .status-boost-link',
|
||||
),
|
||||
);
|
||||
if (
|
||||
activeStatus &&
|
||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||
activeStatusRect.bottom > 0
|
||||
) {
|
||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||
let nextStatus = allStatusLinks[activeStatusIndex + 1];
|
||||
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?.();
|
||||
}
|
||||
} 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?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
enabled: isHomeLocation,
|
||||
},
|
||||
);
|
||||
|
||||
useHotkeys('k. shift+k', () => {
|
||||
// focus on previous status after active status
|
||||
// Traverses .timeline li .status-link, focus on .status-link
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
);
|
||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||
const allStatusLinks = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
useHotkeys(
|
||||
'k, shift+k',
|
||||
(_, handler) => {
|
||||
// focus on previous status after active status
|
||||
// Traverses .timeline li .status-link, focus on .status-link
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
),
|
||||
);
|
||||
if (
|
||||
activeStatus &&
|
||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||
activeStatusRect.bottom > 0
|
||||
) {
|
||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
||||
if (handler.shift) {
|
||||
// get prev status that's not .status-boost-link
|
||||
prevStatus = allStatusLinks.find(
|
||||
(statusLink, index) =>
|
||||
index < activeStatusIndex &&
|
||||
!statusLink.classList.contains('status-boost-link'),
|
||||
);
|
||||
);
|
||||
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||
const allStatusLinks = Array.from(
|
||||
scrollableRef.current.querySelectorAll(
|
||||
'.status-link, .status-boost-link',
|
||||
),
|
||||
);
|
||||
if (
|
||||
activeStatus &&
|
||||
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||
activeStatusRect.bottom > 0
|
||||
) {
|
||||
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
|
||||
let prevStatus = allStatusLinks[activeStatusIndex - 1];
|
||||
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?.();
|
||||
}
|
||||
} 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?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
enabled: isHomeLocation,
|
||||
},
|
||||
);
|
||||
|
||||
useHotkeys(['enter', 'o'], () => {
|
||||
// open active status
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
);
|
||||
if (activeStatus) {
|
||||
activeStatus.click();
|
||||
}
|
||||
});
|
||||
useHotkeys(
|
||||
['enter', 'o'],
|
||||
() => {
|
||||
// open active status
|
||||
const activeStatus = document.activeElement.closest(
|
||||
'.status-link, .status-boost-link',
|
||||
);
|
||||
if (activeStatus) {
|
||||
activeStatus.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: isHomeLocation,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
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 (
|
||||
<div
|
||||
id="home-page"
|
||||
class="deck-container"
|
||||
hidden={hidden}
|
||||
ref={scrollableRef}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<>
|
||||
<div
|
||||
id="home-page"
|
||||
class="deck-container"
|
||||
hidden={hidden}
|
||||
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
|
||||
hidden={scrollDirection === 'end' && !nearReachStart}
|
||||
type="button"
|
||||
|
@ -301,157 +472,7 @@ function Home({ hidden }) {
|
|||
>
|
||||
<Icon icon="quill" size="xxl" alt="Compose" />
|
||||
</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 actualStatusID = reblog || statusID;
|
||||
return (
|
||||
<li>
|
||||
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
|
||||
<li key={statusID}>
|
||||
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
|
||||
<Status statusID={statusID} size="s" />
|
||||
</a>
|
||||
</Link>
|
||||
</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 Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import instancesListURL from '../data/instances.json?url';
|
||||
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
||||
|
@ -111,7 +112,7 @@ function Login() {
|
|||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/#">Go home</a>
|
||||
<Link to="/">Go home</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
padding: 16px !important;
|
||||
gap: 12px;
|
||||
animation: appear 0.2s ease-out;
|
||||
}
|
||||
.notification.mention {
|
||||
margin-top: 16px;
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import './notifications.css';
|
||||
|
||||
import { Link } from 'preact-router/match';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import Status from '../components/status';
|
||||
import states from '../utils/states';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
/*
|
||||
|
@ -45,6 +46,228 @@ const contentText = {
|
|||
|
||||
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 }) {
|
||||
const { id, type, status, account, _accounts } = notification;
|
||||
|
||||
|
@ -61,7 +284,7 @@ function Notification({ notification }) {
|
|||
: contentText[type];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={`notification ${type}`} tabIndex="0">
|
||||
<div
|
||||
class={`notification-type notification-${type}`}
|
||||
title={new Date(notification.createdAt).toLocaleString()}
|
||||
|
@ -137,11 +360,13 @@ function Notification({ notification }) {
|
|||
<Avatar
|
||||
url={account.avatarStatic}
|
||||
size={
|
||||
_accounts.length < 30
|
||||
? 'xl'
|
||||
_accounts.length <= 10
|
||||
? 'xxl'
|
||||
: _accounts.length < 100
|
||||
? 'l'
|
||||
? 'xl'
|
||||
: _accounts.length < 1000
|
||||
? 'l'
|
||||
: _accounts.length < 2000
|
||||
? 'm'
|
||||
: 's' // My god, this person is popular!
|
||||
}
|
||||
|
@ -156,270 +381,12 @@ function Notification({ notification }) {
|
|||
{status && (
|
||||
<Link
|
||||
class={`status-link status-type-${type}`}
|
||||
href={`#/s/${actualStatusID}`}
|
||||
to={`/s/${actualStatusID}`}
|
||||
>
|
||||
<Status status={status} size="s" />
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
|
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) {
|
||||
background-color: var(--bg-color);
|
||||
margin: 0 -16px;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
border-top: 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 {
|
||||
border-top: var(--hairline-width) solid var(--outline-color);
|
||||
#settings-container :is(section, .section) > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#settings-container ul {
|
||||
|
@ -28,33 +29,34 @@
|
|||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
#settings-container ul li {
|
||||
padding: 8px 0;
|
||||
#settings-container ul:not([role='menu']) > li {
|
||||
padding: 8px 0 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
color: var(--green-color);
|
||||
opacity: 0.1;
|
||||
}
|
||||
#settings-container ul li .current.is-current {
|
||||
#settings-container ul:not([role='menu']) > li .current.is-current {
|
||||
opacity: 1;
|
||||
}
|
||||
#settings-container ul li .current.is-current + .avatar {
|
||||
box-shadow: 0 0 0 1.5px var(--green-color);
|
||||
#settings-container ul:not([role='menu']) > li .current.is-current + .avatar {
|
||||
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;
|
||||
max-width: 100%;
|
||||
}
|
||||
#settings-container ul li > div.actions {
|
||||
flex-basis: min-content;
|
||||
#settings-container ul:not([role='menu']) > li > div.actions {
|
||||
flex-basis: fit-content;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#settings-container ul li > div:last-child {
|
||||
#settings-container ul:not([role='menu']) > li > div:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
#settings-container div,
|
||||
|
@ -98,10 +100,3 @@
|
|||
#settings-container .radio-group label:has(input:checked) input:checked + span {
|
||||
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 { useRef, useState } from 'preact/hooks';
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { useReducer, useRef, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import logo from '../assets/logo.svg';
|
||||
import Avatar from '../components/avatar';
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import states from '../utils/states';
|
||||
|
@ -26,6 +29,8 @@ function Settings({ onClose }) {
|
|||
const moreThanOneAccount = accounts.length > 1;
|
||||
const [currentDefault, setCurrentDefault] = useState(0);
|
||||
|
||||
const [_, reload] = useReducer((x) => x + 1, 0);
|
||||
|
||||
return (
|
||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||
<main>
|
||||
|
@ -39,14 +44,30 @@ function Settings({ onClose }) {
|
|||
const isCurrent = account.info.id === currentAccount;
|
||||
const isDefault = i === (currentDefault || 0);
|
||||
return (
|
||||
<li>
|
||||
<li key={i + account.id}>
|
||||
<div>
|
||||
{moreThanOneAccount && (
|
||||
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||
<Icon icon="check-circle" alt="Current" />
|
||||
</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
|
||||
account={account.info}
|
||||
showAcct
|
||||
|
@ -73,11 +94,21 @@ function Settings({ onClose }) {
|
|||
<Icon icon="transfer" /> Switch
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
{!isDefault && moreThanOneAccount && (
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<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={() => {
|
||||
// Move account to the top of the list
|
||||
accounts.splice(i, 1);
|
||||
|
@ -87,29 +118,23 @@ function Settings({ onClose }) {
|
|||
}}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="plain small"
|
||||
onClick={() => {
|
||||
const yes = confirm(
|
||||
'Are you sure you want to log out?',
|
||||
);
|
||||
if (!yes) return;
|
||||
accounts.splice(i, 1);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<MenuItem
|
||||
disabled={!isCurrent}
|
||||
onClick={() => {
|
||||
const yes = confirm(
|
||||
'Are you sure you want to log out?',
|
||||
);
|
||||
if (!yes) return;
|
||||
accounts.splice(i, 1);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
@ -124,9 +149,9 @@ function Settings({ onClose }) {
|
|||
</p>
|
||||
)}
|
||||
<p style={{ textAlign: 'end' }}>
|
||||
<a href="/#/login" class="button" onClick={onClose}>
|
||||
<Link to="/login" class="button" onClick={onClose}>
|
||||
Add new account
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
<h2>Settings</h2>
|
||||
|
@ -226,6 +251,29 @@ function Settings({ onClose }) {
|
|||
</section>
|
||||
<h2>About</h2>
|
||||
<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>
|
||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||
Built
|
||||
|
@ -241,6 +289,13 @@ function Settings({ onClose }) {
|
|||
>
|
||||
@cheeaun
|
||||
</a>
|
||||
.{' '}
|
||||
<a
|
||||
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{__BUILD_TIME__ && (
|
||||
|
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
.hero-heading {
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import './status.css';
|
||||
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
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 { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
|
@ -15,15 +20,23 @@ import Status from '../components/status';
|
|||
import htmlContentLength from '../utils/html-content-length';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, { saveStatus, threadifyStatus } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccount } from '../utils/store-utils';
|
||||
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
||||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
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 [statuses, setStatuses] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -47,19 +60,18 @@ function StatusPage({ id }) {
|
|||
});
|
||||
onScroll();
|
||||
return () => {
|
||||
onScroll.cancel();
|
||||
onScroll.flush();
|
||||
scrollableRef.current?.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [id, uiState !== 'loading']);
|
||||
|
||||
const scrollOffsets = useRef();
|
||||
const cachedStatusesMap = useRef({});
|
||||
const initContext = () => {
|
||||
console.debug('initContext', id);
|
||||
setUIState('loading');
|
||||
let heroTimer;
|
||||
|
||||
const cachedStatuses = cachedStatusesMap.current[id];
|
||||
const cachedStatuses = cachedStatusesMap[id];
|
||||
if (cachedStatuses) {
|
||||
// Case 1: It's cached, let's restore them to make it snappy
|
||||
const reallyCachedStatuses = cachedStatuses.filter(
|
||||
|
@ -80,8 +92,13 @@ function StatusPage({ id }) {
|
|||
}
|
||||
|
||||
(async () => {
|
||||
const heroFetch = () => masto.v1.statuses.fetch(id);
|
||||
const contextFetch = masto.v1.statuses.fetchContext(id);
|
||||
const heroFetch = () =>
|
||||
pRetry(() => masto.v1.statuses.fetch(id), {
|
||||
retries: 4,
|
||||
});
|
||||
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
|
||||
retries: 8,
|
||||
});
|
||||
|
||||
const hasStatus = !!snapStates.statuses[id];
|
||||
let heroStatus = snapStates.statuses[id];
|
||||
|
@ -127,8 +144,8 @@ function StatusPage({ id }) {
|
|||
}
|
||||
parent.__replies.push(status);
|
||||
} else {
|
||||
// If no parent, it's probably a reply to a reply to a reply, level 3
|
||||
console.warn('[LEVEL 3] No parent found for', status);
|
||||
// If no parent, something is wrong
|
||||
console.warn('No parent found for', status);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -149,8 +166,23 @@ function StatusPage({ id }) {
|
|||
thread: s.account.id === heroStatus.account.id,
|
||||
replies: s.__replies?.map((r) => ({
|
||||
id: r.id,
|
||||
account: r.account,
|
||||
repliesCount: r.repliesCount,
|
||||
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 });
|
||||
setStatuses(allStatuses);
|
||||
cachedStatusesMap.current[id] = allStatuses;
|
||||
cachedStatusesMap[id] = allStatuses;
|
||||
|
||||
// Let's threadify this one
|
||||
// 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);
|
||||
if (!!scrollPosition) {
|
||||
console.debug('Case 1', {
|
||||
id,
|
||||
scrollPosition,
|
||||
});
|
||||
scrollableRef.current.scrollTop = scrollPosition;
|
||||
|
@ -196,7 +229,9 @@ function StatusPage({ id }) {
|
|||
scrollTop: scrollableRef.current?.scrollTop,
|
||||
};
|
||||
const newScrollTop =
|
||||
newScrollOffsets.offsetTop - scrollOffsets.current.offsetTop;
|
||||
newScrollOffsets.offsetTop -
|
||||
scrollOffsets.current.offsetTop +
|
||||
newScrollOffsets.scrollTop;
|
||||
console.debug('Case 2', {
|
||||
scrollOffsets: scrollOffsets.current,
|
||||
newScrollOffsets,
|
||||
|
@ -204,6 +239,11 @@ function StatusPage({ id }) {
|
|||
statuses: [...statuses],
|
||||
});
|
||||
scrollableRef.current.scrollTop = newScrollTop;
|
||||
} else if (statuses.length === 1) {
|
||||
console.debug('Case 3', {
|
||||
id,
|
||||
});
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
// RESET
|
||||
|
@ -233,7 +273,7 @@ function StatusPage({ id }) {
|
|||
// RESET
|
||||
states.scrollPositions = {};
|
||||
states.reloadStatusPage = 0;
|
||||
cachedStatusesMap.current = {};
|
||||
cachedStatusesMap = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -268,12 +308,17 @@ function StatusPage({ id }) {
|
|||
heroDisplayName && heroContentText
|
||||
? `${heroDisplayName}: "${heroContentText}"`
|
||||
: 'Status',
|
||||
'/s/:id',
|
||||
);
|
||||
|
||||
const prevRoute = states.history.findLast((h) => {
|
||||
return h === '/' || /notifications/i.test(h);
|
||||
});
|
||||
const closeLink = `#${prevRoute || '/'}`;
|
||||
const closeLink = useMemo(() => {
|
||||
const pathname = snapStates.prevLocation?.pathname;
|
||||
if (!pathname || pathname.startsWith('/s/')) return '/';
|
||||
return pathname;
|
||||
}, []);
|
||||
const onClose = () => {
|
||||
states.showMediaModal = false;
|
||||
};
|
||||
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
const showMore = useMemo(() => {
|
||||
|
@ -281,7 +326,7 @@ function StatusPage({ id }) {
|
|||
return 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 ancestors = statuses.filter((s) => s.ancestor);
|
||||
|
||||
|
@ -295,17 +340,105 @@ function StatusPage({ id }) {
|
|||
}, [heroInView]);
|
||||
|
||||
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({
|
||||
scrollableElement: scrollableRef.current,
|
||||
distanceFromStart: 0.5,
|
||||
distanceFromStart: 0.2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
<Link href={closeLink}></Link>
|
||||
<Link to={closeLink} onClick={onClose}></Link>
|
||||
<div
|
||||
tabIndex="-1"
|
||||
ref={scrollableRef}
|
||||
|
@ -315,17 +448,6 @@ function StatusPage({ id }) {
|
|||
>
|
||||
<header
|
||||
class={`${heroInView ? 'inview' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!/^(a|button)$/i.test(e.target.tagName) &&
|
||||
heroStatusRef.current
|
||||
) {
|
||||
heroStatusRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDblClick={(e) => {
|
||||
// reload statuses
|
||||
states.reloadStatusPage++;
|
||||
|
@ -338,23 +460,34 @@ function StatusPage({ id }) {
|
|||
</div> */}
|
||||
<h1>
|
||||
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
||||
<span class="hero-heading">
|
||||
{!!heroPointer && (
|
||||
<>
|
||||
<Icon
|
||||
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||||
/>{' '}
|
||||
</>
|
||||
)}
|
||||
<NameText showAvatar account={heroStatus.account} short />{' '}
|
||||
<span class="insignificant">
|
||||
•{' '}
|
||||
<RelativeTime
|
||||
datetime={heroStatus.createdAt}
|
||||
format="micro"
|
||||
<>
|
||||
<span class="hero-heading">
|
||||
<NameText showAvatar account={heroStatus.account} short />{' '}
|
||||
<span class="insignificant">
|
||||
•{' '}
|
||||
<RelativeTime
|
||||
datetime={heroStatus.createdAt}
|
||||
format="micro"
|
||||
/>
|
||||
</span>
|
||||
</span>{' '}
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Status{' '}
|
||||
|
@ -383,7 +516,39 @@ function StatusPage({ id }) {
|
|||
</h1>
|
||||
<div class="header-side">
|
||||
<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" />
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -412,39 +577,43 @@ function StatusPage({ id }) {
|
|||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||
>
|
||||
{isHero ? (
|
||||
<InView threshold={0.1} onChange={onView}>
|
||||
<InView
|
||||
threshold={0.1}
|
||||
onChange={onView}
|
||||
class="status-focus"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Status statusID={statusID} withinContext size="l" />
|
||||
</InView>
|
||||
) : (
|
||||
<Link
|
||||
class="
|
||||
status-link
|
||||
"
|
||||
href={`#/s/${statusID}`}
|
||||
class="status-link"
|
||||
to={`/s/${statusID}`}
|
||||
onClick={() => {
|
||||
resetScrollPosition(statusID);
|
||||
}}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
withinContext
|
||||
size={thread || ancestor ? 'm' : 's'}
|
||||
/>
|
||||
{replies?.length > LIMIT && (
|
||||
{/* {replies?.length > LIMIT && (
|
||||
<div class="replies-link">
|
||||
<Icon icon="comment" />{' '}
|
||||
<span title={replies.length}>
|
||||
{shortenNumber(replies.length)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</Link>
|
||||
)}
|
||||
{descendant &&
|
||||
replies?.length > 0 &&
|
||||
replies?.length <= LIMIT && (
|
||||
<SubComments
|
||||
hasManyStatuses={hasManyStatuses}
|
||||
replies={replies}
|
||||
/>
|
||||
)}
|
||||
{descendant && replies?.length > 0 && (
|
||||
<SubComments
|
||||
hasManyStatuses={hasManyStatuses}
|
||||
replies={replies}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'loading' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
|
@ -522,40 +691,86 @@ function StatusPage({ id }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SubComments({
|
||||
hasManyStatuses,
|
||||
replies,
|
||||
onStatusLinkClick = () => {},
|
||||
}) {
|
||||
// If less than or 2 replies and total number of characters of content from replies is less than 500
|
||||
function SubComments({ hasManyStatuses, replies }) {
|
||||
// Set isBrief = true:
|
||||
// - if less than or 2 replies
|
||||
// - if replies have no sub-replies
|
||||
// - if total number of characters of content from replies is less than 500
|
||||
let isBrief = false;
|
||||
if (replies.length <= 2) {
|
||||
let totalLength = replies.reduce((acc, reply) => {
|
||||
const { content } = reply;
|
||||
const length = htmlContentLength(content);
|
||||
return acc + length;
|
||||
}, 0);
|
||||
isBrief = totalLength < 500;
|
||||
const containsSubReplies = replies.some(
|
||||
(r) => r.repliesCount > 0 || r.replies?.length > 0,
|
||||
);
|
||||
if (!containsSubReplies) {
|
||||
let totalLength = replies.reduce((acc, reply) => {
|
||||
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;
|
||||
|
||||
return (
|
||||
<details class="replies" open={open}>
|
||||
<summary hidden={open}>
|
||||
<span title={replies.length}>{shortenNumber(replies.length)}</span> repl
|
||||
{replies.length === 1 ? 'y' : 'ies'}
|
||||
<span class="avatars">
|
||||
{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>
|
||||
<ul>
|
||||
{replies.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
class="status-link"
|
||||
href={`#/s/${r.id}`}
|
||||
onClick={onStatusLinkClick}
|
||||
to={`/s/${r.id}`}
|
||||
onClick={() => {
|
||||
resetScrollPosition(r.id);
|
||||
}}
|
||||
>
|
||||
<Status statusID={r.id} withinContext size="s" />
|
||||
{r.repliesCount > 0 && (
|
||||
{!r.replies?.length && r.repliesCount > 0 && (
|
||||
<div class="replies-link">
|
||||
<Icon icon="comment" />{' '}
|
||||
<span title={r.repliesCount}>
|
||||
|
@ -564,6 +779,12 @@ function SubComments({
|
|||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{r.replies?.length && (
|
||||
<SubComments
|
||||
hasManyStatuses={hasManyStatuses}
|
||||
replies={r.replies}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -38,9 +38,9 @@
|
|||
filter: hue-rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
filter: hue-rotate(360deg);
|
||||
filter: hue-rotate(-90deg);
|
||||
}
|
||||
}
|
||||
#welcome:hover h2 {
|
||||
animation: psychedelic 60s infinite;
|
||||
animation: psychedelic 10s infinite alternate;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import './welcome.css';
|
||||
|
||||
import logo from '../assets/logo.svg';
|
||||
import Link from '../components/link';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
function Welcome() {
|
||||
|
@ -28,9 +29,9 @@ function Welcome() {
|
|||
<p>
|
||||
<big>
|
||||
<b>
|
||||
<a href="#/login" class="button">
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</a>
|
||||
</Link>
|
||||
</b>
|
||||
</big>
|
||||
</p>
|
||||
|
@ -43,6 +44,13 @@ function Welcome() {
|
|||
<a href="https://mastodon.social/@cheeaun" target="_blank">
|
||||
@cheeaun
|
||||
</a>
|
||||
.{' '}
|
||||
<a
|
||||
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
function emojifyText(text, emojis = []) {
|
||||
if (!text) return '';
|
||||
if (!emojis.length) return text;
|
||||
// Replace shortcodes in text with emoji
|
||||
// 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';
|
||||
|
||||
const states = proxy({
|
||||
history: [],
|
||||
// history: [],
|
||||
prevLocation: null,
|
||||
currentLocation: null,
|
||||
statuses: {},
|
||||
statusThreadNumber: {},
|
||||
home: [],
|
||||
specialHome: [],
|
||||
// specialHome: [],
|
||||
homeNew: [],
|
||||
homeLast: null, // Last item in 'home' list
|
||||
homeLastFetchTime: null,
|
||||
notifications: [],
|
||||
notificationLast: null, // Last item in 'notifications' list
|
||||
notificationsNew: [],
|
||||
notificationsLastFetchTime: null,
|
||||
accounts: {},
|
||||
|
@ -22,10 +26,11 @@ const states = proxy({
|
|||
showSettings: false,
|
||||
showAccount: false,
|
||||
showDrafts: false,
|
||||
showMediaModal: false,
|
||||
composeCharacterCount: 0,
|
||||
settings: {
|
||||
boostsCarousel: store.local.get('settings:boostsCarousel')
|
||||
? store.local.get('settings:boostsCarousel')
|
||||
? store.local.get('settings:boostsCarousel') === '1'
|
||||
: 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;
|
||||
}
|
||||
|
||||
setReachStart(scrollStart === 0);
|
||||
setReachStart(scrollStart <= 0);
|
||||
setReachEnd(scrollStart + clientDimension >= scrollDimension);
|
||||
setNearReachStart(scrollStart <= distanceFromStartPx);
|
||||
setNearReachEnd(
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
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;
|
||||
|
||||
export default function useTitle(title) {
|
||||
export default function useTitle(title, path) {
|
||||
const snapStates = useSnapshot(states);
|
||||
useEffect(() => {
|
||||
if (path && !matchPath(path, snapStates.currentLocation)) return;
|
||||
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
|
||||
}, [title]);
|
||||
}, [title, snapStates.currentLocation]);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue