Merge pull request #49 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-02-01 01:27:15 +08:00 committed by GitHub
commit 451dc57a69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 5122 additions and 1791 deletions

View file

@ -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
View 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/).

View file

@ -21,6 +21,8 @@ This is an alternative web client for [Mastodon](https://joinmastodon.org/).
- may break more often - may break more often
- may be fixed much faster too - may be fixed much faster too
🐘 Follow [@phanpy on Mastodon](https://hachyderm.io/@phanpy) for updates ✨
Everything is designed and engineered for my own use case, following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas. Everything is designed and engineered for my own use case, following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas.
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
@ -62,6 +64,7 @@ Prerequisites: Node.js 18+
- [Vite](https://vitejs.dev/) - Build tool - [Vite](https://vitejs.dev/) - Build tool
- [Preact](https://preactjs.com/) - UI library - [Preact](https://preactjs.com/) - UI library
- [Valtio](https://valtio.pmnd.rs/) - State management - [Valtio](https://valtio.pmnd.rs/) - State management
- [React Router](https://reactrouter.com/) - Routing
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client - [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library - [Iconify](https://iconify.design/) - Icon library
- Vanilla CSS - *Yes, I'm old school.* - Vanilla CSS - *Yes, I'm old school.*
@ -90,14 +93,14 @@ And here I am. Building a Mastodon web client.
## Alternative web clients ## Alternative web clients
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) → [Semaphore](https://semaphore.social/)
- [Cuckoo+](https://www.cuckoo.social/) - [Cuckoo+](https://www.cuckoo.social/)
- [Sengi](https://nicolasconstant.github.io/sengi/) - [Sengi](https://nicolasconstant.github.io/sengi/)
- [Soapbox](https://fe.soapbox.pub/) - [Soapbox](https://fe.soapbox.pub/)
- [Elk](https://elk.zone/) - [Elk](https://elk.zone/)
- [Mastodeck](https://mastodeck.com/) - [Mastodeck](https://mastodeck.com/)
- [Tooty](https://github.com/n1k0/tooty) - [Tooty](https://github.com/n1k0/tooty)
- [More...](https://github.com/tleb/awesome-mastodon#clients) - [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## License ## License

View file

@ -45,62 +45,5 @@
<div id="app"></div> <div id="app"></div>
<div id="modal-container"></div> <div id="modal-container"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
<svg
xmlns="http://www.w3.org/2000/svg"
style="position: absolute; width: 0; height: 0"
>
<filter id="spoiler" x="-20%" y="-20%" width="140%" height="140%">
<feColorMatrix
type="matrix"
values="0.01 0 0 0 0
0.01 0 0 0 0
0.01 0 0 0 0
0 0 0 .5 0"
in="SourceGraphic"
result="colormatrix"
/>
<feTurbulence
type="turbulence"
baseFrequency=".5 .5"
numOctaves="10"
seed="1"
result="turbulence"
/>
<feDisplacementMap
in="colormatrix"
in2="turbulence"
scale="20"
xChannelSelector="R"
yChannelSelector="B"
result="displacementMap"
/>
</filter>
<filter id="spoiler-dark" x="-20%" y="-20%" width="140%" height="140%">
<feColorMatrix
type="matrix"
values="1 0 0 .8 0
1 0 0 .8 0
1 0 0 .8 0
0 0 0 .5 0"
in="SourceGraphic"
result="colormatrix"
/>
<feTurbulence
type="turbulence"
baseFrequency=".5 .5"
numOctaves="10"
seed="1"
result="turbulence"
/>
<feDisplacementMap
in="colormatrix"
in2="turbulence"
scale="20"
xChannelSelector="R"
yChannelSelector="B"
result="displacementMap"
/>
</filter>
</svg>
</body> </body>
</html> </html>

1929
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,32 +12,34 @@
"dependencies": { "dependencies": {
"@github/text-expander-element": "~2.3.0", "@github/text-expander-element": "~2.3.0",
"@iconify-icons/mingcute": "~1.2.3", "@iconify-icons/mingcute": "~1.2.3",
"@szhsin/react-menu": "~3.4.0",
"dayjs": "~1.11.7", "dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3", "fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
"idb-keyval": "~6.2.0", "idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"masto": "~5.5.0", "masto": "~5.7.0",
"mem": "~9.0.2", "mem": "~9.0.2",
"p-retry": "~5.1.2",
"preact": "~10.11.3", "preact": "~10.11.3",
"preact-router": "~4.1.0", "react-hotkeys-hook": "~4.3.3",
"react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1", "react-intersection-observer": "~9.4.1",
"react-router-dom": "6.6.2",
"string-length": "~5.0.1", "string-length": "~5.0.1",
"swiped-events": "~1.1.7", "swiped-events": "~1.1.7",
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.1", "uid": "~2.0.1",
"use-debounce": "~9.0.3",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "~1.9.0" "valtio": "~1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "~2.5.0", "@preact/preset-vite": "~2.5.0",
"@trivago/prettier-plugin-sort-imports": "~4.0.0", "@trivago/prettier-plugin-sort-imports": "~4.0.0",
"autoprefixer": "~10.4.13",
"postcss": "~8.4.21", "postcss": "~8.4.21",
"postcss-dark-theme-class": "~0.7.3", "postcss-dark-theme-class": "~0.7.3",
"postcss-preset-env": "~8.0.1",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~4.0.4", "vite": "~4.0.4",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~1.0.11",
@ -52,7 +54,7 @@
"postcss": { "postcss": {
"plugins": { "plugins": {
"postcss-dark-theme-class": {}, "postcss-dark-theme-class": {},
"autoprefixer": {} "postcss-preset-env": {}
} }
}, },
"browserslist": [ "browserslist": [

View file

@ -6,6 +6,9 @@ body {
color: var(--text-color); color: var(--text-color);
overflow-x: hidden; overflow-x: hidden;
} }
body {
touch-action: pan-x pan-y;
}
#app { #app {
min-height: 100vh; min-height: 100vh;
@ -46,6 +49,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transition: opacity 0.1s ease-in-out; transition: opacity 0.1s ease-in-out;
overscroll-behavior: contain; overscroll-behavior: contain;
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: var(--bg-color);
} }
.deck-container[hidden] { .deck-container[hidden] {
display: block; display: block;
@ -66,7 +70,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
min-height: 100dvh; min-height: 100dvh;
margin: auto; margin: auto;
width: 40em; width: 40em;
max-width: 100vw; max-width: 100%;
border-left: 1px solid rgba(0, 0, 0, 0.1); border-left: 1px solid rgba(0, 0, 0, 0.1);
border-right: 1px solid rgba(0, 0, 0, 0.1); border-right: 1px solid rgba(0, 0, 0, 0.1);
background-color: var(--bg-color); background-color: var(--bg-color);
@ -114,6 +118,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding: 0; padding: 0;
font-size: 1.2em; font-size: 1.2em;
text-align: center; text-align: center;
white-space: nowrap;
} }
.deck > header h1:first-child { .deck > header h1:first-child {
text-align: left; text-align: left;
@ -146,17 +151,26 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-bottom: none; border-bottom: none;
} }
.timeline.contextual {
--thread-start: 40px;
--line-start: 40px;
--line-width: 3px;
--line-end: calc(var(--line-start) + var(--line-width));
--line-margin-end: 16px;
--line-radius: 10px;
--line-diameter: calc(var(--line-radius) * 2);
--avatar-size: 50px;
--avatar-margin-start: 16px;
--avatar-margin-end: 12px;
}
.timeline.contextual > li { .timeline.contextual > li {
--width: 3px;
--left: 40px;
--right: calc(var(--left) + var(--width));
background-image: linear-gradient( background-image: linear-gradient(
to right, to right,
transparent, transparent,
transparent var(--left), transparent var(--line-start),
var(--comment-line-color) var(--left), var(--comment-line-color) var(--line-start),
var(--comment-line-color) var(--right), var(--comment-line-color) var(--line-end),
transparent var(--right), transparent var(--line-end),
transparent transparent
); );
background-repeat: no-repeat; background-repeat: no-repeat;
@ -182,41 +196,127 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
> .status-link > .status-link
+ .replies + .replies
> summary { > summary {
margin-left: calc(50px + 16px + 12px); margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies
> summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies
.replies
> summary {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
} }
.timeline.contextual .timeline.contextual
> li.descendant.thread > li.descendant.thread
> .status-link > .status-link
+ .replies + .replies
.status-link { .status-link {
padding-left: calc(50px + 16px + 12px); padding-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies
.status-link {
padding-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
);
}
.timeline.contextual
> li.descendant.thread
> .status-link
+ .replies
.replies
.replies
.status-link {
padding-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
} }
.timeline.contextual .timeline.contextual
> li.descendant:not(.thread) > li.descendant:not(.thread)
> .status-link > .status-link
+ .replies + .replies
> summary { > summary {
margin-left: calc(40px + 16px); margin-left: calc(var(--thread-start) + var(--line-margin-end));
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.replies
> summary {
margin-left: calc(
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
);
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.replies
.replies
> summary {
margin-left: calc(
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
);
} }
.timeline.contextual .timeline.contextual
> li.descendant:not(.thread) > li.descendant:not(.thread)
> .status-link > .status-link
+ .replies + .replies
.status-link { .status-link {
padding-left: calc(40px + 16px); padding-left: calc(var(--thread-start) + var(--line-margin-end));
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.replies
.status-link {
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 2));
}
.timeline.contextual
> li.descendant:not(.thread)
> .status-link
+ .replies
.replies
.replies
.status-link {
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 3));
} }
.timeline.contextual > li.descendant:not(.thread):before { .timeline.contextual > li.descendant:not(.thread):before {
--radius: 10px;
--diameter: calc(var(--radius) * 2);
content: ''; content: '';
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 40px; left: var(--line-start);
width: var(--diameter); width: var(--line-diameter);
height: var(--diameter); height: var(--line-diameter);
border-radius: var(--radius); border-radius: var(--line-radius);
border-style: solid; border-style: solid;
border-width: var(--width); border-width: var(--line-width);
border-color: transparent transparent var(--comment-line-color) transparent; border-color: transparent transparent var(--comment-line-color) transparent;
transform: rotate(45deg); transform: rotate(45deg);
} }
@ -228,7 +328,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
font-size: 90%; font-size: 90%;
} }
.timeline.contextual > li.thread > .status-link .replies-link { .timeline.contextual > li.thread > .status-link .replies-link {
margin-left: calc(50px + 16px + 12px); margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
} }
.timeline.contextual > li .replies-link * { .timeline.contextual > li .replies-link * {
vertical-align: middle; vertical-align: middle;
@ -241,22 +343,33 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.timeline.contextual > li .replies summary { .timeline.contextual > li .replies > summary {
padding: 8px 16px; padding: 8px;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
display: inline-block; display: inline-block;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
font-weight: bold; font-weight: 500;
font-size: 12px; font-size: 12px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
user-select: none; user-select: none;
box-shadow: 0 0 0 2px var(--bg-color); box-shadow: 0 0 0 2px var(--bg-color);
position: relative; position: relative;
list-style: none;
white-space: nowrap;
} }
.timeline.contextual > li .replies summary:active, .timeline.contextual > li .replies > summary::-webkit-details-marker {
.timeline.contextual > li .replies[open] summary { display: none;
}
.timeline.contextual > li .replies > summary > * {
vertical-align: middle;
}
.timeline.contextual > li .replies > summary .avatars {
margin-right: 8px;
}
.timeline.contextual > li .replies > summary:active,
.timeline.contextual > li .replies[open] > summary {
color: var(--text-color); color: var(--text-color);
background-color: var(--comment-line-color); background-color: var(--comment-line-color);
background-image: linear-gradient( background-image: linear-gradient(
@ -265,7 +378,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--bg-faded-color) var(--bg-faded-color)
); );
} }
.timeline.contextual > li .replies[open] summary { .timeline.contextual > li .replies[open] > summary {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
.timeline.contextual > li .replies summary[hidden] { .timeline.contextual > li .replies summary[hidden] {
@ -275,52 +388,89 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
position: relative; position: relative;
} }
.timeline.contextual > li .replies li { .timeline.contextual > li .replies li {
--width: 3px; --line-start: calc(var(--thread-start) + var(--line-margin-end));
--left: calc(40px + 16px); --line-end: calc(var(--line-start) + var(--line-width));
--right: calc(var(--left) + var(--width));
background-image: linear-gradient( background-image: linear-gradient(
to right, to right,
transparent, transparent,
transparent var(--left), transparent var(--line-start),
var(--comment-line-color) var(--left), var(--comment-line-color) var(--line-start),
var(--comment-line-color) var(--right), var(--comment-line-color) var(--line-end),
transparent var(--right), transparent var(--line-end),
transparent transparent
); );
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.timeline.contextual > li .replies .replies li {
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 2));
}
.timeline.contextual > li .replies .replies .replies li {
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 3));
}
.timeline.contextual > li.thread .replies li { .timeline.contextual > li.thread .replies li {
--left: calc(50px + 16px + 12px); --line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
}
.timeline.contextual > li.thread .replies .replies li {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
);
}
.timeline.contextual > li.thread .replies .replies .replies li {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
} }
.timeline.contextual > li .replies li:last-child { .timeline.contextual > li .replies li:last-child {
background-size: 100% 20px; background-size: 100% 20px;
} }
.timeline.contextual > li .replies li:before { .timeline.contextual > li .replies li:before {
--radius: 10px;
--diameter: calc(var(--radius) * 2);
content: ''; content: '';
position: absolute; position: absolute;
top: 10px; top: 10px;
left: calc(40px + 16px); left: var(--line-start);
width: var(--diameter); width: var(--line-diameter);
height: var(--diameter); height: var(--line-diameter);
border-radius: var(--radius); border-radius: var(--line-radius);
border-style: solid; border-style: solid;
border-width: var(--width); border-width: var(--line-width);
border-color: transparent transparent var(--comment-line-color) transparent; border-color: transparent transparent var(--comment-line-color) transparent;
transform: rotate(45deg); transform: rotate(45deg);
} }
.timeline.contextual > li .replies .replies li:before {
--line-start: calc(
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
);
}
.timeline.contextual > li .replies .replies .replies li:before {
--line-start: calc(
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
);
}
.timeline.contextual > li.thread .replies li:before { .timeline.contextual > li.thread .replies li:before {
left: calc(50px + 16px + 12px); --line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
}
.timeline.contextual > li.thread .replies .replies li:before {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
var(--line-margin-end)
);
}
.timeline.contextual > li.thread .replies .replies .replies li:before {
--line-start: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
(var(--line-margin-end) * 2)
);
} }
.timeline.contextual.loading > li:not(.hero) { .timeline.contextual.loading > li:not(.hero) {
opacity: 0.5; /* opacity: 0.5; */
pointer-events: none; pointer-events: none;
/* background-image: none !important; */
} }
/* .timeline.contextual.loading > li:not(.hero):before {
content: none !important;
} */
.timeline-deck.compact .status { .timeline-deck.compact .status {
max-height: max(25vh, 160px); max-height: max(25vh, 160px);
@ -364,10 +514,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out; animation: appear 0.2s ease-out;
} }
.status-link:is(:hover, :focus) { :is(.status-link, .status-focus):is(:focus, .is-active) {
background-color: var(--link-bg-hover-color); background-color: var(--link-bg-hover-color);
outline-offset: -2px; outline-offset: -2px;
} }
@media (hover: hover) {
.status-link:hover {
background-color: var(--link-bg-hover-color);
outline-offset: -2px;
}
}
.status-link:active:not(:has(:is(.media, button):active)) { .status-link:active:not(:has(:is(.media, button):active)) {
filter: brightness(0.95); filter: brightness(0.95);
} }
@ -467,9 +623,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
overflow: hidden; overflow: hidden;
box-shadow: 0 1px var(--bg-color); box-shadow: 0 1px var(--bg-color);
} }
.status-boost-link:is(:hover, :focus) { .status-boost-link::focus {
background-color: var(--link-bg-hover-color); background-color: var(--link-bg-hover-color);
} }
@media (hover: hover) {
.status-boost-link:hover {
background-color: var(--link-bg-hover-color);
}
}
.status-boost-link:active:not(:has(:is(.media, button):active)) { .status-boost-link:active:not(:has(:is(.media, button):active)) {
filter: brightness(0.95); filter: brightness(0.95);
} }
@ -508,11 +669,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-width: 40em; max-width: 40em;
} }
.decks {
flex-grow: 1;
width: 100%;
}
.deck-close { .deck-close {
color: var(--text-insignificant-color) !important; color: var(--text-insignificant-color) !important;
} }
@ -582,6 +738,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
scrollbar-width: none; scrollbar-width: none;
overscroll-behavior: contain; overscroll-behavior: contain;
touch-action: pan-x; touch-action: pan-x;
user-select: none;
width: 100%;
} }
.carousel::-webkit-scrollbar { .carousel::-webkit-scrollbar {
display: none; display: none;
@ -593,7 +751,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
background-color: var(--average-color-alpha); background-color: var(--average-color-alpha);
@ -606,7 +764,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
} }
.carousel > * :is(img, video) { .carousel > * :is(img, video) {
width: auto; width: auto;
max-width: 100vw; max-width: 100%;
height: auto; height: auto;
max-height: 100vh; max-height: 100vh;
max-height: 100dvh; max-height: 100dvh;
@ -621,10 +779,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: 0; top: 0;
top: env(safe-area-inset-top, 0); top: env(safe-area-inset-top, 0);
} }
.carousel-controls {
bottom: 0;
bottom: env(safe-area-inset-bottom, 0);
}
:is(.carousel-top-controls, .carousel-controls) { :is(.carousel-top-controls, .carousel-controls) {
position: fixed; position: fixed;
left: 0; left: 0;
@ -654,26 +808,23 @@ button.carousel-dot {
} }
.carousel-dots { .carousel-dots {
border-radius: 999px; border-radius: 999px;
backdrop-filter: blur(12px) invert(0.25) brightness(1.5); backdrop-filter: blur(12px) invert(0.25);
}
@media (prefers-color-scheme: dark) {
.carousel-dots {
backdrop-filter: blur(12px) brightness(0.5);
}
} }
button.carousel-dot { button.carousel-dot {
color: var(--text-insignificant-color) !important; color: var(--text-insignificant-color) !important;
font-weight: bold; font-weight: bold;
backdrop-filter: none !important; backdrop-filter: none !important;
transition: all 0.2s;
} }
button.carousel-dot:is(:hover, :focus) button.carousel-dot.active, button.carousel-dot[disabled] {
button.carousel-dot[disabled].active { pointer-events: none;
color: var(--link-color) !important;
} }
button.carousel-dot.active, button.carousel-dot:is(:hover, :focus, .active, [disabled].active) {
button.carousel-dot[disabled].active { color: var(--button-text-color) !important;
}
button.carousel-dot:is(.active, [disabled].active) {
opacity: 1; opacity: 1;
transform: scale(2) translateY(-0.5px); transform: scale(2.2) translateY(-0.5px);
} }
@media (hover: hover) { @media (hover: hover) {
.carousel-top-controls { .carousel-top-controls {
@ -681,8 +832,8 @@ button.carousel-dot[disabled].active {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
.carousel-controls { .carousel-controls {
transform: translateY(100%); transform: scale(0);
transition: transform 0.2s ease-in-out; /* transition: transform 0.2s ease-in-out; */
} }
:is(.carousel-top-controls, .carousel-controls)[hidden] { :is(.carousel-top-controls, .carousel-controls)[hidden] {
opacity: 1; opacity: 1;
@ -696,10 +847,33 @@ button.carousel-dot[disabled].active {
transform: translateY(0); transform: translateY(0);
} }
} }
@media (prefers-color-scheme: dark) {
.carousel :is(img, video) { /* CAROUSEL + STATUS PAGE COMBO */
/* No need fade out if inside carousel */
filter: none; .media-post-link .button-label {
display: none;
}
@media (min-width: calc(40em + 350px)) {
.media-post-link .button-label {
display: inline;
}
#modal-container > div,
.status-deck {
transition: all 0.3s ease-in-out;
}
/* Don't do this if there's a modal sheet (.sheet) */
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
.status-deck {
width: 350px;
min-width: 0;
}
:has(#modal-container .carousel):has(.status-deck):not(:has(.sheet))
#modal-container
> div {
left: 0;
right: 350px;
width: auto;
} }
} }
@ -806,54 +980,30 @@ button.carousel-dot[disabled].active {
/* MENU POPUP */ /* MENU POPUP */
.menu-container { .szh-menu {
position: relative; padding: 8px 0 !important;
}
.menu-container button {
color: inherit !important;
}
.menu-container button:is(:hover, :active, :focus) {
background-color: var(--button-plain-bg-hover-color);
}
.menu-container menu {
position: absolute;
right: 0;
top: 0;
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
padding: 8px 0;
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
background-color: var(--bg-color); background-color: var(--bg-color) !important;
width: 10em; border: 1px solid var(--outline-color) !important;
list-style: none;
z-index: 100;
border: 1px solid var(--outline-color);
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease-in-out; box-shadow: 0 3px 6px var(--drop-shadow-color);
box-shadow: 0 0 8px var(--bg-faded-color), 0 4px 8px var(--bg-faded-color),
0 2px 4px var(--bg-faded-color);
}
.menu-container menu li {
margin: 0;
padding: 0;
list-style: none;
}
.menu-container > button:is(:hover, :active, :focus) + menu,
.menu-container menu:is(:hover, :active) {
opacity: 1;
pointer-events: auto;
}
.menu-container menu button {
width: 100%;
text-align: left; text-align: left;
color: var(--text-color) !important; animation: appear 0.15s ease-in-out;
border-radius: 0;
} }
.menu-container menu button:is(:hover, :focus) { .szh-menu .szh-menu__item {
color: var(--bg-color) !important; padding: 8px 16px !important;
background-color: var(--link-color); }
.szh-menu .szh-menu__item * {
vertical-align: middle;
}
.szh-menu
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
color: var(--text-color);
}
.szh-menu .szh-menu__item--hover {
color: var(--button-text-color);
background-color: var(--button-bg-color);
} }
/* DONUT METER */ /* DONUT METER */
@ -943,19 +1093,109 @@ meter.donut:is(.danger, .explode):after {
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
} }
/* I'm just feeling bored, so having fun here */
@media (hover: hover) {
.avatars-stack > * {
transition: transform 0.3s ease-in-out;
}
.avatars-stack:hover > *:nth-of-type(odd) {
transform: rotate(15deg);
}
.avatars-stack:hover > *:nth-of-type(even) {
transform: rotate(-15deg);
}
}
.deck-container {
width: 100%;
flex-grow: 1;
}
#home-page ~ .deck-container {
z-index: 10;
position: fixed;
inset: 0;
}
#home-page:has(~ .deck-container) {
display: block;
position: absolute;
user-select: none;
pointer-events: none;
opacity: 0;
content-visibility: hidden;
}
/* TAB BAR */
#tab-bar:not([hidden]) {
position: fixed;
bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom));
width: calc(100% - 32px);
max-width: calc(40em - 32px);
z-index: 100;
display: flex;
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px) saturate(3);
border: var(--hairline-width) solid var(--outline-color);
border-radius: 16px;
box-shadow: 0 8px 32px var(--outline-color);
}
#tab-bar li {
flex-grow: 1;
margin: 0;
padding: 0;
list-style: none;
}
#tab-bar li a {
text-align: center;
padding: 16px 0;
display: block;
}
/* 404 */
#not-found-page {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
cursor: default;
color: var(--text-insignificant-color);
background-image: radial-gradient(
circle at 50% 50%,
var(--bg-color) 25%,
var(--bg-faded-color)
);
text-shadow: 0 1px var(--bg-color);
}
/* ACCOUNT STATUSES */
.header-account {
font-size: 90% !important;
cursor: pointer;
}
.header-account div {
font-weight: normal;
color: var(--text-insignificant-color);
}
@media (min-width: 40em) { @media (min-width: 40em) {
html, html,
body { body {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
} }
.deck-container {
background-color: var(--bg-faded-color);
}
#app { #app {
display: flex; display: flex;
} }
.decks { .deck-container {
transition: transform 0.4s var(--timing-function); transition: transform 0.4s var(--timing-function);
} }
.decks:has(~ .deck-backdrop) { .deck-container:has(~ .deck-backdrop) {
transition: transform 0.4s ease-out; transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0); transform: translate3d(-5vw, 0, 0);
} }
@ -969,25 +1209,25 @@ meter.donut:is(.danger, .explode):after {
background-color: transparent; background-color: transparent;
} }
.timeline-deck > header { .timeline-deck > header {
min-height: 6em; --margin-top: 8px;
min-height: 4em;
top: var(--margin-top);
border-bottom: 0; border-bottom: 0;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
background-image: linear-gradient( background-image: none;
to bottom,
var(--bg-faded-color),
transparent 50%
);
border-bottom: 0; border-bottom: 0;
mask-image: linear-gradient( border-radius: 16px;
rgba(0, 0, 0, 1) 50%, margin-inline: 8px;
rgba(0, 0, 0, 0.7) 80%, }
rgba(0, 0, 0, 0.5) 90%, .timeline-deck > header[hidden] {
transparent transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
);
} }
.deck > header h1 { .deck > header h1 {
font-size: 1.5em; font-size: 1.5em;
} }
.updates-button {
margin-top: 24px;
}
.timeline-deck .timeline:not(.flat) > li { .timeline-deck .timeline:not(.flat) > li {
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
margin: 16px 0; margin: 16px 0;
@ -995,15 +1235,32 @@ meter.donut:is(.danger, .explode):after {
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: 0px 1px var(--bg-blur-color); box-shadow: 0px 1px var(--bg-blur-color);
transition: transform 0.4s var(--timing-function);
--back-transition: transform 0.4s ease-out;
}
.timeline-deck .timeline:not(.flat) > li:has(.status-link.is-active) {
transition: var(--back-transition);
transform: translate3d(-2.5vw, 0, 0);
}
.timeline-deck
.timeline:not(.flat)
> li:not(:has(.boost-carousel)):has(+ li .status-link.is-active),
.timeline-deck
.timeline:not(.flat)
> li:not(:has(.boost-carousel)):has(.status-link.is-active)
+ li {
transition: var(--back-transition);
transform: translate3d(-1.25vw, 0, 0);
} }
.box { .box {
padding: 32px; padding: 32px;
} }
:is(.carousel-top-controls, .carousel-controls) { /* :is(.carousel-top-controls, .carousel-controls) {
padding: 32px; padding: 32px;
} } */
li:has(.boost-carousel) { li:has(.boost-carousel) {
width: 95vw; width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + 20em)); transform: translateX(calc(-50% + 20em));
} }
} }

View file

@ -1,22 +1,37 @@
import './app.css'; import './app.css';
import 'toastify-js/src/toastify.css'; import 'toastify-js/src/toastify.css';
import { createHashHistory } from 'history';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { login } from 'masto'; import { createClient } from 'masto';
import Router, { route } from 'preact-router'; import {
import { useEffect, useLayoutEffect, useState } from 'preact/hooks'; useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Toastify from 'toastify-js'; import Toastify from 'toastify-js';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Account from './components/account'; import Account from './components/account';
import Compose from './components/compose'; import Compose from './components/compose';
import Drafts from './components/drafts'; import Drafts from './components/drafts';
import Icon from './components/icon';
import Link from './components/link';
import Loader from './components/loader'; import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal'; import Modal from './components/modal';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites';
import Hashtags from './pages/hashtags';
import Home from './pages/home'; import Home from './pages/home';
import Lists from './pages/lists';
import Login from './pages/login'; import Login from './pages/login';
import Notifications from './pages/notifications'; import Notifications from './pages/notifications';
import Public from './pages/public';
import Settings from './pages/settings'; import Settings from './pages/settings';
import Status from './pages/status'; import Status from './pages/status';
import Welcome from './pages/welcome'; import Welcome from './pages/welcome';
@ -24,14 +39,13 @@ import { getAccessToken } from './utils/auth';
import states, { saveStatus } from './utils/states'; import states, { saveStatus } from './utils/states';
import store from './utils/store'; import store from './utils/store';
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
window.__STATES__ = states; window.__STATES__ = states;
function App() { function App() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading'); const [uiState, setUIState] = useState('loading');
const navigate = useNavigate();
useLayoutEffect(() => { useLayoutEffect(() => {
const theme = store.local.get('theme'); const theme = store.local.get('theme');
@ -67,11 +81,9 @@ function App() {
const { access_token: accessToken } = tokenJSON; const { access_token: accessToken } = tokenJSON;
store.session.set('accessToken', accessToken); store.session.set('accessToken', accessToken);
window.masto = await login({ initMasto({
url: `https://${instanceURL}`, url: `https://${instanceURL}`,
accessToken, accessToken,
disableVersionCheck: true,
timeout: 30_000,
}); });
const mastoAccount = await masto.v1.accounts.verifyCredentials(); const mastoAccount = await masto.v1.accounts.verifyCredentials();
@ -105,41 +117,35 @@ function App() {
const instanceURL = account.instanceURL; const instanceURL = account.instanceURL;
const accessToken = account.accessToken; const accessToken = account.accessToken;
store.session.set('currentAccount', account.info.id); store.session.set('currentAccount', account.info.id);
if (accessToken) setIsLoggedIn(true);
(async () => { initMasto({
try { url: `https://${instanceURL}`,
setUIState('loading'); accessToken,
window.masto = await login({ });
url: `https://${instanceURL}`,
accessToken,
disableVersionCheck: true,
timeout: 30_000,
});
setIsLoggedIn(true);
} catch (e) {
setIsLoggedIn(false);
}
setUIState('default');
})();
} else { } else {
setUIState('default'); setUIState('default');
} }
}, []); }, []);
const [currentDeck, setCurrentDeck] = useState('home'); let location = useLocation();
const [currentModal, setCurrentModal] = useState(null); states.currentLocation = location.pathname;
const locationDeckMap = {
'/': 'home-page',
'/notifications': 'notifications-page',
};
const focusDeck = () => { const focusDeck = () => {
if (currentModal) return;
let timer = setTimeout(() => { let timer = setTimeout(() => {
const page = document.getElementById(`${currentDeck}-page`); const page = document.getElementById(locationDeckMap[location.pathname]);
console.debug('FOCUS', currentDeck, page); console.debug('FOCUS', location.pathname, page);
if (page) { if (page) {
page.focus(); page.focus();
} }
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}; };
useEffect(focusDeck, [currentDeck, currentModal]); useEffect(focusDeck, [location]);
useEffect(() => { useEffect(() => {
if ( if (
!snapStates.showCompose && !snapStates.showCompose &&
@ -153,64 +159,80 @@ function App() {
useEffect(() => { useEffect(() => {
// HACK: prevent this from running again due to HMR // HACK: prevent this from running again due to HMR
if (states.init) return; if (states.init) return;
if (isLoggedIn) { if (isLoggedIn) {
requestAnimationFrame(() => { requestAnimationFrame(startVisibility);
startStream();
startVisibility();
// Collect instance info
(async () => {
const info = await masto.v1.instances.fetch();
console.log(info);
const { uri, domain } = info;
const instances = store.local.getJSON('instances') || {};
instances[(domain || uri).toLowerCase()] = info;
store.local.setJSON('instances', instances);
})();
});
states.init = true; states.init = true;
} }
}, [isLoggedIn]); }, [isLoggedIn]);
const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage = /^\/s\//i.test(location.pathname);
if (isModalPage) {
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
} else {
backgroundLocation.current = null;
}
console.debug({
backgroundLocation: backgroundLocation.current,
location,
});
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome|p)/.test(pathname);
}, [location]);
return ( return (
<> <>
{isLoggedIn && currentDeck && ( <Routes location={nonRootLocation || location}>
<div class="decks"> <Route
{/* Home will never be unmounted */} path="/"
<Home hidden={currentDeck !== 'home'} /> element={
{/* Notifications can be unmounted */} isLoggedIn ? (
{currentDeck === 'notifications' && <Notifications />} <Home />
</div> ) : uiState === 'loading' ? (
)} <Loader />
{!isLoggedIn && uiState === 'loading' && <Loader />} ) : (
<Router <Welcome />
history={createHashHistory()} )
onChange={(e) => {
console.debug('ROUTER onChange', e);
// Special handling for Home and Notifications
const { url } = e;
if (/notifications/i.test(url)) {
setCurrentDeck('notifications');
setCurrentModal(null);
} else if (url === '/') {
setCurrentDeck('home');
document.title = `Home / ${CLIENT_NAME}`;
setCurrentModal(null);
} else if (/^\/s\//i.test(url)) {
setCurrentModal('status');
} else {
setCurrentModal(null);
setCurrentDeck(null);
} }
states.history.push(url); />
}} <Route path="/login" element={<Login />} />
> <Route path="/welcome" element={<Welcome />} />
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />} </Routes>
<Welcome path="/welcome" /> <Routes location={backgroundLocation.current || location}>
{isLoggedIn && <Status path="/s/:id" />} {isLoggedIn && (
<Login path="/login" /> <Route path="/notifications" element={<Notifications />} />
</Router> )}
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
{isLoggedIn && <Route path="/t/:hashtag" element={<Hashtags />} />}
{isLoggedIn && <Route path="/a/:id" element={<AccountStatuses />} />}
<Route path="/p/l?/:instance" element={<Public />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
<Routes>
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
</Routes>
<nav id="tab-bar" hidden>
<li>
<Link to="/">
<Icon icon="home" alt="Home" size="xl" />
</Link>
</li>
<li>
<Link to="/notifications">
<Icon icon="notification" alt="Notifications" size="xl" />
</Link>
</li>
<li>
<Link to="/bookmarks">
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
</Link>
</li>
</nav>
{!!snapStates.showCompose && ( {!!snapStates.showCompose && (
<Modal> <Modal>
<Compose <Compose
@ -244,7 +266,8 @@ function App() {
// destination: `/#/s/${newStatus.id}`, // destination: `/#/s/${newStatus.id}`,
onClick: () => { onClick: () => {
toast.hideToast(); toast.hideToast();
route(`/s/${newStatus.id}`); states.prevLocation = location;
navigate(`/s/${newStatus.id}`);
}, },
}); });
toast.showToast(); toast.showToast();
@ -278,7 +301,12 @@ function App() {
} }
}} }}
> >
<Account account={snapStates.showAccount} /> <Account
account={snapStates.showAccount}
onClose={() => {
states.showAccount = false;
}}
/>
</Modal> </Modal>
)} )}
{!!snapStates.showDrafts && ( {!!snapStates.showDrafts && (
@ -292,24 +320,109 @@ function App() {
<Drafts /> <Drafts />
</Modal> </Modal>
)} )}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
</> </>
); );
} }
function initMasto(params) {
const clientParams = {
url: params.url || 'https://mastodon.social',
accessToken: params.accessToken || null,
disableVersionCheck: true,
timeout: 30_000,
};
window.masto = createClient(clientParams);
(async () => {
// Request v2, fallback to v1 if fail
let info;
try {
info = await masto.v2.instance.fetch();
} catch (e) {}
if (!info) {
try {
info = await masto.v1.instances.fetch();
} catch (e) {}
}
if (!info) return;
console.log(info);
const {
// v1
uri,
urls: { streamingApi } = {},
// v2
domain,
configuration: { urls: { streaming } = {} } = {},
} = info;
if (uri || domain) {
const instances = store.local.getJSON('instances') || {};
instances[
(domain || uri)
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '')
.toLowerCase()
] = info;
store.local.setJSON('instances', instances);
}
if (streamingApi || streaming) {
window.masto = createClient({
...clientParams,
streamingApiUrl: streaming || streamingApi,
});
}
})();
}
let ws;
async function startStream() { async function startStream() {
if (
ws &&
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
) {
return;
}
const stream = await masto.v1.stream.streamUser(); const stream = await masto.v1.stream.streamUser();
console.log('STREAM START', { stream }); console.log('STREAM START', { stream });
ws = stream.ws;
const handleNewStatus = debounce((status) => { const handleNewStatus = debounce((status) => {
console.log('UPDATE', status); console.log('UPDATE', status);
const inHomeNew = states.homeNew.find((s) => s.id === status.id); const inHomeNew = states.homeNew.find((s) => s.id === status.id);
const inHome = states.home.find((s) => s.id === status.id); const inHome = status.id === states.homeLast?.id;
if (!inHomeNew && !inHome) { if (!inHomeNew && !inHome) {
states.homeNew.unshift({ if (states.settings.boostsCarousel && status.reblog) {
id: status.id, // do nothing
reblog: status.reblog?.id, } else {
reply: !!status.inReplyToAccountId, states.homeNew.unshift({
}); id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
});
console.log('homeNew 1', [...states.homeNew]);
}
} }
saveStatus(status); saveStatus(status);
@ -331,9 +444,7 @@ async function startStream() {
const inNotificationsNew = states.notificationsNew.find( const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id, (n) => n.id === notification.id,
); );
const inNotifications = states.notifications.find( const inNotifications = notification.id === states.notificationLast?.id;
(n) => n.id === notification.id,
);
if (!inNotificationsNew && !inNotifications) { if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification); states.notificationsNew.unshift(notification);
} }
@ -343,10 +454,9 @@ async function startStream() {
stream.ws.onclose = () => { stream.ws.onclose = () => {
console.log('STREAM CLOSED!'); console.log('STREAM CLOSED!');
if (document.visibilityState !== 'hidden') {
requestAnimationFrame(() => {
startStream(); startStream();
}); }
}; };
return { return {
@ -357,38 +467,38 @@ async function startStream() {
}; };
} }
let lastHidden;
function startVisibility() { function startVisibility() {
const handleVisibilityChange = () => { const handleVisible = (visible) => {
if (document.visibilityState === 'hidden') { if (!visible) {
const timestamp = Date.now(); const timestamp = Date.now();
store.session.set('lastHidden', timestamp); lastHidden = timestamp;
} else { } else {
const timestamp = Date.now(); const timestamp = Date.now();
const lastHidden = store.session.get('lastHidden');
const diff = timestamp - lastHidden; const diff = timestamp - lastHidden;
const diffMins = Math.round(diff / 1000 / 60); const diffMins = Math.round(diff / 1000 / 60);
if (diffMins > 1) { console.log(`visible: ${visible}`, { lastHidden, diffMins });
console.log('visible', { lastHidden, diffMins }); if (!lastHidden || diffMins > 1) {
setTimeout(() => { (async () => {
// Buffer for WS reconnect try {
(async () => { const firstStatusID = states.homeLast?.id;
try { const firstNotificationID = states.notificationsLast?.id;
const firstStatusID = states.home[0]?.id; const fetchHome = masto.v1.timelines.listHome({
const firstNotificationID = states.notifications[0]?.id; limit: 5,
const fetchHome = masto.v1.timelines.listHome({ ...(firstStatusID && { sinceId: firstStatusID }),
limit: 1, });
...(firstStatusID && { sinceId: firstStatusID }), const fetchNotifications = masto.v1.notifications.list({
}); limit: 1,
const fetchNotifications = masto.v1.notifications.list({ ...(firstNotificationID && { sinceId: firstNotificationID }),
limit: 1, });
...(firstNotificationID && { sinceId: firstNotificationID }),
});
const newStatuses = await fetchHome; const newStatuses = await fetchHome;
if ( const hasOneAndReblog =
newStatuses.length && newStatuses.length === 1 && newStatuses?.[0]?.reblog;
newStatuses[0].id !== states.home[0].id if (newStatuses.length) {
) { if (states.settings.boostsCarousel && hasOneAndReblog) {
// do nothing
} else {
states.homeNew = newStatuses.map((status) => { states.homeNew = newStatuses.map((status) => {
saveStatus(status); saveStatus(status);
return { return {
@ -397,33 +507,42 @@ function startVisibility() {
reply: !!status.inReplyToAccountId, reply: !!status.inReplyToAccountId,
}; };
}); });
console.log('homeNew 2', [...states.homeNew]);
} }
const newNotifications = await fetchNotifications;
if (newNotifications.length) {
const notification = newNotifications[0];
const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id,
);
const inNotifications = states.notifications.find(
(n) => n.id === notification.id,
);
if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification);
}
saveStatus(notification.status, { override: false });
}
} catch (e) {
// Silently fail
console.error(e);
} }
})();
}, 100); const newNotifications = await fetchNotifications;
if (newNotifications.length) {
const notification = newNotifications[0];
const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id,
);
const inNotifications =
notification.id === states.notificationLast?.id;
if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification);
}
saveStatus(notification.status, { override: false });
}
} catch (e) {
// Silently fail
console.error(e);
} finally {
startStream();
}
})();
} }
} }
}; };
const handleVisibilityChange = () => {
const hidden = document.visibilityState === 'hidden';
handleVisible(!hidden);
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
};
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
requestAnimationFrame(handleVisibilityChange);
return { return {
stop: () => { stop: () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);

View file

@ -20,10 +20,20 @@
#account-container .stats { #account-container .stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
column-gap: 16px; justify-content: space-around;
row-gap: 4px; gap: 16px;
opacity: 0.75; opacity: 0.75;
font-size: 90%; font-size: 90%;
background-color: var(--bg-faded-color);
padding: 12px;
border-radius: 8px;
line-height: 1.25;
}
#account-container .stats > * {
text-align: center;
}
#account-container .stats a {
color: inherit;
} }
#account-container .actions { #account-container .actions {
@ -70,3 +80,12 @@
#account-container .profile-field p { #account-container .profile-field p {
margin: 0; margin: 0;
} }
#account-container .common-followers {
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
padding: 8px 0;
font-size: 90%;
line-height: 1.5;
color: var(--text-insignificant-color);
}

View file

@ -2,15 +2,19 @@ import './account.css';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import handleContentLinks from '../utils/handle-content-links';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import Avatar from './avatar'; import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import Link from './link';
import NameText from './name-text'; import NameText from './name-text';
function Account({ account }) { function Account({ account, onClose }) {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account); const [info, setInfo] = useState(isString ? null : account);
@ -27,12 +31,29 @@ function Account({ account }) {
setInfo(info); setInfo(info);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
alert(e); try {
setUIState('error'); const result = await masto.v2.search({
q: account,
type: 'accounts',
limit: 1,
resolve: true,
});
if (result.accounts.length) {
setInfo(result.accounts[0]);
setUIState('default');
return;
}
setUIState('error');
} catch (err) {
console.error(err);
setUIState('error');
}
} }
})(); })();
} else {
setInfo(account);
} }
}, []); }, [account]);
const { const {
acct, acct,
@ -59,6 +80,7 @@ function Account({ account }) {
const [relationshipUIState, setRelationshipUIState] = useState('default'); const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null); const [relationship, setRelationship] = useState(null);
const [familiarFollowers, setFamiliarFollowers] = useState([]);
useEffect(() => { useEffect(() => {
if (info) { if (info) {
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
@ -67,14 +89,29 @@ function Account({ account }) {
return; return;
} }
setRelationshipUIState('loading'); setRelationshipUIState('loading');
setFamiliarFollowers([]);
(async () => { (async () => {
const fetchRelationships = masto.v1.accounts.fetchRelationships([id]);
const fetchFamiliarFollowers =
masto.v1.accounts.fetchFamiliarFollowers(id);
try { try {
const relationships = await masto.v1.accounts.fetchRelationships([ const relationships = await fetchRelationships;
id,
]);
console.log('fetched relationship', relationships); console.log('fetched relationship', relationships);
if (relationships.length) { if (relationships.length) {
setRelationship(relationships[0]); const relationship = relationships[0];
setRelationship(relationship);
if (!relationship.following) {
try {
const followers = await fetchFamiliarFollowers;
console.log('fetched familiar followers', followers);
setFamiliarFollowers(followers[0].accounts.slice(0, 10));
} catch (e) {
console.error(e);
}
}
} }
setRelationshipUIState('default'); setRelationshipUIState('default');
} catch (e) { } catch (e) {
@ -104,7 +141,17 @@ function Account({ account }) {
id="account-container" id="account-container"
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`} class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
> >
{!info || uiState === 'loading' ? ( {uiState === 'error' && (
<div class="ui-state">
<p>Unable to load account.</p>
<p>
<a href={account} target="_blank">
Go to account page <Icon icon="external" />
</a>
</p>
</div>
)}
{uiState === 'loading' ? (
<> <>
<header> <header>
<Avatar size="xxxl" /> <Avatar size="xxxl" />
@ -123,133 +170,174 @@ function Account({ account }) {
</main> </main>
</> </>
) : ( ) : (
<> info && (
<header> <>
<Avatar url={avatar} size="xxxl" /> <header>
<NameText account={info} showAcct external /> <Avatar url={avatar} size="xxxl" />
</header> <NameText account={info} showAcct external />
<main tabIndex="-1"> </header>
{bot && ( <main tabIndex="-1">
<> {bot && (
<span class="tag"> <>
<Icon icon="bot" /> Automated <span class="tag">
</span> <Icon icon="bot" /> Automated
</> </span>
)} </>
<div
class="note"
dangerouslySetInnerHTML={{
__html: enhanceContent(note, { emojis }),
}}
/>
{fields?.length > 0 && (
<div class="profile-metadata">
{fields.map(({ name, value, verifiedAt }) => (
<div
class={`profile-field ${
verifiedAt ? 'profile-verified' : ''
}`}
key={name}
>
<b>
{name}{' '}
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
</b>
<p
dangerouslySetInnerHTML={{
__html: value,
}}
/>
</div>
))}
</div>
)}
<p class="stats">
<span>
<b title={statusesCount}>{shortenNumber(statusesCount)}</b>{' '}
Posts
</span>
<span>
<b title={followingCount}>{shortenNumber(followingCount)}</b>{' '}
Following
</span>
<span>
<b title={followersCount}>{shortenNumber(followersCount)}</b>{' '}
Followers
</span>
{!!createdAt && (
<span>
Joined:{' '}
<b>
<time datetime={createdAt}>
{Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(createdAt))}
</time>
</b>
</span>
)} )}
</p> <div
<p class="actions"> class="note"
{followedBy ? <span class="tag">Following you</span> : <span />}{' '} onClick={handleContentLinks()}
{relationshipUIState !== 'loading' && relationship && ( dangerouslySetInnerHTML={{
<button __html: enhanceContent(note, { emojis }),
type="button" }}
class={`${following || requested ? 'light swap' : ''}`} />
data-swap-state={following || requested ? 'danger' : ''} {fields?.length > 0 && (
disabled={relationshipUIState === 'loading'} <div class="profile-metadata">
onClick={() => { {fields.map(({ name, value, verifiedAt }) => (
setRelationshipUIState('loading'); <div
(async () => { class={`profile-field ${
try { verifiedAt ? 'profile-verified' : ''
let newRelationship; }`}
if (following || requested) { key={name}
const yes = confirm( >
requested <b>
? 'Are you sure that you want to withdraw follow request?' <span
: 'Are you sure that you want to unfollow this account?', dangerouslySetInnerHTML={{
); __html: emojifyText(name, emojis),
if (yes) { }}
newRelationship = await masto.v1.accounts.unfollow( />{' '}
{!!verifiedAt && <Icon icon="check-circle" size="s" />}
</b>
<p
dangerouslySetInnerHTML={{
__html: enhanceContent(value, { emojis }),
}}
/>
</div>
))}
</div>
)}
<p class="stats">
<Link to={`/a/${id}`} onClick={onClose}>
Posts
<br />
<b title={statusesCount}>
{shortenNumber(statusesCount)}
</b>{' '}
</Link>
<span>
Following
<br />
<b title={followingCount}>
{shortenNumber(followingCount)}
</b>{' '}
</span>
<span>
Followers
<br />
<b title={followersCount}>
{shortenNumber(followersCount)}
</b>{' '}
</span>
{!!createdAt && (
<span>
Joined
<br />
<b>
<time datetime={createdAt}>
{Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(createdAt))}
</time>
</b>
</span>
)}
</p>
{familiarFollowers?.length > 0 && (
<p class="common-followers">
Common followers{' '}
<span class="ib">
{familiarFollowers.map((follower) => (
<a
href={follower.url}
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = follower;
}}
>
<Avatar
url={follower.avatarStatic}
size="l"
alt={`${follower.displayName} @${follower.acct}`}
/>
</a>
))}
</span>
</p>
)}
<p class="actions">
{followedBy ? <span class="tag">Following you</span> : <span />}{' '}
{relationshipUIState !== 'loading' && relationship && (
<button
type="button"
class={`${following || requested ? 'light swap' : ''}`}
data-swap-state={following || requested ? 'danger' : ''}
disabled={relationshipUIState === 'loading'}
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
let newRelationship;
if (following || requested) {
const yes = confirm(
requested
? 'Are you sure that you want to withdraw follow request?'
: 'Are you sure that you want to unfollow this account?',
);
if (yes) {
newRelationship =
await masto.v1.accounts.unfollow(id);
}
} else {
newRelationship = await masto.v1.accounts.follow(
id, id,
); );
} }
} else { if (newRelationship) setRelationship(newRelationship);
newRelationship = await masto.v1.accounts.follow(id); setRelationshipUIState('default');
} catch (e) {
alert(e);
setRelationshipUIState('error');
} }
if (newRelationship) setRelationship(newRelationship); })();
setRelationshipUIState('default'); }}
} catch (e) { >
alert(e); {following ? (
setRelationshipUIState('error'); <>
} <span>Following</span>
})(); <span>Unfollow</span>
}} </>
> ) : requested ? (
{following ? ( <>
<> <span>Requested</span>
<span>Following</span> <span>Withdraw</span>
<span>Unfollow</span> </>
</> ) : locked ? (
) : requested ? ( <>
<> <Icon icon="lock" /> <span>Follow</span>
<span>Requested</span> </>
<span>Withdraw</span> ) : (
</> 'Follow'
) : locked ? ( )}
<> </button>
<Icon icon="lock" /> <span>Follow</span> )}
</> </p>
) : ( </main>
'Follow' </>
)} )
</button>
)}
</p>
</main>
</>
)} )}
</div> </div>
); );

View file

@ -9,7 +9,7 @@ const SIZES = {
xxxl: 64, xxxl: 64,
}; };
function Avatar({ url, size, alt = '' }) { function Avatar({ url, size, alt = '', ...props }) {
size = SIZES[size] || size || SIZES.m; size = SIZES[size] || size || SIZES.m;
return ( return (
<span <span
@ -19,6 +19,7 @@ function Avatar({ url, size, alt = '' }) {
height: size, height: size,
}} }}
title={alt} title={alt}
{...props}
> >
{!!url && ( {!!url && (
<img src={url} width={size} height={size} alt={alt} loading="lazy" /> <img src={url} width={size} height={size} alt={alt} loading="lazy" />

View file

@ -199,21 +199,22 @@
#compose-container text-expander { #compose-container text-expander {
position: relative; position: relative;
display: block;
} }
#compose-container .text-expander-menu { #compose-container .text-expander-menu {
color: var(--text-color); color: var(--text-color);
background-color: var(--bg-color); background-color: var(--bg-color);
position: absolute; position: absolute;
margin: 0 0 0 -8px; margin-top: 2em;
padding: 0; padding: 0;
list-style: none; list-style: none;
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
/* box-shadow: 0 0 12px var(--outline-color); */ box-shadow: 0 4px 24px var(--drop-shadow-color);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
top: 0 !important;
z-index: 100; z-index: 100;
min-width: 50vw; min-width: 10em;
max-width: 90vw;
} }
#compose-container .text-expander-menu li { #compose-container .text-expander-menu li {
white-space: nowrap; white-space: nowrap;
@ -235,10 +236,16 @@
width: 2.2em; width: 2.2em;
height: 2.2em; height: 2.2em;
} }
#compose-container .text-expander-menu li:is(:hover, :focus) { #compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
color: var(--bg-color); color: var(--bg-color);
background-color: var(--link-color); background-color: var(--link-color);
} }
#compose-container
.text-expander-menu:hover
li[aria-selected]:not(:hover, :focus) {
color: var(--text-color);
background-color: var(--bg-color);
}
#compose-container .media-attachments { #compose-container .media-attachments {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
@ -324,6 +331,16 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
#compose-container .media-sensitive {
padding: 8px;
background-color: var(--bg-blur-color);
border-radius: 8px;
cursor: pointer;
}
#compose-container .media-sensitive > * {
vertical-align: middle;
}
#compose-container form .poll { #compose-container form .poll {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
border-radius: 8px; border-radius: 8px;

View file

@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import stringLength from 'string-length'; import stringLength from 'string-length';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import supportedLanguages from '../data/status-supported-languages'; import supportedLanguages from '../data/status-supported-languages';
@ -17,7 +18,6 @@ import openCompose from '../utils/open-compose';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useInterval from '../utils/useInterval'; import useInterval from '../utils/useInterval';
import visibilityIconsMap from '../utils/visibility-icons-map'; import visibilityIconsMap from '../utils/visibility-icons-map';
@ -45,6 +45,7 @@ const expiryOptions = {
'30 minutes': 30 * 60, '30 minutes': 30 * 60,
'1 hour': 60 * 60, '1 hour': 60 * 60,
'6 hours': 6 * 60 * 60, '6 hours': 6 * 60 * 60,
'12 hours': 12 * 60 * 60,
'1 day': 24 * 60 * 60, '1 day': 24 * 60 * 60,
'3 days': 3 * 24 * 60 * 60, '3 days': 3 * 24 * 60 * 60,
'7 days': 7 * 24 * 60 * 60, '7 days': 7 * 24 * 60 * 60,
@ -62,6 +63,21 @@ const menu = document.createElement('ul');
menu.role = 'listbox'; menu.role = 'listbox';
menu.className = 'text-expander-menu'; menu.className = 'text-expander-menu';
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
const windowMargin = 16;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const { left, width } = entry.boundingClientRect;
const { innerWidth } = window;
if (left + width > innerWidth) {
menu.style.left = innerWidth - width - windowMargin + 'px';
}
}
});
});
observer.observe(menu);
const DEFAULT_LANG = 'en'; const DEFAULT_LANG = 'en';
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js // https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
@ -779,9 +795,10 @@ function Compose({
ref={spoilerTextRef} ref={spoilerTextRef}
type="text" type="text"
name="spoilerText" name="spoilerText"
placeholder="Spoiler text" placeholder="Content warning"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
class="spoiler-text-field" class="spoiler-text-field"
lang={language}
style={{ style={{
opacity: sensitive ? 1 : 0, opacity: sensitive ? 1 : 0,
pointerEvents: sensitive ? 'auto' : 'none', pointerEvents: sensitive ? 'auto' : 'none',
@ -846,6 +863,7 @@ function Compose({
} }
required={mediaAttachments.length === 0} required={mediaAttachments.length === 0}
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
lang={language}
onInput={() => { onInput={() => {
updateCharCount(); updateCharCount();
}} }}
@ -861,6 +879,7 @@ function Compose({
key={id || fileID || i} key={id || fileID || i}
attachment={attachment} attachment={attachment}
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
lang={language}
onDescriptionChange={(value) => { onDescriptionChange={(value) => {
setMediaAttachments((attachments) => { setMediaAttachments((attachments) => {
const newAttachments = [...attachments]; const newAttachments = [...attachments];
@ -876,10 +895,25 @@ function Compose({
/> />
); );
})} })}
<label class="media-sensitive">
<input
name="sensitive"
type="checkbox"
checked={sensitive}
disabled={uiState === 'loading'}
onChange={(e) => {
const sensitive = e.target.checked;
setSensitive(sensitive);
}}
/>{' '}
<span>Mark media as sensitive</span>{' '}
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
</label>
</div> </div>
)} )}
{!!poll && ( {!!poll && (
<Poll <Poll
lang={language}
maxOptions={maxOptions} maxOptions={maxOptions}
maxExpiration={maxExpiration} maxExpiration={maxExpiration}
minExpiration={minExpiration} minExpiration={minExpiration}
@ -934,6 +968,8 @@ function Compose({
return attachments.concat(mediaFiles); return attachments.concat(mediaFiles);
}); });
} }
// Reset
e.target.value = '';
}} }}
/> />
<Icon icon="attachment" /> <Icon icon="attachment" />
@ -1218,6 +1254,7 @@ function CharCountMeter({ maxCharacters = 500 }) {
function MediaAttachment({ function MediaAttachment({
attachment, attachment,
disabled, disabled,
lang,
onDescriptionChange = () => {}, onDescriptionChange = () => {},
onRemove = () => {}, onRemove = () => {},
}) { }) {
@ -1257,6 +1294,7 @@ function MediaAttachment({
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={description || ''} value={description || ''}
lang={lang}
placeholder={ placeholder={
{ {
image: 'Image description', image: 'Image description',
@ -1351,6 +1389,7 @@ function MediaAttachment({
} }
function Poll({ function Poll({
lang,
poll, poll,
disabled, disabled,
onInput = () => {}, onInput = () => {},
@ -1373,6 +1412,7 @@ function Poll({
disabled={disabled} disabled={disabled}
maxlength={maxCharactersPerOption} maxlength={maxCharactersPerOption}
placeholder={`Choice ${i + 1}`} placeholder={`Choice ${i + 1}`}
lang={lang}
onInput={(e) => { onInput={(e) => {
const { value } = e.target; const { value } = e.target;
options[i] = value; options[i] = value;

35
src/components/link.jsx Normal file
View 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;

View 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',
});
}}
>
&bull;
</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>&raquo;
</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
View 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;

View file

@ -2,23 +2,23 @@
.status-reblog { .status-reblog {
background: linear-gradient( background: linear-gradient(
to bottom right, 160deg,
var(--reblog-faded-color), var(--reblog-faded-color),
transparent 160px transparent min(160px, 50%)
); );
} }
.status-reply-to { .status-reply-to {
background: linear-gradient( background: linear-gradient(
to bottom right, 160deg,
var(--reply-to-faded-color), var(--reply-to-faded-color),
transparent 160px transparent min(160px, 50%)
); );
} }
.status-reblog .status-reply-to { .status-reblog .status-reply-to {
background: linear-gradient( background: linear-gradient(
to top left, -20deg,
var(--reply-to-faded-color), var(--reply-to-faded-color),
transparent 160px transparent min(160px, 50%)
); );
} }
.visibility-direct { .visibility-direct {
@ -79,12 +79,26 @@
.status.large.visibility-direct { .status.large.visibility-direct {
background-image: var(--fade-in-out-bg), var(--yellow-stripes); background-image: var(--fade-in-out-bg), var(--yellow-stripes);
} }
@keyframes skeleton-breathe {
0% {
opacity: 1;
}
40% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.status.skeleton { .status.skeleton {
color: var(--outline-color); color: var(--outline-color);
animation: skeleton-breathe 6s linear infinite;
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
contain: layout;
text-rendering: optimizeSpeed;
} }
.status.skeleton > .avatar { .status.skeleton > .avatar {
background-color: var(--outline-color); background-color: var(--outline-color);
} }
@ -188,11 +202,8 @@
~ *:not(.media-container, .card), ~ *:not(.media-container, .card),
.status .content-container.has-spoiler .spoiler ~ .card .meta-container { .status .content-container.has-spoiler .spoiler ~ .card .meta-container {
filter: blur(5px) invert(0.5); filter: blur(5px) invert(0.5);
/* filter: url(#spoiler); */
text-rendering: optimizeSpeed;
image-rendering: crisp-edges; image-rendering: crisp-edges;
image-rendering: pixelated; image-rendering: pixelated;
/* transform: translate3d(-5px, -5px, 0); */
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
contain: layout; contain: layout;
@ -206,15 +217,6 @@
image-rendering: pixelated; image-rendering: pixelated;
animation: none !important; animation: none !important;
} }
/* @media (prefers-color-scheme: dark) {
.status
.content-container.has-spoiler
.spoiler
~ *:not(.media-container, .card),
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
filter: url(#spoiler-dark);
}
} */
.status .content-container.show-spoiler .spoiler { .status .content-container.show-spoiler .spoiler {
border-style: dotted; border-style: dotted;
} }
@ -325,12 +327,41 @@
min-height: 160px; min-height: 160px;
max-height: 60vh; max-height: 60vh;
} }
.status .media { .status .media-container .media {
border-radius: 8px; --media-radius: 16px;
border-radius: var(--media-radius);
overflow: hidden; overflow: hidden;
min-height: 80px; min-height: 80px;
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
} }
/* Special media borders */
.status .media-container.media-eq2 .media:first-of-type {
border-radius: var(--media-radius) 0 0 var(--media-radius);
}
.status .media-container.media-eq2 .media:last-of-type {
border-radius: 0 var(--media-radius) var(--media-radius) 0;
}
.status .media-container.media-eq3 .media:first-of-type {
border-radius: var(--media-radius) 0 0 var(--media-radius);
}
.status .media-container.media-eq3 .media:nth-of-type(2) {
border-radius: 0 var(--media-radius) 0 0;
}
.status .media-container.media-eq3 .media:last-of-type {
border-radius: 0 0 var(--media-radius) 0;
}
.status .media-container.media-eq4 .media:first-of-type {
border-radius: var(--media-radius) 0 0 0;
}
.status .media-container.media-eq4 .media:nth-of-type(2) {
border-radius: 0 var(--media-radius) 0 0;
}
.status .media-container.media-eq4 .media:nth-of-type(3) {
border-radius: 0 0 0 var(--media-radius);
}
.status .media-container.media-eq4 .media:last-of-type {
border-radius: 0 0 var(--media-radius) 0;
}
.status .media:only-child { .status .media:only-child {
grid-area: span 2 / span 2; grid-area: span 2 / span 2;
} }
@ -488,6 +519,63 @@
background-blend-mode: multiply; background-blend-mode: multiply;
} }
.carousel-item {
position: relative;
}
.carousel-item button.media-alt {
position: absolute;
--bottom: 16px;
bottom: var(--bottom);
bottom: calc(var(--bottom) + env(safe-area-inset-bottom));
left: 16px;
left: calc(16px + env(safe-area-inset-left));
text-align: left;
border-radius: 8px;
color: var(--text-color);
padding: 4px 8px 4px 4px;
border: 1px solid var(--outline-color);
box-shadow: 0 4px 16px var(--outline-color);
max-width: min(40em, calc(100% - 32px));
display: flex;
align-items: center;
gap: 4px;
font-size: 90%;
}
.carousel-item button.media-alt .media-alt-desc {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
@media (min-width: 40em) {
.carousel-item button.media-alt .media-alt-desc {
white-space: normal;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
}
.carousel-item button.media-alt[hidden] {
opacity: 0;
}
@media (hover: hover) {
.carousel-item button.media-alt {
opacity: 0;
transform: translateY(var(--bottom)) scale(0.95);
transition: all 0.2s ease-in-out;
}
.carousel-item:hover button.media-alt {
opacity: 1;
transform: translateY(0) scale(1);
}
.carousel-item button.media-alt[hidden] {
opacity: 1;
}
}
/* CARD */ /* CARD */
.card { .card {

View file

@ -1,5 +1,6 @@
import './status.css'; import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem'; import mem from 'mem';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
@ -11,7 +12,6 @@ import {
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import 'swiped-events'; import 'swiped-events';
import useResizeObserver from 'use-resize-observer'; import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -20,19 +20,25 @@ import Loader from '../components/loader';
import Modal from '../components/modal'; import Modal from '../components/modal';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length'; import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import visibilityIconsMap from '../utils/visibility-icons-map'; import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar'; import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import Link from './link';
import Media from './media';
import RelativeTime from './relative-time'; import RelativeTime from './relative-time';
function fetchAccount(id) { function fetchAccount(id) {
return masto.v1.accounts.fetch(id); try {
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
}
} }
const memFetchAccount = mem(fetchAccount); const memFetchAccount = mem(fetchAccount);
@ -146,8 +152,6 @@ function Status({
} }
}; };
const [showMediaModal, setShowMediaModal] = useState(false);
if (reblog) { if (reblog) {
return ( return (
<div class="status-reblog" onMouseEnter={debugHover}> <div class="status-reblog" onMouseEnter={debugHover}>
@ -251,18 +255,14 @@ function Status({
{/* </span> */}{' '} {/* </span> */}{' '}
{size !== 'l' && {size !== 'l' &&
(uri ? ( (uri ? (
<a <Link to={`/s/${id}`} class="time">
href={`#/s/${id}
`}
class="time"
>
<Icon <Icon
icon={visibilityIconsMap[visibility]} icon={visibilityIconsMap[visibility]}
alt={visibility} alt={visibility}
size="s" size="s"
/>{' '} />{' '}
<RelativeTime datetime={createdAtDate} format="micro" /> <RelativeTime datetime={createdAtDate} format="micro" />
</a> </Link>
) : ( ) : (
<span class="time"> <span class="time">
<Icon <Icon
@ -274,7 +274,7 @@ function Status({
</span> </span>
))} ))}
</div> </div>
{!withinContext && size !== 's' && ( {!withinContext && (
<> <>
{inReplyToAccountId === status.account?.id || {inReplyToAccountId === status.account?.id ||
!!snapStates.statusThreadNumber[id] ? ( !!snapStates.statusThreadNumber[id] ? (
@ -324,7 +324,7 @@ function Status({
<p>{spoilerText}</p> <p>{spoilerText}</p>
</div> </div>
<button <button
class="light spoiler" class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -346,37 +346,7 @@ function Status({
lang={language} lang={language}
ref={contentRef} ref={contentRef}
data-read-more={readMoreText} data-read-more={readMoreText}
onClick={(e) => { onClick={handleContentLinks({ mentions })}
let { target } = e;
if (target.parentNode.tagName.toLowerCase() === 'a') {
target = target.parentNode;
}
if (
target.tagName.toLowerCase() === 'a' &&
target.classList.contains('u-url')
) {
e.preventDefault();
e.stopPropagation();
const username = (
target.querySelector('span') || target
).innerText
.trim()
.replace(/^@/, '');
const url = target.getAttribute('href');
const mention = mentions.find(
(mention) =>
mention.username === username ||
mention.acct === username ||
mention.url === url,
);
if (mention) {
states.showAccount = mention.acct;
} else {
const href = target.getAttribute('href');
states.showAccount = href;
}
}
}}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: enhanceContent(content, { __html: enhanceContent(content, {
emojis, emojis,
@ -385,7 +355,9 @@ function Status({
.querySelectorAll('a.u-url[target="_blank"]') .querySelectorAll('a.u-url[target="_blank"]')
.forEach((a) => { .forEach((a) => {
// Remove target="_blank" from links // Remove target="_blank" from links
a.removeAttribute('target'); if (!/http/i.test(a.innerText.trim())) {
a.removeAttribute('target');
}
}); });
}, },
}), }),
@ -403,7 +375,7 @@ function Status({
)} )}
{!spoilerText && sensitive && !!mediaAttachments.length && ( {!spoilerText && sensitive && !!mediaAttachments.length && (
<button <button
class="plain spoiler" class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -421,7 +393,7 @@ function Status({
)} )}
{!!mediaAttachments.length && ( {!!mediaAttachments.length && (
<div <div
class={`media-container ${ class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : '' mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
> >
@ -435,7 +407,11 @@ function Status({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setShowMediaModal(i); states.showMediaModal = {
mediaAttachments,
index: i,
statusID: readOnly ? null : id,
};
}} }}
/> />
))} ))}
@ -617,46 +593,37 @@ function Status({
/> />
</div> </div>
{isSelf && ( {isSelf && (
<span class="menu-container"> <Menu
<button type="button" title="More" class="plain more-button"> align="end"
<Icon icon="more" size="l" alt="More" /> menuButton={
</button> <div class="action">
<menu> <button
{isSelf && ( type="button"
<li> title="More"
<button class="plain more-button"
type="button" >
class="plain" <Icon icon="more" size="l" alt="More" />
onClick={(e) => { </button>
e.preventDefault(); </div>
e.stopPropagation(); }
states.showCompose = { >
editStatus: status, {isSelf && (
}; <MenuItem
}} onClick={() => {
> states.showCompose = {
Edit&hellip; editStatus: status,
</button> };
</li> }}
)} >
</menu> Edit&hellip;
</span> </MenuItem>
)}
</Menu>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
{showMediaModal !== false && (
<Modal>
<Carousel
mediaAttachments={mediaAttachments}
index={showMediaModal}
onClose={() => {
setShowMediaModal(false);
}}
/>
</Modal>
)}
{!!showEdited && ( {!!showEdited && (
<Modal <Modal
onClick={(e) => { onClick={(e) => {
@ -679,193 +646,6 @@ function Status({
); );
} }
/*
Media type
===
unknown = unsupported or unrecognized file type
image = Static image
gifv = Looping, soundless animation
video = Video clip
audio = Audio track
*/
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media;
const { original, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height;
const mediaURL = showOriginal ? url : previewUrl;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
const videoRef = useRef();
let focalBackgroundPosition;
if (focus) {
// Convert focal point to CSS background position
// Formula from jquery-focuspoint
// x = -1, y = 1 => 0% 0%
// x = 0, y = 0 => 50% 50%
// x = 1, y = -1 => 100% 100%
const x = ((focus.x + 1) / 2) * 100;
const y = ((1 - focus.y) / 2) * 100;
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
}
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height
return (
<div
class={`media media-image`}
onClick={onClick}
style={
showOriginal && {
backgroundImage: `url(${previewUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
aspectRatio: `${width}/${height}`,
width,
height,
maxWidth: '100%',
maxHeight: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}
}
>
<img
src={mediaURL}
alt={description}
width={width}
height={height}
loading={showOriginal ? 'eager' : 'lazy'}
style={
!showOriginal && {
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
}
}
/>
</div>
);
} else if (type === 'gifv' || type === 'video') {
const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video
const loopable = original.duration < 61;
const formattedDuration = formatDuration(original.duration);
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
return (
<div
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}}
onClick={(e) => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
onClick(e);
}}
onMouseEnter={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal || autoGIFAnimate ? (
<div
style={{
width: '100%',
height: '100%',
}}
dangerouslySetInnerHTML={{
__html: `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
preload="auto"
autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
></video>
`,
}}
/>
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
preload="auto"
// controls
playsinline
loop
muted
/>
) : (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
)}
</div>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<div
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
) : null}
</div>
);
}
}
function Card({ card }) { function Card({ card }) {
const { const {
blurhash, blurhash,
@ -1281,169 +1061,7 @@ function StatusButton({
); );
} }
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) { export function formatDuration(time) {
const carouselRef = useRef(null);
const [currentIndex, setCurrentIndex] = useState(index);
const carouselFocusItem = useRef(null);
useLayoutEffect(() => {
carouselFocusItem.current?.node?.scrollIntoView();
}, []);
useLayoutEffect(() => {
carouselFocusItem.current?.node?.scrollIntoView({
behavior: 'smooth',
});
}, [currentIndex]);
const onSnap = useDebouncedCallback((inView, i) => {
if (inView) {
setCurrentIndex(i);
}
}, 100);
const [showControls, setShowControls] = useState(true);
useEffect(() => {
let handleSwipe = () => {
onClose();
};
if (carouselRef.current) {
carouselRef.current.addEventListener('swiped-down', handleSwipe);
}
return () => {
if (carouselRef.current) {
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
}
};
}, []);
useHotkeys('esc', onClose, [onClose]);
return (
<>
<div
ref={carouselRef}
tabIndex="-1"
data-swipe-threshold="44"
class="carousel"
onClick={(e) => {
if (
e.target.classList.contains('carousel-item') ||
e.target.classList.contains('media')
) {
onClose();
}
}}
>
{mediaAttachments?.map((media, i) => {
const { blurhash } = media;
const rgbAverageColor = blurhash
? getBlurHashAverageColor(blurhash)
: null;
return (
<InView
class="carousel-item"
style={{
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
',',
)}, .5)`,
}}
tabindex="0"
key={media.id}
ref={i === currentIndex ? carouselFocusItem : null} // InView options
root={carouselRef.current}
threshold={1}
onChange={(inView) => onSnap(inView, i)}
onClick={(e) => {
if (e.target !== e.currentTarget) {
setShowControls(!showControls);
}
}}
>
<Media media={media} showOriginal />
</InView>
);
})}
</div>
<div class="carousel-top-controls" hidden={!showControls}>
<span />
<span>
<a
href={
mediaAttachments[currentIndex]?.remoteUrl ||
mediaAttachments[currentIndex]?.url
}
target="_blank"
class="button carousel-button plain2"
title="Open original media in new window"
>
<Icon icon="popout" alt="Open original media in new window" />
</a>{' '}
<button
type="button"
class="carousel-button plain2"
onClick={() => onClose()}
>
<Icon icon="x" />
</button>
</span>
</div>
{mediaAttachments?.length > 1 && (
<div class="carousel-controls" hidden={!showControls}>
<button
type="button"
class="carousel-button plain2"
hidden={currentIndex === 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCurrentIndex(
(currentIndex - 1 + mediaAttachments.length) %
mediaAttachments.length,
);
}}
>
<Icon icon="arrow-left" />
</button>
<span class="carousel-dots">
{mediaAttachments?.map((media, i) => (
<button
key={media.id}
type="button"
disabled={i === currentIndex}
class={`plain carousel-dot ${
i === currentIndex ? 'active' : ''
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCurrentIndex(i);
}}
>
&bull;
</button>
))}
</span>
<button
type="button"
class="carousel-button plain2"
hidden={currentIndex === mediaAttachments.length - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCurrentIndex((currentIndex + 1) % mediaAttachments.length);
}}
>
<Icon icon="arrow-right" />
</button>
</div>
)}
</>
);
}
function formatDuration(time) {
if (!time) return; if (!time) return;
let hours = Math.floor(time / 3600); let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60); let minutes = Math.floor((time % 3600) / 60);

163
src/components/timeline.jsx Normal file
View 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&hellip;</>
)}
</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;

View file

@ -2,7 +2,7 @@ import './index.css';
import './app.css'; import './app.css';
import { login } from 'masto'; import { createClient } from 'masto';
import { render } from 'preact'; import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
@ -14,12 +14,12 @@ if (window.opener) {
console = window.opener.console; console = window.opener.console;
} }
(async () => { (() => {
if (window.masto) return; if (window.masto) return;
console.warn('window.masto not found. Trying to log in...'); console.warn('window.masto not found. Trying to log in...');
try { try {
const { instanceURL, accessToken } = getCurrentAccount(); const { instanceURL, accessToken } = getCurrentAccount();
window.masto = await login({ window.masto = createClient({
url: `https://${instanceURL}`, url: `https://${instanceURL}`,
accessToken, accessToken,
disableVersionCheck: true, disableVersionCheck: true,

View file

@ -28,14 +28,15 @@
--reply-to-color: var(--orange-color); --reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200; --reply-to-text-color: #b36200;
--favourite-color: var(--red-color); --favourite-color: var(--red-color);
--reply-to-faded-color: #ffa6001a; --reply-to-faded-color: #ffa60030;
--outline-color: rgba(128, 128, 128, 0.2); --outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7); --outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1); --divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(255, 255, 255, 0.5); --backdrop-color: rgba(0, 0, 0, 0.05);
--img-bg-color: rgba(128, 128, 128, 0.2); --img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199; --loader-color: #1c1e2199;
--comment-line-color: #e5e5e5; --comment-line-color: #e5e5e5;
--drop-shadow-color: rgba(0, 0, 0, 0.15);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
} }
@ -49,10 +50,9 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--blue-color: CornflowerBlue; --blue-color: CornflowerBlue;
--purple-color: mediumpurple; --purple-color: #b190f1;
--green-color: lightgreen; --green-color: lightgreen;
--orange-color: orange; --orange-color: orange;
--reply-to-text-color: var(--reply-to-color);
--bg-color: #242526; --bg-color: #242526;
--bg-faded-color: #18191a; --bg-faded-color: #18191a;
--bg-blur-color: #0009; --bg-blur-color: #0009;
@ -62,11 +62,15 @@
--link-light-color: #6494ed99; --link-light-color: #6494ed99;
--link-faded-color: #6494ed88; --link-faded-color: #6494ed88;
--link-bg-hover-color: #34353799; --link-bg-hover-color: #34353799;
--reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60027;
--divider-color: rgba(255, 255, 255, 0.1); --divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699; --bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5); --backdrop-color: rgba(0, 0, 0, 0.5);
--loader-color: #f0f2f599; --loader-color: #f0f2f599;
--comment-line-color: #565656; --comment-line-color: #565656;
--drop-shadow-color: rgba(0, 0, 0, 0.5);
} }
} }
@ -160,6 +164,18 @@ button,
color: var(--link-color); color: var(--link-color);
backdrop-filter: blur(12px) invert(0.25) brightness(1.5); backdrop-filter: blur(12px) invert(0.25) brightness(1.5);
} }
:is(button, .button).plain3 {
background-color: transparent;
color: var(--button-text-color);
backdrop-filter: blur(12px) invert(0.25);
}
:is(button, .button).plain4 {
background-color: transparent;
color: var(--text-insignificant-color);
}
:is(button, .button).plain4:not(:disabled, .disabled):is(:hover, :focus) {
color: var(--text-color);
}
:is(button, .button).light { :is(button, .button).light {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
color: var(--text-color); color: var(--text-color);

View file

@ -1,6 +1,9 @@
import './index.css'; import './index.css';
import '@szhsin/react-menu/dist/core.css';
import { render } from 'preact'; import { render } from 'preact';
import { HashRouter } from 'react-router-dom';
import { App } from './app'; import { App } from './app';
@ -8,7 +11,12 @@ if (import.meta.env.DEV) {
import('preact/debug'); import('preact/debug');
} }
render(<App />, document.getElementById('app')); render(
<HashRouter>
<App />
</HashRouter>,
document.getElementById('app'),
);
// Clean up iconify localStorage // Clean up iconify localStorage
// TODO: Remove this after few weeks? // TODO: Remove this after few weeks?

15
src/pages/404.jsx Normal file
View 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>
);
}

View 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
View 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
View 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
View 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;

View file

@ -1,32 +1,31 @@
import { Link } from 'preact-router/match';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Status from '../components/status'; import Status from '../components/status';
import db from '../utils/db'; import db from '../utils/db';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
function Home({ hidden }) { function Home({ hidden }) {
useTitle('Home', '/');
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const isHomeLocation = snapStates.currentLocation === '/';
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
console.debug('RENDER Home'); console.debug('RENDER Home');
const homeIterator = useRef( const homeIterator = useRef();
masto.v1.timelines.listHome({
limit: LIMIT,
}),
);
async function fetchStatuses(firstLoad) { async function fetchStatuses(firstLoad) {
if (firstLoad) { if (firstLoad) {
// Reset iterator // Reset iterator
@ -36,102 +35,111 @@ function Home({ hidden }) {
states.homeNew = []; states.homeNew = [];
} }
const allStatuses = await homeIterator.current.next(); const allStatuses = await homeIterator.current.next();
if (allStatuses.value <= 0) { if (allStatuses.value?.length) {
return { done: true }; // ENFORCE sort by datetime (Latest first)
} allStatuses.value.sort((a, b) => {
const homeValues = allStatuses.value.map((status) => { const aDate = new Date(a.createdAt);
saveStatus(status); const bDate = new Date(b.createdAt);
return { return bDate - aDate;
id: status.id, });
reblog: status.reblog?.id, const homeValues = allStatuses.value.map((status) => {
reply: !!status.inReplyToAccountId, saveStatus(status);
}; return {
}); id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
// BOOSTS CAROUSEL // BOOSTS CAROUSEL
if (snapStates.settings.boostsCarousel) { if (snapStates.settings.boostsCarousel) {
let specialHome = []; let specialHome = [];
let boostStash = []; let boostStash = [];
let serialBoosts = 0; let serialBoosts = 0;
for (let i = 0; i < homeValues.length; i++) { for (let i = 0; i < homeValues.length; i++) {
const status = homeValues[i]; const status = homeValues[i];
if (status.reblog) { if (status.reblog) {
boostStash.push(status); boostStash.push(status);
serialBoosts++; serialBoosts++;
} else { } else {
specialHome.push(status); specialHome.push(status);
if (serialBoosts < 3) { if (serialBoosts < 3) {
serialBoosts = 0; serialBoosts = 0;
}
} }
} }
} // if boostStash is more than quarter of homeValues
// if boostStash is more than quarter of homeValues // or if there are 3 or more boosts in a row
// or if there are 3 or more boosts in a row if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) { // if boostStash is more than 3 quarter of homeValues
// if boostStash is more than 3 quarter of homeValues const boostStashID = boostStash.map((status) => status.id);
const boostStashID = boostStash.map((status) => status.id); if (boostStash.length > (homeValues.length * 3) / 4) {
if (boostStash.length > (homeValues.length * 3) / 4) { // insert boost array at the end of specialHome list
// insert boost array at the end of specialHome list specialHome = [
specialHome = [ ...specialHome,
...specialHome, { id: boostStashID, boosts: boostStash },
{ id: boostStashID, boosts: boostStash }, ];
]; } else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(specialHome.length / 2);
specialHome = [
...specialHome.slice(0, half),
{
id: boostStashID,
boosts: boostStash,
},
...specialHome.slice(half),
];
}
} else { } else {
// insert boosts array in the middle of specialHome list // Untouched, this is fine
const half = Math.floor(specialHome.length / 2); specialHome = homeValues;
specialHome = [ }
...specialHome.slice(0, half), console.log({
{ specialHome,
id: boostStashID, });
boosts: boostStash, if (firstLoad) {
}, states.homeLast = specialHome[0];
...specialHome.slice(half), states.home = specialHome;
]; } else {
states.home.push(...specialHome);
} }
} else { } else {
// Untouched, this is fine if (firstLoad) {
specialHome = homeValues; states.homeLast = homeValues[0];
} states.home = homeValues;
console.log({ } else {
specialHome, states.home.push(...homeValues);
}); }
if (firstLoad) {
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
if (firstLoad) {
states.home = homeValues;
} else {
states.home.push(...homeValues);
} }
} }
states.homeLastFetchTime = Date.now(); states.homeLastFetchTime = Date.now();
return { return allStatuses;
done: false,
};
} }
const loadingStatuses = useRef(false); const loadingStatuses = useRef(false);
const loadStatuses = useDebouncedCallback((firstLoad) => { const loadStatuses = useDebouncedCallback(
if (loadingStatuses.current) return; (firstLoad) => {
loadingStatuses.current = true; if (loadingStatuses.current) return;
setUIState('loading'); loadingStatuses.current = true;
(async () => { setUIState('loading');
try { (async () => {
const { done } = await fetchStatuses(firstLoad); try {
setShowMore(!done); const { done } = await fetchStatuses(firstLoad);
setUIState('default'); setShowMore(!done);
} catch (e) { setUIState('default');
console.warn(e); } catch (e) {
setUIState('error'); console.warn(e);
} finally { setUIState('error');
loadingStatuses.current = false; } finally {
} loadingStatuses.current = false;
})(); }
}, 1000); })();
},
3000,
{ leading: true, trailing: false },
);
useEffect(() => { useEffect(() => {
loadStatuses(true); loadStatuses(true);
@ -139,103 +147,121 @@ function Home({ hidden }) {
const scrollableRef = useRef(); const scrollableRef = useRef();
useHotkeys('j, shift+j', (_, handler) => { useHotkeys(
// focus on next status after active status 'j, shift+j',
// Traverses .timeline li .status-link, focus on .status-link (_, handler) => {
const activeStatus = document.activeElement.closest( // focus on next status after active status
'.status-link, .status-boost-link', // Traverses .timeline li .status-link, focus on .status-link
); const activeStatus = document.activeElement.closest(
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link', '.status-link, .status-boost-link',
), );
); const activeStatusRect = activeStatus?.getBoundingClientRect();
if ( const allStatusLinks = Array.from(
activeStatus && scrollableRef.current.querySelectorAll(
activeStatusRect.top < scrollableRef.current.clientHeight && '.status-link, .status-boost-link',
activeStatusRect.bottom > 0 ),
) { );
const activeStatusIndex = allStatusLinks.indexOf(activeStatus); if (
let nextStatus = allStatusLinks[activeStatusIndex + 1]; activeStatus &&
if (handler.shift) { activeStatusRect.top < scrollableRef.current.clientHeight &&
// get next status that's not .status-boost-link activeStatusRect.bottom > 0
nextStatus = allStatusLinks.find( ) {
(statusLink, index) => const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
index > activeStatusIndex && let nextStatus = allStatusLinks[activeStatusIndex + 1];
!statusLink.classList.contains('status-boost-link'), if (handler.shift) {
); // get next status that's not .status-boost-link
nextStatus = allStatusLinks.find(
(statusLink, index) =>
index > activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
} }
if (nextStatus) { },
nextStatus.focus(); {
nextStatus.scrollIntoViewIfNeeded?.(); enabled: isHomeLocation,
} },
} else { );
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
useHotkeys('k. shift+k', () => { useHotkeys(
// focus on previous status after active status 'k, shift+k',
// Traverses .timeline li .status-link, focus on .status-link (_, handler) => {
const activeStatus = document.activeElement.closest( // focus on previous status after active status
'.status-link, .status-boost-link', // Traverses .timeline li .status-link, focus on .status-link
); const activeStatus = document.activeElement.closest(
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link', '.status-link, .status-boost-link',
), );
); const activeStatusRect = activeStatus?.getBoundingClientRect();
if ( const allStatusLinks = Array.from(
activeStatus && scrollableRef.current.querySelectorAll(
activeStatusRect.top < scrollableRef.current.clientHeight && '.status-link, .status-boost-link',
activeStatusRect.bottom > 0 ),
) { );
const activeStatusIndex = allStatusLinks.indexOf(activeStatus); if (
let prevStatus = allStatusLinks[activeStatusIndex - 1]; activeStatus &&
if (handler.shift) { activeStatusRect.top < scrollableRef.current.clientHeight &&
// get prev status that's not .status-boost-link activeStatusRect.bottom > 0
prevStatus = allStatusLinks.find( ) {
(statusLink, index) => const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
index < activeStatusIndex && let prevStatus = allStatusLinks[activeStatusIndex - 1];
!statusLink.classList.contains('status-boost-link'), if (handler.shift) {
); // get prev status that's not .status-boost-link
prevStatus = allStatusLinks.findLast(
(statusLink, index) =>
index < activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
} }
if (prevStatus) { },
prevStatus.focus(); {
prevStatus.scrollIntoViewIfNeeded?.(); enabled: isHomeLocation,
} },
} else { );
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
useHotkeys(['enter', 'o'], () => { useHotkeys(
// open active status ['enter', 'o'],
const activeStatus = document.activeElement.closest( () => {
'.status-link, .status-boost-link', // open active status
); const activeStatus = document.activeElement.closest(
if (activeStatus) { '.status-link, .status-boost-link',
activeStatus.click(); );
} if (activeStatus) {
}); activeStatus.click();
}
},
{
enabled: isHomeLocation,
},
);
const { const {
scrollDirection, scrollDirection,
@ -275,14 +301,159 @@ function Home({ hidden }) {
})(); })();
}, []); }, []);
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
useEffect(() => {
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length]);
return ( return (
<div <>
id="home-page" <div
class="deck-container" id="home-page"
hidden={hidden} class="deck-container"
ref={scrollableRef} hidden={hidden}
tabIndex="-1" ref={scrollableRef}
> tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
}}
>
<div class="header-side">
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" />
</button>
</div>
<h1>Home</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '}
<Link
to="/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
</div>
</header>
{snapStates.homeNew.length > 0 &&
uiState !== 'loading' &&
((scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd) ||
showUpdatesButton) && (
<button
class="updates-button"
type="button"
onClick={() => {
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) => !states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link class="status-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
</>
) : (
<>
{uiState === 'loading' && (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</>
)}
</div>
</div>
<button <button
hidden={scrollDirection === 'end' && !nearReachStart} hidden={scrollDirection === 'end' && !nearReachStart}
type="button" type="button"
@ -301,157 +472,7 @@ function Home({ hidden }) {
> >
<Icon icon="quill" size="xxl" alt="Compose" /> <Icon icon="quill" size="xxl" alt="Compose" />
</button> </button>
<div class="timeline-deck deck"> </>
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
}}
>
<div class="header-side">
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" />
</button>
</div>
<h1>Home</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '}
<a
href="#/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</a>
</div>
</header>
{snapStates.homeNew.length > 0 &&
scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd && (
<button
class="updates-button"
type="button"
onClick={() => {
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) => !states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link
activeClassName="active"
class="status-link"
href={`#/s/${actualStatusID}`}
>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && (
<>
{/* <InView
as="li"
style={{
height: '20vh',
}}
onChange={(inView) => {
if (inView) loadStatuses();
}}
root={scrollableRef.current}
rootMargin="100px 0px"
> */}
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
{/* </InView> */}
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
</>
) : (
<>
{uiState === 'loading' && (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</>
)}
</div>
</div>
); );
} }
@ -503,10 +524,10 @@ function BoostsCarousel({ boosts }) {
const { id: statusID, reblog } = boost; const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID; const actualStatusID = reblog || statusID;
return ( return (
<li> <li key={statusID}>
<a class="status-boost-link" href={`#/s/${actualStatusID}`}> <Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" /> <Status statusID={statusID} size="s" />
</a> </Link>
</li> </li>
); );
})} })}

43
src/pages/lists.jsx Normal file
View 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;

View file

@ -2,6 +2,7 @@ import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import instancesListURL from '../data/instances.json?url'; import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth'; import { getAuthorizationURL, registerApplication } from '../utils/auth';
@ -111,7 +112,7 @@ function Login() {
</a> </a>
</p> </p>
<p> <p>
<a href="/#">Go home</a> <Link to="/">Go home</Link>
</p> </p>
</form> </form>
</main> </main>

View file

@ -2,6 +2,7 @@
display: flex; display: flex;
padding: 16px !important; padding: 16px !important;
gap: 12px; gap: 12px;
animation: appear 0.2s ease-out;
} }
.notification.mention { .notification.mention {
margin-top: 16px; margin-top: 16px;

View file

@ -1,18 +1,19 @@
import './notifications.css'; import './notifications.css';
import { Link } from 'preact-router/match';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import Status from '../components/status'; import Status from '../components/status';
import states from '../utils/states'; import states, { saveStatus } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
/* /*
@ -45,6 +46,228 @@ const contentText = {
const LIMIT = 30; // 30 is the maximum limit :( const LIMIT = 30; // 30 is the maximum limit :(
function Notifications() {
useTitle('Notifications', '/notifications');
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
const { nearReachEnd, reachStart } = useScroll({
scrollableElement: scrollableRef.current,
});
console.debug('RENDER Notifications');
const notificationsIterator = useRef();
async function fetchNotifications(firstLoad) {
if (firstLoad) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value?.length) {
const notificationsValues = allNotifications.value.map((notification) => {
saveStatus(notification.status, {
skipThreading: true,
override: false,
});
return notification;
});
const groupedNotifications = groupNotifications(notificationsValues);
if (firstLoad) {
states.notificationLast = notificationsValues[0];
states.notifications = groupedNotifications;
} else {
states.notifications.push(...groupedNotifications);
}
}
states.notificationsLastFetchTime = Date.now();
return allNotifications;
}
const loadNotifications = (firstLoad) => {
setUIState('loading');
(async () => {
try {
const { done } = await fetchNotifications(firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
setUIState('error');
}
})();
};
useEffect(() => {
loadNotifications(true);
}, []);
useEffect(() => {
if (reachStart) {
loadNotifications(true);
}
}, [reachStart]);
useEffect(() => {
if (nearReachEnd && showMore) {
loadNotifications();
}
}, [nearReachEnd, showMore]);
const todayDate = new Date();
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
let currentDay = new Date();
const showTodayEmpty = !snapStates.notifications.some(
(notification) =>
new Date(notification.createdAt).toDateString() ===
todayDate.toDateString(),
);
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
<header
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<div class="header-side">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Notifications</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
</div>
</header>
{snapStates.notificationsNew.length > 0 && uiState !== 'loading' && (
<button
class="updates-button"
type="button"
onClick={() => {
loadNotifications(true);
states.notificationsNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New notifications
</button>
)}
<div id="mentions-option">
<label>
<input
type="checkbox"
checked={onlyMentions}
onChange={(e) => {
setOnlyMentions(e.target.checked);
}}
/>{' '}
Only mentions
</label>
</div>
<h2 class="timeline-header">Today</h2>
{showTodayEmpty && !!snapStates.notifications.length && (
<p class="ui-state insignificant">
{uiState === 'default' ? "You're all caught up." : <>&hellip;</>}
</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&hellip;</>}
</button>
)}
</div>
</div>
);
}
function Notification({ notification }) { function Notification({ notification }) {
const { id, type, status, account, _accounts } = notification; const { id, type, status, account, _accounts } = notification;
@ -61,7 +284,7 @@ function Notification({ notification }) {
: contentText[type]; : contentText[type];
return ( return (
<> <div class={`notification ${type}`} tabIndex="0">
<div <div
class={`notification-type notification-${type}`} class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()} title={new Date(notification.createdAt).toLocaleString()}
@ -137,11 +360,13 @@ function Notification({ notification }) {
<Avatar <Avatar
url={account.avatarStatic} url={account.avatarStatic}
size={ size={
_accounts.length < 30 _accounts.length <= 10
? 'xl' ? 'xxl'
: _accounts.length < 100 : _accounts.length < 100
? 'l' ? 'xl'
: _accounts.length < 1000 : _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm' ? 'm'
: 's' // My god, this person is popular! : 's' // My god, this person is popular!
} }
@ -156,270 +381,12 @@ function Notification({ notification }) {
{status && ( {status && (
<Link <Link
class={`status-link status-type-${type}`} class={`status-link status-type-${type}`}
href={`#/s/${actualStatusID}`} to={`/s/${actualStatusID}`}
> >
<Status status={status} size="s" /> <Status status={status} size="s" />
</Link> </Link>
)} )}
</div> </div>
</>
);
}
function NotificationsList({ notifications, emptyCopy }) {
if (!notifications.length && emptyCopy) {
return <p class="timeline-empty">{emptyCopy}</p>;
}
// Create new flat list of notifications
// Combine sibling notifications based on type and status id, ignore the id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
// const cleanNotification = cleanNotifications[j];
const { status, account, type, created_at } = notification;
const createdAt = new Date(created_at).toLocaleDateString();
const key = `${status?.id}-${type}-${createdAt}`;
const mappedNotification = notificationsMap[key];
if (mappedNotification?.account) {
mappedNotification._accounts.push(account);
} else {
let n = (notificationsMap[key] = {
...notification,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
// console.log({ notifications, cleanNotifications });
return (
<ul class="timeline flat">
{cleanNotifications.map((notification, i) => {
const { id, type } = notification;
return (
<li key={id} class={`notification ${type}`} tabIndex="0">
<Notification notification={notification} />
</li>
);
})}
</ul>
);
}
function Notifications() {
useTitle('Notifications');
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
console.debug('RENDER Notifications');
const notificationsIterator = useRef(
masto.v1.notifications.list({
limit: LIMIT,
}),
);
async function fetchNotifications(firstLoad) {
if (firstLoad) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value <= 0) {
return { done: true };
}
const notificationsValues = allNotifications.value.map((notification) => {
if (notification.status) {
states.statuses[notification.status.id] = notification.status;
}
return notification;
});
if (firstLoad) {
states.notifications = notificationsValues;
} else {
states.notifications.push(...notificationsValues);
}
states.notificationsLastFetchTime = Date.now();
return allNotifications;
}
const loadNotifications = (firstLoad) => {
setUIState('loading');
(async () => {
try {
const { done } = await fetchNotifications(firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
setUIState('error');
}
})();
};
useEffect(() => {
loadNotifications(true);
}, []);
const scrollableRef = useRef();
// Group notifications by today, yesterday, and older
const groupedNotifications = snapStates.notifications.reduce(
(acc, notification) => {
const date = new Date(notification.createdAt);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
) {
acc.today.push(notification);
} else if (
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
) {
acc.yesterday.push(notification);
} else {
acc.older.push(notification);
}
return acc;
},
{ today: [], yesterday: [], older: [] },
);
// console.log(groupedNotifications);
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
<header
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<div class="header-side">
<a href="#" class="button plain">
<Icon icon="home" size="l" />
</a>
</div>
<h1>Notifications</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
</div>
</header>
{snapStates.notificationsNew.length > 0 && (
<button
class="updates-button"
type="button"
onClick={() => {
const uniqueNotificationsNew = snapStates.notificationsNew.filter(
(notification) =>
!snapStates.notifications.some(
(n) => n.id === notification.id,
),
);
states.notifications.unshift(...uniqueNotificationsNew);
loadNotifications(true);
states.notificationsNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New notifications
</button>
)}
<div id="mentions-option">
<label>
<input
type="checkbox"
checked={onlyMentions}
onChange={(e) => {
setOnlyMentions(e.target.checked);
}}
/>{' '}
Only mentions
</label>
</div>
{snapStates.notifications.length ? (
<>
<h2 class="timeline-header">Today</h2>
<NotificationsList
notifications={groupedNotifications.today}
emptyCopy="You're all caught up."
/>
{groupedNotifications.yesterday.length > 0 && (
<>
<h2 class="timeline-header">Yesterday</h2>
<NotificationsList
notifications={groupedNotifications.yesterday}
/>
</>
)}
{groupedNotifications.older.length > 0 && (
<>
<h2 class="timeline-header">Older</h2>
<NotificationsList notifications={groupedNotifications.older} />
</>
)}
{showMore && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadNotifications()}
style={{ marginBlockEnd: '6em' }}
>
{uiState === 'loading' ? <Loader /> : <>Show more&hellip;</>}
</button>
)}
</>
) : (
<>
{uiState === 'loading' && (
<>
<h2 class="timeline-header">Today</h2>
<ul class="timeline flat">
{Array.from({ length: 5 }).map((_, i) => (
<li class="notification skeleton">
<div class="notification-type">
<Icon icon="notification" size="xl" />
</div>
<div class="notification-content">
<p> </p>
</div>
</li>
))}
</ul>
</>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load notifications
<br />
<br />
<button type="button" onClick={() => loadNotifications(true)}>
Try again
</button>
</p>
)}
</>
)}
</div>
</div> </div>
); );
} }
@ -470,4 +437,29 @@ function FollowRequestButtons({ accountID, onChange }) {
); );
} }
function groupNotifications(notifications) {
// Create new flat list of notifications
// Combine sibling notifications based on type and status id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { status, account, type, created_at } = notification;
const createdAt = new Date(created_at).toLocaleDateString();
const key = `${status?.id}-${type}-${createdAt}`;
const mappedNotification = notificationsMap[key];
if (mappedNotification?.account) {
mappedNotification._accounts.push(account);
} else {
let n = (notificationsMap[key] = {
...notification,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
}
export default memo(Notifications); export default memo(Notifications);

76
src/pages/public.jsx Normal file
View 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;

View file

@ -14,13 +14,14 @@
#settings-container :is(section, .section) { #settings-container :is(section, .section) {
background-color: var(--bg-color); background-color: var(--bg-color);
margin: 0 -16px; margin: 0;
padding: 8px 16px; padding: 8px 16px;
border-top: var(--hairline-width) solid var(--outline-color); border-top: var(--hairline-width) solid var(--outline-color);
border-bottom: var(--hairline-width) solid var(--outline-color); border-bottom: var(--hairline-width) solid var(--outline-color);
border-radius: 8px;
} }
#settings-container :is(section, .section) > li + li { #settings-container :is(section, .section) > li:last-child {
border-top: var(--hairline-width) solid var(--outline-color); border-bottom: none;
} }
#settings-container ul { #settings-container ul {
@ -28,33 +29,34 @@
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
#settings-container ul li { #settings-container ul:not([role='menu']) > li {
padding: 8px 0; padding: 8px 0 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
border-bottom: var(--hairline-width) solid var(--outline-color);
} }
#settings-container ul li .current { #settings-container ul:not([role='menu']) > li .current {
margin-right: 8px; margin-right: 8px;
color: var(--green-color); color: var(--green-color);
opacity: 0.1; opacity: 0.1;
} }
#settings-container ul li .current.is-current { #settings-container ul:not([role='menu']) > li .current.is-current {
opacity: 1; opacity: 1;
} }
#settings-container ul li .current.is-current + .avatar { #settings-container ul:not([role='menu']) > li .current.is-current + .avatar {
box-shadow: 0 0 0 1.5px var(--green-color); box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color);
} }
#settings-container ul li > div { #settings-container ul:not([role='menu']) > li > div {
flex-grow: 1; flex-grow: 1;
max-width: 100%; max-width: 100%;
} }
#settings-container ul li > div.actions { #settings-container ul:not([role='menu']) > li > div.actions {
flex-basis: min-content; flex-basis: fit-content;
margin-top: 8px; margin-top: 8px;
} }
#settings-container ul li > div:last-child { #settings-container ul:not([role='menu']) > li > div:last-child {
text-align: right; text-align: right;
} }
#settings-container div, #settings-container div,
@ -98,10 +100,3 @@
#settings-container .radio-group label:has(input:checked) input:checked + span { #settings-container .radio-group label:has(input:checked) input:checked + span {
color: inherit; color: inherit;
} }
@media (min-width: 40em) {
#settings-container :is(section, .section) {
margin-inline: 0;
border-radius: 8px;
}
}

View file

@ -1,10 +1,13 @@
import './settings.css'; import './settings.css';
import { useRef, useState } from 'preact/hooks'; import { Menu, MenuItem } from '@szhsin/react-menu';
import { useReducer, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import states from '../utils/states'; import states from '../utils/states';
@ -26,6 +29,8 @@ function Settings({ onClose }) {
const moreThanOneAccount = accounts.length > 1; const moreThanOneAccount = accounts.length > 1;
const [currentDefault, setCurrentDefault] = useState(0); const [currentDefault, setCurrentDefault] = useState(0);
const [_, reload] = useReducer((x) => x + 1, 0);
return ( return (
<div id="settings-container" class="sheet" tabIndex="-1"> <div id="settings-container" class="sheet" tabIndex="-1">
<main> <main>
@ -39,14 +44,30 @@ function Settings({ onClose }) {
const isCurrent = account.info.id === currentAccount; const isCurrent = account.info.id === currentAccount;
const isDefault = i === (currentDefault || 0); const isDefault = i === (currentDefault || 0);
return ( return (
<li> <li key={i + account.id}>
<div> <div>
{moreThanOneAccount && ( {moreThanOneAccount && (
<span class={`current ${isCurrent ? 'is-current' : ''}`}> <span class={`current ${isCurrent ? 'is-current' : ''}`}>
<Icon icon="check-circle" alt="Current" /> <Icon icon="check-circle" alt="Current" />
</span> </span>
)} )}
<Avatar url={account.info.avatarStatic} size="xxl" /> <Avatar
url={account.info.avatarStatic}
size="xxl"
onDblClick={async () => {
if (isCurrent) {
try {
const info = await masto.v1.accounts.fetch(
account.info.id,
);
console.log('fetched account info', info);
account.info = info;
store.local.setJSON('accounts', accounts);
reload();
} catch (e) {}
}
}}
/>
<NameText <NameText
account={account.info} account={account.info}
showAcct showAcct
@ -73,11 +94,21 @@ function Settings({ onClose }) {
<Icon icon="transfer" /> Switch <Icon icon="transfer" /> Switch
</button> </button>
)} )}
<div> <Menu
{!isDefault && moreThanOneAccount && ( align="end"
menuButton={
<button <button
type="button" type="button"
class="plain small" title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
}
>
{moreThanOneAccount && (
<MenuItem
disabled={isDefault}
onClick={() => { onClick={() => {
// Move account to the top of the list // Move account to the top of the list
accounts.splice(i, 1); accounts.splice(i, 1);
@ -87,29 +118,23 @@ function Settings({ onClose }) {
}} }}
> >
Set as default Set as default
</button> </MenuItem>
)} )}
{isCurrent && ( <MenuItem
<> disabled={!isCurrent}
{' '} onClick={() => {
<button const yes = confirm(
type="button" 'Are you sure you want to log out?',
class="plain small" );
onClick={() => { if (!yes) return;
const yes = confirm( accounts.splice(i, 1);
'Are you sure you want to log out?', store.local.setJSON('accounts', accounts);
); location.reload();
if (!yes) return; }}
accounts.splice(i, 1); >
store.local.setJSON('accounts', accounts); Log out
location.reload(); </MenuItem>
}} </Menu>
>
Log out
</button>
</>
)}
</div>
</div> </div>
</li> </li>
); );
@ -124,9 +149,9 @@ function Settings({ onClose }) {
</p> </p>
)} )}
<p style={{ textAlign: 'end' }}> <p style={{ textAlign: 'end' }}>
<a href="/#/login" class="button" onClick={onClose}> <Link to="/login" class="button" onClick={onClose}>
Add new account Add new account
</a> </Link>
</p> </p>
</section> </section>
<h2>Settings</h2> <h2>Settings</h2>
@ -226,6 +251,29 @@ function Settings({ onClose }) {
</section> </section>
<h2>About</h2> <h2>About</h2>
<section> <section>
<p>
<img
src={logo}
alt=""
width="20"
height="20"
style={{
aspectRatio: '1/1',
verticalAlign: 'middle',
}}
/>{' '}
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
}}
>
@phanpy
</a>
.
</p>
<p> <p>
<a href="https://github.com/cheeaun/phanpy" target="_blank"> <a href="https://github.com/cheeaun/phanpy" target="_blank">
Built Built
@ -241,6 +289,13 @@ function Settings({ onClose }) {
> >
@cheeaun @cheeaun
</a> </a>
.{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
>
Privacy Policy
</a>
. .
</p> </p>
{__BUILD_TIME__ && ( {__BUILD_TIME__ && (

View file

@ -20,7 +20,6 @@
.hero-heading { .hero-heading {
font-size: 16px; font-size: 16px;
pointer-events: none;
display: inline-block; display: inline-block;
margin-bottom: 0.25em; margin-bottom: 0.25em;
} }

View file

@ -1,13 +1,18 @@
import './status.css'; import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { Link } from 'preact-router/match'; import pRetry from 'p-retry';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
@ -15,15 +20,23 @@ import Status from '../components/status';
import htmlContentLength from '../utils/html-content-length'; import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states, { saveStatus, threadifyStatus } from '../utils/states'; import states, { saveStatus, threadifyStatus } from '../utils/states';
import store from '../utils/store';
import { getCurrentAccount } from '../utils/store-utils'; import { getCurrentAccount } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 40; const LIMIT = 40;
const THREAD_LIMIT = 20;
function StatusPage({ id }) { let cachedStatusesMap = {};
function resetScrollPosition(id) {
delete cachedStatusesMap[id];
delete states.scrollPositions[id];
}
function StatusPage() {
const { id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([]); const [statuses, setStatuses] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -47,19 +60,18 @@ function StatusPage({ id }) {
}); });
onScroll(); onScroll();
return () => { return () => {
onScroll.cancel(); onScroll.flush();
scrollableRef.current?.removeEventListener('scroll', onScroll); scrollableRef.current?.removeEventListener('scroll', onScroll);
}; };
}, [id, uiState !== 'loading']); }, [id, uiState !== 'loading']);
const scrollOffsets = useRef(); const scrollOffsets = useRef();
const cachedStatusesMap = useRef({});
const initContext = () => { const initContext = () => {
console.debug('initContext', id); console.debug('initContext', id);
setUIState('loading'); setUIState('loading');
let heroTimer; let heroTimer;
const cachedStatuses = cachedStatusesMap.current[id]; const cachedStatuses = cachedStatusesMap[id];
if (cachedStatuses) { if (cachedStatuses) {
// Case 1: It's cached, let's restore them to make it snappy // Case 1: It's cached, let's restore them to make it snappy
const reallyCachedStatuses = cachedStatuses.filter( const reallyCachedStatuses = cachedStatuses.filter(
@ -80,8 +92,13 @@ function StatusPage({ id }) {
} }
(async () => { (async () => {
const heroFetch = () => masto.v1.statuses.fetch(id); const heroFetch = () =>
const contextFetch = masto.v1.statuses.fetchContext(id); pRetry(() => masto.v1.statuses.fetch(id), {
retries: 4,
});
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
retries: 8,
});
const hasStatus = !!snapStates.statuses[id]; const hasStatus = !!snapStates.statuses[id];
let heroStatus = snapStates.statuses[id]; let heroStatus = snapStates.statuses[id];
@ -127,8 +144,8 @@ function StatusPage({ id }) {
} }
parent.__replies.push(status); parent.__replies.push(status);
} else { } else {
// If no parent, it's probably a reply to a reply to a reply, level 3 // If no parent, something is wrong
console.warn('[LEVEL 3] No parent found for', status); console.warn('No parent found for', status);
} }
} }
}); });
@ -149,8 +166,23 @@ function StatusPage({ id }) {
thread: s.account.id === heroStatus.account.id, thread: s.account.id === heroStatus.account.id,
replies: s.__replies?.map((r) => ({ replies: s.__replies?.map((r) => ({
id: r.id, id: r.id,
account: r.account,
repliesCount: r.repliesCount, repliesCount: r.repliesCount,
content: r.content, content: r.content,
replies: r.__replies?.map((r2) => ({
// Level 3
id: r2.id,
account: r2.account,
repliesCount: r2.repliesCount,
content: r2.content,
replies: r2.__replies?.map((r3) => ({
// Level 4
id: r3.id,
account: r3.account,
repliesCount: r3.repliesCount,
content: r3.content,
})),
})),
})), })),
})), })),
]; ];
@ -162,7 +194,7 @@ function StatusPage({ id }) {
}; };
console.log({ allStatuses }); console.log({ allStatuses });
setStatuses(allStatuses); setStatuses(allStatuses);
cachedStatusesMap.current[id] = allStatuses; cachedStatusesMap[id] = allStatuses;
// Let's threadify this one // Let's threadify this one
// Note that all non-hero statuses will trigger saveStatus which will threadify them too // Note that all non-hero statuses will trigger saveStatus which will threadify them too
@ -187,6 +219,7 @@ function StatusPage({ id }) {
console.debug('scrollPosition', scrollPosition); console.debug('scrollPosition', scrollPosition);
if (!!scrollPosition) { if (!!scrollPosition) {
console.debug('Case 1', { console.debug('Case 1', {
id,
scrollPosition, scrollPosition,
}); });
scrollableRef.current.scrollTop = scrollPosition; scrollableRef.current.scrollTop = scrollPosition;
@ -196,7 +229,9 @@ function StatusPage({ id }) {
scrollTop: scrollableRef.current?.scrollTop, scrollTop: scrollableRef.current?.scrollTop,
}; };
const newScrollTop = const newScrollTop =
newScrollOffsets.offsetTop - scrollOffsets.current.offsetTop; newScrollOffsets.offsetTop -
scrollOffsets.current.offsetTop +
newScrollOffsets.scrollTop;
console.debug('Case 2', { console.debug('Case 2', {
scrollOffsets: scrollOffsets.current, scrollOffsets: scrollOffsets.current,
newScrollOffsets, newScrollOffsets,
@ -204,6 +239,11 @@ function StatusPage({ id }) {
statuses: [...statuses], statuses: [...statuses],
}); });
scrollableRef.current.scrollTop = newScrollTop; scrollableRef.current.scrollTop = newScrollTop;
} else if (statuses.length === 1) {
console.debug('Case 3', {
id,
});
scrollableRef.current.scrollTop = 0;
} }
// RESET // RESET
@ -233,7 +273,7 @@ function StatusPage({ id }) {
// RESET // RESET
states.scrollPositions = {}; states.scrollPositions = {};
states.reloadStatusPage = 0; states.reloadStatusPage = 0;
cachedStatusesMap.current = {}; cachedStatusesMap = {};
}; };
}, []); }, []);
@ -268,12 +308,17 @@ function StatusPage({ id }) {
heroDisplayName && heroContentText heroDisplayName && heroContentText
? `${heroDisplayName}: "${heroContentText}"` ? `${heroDisplayName}: "${heroContentText}"`
: 'Status', : 'Status',
'/s/:id',
); );
const prevRoute = states.history.findLast((h) => { const closeLink = useMemo(() => {
return h === '/' || /notifications/i.test(h); const pathname = snapStates.prevLocation?.pathname;
}); if (!pathname || pathname.startsWith('/s/')) return '/';
const closeLink = `#${prevRoute || '/'}`; return pathname;
}, []);
const onClose = () => {
states.showMediaModal = false;
};
const [limit, setLimit] = useState(LIMIT); const [limit, setLimit] = useState(LIMIT);
const showMore = useMemo(() => { const showMore = useMemo(() => {
@ -281,7 +326,7 @@ function StatusPage({ id }) {
return statuses.length - limit; return statuses.length - limit;
}, [statuses.length, limit]); }, [statuses.length, limit]);
const hasManyStatuses = statuses.length > LIMIT; const hasManyStatuses = statuses.length > THREAD_LIMIT;
const hasDescendants = statuses.some((s) => s.descendant); const hasDescendants = statuses.some((s) => s.descendant);
const ancestors = statuses.filter((s) => s.ancestor); const ancestors = statuses.filter((s) => s.ancestor);
@ -295,17 +340,105 @@ function StatusPage({ id }) {
}, [heroInView]); }, [heroInView]);
useHotkeys(['esc', 'backspace'], () => { useHotkeys(['esc', 'backspace'], () => {
location.hash = closeLink; // location.hash = closeLink;
onClose();
navigate(closeLink);
});
useHotkeys('j', () => {
const activeStatus = document.activeElement.closest(
'.status-link, .status-focus',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
// Select all statuses except those inside collapsed details/summary
// Hat-tip to @AmeliaBR@front-end.social
// https://front-end.social/@AmeliaBR/109784776146144471
scrollableRef.current.querySelectorAll(
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
),
);
console.log({ allStatusLinks });
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
useHotkeys('k', () => {
const activeStatus = document.activeElement.closest(
'.status-link, .status-focus',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
// NOTE: I'm not sure if 'x' is the best shortcut for this, might change it later
// IDEA: x is for expand
useHotkeys('x', () => {
const activeStatus = document.activeElement.closest(
'.status-link, .status-focus',
);
if (activeStatus) {
const details = activeStatus.nextElementSibling;
if (details && details.tagName.toLowerCase() === 'details') {
details.open = !details.open;
}
}
}); });
const { nearReachStart } = useScroll({ const { nearReachStart } = useScroll({
scrollableElement: scrollableRef.current, scrollableElement: scrollableRef.current,
distanceFromStart: 0.5, distanceFromStart: 0.2,
}); });
return ( return (
<div class="deck-backdrop"> <div class="deck-backdrop">
<Link href={closeLink}></Link> <Link to={closeLink} onClick={onClose}></Link>
<div <div
tabIndex="-1" tabIndex="-1"
ref={scrollableRef} ref={scrollableRef}
@ -315,17 +448,6 @@ function StatusPage({ id }) {
> >
<header <header
class={`${heroInView ? 'inview' : ''}`} class={`${heroInView ? 'inview' : ''}`}
onClick={(e) => {
if (
!/^(a|button)$/i.test(e.target.tagName) &&
heroStatusRef.current
) {
heroStatusRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}}
onDblClick={(e) => { onDblClick={(e) => {
// reload statuses // reload statuses
states.reloadStatusPage++; states.reloadStatusPage++;
@ -338,23 +460,34 @@ function StatusPage({ id }) {
</div> */} </div> */}
<h1> <h1>
{!heroInView && heroStatus && uiState !== 'loading' ? ( {!heroInView && heroStatus && uiState !== 'loading' ? (
<span class="hero-heading"> <>
{!!heroPointer && ( <span class="hero-heading">
<> <NameText showAvatar account={heroStatus.account} short />{' '}
<Icon <span class="insignificant">
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'} &bull;{' '}
/>{' '} <RelativeTime
</> datetime={heroStatus.createdAt}
)} format="micro"
<NameText showAvatar account={heroStatus.account} short />{' '} />
<span class="insignificant"> </span>
&bull;{' '} </span>{' '}
<RelativeTime <button
datetime={heroStatus.createdAt} type="button"
format="micro" class="ancestors-indicator light small"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
heroStatusRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Icon
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
/> />
</span> </button>
</span> </>
) : ( ) : (
<> <>
Status{' '} Status{' '}
@ -383,7 +516,39 @@ function StatusPage({ id }) {
</h1> </h1>
<div class="header-side"> <div class="header-side">
<Loader hidden={uiState !== 'loading'} /> <Loader hidden={uiState !== 'loading'} />
<Link class="button plain deck-close" href={closeLink}> <Menu
align="end"
portal={{
// Need this, else the menu click will cause scroll jump
target: scrollableRef.current,
}}
menuButton={
<button type="button" class="button plain4">
<Icon icon="more" alt="Actions" size="xl" />
</button>
}
>
<MenuItem
onClick={() => {
// Click all buttons with class .spoiler but not .spoiling
const buttons = Array.from(
scrollableRef.current.querySelectorAll(
'button.spoiler:not(.spoiling)',
),
);
buttons.forEach((button) => {
button.click();
});
}}
>
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
</MenuItem>
</Menu>
<Link
class="button plain deck-close"
to={closeLink}
onClick={onClose}
>
<Icon icon="x" size="xl" /> <Icon icon="x" size="xl" />
</Link> </Link>
</div> </div>
@ -412,39 +577,43 @@ function StatusPage({ id }) {
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
> >
{isHero ? ( {isHero ? (
<InView threshold={0.1} onChange={onView}> <InView
threshold={0.1}
onChange={onView}
class="status-focus"
tabIndex={0}
>
<Status statusID={statusID} withinContext size="l" /> <Status statusID={statusID} withinContext size="l" />
</InView> </InView>
) : ( ) : (
<Link <Link
class=" class="status-link"
status-link to={`/s/${statusID}`}
" onClick={() => {
href={`#/s/${statusID}`} resetScrollPosition(statusID);
}}
> >
<Status <Status
statusID={statusID} statusID={statusID}
withinContext withinContext
size={thread || ancestor ? 'm' : 's'} size={thread || ancestor ? 'm' : 's'}
/> />
{replies?.length > LIMIT && ( {/* {replies?.length > LIMIT && (
<div class="replies-link"> <div class="replies-link">
<Icon icon="comment" />{' '} <Icon icon="comment" />{' '}
<span title={replies.length}> <span title={replies.length}>
{shortenNumber(replies.length)} {shortenNumber(replies.length)}
</span> </span>
</div> </div>
)} )} */}
</Link> </Link>
)} )}
{descendant && {descendant && replies?.length > 0 && (
replies?.length > 0 && <SubComments
replies?.length <= LIMIT && ( hasManyStatuses={hasManyStatuses}
<SubComments replies={replies}
hasManyStatuses={hasManyStatuses} />
replies={replies} )}
/>
)}
{uiState === 'loading' && {uiState === 'loading' &&
isHero && isHero &&
!!heroStatus?.repliesCount && !!heroStatus?.repliesCount &&
@ -522,40 +691,86 @@ function StatusPage({ id }) {
); );
} }
function SubComments({ function SubComments({ hasManyStatuses, replies }) {
hasManyStatuses, // Set isBrief = true:
replies, // - if less than or 2 replies
onStatusLinkClick = () => {}, // - if replies have no sub-replies
}) { // - if total number of characters of content from replies is less than 500
// If less than or 2 replies and total number of characters of content from replies is less than 500
let isBrief = false; let isBrief = false;
if (replies.length <= 2) { if (replies.length <= 2) {
let totalLength = replies.reduce((acc, reply) => { const containsSubReplies = replies.some(
const { content } = reply; (r) => r.repliesCount > 0 || r.replies?.length > 0,
const length = htmlContentLength(content); );
return acc + length; if (!containsSubReplies) {
}, 0); let totalLength = replies.reduce((acc, reply) => {
isBrief = totalLength < 500; const { content } = reply;
const length = htmlContentLength(content);
return acc + length;
}, 0);
isBrief = totalLength < 500;
}
} }
// Total comments count, including sub-replies
const diveDeep = (replies) => {
return replies.reduce((acc, reply) => {
const { repliesCount, replies } = reply;
const count = replies?.length || repliesCount;
return acc + count + diveDeep(replies || []);
}, 0);
};
const totalComments = replies.length + diveDeep(replies);
const sameCount = replies.length === totalComments;
// Get the first 3 accounts, unique by id
const accounts = replies
.map((r) => r.account)
.filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i)
.slice(0, 3);
const open = isBrief || !hasManyStatuses; const open = isBrief || !hasManyStatuses;
return ( return (
<details class="replies" open={open}> <details class="replies" open={open}>
<summary hidden={open}> <summary hidden={open}>
<span title={replies.length}>{shortenNumber(replies.length)}</span> repl <span class="avatars">
{replies.length === 1 ? 'y' : 'ies'} {accounts.map((a) => (
<Avatar
key={a.id}
url={a.avatarStatic}
title={`${a.displayName} @${a.username}`}
/>
))}
</span>
<span>
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
repl
{replies.length === 1 ? 'y' : 'ies'}
</span>
{!sameCount && totalComments > 1 && (
<>
{' '}
&middot;{' '}
<span>
<span title={totalComments}>{shortenNumber(totalComments)}</span>{' '}
comment
{totalComments === 1 ? '' : 's'}
</span>
</>
)}
</summary> </summary>
<ul> <ul>
{replies.map((r) => ( {replies.map((r) => (
<li key={r.id}> <li key={r.id}>
<Link <Link
class="status-link" class="status-link"
href={`#/s/${r.id}`} to={`/s/${r.id}`}
onClick={onStatusLinkClick} onClick={() => {
resetScrollPosition(r.id);
}}
> >
<Status statusID={r.id} withinContext size="s" /> <Status statusID={r.id} withinContext size="s" />
{r.repliesCount > 0 && ( {!r.replies?.length && r.repliesCount > 0 && (
<div class="replies-link"> <div class="replies-link">
<Icon icon="comment" />{' '} <Icon icon="comment" />{' '}
<span title={r.repliesCount}> <span title={r.repliesCount}>
@ -564,6 +779,12 @@ function SubComments({
</div> </div>
)} )}
</Link> </Link>
{r.replies?.length && (
<SubComments
hasManyStatuses={hasManyStatuses}
replies={r.replies}
/>
)}
</li> </li>
))} ))}
</ul> </ul>

View file

@ -38,9 +38,9 @@
filter: hue-rotate(0deg); filter: hue-rotate(0deg);
} }
100% { 100% {
filter: hue-rotate(360deg); filter: hue-rotate(-90deg);
} }
} }
#welcome:hover h2 { #welcome:hover h2 {
animation: psychedelic 60s infinite; animation: psychedelic 10s infinite alternate;
} }

View file

@ -1,6 +1,7 @@
import './welcome.css'; import './welcome.css';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Link from '../components/link';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
function Welcome() { function Welcome() {
@ -28,9 +29,9 @@ function Welcome() {
<p> <p>
<big> <big>
<b> <b>
<a href="#/login" class="button"> <Link to="/login" class="button">
Log in Log in
</a> </Link>
</b> </b>
</big> </big>
</p> </p>
@ -43,6 +44,13 @@ function Welcome() {
<a href="https://mastodon.social/@cheeaun" target="_blank"> <a href="https://mastodon.social/@cheeaun" target="_blank">
@cheeaun @cheeaun
</a> </a>
.{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
>
Privacy Policy
</a>
. .
</p> </p>
</main> </main>

View file

@ -1,4 +1,5 @@
function emojifyText(text, emojis = []) { function emojifyText(text, emojis = []) {
if (!text) return '';
if (!emojis.length) return text; if (!emojis.length) return text;
// Replace shortcodes in text with emoji // Replace shortcodes in text with emoji
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }] // emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]

View 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;

View file

@ -3,14 +3,18 @@ import { proxy, subscribe } from 'valtio';
import store from './store'; import store from './store';
const states = proxy({ const states = proxy({
history: [], // history: [],
prevLocation: null,
currentLocation: null,
statuses: {}, statuses: {},
statusThreadNumber: {}, statusThreadNumber: {},
home: [], home: [],
specialHome: [], // specialHome: [],
homeNew: [], homeNew: [],
homeLast: null, // Last item in 'home' list
homeLastFetchTime: null, homeLastFetchTime: null,
notifications: [], notifications: [],
notificationLast: null, // Last item in 'notifications' list
notificationsNew: [], notificationsNew: [],
notificationsLastFetchTime: null, notificationsLastFetchTime: null,
accounts: {}, accounts: {},
@ -22,10 +26,11 @@ const states = proxy({
showSettings: false, showSettings: false,
showAccount: false, showAccount: false,
showDrafts: false, showDrafts: false,
showMediaModal: false,
composeCharacterCount: 0, composeCharacterCount: 0,
settings: { settings: {
boostsCarousel: store.local.get('settings:boostsCarousel') boostsCarousel: store.local.get('settings:boostsCarousel')
? store.local.get('settings:boostsCarousel') ? store.local.get('settings:boostsCarousel') === '1'
: true, : true,
}, },
}); });

View file

@ -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);
}

View file

@ -51,7 +51,7 @@ export default function useScroll({
previousScrollStart = scrollStart; previousScrollStart = scrollStart;
} }
setReachStart(scrollStart === 0); setReachStart(scrollStart <= 0);
setReachEnd(scrollStart + clientDimension >= scrollDimension); setReachEnd(scrollStart + clientDimension >= scrollDimension);
setNearReachStart(scrollStart <= distanceFromStartPx); setNearReachStart(scrollStart <= distanceFromStartPx);
setNearReachEnd( setNearReachEnd(

View file

@ -1,9 +1,15 @@
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { matchPath } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import states from './states';
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env; const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
export default function useTitle(title) { export default function useTitle(title, path) {
const snapStates = useSnapshot(states);
useEffect(() => { useEffect(() => {
if (path && !matchPath(path, snapStates.currentLocation)) return;
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME; document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
}, [title]); }, [title, snapStates.currentLocation]);
} }