Compare commits

..

68 commits

Author SHA1 Message Date
Chee Aun 375da8d173
Merge pull request #173 from cheeaun/main
Update from main
2023-06-30 23:25:52 +08:00
Chee Aun 2c31e8e04c
Merge pull request #172 from cheeaun/main
Update from main
2023-06-24 09:15:26 +08:00
Chee Aun 534c4c97cd
Merge pull request #170 from cheeaun/main
Update from main
2023-06-20 21:38:11 +08:00
Chee Aun 482a64cfac
Merge pull request #166 from cheeaun/main
Update from main
2023-06-16 18:21:45 +08:00
Chee Aun 2dc1343f54
Merge pull request #156 from cheeaun/main
Update from main
2023-06-15 09:05:28 +08:00
Chee Aun 5e52fa87e0
Merge pull request #153 from cheeaun/main
Update from main
2023-05-30 09:46:02 +08:00
Chee Aun 6b03ae1fee
Merge pull request #152 from cheeaun/main
Update from main
2023-05-27 21:57:38 +08:00
Chee Aun c763d8b954
Merge pull request #151 from cheeaun/main
Update from main
2023-05-23 13:11:19 +08:00
Chee Aun 0a5d7267d5
Merge pull request #143 from cheeaun/main
Update from main
2023-05-22 23:40:44 +08:00
Chee Aun 5ee926481a
Merge pull request #142 from cheeaun/main
Update from main
2023-05-18 00:13:24 +08:00
Chee Aun 0cd9a2db6e
Merge pull request #141 from cheeaun/main
Update from main
2023-05-16 19:38:41 +08:00
Chee Aun 69f9b750c2
Merge pull request #139 from cheeaun/main
Update from main
2023-05-14 22:02:10 +08:00
Chee Aun f5955ef258
Merge pull request #133 from cheeaun/main
Update from main
2023-05-14 11:55:03 +08:00
Chee Aun 27a999f733
Merge pull request #122 from cheeaun/main
Update from main
2023-05-11 21:59:39 +08:00
Chee Aun 54271101c1
Merge pull request #119 from cheeaun/main
Update from main
2023-04-28 23:23:10 +08:00
Chee Aun d0cbb0812d
Merge pull request #117 from cheeaun/main
Update from main
2023-04-26 15:36:11 +08:00
Chee Aun ad45bf9d19
Merge pull request #97 from cheeaun/main
Update from main
2023-04-25 22:54:01 +08:00
Chee Aun 982f7b3ec4
Merge pull request #94 from cheeaun/main
Update from main
2023-04-07 21:57:29 +08:00
Chee Aun 4e50f227d8
Merge pull request #93 from cheeaun/main
Update from main
2023-04-03 09:28:40 +08:00
Chee Aun 546e77d3e1
Merge pull request #92 from cheeaun/main
Update from main
2023-04-03 01:25:32 +08:00
Chee Aun e29f14bbcf
Merge pull request #86 from cheeaun/main
Update from main
2023-03-31 23:21:27 +08:00
Chee Aun 05e87e084a
Merge pull request #84 from cheeaun/main
Update from main
2023-03-16 23:31:30 +08:00
Chee Aun 01f10d3daa
Merge pull request #83 from cheeaun/main
Update from main
2023-03-15 23:25:58 +08:00
Chee Aun fc615e0c0d
Merge pull request #82 from cheeaun/main
Update from main
2023-03-15 22:12:17 +08:00
Chee Aun 25e9771754
Merge pull request #80 from cheeaun/main
Update from main
2023-03-15 21:21:04 +08:00
Chee Aun 5e916559b3
Merge pull request #79 from cheeaun/main
Update from main
2023-03-15 20:49:59 +08:00
Chee Aun 883fe39b6c
Merge pull request #78 from cheeaun/main
Update from main
2023-03-03 13:08:28 +08:00
Chee Aun 9933d83846
Merge pull request #77 from cheeaun/main
Update from main
2023-03-02 22:56:43 +08:00
Chee Aun 7d806301f2
Merge pull request #74 from cheeaun/main
Update from main
2023-03-02 22:29:28 +08:00
Chee Aun faf9cbf23d
Merge pull request #73 from cheeaun/main
Update from main
2023-02-24 12:35:44 +08:00
Chee Aun a0f79e7eea
Merge pull request #71 from cheeaun/main
Update from main
2023-02-24 12:04:00 +08:00
Chee Aun 0b1974e94b
Merge pull request #70 from cheeaun/main
Update from main
2023-02-23 23:31:09 +08:00
Chee Aun b4a4615b9a
Merge pull request #68 from cheeaun/main
Update from main
2023-02-22 09:51:37 +08:00
Chee Aun dda14587c0
Merge pull request #67 from cheeaun/main
Update from main
2023-02-22 00:47:07 +08:00
Chee Aun ed9289d8c6
Merge pull request #66 from cheeaun/main
Update from main
2023-02-21 09:22:54 +08:00
Chee Aun 6274f2f24f
Merge pull request #63 from cheeaun/main
Update from main
2023-02-20 00:49:21 +08:00
Chee Aun b4e8ba820c
Merge pull request #62 from cheeaun/main
Update from main
2023-02-20 00:23:56 +08:00
Chee Aun 29896dfe0e
Merge pull request #54 from cheeaun/main
Update from main
2023-02-19 22:33:56 +08:00
Chee Aun 69c3f1a082
Merge pull request #53 from cheeaun/main
Update from main
2023-02-01 02:10:43 +08:00
Chee Aun 451dc57a69
Merge pull request #49 from cheeaun/main
Update from main
2023-02-01 01:27:15 +08:00
Chee Aun 4fbee9168d
Merge pull request #48 from cheeaun/main
Update from main
2023-01-17 21:34:52 +08:00
Chee Aun 6ecc015199
Merge pull request #47 from cheeaun/main
Update from main
2023-01-17 18:05:25 +08:00
Chee Aun a7a3d5605b
Merge pull request #42 from cheeaun/main
Update from main
2023-01-06 23:24:13 +08:00
Chee Aun ad4ed66cd6
Merge pull request #41 from cheeaun/main
Update from main
2023-01-01 19:26:30 +08:00
Chee Aun 4277992773
Merge pull request #39 from cheeaun/main
Update from main
2023-01-01 19:06:33 +08:00
Chee Aun 6bcf6b143c
Merge pull request #38 from cheeaun/main
Update from main
2022-12-28 20:57:56 +08:00
Chee Aun 9e9f7a6ea1
Merge pull request #37 from cheeaun/main
Update from main
2022-12-27 22:07:51 +08:00
Chee Aun f0014cb26a
Merge pull request #36 from cheeaun/main
Update from main
2022-12-27 19:59:16 +08:00
Chee Aun b0e118fcab
Merge pull request #34 from cheeaun/main
Update from main
2022-12-27 09:57:15 +08:00
Chee Aun f51201a787
Merge pull request #33 from cheeaun/main
Update from main
2022-12-27 01:18:41 +08:00
Chee Aun 5a035089ab
Merge pull request #31 from cheeaun/main
Update from main
2022-12-27 00:10:32 +08:00
Chee Aun 206f00af40
Merge pull request #27 from cheeaun/main
Update from main
2022-12-24 23:20:13 +08:00
Chee Aun 13de3d9263
Merge pull request #20 from cheeaun/main
Update from main
2022-12-24 23:06:13 +08:00
Chee Aun eb41ddf2de
Merge pull request #18 from cheeaun/main
Update from main
2022-12-22 09:04:05 +08:00
Chee Aun 940e8f5376
Merge pull request #16 from cheeaun/main
Update from main
2022-12-22 08:44:56 +08:00
Chee Aun 77ba42dba9
Merge pull request #13 from cheeaun/main
Update from main
2022-12-21 21:32:38 +08:00
Chee Aun 95e204c439
Merge pull request #12 from cheeaun/main
Update from main
2022-12-19 20:12:56 +08:00
Chee Aun 82770e8035
Merge pull request #11 from cheeaun/main 2022-12-19 18:44:01 +08:00
Chee Aun 818c8e61cd
Merge pull request #10 from cheeaun/main
Update from main
2022-12-18 13:07:45 +08:00
Chee Aun 3b8592e946
Merge pull request #9 from cheeaun/main
Update from main
2022-12-17 18:37:19 +08:00
Chee Aun c0c7d65034
Merge pull request #8 from cheeaun/main
Update from main
2022-12-16 13:59:24 +08:00
Chee Aun 5631126e8d
Merge pull request #7 from cheeaun/main
Update from main
2022-12-16 13:33:54 +08:00
Chee Aun bd2ed53f32
Merge pull request #6 from cheeaun/main
Update from main
2022-12-16 12:28:52 +08:00
Chee Aun 694fa22942
Merge pull request #5 from cheeaun/main
Update from main
2022-12-15 21:48:39 +08:00
Chee Aun 15c3979815
Merge pull request #4 from cheeaun/main
Update from main
2022-12-15 17:45:04 +08:00
Chee Aun ab5f53273f
Merge pull request #3 from cheeaun/main
Update from main
2022-12-15 14:48:02 +08:00
Chee Aun 19c2f9b048
Merge pull request #2 from cheeaun/main
Update from main
2022-12-15 13:17:46 +08:00
Chee Aun a45250ac96
Merge pull request #1 from cheeaun/main
Update from main
2022-12-15 12:02:26 +08:00
187 changed files with 14177 additions and 32575 deletions

7
.env
View file

@ -1,4 +1,3 @@
PHANPY_CLIENT_NAME=Phanpy VITE_CLIENT_NAME=Phanpy
PHANPY_WEBSITE=https://phanpy.social VITE_CLIENT_ID=social.phanpy
PHANPY_LINGVA_INSTANCES="lingva.phanpy.social lingva.lunar.icu lingva.garudalinux.org translate.plausibility.cloud" VITE_WEBSITE=https://phanpy.social
PHANPY_PRIVACY_POLICY_URL="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"

View file

@ -10,8 +10,6 @@ assignees: ''
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
- Which site: [e.g. dev.phanpy.social OR phanpy.social] - Which site: [e.g. dev.phanpy.social OR phanpy.social]
- Which site version: [On Phanpy, go to Settings -> About]
- Which instance: [e.g. mastodon.social]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,32 +0,0 @@
name: Auto-create tag/release on every push to `production`
on:
push:
branches:
- production
jobs:
tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: production
# - run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
# - run: git push --tags
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci && npm run build
- run: cd dist && zip -r ../phanpy-dist.zip . && tar -czf ../phanpy-dist.tar.gz . && cd ..
- id: tag_name
run: echo ::set-output name=tag_name::$(date +%Y.%m.%d).$(git rev-parse --short HEAD)
- uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag_name.outputs.tag_name }}
generate_release_notes: true
files: |
phanpy-dist.zip
phanpy-dist.tar.gz

8
.gitignore vendored
View file

@ -24,10 +24,4 @@ dist-ssr
*.sw? *.sw?
# Custom # Custom
.env.dev .env.dev
phanpy-dist.zip
phanpy-dist.tar.gz
# Nix
.direnv
result

View file

@ -3,20 +3,16 @@
"useTabs": false, "useTabs": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": [ "importOrder": [
"^[^.].*.css$", "^[^.].*.css$",
"index.css$", "index.css$",
".css$", ".css$",
"",
"./polyfills",
"",
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"",
"/assets/",
"",
"^../", "^../",
"",
"^[./]" "^[./]"
] ],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true,
"importOrderCaseInsensitive": true
} }

View file

@ -6,10 +6,6 @@ Phanpy does not collect or process any personal information from its users. The
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/). 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/).
## Translations
Phanpy uses [Lingva API](https://github.com/cheeaun/lingva-api) and [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) as fallbacks for translating post content, profile bio and media description.
## Error logging ## 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. 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.

153
README.md
View file

@ -7,7 +7,7 @@ Phanpy
**Minimalistic opinionated Mastodon web client.** **Minimalistic opinionated Mastodon web client.**
</div> </div>
![Fancy screenshot](readme-assets/fancy-screenshot.jpg) <br>
**🗣️ Pronunciation**: [`/fænpi/`](https://ythi.net/how-do-you-pronounce/phanpy/english/) ([`FAN-pee`](https://www.smogon.com/forums/threads/the-official-name-pronunciation-guide.3474941/)) [🔊 Listen](https://www.youtube.com/watch?v=DIUbWe-ysJI) **🗣️ Pronunciation**: [`/fænpi/`](https://ythi.net/how-do-you-pronounce/phanpy/english/) ([`FAN-pee`](https://www.smogon.com/forums/threads/the-official-name-pronunciation-guide.3474941/)) [🔊 Listen](https://www.youtube.com/watch?v=DIUbWe-ysJI)
@ -43,7 +43,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here. - **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here.
- **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple. - **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple.
- ~~**Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.~~ DTTHDon fork displays the full username by default. - **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.
- **No autoplay for video/GIF/whatever in timeline**.<br>The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.) - **No autoplay for video/GIF/whatever in timeline**.<br>The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.)
- **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day. - **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day.
@ -74,18 +74,6 @@ Everything is designed and engineered following my taste and vision. This is a p
- Limit up to 3 API requests as the root post may be very old or the thread is super long. - Limit up to 3 API requests as the root post may be very old or the thread is super long.
- If index number couldn't be found, badge will fallback to showing `Thread` without the number. - If index number couldn't be found, badge will fallback to showing `Thread` without the number.
### Hashtag stuffing collapsing
![Hashtag stuffing collapsing](readme-assets/hashtag-stuffing-collapsing.jpg)
- First paragraph of post content with more than 3 hashtags will be collapsed to max 3 lines.
- Subsequent paragraphs after first paragraph with more than 3 hashtags will be collapsed to 1 line.
- Adjacent paragraphs with more than 1 hashtag after collapsed paragraphs will be collapsed to 1 line.
- If there are text around or between the hashtags, they will not be collapsed.
- Collapsed hashtags will be appended with `...` at the end.
- They are also slightly faded out to reduce visual noise.
- Opening the post view will reveal the hashtags uncollapsed.
### Filtered posts ### Filtered posts
- "Hide completely"-filtered posts will be hidden, with no UI to reveal it. - "Hide completely"-filtered posts will be hidden, with no UI to reveal it.
@ -93,7 +81,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- Content can be partially revealed by hovering over the post, with tooltip showing the post text. - Content can be partially revealed by hovering over the post, with tooltip showing the post text.
- Clicking it will open the Post page. - Clicking it will open the Post page.
- Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI. - Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI.
- On boosts carousel, they are sorted to the end of the carousel. - On boosts carousel, they are not partially hidden, but sorted to the end of the carousel.
## Development ## Development
@ -103,9 +91,16 @@ Prerequisites: Node.js 18+
- `npm run dev` - Start development server - `npm run dev` - Start development server
- `npm run build` - Build for production - `npm run build` - Build for production
- `npm run preview` - Preview the production build - `npm run preview` - Preview the production build
- `npm run fetch-instances` - Fetch instances list from [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json` - `npm run fetch-instances` - Fetch instances list from [instances.social](https://instances.social/), save it to `src/data/instances.json`
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
- `npm run sourcemap` - Run `source-map-explorer` on the production build - `npm run sourcemap` - Run `source-map-explorer` on the production build
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` and serve the `dist` folder.
Try search for "how to self-host static sites" as there are many ways to do it.
## Tech stack ## Tech stack
- [Vite](https://vitejs.dev/) - Build tool - [Vite](https://vitejs.dev/) - Build tool
@ -114,125 +109,18 @@ Prerequisites: Node.js 18+
- [React Router](https://reactrouter.com/) - Routing - [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
- [MingCute icons](https://www.mingcute.com/) - Vanilla CSS - *Yes, I'm old school.*
- Vanilla CSS - _Yes, I'm old school._
Some of these may change in the future. The front-end world is ever-changing. Some of these may change in the future. The front-end world is ever-changing.
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want.
Two ways (choose one):
### Easy way
Go to [Releases](https://github.com/cheeaun/phanpy/releases) and download the latest `phanpy-dist.zip` or `phanpy-dist.tar.gz`. It's pre-built so don't need to run any install/build commands. Extract it. Serve the folder of extracted files.
### Custom-build way
Requires [Node.js](https://nodejs.org/).
Download or `git clone` this repository. Use `production` branch for *stable* releases, `main` for *latest*. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder.
Customization can be done by passing environment variables to the build command. Examples:
```bash
PHANPY_CLIENT_NAME="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build
```
```bash
PHANPY_DEFAULT_INSTANCE=hachyderm.io \
PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL=https://hachyderm.io/auth/sign_up \
PHANPY_PRIVACY_POLICY_URL=https://hachyderm.io/privacy-policy \
npm run build
```
It's also possible to set them in the `.env` file.
Available variables:
- `PHANPY_CLIENT_NAME` (optional, default: `Phanpy`) affects:
- Web page title, shown in the browser window or tab title
- App title, when installed as PWA, shown in the Home screen, macOS dock, Windows taskbar, etc
- OpenGraph card title, when shared on social networks
- Client name, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients
- `PHANPY_WEBSITE` (optional but recommended, default: `https://phanpy.social`) affects:
- Canonical URL of the website
- OpenGraph card URL, when shared on social networks
- Root path for the OpenGraph card image
- Client URL, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients
- `PHANPY_DEFAULT_INSTANCE` (optional, no defaults):
- e.g. 'mastodon.social', without `https://`
- Default instance for log-in
- When logging in, the user will be redirected instantly to the instance's authentication page instead of having to manually type the instance URL and submit
- `PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL` (optional, no defaults):
- URL of the instance registration page
- E.g. `https://mastodon.social/auth/sign_up`
- `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy):
- URL of the privacy policy page
- May specify the instance's own privacy policy
- `PHANPY_LINGVA_INSTANCES` (optional, space-separated list, default: `lingva.phanpy.social [...hard-coded list of fallback instances]`):
- Specify a space-separated list of instances. First will be used as default before falling back to the subsequent instances. If there's only 1 instance, means no fallback.
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
- List of fallback instances hard-coded in `/.env`
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
- `PHANPY_IMG_ALT_API_URL` (optional, no defaults):
- API endpoint for self-hosted instance of [img-alt-api](https://github.com/cheeaun/img-alt-api).
- If provided, a setting will appear for users to enable the image description generator in the composer. Disabled by default.
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
- This is not self-hosted.
### Static site hosting
Try online search for "how to self-host static sites" as there are many ways to do it.
#### Lingva-translate or lingva-api hosting
See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api).
## Community deployments
These are self-hosted by other wonderful folks.
- [ferengi.one](https://m.ferengi.one/) by [@david@weaknotes.com](https://weaknotes.com/@david)
- [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
> Note: Add yours by creating a pull request.
## Costs
Costs involved in running and developing this web app:
- Domain name (.social): **USD$23.18/year** (USD$6.87 1st year)
- Hosting: Free
- Development, design, maintenance: "Free" (My precious time)
## Mascot ## Mascot
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon. [Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
## Maintainers + contributors ## Maintainers
- [Chee Aun](https://github.com/cheeaun) ([Mastodon](https://mastodon.social/@cheeaun)) ([Twitter](https://twitter.com/cheeaun)) - [Chee Aun](https://github.com/cheeaun) ([Mastodon](https://mastodon.social/@cheeaun)) ([Twitter](https://twitter.com/cheeaun))
[![Contributors](https://contrib.rocks/image?repo=cheeaun/phanpy)](https://github.com/cheeaun/phanpy/graphs/contributors)
## Backstory ## Backstory
I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006. I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006.
@ -247,25 +135,16 @@ And here I am. Building a Mastodon web client.
## Alternative web clients ## Alternative web clients
- Phanpy forks ↓ - [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) → [Semaphore](https://semaphore.social/)
- [Agora](https://agorasocial.app/)
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/)
- [Enafore](https://enafore.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/) - forks ↓ - [Elk](https://elk.zone/)
- [elk.fedified.com](https://elk.fedified.com/)
- [Mastodeck](https://mastodeck.com/) - [Mastodeck](https://mastodeck.com/)
- [Trunks](https://trunks.social/) - [Trunks (alpha)](https://alpha.trunks.social/)
- [Tooty](https://github.com/n1k0/tooty) - [Tooty](https://github.com/n1k0/tooty)
- [Litterbox](https://litterbox.koyu.space/) - [Litterbox](https://litterbox.koyu.space/)
- [Statuzer](https://statuzer.com/) - [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/)
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
- [Mangane](https://github.com/BDX-town/Mangane)
- [TheDesk](https://github.com/cutls/TheDesk)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients) - [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## 💁‍♂️ Notice to all other social media client developers ## 💁‍♂️ Notice to all other social media client developers

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compose / %PHANPY_CLIENT_NAME%</title> <title>Compose / %VITE_CLIENT_NAME%</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
</head> </head>

Binary file not shown.

View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1723175592,
"narHash": "sha256-M0xJ3FbDUc4fRZ84dPGx5VvgFsOzds77KiBMW/mMTnI=",
"owner": "nixOS",
"repo": "nixpkgs",
"rev": "5e0ca22929f3342b19569b21b2f3462f053e497b",
"type": "github"
},
"original": {
"owner": "nixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,60 +0,0 @@
{
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixos-unstable;
inputs.flake-utils.url = github:numtide/flake-utils;
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
esbuild = pkgs.buildGoModule rec {
pname = "esbuild";
version = "0.21.5";
src = pkgs.fetchFromGitHub {
owner = "evanw";
repo = "esbuild";
rev = "v${version}";
hash = "sha256-FpvXWIlt67G8w3pBKZo/mcp57LunxDmRUaCU/Ne89B8=";
};
vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ=";
subPackages = [ "cmd/esbuild" ];
ldflags = [ "-s" "-w" ];
meta.mainProgram = "esbuild";
};
in
rec {
packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy";
version = "0.1.0";
nativeBuildInputs = with pkgs; [ git ];
ESBUILD_BINARY_PATH = lib.getExe esbuild;
src = lib.cleanSource ./.;
npmFlags = [ "--legacy-peer-deps" ];
npmDepsHash = "sha256-VROK9Emxi+jFqwidA/CUxQwxitKf7Y6mx0yuOCUwrzI=";
# npmDepsHash = lib.fakeHash;
# DTTH-specific env variables
PHANPY_CLIENT_NAME = "DTTH Phanpy";
PHANPY_CLIENT_ID = "ch.dtth.phanpy";
PHANPY_WEBSITE = "https://social.dtth.ch";
installPhase = ''
runHook preInstall
mkdir -p $out/lib
cp -r dist $out/lib/phanpy
runHook postInstall
'';
};
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
buildInputs = with pkgs; [ nodejs ];
};
});
}

View file

@ -6,7 +6,7 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover" content="width=device-width, initial-scale=1, viewport-fit=cover"
/> />
<title>%PHANPY_CLIENT_NAME%</title> <title>%VITE_CLIENT_NAME%</title>
<meta <meta
name="description" name="description"
content="Minimalistic opinionated Mastodon web client" content="Minimalistic opinionated Mastodon web client"
@ -14,33 +14,18 @@
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="%PHANPY_CLIENT_NAME%" /> <meta name="apple-mobile-web-app-title" content="%VITE_CLIENT_NAME%" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="canonical" href="%PHANPY_WEBSITE%" /> <link rel="canonical" href="%VITE_WEBSITE%" />
<meta
name=""
data-theme-setting="manual"
content="#242526"
data-theme-light-color="#fff"
data-theme-light-color-temp="#ffff"
data-theme-dark-color="#242526"
data-theme-dark-color-temp="#242526ff"
/>
<meta <meta
name="theme-color" name="theme-color"
data-theme-setting="auto"
content="#fff" content="#fff"
data-content="#fff"
data-content-temp="#fffa"
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
/> />
<meta <meta
name="theme-color" name="theme-color"
data-theme-setting="auto"
content="#242526" content="#242526"
data-content="#242526"
data-content-temp="#242526aa"
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
@ -48,13 +33,13 @@
<!-- Metacrap https://broken-links.com/2015/12/01/little-less-metacrap/ --> <!-- Metacrap https://broken-links.com/2015/12/01/little-less-metacrap/ -->
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="og:url" content="%PHANPY_WEBSITE%" /> <meta property="og:url" content="%VITE_WEBSITE%" />
<meta property="og:title" content="%PHANPY_CLIENT_NAME%" /> <meta property="og:title" content="%VITE_CLIENT_NAME%" />
<meta <meta
property="og:description" property="og:description"
content="Minimalistic opinionated Mastodon web client" content="Minimalistic opinionated Mastodon web client"
/> />
<meta property="og:image" content="%PHANPY_WEBSITE%/og-image-2.jpg" /> <meta property="og:image" content="%VITE_WEBSITE%/og-image-2.jpg" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

11045
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,64 +6,56 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js", "fetch-instances": "env $(cat .env.dev | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"sourcemap": "npx source-map-explorer dist/assets/*.js", "sourcemap": "npx source-map-explorer dist/assets/*.js"
"bundle-visualizer": "npx vite-bundle-visualizer"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.5.4", "@formatjs/intl-localematcher": "~0.4.0",
"@formatjs/intl-segmenter": "~11.5.7", "@github/text-expander-element": "~2.5.0",
"@formkit/auto-animate": "~0.8.2", "@iconify-icons/mingcute": "~1.2.5",
"@github/text-expander-element": "~2.7.1",
"@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.2.1", "@szhsin/react-menu": "~4.0.0",
"compare-versions": "~6.1.1", "@uidotdev/usehooks": "~2.0.1",
"dayjs": "~1.11.12", "dayjs": "~1.11.8",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.4", "fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1", "fast-deep-equal": "~3.1.3",
"fuse.js": "~7.0.0",
"html-prettify": "~1.0.7",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "masto": "~5.11.3",
"masto": "~6.8.0", "mem": "~9.0.2",
"moize": "~6.1.6", "p-retry": "~5.1.2",
"p-retry": "~6.2.0", "p-throttle": "~5.1.0",
"p-throttle": "~6.1.0", "preact": "~10.15.1",
"preact": "~10.23.1", "react-hotkeys-hook": "~4.4.0",
"punycode": "~2.3.1", "react-intersection-observer": "~9.4.4",
"react-hotkeys-hook": "~4.5.0", "react-quick-pinch-zoom": "~4.9.0",
"react-intersection-observer": "~9.13.0",
"react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2", "react-router-dom": "6.6.2",
"string-length": "6.0.0", "string-length": "5.0.1",
"swiped-events": "~1.2.0", "swiped-events": "~1.1.7",
"tinyld": "~1.3.4",
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.2", "uid": "~2.0.2",
"use-debounce": "~10.0.2", "use-debounce": "~9.0.4",
"use-long-press": "~3.2.0", "use-long-press": "~3.1.5",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.13.2" "valtio": "1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "~4.3.1", "@preact/preset-vite": "~2.5.0",
"@preact/preset-vite": "~2.9.0", "@trivago/prettier-plugin-sort-imports": "~4.1.1",
"postcss": "~8.4.40", "postcss": "~8.4.24",
"postcss-dark-theme-class": "~1.3.0", "postcss-dark-theme-class": "~0.7.3",
"postcss-preset-env": "~10.0.0", "postcss-preset-env": "~8.5.0",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~5.3.5", "vite": "~4.3.9",
"vite-plugin-generate-file": "~0.2.0", "vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.20.1", "vite-plugin-pwa": "~0.16.4",
"vite-plugin-remove-console": "~2.2.0", "vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.1.0", "workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.1.0", "workbox-expiration": "~7.0.0",
"workbox-routing": "~7.1.0", "workbox-routing": "~7.0.0",
"workbox-strategies": "~7.1.0" "workbox-strategies": "~7.0.0"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {
@ -75,11 +67,6 @@
} }
} }
}, },
"overrides": {
"vite": {
"rollup": ">=4.5.1"
}
},
"browserslist": [ "browserslist": [
"defaults", "defaults",
"android >= 4" "android >= 4"

View file

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Page not found</title>
<meta name="color-scheme" content="dark light" />
<style>
body {
text-align: center;
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI,
Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
}
h1 {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<h1>Page not found</h1>
<p><a href="/">Go home</a></p>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -9,33 +9,13 @@ import {
self.__WB_DISABLE_DEV_LOGS = true; self.__WB_DISABLE_DEV_LOGS = true;
const assetsRoute = new Route(
({ request, sameOrigin }) => {
const isAsset =
request.destination === 'style' || request.destination === 'script';
const hasHash = /-[0-9a-f]{4,}\./i.test(request.url);
return sameOrigin && isAsset && hasHash;
},
new NetworkFirst({
cacheName: 'assets',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(assetsRoute);
const imageRoute = new Route( const imageRoute = new Route(
({ request, sameOrigin }) => { ({ request, sameOrigin }) => {
const isRemote = !sameOrigin; const isRemote = !sameOrigin;
const isImage = request.destination === 'image'; const isImage = request.destination === 'image';
const isAvatar = request.url.includes('/avatars/'); const isAvatar = request.url.includes('/avatars/');
const isCustomEmoji = request.url.includes('/custom/_emojis');
const isEmoji = request.url.includes('/emoji/'); const isEmoji = request.url.includes('/emoji/');
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji); return isRemote && isImage && (isAvatar || isEmoji);
}, },
new CacheFirst({ new CacheFirst({
cacheName: 'remote-images', cacheName: 'remote-images',
@ -62,7 +42,7 @@ const iconsRoute = new Route(
cacheName: 'icons', cacheName: 'icons',
plugins: [ plugins: [
new ExpirationPlugin({ new ExpirationPlugin({
maxEntries: 300, maxEntries: 50,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true, purgeOnQuotaError: true,
}), }),
@ -96,28 +76,6 @@ const apiExtendedRoute = new RegExpRoute(
); );
registerRoute(apiExtendedRoute); registerRoute(apiExtendedRoute);
// Note: expiration is not working as expected
// https://github.com/GoogleChrome/workbox/issues/3316
//
// const apiIntermediateRoute = new RegExpRoute(
// // Matches:
// // - trends/*
// // - timelines/link
// /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
// new StaleWhileRevalidate({
// cacheName: 'api-intermediate',
// plugins: [
// new ExpirationPlugin({
// maxAgeSeconds: 1 * 60, // 1min
// }),
// new CacheableResponsePlugin({
// statuses: [0, 200],
// }),
// ],
// }),
// );
// registerRoute(apiIntermediateRoute);
const apiRoute = new RegExpRoute( const apiRoute = new RegExpRoute(
// Matches: // Matches:
// - statuses/:id/context - some contexts are really huge // - statuses/:id/context - some contexts are really huge
@ -136,88 +94,3 @@ const apiRoute = new RegExpRoute(
}), }),
); );
registerRoute(apiRoute); registerRoute(apiRoute);
// PUSH NOTIFICATIONS
// ==================
self.addEventListener('push', (event) => {
const { data } = event;
if (data) {
const payload = data.json();
console.log('PUSH payload', payload);
const {
access_token,
title,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = payload;
if (!!navigator.setAppBadge) {
if (notification_type === 'mention') {
navigator.setAppBadge(1);
}
}
event.waitUntil(
self.registration.showNotification(title, {
body,
icon,
dir: 'auto',
badge: '/logo-badge-72.png',
lang: preferred_locale,
tag: notification_id,
timestamp: Date.now(),
data: {
access_token,
notification_type,
},
}),
);
}
});
self.addEventListener('notificationclick', (event) => {
const payload = event.notification;
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) => client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.focus();
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
}
await event.notification.close();
})(),
);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View file

@ -1,12 +1,24 @@
import fs from 'fs'; import fs from 'fs';
const url = 'https://api.joinmastodon.org/servers'; const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
const results = await fetch(url);
const params = new URLSearchParams({
count: 0,
min_users: 500,
sort_by: 'active_users',
sort_order: 'desc',
});
const url = `https://instances.social/api/1.0/instances/list?${params.toString()}`;
const results = await fetch(url, {
headers: {
Authorization: `Bearer ${INSTANCES_SOCIAL_SECRET_TOKEN}`,
},
});
const json = await results.json(); const json = await results.json();
const names = json.instances.map((instance) => instance.name);
const domains = json.map((instance) => instance.domain);
// Write to file // Write to file
const path = './src/data/instances.json'; const path = './src/data/instances.json';
fs.writeFileSync(path, JSON.stringify(domains, null, '\t'), 'utf8'); fs.writeFileSync(path, JSON.stringify(names, null, '\t'), 'utf8');

View file

@ -1,6 +1,7 @@
// Fetch https://lingva.ml/api/v1/languages/{source|target}
import fs from 'fs'; import fs from 'fs';
fetch('https://lingva.phanpy.social/api/v1/languages/source') fetch('https://lingva.ml/api/v1/languages/source')
.then((response) => response.json()) .then((response) => response.json())
.then((json) => { .then((json) => {
const file = './src/data/lingva-source-languages.json'; const file = './src/data/lingva-source-languages.json';
@ -8,7 +9,7 @@ fetch('https://lingva.phanpy.social/api/v1/languages/source')
fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
}); });
fetch('https://lingva.phanpy.social/api/v1/languages/target') fetch('https://lingva.ml/api/v1/languages/target')
.then((response) => response.json()) .then((response) => response.json())
.then((json) => { .then((json) => {
const file = './src/data/lingva-target-languages.json'; const file = './src/data/lingva-target-languages.json';

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
import './app.css'; import './app.css';
import debounce from 'just-debounce-it';
import { import {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -8,32 +7,36 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; import {
matchPath,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import 'swiped-events'; import 'swiped-events';
import { useSnapshot } from 'valtio';
import { subscribe } from 'valtio'; import AccountSheet from './components/account-sheet';
import Compose from './components/compose';
import BackgroundService from './components/background-service'; import Drafts from './components/drafts';
import ComposeButton from './components/compose-button'; import Icon from './components/icon';
import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader'; import Loader from './components/loader';
import Modals from './components/modals'; import MediaModal from './components/media-modal';
import NotificationService from './components/notification-service'; import Modal from './components/modal';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts'; import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404'; import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses'; import AccountStatuses from './pages/account-statuses';
import Accounts from './pages/accounts';
import Bookmarks from './pages/bookmarks'; import Bookmarks from './pages/bookmarks';
import Catchup from './pages/catchup';
import Favourites from './pages/favourites'; import Favourites from './pages/favourites';
import Filters from './pages/filters';
import FollowedHashtags from './pages/followed-hashtags'; import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following'; import Following from './pages/following';
import Hashtag from './pages/hashtag'; import Hashtag from './pages/hashtag';
import Home from './pages/home'; import Home from './pages/home';
import HttpRoute from './pages/http-route'; import HttpRoute from './pages/HttpRoute';
import List from './pages/list'; import List from './pages/list';
import Lists from './pages/lists'; import Lists from './pages/lists';
import Login from './pages/login'; import Login from './pages/login';
@ -41,7 +44,8 @@ import Mentions from './pages/mentions';
import Notifications from './pages/notifications'; import Notifications from './pages/notifications';
import Public from './pages/public'; import Public from './pages/public';
import Search from './pages/search'; import Search from './pages/search';
import StatusRoute from './pages/status-route'; import Settings from './pages/settings';
import Status from './pages/status';
import Trending from './pages/trending'; import Trending from './pages/trending';
import Welcome from './pages/welcome'; import Welcome from './pages/welcome';
import { import {
@ -52,253 +56,38 @@ import {
initPreferences, initPreferences,
} from './utils/api'; } from './utils/api';
import { getAccessToken } from './utils/auth'; import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck'; import openCompose from './utils/open-compose';
import states, { initStates, statusKey } from './utils/states'; import showToast from './utils/show-toast';
import states, { getStatus, saveStatus } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils'; import { getCurrentAccount } from './utils/store-utils';
import useInterval from './utils/useInterval';
import './utils/toast-alert'; import usePageVisibility from './utils/usePageVisibility';
window.__STATES__ = states; window.__STATES__ = states;
window.__STATES_STATS__ = () => {
const keys = [
'statuses',
'accounts',
'spoilers',
'unfurledLinks',
'statusQuotes',
];
const counts = {};
keys.forEach((key) => {
counts[key] = Object.keys(states[key]).length;
});
console.warn('STATE stats', counts);
const { statuses } = states;
const unmountedPosts = [];
for (const key in statuses) {
const $post = document.querySelector(
`[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
);
if (!$post) {
unmountedPosts.push(key);
}
}
console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts);
};
// Experimental "garbage collection" for states
// Every 15 minutes
// Only posts for now
setInterval(() => {
if (!window.__IDLE__) return;
const { statuses, unfurledLinks, notifications } = states;
let keysCount = 0;
const { instance } = api();
for (const key in statuses) {
if (!window.__IDLE__) break;
try {
const $post = document.querySelector(
`[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
);
const postInNotifications = notifications.some(
(n) => key === statusKey(n.status?.id, instance),
);
if (!$post && !postInNotifications) {
delete states.statuses[key];
delete states.statusQuotes[key];
for (const link in unfurledLinks) {
const unfurled = unfurledLinks[link];
const sKey = statusKey(unfurled.id, unfurled.instance);
if (sKey === key) {
delete states.unfurledLinks[link];
break;
}
}
keysCount++;
}
} catch (e) {}
}
if (keysCount) {
console.info(`GC: Removed ${keysCount} keys`);
}
}, 15 * 60 * 1000);
// Preload icons
// There's probably a better way to do this
// Related: https://github.com/vitejs/vite/issues/10600
setTimeout(() => {
for (const icon in ICONS) {
setTimeout(() => {
if (Array.isArray(ICONS[icon])) {
ICONS[icon][0]?.();
} else if (typeof ICONS[icon] === 'object') {
ICONS[icon].module?.();
} else {
ICONS[icon]?.();
}
}, 1);
}
}, 5000);
(() => {
window.__IDLE__ = true;
const nonIdleEvents = [
'mousemove',
'mousedown',
'resize',
'keydown',
'touchstart',
'pointerdown',
'pointermove',
'wheel',
];
const setIdle = () => {
window.__IDLE__ = true;
};
const IDLE_TIME = 3_000; // 3 seconds
const debouncedSetIdle = debounce(setIdle, IDLE_TIME);
const onNonIdle = () => {
window.__IDLE__ = false;
debouncedSetIdle();
};
nonIdleEvents.forEach((event) => {
window.addEventListener(event, onNonIdle, {
passive: true,
capture: true,
});
});
window.addEventListener('blur', setIdle, {
passive: true,
});
// When cursor leaves the window, set idle
document.documentElement.addEventListener(
'mouseleave',
(e) => {
if (!e.relatedTarget && !e.toElement) {
setIdle();
}
},
{
passive: true,
},
);
// document.addEventListener(
// 'visibilitychange',
// () => {
// if (document.visibilityState === 'visible') {
// onNonIdle();
// }
// },
// {
// passive: true,
// },
// );
})();
// Possible fix for iOS PWA theme-color bug
// It changes when loading web pages in "webview"
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const theme = store.local.get('theme');
let $meta;
if (theme) {
// Get current meta
$meta = document.querySelector(
`meta[name="theme-color"][data-theme-setting="manual"]`,
);
if ($meta) {
const color = $meta.content;
const tempColor =
theme === 'light'
? $meta.dataset.themeLightColorTemp
: $meta.dataset.themeDarkColorTemp;
$meta.content = tempColor || '';
setTimeout(() => {
$meta.content = color;
}, 10);
}
} else {
// Get current color scheme
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
// Get current theme-color
$meta = document.querySelector(
`meta[name="theme-color"][media*="${colorScheme}"]`,
);
if ($meta) {
const color = $meta.dataset.content;
const tempColor = $meta.dataset.contentTemp;
$meta.content = tempColor || '';
setTimeout(() => {
$meta.content = color;
}, 10);
}
}
}
});
}
{
const theme = store.local.get('theme');
// If there's a theme, it's NOT auto
if (theme) {
// dark | light
document.documentElement.classList.add(`is-${theme}`);
document
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme || 'dark light');
// Enable manual theme <meta>
const $manualMeta = document.querySelector(
'meta[data-theme-setting="manual"]',
);
if ($manualMeta) {
$manualMeta.name = 'theme-color';
$manualMeta.content =
theme === 'light'
? $manualMeta.dataset.themeLightColor
: $manualMeta.dataset.themeDarkColor;
}
// Disable auto theme <meta>s
const $autoMetas = document.querySelectorAll(
'meta[data-theme-setting="auto"]',
);
$autoMetas.forEach((m) => {
m.name = '';
});
}
const textSize = store.local.get('textSize');
if (textSize) {
document.documentElement.style.setProperty('--text-size', `${textSize}px`);
}
}
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// Change #app dataset based on settings.shortcutsViewMode
if (path.join('.') === 'settings.shortcutsViewMode') {
const $app = document.getElementById('app');
if ($app) {
$app.dataset.shortcutsViewMode = states.shortcuts?.length ? value : '';
}
}
// Add/Remove cloak class to body
if (path.join('.') === 'settings.cloakMode') {
const $body = document.body;
$body.classList.toggle('cloak', value);
}
}
});
function App() { function App() {
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(() => {
const theme = store.local.get('theme');
if (theme) {
document.documentElement.classList.add(`is-${theme}`);
document
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
}
const textSize = store.local.get('textSize');
if (textSize) {
document.documentElement.style.setProperty(
'--text-size',
`${textSize}px`,
);
}
}, []);
useEffect(() => { useEffect(() => {
const instanceURL = store.local.get('instanceURL'); const instanceURL = store.local.get('instanceURL');
@ -309,15 +98,10 @@ function App() {
if (code) { if (code) {
console.log({ code }); console.log({ code });
// Clear the code from the URL // Clear the code from the URL
window.history.replaceState( window.history.replaceState({}, document.title, '/');
{},
document.title,
window.location.pathname || '/',
);
const clientID = store.session.get('clientID'); const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret'); const clientSecret = store.session.get('clientSecret');
const vapidKey = store.session.get('vapidKey');
(async () => { (async () => {
setUIState('loading'); setUIState('loading');
@ -328,31 +112,27 @@ function App() {
code, code,
}); });
const client = initClient({ instance: instanceURL, accessToken }); const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initPreferences(client), initInstance(masto, instanceURL),
initInstance(client, instanceURL), initAccount(masto, instanceURL, accessToken),
initAccount(client, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initPreferences(masto);
setIsLoggedIn(true); setIsLoggedIn(true);
setUIState('default'); setUIState('default');
})(); })();
} else { } else {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount(); const account = getCurrentAccount();
if (account) { if (account) {
setCurrentAccountID(account.info.id); store.session.set('currentAccount', account.info.id);
const { client } = api({ account }); const { masto, instance } = api({ account });
const { instance } = client; console.log('masto', masto);
// console.log('masto', masto); initPreferences(masto);
initStates();
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
await initPreferences(client); await initInstance(masto, instance);
await initInstance(client, instance);
} catch (e) { } catch (e) {
} finally { } finally {
setIsLoggedIn(true); setIsLoggedIn(true);
@ -367,81 +147,47 @@ function App() {
let location = useLocation(); let location = useLocation();
states.currentLocation = location.pathname; states.currentLocation = location.pathname;
// useLayoutEffect(() => {
// states.currentLocation = location.pathname;
// }, [location.pathname]);
useEffect(focusDeck, [location, isLoggedIn]); const focusDeck = () => {
let timer = setTimeout(() => {
if (/\/https?:/.test(location.pathname)) { const columns = document.getElementById('columns');
return <HttpRoute />; if (columns) {
} // Focus first column
// columns.querySelector('.deck-container')?.focus?.();
return ( } else {
<> const backDrop = document.querySelector('.deck-backdrop');
<PrimaryRoutes isLoggedIn={isLoggedIn} loading={uiState === 'loading'} /> if (backDrop) return;
<SecondaryRoutes isLoggedIn={isLoggedIn} /> // Focus last deck
{uiState === 'default' && ( const pages = document.querySelectorAll('.deck-container');
<Routes> const page = pages[pages.length - 1]; // last one
<Route path="/:instance?/s/:id" element={<StatusRoute />} /> if (page && page.tabIndex === -1) {
</Routes> console.log('FOCUS', page);
)} page.focus();
{isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />}
<Modals />
{isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
<KeyboardShortcutsHelp />
</>
);
}
function PrimaryRoutes({ isLoggedIn, loading }) {
const location = useLocation();
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/i.test(pathname);
}, [location]);
return (
<Routes location={nonRootLocation || location}>
<Route
path="/"
element={
isLoggedIn ? (
<Home />
) : loading ? (
<Loader id="loader-root" />
) : (
<Welcome />
)
} }
/> }
<Route path="/login" element={<Login />} /> }, 100);
<Route path="/welcome" element={<Welcome />} /> return () => clearTimeout(timer);
</Routes> };
); useEffect(focusDeck, [location]);
} const showModal =
snapStates.showCompose ||
snapStates.showSettings ||
snapStates.showAccounts ||
snapStates.showAccount ||
snapStates.showDrafts ||
snapStates.showMediaModal ||
snapStates.showShortcutsSettings;
useEffect(() => {
if (!showModal) focusDeck();
}, [showModal]);
function getPrevLocation() { const { prevLocation } = snapStates;
return states.prevLocation || null; const backgroundLocation = useRef(prevLocation || null);
} const isModalPage =
function SecondaryRoutes({ isLoggedIn }) { matchPath('/:instance/s/:id', location.pathname) ||
// const snapStates = useSnapshot(states); matchPath('/s/:id', location.pathname);
const location = useLocation();
// const prevLocation = snapStates.prevLocation;
const backgroundLocation = useRef(getPrevLocation());
const isModalPage = useMemo(() => {
return (
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname)
);
}, [location.pathname, matchPath]);
if (isModalPage) { if (isModalPage) {
if (!backgroundLocation.current) if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
backgroundLocation.current = getPrevLocation();
} else { } else {
backgroundLocation.current = null; backgroundLocation.current = null;
} }
@ -450,35 +196,337 @@ function SecondaryRoutes({ isLoggedIn }) {
location, location,
}); });
if (/\/https?:/.test(location.pathname)) {
return <HttpRoute />;
}
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
// Change #app dataset based on snapStates.settings.shortcutsViewMode
useEffect(() => {
const $app = document.getElementById('app');
if ($app) {
$app.dataset.shortcutsViewMode = snapStates.settings.shortcutsViewMode;
}
}, [snapStates.settings.shortcutsViewMode]);
// Add/Remove cloak class to body
useEffect(() => {
const $body = document.body;
$body.classList.toggle('cloak', snapStates.settings.cloakMode);
}, [snapStates.settings.cloakMode]);
return ( return (
<Routes location={backgroundLocation.current || location}> <>
{isLoggedIn && ( <Routes location={nonRootLocation || location}>
<> <Route
path="/"
element={
isLoggedIn ? (
<Home />
) : uiState === 'loading' ? (
<Loader />
) : (
<Welcome />
)
}
/>
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
</Routes>
<Routes location={backgroundLocation.current || location}>
{isLoggedIn && (
<Route path="/notifications" element={<Notifications />} /> <Route path="/notifications" element={<Notifications />} />
<Route path="/mentions" element={<Mentions />} /> )}
<Route path="/following" element={<Following />} /> {isLoggedIn && <Route path="/mentions" element={<Mentions />} />}
<Route path="/b" element={<Bookmarks />} /> {isLoggedIn && <Route path="/following" element={<Following />} />}
<Route path="/f" element={<Favourites />} /> {isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && (
<Route path="/l"> <Route path="/l">
<Route index element={<Lists />} /> <Route index element={<Lists />} />
<Route path=":id" element={<List />} /> <Route path=":id" element={<List />} />
</Route> </Route>
<Route path="/fh" element={<FollowedHashtags />} /> )}
<Route path="/ft" element={<Filters />} /> {isLoggedIn && <Route path="/ft" element={<FollowedHashtags />} />}
<Route path="/catchup" element={<Catchup />} /> <Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
</> <Route path="/:instance?/a/:id" element={<AccountStatuses />} />
<Route path="/:instance?/p">
<Route index element={<Public />} />
<Route path="l" element={<Public local />} />
</Route>
<Route path="/:instance?/trending" element={<Trending />} />
<Route path="/:instance?/search" element={<Search />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
{uiState === 'default' && (
<Routes>
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes>
)} )}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> {isLoggedIn && (
<Route path="/:instance?/a/:id" element={<AccountStatuses />} /> <button
<Route path="/:instance?/p"> type="button"
<Route index element={<Public />} /> id="compose-button"
<Route path="l" element={<Public local />} /> onClick={(e) => {
</Route> if (e.shiftKey) {
<Route path="/:instance?/trending" element={<Trending />} /> const newWin = openCompose();
<Route path="/:instance?/search" element={<Search />} /> if (!newWin) {
{/* <Route path="/:anything" element={<NotFound />} /> */} alert('Looks like your browser is blocking popups.');
</Routes> states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
)}
{isLoggedIn &&
!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts />
)}
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: 'Post published. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccounts = false;
}
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination }) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
<BackgroundService isLoggedIn={isLoggedIn} />
</>
); );
} }
function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
states.notificationsShowNew = true;
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
// Check for updates service
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
return null;
}
function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}
export { App }; export { App };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,71 +1,39 @@
body.cloak, body.cloak a {
.cloak { text-decoration-color: var(--link-color);
a { }
text-decoration-color: var(--link-color);
}
.name-text, body.cloak .name-text,
.name-text *, body.cloak .name-text *,
.status .content-container, body.cloak .status .content-container,
.status .content-container *, body.cloak .status .content-container *,
.status .content-compact > *, body.cloak .status .content-compact,
.account-container .actions small, body.cloak .account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)), body.cloak .account-container :is(header, main > *:not(.actions)) *,
.account-container :is(header, main > *:not(.actions)) *, body.cloak .header-account,
.header-double-lines *, body.cloak .account-block {
.account-block, text-decoration-thickness: 1.1em;
.catchup-filters .filter-author *, text-decoration-line: line-through;
.post-peek-html *, text-rendering: optimizeSpeed;
.post-peek-content > *, filter: opacity(0.5);
.request-notifications-account *, }
.status.compact-thread *, body.cloak .name-text *,
.status .content-compact { body.cloak .status .content-container *,
text-decoration-thickness: 1.1em; body.cloak .account-container :is(header, main > *:not(.actions)) * {
text-decoration-line: line-through; filter: none;
/* text-rendering: optimizeSpeed; */ }
filter: opacity(0.5);
}
.name-text *,
.status .content-container *,
.account-container :is(header, main > *:not(.actions)) *,
.post-peek-content > * {
filter: none;
}
.status :is(img, video, audio), body.cloak .status :is(img, video, audio),
.media-post .media, body.cloak .avatar,
.avatar *, body.cloak .emoji,
.emoji, body.cloak .header-banner {
.header-banner, filter: contrast(0) !important;
.post-peek-media { background-color: #000 !important;
filter: contrast(0) !important;
background-color: #000 !important;
}
} }
/* SPECIAL CASES */ /* SPECIAL CASES */
@supports (display: -webkit-box) { @supports (display: -webkit-box) {
:is(body.cloak, .cloak) .card :is(.title, .meta) { body.cloak .card :is(.title, .meta) {
background-color: currentColor !important; background-color: var(--text-color) !important;
}
}
body.cloak,
.cloak {
.header-double-lines *,
.account-container .profile-metadata b,
.account-container .actions small,
.account-container .stats *,
.media-container figcaption,
.media-container figcaption > *,
.catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important;
}
.account-container .actions small,
.status .content-compact {
background-color: currentColor !important;
} }
} }

View file

@ -1,178 +0,0 @@
export const ICONS = {
x: () => import('@iconify-icons/mingcute/close-line'),
heart: () => import('@iconify-icons/mingcute/heart-line'),
bookmark: () => import('@iconify-icons/mingcute/bookmark-line'),
'check-circle': () => import('@iconify-icons/mingcute/check-circle-line'),
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
'arrow-left': {
module: () => import('@iconify-icons/mingcute/arrow-left-line'),
rtl: true,
},
'arrow-right': {
module: () => import('@iconify-icons/mingcute/arrow-right-line'),
rtl: true,
},
'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'),
lock: () => import('@iconify-icons/mingcute/lock-line'),
unlock: () => import('@iconify-icons/mingcute/unlock-line'),
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
message: () => import('@iconify-icons/mingcute/mail-line'),
comment: {
module: () => import('@iconify-icons/mingcute/chat-3-line'),
rtl: true,
},
comment2: {
module: () => import('@iconify-icons/mingcute/comment-2-line'),
rtl: true,
},
home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
'follow-add': () => import('@iconify-icons/mingcute/user-add-line'),
poll: [() => import('@iconify-icons/mingcute/chart-bar-line'), '90deg'],
pencil: () => import('@iconify-icons/mingcute/pencil-line'),
quill: () => import('@iconify-icons/mingcute/quill-pen-line'),
at: () => import('@iconify-icons/mingcute/at-line'),
attachment: () => import('@iconify-icons/mingcute/attachment-line'),
upload: () => import('@iconify-icons/mingcute/upload-3-line'),
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
more: () => import('@iconify-icons/mingcute/more-3-line'),
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
external: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true,
},
popout: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true,
},
popin: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rotate: '180deg',
rtl: true,
},
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': {
module: () => import('@iconify-icons/mingcute/left-line'),
rtl: true,
},
'chevron-right': {
module: () => import('@iconify-icons/mingcute/right-line'),
rtl: true,
},
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: {
module: () => import('@iconify-icons/mingcute/share-forward-line'),
rotate: '180deg',
flip: 'horizontal',
rtl: true,
},
thread: () => import('@iconify-icons/mingcute/route-line'),
group: {
module: () => import('@iconify-icons/mingcute/group-line'),
rtl: true,
},
bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: {
module: () => import('@iconify-icons/mingcute/list-check-line'),
rtl: true,
},
search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'),
shortcut: () => import('@iconify-icons/mingcute/lightning-line'),
user: () => import('@iconify-icons/mingcute/user-4-line'),
following: () => import('@iconify-icons/mingcute/walk-line'),
pin: () => import('@iconify-icons/mingcute/pin-line'),
unpin: [() => import('@iconify-icons/mingcute/pin-line'), '180deg'],
bus: () => import('@iconify-icons/mingcute/bus-2-line'),
link: () => import('@iconify-icons/mingcute/link-2-line'),
history: () => import('@iconify-icons/mingcute/history-line'),
share: () => import('@iconify-icons/mingcute/share-2-line'),
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
exit: {
module: () => import('@iconify-icons/mingcute/exit-line'),
rtl: true,
},
translate: () => import('@iconify-icons/mingcute/translate-line'),
play: () => import('@iconify-icons/mingcute/play-fill'),
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
mute: {
module: () => import('@iconify-icons/mingcute/volume-mute-line'),
rtl: true,
},
unmute: {
module: () => import('@iconify-icons/mingcute/volume-line'),
rtl: true,
},
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'),
'180deg',
],
flag: () => import('@iconify-icons/mingcute/flag-1-line'),
time: () => import('@iconify-icons/mingcute/time-line'),
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
filters: () => import('@iconify-icons/mingcute/filter-line'),
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'),
layout4: {
module: () => import('@iconify-icons/mingcute/layout-4-line'),
rtl: true,
},
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: {
module: () => import('@iconify-icons/mingcute/announcement-line'),
rtl: true,
},
alert: () => import('@iconify-icons/mingcute/alert-line'),
round: () => import('@iconify-icons/mingcute/round-fill'),
'arrow-up-circle': () =>
import('@iconify-icons/mingcute/arrow-up-circle-line'),
'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: {
module: () => import('@iconify-icons/mingcute/clipboard-line'),
rtl: true,
},
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: {
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
rtl: true,
},
media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'),
history2: {
module: () => import('@iconify-icons/mingcute/history-2-line'),
rtl: true,
},
document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': {
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
rtl: true,
},
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: {
module: () => import('@iconify-icons/mingcute/quote-left-line'),
rtl: true,
},
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
};

View file

@ -4,10 +4,6 @@
gap: 8px; gap: 8px;
color: var(--text-color); color: var(--text-color);
text-decoration: none; text-decoration: none;
.account-block-acct {
display: inline-block;
}
} }
.account-block:hover b { .account-block:hover b {
text-decoration: underline; text-decoration: underline;
@ -16,57 +12,3 @@
.account-block.skeleton { .account-block.skeleton {
color: var(--bg-faded-color); color: var(--bg-faded-color);
} }
.account-block .verified-field {
display: inline-flex;
align-items: baseline;
gap: 2px;
* {
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
text-overflow: ellipsis;
overflow: hidden;
unicode-bidi: isolate;
direction: initial;
}
a {
pointer-events: none;
color: color-mix(
in lch,
var(--green-color) 20%,
var(--text-insignificant-color) 80%
) !important;
}
.icon {
color: var(--green-color);
transform: translateY(1px);
}
.invisible {
display: none;
}
.ellipsis:after {
content: '…';
}
}
.account-block .account-block-stats {
line-height: 1.25;
margin-top: 2px;
font-size: 0.9em;
color: var(--text-insignificant-color);
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
a {
color: inherit;
text-decoration: none;
}
}

View file

@ -1,30 +1,22 @@
import './account-block.css'; import './account-block.css';
// import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states from '../utils/states';
import Avatar from './avatar'; import Avatar from './avatar';
import EmojiText from './emoji-text'; import EmojiText from './emoji-text';
import Icon from './icon';
function AccountBlock({ function AccountBlock({
skeleton, skeleton,
account, account,
avatarSize = 'xl', avatarSize = 'xl',
useAvatarStatic = false,
instance, instance,
external, external,
internal, internal,
onClick, onClick,
showActivity = false, showActivity = false,
showStats = false,
accountInstance,
hideDisplayName = false,
relationship = {},
excludeRelationshipAttrs = [],
}) { }) {
if (skeleton) { if (skeleton) {
return ( return (
@ -33,17 +25,13 @@ function AccountBlock({
<span> <span>
<b></b> <b></b>
<br /> <br />
<span class="account-block-acct"></span> <span class="account-block-acct">@</span>
</span> </span>
</div> </div>
); );
} }
if (!account) { const navigate = useNavigate();
return null;
}
// const navigate = useNavigate();
const { const {
id, id,
@ -57,44 +45,21 @@ function AccountBlock({
statusesCount, statusesCount,
lastStatusAt, lastStatusAt,
bot, bot,
fields,
note,
group,
followersCount,
createdAt,
locked,
} = account; } = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
acct2 = `@${accountInstance}`;
}
const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value);
const excludedRelationship = {};
for (const r in relationship) {
if (!excludeRelationshipAttrs.includes(r)) {
excludedRelationship[r] = relationship[r];
}
}
const hasRelationship =
excludedRelationship.following ||
excludedRelationship.followedBy ||
excludedRelationship.requested;
return ( return (
<a <a
class="account-block" class="account-block"
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={acct2 ? acct : `@${acct}`} title={`@${acct}`}
onClick={(e) => { onClick={(e) => {
if (external) return; if (external) return;
e.preventDefault(); e.preventDefault();
if (onClick) return onClick(e); if (onClick) return onClick(e);
if (internal) { if (internal) {
// navigate(`/${instance}/a/${id}`); navigate(`/${instance}/a/${id}`);
location.hash = `/${instance}/a/${id}`;
} else { } else {
states.showAccount = { states.showAccount = {
account, account,
@ -103,113 +68,37 @@ function AccountBlock({
} }
}} }}
> >
<Avatar <Avatar url={avatar} size={avatarSize} squircle={bot} />
url={useAvatarStatic ? avatarStatic : avatar || avatarStatic} <span>
size={avatarSize} {displayName ? (
squircle={bot} <b>
/> <EmojiText text={displayName} emojis={emojis} />
<span class="account-block-content"> </b>
{!hideDisplayName && ( ) : (
<> <b>{username}</b>
{displayName ? ( )}
<b> <br />
<EmojiText text={displayName} emojis={emojis} /> <span class="account-block-acct">
</b> @{acct1}
) : (
<b>{username}</b>
)}
</>
)}{' '}
<span class="account-block-acct bidi-isolate">
{acct2 ? '' : '@'}
{acct1}
<wbr /> <wbr />
{acct2} {acct2}
{locked && (
<>
{' '}
<Icon icon="lock" size="s" alt="Locked" />
</>
)}
</span> </span>
{showActivity && ( {showActivity && (
<div class="account-block-stats"> <>
Posts: {shortenNumber(statusesCount)} <br />
{!!lastStatusAt && ( <small class="last-status-at insignificant">
<> Posts: {statusesCount}
{' '} {!!lastStatusAt && (
&middot; Last posted:{' '} <>
{niceDateTime(lastStatusAt, { {' '}
hideTime: true, &middot; Last posted:{' '}
})} {niceDateTime(lastStatusAt, {
</> hideTime: true,
)} })}
</div> </>
)}
{showStats && (
<div class="account-block-stats">
{bot && (
<>
<span class="tag collapsed">
<Icon icon="bot" /> Automated
</span>
</>
)}
{!!group && (
<>
<span class="tag collapsed">
<Icon icon="group" /> Group
</span>
</>
)}
{hasRelationship && (
<div key={relationship.id} class="shazam-container-horizontal">
<div class="shazam-container-inner">
{excludedRelationship.following &&
excludedRelationship.followedBy ? (
<span class="tag minimal">Mutual</span>
) : excludedRelationship.requested ? (
<span class="tag minimal">Requested</span>
) : excludedRelationship.following ? (
<span class="tag minimal">Following</span>
) : excludedRelationship.followedBy ? (
<span class="tag minimal">Follows you</span>
) : null}
</div>
</div>
)}
{!!followersCount && (
<span class="ib">
{shortenNumber(followersCount)}{' '}
{followersCount === 1 ? 'follower' : 'followers'}
</span>
)}
{!!verifiedField && (
<span class="verified-field">
<Icon icon="check-circle" size="s" />{' '}
<span
dangerouslySetInnerHTML={{
__html: enhanceContent(verifiedField.value, { emojis }),
}}
/>
</span>
)}
{!bot &&
!group &&
!hasRelationship &&
!followersCount &&
!verifiedField &&
!!createdAt && (
<span class="created-at">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</span>
)} )}
</div> </small>
</>
)} )}
</span> </span>
</a> </a>

View file

@ -1,152 +1,16 @@
.account-container { .account-container {
/* display: flex; */ display: flex;
/* flex-direction: column; */ flex-direction: column;
/* overflow: hidden; */ overflow: hidden;
overflow-y: auto;
max-width: 100%; max-width: 100%;
--banner-overlap: 44px;
--posting-stats-size: 8px;
--original-color: var(--link-color);
.note {
font-size: 0.95em;
line-height: 1.4;
text-wrap: pretty;
margin-bottom: 16px;
&:empty {
display: none;
}
> *:first-child {
margin-top: 0;
padding-top: 0;
}
> *:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
&:not(:has(p)):not(:empty) {
/* Some notes don't have <p> tags, so we need to add some padding */
padding: 1em 0;
}
}
.posting-stats {
font-size: 90%;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
padding: 8px 12px;
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
}
.posting-stats-bar {
--gap: 0.5px;
--gap-color: var(--outline-color);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
overflow: hidden;
margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
background-color: var(--bg-color);
background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient(
var(--to-forward),
var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) 100%
);
}
.posting-stats-legends {
font-size: 12px;
text-transform: uppercase;
}
.posting-stats-legend-item {
display: inline-block;
width: var(--posting-stats-size);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
background-color: var(--text-insignificant-color);
vertical-align: middle;
margin: 0 4px 2px;
/* border: 1px solid var(--outline-color); */
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
&.posting-stats-legend-item-originals {
background-color: var(--original-color);
}
&.posting-stats-legend-item-replies {
background-color: var(--reply-to-color);
}
&.posting-stats-legend-item-boosts {
background-color: var(--reblog-color);
}
}
} }
.account-container.skeleton { .account-container.skeleton {
color: var(--outline-color); color: var(--outline-color);
} }
.account-container .account-moved {
animation: fade-in 0.3s both ease-in-out 0.3s;
padding: 16px;
background-color: var(--bg-color);
position: absolute;
top: 8px;
inset-inline: 8px;
z-index: 3;
border: 1px solid var(--outline-color);
box-shadow: 0 8px 16px var(--drop-shadow-color);
border-radius: calc(16px - 8px);
overflow: hidden;
p {
margin: 0 0 8px;
padding: 0;
}
.account-block {
background-color: var(--bg-faded-color);
padding: 8px;
border-radius: 8px;
border: 1px solid var(--link-faded-color);
&:hover {
background-color: var(--link-bg-hover-color);
border-color: var(--link-color);
}
b {
color: var(--link-color);
}
}
~ * {
/* pointer-events: none; */
filter: grayscale(0.75) opacity(0.75);
}
}
.account-container .header-banner { .account-container .header-banner {
/* pointer-events: none; */ /* pointer-events: none; */
vertical-align: top;
aspect-ratio: 6 / 1; aspect-ratio: 6 / 1;
width: 100%; width: 100%;
height: auto; height: auto;
@ -171,7 +35,7 @@
hsla(0, 0%, 0%, 0.013) 95.3%, hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100% hsla(0, 0%, 0%, 0) 100%
); );
margin-bottom: calc(-1 * var(--banner-overlap)); margin-bottom: -44px;
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
opacity: 0; opacity: 0;
@ -181,8 +45,8 @@
opacity: 1; opacity: 1;
} }
.sheet .account-container .header-banner { .sheet .account-container .header-banner {
border-start-start-radius: 16px; border-top-left-radius: 16px;
border-start-end-radius: 16px; border-top-right-radius: 16px;
} }
.account-container .header-banner.header-is-avatar { .account-container .header-banner.header-is-avatar {
mask-image: linear-gradient( mask-image: linear-gradient(
@ -212,26 +76,18 @@
} }
.account-container .header-banner:active { .account-container .header-banner:active {
mask-image: none; mask-image: none;
}
& + header { .account-container .header-banner:active + header .avatar + * {
background-image: none; transition: opacity 0.3s ease-in-out;
} opacity: 0 !important;
}
& + header .avatar + * { .account-container .header-banner:active + header .avatar {
transition: opacity 0.3s ease-in-out; transition: filter 0.3s ease-in-out;
opacity: 0 !important; filter: none !important;
} }
.account-container .header-banner:active + header .avatar img {
&, transition: border-radius 0.3s ease-in-out;
& + header .avatar { border-radius: 8px;
transition: filter 0.3s ease-in-out;
filter: none !important;
}
& + header .avatar img {
transition: border-radius 0.3s ease-in-out;
border-radius: 8px;
}
} }
@media (min-height: 480px) { @media (min-height: 480px) {
@ -269,140 +125,35 @@
animation: fade-in 0.3s both ease-in-out 0.2s; animation: fade-in 0.3s both ease-in-out 0.2s;
} }
.account-container .account-block .account-block-acct { .account-container .note {
display: block; font-size: 95%;
opacity: 0.7; line-height: 1.4;
} }
.account-container .note:not(:has(p)):not(:empty) {
.private-note-tag { /* Some notes don't have <p> tags, so we need to add some padding */
z-index: 1; padding: 1em 0;
appearance: none;
display: inline-block;
color: var(--private-note-text-color);
background-color: var(--private-note-bg-color);
border: 1px solid var(--private-note-border-color);
padding: 4px;
line-height: normal;
font-size: smaller;
border-radius: 0;
align-self: center !important;
/* clip a dog ear on top right */
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
&:dir(rtl) {
/* top left */
clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px);
}
/* 4x4px square on top right */
background-size: 4px 4px;
background-repeat: no-repeat;
background-position: top right;
&:dir(rtl) {
background-position: top left;
}
background-image: linear-gradient(
to bottom,
var(--private-note-border-color),
var(--private-note-border-color)
);
transition: transform 0.15s ease-in-out;
overflow-wrap: anywhere;
span {
color: inherit;
opacity: 0.75;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
text-align: start;
}
&:hover:not(:active) {
filter: none !important;
transform: rotate(-0.5deg) scale(1.05);
span {
opacity: 1;
}
}
}
.account-container .private-note {
font-size: 90%;
color: var(--text-insignificant-color);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 12px;
background-color: var(--bg-faded-color);
display: flex;
gap: 0.5em;
align-items: center;
b {
font-size: 90%;
text-transform: uppercase;
}
p {
margin: 0;
padding: 0;
}
} }
.account-container .stats { .account-container .stats {
display: flex; display: flex;
/* flex-wrap: wrap; */ flex-wrap: wrap;
column-gap: 24px; justify-content: space-around;
row-gap: 8px; gap: 16px;
/* opacity: 0.75; */ opacity: 0.75;
font-size: 90%; font-size: 90%;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
padding: 12px; padding: 12px;
/* border-radius: 16px; */ border-radius: 16px;
line-height: 1.25; line-height: 1.25;
overflow-x: auto !important;
justify-content: flex-start;
position: relative;
[tabindex='0']:is(:hover, :focus) {
color: var(--text-color);
cursor: pointer;
text-decoration-color: var(--text-insignificant-color);
}
.stats-avatars-bunch {
animation: appear 1s both ease-in-out;
> *:not(:first-child) {
margin: 0;
margin-inline-start: -4px;
}
}
}
.timeline-start .account-container .stats {
flex-wrap: wrap;
} }
.account-container .stats > * { .account-container .stats > * {
/* text-align: center; */ text-align: center;
flex-shrink: 0;
display: flex;
gap: 0.5em;
} }
.account-container .stats a:not(.insignificant) { .account-container .stats a {
color: inherit;
}
.account-container .stats a:hover {
color: inherit; color: inherit;
} }
.account-container footer {
padding: 0 16px 16px;
}
.account-container .actions { .account-container .actions {
/* margin-block: 8px; */
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
@ -410,47 +161,18 @@
align-items: center; align-items: center;
} }
.account-container .actions button { .account-container .actions button {
/* align-self: flex-end; */ align-self: flex-end;
} }
.account-container .actions .buttons { .account-container .actions .buttons {
display: flex; display: flex;
align-items: center;
}
.account-container .account-metadata-box {
overflow: hidden;
border-radius: 16px;
display: block;
text-decoration: none;
& > * {
margin-bottom: 2px;
border-radius: 4px;
overflow: hidden;
}
&:has(+ .account-metadata-box) {
border-end-start-radius: 4px;
border-end-end-radius: 4px;
}
+ .account-metadata-box {
border-start-start-radius: 4px;
border-start-end-radius: 4px;
border-end-start-radius: 16px;
border-end-end-radius: 16px;
}
} }
.account-container .profile-metadata { .account-container .profile-metadata {
display: flex; display: flex;
/* flex-wrap: wrap; */
gap: 2px;
overflow: hidden;
overflow-x: auto;
}
.timeline-start .account-container .profile-metadata {
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px;
border-radius: 16px;
overflow: hidden;
} }
.account-container .profile-field { .account-container .profile-field {
min-width: 0; min-width: 0;
@ -461,15 +183,6 @@
border-radius: 4px; border-radius: 4px;
filter: saturate(0.75); filter: saturate(0.75);
line-height: 1.25; line-height: 1.25;
flex-shrink: 0;
max-width: calc(100% - 12px - 2em);
}
.account-container .profile-field:only-child {
max-width: 100%;
}
.timeline-start .account-container .profile-field {
flex-shrink: 1;
max-width: 100%;
} }
.account-container :is(.note, .profile-field) .invisible { .account-container :is(.note, .profile-field) .invisible {
@ -491,210 +204,67 @@
margin: 0; margin: 0;
} }
.account-container .common-followers { .account-container .common-followers p {
font-size: 90%; font-size: 90%;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
background-color: var(--bg-faded-color); border-top: 1px solid var(--outline-color);
padding: 8px 12px; border-bottom: 1px solid var(--outline-color);
padding: 8px 0;
margin: 0; margin: 0;
} }
.timeline-start .account-container { .timeline-start .account-container {
border-bottom: 1px solid var(--outline-color); border-bottom: 1px solid var(--outline-color);
position: relative;
} }
.timeline-start .account-container header { .timeline-start .account-container header {
padding: 16px; padding: 16px 16px 1px;
animation: none; animation: none;
} }
.timeline-start .account-container main { .timeline-start .account-container main {
padding: 1px 16px 16px; padding: 1px 16px 1px;
} }
.timeline-start .account-container main > * { .timeline-start .account-container main > * {
animation: none; animation: none;
} }
.timeline-start .account-container .account-block .account-block-acct {
.faux-header-bg { opacity: 0.5;
display: none;
} }
@keyframes bye-banner { @keyframes shine {
20% { 0% {
filter: blur(0) opacity(1); left: -100%;
} }
100% { 100% {
filter: blur(16px) opacity(0.2); left: 100%;
} }
} }
@keyframes surface-header { .timeline-start .account-container {
0% { position: relative;
border-bottom-color: transparent; overflow: hidden;
box-shadow: none;
}
100% {
border-bottom-color: var(--outline-color);
box-shadow: 0 8px 16px -8px var(--drop-shadow-color);
}
} }
@keyframes shrink-avatar { .timeline-start .account-container:before {
0% { content: '';
width: 64px; position: absolute;
height: 64px; z-index: 2;
} width: 100%;
100% { height: 100%;
width: 2.5em; background-image: linear-gradient(
height: 2.5em; 100deg,
} rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0) 70%
);
top: 0;
left: -100%;
pointer-events: none;
} }
.sheet .account-container { @media (prefers-color-scheme: dark) {
border-radius: 16px 16px 0 0; .timeline-start .account-container:before {
overflow-x: hidden;
max-height: 75vh;
overscroll-behavior: none;
scroll-timeline: --account-scroll;
header {
padding-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
background-image: linear-gradient(
to bottom,
transparent 30%,
var(--bg-color) var(--banner-overlap),
var(--bg-color) calc(100% - 8px),
transparent
);
.account-block-content {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 3;
-webkit-line-clamp: 3;
}
}
.faux-header-bg {
display: block;
height: var(--banner-overlap);
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bg-color);
margin-top: calc(-1 * var(--banner-overlap));
}
@supports (animation-timeline: scroll()) {
.header-banner:not(.header-is-avatar):not(:hover):not(:active) {
animation: bye-banner 1s linear both;
animation-timeline: view();
animation-range: contain 100% cover 100%;
}
header {
background-image: linear-gradient(
to bottom,
transparent 30%,
var(--bg-color) var(--banner-overlap)
);
border-bottom: 1px solid transparent;
animation: surface-header 1s linear both;
animation-timeline: --account-scroll;
animation-range: 0 150px;
}
header .avatar {
animation: shrink-avatar 1s linear both;
animation-timeline: --account-scroll;
animation-range: 0 150px;
}
}
main {
/* margin-top: -8px; */
padding-top: 1px;
padding-bottom: 16px;
}
footer {
min-height: calc(40px + 16px);
animation: slide-up 0.3s ease-out 0.3s both;
position: sticky;
bottom: 0;
background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px) saturate(3);
padding: 8px 16px;
border-top: var(--hairline-width) solid var(--outline-color);
padding-bottom: max(8px, env(safe-area-inset-bottom));
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
}
}
@keyframes swoosh-bg-image {
0% {
background-position: -320px 0;
opacity: 0.25; opacity: 0.25;
} }
100% {
background-position: 0 0;
opacity: 1;
}
} }
.account-container .posting-stats-button { .timeline-start .account-container:hover:before {
display: flex; animation: shine 1s ease-in-out 1s;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
color: inherit;
background-color: var(--bg-faded-color);
padding: 8px 12px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1;
vertical-align: text-top;
border-radius: 4px;
&:is(:hover, :focus-within) {
color: var(--text-color);
background-color: var(--link-bg-hover-color);
filter: none !important;
}
.loader-container {
margin: 0;
opacity: 0.5;
transform: scale(0.75);
}
}
@keyframes wobble {
0% {
transform: rotate(-4deg);
}
100% {
transform: rotate(4deg);
}
}
@keyframes loading-spin {
0% {
transform: rotate(0deg) scale(0.75);
}
100% {
transform: rotate(360deg) scale(0.75);
}
}
.posting-stats-icon {
display: inline-block;
width: 24px;
height: 8px;
filter: opacity(0.75);
animation: wobble 2s linear both infinite alternate !important;
&.loading {
animation: loading-spin 0.35s linear both infinite !important;
}
} }
#list-add-remove-container .list-add-remove { #list-add-remove-container .list-add-remove {
@ -727,7 +297,6 @@
@media (min-width: 40em) { @media (min-width: 40em) {
.timeline-start .account-container { .timeline-start .account-container {
--banner-overlap: 77px;
--item-radius: 16px; --item-radius: 16px;
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
margin: 16px 0; margin: 16px 0;
@ -744,12 +313,12 @@
var(--shadow-offset) var(--shadow-offset) var(--shadow-blur) var(--shadow-offset) var(--shadow-offset) var(--shadow-blur)
var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color)); var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color));
} }
/* .timeline-start .account-container .header-banner { .timeline-start .account-container .header-banner {
margin-bottom: -77px; margin-bottom: -77px;
} */ }
.timeline-start .account-container header .account-block { .timeline-start .account-container header .account-block {
font-size: 175%; font-size: 175%;
/* margin-bottom: -8px; */ margin-bottom: -8px;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.5px; letter-spacing: -0.5px;
mix-blend-mode: multiply; mix-blend-mode: multiply;
@ -762,135 +331,3 @@
drop-shadow(8px 0 8px var(--header-color-4, --bg-color)); drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
} }
} }
#private-note-container {
textarea {
margin-top: 8px;
width: 100%;
resize: vertical;
height: 33vh;
min-height: 25vh;
max-height: 50vh;
color: var(--private-note-text-color);
background-color: var(--private-note-bg-color);
border: 1px solid var(--private-note-border-color);
box-shadow: 0 2px 8px var(--drop-shadow-color);
border-radius: 0;
padding: 16px;
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
#edit-profile-container {
p {
margin-block: 8px;
}
label {
input,
textarea {
display: block;
width: 100%;
}
textarea {
resize: vertical;
min-height: 5em;
max-height: 50vh;
}
}
table {
width: 100%;
th {
text-align: start;
color: var(--text-insignificant-color);
font-weight: normal;
font-size: 0.8em;
text-transform: uppercase;
}
tbody tr td:first-child {
width: 40%;
}
input {
width: 100%;
}
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
.handle-info {
.handle-handle {
display: inline-block;
margin-block: 5px;
b {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
display: inline-block;
box-shadow: 0 0 0 5px var(--bg-blur-color);
&.handle-username {
color: var(--orange-fg-color);
background-color: var(--orange-bg-color);
}
&.handle-server {
color: var(--purple-fg-color);
background-color: var(--purple-bg-color);
}
}
}
.handle-at {
display: inline-block;
margin-inline: -3px;
position: relative;
z-index: 1;
}
.handle-legend {
margin-top: 0.25em;
}
.handle-legend-icon {
overflow: hidden;
display: inline-block;
width: 14px;
height: 14px;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
&.username {
background-color: var(--orange-fg-color);
border-color: var(--orange-bg-color);
}
&.server {
background-color: var(--purple-fg-color);
border-color: var(--purple-bg-color);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api'; import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import useLocationChange from '../utils/useLocationChange';
import AccountInfo from './account-info'; import AccountInfo from './account-info';
import Icon from './icon'; import Icon from './icon';
@ -11,25 +11,26 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance }); const { masto, instance, authenticated } = api({ instance: propInstance });
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const escRef = useHotkeys('esc', onClose, [onClose]);
useEffect(() => { useEffect(() => {
if (!isString) { if (!isString) {
states.accounts[`${account.id}@${instance}`] = account; states.accounts[`${account.id}@${instance}`] = account;
} }
}, [account]); }, [account]);
useLocationChange(onClose);
return ( return (
<div <div
ref={escRef}
class="sheet" class="sheet"
// onClick={(e) => { onClick={(e) => {
// const accountBlock = e.target.closest('.account-block'); const accountBlock = e.target.closest('.account-block');
// if (accountBlock) { if (accountBlock) {
// onClose({ onClose({
// destination: 'account-statuses', destination: 'account-statuses',
// }); });
// } }
// }} }}
> >
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}> <button type="button" class="sheet-close outer" onClick={onClose}>
@ -49,7 +50,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
}); });
return info; return info;
} catch (e) { } catch (e) {
const result = await masto.v2.search.fetch({ const result = await masto.v2.search({
q: account, q: account,
type: 'accounts', type: 'accounts',
limit: 1, limit: 1,
@ -57,22 +58,6 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
}); });
if (result.accounts.length) { if (result.accounts.length) {
return result.accounts[0]; return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = URL.parse(account);
const { hostname, pathname } = accountURL;
const acct =
pathname.replace(/^\//, '').replace(/\/$/, '') +
'@' +
hostname;
const result = await masto.v2.search.fetch({
q: acct,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
return result.accounts[0];
}
} }
} }
} else { } else {

View file

@ -8,31 +8,23 @@
box-shadow: 0 0 0 1px var(--bg-blur-color); box-shadow: 0 0 0 1px var(--bg-blur-color);
flex-shrink: 0; flex-shrink: 0;
vertical-align: middle; vertical-align: middle;
}
&.has-alpha { .avatar.has-alpha {
border-radius: 0; border-radius: 0;
background-color: transparent; }
box-shadow: none; .avatar:not(.has-alpha).squircle {
border-radius: 25%;
img { }
background-color: transparent;
} .avatar img {
} width: 100%;
&:not(.has-alpha).squircle { height: 100%;
border-radius: 25%; object-fit: cover;
} background-color: var(--img-bg-color);
}
img {
width: 100%; .avatar[data-loaded],
height: 100%; .avatar[data-loaded] img {
object-fit: cover; box-shadow: none;
background-color: var(--img-bg-color); background-color: transparent;
contain: none;
}
&[data-loaded],
&[data-loaded] img {
box-shadow: none;
background-color: transparent;
}
} }

View file

@ -2,8 +2,6 @@ import './avatar.css';
import { useRef } from 'preact/hooks'; import { useRef } from 'preact/hooks';
import mem from '../utils/mem';
const SIZES = { const SIZES = {
s: 16, s: 16,
m: 20, m: 20,
@ -18,10 +16,7 @@ const alphaCache = {};
const canvas = window.OffscreenCanvas const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1) ? new OffscreenCanvas(1, 1)
: document.createElement('canvas'); : document.createElement('canvas');
const ctx = canvas.getContext('2d', { const ctx = canvas.getContext('2d');
willReadFrequently: true,
});
ctx.imageSmoothingEnabled = false;
function Avatar({ url, size, alt = '', squircle, ...props }) { function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m; size = SIZES[size] || size || SIZES.m;
@ -63,32 +58,29 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
if (avatarRef.current) avatarRef.current.dataset.loaded = true; if (avatarRef.current) avatarRef.current.dataset.loaded = true;
if (alphaCache[url] !== undefined) return; if (alphaCache[url] !== undefined) return;
if (isMissing) return; if (isMissing) return;
setTimeout(() => { try {
try { // Check if image has alpha channel
// Check if image has alpha channel const { width, height } = e.target;
const { width, height } = e.target; if (canvas.width !== width) canvas.width = width;
if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height;
if (canvas.height !== height) canvas.height = height; ctx.drawImage(e.target, 0, 0);
ctx.drawImage(e.target, 0, 0); const allPixels = ctx.getImageData(0, 0, width, height);
const allPixels = ctx.getImageData(0, 0, width, height); // At least 10% of pixels have alpha <= 128
// At least 10% of pixels have alpha <= 128 const hasAlpha =
const hasAlpha = allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
allPixels.data.filter( .length /
(pixel, i) => i % 4 === 3 && pixel <= 128, (allPixels.data.length / 4) >
).length / 0.1;
(allPixels.data.length / 4) > if (hasAlpha) {
0.1; // console.log('hasAlpha', hasAlpha, allPixels.data);
if (hasAlpha) { avatarRef.current.classList.add('has-alpha');
// console.log('hasAlpha', hasAlpha, allPixels.data);
avatarRef.current.classList.add('has-alpha');
}
alphaCache[url] = hasAlpha;
ctx.clearRect(0, 0, width, height);
} catch (e) {
// Silent fail
alphaCache[url] = false;
} }
}, 1); alphaCache[url] = hasAlpha;
ctx.clearRect(0, 0, width, height);
} catch (e) {
// Silent fail
alphaCache[url] = false;
}
}} }}
/> />
)} )}
@ -96,4 +88,4 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
); );
} }
export default mem(Avatar); export default Avatar;

View file

@ -1,142 +0,0 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
const POLL_INTERVAL = 20_000; // 20 seconds
export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
sinceId: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
if (skipCheckMarkers) {
states.notificationsShowNew = true;
} else {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
timeline: 'notifications',
});
lastReadId = markers?.notifications?.lastReadId;
} catch (e) {}
if (lastReadId) {
states.notificationsShowNew = notifications[0].id !== lastReadId;
} else {
states.notificationsShowNew = true;
}
}
}
}
};
useEffect(() => {
let sub;
let streamTimeout;
let pollNotifications;
if (isLoggedIn && visible) {
const { masto, streaming, instance } = api();
(async () => {
// 1. Get the latest notification
await checkLatestNotification(masto, instance);
let hasStreaming = false;
// 2. Start streaming
if (streaming) {
streamTimeout = setTimeout(() => {
(async () => {
try {
hasStreaming = true;
sub = streaming.user.notification.subscribe();
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
if (!visible) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
}
console.log('💥 Streaming notification loop STOPPED');
} catch (e) {
hasStreaming = false;
console.error(e);
}
if (!hasStreaming) {
console.log('🎏 Streaming failed, fallback to polling');
pollNotifications = setInterval(() => {
checkLatestNotification(masto, instance, true);
}, POLL_INTERVAL);
}
})();
}, STREAMING_TIMEOUT);
}
})();
}
return () => {
sub?.unsubscribe?.();
sub = null;
clearTimeout(streamTimeout);
clearInterval(pollNotifications);
};
}, [visible, isLoggedIn]);
// Check for updates service
const lastCheckDate = useRef();
const checkForUpdates = () => {
lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
.then((info) => {
if (info) states.appVersion = info;
})
.catch((e) => {
console.error(e);
});
};
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
usePageVisibility((visible) => {
if (visible) {
if (!lastCheckDate.current) {
checkForUpdates();
} else {
const diff = Date.now() - lastCheckDate.current;
if (diff > 1000 * 60 * 60) {
// 1 hour
checkForUpdates();
}
}
}
});
// Global keyboard shortcuts "service"
useHotkeys('shift+alt+k', () => {
const currentCloakMode = states.settings.cloakMode;
states.settings.cloakMode = !currentCloakMode;
showToast({
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,
});
});
return null;
});

View file

@ -9,7 +9,6 @@ import List from '../pages/list';
import Mentions from '../pages/mentions'; import Mentions from '../pages/mentions';
import Notifications from '../pages/notifications'; import Notifications from '../pages/notifications';
import Public from '../pages/public'; import Public from '../pages/public';
import Search from '../pages/search';
import Trending from '../pages/trending'; import Trending from '../pages/trending';
import states from '../utils/states'; import states from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -19,8 +18,6 @@ function Columns() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
console.debug('RENDER Columns', shortcuts);
const components = shortcuts.map((shortcut) => { const components = shortcuts.map((shortcut) => {
if (!shortcut) return null; if (!shortcut) return null;
const { type, ...params } = shortcut; const { type, ...params } = shortcut;
@ -34,16 +31,9 @@ function Columns() {
hashtag: Hashtag, hashtag: Hashtag,
mentions: Mentions, mentions: Mentions,
trending: Trending, trending: Trending,
search: Search,
}[type]; }[type];
if (!Component) return null; if (!Component) return null;
// Don't show Search column with no query, for now return <Component {...params} />;
if (type === 'search' && !params.query) return null;
// Don't show List column with no list, for now
if (type === 'list' && !params.id) return null;
return (
<Component key={type + JSON.stringify(params)} {...params} columnMode />
);
}); });
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
@ -55,24 +45,7 @@ function Columns() {
} }
}); });
return ( return <div id="columns">{components}</div>;
<div
id="columns"
onContextMenu={(e) => {
// If right-click on header, but not links or buttons
if (
e.target.closest('.deck > header') &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
e.preventDefault();
states.showShortcutsSettings = true;
}
}}
>
{components}
</div>
);
} }
export default Columns; export default Columns;

View file

@ -1,51 +0,0 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import openCompose from '../utils/open-compose';
import openOSK from '../utils/open-osk';
import states from '../utils/states';
import Icon from './icon';
export default function ComposeButton() {
const snapStates = useSnapshot(states);
function handleButton(e) {
if (snapStates.composerState.minimized) {
states.composerState.minimized = false;
openOSK();
return;
}
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
states.showCompose = true;
}
} else {
openOSK();
states.showCompose = true;
}
}
useHotkeys('c, shift+c', handleButton, {
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
});
return (
<button
type="button"
id="compose-button"
onClick={handleButton}
class={`${snapStates.composerState.minimized ? 'min' : ''} ${
snapStates.composerState.publishing ? 'loading' : ''
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
);
}

View file

@ -1,48 +0,0 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
function importIntlSegmenter() {
if (!supportsIntlSegmenter) {
return import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
}
function importCompose() {
return import('./compose');
}
export async function preload() {
try {
await importIntlSegmenter();
importCompose();
} catch (e) {
console.error(e);
}
}
export default function ComposeSuspense(props) {
const [Compose, setCompose] = useState(null);
useEffect(() => {
(async () => {
try {
if (supportsIntlSegmenter) {
const component = await importCompose();
setCompose(component);
} else {
await importIntlSegmenter();
const component = await importCompose();
setCompose(component);
}
} catch (e) {
console.error(e);
}
})();
}, []);
return Compose?.default ? <Compose.default {...props} /> : <Loader />;
}

View file

@ -16,6 +16,7 @@
} }
#compose-container .compose-top { #compose-container .compose-top {
text-align: right;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
@ -24,19 +25,28 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
white-space: nowrap;
} }
#compose-container .compose-top .account-block {
text-align: start; #compose-container textarea {
pointer-events: none; width: 100%;
overflow: hidden; max-width: 100%;
color: var(--text-insignificant-color); height: 5em;
line-height: 1.1; min-height: 5em;
font-size: 90%; max-height: 50vh;
background-color: var(--bg-faded-blur-color); resize: vertical;
backdrop-filter: blur(16px); line-height: 1.4;
padding-inline-end: 1em; border-color: transparent;
border-radius: 9999px; }
#compose-container textarea:hover {
border-color: var(--divider-color);
}
@media (min-width: 40em) {
#compose-container textarea {
font-size: 150%;
font-size: calc(100% + 50% / var(--text-weight));
max-height: 65vh;
}
} }
@keyframes appear-up { @keyframes appear-up {
@ -61,7 +71,7 @@
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color); box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
} }
#compose-container .status-preview:has(.status-badge:not(:empty)) { #compose-container .status-preview:has(.status-badge:not(:empty)) {
border-start-end-radius: 8px; border-top-right-radius: 8px;
} }
#compose-container .status-preview :is(.content-container, .time) { #compose-container .status-preview :is(.content-container, .time) {
pointer-events: none; pointer-events: none;
@ -94,10 +104,6 @@
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color); 0 1px 10px var(--bg-color);
z-index: 2; z-index: 2;
strong {
color: var(--red-color);
}
} }
#_compose-container .status-preview-legend.reply-to { #_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color); color: var(--reply-to-color);
@ -110,45 +116,19 @@
} }
#compose-container form { #compose-container form {
--form-padding-inline: 8px; border-radius: 16px;
--form-padding-block: 0; padding: 4px 12px;
/* border-radius: 16px; */
padding: var(--form-padding-block) var(--form-padding-inline);
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
/* background-image: linear-gradient(var(--bg-color) 85%, transparent); */ /* background-image: linear-gradient(var(--bg-color) 85%, transparent); */
position: relative; position: relative;
z-index: 2; z-index: 2;
--drop-shadow: 0 3px 6px -3px var(--drop-shadow-color); --drop-shadow: 0 3px 6px -3px var(--drop-shadow-color);
box-shadow: var(--drop-shadow); box-shadow: var(--drop-shadow);
@media (min-width: 40em) {
border-radius: 16px;
}
} }
#compose-container .status-preview ~ form { #compose-container .status-preview ~ form {
box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color); box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color);
} }
#compose-container textarea {
width: 100%;
max-width: 100%;
height: 5em;
min-height: 5em;
max-height: 50vh;
resize: vertical;
line-height: 1.4;
border-color: transparent;
&.compose-field {
@media (min-width: 40em) {
max-height: 65vh;
}
}
}
#compose-container textarea:hover {
border-color: var(--divider-color);
}
#compose-container .toolbar { #compose-container .toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -212,11 +192,10 @@
padding: 0 0 0 8px; padding: 0 0 0 8px;
margin: 0; margin: 0;
appearance: none; appearance: none;
line-height: 1em;
} }
#compose-container .toolbar-button:not(.show-field) select { #compose-container .toolbar-button:not(.show-field) select {
inset-inline-end: 0; right: 0;
inset-inline-start: auto !important; left: auto !important;
} }
#compose-container #compose-container
.toolbar-button:not(:disabled):is( .toolbar-button:not(:disabled):is(
@ -277,15 +256,6 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
font-size: 90%; font-size: 90%;
.grow {
flex-grow: 1;
}
.count {
font-size: 80%;
opacity: 0.5;
}
} }
#compose-container .text-expander-menu li b img { #compose-container .text-expander-menu li b img {
/* The shortcode emojis */ /* The shortcode emojis */
@ -297,28 +267,19 @@
height: 2.2em; height: 2.2em;
} }
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) { #compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
background-color: var(--link-bg-color); color: var(--bg-color);
background-color: var(--link-color);
}
#compose-container
.text-expander-menu:hover
li[aria-selected]:not(:hover, :focus) {
color: var(--text-color); color: var(--text-color);
} background-color: var(--bg-color);
#compose-container .text-expander-menu li[aria-selected] {
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
:dir(rtl) & {
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
}
}
#compose-container .text-expander-menu li[data-more] {
&:not(:hover, :focus, [aria-selected]) {
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
}
font-size: 0.8em;
justify-content: center;
} }
#compose-container .form-visibility-direct { #compose-container .form-visibility-direct {
--yellow-stripes: repeating-linear-gradient( --yellow-stripes: repeating-linear-gradient(
135deg, -45deg,
var(--reply-to-faded-color), var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px, var(--reply-to-faded-color) 10px,
@ -342,21 +303,6 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
.media-error {
padding: 2px;
color: var(--orange-fg-color);
background-color: transparent;
border: 1.5px dashed transparent;
line-height: 1;
border-radius: 4px;
display: flex;
&:is(:hover, :focus) {
background-color: var(--bg-color);
border-color: var(--orange-fg-color);
}
}
} }
#compose-container .media-preview { #compose-container .media-preview {
flex-shrink: 0; flex-shrink: 0;
@ -380,7 +326,7 @@
#compose-container .media-preview > * { #compose-container .media-preview > * {
width: 80px; width: 80px;
height: 80px; height: 80px;
object-fit: scale-down; object-fit: contain;
vertical-align: middle; vertical-align: middle;
pointer-events: none; pointer-events: none;
} }
@ -496,14 +442,14 @@
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
border-inline-start: 1px solid var(--outline-color); border-left: 1px solid var(--outline-color);
padding-inline-start: 8px; padding-left: 8px;
} }
#compose-container .expires-in { #compose-container .expires-in {
flex-grow: 1; flex-grow: 1;
border-inline-start: 1px solid var(--outline-color); border-left: 1px solid var(--outline-color);
padding-inline-start: 8px; padding-left: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
@ -523,51 +469,12 @@
} }
} }
#compose-container button[type='submit'] { @media (min-width: 480px) {
border-radius: 8px; #compose-container button[type='submit'] {
@media (min-width: 480px) {
padding-inline: 24px; padding-inline: 24px;
} }
} }
@keyframes breathe {
0% {
opacity: 1;
}
40% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
#media-sheet {
.media-form {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 50vh;
textarea {
flex-grow: 1;
resize: none;
width: 100%;
/* height: 10em; */
&.loading {
animation: skeleton-breathe 1.5s linear infinite;
}
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
#media-sheet main { #media-sheet main {
padding-top: 8px; padding-top: 8px;
display: flex; display: flex;
@ -575,6 +482,10 @@
flex: 1; flex: 1;
gap: 8px; gap: 8px;
} }
#media-sheet textarea {
width: 100%;
height: 10em;
}
#media-sheet .media-preview { #media-sheet .media-preview {
border: 2px solid var(--outline-color); border: 2px solid var(--outline-color);
border-radius: 8px; border-radius: 8px;
@ -591,13 +502,12 @@
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%); linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 20px 20px; background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
flex: 0.8;
} }
#media-sheet .media-preview > * { #media-sheet .media-preview > * {
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 50vh; max-height: 50vh;
object-fit: scale-down; object-fit: contain;
vertical-align: middle; vertical-align: middle;
} }
@ -611,455 +521,49 @@
#media-sheet .media-preview > * { #media-sheet .media-preview > * {
max-height: none; max-height: none;
} }
/* #media-sheet textarea { #media-sheet textarea {
flex: 1; flex: 1;
min-height: 100%; min-height: 100%;
height: auto; height: auto;
} */
}
#mention-sheet {
height: 50vh;
.accounts-list {
--list-gap: 1px;
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-direction: column;
row-gap: var(--list-gap);
&.loading {
opacity: 0.5;
}
li {
display: flex;
flex-grow: 1;
/* align-items: center; */
margin: 0 -8px;
padding: 8px;
gap: 8px;
position: relative;
justify-content: space-between;
border-radius: 8px;
/* align-items: center; */
&:hover {
background-image: linear-gradient(
var(--to-forward),
transparent 75%,
var(--link-bg-color)
);
}
&.selected {
background-image: linear-gradient(
var(--to-forward),
var(--bg-faded-color) 75%,
var(--link-bg-color)
);
}
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: 0;
inset-inline-start: 58px;
inset-inline-end: 0;
}
&:has(+ li:is(.selected, :hover)):before,
&:is(.selected, :hover):before {
opacity: 0;
}
> button {
border-radius: 4px;
&:hover {
outline: 2px solid var(--button-bg-blur-color);
}
}
}
} }
} }
#custom-emojis-sheet { #custom-emojis-sheet {
max-height: 50vh; max-height: 50vh;
max-height: 50dvh; max-height: 50dvh;
header {
.loader-container {
margin: 0;
}
form {
margin: 8px 0 0;
input {
width: 100%;
min-width: 0;
}
}
}
main {
mask-image: none;
min-height: 40vh;
padding-bottom: 88px;
}
.custom-emojis-matches {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
}
.custom-emojis-list {
.section-header {
font-size: 80%;
text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-color);
z-index: 1;
}
section {
display: flex;
flex-wrap: wrap;
}
button {
color: var(--text-color);
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
text-shadow: 0 1px 0 var(--bg-color);
position: relative;
min-width: 44px;
min-height: 44px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
&[data-title]:after {
max-width: 50vw;
pointer-events: none;
position: absolute;
content: attr(data-title);
left: 50%;
top: 0;
background-color: var(--bg-color);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--text-color);
transform: translate(-50%, -110%);
opacity: 0;
transition: opacity 0.1s ease-out 0.1s;
font-family: var(--monospace-font);
line-height: 1;
}
&.edge-left[data-title]:after {
left: 0;
transform: translate(0, -110%);
}
&.edge-right[data-title]:after {
left: 100%;
transform: translate(-100%, -110%);
}
&:is(:hover, :focus) {
z-index: 1;
filter: none;
background-color: var(--bg-faded-color);
&[data-title]:after {
opacity: 1;
}
}
img {
transition: transform 0.1s ease-out;
}
&:is(:hover, :focus) img {
transform: scale(2);
}
&.edge-left img {
transform-origin: left center;
}
&.edge-right img {
transform-origin: right center;
}
code {
font-size: 0.8em;
}
}
}
} }
#custom-emojis-sheet main {
.compose-field-container { mask-image: none;
display: grid !important;
@media (width < 30em) {
margin-inline: calc(-1 * var(--form-padding-inline));
width: 100vw !important;
max-width: 100vw;
.compose-field {
border-radius: 0;
outline-offset: -2px;
}
}
&.debug {
grid-template-columns: 1fr 1fr;
}
> .compose-field,
> .compose-highlight {
grid-area: 1 / 1 / 2 / 2;
}
.compose-highlight {
user-drag: none;
user-select: none;
pointer-events: none;
touch-action: none;
padding: 8px;
color: transparent;
background-color: transparent;
border: 2px solid transparent;
line-height: 1.4;
overflow: auto;
unicode-bidi: plaintext;
-webkit-rtl-ordering: logical;
rtl-ordering: logical;
overflow-wrap: break-word;
white-space: pre-wrap;
min-height: 5em;
max-height: 50vh;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
/* Follow textarea styles */
@media (min-width: 40em) {
max-height: 65vh;
}
mark {
color: inherit;
}
.compose-highlight-url,
.compose-highlight-hashtag {
background-color: transparent;
text-decoration: underline;
text-decoration-color: var(--link-faded-color);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
.compose-highlight-mention,
.compose-highlight-emoji-shortcode,
.compose-highlight-exceeded {
mix-blend-mode: multiply;
border-radius: 4px;
box-shadow: 0 0 0 1px;
}
.compose-highlight-mention {
background-color: var(--orange-light-bg-color);
box-shadow-color: var(--orange-light-bg-color);
}
.compose-highlight-emoji-shortcode {
background-color: var(--bg-faded-color);
box-shadow-color: var(--bg-faded-color);
}
.compose-highlight-exceeded {
background-color: var(--red-bg-color);
box-shadow-color: var(--red-bg-color);
}
@media (prefers-color-scheme: dark) {
.compose-highlight-mention,
.compose-highlight-emoji-shortcode,
.compose-highlight-exceeded {
mix-blend-mode: screen;
}
}
}
} }
#custom-emojis-sheet .custom-emojis-list .section-header {
@keyframes gif-shake { font-size: 80%;
0% { text-transform: uppercase;
transform: rotate(0deg); color: var(--text-insignificant-color);
} padding: 8px 0 4px;
25% { position: sticky;
transform: rotate(5deg); top: 0;
} background-color: var(--bg-blur-color);
50% { backdrop-filter: blur(1px);
transform: rotate(0deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
} }
#custom-emojis-sheet .custom-emojis-list section {
.gif-picker-button { display: flex;
span { flex-wrap: wrap;
font-weight: bold;
font-size: 11.5px;
display: block;
}
&:is(:hover, :focus) {
span {
animation: gif-shake 0.3s 3;
}
}
} }
#custom-emojis-sheet .custom-emojis-list button {
#gif-picker-sheet { border-radius: 8px;
height: 50vh; background-image: radial-gradient(
closest-side,
form { var(--img-bg-color),
display: flex; transparent
flex-direction: row; );
gap: 8px; }
align-items: center; #custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
filter: none;
input[type='search'] { background-color: var(--bg-faded-color);
flex-grow: 1; }
min-width: 0; #custom-emojis-sheet .custom-emojis-list button img {
} transition: transform 0.1s ease-out;
} }
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
main { transform: scale(1.5);
overflow-x: auto;
overflow-y: hidden;
mask-image: linear-gradient(
var(--to-forward),
transparent 2px,
black 16px,
black calc(100% - 16px),
transparent calc(100% - 2px)
);
@media (min-height: 480px) {
overflow-y: auto;
max-height: 50vh;
}
&.loading {
opacity: 0.25;
}
.ui-state {
min-height: 100px;
}
ul {
min-height: 100px;
display: flex;
gap: 4px;
list-style: none;
padding: 8px 2px;
margin: 0;
@media (min-height: 480px) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-auto-rows: 1fr;
}
li {
list-style: none;
padding: 0;
margin: 0;
max-width: 100%;
display: flex;
button {
padding: 4px;
margin: 0;
border: none;
background-color: transparent;
color: inherit;
cursor: pointer;
border-radius: 8px;
background-color: var(--bg-faded-color);
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
&:is(:hover, :focus) {
background-color: var(--link-bg-color);
box-shadow: 0 0 0 2px var(--link-light-color);
filter: none;
}
}
figure {
margin: 0;
padding: 0;
width: var(--figure-width);
max-width: 100%;
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
figcaption {
font-size: 0.8em;
padding: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-insignificant-color);
}
}
img {
background-color: var(--img-bg-color);
border-radius: 4px;
vertical-align: top;
object-fit: contain;
}
}
}
.pagination {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 0;
margin: 0;
position: sticky;
bottom: 0;
left: 0;
right: 0;
@media (min-height: 480px) {
position: static;
}
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
export default function CustomEmoji({ staticUrl, alt, url }) {
return (
<picture>
{staticUrl && (
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
)}
<img
key={alt || url}
src={url}
alt={alt}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
}

View file

@ -27,7 +27,7 @@ button.draft-item {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--link-faded-color); border: 1px solid var(--link-faded-color);
text-align: start; text-align: left;
padding: 0; padding: 0;
} }
button.draft-item:is(:hover, :focus) { button.draft-item:is(:hover, :focus) {

View file

@ -10,7 +10,6 @@ import { getCurrentAccountNS } from '../utils/store-utils';
import Icon from './icon'; import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
import MenuConfirm from './menu-confirm';
function Drafts({ onClose }) { function Drafts({ onClose }) {
const { masto } = api(); const { masto } = api();
@ -90,33 +89,26 @@ function Drafts({ onClose }) {
{niceDateTime(updatedAtDate)} {niceDateTime(updatedAtDate)}
</time> </time>
</b> </b>
<MenuConfirm <button
confirmLabel={<span>Delete this draft?</span>} type="button"
menuItemClassName="danger" class="small light"
align="end"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
(async () => { (async () => {
try { try {
// const yes = confirm('Delete this draft?'); const yes = confirm('Delete this draft?');
// if (yes) { if (yes) {
await db.drafts.del(key); await db.drafts.del(key);
reload(); reload();
// } }
} catch (e) { } catch (e) {
alert('Error deleting draft! Please try again.'); alert('Error deleting draft! Please try again.');
} }
})(); })();
}} }}
> >
<button Delete&hellip;
type="button" </button>
class="small light"
disabled={uiState === 'loading'}
>
Delete&hellip;
</button>
</MenuConfirm>
</div> </div>
<button <button
type="button" type="button"
@ -128,9 +120,9 @@ function Drafts({ onClose }) {
if (replyTo) { if (replyTo) {
setUIState('loading'); setUIState('loading');
try { try {
replyToStatus = await masto.v1.statuses replyToStatus = await masto.v1.statuses.fetch(
.$select(replyTo.id) replyTo.id,
.fetch(); );
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Error fetching reply-to status!'); alert('Error fetching reply-to status!');
@ -153,16 +145,15 @@ function Drafts({ onClose }) {
); );
})} })}
</ul> </ul>
{drafts.length > 1 && ( <p>
<p> <button
<MenuConfirm type="button"
confirmLabel={<span>Delete all drafts?</span>} class="light danger"
menuItemClassName="danger" disabled={uiState === 'loading'}
disabled={uiState === 'loading'} onClick={() => {
onClick={() => { (async () => {
(async () => { const yes = confirm('Delete all drafts?');
// const yes = confirm('Delete all drafts?'); if (yes) {
// if (yes) {
setUIState('loading'); setUIState('loading');
try { try {
await db.drafts.delMany( await db.drafts.delMany(
@ -175,20 +166,13 @@ function Drafts({ onClose }) {
alert('Error deleting drafts! Please try again.'); alert('Error deleting drafts! Please try again.');
setUIState('error'); setUIState('error');
} }
// } }
})(); })();
}} }}
> >
<button Delete all drafts&hellip;
type="button" </button>
class="light danger" </p>
disabled={uiState === 'loading'}
>
Delete all&hellip;
</button>
</MenuConfirm>
</p>
)}
</> </>
) : ( ) : (
<p>No drafts found.</p> <p>No drafts found.</p>

View file

@ -1,31 +0,0 @@
.embed-modal-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
pointer-events: none;
.top-controls {
padding: 16px;
display: flex;
gap: 8px;
justify-content: space-between;
pointer-events: auto;
}
.embed-content {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
iframe {
pointer-events: auto;
max-width: 100%;
max-height: 100%;
width: max(var(--width), 480px);
height: auto;
aspect-ratio: var(--aspect-ratio);
}
}
}

View file

@ -1,36 +0,0 @@
import './embed-modal.css';
import Icon from './icon';
function EmbedModal({ html, url, width, height, onClose = () => {} }) {
return (
<div class="embed-modal-container">
<div class="top-controls">
<button type="button" class="light" onClick={() => onClose()}>
<Icon icon="x" />
</button>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="button plain"
>
<span>Open link</span> <Icon icon="external" />
</a>
)}
</div>
<div
class="embed-content"
dangerouslySetInnerHTML={{ __html: html }}
style={{
'--width': width + 'px',
'--height': height + 'px',
'--aspect-ratio': `${width}/${height}`,
}}
/>
</div>
);
}
export default EmbedModal;

View file

@ -1,29 +1,42 @@
import { memo } from 'preact/compat';
import CustomEmoji from './custom-emoji';
function EmojiText({ text, emojis }) { function EmojiText({ text, emojis }) {
if (!text) return ''; if (!text) return '';
if (!emojis?.length) return text; if (!emojis?.length) return text;
if (text.indexOf(':') === -1) return text; if (text.indexOf(':') === -1) return text;
const regex = new RegExp(
`:(${emojis.map((e) => e.shortcode).join('|')}):`, const components = [];
'g', let lastIndex = 0;
);
const elements = text.split(regex).map((word) => { emojis.forEach((shortcodeObj) => {
const emoji = emojis.find((e) => e.shortcode === word); const { shortcode, staticUrl, url } = shortcodeObj;
if (emoji) { const regex = new RegExp(`:${shortcode}:`, 'g');
const { url, staticUrl } = emoji; let match;
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
while ((match = regex.exec(text))) {
const beforeText = text.substring(lastIndex, match.index);
if (beforeText) {
components.push(beforeText);
}
components.push(
<img
src={url}
alt={shortcode}
class="shortcode-emoji emoji"
width="12"
height="12"
loading="lazy"
decoding="async"
/>,
);
lastIndex = match.index + match[0].length;
} }
return word;
}); });
return elements;
const afterText = text.substring(lastIndex);
if (afterText) {
components.push(afterText);
}
return components;
} }
export default memo( export default EmojiText;
EmojiText,
(oldProps, newProps) =>
oldProps.text === newProps.text &&
oldProps.emojis?.length === newProps.emojis?.length,
);

View file

@ -2,39 +2,26 @@ import { useState } from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
function FollowRequestButtons({ accountID, onChange }) { function FollowRequestButtons({ accountID, onChange }) {
const { masto } = api(); const { masto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [requestState, setRequestState] = useState(null); // accept, reject
const [relationship, setRelationship] = useState(null);
const hasRelationship = relationship !== null;
return ( return (
<p class="follow-request-buttons"> <p class="follow-request-buttons">
<button <button
type="button" type="button"
disabled={uiState === 'loading' || hasRelationship} disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
setUIState('loading'); setUIState('loading');
setRequestState('accept');
(async () => { (async () => {
try { try {
const rel = await masto.v1.followRequests await masto.v1.followRequests.authorize(accountID);
.$select(accountID)
.authorize();
if (!rel?.followedBy) {
throw new Error('Follow request not accepted');
}
setRelationship(rel);
onChange(); onChange();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('default');
} }
setUIState('default');
})(); })();
}} }}
> >
@ -42,20 +29,13 @@ function FollowRequestButtons({ accountID, onChange }) {
</button>{' '} </button>{' '}
<button <button
type="button" type="button"
disabled={uiState === 'loading' || hasRelationship} disabled={uiState === 'loading'}
class="light danger" class="light danger"
onClick={() => { onClick={() => {
setUIState('loading'); setUIState('loading');
setRequestState('reject');
(async () => { (async () => {
try { try {
const rel = await masto.v1.followRequests await masto.v1.followRequests.reject(accountID);
.$select(accountID)
.reject();
if (rel?.followedBy) {
throw new Error('Follow request not rejected');
}
setRelationship(rel);
onChange(); onChange();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -66,17 +46,7 @@ function FollowRequestButtons({ accountID, onChange }) {
> >
Reject Reject
</button> </button>
<span class="follow-request-states"> <Loader hidden={uiState !== 'loading'} />
{hasRelationship && requestState ? (
requestState === 'accept' ? (
<Icon icon="check-circle" alt="Accepted" class="follow-accepted" />
) : (
<Icon icon="x-circle" alt="Rejected" class="follow-rejected" />
)
) : (
<Loader hidden={uiState !== 'loading'} />
)}
</span>
</p> </p>
); );
} }

View file

@ -1,111 +0,0 @@
#generic-accounts-container {
.post-preview {
--max-height: 120px;
max-height: var(--max-height);
overflow: hidden;
margin-block: 8px;
border: 1px solid var(--outline-color);
border-radius: 8px;
pointer-events: none;
.status {
font-size: calc(var(--text-size) * 0.9);
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
filter: saturate(0.5);
}
&:is(a) {
pointer-events: auto;
display: block;
text-decoration: none;
color: inherit;
&:hover {
border-color: var(--outline-hover-color);
}
> * {
pointer-events: none;
}
}
}
.accounts-list {
--list-gap: 16px;
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 1.5em;
row-gap: var(--list-gap);
li {
display: flex;
flex-grow: 1;
flex-basis: 16em;
/* align-items: center; */
margin: 0;
padding: 0;
gap: 8px;
position: relative;
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: calc(-1 * var(--list-gap) / 2);
inset-inline-start: 40px;
inset-inline-end: 0;
}
&:has(.reactions-block):before {
/* avatar + reactions + gap */
inset-inline-start: calc(40px + 16px + 8px);
}
}
.account-block-acct {
font-size: 0.9em;
color: var(--text-insignificant-color);
/* display: block; */
}
}
.reactions-block {
display: flex;
flex-direction: column;
/* align-self: center; */
.favourite-icon {
color: var(--favourite-color);
}
.reblog-icon {
color: var(--reblog-color);
}
> .icon:only-child {
margin-top: 8px; /* half of icon dimension */
}
}
.account-relationships {
flex-grow: 1;
.tag {
animation: appear 0.3s ease-out;
}
}
.account-block {
align-items: flex-start;
}
}

View file

@ -1,230 +0,0 @@
import './generic-accounts.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import { fetchRelationships } from '../utils/relationships';
import states from '../utils/states';
import useLocationChange from '../utils/useLocationChange';
import AccountBlock from './account-block';
import Icon from './icon';
import Link from './link';
import Loader from './loader';
import Status from './status';
export default function GenericAccounts({
instance,
excludeRelationshipAttrs = [],
postID,
onClose = () => {},
blankCopy = 'Nothing to show',
}) {
const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true;
const snapStates = useSnapshot(states);
``;
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
useLocationChange(onClose);
if (!snapStates.showGenericAccounts) {
return null;
}
const {
id,
heading,
fetchAccounts,
accounts: staticAccounts,
showReactions,
} = snapStates.showGenericAccounts;
const [relationshipsMap, setRelationshipsMap] = useState({});
const loadRelationships = async (accounts) => {
if (!accounts?.length) return;
if (!isCurrentInstance) return;
const relationships = await fetchRelationships(accounts, relationshipsMap);
if (relationships) {
setRelationshipsMap({
...relationshipsMap,
...relationships,
});
}
};
const loadAccounts = (firstLoad) => {
if (!fetchAccounts) return;
if (firstLoad) setAccounts([]);
setUIState('loading');
(async () => {
try {
const { done, value } = await fetchAccounts(firstLoad);
if (Array.isArray(value)) {
if (firstLoad) {
const accounts = [];
for (let i = 0; i < value.length; i++) {
const account = value[i];
const theAccount = accounts.find(
(a, j) => a.id === account.id && i !== j,
);
if (!theAccount) {
accounts.push({
_types: [],
...account,
});
} else {
theAccount._types.push(...account._types);
}
}
setAccounts(accounts);
} else {
// setAccounts((prev) => [...prev, ...value]);
// Merge accounts by id and _types
setAccounts((prev) => {
const newAccounts = prev;
for (const account of value) {
const theAccount = newAccounts.find((a) => a.id === account.id);
if (!theAccount) {
newAccounts.push(account);
} else {
theAccount._types.push(...account._types);
}
}
return newAccounts;
});
}
setShowMore(!done);
loadRelationships(value);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
};
const firstLoad = useRef(true);
useEffect(() => {
if (staticAccounts?.length > 0) {
setAccounts(staticAccounts);
loadRelationships(staticAccounts);
} else {
loadAccounts(true);
firstLoad.current = false;
}
}, [staticAccounts, fetchAccounts]);
useEffect(() => {
if (firstLoad.current) return;
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
// We only need to reload if the id matches
if (snapStates.reloadGenericAccounts?.id === id) {
loadAccounts(true);
}
}, [snapStates.reloadGenericAccounts.counter]);
const post = states.statuses[postID];
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{post && (
<Link
to={`/${instance || currentInstance}/s/${post.id}`}
class="post-preview"
>
<Status status={post} size="s" readOnly />
</Link>
)}
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
{accounts.map((account) => {
const relationship = relationshipsMap[account.id];
const key = `${account.id}-${account._types?.length || ''}`;
return (
<li key={key}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
)}
<div class="account-relationships">
<AccountBlock
account={account}
showStats
relationship={relationship}
excludeRelationshipAttrs={excludeRelationshipAttrs}
/>
</div>
</li>
);
})}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
) : (
<p class="ui-state insignificant">{blankCopy}</p>
)}
</main>
</div>
);
}

View file

@ -1,7 +1,4 @@
import moize from 'moize'; import { useEffect, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { ICONS } from './ICONS';
const SIZES = { const SIZES = {
s: 12, s: 12,
@ -11,30 +8,81 @@ const SIZES = {
xxl: 32, xxl: 32,
}; };
const ICONDATA = {}; const ICONS = {
x: 'mingcute:close-line',
heart: 'mingcute:heart-line',
bookmark: 'mingcute:bookmark-line',
'check-circle': 'mingcute:check-circle-line',
transfer: 'mingcute:transfer-4-line',
rocket: 'mingcute:rocket-line',
'arrow-left': 'mingcute:arrow-left-line',
'arrow-right': 'mingcute:arrow-right-line',
'arrow-up': 'mingcute:arrow-up-line',
'arrow-down': 'mingcute:arrow-down-line',
earth: 'mingcute:earth-line',
lock: 'mingcute:lock-line',
unlock: 'mingcute:unlock-line',
'eye-close': 'mingcute:eye-close-line',
'eye-open': 'mingcute:eye-2-line',
message: 'mingcute:mail-line',
comment: 'mingcute:chat-3-line',
home: 'mingcute:home-3-line',
notification: 'mingcute:notification-line',
follow: 'mingcute:user-follow-line',
'follow-add': 'mingcute:user-add-line',
poll: ['mingcute:chart-bar-line', '90deg'],
pencil: 'mingcute:pencil-line',
quill: 'mingcute:quill-pen-line',
at: 'mingcute:at-line',
attachment: 'mingcute:attachment-line',
upload: 'mingcute:upload-3-line',
gear: 'mingcute:settings-3-line',
more: 'mingcute:more-3-line',
external: 'mingcute:external-link-line',
popout: 'mingcute:external-link-line',
popin: ['mingcute:external-link-line', '180deg'],
plus: 'mingcute:add-circle-line',
'chevron-left': 'mingcute:left-line',
'chevron-right': 'mingcute:right-line',
reply: ['mingcute:share-forward-line', '180deg', 'horizontal'],
thread: 'mingcute:route-line',
group: 'mingcute:group-line',
bot: 'mingcute:android-2-line',
menu: 'mingcute:rows-4-line',
list: 'mingcute:list-check-line',
search: 'mingcute:search-2-line',
hashtag: 'mingcute:hashtag-line',
info: 'mingcute:information-line',
shortcut: 'mingcute:lightning-line',
user: 'mingcute:user-4-line',
following: 'mingcute:walk-line',
pin: 'mingcute:pin-line',
bus: 'mingcute:bus-2-line',
link: 'mingcute:link-2-line',
history: 'mingcute:history-line',
share: 'mingcute:share-2-line',
sparkles: 'mingcute:sparkles-line',
exit: 'mingcute:exit-line',
translate: 'mingcute:translate-line',
play: 'mingcute:play-fill',
trash: 'mingcute:delete-2-line',
mute: 'mingcute:volume-mute-line',
unmute: 'mingcute:volume-line',
block: 'mingcute:forbid-circle-line',
unblock: ['mingcute:forbid-circle-line', '180deg'],
flag: 'mingcute:flag-4-line',
time: 'mingcute:time-line',
refresh: 'mingcute:refresh-2-line',
emoji2: 'mingcute:emoji-2-line',
filter: 'mingcute:filter-2-line',
chart: 'mingcute:chart-line-line',
react: 'mingcute:react-line',
layout4: 'mingcute:layout-4-line',
layout5: 'mingcute:layout-5-line',
announce: 'mingcute:announcement-line',
};
// Memoize the dangerouslySetInnerHTML of the SVGs const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
const SVGICon = moize(
function ({ width, height, body, rotate, flip }) {
return (
<svg
viewBox={`0 0 ${width} ${height}`}
dangerouslySetInnerHTML={{ __html: body }}
style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : ''
}`,
}}
/>
);
},
{
isShallowEqual: true,
maxSize: Object.keys(ICONS).length,
matchesArg: (cacheKeyArg, keyArg) =>
cacheKeyArg.icon === keyArg.icon && cacheKeyArg.body === keyArg.body,
},
);
function Icon({ function Icon({
icon, icon,
@ -47,67 +95,48 @@ function Icon({
if (!icon) return null; if (!icon) return null;
const iconSize = SIZES[size]; const iconSize = SIZES[size];
let iconBlock = ICONS[icon]; let iconName = ICONS[icon];
if (!iconBlock) { let rotate, flip;
console.warn(`Icon ${icon} not found`); if (Array.isArray(iconName)) {
return null; [iconName, rotate, flip] = iconName;
} }
let rotate, const [iconData, setIconData] = useState(null);
flip, useEffect(async () => {
rtl = false; const name = iconName.replace('mingcute:', '');
if (Array.isArray(iconBlock)) { const icon = await modules[
[iconBlock, rotate, flip] = iconBlock; `/node_modules/@iconify-icons/mingcute/${name}.js`
} else if (typeof iconBlock === 'object') { ]();
({ rotate, flip, rtl } = iconBlock); setIconData(icon.default);
iconBlock = iconBlock.module; }, [iconName]);
}
const [iconData, setIconData] = useState(ICONDATA[icon]);
const currentIcon = useRef(icon);
useEffect(() => {
if (iconData && currentIcon.current === icon) return;
(async () => {
const iconB = await iconBlock();
setIconData(iconB.default);
ICONDATA[icon] = iconB.default;
})();
currentIcon.current = icon;
}, [icon]);
return ( return (
<span <div
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`} class={`icon ${className}`}
title={title || alt} title={title || alt}
style={{ style={{
width: `${iconSize}px`, width: `${iconSize}px`,
height: `${iconSize}px`, height: `${iconSize}px`,
display: 'inline-block',
overflow: 'hidden',
lineHeight: 0,
...style, ...style,
}} }}
data-icon={icon}
> >
{iconData && ( {iconData && (
// <svg <svg
// width={iconSize} width={iconSize}
// height={iconSize} height={iconSize}
// viewBox={`0 0 ${iconData.width} ${iconData.height}`} viewBox={`0 0 ${iconData.width} ${iconData.height}`}
// dangerouslySetInnerHTML={{ __html: iconData.body }} dangerouslySetInnerHTML={{ __html: iconData.body }}
// style={{ style={{
// transform: `${rotate ? `rotate(${rotate})` : ''} ${ transform: `${rotate ? `rotate(${rotate})` : ''} ${
// flip ? `scaleX(-1)` : '' flip ? `scaleX(-1)` : ''
// }`, }`,
// }} }}
// />
<SVGICon
icon={icon}
width={iconData.width}
height={iconData.height}
body={iconData.body}
rotate={rotate}
flip={flip}
/> />
)} )}
</span> </div>
); );
} }

View file

@ -1,29 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
const IntersectionView = ({ children, root = null, fallback = null }) => {
const ref = useRef();
const [show, setShow] = useState(false);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
setShow(true);
observer.unobserve(ref.current);
}
},
{
root,
rootMargin: `${screen.height}px`,
},
);
if (ref.current) observer.observe(ref.current);
return () => {
if (ref.current) observer.unobserve(ref.current);
};
}, []);
return show ? children : <div ref={ref}>{fallback}</div>;
};
export default IntersectionView;

View file

@ -1,36 +0,0 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { Suspense } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
// Preload IntlSegmenter
setTimeout(() => {
queueMicrotask(() => {
if (!supportsIntlSegmenter) {
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
});
}, 1000);
export default function IntlSegmenterSuspense({ children }) {
if (supportsIntlSegmenter) {
return <Suspense fallback={<Loader />}>{children}</Suspense>;
}
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
useEffect(() => {
(async () => {
await import('@formatjs/intl-segmenter/polyfill-force');
setPolyfillLoaded(true);
})();
}, []);
return polyfillLoaded ? (
<Suspense fallback={<Loader />}>{children}</Suspense>
) : (
<Loader />
);
}

View file

@ -1,44 +0,0 @@
#keyboard-shortcuts-help-container {
table {
tr > * {
border-top: 1px solid var(--outline-color);
vertical-align: middle;
}
th {
font-weight: normal;
text-align: start;
padding: 0.25em 0;
line-height: 1;
width: 60%;
}
td {
padding: 0.25em 1em;
}
}
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
background-image: linear-gradient(
to top,
var(--bg-blur-color),
transparent
);
text-shadow: 0 1px var(--bg-color);
box-shadow: 0 1px var(--drop-shadow-color),
0 1px 1px var(--drop-shadow-color), 0 1px 8px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
&:active {
box-shadow: 0 1px 4px var(--drop-shadow-color),
inset 0 1px var(--bg-blur-color);
transform: translateY(1px);
filter: brightness(0.95);
}
}
}

View file

@ -1,191 +0,0 @@
import './keyboard-shortcuts-help.css';
import { memo } from 'preact/compat';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import states from '../utils/states';
import Icon from './icon';
import Modal from './modal';
export default memo(function KeyboardShortcutsHelp() {
const snapStates = useSnapshot(states);
function onClose() {
states.showKeyboardShortcutsHelp = false;
}
useHotkeys(
'?, shift+?, shift+slash',
(e) => {
console.log('help');
states.showKeyboardShortcutsHelp = true;
},
{
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
);
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>Keyboard shortcuts</h2>
</header>
<main>
<table>
{[
{
action: 'Keyboard shortcuts help',
keys: <kbd>?</kbd>,
},
{
action: 'Next post',
keys: <kbd>j</kbd>,
},
{
action: 'Previous post',
keys: <kbd>k</kbd>,
},
{
action: 'Skip carousel to next post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>j</kbd>
</>
),
},
{
action: 'Skip carousel to previous post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>k</kbd>
</>
),
},
{
action: 'Load new posts',
keys: <kbd>.</kbd>,
},
{
action: 'Open post details',
keys: (
<>
<kbd>Enter</kbd> or <kbd>o</kbd>
</>
),
},
{
action: (
<>
Expand content warning or
<br />
toggle expanded/collapsed thread
</>
),
keys: <kbd>x</kbd>,
},
{
action: 'Close post or dialogs',
keys: (
<>
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
</>
),
},
{
action: 'Focus column in multi-column mode',
keys: (
<>
<kbd>1</kbd> to <kbd>9</kbd>
</>
),
},
{
action: 'Compose new post',
keys: <kbd>c</kbd>,
},
{
action: 'Compose new post (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>c</kbd>
</>
),
},
{
action: 'Send post',
keys: (
<>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd>
</>
),
},
{
action: 'Search',
keys: <kbd>/</kbd>,
},
{
action: 'Reply',
keys: <kbd>r</kbd>,
},
{
action: 'Reply (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>r</kbd>
</>
),
},
{
action: 'Like (favourite)',
keys: (
<>
<kbd>l</kbd> or <kbd>f</kbd>
</>
),
},
{
action: 'Boost',
keys: (
<>
<kbd>Shift</kbd> + <kbd>b</kbd>
</>
),
},
{
action: 'Bookmark',
keys: <kbd>d</kbd>,
},
{
action: 'Toggle Cloak mode',
keys: (
<>
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
</>
),
},
].map(({ action, className, keys }) => (
<tr key={action}>
<th class={className}>{action}</th>
<td>{keys}</td>
</tr>
))}
</table>
</main>
</div>
</Modal>
)
);
});

View file

@ -1,59 +0,0 @@
/*
Rendered but hidden. Only show when visible
*/
import { useEffect, useRef, useState } from 'preact/hooks';
import { useInView } from 'react-intersection-observer';
// The sticky header, usually at the top
const TOP = 48;
const shazamIDs = {};
export default function LazyShazam({ id, children }) {
const containerRef = useRef();
const hasID = !!shazamIDs[id];
const [visible, setVisible] = useState(false);
const [visibleStart, setVisibleStart] = useState(hasID || false);
const { ref } = useInView({
root: null,
rootMargin: `-${TOP}px 0px 0px 0px`,
trackVisibility: true,
delay: 1000,
onChange: (inView) => {
if (inView) {
setVisible(true);
if (id) shazamIDs[id] = true;
}
},
triggerOnce: true,
skip: visibleStart || visible,
});
useEffect(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.bottom > TOP) {
if (rect.top < window.innerHeight) {
setVisible(true);
} else {
setVisibleStart(true);
}
if (id) shazamIDs[id] = true;
}
}, []);
if (visibleStart) return children;
return (
<div
ref={containerRef}
class="shazam-container no-animation"
hidden={!visible}
>
<div ref={ref} class="shazam-container-inner">
{children}
</div>
</div>
);
}

View file

@ -19,19 +19,7 @@ const Link = forwardRef((props, ref) => {
let hash = (location.hash || '').replace(/^#/, '').trim(); let hash = (location.hash || '').replace(/^#/, '').trim();
if (hash === '') hash = '/'; if (hash === '') hash = '/';
const { to, ...restProps } = props; const { to, ...restProps } = props;
const isActive = decodeURIComponent(hash) === to;
// Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) {
const parsedHash = URL.parse(hash, location.origin); // Fake base URL
if (parsedHash?.searchParams?.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
hash = parsedHash.pathname + '?' + searchParamsStr;
}
}
const isActive = hash === to || decodeURIComponent(hash) === to;
return ( return (
<a <a
ref={ref} ref={ref}
@ -39,10 +27,6 @@ const Link = forwardRef((props, ref) => {
{...restProps} {...restProps}
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`} class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
onClick={(e) => { onClick={(e) => {
if (e.currentTarget?.parentNode?.closest('a')) {
// If this <a> is nested inside another <a>
e.stopPropagation();
}
if (routerLocation) states.prevLocation = routerLocation; if (routerLocation) states.prevLocation = routerLocation;
props.onClick?.(e); props.onClick?.(e);
}} }}

View file

@ -1,226 +0,0 @@
.links-bar {
position: relative;
display: flex;
padding: 16px 16px 20px 16px;
gap: 16px;
overflow-x: auto;
background-color: var(--bg-faded-color);
mask-image: linear-gradient(
var(--to-forward),
transparent,
black 16px,
black calc(100% - 16px),
transparent
);
text-shadow: 0 1px var(--bg-blur-color);
transition: opacity 0.3s ease-out;
#trending-page &:not(#columns &) {
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2));
&:dir(rtl) {
transform: translateX(calc(50% - var(--main-width) / 2));
}
}
}
& > header {
width: 1.2em;
white-space: nowrap;
position: relative;
flex-shrink: 0;
h3 {
font-size: 90%;
font-style: italic;
margin: 0;
padding: 0;
text-transform: uppercase;
color: var(--text-insignificant-color);
position: absolute;
top: 8px;
inset-inline-start: 0;
transform-origin: top left;
transform: rotate(-90deg) translateX(-100%);
&:dir(rtl) {
transform-origin: top right;
transform: rotate(90deg) translateX(100%);
}
user-select: none;
background-image: linear-gradient(
var(--to-backward),
var(--text-color),
var(--link-color)
);
background-clip: text;
text-fill-color: transparent;
-webkit-text-fill-color: transparent;
}
}
a {
min-width: 240px;
flex-grow: 1;
max-width: 320px;
text-decoration: none;
color: inherit;
border-radius: 16px;
overflow: hidden;
background-color: var(--accent-alpha-color);
border: 4px solid transparent;
box-shadow: 0 4px 8px -2px var(--drop-shadow-color);
transition: all 0.15s ease-out;
display: flex;
background-image: linear-gradient(
to bottom,
var(--accent-color, var(--link-text-color)) -50%,
transparent
);
background-clip: border-box;
background-origin: border-box;
min-height: 160px;
height: 320px;
max-height: 50vh;
&:not(:active):is(:hover, :focus-visible) {
border-color: var(--accent-color, var(--link-light-color));
box-shadow: 0 4px 8px var(--drop-shadow-color),
0 8px 16px var(--drop-shadow-color);
transform-origin: center bottom;
transform: scale(1.02);
img {
animation: position-object 5s ease-in-out 1s 5;
}
}
&:active {
transition: none;
transform: scale(1.015);
filter: brightness(0.8);
}
figure {
transition: 1s ease-out;
transition-property: opacity, mix-blend-mode;
}
&.inactive:not(:active, :hover) {
figure {
transition-duration: 0.3s;
opacity: 0.5;
mix-blend-mode: luminosity;
}
}
&.active {
border-color: var(--accent-color, var(--link-light-color));
height: 100%;
max-height: 100%;
+ button[disabled] {
display: none;
}
}
article {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
background-color: var(--bg-color);
background-repeat: no-repeat;
background-image: linear-gradient(
to bottom,
var(--accent-alpha-color) 70%,
var(--bg-color) 100%
);
transition: background-position-y 0.15s ease-out;
figure {
flex-grow: 1;
margin: 0 0 -16px;
padding: 0;
position: relative;
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
}
}
}
&:is(:hover, :focus-visible) article {
background-position-y: -40px;
}
.article-body {
padding: 0 8px 8px;
line-height: 1.3;
flex-shrink: 0;
}
.article-meta {
color: var(--text-insignificant-color);
font-size: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover .domain {
color: var(--link-text-color);
}
h1 {
font-weight: normal;
font-size: inherit;
margin: 0;
padding: 0;
text-wrap: balance;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
p {
color: var(--text-insignificant-color);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 90%;
}
hr {
margin: 4px 0;
}
}
}

View file

@ -1,30 +1,21 @@
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
import supports from '../utils/supports';
import Icon from './icon'; import Icon from './icon';
import MenuConfirm from './menu-confirm';
function ListAddEdit({ list, onClose }) { function ListAddEdit({ list, onClose }) {
const { masto } = api(); const { masto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUiState] = useState('default');
const editMode = !!list; const editMode = !!list;
const nameFieldRef = useRef(); const nameFieldRef = useRef();
const repliesPolicyFieldRef = useRef(); const repliesPolicyFieldRef = useRef();
const exclusiveFieldRef = useRef();
useEffect(() => { useEffect(() => {
if (editMode) { if (editMode) {
nameFieldRef.current.value = list.title; nameFieldRef.current.value = list.title;
repliesPolicyFieldRef.current.value = list.repliesPolicy; repliesPolicyFieldRef.current.value = list.repliesPolicy;
if (exclusiveFieldRef.current) {
exclusiveFieldRef.current.checked = list.exclusive;
}
} }
}, [editMode]); }, [editMode]);
const supportsExclusive = supports('@mastodon/list-exclusive');
return ( return (
<div class="sheet"> <div class="sheet">
{!!onClose && ( {!!onClose && (
@ -44,49 +35,37 @@ function ListAddEdit({ list, onClose }) {
const formData = new FormData(e.target); const formData = new FormData(e.target);
const title = formData.get('title'); const title = formData.get('title');
const repliesPolicy = formData.get('replies_policy'); const repliesPolicy = formData.get('replies_policy');
const exclusive = formData.get('exclusive') === 'on';
console.log({ console.log({
title, title,
repliesPolicy, repliesPolicy,
exclusive,
}); });
setUIState('loading'); setUiState('loading');
(async () => { (async () => {
try { try {
let listResult; let listResult;
if (editMode) { if (editMode) {
listResult = await masto.v1.lists.$select(list.id).update({ listResult = await masto.v1.lists.update(list.id, {
title, title,
replies_policy: repliesPolicy, replies_policy: repliesPolicy,
exclusive,
}); });
} else { } else {
listResult = await masto.v1.lists.create({ listResult = await masto.v1.lists.create({
title, title,
replies_policy: repliesPolicy, replies_policy: repliesPolicy,
exclusive,
}); });
} }
console.log(listResult); console.log(listResult);
setUIState('default'); setUiState('default');
onClose?.({ onClose?.({
state: 'success', state: 'success',
list: listResult, list: listResult,
}); });
setTimeout(() => {
if (editMode) {
updateListStore(listResult);
} else {
addListStore(listResult);
}
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUiState('error');
alert( alert(
editMode ? 'Unable to edit list.' : 'Unable to create list.', editMode ? 'Unable to edit list.' : 'Unable to create list.',
); );
@ -104,7 +83,6 @@ function ListAddEdit({ list, onClose }) {
name="title" name="title"
required required
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
dir="auto"
/> />
</label> </label>
</div> </div>
@ -120,60 +98,37 @@ function ListAddEdit({ list, onClose }) {
<option value="none">Don't show replies</option> <option value="none">Don't show replies</option>
</select> </select>
</div> </div>
{supportsExclusive && (
<div class="list-form-row">
<label class="label-block">
<input
ref={exclusiveFieldRef}
type="checkbox"
name="exclusive"
disabled={uiState === 'loading'}
/>{' '}
Hide posts on this list from Home/Following
</label>
</div>
)}
<div class="list-form-footer"> <div class="list-form-footer">
<button type="submit" disabled={uiState === 'loading'}> <button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'} {editMode ? 'Save' : 'Create'}
</button> </button>
{editMode && ( {editMode && (
<MenuConfirm <button
type="button"
class="light danger"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this list?"
onClick={() => { onClick={() => {
// const yes = confirm('Delete this list?'); const yes = confirm('Delete this list?');
// if (!yes) return; if (!yes) return;
setUIState('loading'); setUiState('loading');
(async () => { (async () => {
try { try {
await masto.v1.lists.$select(list.id).remove(); await masto.v1.lists.remove(list.id);
setUIState('default'); setUiState('default');
onClose?.({ onClose?.({
state: 'deleted', state: 'deleted',
}); });
setTimeout(() => {
deleteListStore(list.id);
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUiState('error');
alert('Unable to delete list.'); alert('Unable to delete list.');
} }
})(); })();
}} }}
> >
<button Delete
type="button" </button>
class="light danger"
disabled={uiState === 'loading'}
>
Delete
</button>
</MenuConfirm>
)} )}
</div> </div>
</form> </form>

View file

@ -1,15 +1,14 @@
import './loader.css'; import './loader.css';
function Loader({ abrupt, hidden, ...props }) { function Loader({ abrupt, hidden }) {
return ( return (
<span <div
{...props}
class={`loader-container ${abrupt ? 'abrupt' : ''} ${ class={`loader-container ${abrupt ? 'abrupt' : ''} ${
hidden ? 'hidden' : '' hidden ? 'hidden' : ''
}`} }`}
> >
<span class="loader" /> <div class="loader" />
</span> </div>
); );
} }

View file

@ -1,87 +0,0 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
import { speak, supportsTTS } from '../utils/speech';
import states from '../utils/states';
import Icon from './icon';
import Menu2 from './menu2';
import TranslationBlock from './translation-block';
export default function MediaAltModal({ alt, lang, onClose }) {
const snapStates = useSnapshot(states);
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
const differentLanguage =
!!lang &&
lang !== targetLanguage &&
!localeMatch([lang], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => lang === l || localeMatch([lang], [l]),
);
return (
<div class="sheet" tabindex="-1">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu2
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
{supportsTTS && (
<MenuItem
onClick={() => {
speak(alt, lang);
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</Menu2>
</div>
</header>
<main lang={lang} dir="auto">
<p
style={{
whiteSpace: 'pre-wrap',
textWrap: 'pretty',
}}
>
{alt}
</p>
{(differentLanguage || forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
sourceLanguage={lang}
text={alt}
/>
)}
</main>
</div>
);
}

View file

@ -1,36 +1,22 @@
import { MenuDivider, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import Media from './media'; import Media from './media';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import Menu2 from './menu2'; import Modal from './modal';
import TranslationBlock from './translation-block';
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
function MediaModal({ function MediaModal({
mediaAttachments, mediaAttachments,
statusID, statusID,
instance, instance,
lang,
index = 0, index = 0,
onClose = () => {}, onClose = () => {},
}) { }) {
const [uiState, setUIState] = useState('default');
const carouselRef = useRef(null); const carouselRef = useRef(null);
const [currentIndex, setCurrentIndex] = useState(index); const [currentIndex, setCurrentIndex] = useState(index);
@ -55,10 +41,9 @@ function MediaModal({
const differentStatusID = prevStatusID.current !== statusID; const differentStatusID = prevStatusID.current !== statusID;
if (differentStatusID) prevStatusID.current = statusID; if (differentStatusID) prevStatusID.current = statusID;
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: scrollLeft * (isRTL() ? -1 : 1), left: scrollLeft,
behavior: differentStatusID ? 'auto' : 'smooth', behavior: differentStatusID ? 'auto' : 'smooth',
}); });
carouselRef.current.focus();
}, [index, statusID]); }, [index, statusID]);
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
@ -77,22 +62,14 @@ function MediaModal({
}; };
}, []); }, []);
useHotkeys( useHotkeys('esc', onClose, [onClose]);
'esc',
onClose, const [showMediaAlt, setShowMediaAlt] = useState(false);
{
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
[onClose],
);
useEffect(() => { useEffect(() => {
let handleScroll = () => { let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current; const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(Math.abs(scrollLeft) / clientWidth); const index = Math.round(scrollLeft / clientWidth);
setCurrentIndex(index); setCurrentIndex(index);
}; };
if (carouselRef.current) { if (carouselRef.current) {
@ -114,55 +91,11 @@ function MediaModal({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const mediaAccentColors = useMemo(() => {
return mediaAttachments?.map((media) => {
const { blurhash } = media;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor);
return oklab2rgb([0.6, labAverageColor[1], labAverageColor[2]]);
}
return null;
});
}, [mediaAttachments]);
const mediaAccentGradient = useMemo(() => {
const gap = 5;
const range = 100 / mediaAccentColors.length;
return (
mediaAccentColors
?.map((color, i) => {
const start = i * range + gap;
const end = (i + 1) * range - gap;
if (color) {
return `
rgba(${color?.join(',')}, 0.4) ${start}%,
rgba(${color?.join(',')}, 0.4) ${end}%
`;
}
return `
transparent ${start}%,
transparent ${end}%
`;
})
?.join(', ') || 'transparent'
);
}, [mediaAccentColors]);
let toastRef = useRef(null);
useEffect(() => {
return () => {
toastRef.current?.hideToast?.();
};
}, []);
return ( return (
<div <div class="media-modal-container">
class={`media-modal-container media-modal-count-${mediaAttachments?.length}`}
>
<div <div
ref={carouselRef} ref={carouselRef}
tabIndex="0" tabIndex="-1"
data-swipe-threshold="44" data-swipe-threshold="44"
class="carousel" class="carousel"
onClick={(e) => { onClick={(e) => {
@ -174,41 +107,26 @@ function MediaModal({
onClose(); onClose();
} }
}} }}
style={
mediaAttachments.length > 1
? {
backgroundAttachment: 'local',
backgroundImage: `linear-gradient(
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
}
: {}
}
> >
{mediaAttachments?.map((media, i) => { {mediaAttachments?.map((media, i) => {
const accentColor = const { blurhash } = media;
mediaAttachments.length === 1 ? mediaAccentColors[i] : null; const rgbAverageColor = blurhash
? getBlurHashAverageColor(blurhash)
: null;
return ( return (
<div <div
class="carousel-item" class="carousel-item"
style={ style={{
accentColor '--average-color': `rgb(${rgbAverageColor?.join(',')})`,
? { '--average-color-alpha': `rgba(${rgbAverageColor?.join(
'--accent-color': `rgb(${accentColor?.join(',')})`, ',',
'--accent-alpha-color': `rgba(${accentColor?.join( )}, .5)`,
',', }}
)}, 0.4)`,
}
: {}
}
tabindex="0" tabindex="0"
key={media.id} key={media.id}
ref={i === currentIndex ? carouselFocusItem : null} ref={i === currentIndex ? carouselFocusItem : null}
onClick={(e) => { onClick={(e) => {
// console.log(e); if (e.target !== e.currentTarget) {
// if (e.target !== e.currentTarget) {
// setShowControls(!showControls);
// }
if (!e.target.classList.contains('media')) {
setShowControls(!showControls); setShowControls(!showControls);
} }
}} }}
@ -216,22 +134,17 @@ function MediaModal({
{!!media.description && ( {!!media.description && (
<button <button
type="button" type="button"
class="media-alt" class="plain2 media-alt"
hidden={!showControls} hidden={!showControls}
onClick={() => { onClick={() => {
states.showMediaAlt = { setShowMediaAlt(media.description);
alt: media.description,
lang,
};
}} }}
> >
<span class="alt-badge">ALT</span> <Icon icon="info" />
<span class="media-alt-desc" lang={lang} dir="auto"> <span class="media-alt-desc">{media.description}</span>
{media.description}
</span>
</button> </button>
)} )}
<Media media={media} showOriginal lang={lang} /> <Media media={media} showOriginal />
</div> </div>
); );
})} })}
@ -240,7 +153,7 @@ function MediaModal({
<span> <span>
<button <button
type="button" type="button"
class="carousel-button" class="carousel-button plain3"
onClick={() => onClose()} onClick={() => onClose()}
> >
<Icon icon="x" /> <Icon icon="x" />
@ -253,19 +166,19 @@ function MediaModal({
key={media.id} key={media.id}
type="button" type="button"
disabled={i === currentIndex} disabled={i === currentIndex}
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`} class={`plain3 carousel-dot ${
i === currentIndex ? 'active' : ''
}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * i,
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
carouselRef.current.focus();
}} }}
> >
<Icon icon="round" size="s" /> &bull;
</button> </button>
))} ))}
</span> </span>
@ -273,14 +186,15 @@ function MediaModal({
<span /> <span />
)} )}
<span> <span>
<Menu2 <Menu
overflow="auto" overflow="auto"
align="end" align="end"
position="anchor" position="anchor"
boundingBoxPadding="8 8 8 8"
gap={4} gap={4}
menuClassName="glass-menu" menuClassName="glass-menu"
menuButton={ menuButton={
<button type="button" class="carousel-button"> <button type="button" class="carousel-button plain3">
<Icon icon="more" alt="More" /> <Icon icon="more" alt="More" />
</button> </button>
} }
@ -290,62 +204,21 @@ function MediaModal({
mediaAttachments[currentIndex]?.remoteUrl || mediaAttachments[currentIndex]?.remoteUrl ||
mediaAttachments[currentIndex]?.url mediaAttachments[currentIndex]?.url
} }
class="carousel-button" class="carousel-button plain3"
target="_blank" target="_blank"
title="Open original media in new window" title="Open original media in new window"
> >
<Icon icon="popout" /> <Icon icon="popout" />
<span>Open original media</span> <span>Open original media</span>
</MenuLink> </MenuLink>
{import.meta.env.DEV && // Only dev for now </Menu>{' '}
!!states.settings.mediaAltGenerator &&
!!IMG_ALT_API_URL &&
!!mediaAttachments[currentIndex]?.url &&
!mediaAttachments[currentIndex]?.description &&
mediaAttachments[currentIndex]?.type === 'image' && (
<>
<MenuDivider />
<MenuItem
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
toastRef.current = showToast({
text: 'Attempting to describe image. Please wait...',
duration: -1,
});
(async function () {
try {
const response = await fetch(
`${IMG_ALT_API_URL}?image=${encodeURIComponent(
mediaAttachments[currentIndex]?.url,
)}`,
).then((r) => r.json());
states.showMediaAlt = {
alt: response.description,
};
} catch (e) {
console.error(e);
showToast('Failed to describe image');
} finally {
setUIState('default');
toastRef.current?.hideToast?.();
}
})();
}}
>
<Icon icon="sparkles2" />
<span>Describe image</span>
</MenuItem>
</>
)}
</Menu2>{' '}
<Link <Link
to={`${instance ? `/${instance}` : ''}/s/${statusID}${ to={`${instance ? `/${instance}` : ''}/s/${statusID}${
window.matchMedia('(min-width: calc(40em + 350px))').matches window.matchMedia('(min-width: calc(40em + 350px))').matches
? `?media=${currentIndex + 1}` ? `?media=${currentIndex + 1}`
: '' : ''
}`} }`}
class="button carousel-button media-post-link" class="button carousel-button media-post-link plain3"
// onClick={() => { // onClick={() => {
// // if small screen (not media query min-width 40em + 350px), run onClose // // if small screen (not media query min-width 40em + 350px), run onClose
// if ( // if (
@ -355,7 +228,7 @@ function MediaModal({
// } // }
// }} // }}
> >
<span class="button-label">View post </span>&raquo; <span class="button-label">See post </span>&raquo;
</Link> </Link>
</span> </span>
</div> </div>
@ -363,17 +236,13 @@ function MediaModal({
<div class="carousel-controls" hidden={!showControls}> <div class="carousel-controls" hidden={!showControls}>
<button <button
type="button" type="button"
class="carousel-button" class="carousel-button plain3"
hidden={currentIndex === 0} hidden={currentIndex === 0}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * (currentIndex - 1),
carouselRef.current.clientWidth *
(currentIndex - 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
@ -382,17 +251,13 @@ function MediaModal({
</button> </button>
<button <button
type="button" type="button"
class="carousel-button" class="carousel-button plain3"
hidden={currentIndex === mediaAttachments.length - 1} hidden={currentIndex === mediaAttachments.length - 1}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: left: carouselRef.current.clientWidth * (currentIndex + 1),
carouselRef.current.clientWidth *
(currentIndex + 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
@ -401,6 +266,69 @@ function MediaModal({
</button> </button>
</div> </div>
)} )}
{!!showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowMediaAlt(false);
}
}}
>
<MediaAltModal
alt={showMediaAlt}
onClose={() => setShowMediaAlt(false)}
/>
</Modal>
)}
</div>
);
}
function MediaAltModal({ alt, onClose }) {
const [forceTranslate, setForceTranslate] = useState(false);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{forceTranslate && (
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
)}
</main>
</div> </div>
); );
} }

View file

@ -1,122 +0,0 @@
.media-post {
--item-radius: 16px;
position: relative;
animation: appear-smooth 1s ease-out;
&:is(.filtered, .has-spoiler:not(.show-media)) :is(img, video) {
/* filter: blur(32px);
image-rendering: crisp-edges;
image-rendering: pixelated; */
opacity: 0;
animation: none !important;
}
&.filtered[data-filtered-text]:before {
content: attr(data-filtered-text);
}
&.has-spoiler[data-spoiler-text]:before {
content: attr(data-spoiler-text);
}
&.filtered[data-filtered-text]:before,
&.has-spoiler[data-spoiler-text]:before {
pointer-events: none;
position: absolute;
top: 0;
inset-inline-start: 0;
z-index: 1;
background-color: var(--bg-blur-color);
margin: 8px;
padding: 4px 6px;
border-radius: calc(var(--item-radius) / 2);
font-size: 90%;
border: var(--hairline-width) dashed var(--bg-color);
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
/* mix-blend-mode: luminosity; */
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
box-orient: vertical;
display: -webkit-box;
display: box;
overflow: hidden;
z-index: 2;
> * {
pointer-events: none;
}
}
&.has-spoiler.show-media[data-spoiler-text]:before {
mix-blend-mode: normal;
backdrop-filter: blur(4px);
}
.media {
border-radius: var(--item-radius);
overflow: hidden;
position: relative;
display: block;
aspect-ratio: 1 !important;
&:before {
position: absolute;
inset: 0;
content: '';
border: 1px solid var(--outline-color);
border-radius: inherit;
}
&:not(.media-audio) {
background-color: var(--average-color, var(--media-bg-color));
background-clip: padding-box;
}
@media (hover: hover) {
&:hover {
--drop-shadow: var(--drop-shadow-color);
position: relative;
z-index: 1;
box-shadow: 0 8px 16px -4px var(--drop-shadow),
0 4px 8px var(--drop-shadow);
@media (prefers-color-scheme: dark) {
--drop-shadow: var(--link-color);
}
}
}
&:active:not(:has(button:active)) {
box-shadow: none;
filter: brightness(0.8);
transform: scale(0.99);
}
video,
img,
audio {
border-radius: 16px;
/* object-fit: scale-down; */
object-fit: cover;
width: 100%;
height: 100%;
vertical-align: top;
}
:not(.filtered, .has-spoiler) &:is(:hover, :focus) img {
/* Less delay here to make it feel more responsive */
animation: position-object 5s ease-in-out 0.1s 5;
animation-duration: var(--anim-duration, 5s);
}
}
&.has-spoiler .media:not(.media-audio) {
background-image: radial-gradient(
circle at 50% 50%,
var(--average-color, var(--bg-faded-color)),
var(--bg-color) 20em
);
}
}

View file

@ -1,154 +0,0 @@
import './media-post.css';
import { memo } from 'preact/compat';
import { useContext, useMemo } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import Media from './media';
function MediaPost({
class: className,
statusID,
status,
instance,
parent,
// allowFilters,
onMediaClick,
}) {
let sKey = statusKey(statusID, instance);
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
sKey = statusKey(status?.id, instance);
}
if (!status) {
return null;
}
const {
account: {
acct,
avatar,
avatarStatic,
id: accountId,
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
url,
emojis,
// Non-API props
_deleted,
_pinned,
// _filtered,
} = status;
if (!mediaAttachments?.length) {
return null;
}
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...status,
});
}
};
const currentAccount = useMemo(() => {
return getCurrentAccountID();
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo = !isSelf && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Media post', id, status?.account.displayName);
const hasSpoiler = sensitive;
const readingExpandMedia = useMemo(() => {
// default | show_all | hide_all
const prefs = store.account.get('preferences') || {};
return prefs['reading:expand:media'] || 'default';
}, []);
const showSpoilerMedia = readingExpandMedia === 'show_all';
const Parent = parent || 'div';
return mediaAttachments.map((media, i) => {
const mediaKey = `${sKey}-${media.id}`;
const filterTitleStr = filterInfo?.titlesStr;
return (
<Parent
data-state-post-id={sKey}
onMouseEnter={debugHover}
key={mediaKey}
data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined)
}
data-filtered-text={
filterInfo
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
: undefined
}
class={`
media-post
${filterInfo ? 'filtered' : ''}
${hasSpoiler ? 'has-spoiler' : ''}
${showSpoilerMedia ? 'show-media' : ''}
`}
>
<Media
class={className}
media={media}
lang={language}
to={`/${instance}/s/${id}?media-only=${i + 1}`}
onClick={
onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined
}
/>
</Parent>
);
});
}
export default memo(MediaPost);

View file

@ -1,6 +1,4 @@
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { import {
useCallback, useCallback,
useLayoutEffect, useLayoutEffect,
@ -10,12 +8,9 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import formatDuration from '../utils/format-duration';
import mem from '../utils/mem';
import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import { formatDuration } from './status';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
@ -29,53 +24,8 @@ video = Video clip
audio = Audio track audio = Audio track
*/ */
const dataAltLabel = 'ALT'; function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
const AltBadge = (props) => { const {
const { alt, lang, index, ...rest } = props;
if (!alt || !alt.trim()) return null;
return (
<button
type="button"
class="alt-badge clickable"
{...rest}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
states.showMediaAlt = {
alt,
lang,
};
}}
title="Media description"
>
{dataAltLabel}
{!!index && <sup>{index}</sup>}
</button>
);
};
const MEDIA_CAPTION_LIMIT = 140;
const MEDIA_CAPTION_LIMIT_LONGER = 280;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
/[\n\r].*[\n\r]/.test(caption.trim())
: false,
);
function Media({
class: className = '',
media,
to,
lang,
showOriginal,
autoAnimate,
showCaption,
allowLongerCaption,
altIndex,
onClick = () => {},
}) {
let {
blurhash, blurhash,
description, description,
meta, meta,
@ -85,33 +35,21 @@ function Media({
url, url,
type, type,
} = media; } = media;
if (/no\-preview\./i.test(previewUrl)) {
previewUrl = null;
}
const { original = {}, small, focus } = meta || {}; const { original = {}, small, focus } = meta || {};
const width = showOriginal const width = showOriginal ? original?.width : small?.width;
? original?.width const height = showOriginal ? original?.height : small?.height;
: small?.width || original?.width;
const height = showOriginal
? original?.height
: small?.height || original?.height;
const mediaURL = showOriginal ? url : previewUrl || url; const mediaURL = showOriginal ? url : previewUrl || url;
const remoteMediaURL = showOriginal const remoteMediaURL = showOriginal
? remoteUrl ? remoteUrl
: previewRemoteUrl || remoteUrl; : previewRemoteUrl || remoteUrl;
const hasDimensions = width && height; const orientation = width >= height ? 'landscape' : 'portrait';
const orientation = hasDimensions
? width > height
? 'landscape'
: 'portrait'
: null;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
const videoRef = useRef(); const videoRef = useRef();
let focalPosition; let focalBackgroundPosition;
if (focus) { if (focus) {
// Convert focal point to CSS background position // Convert focal point to CSS background position
// Formula from jquery-focuspoint // Formula from jquery-focuspoint
@ -120,7 +58,7 @@ function Media({
// x = 1, y = -1 => 100% 100% // x = 1, y = -1 => 100% 100%
const x = ((focus.x + 1) / 2) * 100; const x = ((focus.x + 1) / 2) * 100;
const y = ((1 - focus.y) / 2) * 100; const y = ((1 - focus.y) / 2) * 100;
focalPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
} }
const mediaRef = useRef(); const mediaRef = useRef();
@ -146,8 +84,6 @@ function Media({
enabled: pinchZoomEnabled, enabled: pinchZoomEnabled,
draggableUnZoomed: false, draggableUnZoomed: false,
inertiaFriction: 0.9, inertiaFriction: 0.9,
tapZoomFactor: 2,
doubleTapToggleZoom: true,
containerProps: { containerProps: {
className: 'media-zoom', className: 'media-zoom',
style: { style: {
@ -167,18 +103,7 @@ function Media({
[to], [to],
); );
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null; const isImage = type === 'image' || (type === 'unknown' && previewUrl);
const isVideoMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(remoteMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(remoteMediaURLObj.pathname);
const isImage =
type === 'image' ||
(type === 'unknown' && previewUrl && !isVideoMaybe && !isAudioMaybe);
const parentRef = useRef(); const parentRef = useRef();
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false); const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
@ -191,66 +116,6 @@ function Media({
if (smaller) setImageSmallerThanParent(smaller); if (smaller) setImageSmallerThanParent(smaller);
}, [width, height]); }, [width, height]);
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
const averageColorStyle = {
'--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
};
const mediaStyles =
width && height
? {
'--width': `${width}px`,
'--height': `${height}px`,
// Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px
'--aspectWidth': `${
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
...averageColorStyle,
}
: {
...averageColorStyle,
};
const longDesc = isMediaCaptionLong(description);
let showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
if (
allowLongerCaption &&
!showInlineDesc &&
description?.length <= MEDIA_CAPTION_LIMIT_LONGER
) {
showInlineDesc = true;
}
const Figure = !showInlineDesc
? Fragment
: (props) => {
const { children, ...restProps } = props;
return (
<figure {...restProps}>
{children}
<figcaption
class="media-caption"
lang={lang}
dir="auto"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: description,
lang,
};
}}
>
{description}
</figcaption>
</figure>
);
};
const [hasNaturalAspectRatio, setHasNaturalAspectRatio] = useState(undefined);
if (isImage) { if (isImage) {
// Note: type: unknown might not have width/height // Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit'; quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -269,139 +134,71 @@ function Media({
}, [mediaURL]); }, [mediaURL]);
return ( return (
<Figure> <Parent
<Parent ref={parentRef}
ref={parentRef} class={`media media-image`}
class={`media media-image ${className}`} onClick={onClick}
onClick={onClick} style={
data-orientation={orientation} showOriginal && {
data-has-alt={!showInlineDesc || undefined} backgroundImage: `url(${previewUrl})`,
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined} backgroundSize: imageSmallerThanParent
style={ ? `${width}px ${height}px`
showOriginal : undefined,
? {
backgroundImage: `url(${previewUrl})`,
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
...averageColorStyle,
}
: mediaStyles
} }
> }
{showOriginal ? ( >
<QuickPinchZoom {...quickPinchZoomProps}> {showOriginal ? (
<img <QuickPinchZoom {...quickPinchZoomProps}>
ref={mediaRef} <img
src={mediaURL} ref={mediaRef}
alt={description} src={mediaURL}
width={width} alt={description}
height={height} width={width}
data-orientation={orientation} height={height}
loading="eager" data-orientation={orientation}
decoding="sync" loading="eager"
onLoad={(e) => { decoding="sync"
e.target.closest('.media-image').style.backgroundImage = ''; onLoad={(e) => {
e.target.closest('.media-zoom').style.display = ''; e.target.closest('.media-image').style.backgroundImage = '';
setPinchZoomEnabled(true); e.target.closest('.media-zoom').style.display = '';
}} setPinchZoomEnabled(true);
onError={(e) => { }}
const { src } = e.target; onError={(e) => {
if ( const { src } = e.target;
src === mediaURL && if (src === mediaURL) {
remoteMediaURL && e.target.src = remoteMediaURL;
mediaURL !== remoteMediaURL }
) { }}
e.target.src = remoteMediaURL; />
} </QuickPinchZoom>
}} ) : (
/> <img
</QuickPinchZoom> src={mediaURL}
) : ( alt={description}
<> width={width}
<img height={height}
src={mediaURL} data-orientation={orientation}
alt={showInlineDesc ? '' : description} loading="lazy"
width={width} style={{
height={height} backgroundColor:
data-orientation={orientation} rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
loading="lazy" backgroundPosition: focalBackgroundPosition || 'center',
style={{ }}
// backgroundColor: onLoad={(e) => {
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, e.target.closest('.media-image').style.backgroundImage = '';
// backgroundPosition: focalBackgroundPosition || 'center', e.target.dataset.loaded = true;
// Duration based on width or height in pixels }}
objectPosition: focalPosition || 'center', onError={(e) => {
// 100px per second (rough estimate) const { src } = e.target;
// Clamp between 5s and 120s if (src === mediaURL) {
'--anim-duration': `${Math.min( e.target.src = remoteMediaURL;
Math.max(Math.max(width, height) / 100, 5), }
120, }}
)}s`, />
}} )}
onLoad={(e) => { </Parent>
// e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
const $media = e.target.closest('.media');
if (!hasDimensions && $media) {
const { naturalWidth, naturalHeight } = e.target;
$media.dataset.orientation =
naturalWidth > naturalHeight ? 'landscape' : 'portrait';
$media.style.setProperty('--width', `${naturalWidth}px`);
$media.style.setProperty('--height', `${naturalHeight}px`);
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
}
// Check natural aspect ratio vs display aspect ratio
if ($media) {
const {
clientWidth,
clientHeight,
naturalWidth,
naturalHeight,
} = e.target;
if (
clientWidth &&
clientHeight &&
naturalWidth &&
naturalHeight
) {
const minDimension = 88;
if (
naturalWidth < minDimension ||
naturalHeight < minDimension
) {
$media.dataset.hasSmallDimension = true;
} else {
const displayNaturalHeight =
(naturalHeight * clientWidth) / naturalWidth;
const almostSimilarHeight =
Math.abs(displayNaturalHeight - clientHeight) < 5;
if (almostSimilarHeight) {
setHasNaturalAspectRatio(true);
}
}
}
}
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL && mediaURL !== remoteMediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)}
</Parent>
</Figure>
); );
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) { } else if (type === 'gifv' || type === 'video') {
const hasDuration = original.duration > 0;
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration; const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video // If GIF is too long, treat it as a video
@ -409,282 +206,135 @@ function Media({
const formattedDuration = formatDuration(original.duration); const formattedDuration = formatDuration(original.duration);
const hoverAnimate = !showOriginal && !autoAnimate && isGIF; const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
const showProgress = original.duration > 5;
// This string is only for autoplay + muted to work on Mobile Safari
const gifHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
muted
playsinline
${loopable ? 'loop' : ''}
ondblclick="this.paused ? this.play() : this.pause()"
${
showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
`;
const videoHTML = ` const videoHTML = `
<video <video
src="${url}" src="${url}"
poster="${previewUrl}" poster="${previewUrl}"
width="${width}" width="${width}"
height="${height}" height="${height}"
data-orientation="${orientation}" data-orientation="${orientation}"
preload="auto" preload="auto"
autoplay autoplay
playsinline muted="${isGIF}"
${loopable ? 'loop' : ''} ${isGIF ? '' : 'controls'}
controls playsinline
></video> loop="${loopable}"
`; ${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
></video>
`;
return ( return (
<Figure> <Parent
<Parent class={`media media-${isGIF ? 'gif' : 'video'} ${
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${ autoGIFAnimate ? 'media-contain' : ''
autoGIFAnimate ? 'media-contain' : '' }`}
} ${hoverAnimate ? 'media-hover-animate' : ''}`} data-formatted-duration={formattedDuration}
data-orientation={orientation} data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-formatted-duration={ // style={{
!showOriginal ? formattedDuration : undefined // backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
// }}
onClick={(e) => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
} }
data-label={ onClick(e);
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined }}
onMouseEnter={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
} }
data-has-alt={!showInlineDesc || undefined} }}
// style={{ onMouseLeave={() => {
// backgroundColor: if (hoverAnimate) {
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, try {
// }} videoRef.current.pause();
style={!showOriginal && mediaStyles} } catch (e) {}
onClick={(e) => { }
if (hoverAnimate) { }}
try { >
videoRef.current.pause(); {showOriginal || autoGIFAnimate ? (
} catch (e) {} isGIF && showOriginal ? (
} <QuickPinchZoom {...quickPinchZoomProps} enabled>
onClick(e);
}}
onMouseEnter={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
onFocus={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onBlur={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps} enabled>
<div
ref={mediaRef}
dangerouslySetInnerHTML={{
__html: gifHTML,
}}
/>
</QuickPinchZoom>
) : isGIF ? (
<div <div
class="video-container" ref={mediaRef}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: gifHTML, __html: videoHTML,
}} }}
/> />
) : ( </QuickPinchZoom>
<div
class="video-container"
dangerouslySetInnerHTML={{ __html: videoHTML }}
/>
)
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
data-orientation={orientation}
preload="auto"
// controls
playsinline
loop
muted
onTimeUpdate={
showProgress
? (e) => {
const { target } = e;
const container = target?.closest('.media-gif');
if (container) {
const percentage =
(target.currentTime / target.duration) * 100;
container.style.setProperty(
'--progress',
`${percentage}%`,
);
}
}
: undefined
}
/>
) : ( ) : (
<> <div
{previewUrl ? ( class="video-container"
<img dangerouslySetInnerHTML={{
src={previewUrl} __html: videoHTML,
alt={showInlineDesc ? '' : description} }}
width={width} />
height={height} )
data-orientation={orientation} ) : isGIF ? (
loading="lazy" <video
decoding="async" ref={videoRef}
onLoad={(e) => { src={url}
if (!hasDimensions) { poster={previewUrl}
const $media = e.target.closest('.media'); width={width}
if ($media) { height={height}
const { naturalHeight, naturalWidth } = e.target; data-orientation={orientation}
$media.dataset.orientation = preload="auto"
naturalWidth > naturalHeight // controls
? 'landscape' playsinline
: 'portrait'; loop
$media.style.setProperty( muted
'--width', />
`${naturalWidth}px`, ) : (
); <>
$media.style.setProperty(
'--height',
`${naturalHeight}px`,
);
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
}
}
}}
/>
) : (
<video
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
width={width}
height={height}
data-orientation={orientation}
preload="metadata"
muted
disablePictureInPicture
onLoadedMetadata={(e) => {
if (!hasDuration) {
const { duration } = e.target;
if (duration) {
const formattedDuration = formatDuration(duration);
const container = e.target.closest('.media-video');
if (container) {
container.dataset.formattedDuration =
formattedDuration;
}
}
}
}}
/>
)}
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
</>
)}
{!showOriginal && !showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</Parent>
</Figure>
);
} else if (type === 'audio' || isAudioMaybe) {
const formattedDuration = formatDuration(original.duration);
return (
<Figure>
<Parent
class={`media media-audio ${className}`}
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-has-alt={!showInlineDesc || undefined}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoPlay />
) : previewUrl ? (
<img <img
src={previewUrl} src={previewUrl}
alt={showInlineDesc ? '' : description} alt={description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation} data-orientation={orientation}
loading="lazy" loading="lazy"
onError={(e) => {
try {
// Remove self if broken
e.target?.remove?.();
} catch (e) {}
}}
/> />
) : null} <div class="media-play">
{!showOriginal && ( <Icon icon="play" size="xxl" />
<> </div>
<div class="media-play"> </>
<Icon icon="play" size="xl" /> )}
</div> </Parent>
{!showInlineDesc && ( );
<AltBadge alt={description} lang={lang} index={altIndex} /> } else if (type === 'audio') {
)} const formattedDuration = formatDuration(original.duration);
</> return (
)} <Parent
</Parent> class="media media-audio"
</Figure> 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}
data-orientation={orientation}
loading="lazy"
/>
) : null}
{!showOriginal && (
<div class="media-play">
<Icon icon="play" size="xxl" />
</div>
)}
</Parent>
); );
} }
} }
function getURLObj(url) { export default Media;
// Fake base URL if url doesn't have https:// prefix
return URL.parse(url, location.origin);
}
export default memo(Media, (oldProps, newProps) => {
const oldMedia = oldProps.media || {};
const newMedia = newProps.media || {};
return (
oldMedia?.id === newMedia?.id &&
oldMedia.url === newMedia.url &&
oldProps.to === newProps.to &&
oldProps.class === newProps.class
);
});

View file

@ -1,48 +0,0 @@
import { MenuItem } from '@szhsin/react-menu';
import { cloneElement } from 'preact';
import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function MenuConfirm({
subMenu = false,
confirm = true,
confirmLabel,
menuItemClassName,
menuFooter,
menuExtras,
...props
}) {
const { children, onClick, ...restProps } = props;
if (!confirm) {
if (subMenu) return <MenuItem {...props} />;
if (onClick) {
return cloneElement(children, {
onClick,
});
}
return children;
}
const Parent = subMenu ? SubMenu2 : Menu2;
return (
<Parent
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
gap={-8}
shift={8}
menuClassName="menu-emphasized"
{...restProps}
menuButton={subMenu ? undefined : children}
label={subMenu ? children : undefined}
>
<MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel}
</MenuItem>
{menuExtras}
{menuFooter}
</Parent>
);
}
export default MenuConfirm;

View file

@ -3,12 +3,11 @@ import { FocusableItem } from '@szhsin/react-menu';
import Link from './link'; import Link from './link';
function MenuLink(props) { function MenuLink(props) {
const { className, disabled, ...restProps } = props;
return ( return (
<FocusableItem className={className} disabled={disabled}> <FocusableItem>
{({ ref, closeMenu }) => ( {({ ref, closeMenu }) => (
<Link <Link
{...restProps} {...props}
ref={ref} ref={ref}
onClick={({ detail }) => onClick={({ detail }) =>
closeMenu(detail === 0 ? 'Enter' : undefined) closeMenu(detail === 0 ? 'Enter' : undefined)

View file

@ -1,33 +1,19 @@
import { Menu } from '@szhsin/react-menu'; import { Menu } from '@szhsin/react-menu';
import { useWindowSize } from '@uidotdev/usehooks';
import { useRef } from 'preact/hooks'; import { useRef } from 'preact/hooks';
import isRTL from '../utils/is-rtl';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import useWindowSize from '../utils/useWindowSize';
// It's like Menu but with sensible defaults, bug fixes and improvements. // It's like Menu but with sensible defaults, bug fixes and improvements.
function Menu2(props) { function Menu2(props) {
const { containerProps, instanceRef: _instanceRef, align } = props; const { containerProps } = props;
const size = useWindowSize(); const size = useWindowSize();
const instanceRef = _instanceRef?.current ? _instanceRef : useRef(); const instanceRef = useRef();
// Values: start, end, center
// Note: don't mess with 'center'
const rtlAlign = isRTL()
? align === 'end'
? 'start'
: align === 'start'
? 'end'
: align
: align;
return ( return (
<Menu <Menu
boundingBoxPadding={safeBoundingBoxPadding()} boundingBoxPadding={safeBoundingBoxPadding()}
repositionFlag={`${size.width}x${size.height}`} repositionFlag={`${size.width}x${size.height}`}
unmountOnClose
{...props} {...props}
align={rtlAlign}
instanceRef={instanceRef} instanceRef={instanceRef}
containerProps={{ containerProps={{
onClick: (e) => { onClick: (e) => {

View file

@ -1,7 +1,7 @@
#modal-container > div { #modal-container > div {
position: fixed; position: fixed;
top: 0; top: 0;
inset-inline-end: 0; right: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
@ -9,66 +9,10 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: var(--backdrop-color); background-color: var(--backdrop-color);
backdrop-filter: blur(24px);
animation: appear 0.5s var(--timing-function) both; animation: appear 0.5s var(--timing-function) both;
transition: all 0.5s var(--timing-function);
&.solid {
background-color: var(--backdrop-solid-color);
}
--compose-button-dimension: 56px;
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
--compose-button-dimension-margin: 16px;
&.min {
/* Minimized */
pointer-events: none;
user-select: none;
overflow: hidden;
transform: scale(0);
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-right)
);
:dir(rtl) & {
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-left)
);
}
--bottom: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-bottom)
);
--origin-end: calc(
100% - var(--compose-button-dimension-half) - var(--end)
);
:dir(rtl) & {
--origin-end: calc(var(--compose-button-dimension-half) + var(--end));
}
--origin-bottom: calc(
100% - var(--compose-button-dimension-half) - var(--bottom)
);
transform-origin: var(--origin-end) var(--origin-bottom);
}
.sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: 80% 80%;
}
&:has(~ div) .sheet {
transform: scale(0.975);
}
} }
@media (max-width: calc(40em - 1px)) { #modal-container > .light {
#app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min { backdrop-filter: saturate(0.75);
border: 2px solid red;
--bottom: calc(
var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) +
52px
);
}
} }

View file

@ -2,13 +2,10 @@ import './modal.css';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import useCloseWatcher from '../utils/useCloseWatcher';
const $modalContainer = document.getElementById('modal-container'); const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClose, onClick, class: className, minimized }) { function Modal({ children, onClick, class: className }) {
if (!children) return null; if (!children) return null;
const modalRef = useRef(); const modalRef = useRef();
@ -22,84 +19,8 @@ function Modal({ children, onClose, onClick, class: className, minimized }) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const supportsCloseWatcher = window.CloseWatcher;
const escRef = useHotkeys(
'esc',
() => {
setTimeout(() => {
onClose?.();
}, 0);
},
{
enabled: !supportsCloseWatcher && !!onClose,
// Using keyup and setTimeout above
// This will run "later" to prevent clash with esc handlers from other components
keydown: false,
keyup: true,
},
[onClose],
);
useCloseWatcher(onClose, [onClose]);
useEffect(() => {
const $deckContainers = document.querySelectorAll('.deck-container');
if (minimized) {
// Similar to focusDeck in focus-deck.jsx
// Focus last deck
const page = $deckContainers[$deckContainers.length - 1]; // last one
if (page && page.tabIndex === -1) {
page.focus();
}
} else {
if (children) {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.setAttribute('inert', '');
});
} else {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
}
}
return () => {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
};
}, [children, minimized]);
const Modal = ( const Modal = (
<div <div ref={modalRef} className={className} onClick={onClick}>
ref={(node) => {
modalRef.current = node;
escRef.current = node?.querySelector?.('[tabindex="-1"]') || node;
}}
className={className}
onClick={(e) => {
onClick?.(e);
if (e.target === e.currentTarget) {
onClose?.(e);
}
}}
tabIndex={minimized ? 0 : '-1'}
inert={minimized}
onFocus={(e) => {
try {
if (e.target === e.currentTarget) {
const focusElement =
modalRef.current?.querySelector('[tabindex="-1"]');
const isFocusable =
!!focusElement &&
getComputedStyle(focusElement)?.pointerEvents !== 'none';
if (focusElement && isFocusable) {
focusElement.focus();
}
}
} catch (err) {
console.error(err);
}
}}
>
{children} {children}
</div> </div>
); );

View file

@ -1,246 +0,0 @@
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
import Accounts from '../pages/accounts';
import Settings from '../pages/settings';
import focusDeck from '../utils/focus-deck';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import AccountSheet from './account-sheet';
import ComposeSuspense, { preload } from './compose-suspense';
import Drafts from './drafts';
import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ReportModal from './report-modal';
import ShortcutsSettings from './shortcuts-settings';
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// When closing modal, focus on deck
if (/^show/i.test(path) && !value) {
focusDeck();
}
}
});
export default function Modals() {
const snapStates = useSnapshot(states);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setTimeout(preload, 1000);
}, []);
return (
<>
{!!snapStates.showCompose && (
<Modal
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
minimized={!!snapStates.composerState.minimized}
>
<ComposeSuspense
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
}[type || 'post'],
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClose={() => {
states.showSettings = false;
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClose={() => {
states.showAccounts = false;
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
onClose={() => {
states.showAccount = false;
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
// states.showGenericAccounts = false;
// if (destination) {
// states.showAccounts = false;
// }
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClose={() => {
states.showDrafts = false;
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
onClose={() => {
states.showShortcutsSettings = false;
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
{!!snapStates.showGenericAccounts && (
<Modal
onClose={() => {
states.showGenericAccounts = false;
}}
>
<GenericAccounts
instance={snapStates.showGenericAccounts.instance}
excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs
}
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)}
blankCopy={snapStates.showGenericAccounts.blankCopy}
/>
</Modal>
)}
{!!snapStates.showMediaAlt && (
<Modal
onClose={(e) => {
states.showMediaAlt = false;
}}
>
<MediaAltModal
alt={snapStates.showMediaAlt.alt || snapStates.showMediaAlt}
lang={snapStates.showMediaAlt?.lang}
onClose={() => {
states.showMediaAlt = false;
}}
/>
</Modal>
)}
{!!snapStates.showEmbedModal && (
<Modal
class="solid"
onClose={() => {
states.showEmbedModal = false;
}}
>
<EmbedModal
html={snapStates.showEmbedModal.html}
url={snapStates.showEmbedModal.url}
width={snapStates.showEmbedModal.width}
height={snapStates.showEmbedModal.height}
onClose={() => {
states.showEmbedModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showReportModal && (
<Modal
onClose={() => {
states.showReportModal = false;
}}
>
<ReportModal
account={snapStates.showReportModal.account}
post={snapStates.showReportModal.post}
onClose={() => {
states.showReportModal = false;
}}
/>
</Modal>
)}
</>
);
}

View file

@ -2,17 +2,6 @@
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
display: inline; display: inline;
unicode-bidi: isolate;
b {
font-weight: 600;
unicode-bidi: isolate;
}
i {
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
} }
.name-text.show-acct { .name-text.show-acct {
display: inline-block; display: inline-block;
@ -26,9 +15,6 @@ a.name-text.short:is(:hover, :focus) i {
font-style: normal; font-style: normal;
opacity: 0.75; opacity: 0.75;
} }
.name-text i.instance {
opacity: 0.35;
}
.name-text .avatar { .name-text .avatar {
vertical-align: middle; vertical-align: middle;

View file

@ -1,17 +1,10 @@
import './name-text.css'; import './name-text.css';
import { memo } from 'preact/compat';
import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import Avatar from './avatar'; import Avatar from './avatar';
import EmojiText from './emoji-text'; import EmojiText from './emoji-text';
const nameCollator = new Intl.Collator('en', {
sensitivity: 'base',
});
function NameText({ function NameText({
account, account,
instance, instance,
@ -21,60 +14,35 @@ function NameText({
external, external,
onClick, onClick,
}) { }) {
const { const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
acct, account;
avatar, let { username } = account;
avatarStatic,
id,
url,
displayName,
emojis,
bot,
username,
} = account;
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (!instance) instance = api().instance;
const trimmedUsername = username.toLowerCase().trim(); const trimmedUsername = username.toLowerCase().trim();
const trimmedDisplayName = (displayName || '').toLowerCase().trim(); const trimmedDisplayName = (displayName || '').toLowerCase().trim();
const shortenedDisplayName = trimmedDisplayName const shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1 .replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, ''); // E.g. "My name" === "myname" .replace(/\s+/g, '') // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace( .replace(/[^a-z0-9]/gi, ''); // Remove non-alphanumeric characters
/[^a-z0-9@\.]/gi,
'',
); // Remove non-alphanumeric characters
const hideUsername = if (
(!short && !short &&
(trimmedUsername === trimmedDisplayName || (trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName || trimmedUsername === shortenedDisplayName)
trimmedUsername === shortenedAlphaNumericDisplayName || ) {
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) || username = null;
shortenedAlphaNumericDisplayName === acct.toLowerCase(); }
return ( return (
<a <a
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`} class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={ title={`@${acct}`}
displayName
? `${displayName} (${acct2 ? '' : '@'}${acct})`
: `${acct2 ? '' : '@'}${acct}`
}
onClick={(e) => { onClick={(e) => {
if (external) return; if (external) return;
if (e.shiftKey) return; // Save link? 🤷
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (onClick) return onClick(e); if (onClick) return onClick(e);
if (e.metaKey || e.ctrlKey || e.shiftKey || e.which === 2) {
const internalURL = `#/${instance}/a/${id}`;
window.open(internalURL, '_blank');
return;
}
states.showAccount = { states.showAccount = {
account, account,
instance, instance,
@ -88,39 +56,29 @@ function NameText({
)} )}
{displayName && !short ? ( {displayName && !short ? (
<> <>
<b dir="auto"> <b>
<EmojiText text={displayName} emojis={emojis} /> <EmojiText text={displayName} emojis={emojis} />
</b> </b>
{!showAcct && !hideUsername ? ( {!showAcct && username && (
<> <>
{' '} {' '}
<i class="bidi-isolate">@{username}</i> <i>@{username}</i>
</> </>
) : ' '} )}
<i class="instance">{acct2}</i>
</> </>
) : short ? ( ) : short ? (
<i>{username}</i> <i>@{username}</i>
) : ( ) : (
<b>{username}</b> <b>@{username}</b>
)} )}
{showAcct && ( {showAcct && (
<> <>
<br /> <br />
<i class="bidi-isolate"> <i>@{acct}</i>
{acct2 ? '' : '@'}
{acct1}
{!!acct2 && <span class="ib">{acct2}</span>}
</i>
</> </>
)} )}
</a> </a>
); );
} }
export default memo(NameText, (oldProps, newProps) => { export default NameText;
// Only care about account.id, the other props usually don't change
const { account } = oldProps;
const { account: newAccount } = newProps;
return account?.acct === newAccount?.acct;
});

View file

@ -1,29 +1,12 @@
.nav-menu section:last-child {
background-color: var(--bg-faded-color);
margin-bottom: -8px;
padding-bottom: 8px;
}
@media (min-width: 23em) { @media (min-width: 23em) {
.nav-menu { .nav-menu {
display: grid; display: flex;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'top top'
'left right';
padding: 0; padding: 0;
width: 22em; width: 22em;
max-width: calc(100vw - 16px);
}
.nav-menu .top-menu {
grid-area: top;
padding-top: 8px;
margin-bottom: -8px;
} }
.nav-menu section { .nav-menu section {
padding: 8px 0; padding: 8px 0;
/* width: 50%; */ width: 50%;
} }
@keyframes phanpying { @keyframes phanpying {
0% { 0% {
@ -34,16 +17,13 @@
} }
} }
.nav-menu section:last-child { .nav-menu section:last-child {
background-color: var(--bg-faded-color);
background-image: linear-gradient( background-image: linear-gradient(
var(--to-forward), to right,
var(--divider-color) 1px, var(--divider-color) 1px,
transparent 1px transparent 1px
), ),
linear-gradient( linear-gradient(to bottom left, var(--bg-blur-color), transparent),
to bottom var(--backward),
var(--bg-blur-color),
transparent
),
url(../assets/phanpy-bg.svg); url(../assets/phanpy-bg.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
/* background-size: auto, auto, 200%; */ /* background-size: auto, auto, 200%; */
@ -53,17 +33,6 @@
position: sticky; position: sticky;
top: 0; top: 0;
animation: phanpying 0.2s ease-in-out both; animation: phanpying 0.2s ease-in-out both;
border-start-end-radius: inherit;
border-end-end-radius: inherit;
margin-bottom: 0;
display: flex;
flex-direction: column;
.divider-grow {
flex-grow: 1;
height: auto;
background-color: transparent;
}
} }
.nav-menu section:last-child > .szh-menu__divider:first-child { .nav-menu section:last-child > .szh-menu__divider:first-child {
display: none; display: none;
@ -78,21 +47,3 @@
width: 28em; width: 28em;
} }
} }
@keyframes sparkle-icon {
0% {
transform: scale(1);
color: var(--red-color);
}
100% {
transform: scale(1.2);
color: var(--orange-color);
}
}
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}
.nav-submenu {
max-width: 14em;
}

View file

@ -1,35 +1,33 @@
import './nav-menu.css'; import './nav-menu.css';
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu'; import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import supports from '../utils/supports';
import Avatar from './avatar'; import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import SubMenu2 from './submenu2';
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api(); const { instance, authenticated } = api();
const [currentAccount, moreThanOneAccount] = useMemo(() => { const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
const acc = const acc = accounts.find(
accounts.find((account) => account.info.id === getCurrentAccountID()) || (account) => account.info.id === store.session.get('currentAccount'),
accounts[0]; );
return [acc, accounts.length > 1]; if (acc) setCurrentAccount(acc);
setMoreThanOneAccount(accounts.length > 1);
}, []); }, []);
// Home = Following // Home = Following
@ -37,9 +35,8 @@ function NavMenu(props) {
// User may choose pin or not to pin Following // User may choose pin or not to pin Following
// If user doesn't pin Following, we show it in the menu // If user doesn't pin Following, we show it in the menu
const showFollowing = const showFollowing =
(snapStates.settings.shortcutsViewMode === 'multi-column' || (snapStates.settings.shortcutsColumnsMode ||
(!snapStates.settings.shortcutsViewMode && snapStates.settings.shortcutsViewMode === 'multi-column') &&
snapStates.settings.shortcutsColumnsMode)) &&
!snapStates.shortcuts.find((pin) => pin.type === 'following'); !snapStates.shortcuts.find((pin) => pin.type === 'following');
const bindLongPress = useLongPress( const bindLongPress = useLongPress(
@ -63,38 +60,6 @@ function NavMenu(props) {
0, 0,
]); ]);
const mutesIterator = useRef();
async function fetchMutes(firstLoad) {
if (firstLoad || !mutesIterator.current) {
mutesIterator.current = masto.v1.mutes.list({
limit: 80,
});
}
const results = await mutesIterator.current.next();
return results;
}
const blocksIterator = useRef();
async function fetchBlocks(firstLoad) {
if (firstLoad || !blocksIterator.current) {
blocksIterator.current = masto.v1.blocks.list({
limit: 80,
});
}
const results = await blocksIterator.current.next();
return results;
}
const supportsLists = supports('@mastodon/lists');
const [lists, setLists] = useState([]);
useEffect(() => {
if (!supportsLists) return;
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState === 'open']);
const buttonClickTS = useRef();
return ( return (
<> <>
<button <button
@ -102,10 +67,9 @@ function NavMenu(props) {
type="button" type="button"
class={`button plain nav-menu-button ${ class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : '' moreThanOneAccount ? 'with-avatar' : ''
} ${menuState === 'open' ? 'active' : ''}`} } ${open ? 'active' : ''}`}
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={() => { onClick={() => {
buttonClickTS.current = Date.now();
setMenuState((state) => (!state ? 'open' : undefined)); setMenuState((state) => (!state ? 'open' : undefined));
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
@ -137,10 +101,7 @@ function NavMenu(props) {
zIndex: 10, zIndex: 10,
}, },
onClick: () => { onClick: () => {
if (Date.now() - buttonClickTS.current < 300) { setMenuState(undefined);
return;
}
// setMenuState(undefined);
}, },
}} }}
portal={{ portal={{
@ -154,47 +115,41 @@ function NavMenu(props) {
boundingBoxPadding={boundingBoxPadding} boundingBoxPadding={boundingBoxPadding}
unmountOnClose unmountOnClose
> >
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<div class="top-menu">
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</div>
)}
<section> <section>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<>
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuLink to="/"> <MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span> <Icon icon="home" size="l" /> <span>Home</span>
</MenuLink> </MenuLink>
{authenticated ? ( {authenticated && (
<> <>
{showFollowing && ( {showFollowing && (
<MenuLink to="/following"> <MenuLink to="/following">
<Icon icon="following" size="l" /> <span>Following</span> <Icon icon="following" size="l" /> <span>Following</span>
</MenuLink> </MenuLink>
)} )}
<MenuLink to="/catchup"> <MenuLink to="/mentions">
<Icon icon="history2" size="l" /> <Icon icon="at" size="l" /> <span>Mentions</span>
<span>Catch-up</span>
</MenuLink> </MenuLink>
{supports('@mastodon/mentions') && (
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
</MenuLink>
)}
<MenuLink to="/notifications"> <MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span> <Icon icon="notification" size="l" /> <span>Notifications</span>
{snapStates.notificationsShowNew && ( {snapStates.notificationsShowNew && (
@ -204,107 +159,44 @@ function NavMenu(props) {
</sup> </sup>
)} )}
</MenuLink> </MenuLink>
<MenuDivider />
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
<MenuLink to="/ft">
<Icon icon="hashtag" size="l" /> <span>Followed Hashtags</span>
</MenuLink>
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Favourites</span>
</MenuLink>
</>
)}
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="group" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
</section>
<section>
{authenticated ? (
<>
<MenuDivider /> <MenuDivider />
{currentAccount?.info?.id && ( {currentAccount?.info?.id && (
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}> <MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
<Icon icon="user" size="l" /> <span>Profile</span> <Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink> </MenuLink>
)} )}
{currentAccount && accountsIsDtth(currentAccount) &&
<FocusableItem title="Takes you to DTTHDon settings">
<a href={gtsDtthSettings} target='_blank'><Icon icon="user-setting" size="l" /> <span>User Settings&hellip;</span></a>
</FocusableItem>}
{lists?.length > 0 ? (
<SubMenu2
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="list" size="l" />
<span class="menu-grow">Lists</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</SubMenu2>
) : (
supportsLists && (
<MenuLink to="/l">
<Icon icon="list" size="l" />
<span>Lists</span>
</MenuLink>
)
)}
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<SubMenu2
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="more" size="l" />
<span class="menu-grow">More</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
<MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span>
</MenuLink>
<MenuDivider />
{supports('@mastodon/filters') && (
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
</MenuLink>
)}
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>{' '}
</SubMenu2>
<MenuDivider />
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showAccounts = true; states.showAccounts = true;
@ -312,6 +204,21 @@ function NavMenu(props) {
> >
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span> <Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem> </MenuItem>
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;
}}
>
<Icon icon="shortcut" size="l" />{' '}
<span>Shortcuts Settings&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</> </>
) : ( ) : (
<> <>
@ -322,63 +229,9 @@ function NavMenu(props) {
</> </>
)} )}
</section> </section>
<section>
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
{authenticated ? (
<>
<MenuDivider className="divider-grow" />
<MenuItem
onClick={() => {
states.showKeyboardShortcutsHelp = true;
}}
>
<Icon icon="keyboard" size="l" />{' '}
<span>Keyboard shortcuts</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;
}}
>
<Icon icon="shortcut" size="l" />{' '}
<span>Shortcuts / Columns&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</>
) : (
<>
<MenuDivider />
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</>
)}
</section>
</ControlledMenu> </ControlledMenu>
</> </>
); );
} }
export default memo(NavMenu); export default NavMenu;

View file

@ -1,199 +0,0 @@
import { memo } from 'preact/compat';
import { useLayoutEffect, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import states from '../utils/states';
import {
getAccountByAccessToken,
getCurrentAccount,
} from '../utils/store-utils';
import usePageVisibility from '../utils/usePageVisibility';
import Icon from './icon';
import Link from './link';
import Modal from './modal';
import Notification from './notification';
{
if ('serviceWorker' in navigator) {
console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {};
if (type === 'notification') {
states.routeNotification = {
id,
accessToken,
};
}
});
}
}
export default memo(function NotificationService() {
if (!('serviceWorker' in navigator)) return null;
const snapStates = useSnapshot(states);
const { routeNotification } = snapStates;
console.log('🛎️ Notification service', routeNotification);
const { id, accessToken } = routeNotification || {};
const [showNotificationSheet, setShowNotificationSheet] = useState(false);
useLayoutEffect(() => {
if (!id || !accessToken) return;
const { instance: currentInstance } = api();
const { masto, instance } = api({
accessToken,
});
console.log('API', { accessToken, currentInstance, instance });
const sameInstance = currentInstance === instance;
const account = accessToken
? getAccountByAccessToken(accessToken)
: getCurrentAccount();
(async () => {
const notification = await masto.v1.notifications.$select(id).fetch();
if (notification && account) {
console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL;
const { type, status, account: notificationAccount } = notification;
const hasModal = !!document.querySelector('#modal-container > *');
const isFollow = type === 'follow' && !!notificationAccount?.id;
const hasAccount = !!notificationAccount?.id;
const hasStatus = !!status?.id;
if (isFollow && sameInstance) {
// Show account sheet, can handle different instances
states.showAccount = {
account: notificationAccount,
instance: accountInstance,
};
} else if (hasModal || !sameInstance || (hasAccount && hasStatus)) {
// Show sheet of notification, if
// - there is a modal open
// - the notification is from another instance
// - the notification has both account and status, gives choice for users to go to account or status
setShowNotificationSheet({
id,
account,
notification,
sameInstance,
});
} else {
if (hasStatus) {
// Go to status page
location.hash = `/${currentInstance}/s/${status.id}`;
} else if (isFollow) {
// Go to profile page
location.hash = `/${currentInstance}/a/${notificationAccount.id}`;
} else {
// Go to notifications page
location.hash = '/notifications';
}
}
} else {
console.warn('🛎️ Notification not found', id);
}
})();
}, [id, accessToken]);
// useLayoutEffect(() => {
// // Listen to message from service worker
// const handleMessage = (event) => {
// console.log('💥💥💥 Message event', event);
// const { type, id, accessToken } = event?.data || {};
// if (type === 'notification') {
// states.routeNotification = {
// id,
// accessToken,
// };
// }
// };
// console.log('👂👂👂 Listen to message');
// navigator.serviceWorker.addEventListener('message', handleMessage);
// return () => {
// console.log('👂👂👂 Remove listen to message');
// navigator.serviceWorker.removeEventListener('message', handleMessage);
// };
// }, []);
useLayoutEffect(() => {
if (navigator?.clearAppBadge) {
navigator.clearAppBadge();
}
}, []);
usePageVisibility((visible) => {
if (visible && navigator?.clearAppBadge) {
console.log('🔰 Clear app badge');
navigator.clearAppBadge();
}
});
const onClose = () => {
setShowNotificationSheet(false);
states.routeNotification = null;
// If url is #/notifications?id=123, go to #/notifications
if (/\/notifications\?id=/i.test(location.hash)) {
location.hash = '/notifications';
}
};
if (showNotificationSheet) {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notification</b>
</header>
<main>
{!sameInstance && (
<p>This notification is from your other account.</p>
)}
<div
class="notification-peek"
// style={{
// pointerEvents: sameInstance ? '' : 'none',
// }}
onClick={(e) => {
const { target } = e;
// If button or links
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
onClose();
}
}}
>
<Notification
instance={account.instanceURL}
notification={notification}
isStatic
/>
</div>
<div
style={{
textAlign: 'end',
}}
>
<Link to="/notifications" class="button light" onClick={onClose}>
<span>View all notifications</span> <Icon icon="arrow-right" />
</Link>
</div>
</main>
</div>
</Modal>
);
}
return null;
});

View file

@ -1,14 +1,7 @@
import { Fragment } from 'preact'; import states from '../utils/states';
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
import states, { statusKey } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import useTruncated from '../utils/useTruncated';
import Avatar from './avatar'; import Avatar from './avatar';
import CustomEmoji from './custom-emoji';
import FollowRequestButtons from './follow-request-buttons'; import FollowRequestButtons from './follow-request-buttons';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
@ -25,12 +18,6 @@ const NOTIFICATION_ICONS = {
favourite: 'heart', favourite: 'heart',
poll: 'poll', poll: 'poll',
update: 'pencil', update: 'pencil',
'admin.signup': 'account-edit',
'admin.report': 'account-warning',
severed_relationships: 'heart-break',
moderation_warning: 'alert',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
}; };
/* /*
@ -46,234 +33,63 @@ poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins) admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed admin.report = A new report has been filed
severed_relationships = Severed relationships
moderation_warning = Moderation warning
*/ */
function emojiText(emoji, emoji_url) {
let url;
let staticUrl;
if (typeof emoji_url === 'string') {
url = emoji_url;
} else {
url = emoji_url?.url;
staticUrl = emoji_url?.staticUrl;
}
return url ? (
<>
reacted to your post with{' '}
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
</>
) : (
`reacted to your post with ${emoji}.`
);
}
const contentText = { const contentText = {
mention: 'mentioned you in their post.', mention: 'mentioned you in their post.',
status: 'published a post.', status: 'published a post.',
reblog: 'boosted your post.', reblog: 'boosted your post.',
'reblog+account': (count) => `boosted ${count} of your posts.`,
reblog_reply: 'boosted your reply.',
follow: 'followed you.', follow: 'followed you.',
follow_request: 'requested to follow you.', follow_request: 'requested to follow you.',
favourite: 'liked your post.', favourite: 'favourited your post.',
'favourite+account': (count) => `liked ${count} of your posts.`,
favourite_reply: 'liked your reply.',
poll: 'A poll you have voted in or created has ended.', poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.', 'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.', 'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.', update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & liked your post.', 'favourite+reblog': 'boosted & favourited your post.',
'favourite+reblog+account': (count) =>
`boosted & liked ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
severed_relationships: (name) => (
<>
Lost connections with <i>{name}</i>.
</>
),
moderation_warning: <b>Moderation warning</b>,
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
}; };
// account_suspension, domain_block, user_domain_block function Notification({ notification, instance, reload }) {
const SEVERED_RELATIONSHIPS_TEXT = { const { id, status, account, _accounts } = notification;
account_suspension: ({ from, targetName }) => (
<>
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
you can no longer receive updates from them or interact with them.
</>
),
domain_block: ({ from, targetName, followersCount, followingCount }) => (
<>
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
followers: {followersCount}, followings: {followingCount}.
</>
),
user_domain_block: ({ targetName, followersCount, followingCount }) => (
<>
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
followings: {followingCount}.
</>
),
};
const MODERATION_WARNING_TEXT = {
none: 'Your account has received a moderation warning.',
disable: 'Your account has been disabled.',
mark_statuses_as_sensitive:
'Some of your posts have been marked as sensitive.',
delete_statuses: 'Some of your posts have been deleted.',
sensitive: 'Your posts will be marked as sensitive from now on.',
silence: 'Your account has been limited.',
suspend: 'Your account has been suspended.',
};
const AVATARS_LIMIT = 30;
function Notification({
notification,
instance,
isStatic,
disableContextMenu,
}) {
const {
id,
status,
account,
report,
event,
moderation_warning,
// Client-side grouped notification
_ids,
_accounts,
_statuses,
// Server-side grouped notification
sampleAccounts,
notificationsCount,
} = notification;
let { type } = notification; let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatus = status?.reblog || status; const actualStatusID = status?.reblog?.id || status?.id;
const actualStatusID = actualStatus?.id;
const currentAccount = getCurrentAccountID(); const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id; const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted; const isVoted = status?.poll?.voted;
const isReplyToOthers =
!!status?.inReplyToAccountId &&
status?.inReplyToAccountId !== currentAccount &&
status?.account?.id === currentAccount;
let favsCount = 0; let favsCount = 0;
let reblogsCount = 0; let reblogsCount = 0;
if (type === 'favourite+reblog') { if (type === 'favourite+reblog') {
if (_accounts) { for (const account of _accounts) {
for (const account of _accounts) { if (account._types?.includes('favourite')) {
if (account._types?.includes('favourite')) { favsCount++;
favsCount++; }
} if (account._types?.includes('reblog')) {
if (account._types?.includes('reblog')) { reblogsCount++;
reblogsCount++;
}
} }
} }
if (!reblogsCount && favsCount) type = 'favourite'; if (!reblogsCount && favsCount) type = 'favourite';
if (!favsCount && reblogsCount) type = 'reblog'; if (!favsCount && reblogsCount) type = 'reblog';
} }
let text; const text =
if (type === 'poll') { type === 'poll'
text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']; ? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
} else if ( : contentText[type];
type === 'reblog' ||
type === 'favourite' ||
type === 'favourite+reblog'
) {
if (_statuses?.length > 1) {
text = contentText[`${type}+account`];
} else if (isReplyToOthers) {
text = contentText[`${type}_reply`];
} else {
text = contentText[type];
}
} else if (contentText[type]) {
text = contentText[type];
} else {
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
// This surfaces the error to the user, hoping that users will report it
text = `[Unknown notification type: ${type}]`;
}
if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length;
if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
}
} else if (type === 'severed_relationships') {
const targetName = event?.targetName;
if (targetName) {
text = text(targetName);
}
} else if (
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
notification.emoji
) {
const emojiURL =
notification.emoji_url || // This is string
status?.emojis?.find?.(
(emoji) =>
emoji?.shortcode ===
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
); // Emoji object instead of string
text = text(notification.emoji, emojiURL);
} else if (count) {
text = text(count);
}
}
if (type === 'mention' && !status) { if (type === 'mention' && !status) {
// Could be deleted // Could be deleted
return null; return null;
} }
const formattedCreatedAt =
notification.createdAt && new Date(notification.createdAt).toLocaleString();
const genericAccountsHeading =
{
'favourite+reblog': 'Boosted/Liked by…',
favourite: 'Liked by…',
reblog: 'Boosted by…',
follow: 'Followed by…',
}[type] || 'Accounts';
const handleOpenGenericAccounts = () => {
states.showGenericAccounts = {
heading: genericAccountsHeading,
accounts: _accounts,
showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
};
};
console.debug('RENDER Notification', notification.id);
return ( return (
<div <div class={`notification notification-${type}`} tabIndex="0">
class={`notification notification-${type}`}
data-notification-id={_ids || id}
tabIndex="0"
>
<div <div
class={`notification-type notification-${type}`} class={`notification-type notification-${type}`}
title={formattedCreatedAt} title={new Date(notification.createdAt).toLocaleString()}
> >
{type === 'favourite+reblog' ? ( {type === 'favourite+reblog' ? (
<> <>
@ -292,32 +108,16 @@ function Notification({
{type !== 'mention' && ( {type !== 'mention' && (
<> <>
<p> <p>
{!/poll|update|severed_relationships/i.test(type) && ( {!/poll|update/i.test(type) && (
<> <>
{_accounts?.length > 1 ? ( {_accounts?.length > 1 ? (
<> <>
<b tabIndex="0" onClick={handleOpenGenericAccounts}> <b>{_accounts.length} people</b>{' '}
<span title={_accounts.length}>
{shortenNumber(_accounts.length)}
</span>{' '}
people
</b>{' '}
</>
) : notificationsCount > 1 ? (
<>
<b>
<span title={notificationsCount}>
{shortenNumber(notificationsCount)}
</span>{' '}
people
</b>{' '}
</> </>
) : ( ) : (
account && ( <>
<> <NameText account={account} showAvatar />{' '}
<NameText account={account} showAvatar />{' '} </>
</>
)
)} )}
</> </>
)} )}
@ -334,47 +134,20 @@ function Notification({
)} )}
</p> </p>
{type === 'follow_request' && ( {type === 'follow_request' && (
<FollowRequestButtons accountID={account.id} /> <FollowRequestButtons
)} accountID={account.id}
{type === 'severed_relationships' && ( onChange={() => {
<div> reload();
{SEVERED_RELATIONSHIPS_TEXT[event.type]({ }}
from: instance, />
...event,
})}
<br />
<a
href={`https://${instance}/severed_relationships`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
{type === 'moderation_warning' && !!moderation_warning && (
<div>
{MODERATION_WARNING_TEXT[moderation_warning.action]}
<br />
<a
href={`/disputes/strikes/${moderation_warning.id}`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)} )}
</> </>
)} )}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (
<p class="avatars-stack"> <p class="avatars-stack">
{_accounts.slice(0, AVATARS_LIMIT).map((account) => ( {_accounts.map((account, i) => (
<Fragment key={account.id}> <>
<a <a
key={account.id}
href={account.url} href={account.url}
rel="noopener noreferrer" rel="noopener noreferrer"
class="account-avatar-stack" class="account-avatar-stack"
@ -388,9 +161,13 @@ function Notification({
size={ size={
_accounts.length <= 10 _accounts.length <= 10
? 'xxl' ? 'xxl'
: _accounts.length < 20 : _accounts.length < 100
? 'xl' ? 'xl'
: 'l' : _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm'
: 's' // My god, this person is popular!
} }
key={account.id} key={account.id}
alt={`${account.displayName} @${account.acct}`} alt={`${account.displayName} @${account.acct}`}
@ -408,142 +185,25 @@ function Notification({
</div> </div>
)} )}
</a>{' '} </a>{' '}
</Fragment> </>
))} ))}
<button
type="button"
class="small plain"
onClick={handleOpenGenericAccounts}
>
{_accounts.length > AVATARS_LIMIT &&
`+${_accounts.length - AVATARS_LIMIT}`}
<Icon icon="chevron-down" />
</button>
</p> </p>
)} )}
{!_accounts?.length && sampleAccounts?.length > 1 && ( {status && (
<p class="avatars-stack"> <Link
{sampleAccounts.map((account) => (
<Fragment key={account.id}>
<a
key={account.id}
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size="xxl"
key={account.id}
alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/>
{/* {type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)} */}
</a>{' '}
</Fragment>
))}
{notificationsCount > sampleAccounts.length && (
<Link
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
class="button small plain centered"
>
+{notificationsCount - sampleAccounts.length}
<Icon icon="chevron-right" />
</Link>
)}
</p>
)}
{_statuses?.length > 1 && (
<ul class="notification-group-statuses">
{_statuses.map((status) => (
<li key={status.id}>
<TruncatedLink
class={`status-link status-type-${type}`}
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
>
<Status
status={status}
size="s"
previewMode
allowContextMenu
/>
</TruncatedLink>
</li>
))}
</ul>
)}
{status && (!_statuses?.length || _statuses?.length <= 1) && (
<TruncatedLink
class={`status-link status-type-${type}`} class={`status-link status-type-${type}`}
to={ to={
instance instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}` : `/s/${actualStatusID}`
} }
onContextMenu={
!disableContextMenu
? (e) => {
const post = e.target.querySelector('.status');
if (post) {
// Fire a custom event to open the context menu
if (e.metaKey) return;
e.preventDefault();
post.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: e.clientX,
clientY: e.clientY,
}),
);
}
}
: undefined
}
> >
{isStatic ? ( <Status statusID={actualStatusID} size="s" />
<Status </Link>
status={actualStatus}
size="s"
readOnly
allowContextMenu
/>
) : (
<Status
statusID={actualStatusID}
size="s"
readOnly
allowContextMenu
/>
)}
</TruncatedLink>
)} )}
</div> </div>
</div> </div>
); );
} }
function TruncatedLink(props) { export default Notification;
const ref = useTruncated();
return <Link {...props} data-read-more="Read more →" ref={ref} />;
}
export default memo(Notification, (oldProps, newProps) => {
return oldProps.notification?.id === newProps.notification?.id;
});

View file

@ -1,4 +1,4 @@
import { useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
@ -62,13 +62,29 @@ export default function Poll({
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null); const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
const pollRef = useRef();
useEffect(() => {
const handleSwipe = () => {
console.log('swiped left');
setShowResults(!showResults);
};
pollRef.current?.addEventListener?.('swiped-left', handleSwipe);
return () => {
pollRef.current?.removeEventListener?.('swiped-left', handleSwipe);
};
}, [showResults]);
return ( return (
<div <div
ref={pollRef}
lang={lang} lang={lang}
dir="auto" dir="auto"
class={`poll ${readOnly ? 'read-only' : ''} ${ class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : '' uiState === 'loading' ? 'loading' : ''
}`} }`}
onDblClick={() => {
setShowResults(!showResults);
}}
> >
{(showResults && optionsHaveVoteCounts) || voted || expired ? ( {(showResults && optionsHaveVoteCounts) || voted || expired ? (
<> <>
@ -122,12 +138,11 @@ export default function Poll({
<button <button
class="poll-vote-button plain2" class="poll-vote-button plain2"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
onClick={(e) => { onClick={() => {
e.preventDefault();
setShowResults(false); setShowResults(false);
}} }}
> >
<Icon icon="arrow-left" size="s" /> Hide results <Icon icon="arrow-left" /> Hide results
</button> </button>
)} )}
</> </>
@ -181,59 +196,44 @@ export default function Poll({
)} )}
</form> </form>
)} )}
<p class="poll-meta"> {!readOnly && (
{!expired && !readOnly && ( <p class="poll-meta">
<button {!expired && (
type="button" <>
class="plain small" <button
disabled={uiState === 'loading'} type="button"
onClick={(e) => { class="textual"
e.preventDefault(); disabled={uiState === 'loading'}
setUIState('loading'); onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => { (async () => {
await refresh(); await refresh();
setUIState('default'); setUIState('default');
})(); })();
}} }}
title="Refresh" >
> Refresh
<Icon icon="refresh" alt="Refresh" /> </button>{' '}
</button> &bull;{' '}
)} </>
{!voted && !expired && !readOnly && optionsHaveVoteCounts && ( )}
<button <span title={votesCount}>{shortenNumber(votesCount)}</span> vote
type="button" {votesCount === 1 ? '' : 's'}
class="plain small" {!!votersCount && votersCount !== votesCount && (
disabled={uiState === 'loading'} <>
onClick={(e) => { {' '}
e.preventDefault(); &bull;{' '}
setShowResults(!showResults); <span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
}} voter
title={showResults ? 'Hide results' : 'Show results'} {votersCount === 1 ? '' : 's'}
> </>
<Icon )}{' '}
icon={showResults ? 'eye-open' : 'eye-close'} &bull; {expired ? 'Ended' : 'Ending'}{' '}
alt={showResults ? 'Hide results' : 'Show results'} {!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
/>{' '} </p>
</button> )}
)}
{!expired && !readOnly && ' '}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votersCount}>
{shortenNumber(votersCount)}
</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>{' '}
</div> </div>
); );
} }

View file

@ -8,7 +8,7 @@ import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter'; import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { useEffect, useMemo, useReducer } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
dayjs.extend(dayjsTwitter); dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -18,54 +18,40 @@ const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) { export default function RelativeTime({ datetime, format }) {
if (!datetime) return null; if (!datetime) return null;
const [renderCount, rerender] = useReducer((x) => x + 1, 0); const date = dayjs(datetime);
const date = useMemo(() => dayjs(datetime), [datetime]); const [dateStr, setDateStr] = useState('');
const [dateStr, dt, title] = useMemo(() => {
if (!date.isValid()) return ['' + datetime, '', ''];
let str;
if (format === 'micro') {
// If date <= 1 day ago or day is within this year
const now = dayjs();
const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) {
str = date.twitter();
} else {
str = dtf.format(date.toDate());
}
}
if (!str) str = date.fromNow();
return [str, date.toISOString(), date.format('LLLL')];
}, [date, format, renderCount]);
useEffect(() => { useEffect(() => {
if (!date.isValid()) return; let timer, raf;
let timeout; const update = () => {
let raf;
function rafRerender() {
raf = requestAnimationFrame(() => { raf = requestAnimationFrame(() => {
rerender(); let str;
scheduleRerender(); if (format === 'micro') {
// If date <= 1 day ago or day is within this year
const now = dayjs();
const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) {
str = date.twitter();
} else {
str = dtf.format(date.toDate());
}
} else {
str = date.fromNow();
}
setDateStr(str);
timer = setTimeout(update, 30_000);
}); });
} };
function scheduleRerender() { raf = requestAnimationFrame(update);
// If less than 1 minute, rerender every 10s
// If less than 1 hour rerender every 1m
// Else, don't need to rerender
if (date.diff(dayjs(), 'minute', true) < 1) {
timeout = setTimeout(rafRerender, 10_000);
} else if (date.diff(dayjs(), 'hour', true) < 1) {
timeout = setTimeout(rafRerender, 60_000);
}
}
scheduleRerender();
return () => { return () => {
clearTimeout(timeout); clearTimeout(timer);
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
}; };
}, []); }, [date]);
return ( return (
<time datetime={dt} title={title}> <time datetime={date.toISOString()} title={date.format('LLLL')}>
{dateStr} {dateStr}
</time> </time>
); );

View file

@ -1,200 +0,0 @@
.report-modal-container {
width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
max-width: 40em;
background-color: var(--bg-color);
box-shadow: 0 16px 32px -8px var(--drop-shadow-color);
overflow-y: auto;
animation: slide-up-smooth 0.3s ease-in-out;
position: relative;
@media (min-width: 40em) {
max-height: calc(100% - 32px);
}
h1 {
margin: 0;
padding: 0;
}
.top-controls {
position: sticky;
top: var(--sai-top, 0);
z-index: 1;
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px);
padding: 16px;
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
16px calc(var(--sai-left, 0) + 16px);
display: flex;
gap: 8px;
justify-content: space-between;
pointer-events: auto;
align-items: center;
h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
main {
padding: 0 16px 16px;
padding: 0 calc(var(--sai-right, 0) + 16px)
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
/* display: flex;
flex-direction: column;
gap: 16px; */
}
form {
/* display: flex; */
/* flex-direction: column; */
/* gap: 16px; */
text-wrap: pretty;
input {
margin-inline: 0;
}
}
.report-preview {
background-color: var(--bg-color);
border-radius: 8px;
border: 2px dashed var(--red-color);
box-shadow: inset 0 0 16px -4px var(--red-bg-color);
overflow: auto;
max-height: 33vh;
.status {
font-size: 90%;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
.account-block {
margin: 16px;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
}
.rubber-stamp {
pointer-events: none;
user-select: none;
position: absolute;
inset-inline-end: 32px;
margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both;
position: absolute;
font-weight: bold;
color: var(--red-color);
text-transform: uppercase;
letter-spacing: -0.5px;
font-size: 2em;
line-height: 1;
padding: 0.1em;
border: 0.15em solid var(--red-color);
border-radius: 0.3em;
background-color: var(--bg-blur-color);
text-align: center;
/* Noise pattern - https://css-tricks.com/making-static-noise-from-a-weird-css-gradient-bug/ */
mask-image: repeating-conic-gradient(
#000 0 0.01%,
rgba(0, 0, 0, 0.45) 0 0.02%
);
small {
display: block;
font-size: 11px;
}
}
p {
margin-block: 0.5em;
}
section {
label {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
&:has(:checked) {
.insignificant {
color: var(--text-color);
}
}
}
> label:last-child {
margin-bottom: 0;
}
}
.report-categories {
label {
align-items: flex-start;
}
.report-rules {
margin-inline-start: 1.75em;
}
}
.report-comment {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 2em;
flex-wrap: wrap;
p {
margin: 0;
padding: 8px 0 0;
flex-shrink: 0;
label {
margin-bottom: 0;
}
}
textarea {
flex-grow: 1;
resize: vertical;
}
}
footer {
margin-top: 2em;
display: flex;
gap: 8px;
align-items: center;
button {
border-radius: 8px !important;
align-self: stretch;
}
}
}
@keyframes rubber-stamp {
0% {
transform: rotate(-20deg) scale(5);
opacity: 0;
}
100% {
transform: rotate(-20deg) scale(1);
opacity: 1;
}
}

View file

@ -1,298 +0,0 @@
import './report-modal.css';
import { Fragment } from 'preact';
import { useMemo, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import { getCurrentInstance } from '../utils/store-utils';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
import Status from './status';
// NOTE: `dislike` hidden for now, it's actually not used for reporting
// Mastodon shows another screen for unfollowing, muting or blocking instead of reporting
const CATEGORIES = [, /*'dislike'*/ 'spam', 'legal', 'violation', 'other'];
// `violation` will be set if there are `rule_ids[]`
const CATEGORIES_INFO = {
// dislike: {
// label: 'Dislike',
// description: 'Not something you want to see',
// },
spam: {
label: 'Spam',
description: 'Malicious links, fake engagement, or repetitive replies',
},
legal: {
label: 'Illegal',
description: "Violates the law of your or the server's country",
},
violation: {
label: 'Server rule violation',
description: 'Breaks specific server rules',
stampLabel: 'Violation',
},
other: {
label: 'Other',
description: "Issue doesn't fit other categories",
excludeStamp: true,
},
};
function ReportModal({ account, post, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [username, domain] = account.acct.split('@');
const [rules, currentDomain] = useMemo(() => {
const { rules, domain } = getCurrentInstance();
return [rules || [], domain];
});
const [selectedCategory, setSelectedCategory] = useState(null);
const [showRules, setShowRules] = useState(false);
const rulesRef = useRef(null);
const [hasRules, setHasRules] = useState(false);
return (
<div class="report-modal-container">
<div class="top-controls">
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
<button
type="button"
class="plain4 small"
disabled={uiState === 'loading'}
onClick={() => onClose()}
>
<Icon icon="x" size="xl" />
</button>
</div>
<main>
<div class="report-preview">
{post ? (
<Status status={post} size="s" previewMode />
) : (
<AccountBlock
account={account}
avatarSize="xxl"
useAvatarStatic
showStats
showActivity
/>
)}
</div>
{!!selectedCategory &&
!CATEGORIES_INFO[selectedCategory].excludeStamp && (
<span
class="rubber-stamp"
key={selectedCategory}
aria-hidden="true"
>
{CATEGORIES_INFO[selectedCategory].stampLabel ||
CATEGORIES_INFO[selectedCategory].label}
<small>Pending review</small>
</span>
)}
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const entries = Object.fromEntries(formData.entries());
console.log('ENTRIES', entries);
let { category, comment, forward } = entries;
if (!comment) comment = undefined;
if (forward === 'on') forward = true;
const ruleIds =
category === 'violation'
? Object.entries(entries)
.filter(([key]) => key.startsWith('rule_ids'))
.map(([key, value]) => value)
: undefined;
const params = {
category,
comment,
forward,
ruleIds,
};
console.log('PARAMS', params);
setUIState('loading');
(async () => {
try {
await masto.v1.reports.create({
accountId: account.id,
statusIds: post?.id ? [post.id] : undefined,
category,
comment,
ruleIds,
forward,
});
setUIState('success');
showToast(post ? 'Post reported' : 'Profile reported');
onClose();
} catch (error) {
console.error(error);
setUIState('error');
showToast(
error?.message ||
(post
? 'Unable to report post'
: 'Unable to report profile'),
);
}
})();
}}
>
<p>
{post
? `What's the issue with this post?`
: `What's the issue with this profile?`}
</p>
<section class="report-categories">
{CATEGORIES.map((category) =>
category === 'violation' && !rules?.length ? null : (
<Fragment key={category}>
<label class="report-category">
<input
type="radio"
name="category"
value={category}
required
disabled={uiState === 'loading'}
onChange={(e) => {
setSelectedCategory(e.target.value);
setShowRules(e.target.value === 'violation');
}}
/>
<span>
{CATEGORIES_INFO[category].label} &nbsp;
<small class="ib insignificant">
{CATEGORIES_INFO[category].description}
</small>
</span>
</label>
{category === 'violation' && !!rules?.length && (
<div
class="shazam-container no-animation"
hidden={!showRules}
>
<div class="shazam-container-inner">
<div class="report-rules" ref={rulesRef}>
{rules.map((rule, i) => (
<label class="report-rule" key={rule.id}>
<input
type="checkbox"
name={`rule_ids[${i}]`}
value={rule.id}
required={showRules && !hasRules}
disabled={uiState === 'loading'}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
setHasRules(true);
} else {
const checkedInputs =
rulesRef.current.querySelectorAll(
'input:checked',
);
if (!checkedInputs.length) {
setHasRules(false);
}
}
}}
/>
<span>{rule.text}</span>
</label>
))}
</div>
</div>
</div>
)}
</Fragment>
),
)}
</section>
<section class="report-comment">
<p>
<label for="report-comment">Additional info</label>
</p>
<textarea
maxlength="1000"
rows="1"
name="comment"
id="report-comment"
disabled={uiState === 'loading'}
/>
</section>
{!!domain && domain !== currentDomain && (
<section>
<p>
<label>
<input
type="checkbox"
switch
name="forward"
disabled={uiState === 'loading'}
/>{' '}
<span>
Forward to <i>{domain}</i>
</span>
</label>
</p>
</section>
)}
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
showToast(`Muted ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to mute ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Mute profile</small>
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).block();
showToast(`Blocked ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to block ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Block profile</small>
</button>
<Loader hidden={uiState !== 'loading'} />
</footer>
</form>
</main>
</div>
);
}
export default ReportModal;

View file

@ -1,54 +0,0 @@
#search-command-container {
position: fixed;
inset: 0;
z-index: 1002;
background-color: var(--backdrop-darker-color);
background-image: radial-gradient(
farthest-corner at top,
var(--backdrop-color),
transparent
);
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
transition: opacity 0.1s ease-in-out;
}
#search-command-container[hidden] {
opacity: 0;
pointer-events: none;
}
#search-command-container form {
width: calc(40em - 32px);
max-width: 100%;
transition: transform 0.1s ease-in-out;
}
#search-command-container[hidden] form {
transform: translateY(-64px) scale(0.9);
}
#search-command-container input {
width: 100%;
padding: 16px;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid var(--outline-color);
box-shadow: 0 2px 16px var(--drop-shadow-color),
0 32px 64px var(--drop-shadow-color);
}
#search-command-container input:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
}
@media (min-width: 40em) {
#search-command-container {
align-items: center;
background-image: radial-gradient(
closest-side,
var(--backdrop-color),
transparent
);
}
}

View file

@ -1,69 +0,0 @@
import './search-command.css';
import { memo } from 'preact/compat';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import SearchForm from './search-form';
export default memo(function SearchCommand({ onClose = () => {} }) {
const [showSearch, setShowSearch] = useState(false);
const searchFormRef = useRef(null);
useHotkeys(
['Slash', '/'],
(e) => {
setShowSearch(true);
setTimeout(() => {
searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
}, 0);
},
{
preventDefault: true,
ignoreEventWhen: (e) => {
const isSearchPage = /\/search/.test(location.hash);
const hasModal = !!document.querySelector('#modal-container > *');
return isSearchPage || hasModal;
},
},
);
const closeSearch = () => {
setShowSearch(false);
onClose();
};
useHotkeys(
'esc',
(e) => {
searchFormRef.current?.blur?.();
closeSearch();
},
{
enabled: showSearch,
enableOnFormTags: true,
preventDefault: true,
},
);
return (
<div
id="search-command-container"
hidden={!showSearch}
onClick={(e) => {
console.log(e);
if (e.target === e.currentTarget) {
closeSearch();
}
}}
>
<SearchForm
ref={searchFormRef}
onSubmit={() => {
closeSearch();
}}
/>
</div>
);
});

View file

@ -1,292 +0,0 @@
import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { api } from '../utils/api';
import Icon from './icon';
import Link from './link';
const SearchForm = forwardRef((props, ref) => {
const { instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
const [query, setQuery] = useState(searchParams.get('q') || '');
const type = searchParams.get('type');
const formRef = useRef(null);
const searchFieldRef = useRef(null);
useImperativeHandle(ref, () => ({
setValue: (value) => {
setQuery(value);
},
focus: () => {
searchFieldRef.current.focus();
},
select: () => {
searchFieldRef.current.select();
},
blur: () => {
searchFieldRef.current.blur();
},
}));
return (
<form
ref={formRef}
class="search-popover-container"
onSubmit={(e) => {
e.preventDefault();
const isSearchPage = /\/search/.test(location.hash);
if (isSearchPage) {
if (query) {
const params = {
q: query,
};
if (type) params.type = type; // Preserve type
setSearchParams(params);
} else {
setSearchParams({});
}
} else {
if (query) {
location.hash = `/search?q=${encodeURIComponent(query)}${
type ? `&type=${type}` : ''
}`;
} else {
location.hash = `/search`;
}
}
props?.onSubmit?.(e);
}}
>
<input
ref={searchFieldRef}
value={query}
name="q"
type="search"
// autofocus
placeholder="Search"
dir="auto"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellCheck="false"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
}
}}
onInput={(e) => {
setQuery(e.target.value);
setSearchMenuOpen(true);
}}
onFocus={() => {
setSearchMenuOpen(true);
formRef.current
?.querySelector('.search-popover-item')
?.classList.add('focus');
}}
onBlur={() => {
setTimeout(() => {
setSearchMenuOpen(false);
}, 100);
formRef.current
?.querySelector('.search-popover-item.focus')
?.classList.remove('focus');
}}
onKeyDown={(e) => {
const { key } = e;
switch (key) {
case 'Escape':
setSearchMenuOpen(false);
break;
case 'Down':
case 'ArrowDown':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = formRef.current.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let nextItem = focusItem.nextElementSibling;
while (nextItem && nextItem.hidden) {
nextItem = nextItem.nextElementSibling;
}
if (nextItem) {
nextItem.classList.add('focus');
const siblings = Array.from(
nextItem.parentElement.children,
).filter((el) => el !== nextItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const firstItem = formRef.current.querySelector(
'.search-popover-item',
);
if (firstItem) {
firstItem.classList.add('focus');
}
}
}
break;
case 'Up':
case 'ArrowUp':
e.preventDefault();
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
let prevItem = focusItem.previousElementSibling;
while (prevItem && prevItem.hidden) {
prevItem = prevItem.previousElementSibling;
}
if (prevItem) {
prevItem.classList.add('focus');
const siblings = Array.from(
prevItem.parentElement.children,
).filter((el) => el !== prevItem);
siblings.forEach((el) => {
el.classList.remove('focus');
});
}
} else {
const lastItem = document.querySelector(
'.search-popover-item:last-child',
);
if (lastItem) {
lastItem.classList.add('focus');
}
}
}
break;
case 'Enter':
if (searchMenuOpen) {
const focusItem = document.querySelector(
'.search-popover-item.focus',
);
if (focusItem) {
e.preventDefault();
focusItem.click();
}
setSearchMenuOpen(false);
props?.onSubmit?.(e);
}
break;
}
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{/* {!!query && (
<Link
to={`/search?q=${encodeURIComponent(query)}`}
class="search-popover-item focus"
onClick={(e) => {
props?.onSubmit?.(e);
}}
>
<Icon icon="search" />
<span>{query}</span>
</Link>
)} */}
{!!query &&
[
{
label: (
<>
{query}{' '}
<small class="insignificant">
accounts, hashtags &amp; posts
</small>
</>
),
to: `/search?q=${encodeURIComponent(query)}`,
top: !type && !/\s/.test(query),
hidden: !!type,
},
{
label: (
<>
Posts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
top: /\s/.test(query),
icon: 'document',
queryType: 'statuses',
},
{
label: (
<>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
icon: 'hashtag',
queryType: 'hashtags',
},
{
label: (
<>
Look up <mark>{query}</mark>
</>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
top: /^https?:/.test(query),
type: 'link',
},
{
label: (
<>
Accounts with <q>{query}</q>
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
icon: 'group',
queryType: 'accounts',
},
]
.sort((a, b) => {
if (type) {
if (a.queryType === type) return -1;
if (b.queryType === type) return 1;
}
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.filter(({ hidden }) => !hidden)
.map(({ label, to, icon, type }, i) => (
<Link
to={to}
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
// hidden={hidden}
onClick={(e) => {
console.log('onClick', e);
props?.onSubmit?.(e);
}}
>
<Icon
icon={icon || (type === 'link' ? 'arrow-right' : 'search')}
class="more-insignificant"
/>
<span>{label}</span>{' '}
</Link>
))}
</div>
</form>
);
});
export default SearchForm;

View file

@ -18,11 +18,10 @@
counter-increment: index; counter-increment: index;
display: inline-block; display: inline-block;
width: 1.2em; width: 1.2em;
text-align: end; text-align: right;
margin-inline-end: 8px; margin-right: 8px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-size: 90%; font-size: 90%;
flex-shrink: 0;
} }
#shortcuts-settings-container .shortcuts-list li .shortcut-text { #shortcuts-settings-container .shortcuts-list li .shortcut-text {
flex-grow: 1; flex-grow: 1;
@ -36,7 +35,7 @@
#shortcuts-settings-container .shortcuts-view-mode { #shortcuts-settings-container .shortcuts-view-mode {
display: flex; display: flex;
align-items: stretch; align-items: center;
gap: 2px; gap: 2px;
margin: 8px 0 0; margin: 8px 0 0;
} }
@ -52,15 +51,14 @@
gap: 8px; gap: 8px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
} }
#shortcuts-settings-container .shortcuts-view-mode label:first-child { #shortcuts-settings-container .shortcuts-view-mode label:first-child {
border-start-start-radius: 16px; border-top-left-radius: 16px;
border-end-start-radius: 16px; border-bottom-left-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label:last-child { #shortcuts-settings-container .shortcuts-view-mode label:last-child {
border-start-end-radius: 16px; border-top-right-radius: 16px;
border-end-end-radius: 16px; border-bottom-right-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label img { #shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px; max-height: 64px;
@ -86,9 +84,8 @@
transform: scale(0.975); transform: scale(0.975);
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} }
#shortcuts-settings-container .shortcuts-view-mode label.checked { #shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) {
box-shadow: inset 0 0 0 3px var(--link-color), box-shadow: inset 0 0 0 3px var(--link-color);
inset 0 0 32px var(--link-faded-color);
} }
#shortcuts-settings-container #shortcuts-settings-container
.shortcuts-view-mode .shortcuts-view-mode
@ -114,7 +111,7 @@
} }
#shortcut-settings-form label > span:first-child { #shortcut-settings-form label > span:first-child {
flex-basis: 5em; flex-basis: 5em;
text-align: end; text-align: right;
} }
#shortcut-settings-form :is(input[type='text'], select) { #shortcut-settings-form :is(input[type='text'], select) {
flex-grow: 1; flex-grow: 1;
@ -124,81 +121,7 @@
min-width: 0; min-width: 0;
max-width: 320px; max-width: 320px;
} }
#shortcut-settings-form .form-note {
display: flex;
gap: 6px;
align-items: center;
}
#shortcut-settings-form form footer { #shortcut-settings-form form footer {
display: flex; display: flex;
gap: 16px; gap: 16px;
} }
/* Import/Export */
#import-export-container input[type='text'] {
font-family: var(--monospace-font);
}
#import-export-container section {
margin: 8px 0;
background-color: var(--bg-faded-color);
border-radius: 16px;
padding: 8px;
}
#import-export-container section h3 {
margin: 0 0 8px;
}
#import-export-container section h3 * {
vertical-align: middle;
}
#import-export-container section p {
margin: 8px 0;
&.field-button {
display: flex;
gap: 8px;
button {
flex-shrink: 0;
}
}
}
#import-export-container section details > summary {
cursor: pointer;
}
#import-export-container .import-settings-list {
border-radius: 8px;
overflow: hidden;
margin: 8px 0 0;
padding: 0;
counter-reset: index;
}
#import-export-container .import-settings-list li {
background-color: var(--bg-blur-color);
margin: 0 0 2px;
padding: 8px 4px;
display: flex;
gap: 4px;
}
#import-export-container .import-settings-list li::before {
content: counter(index);
counter-increment: index;
display: inline-block;
width: 1.2em;
text-align: end;
margin-inline-end: 8px;
color: var(--text-insignificant-color);
font-size: 90%;
flex-shrink: 0;
}
#import-export-container {
footer {
font-size: 90%;
color: var(--text-insignificant-color);
.icon {
vertical-align: text-bottom;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,8 @@
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom)); bottom: max(16px, env(safe-area-inset-bottom));
inset-inline-start: 16px; left: 16px;
inset-inline-start: max(16px, env(safe-area-inset-left)); left: max(16px, env(safe-area-inset-left));
padding: 16px; padding: 16px;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
z-index: 101; z-index: 101;
@ -34,9 +34,9 @@
@media (min-width: calc(40em + 56px + 8px)) { @media (min-width: calc(40em + 56px + 8px)) {
#shortcuts-button { #shortcuts-button {
inset-inline-end: 16px; right: 16px;
inset-inline-end: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
inset-inline-start: auto; left: auto;
top: 16px; top: 16px;
top: max(16px, env(safe-area-inset-top)); top: max(16px, env(safe-area-inset-top));
bottom: auto; bottom: auto;
@ -59,9 +59,8 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
background-color: var(--bg-color); background-color: var(--bg-blur-color);
/* background-color: var(--bg-blur-color); backdrop-filter: blur(16px) saturate(3);
backdrop-filter: blur(16px) saturate(3); */
border-top: var(--hairline-width) solid var(--outline-color); border-top: var(--hairline-width) solid var(--outline-color);
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color); box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
overflow: auto; overflow: auto;
@ -83,8 +82,6 @@
list-style: none; list-style: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
min-width: 20vw;
flex-basis: 20vw;
} }
#shortcuts .tab-bar li a { #shortcuts .tab-bar li a {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -98,13 +95,7 @@
padding: 8px; padding: 8px;
text-decoration: none; text-decoration: none;
text-shadow: 0 var(--hairline-width) var(--bg-color); text-shadow: 0 var(--hairline-width) var(--bg-color);
width: 100%; width: 20vw;
@media (hover: hover) {
&:is(:hover, :focus) {
color: var(--text-color);
}
}
} }
#shortcuts .tab-bar li a:active { #shortcuts .tab-bar li a:active {
transform: scale(0.95); transform: scale(0.95);
@ -153,7 +144,7 @@ shortcuts .tab-bar[hidden] {
} }
} }
@media (min-width: 40em) and (hover: hover) { @media (min-width: 40em) {
#app[data-shortcuts-view-mode='tab-menu-bar'] .timeline-deck { #app[data-shortcuts-view-mode='tab-menu-bar'] .timeline-deck {
margin-top: 44px; margin-top: 44px;
} }
@ -166,7 +157,6 @@ shortcuts .tab-bar[hidden] {
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left); env(safe-area-inset-left);
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px);
border: 0; border: 0;
box-shadow: none; box-shadow: none;
border-bottom: var(--hairline-width) solid var(--bg-faded-color); border-bottom: var(--hairline-width) solid var(--bg-faded-color);
@ -181,8 +171,6 @@ shortcuts .tab-bar[hidden] {
} }
#shortcuts .tab-bar li { #shortcuts .tab-bar li {
flex-grow: 0; flex-grow: 0;
min-width: auto;
flex-basis: auto;
} }
#shortcuts .tab-bar li a { #shortcuts .tab-bar li a {
padding: 0 16px; padding: 0 16px;

View file

@ -1,83 +1,72 @@
import './shortcuts.css'; import './shortcuts.css';
import { MenuDivider } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { useMemo, useRef } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings'; import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import states from '../utils/states'; import states from '../utils/states';
import AsyncText from './AsyncText'; import AsyncText from './AsyncText';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function Shortcuts() { function Shortcuts() {
const { instance } = api(); const { instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts, settings } = snapStates; const { shortcuts } = snapStates;
if (!shortcuts.length) { if (!shortcuts.length) {
return null; return null;
} }
if (
settings.shortcutsViewMode === 'multi-column' ||
(!settings.shortcutsViewMode && settings.shortcutsColumnsMode)
) {
return null;
}
const menuRef = useRef(); const menuRef = useRef();
const hasLists = useRef(false); const formattedShortcuts = useMemo(
const formattedShortcuts = shortcuts () =>
.map((pin, i) => { shortcuts
const { type, ...data } = pin; .map((pin, i) => {
if (!SHORTCUTS_META[type]) return null; const { type, ...data } = pin;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type]; if (!SHORTCUTS_META[type]) return null;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
if (typeof id === 'function') { if (typeof id === 'function') {
id = id(data, i); id = id(data, i);
} }
if (typeof path === 'function') { if (typeof path === 'function') {
path = path( path = path(
{ {
...data, ...data,
instance: data.instance || instance, instance: data.instance || instance,
}, },
i, i,
); );
} }
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(data, i); title = title(data, i);
} }
if (typeof subtitle === 'function') { if (typeof subtitle === 'function') {
subtitle = subtitle(data, i); subtitle = subtitle(data, i);
} }
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(data, i); icon = icon(data, i);
} }
if (id === 'lists') { return {
hasLists.current = true; id,
} path,
title,
return { subtitle,
id, icon,
path, };
title, })
subtitle, .filter(Boolean),
icon, [shortcuts],
}; );
})
.filter(Boolean);
const navigate = useNavigate(); const navigate = useNavigate();
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
@ -91,23 +80,15 @@ function Shortcuts() {
} }
}); });
const [lists, setLists] = useState([]);
return ( return (
<div id="shortcuts"> <div id="shortcuts">
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( {snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
<nav <nav class="tab-bar">
class="tab-bar"
onContextMenu={(e) => {
e.preventDefault();
states.showShortcutsSettings = true;
}}
>
<ul> <ul>
{formattedShortcuts.map( {formattedShortcuts.map(
({ id, path, title, subtitle, icon }, i) => { ({ id, path, title, subtitle, icon }, i) => {
return ( return (
<li key={`${i}-${id}-${title}-${subtitle}-${path}`}> <li key={i + title}>
<Link <Link
class={subtitle ? 'has-subtitle' : ''} class={subtitle ? 'has-subtitle' : ''}
to={path} to={path}
@ -145,27 +126,19 @@ function Shortcuts() {
</ul> </ul>
</nav> </nav>
) : ( ) : (
<Menu2 <Menu
instanceRef={menuRef} instanceRef={menuRef}
overflow="auto" overflow="auto"
viewScroll="close" viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuClassName="glass-menu shortcuts-menu" menuClassName="glass-menu shortcuts-menu"
gap={8} gap={8}
position="anchor" position="anchor"
onMenuChange={(e) => {
if (e.open && hasLists.current) {
getLists().then(setLists);
}
}}
menuButton={ menuButton={
<button <button
type="button" type="button"
id="shortcuts-button" id="shortcuts-button"
class="plain" class="plain"
onContextMenu={(e) => {
e.preventDefault();
states.showShortcutsSettings = true;
}}
onTransitionStart={(e) => { onTransitionStart={(e) => {
// Close menu if the button disappears // Close menu if the button disappears
try { try {
@ -180,42 +153,9 @@ function Shortcuts() {
</button> </button>
} }
> >
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => { {formattedShortcuts.map(({ path, title, subtitle, icon }, i) => {
if (id === 'lists') {
return (
<SubMenu2
menuClassName="glass-menu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon={icon} size="l" />
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</SubMenu2>
);
}
return ( return (
<MenuLink <MenuLink to={path} key={i + title} class="glass-menu-item">
to={path}
key={`${i}-${id}-${title}-${subtitle}-${path}`}
class="glass-menu-item"
>
<Icon icon={icon} size="l" />{' '} <Icon icon={icon} size="l" />{' '}
<span class="menu-grow"> <span class="menu-grow">
<span> <span>
@ -234,10 +174,10 @@ function Shortcuts() {
</MenuLink> </MenuLink>
); );
})} })}
</Menu2> </Menu>
)} )}
</div> </div>
); );
} }
export default memo(Shortcuts); export default Shortcuts;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
import { SubMenu } from '@szhsin/react-menu';
import { useRef } from 'preact/hooks';
export default function SubMenu2(props) {
const menuRef = useRef();
return (
<SubMenu
{...props}
instanceRef={menuRef}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
/>
);
}

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@
border-bottom: 0; border-bottom: 0;
margin-bottom: -1px; margin-bottom: -1px;
background-image: linear-gradient( background-image: linear-gradient(
to top var(--backward), to top left,
var(--bg-color) 50%, var(--bg-color) 50%,
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );
@ -44,13 +44,12 @@
.status-translation-block .translated-block { .status-translation-block .translated-block {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
line-height: 1.3; line-height: 1.3;
border-radius: 8px; border-radius: 0 8px 8px 8px;
border-start-start-radius: 0;
margin: 0; margin: 0;
padding: 8px; padding: 8px;
background-color: var(--bg-color); background-color: var(--bg-color);
background-image: linear-gradient( background-image: linear-gradient(
to bottom var(--forward), to bottom right,
var(--bg-color), var(--bg-color),
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );
@ -84,7 +83,6 @@
.status-translation-block .translated-block output { .status-translation-block .translated-block output {
display: block; display: block;
margin-top: 0.75em; margin-top: 0.75em;
text-wrap: pretty;
} }
.status-translation-block .status-translation-block
.translated-block .translated-block
@ -107,22 +105,3 @@
overflow: visible; overflow: visible;
mask-image: none; mask-image: none;
} }
/* MINI */
.status-translation-block-mini {
display: flex;
margin: 8px 0 0;
padding: 8px 0 0;
font-size: 90%;
border-top: var(--hairline-width) solid var(--outline-color);
color: var(--text-insignificant-color);
gap: 8px;
transition: color 0.3s ease-in-out;
}
.status-translation-block-mini .icon {
margin-top: 2px;
}
.status:is(:hover, :active) .status-translation-block-mini {
color: var(--text-color);
}

View file

@ -1,83 +1,19 @@
import './translation-block.css'; import './translation-block.css';
import pRetry from 'p-retry';
import pThrottle from 'p-throttle';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import sourceLanguages from '../data/lingva-source-languages'; import sourceLanguages from '../data/lingva-source-languages';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text'; import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem';
import Icon from './icon'; import Icon from './icon';
import LazyShazam from './lazy-shazam';
import Loader from './loader'; import Loader from './loader';
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
const LINGVA_INSTANCES = PHANPY_LINGVA_INSTANCES
? PHANPY_LINGVA_INSTANCES.split(/\s+/)
: [];
const throttle = pThrottle({
limit: 1,
interval: 2000,
});
let currentLingvaInstance = 0;
function _lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target);
const fetchCall = () => {
let instance = LINGVA_INSTANCES[currentLingvaInstance];
return fetch(
`https://${instance}/api/v1/${source}/${target}/${encodeURIComponent(
text,
)}`,
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((res) => {
return {
provider: 'lingva',
content: res.translation,
detectedSourceLanguage: res.info?.detectedSource,
info: res.info,
};
});
};
return pRetry(fetchCall, {
retries: 3,
onFailedAttempt: (e) => {
currentLingvaInstance =
(currentLingvaInstance + 1) % LINGVA_INSTANCES.length;
console.log(
'Retrying translation with another instance',
currentLingvaInstance,
);
},
});
// return masto.v1.statuses.$select(id).translate({
// lang: DEFAULT_LANG,
// });
}
const TRANSLATED_MAX_AGE = 1000 * 60 * 60; // 1 hour
const lingvaTranslate = pmem(_lingvaTranslate, {
maxAge: TRANSLATED_MAX_AGE,
});
const throttledLingvaTranslate = pmem(throttle(lingvaTranslate), {
// I know, this is double-layered memoization
maxAge: TRANSLATED_MAX_AGE,
});
function TranslationBlock({ function TranslationBlock({
forceTranslate, forceTranslate,
sourceLanguage, sourceLanguage,
onTranslate, onTranslate,
text = '', text = '',
mini,
autoDetected,
}) { }) {
const targetLang = getTranslateTargetLanguage(true); const targetLang = getTranslateTargetLanguage(true);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -92,15 +28,35 @@ function TranslationBlock({
const targetLangText = localeCode2Text(targetLang); const targetLangText = localeCode2Text(targetLang);
const apiSourceLang = useRef('auto'); const apiSourceLang = useRef('auto');
if (!onTranslate) { if (!onTranslate)
onTranslate = mini ? throttledLingvaTranslate : lingvaTranslate; onTranslate = (source, target) => {
} console.log('TRANSLATE', source, target, text);
// Using another API instance instead of lingva.ml because of this bug (slashes don't work):
// https://github.com/thedaviddelta/lingva-translate/issues/68
return fetch(
`https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
text,
)}`,
)
.then((res) => res.json())
.then((res) => {
return {
provider: 'lingva',
content: res.translation,
detectedSourceLanguage: res.info?.detectedSource,
info: res.info,
};
});
// return masto.v1.statuses.translate(id, {
// lang: DEFAULT_LANG,
// });
};
const translate = async () => { const translate = async () => {
setUIState('loading'); setUIState('loading');
try { try {
const { content, detectedSourceLanguage, provider, error, ...props } = const { content, detectedSourceLanguage, provider, ...props } =
await onTranslate(text, apiSourceLang.current, targetLang); await onTranslate(apiSourceLang.current, targetLang);
if (content) { if (content) {
if (detectedSourceLanguage) { if (detectedSourceLanguage) {
const detectedLangText = localeCode2Text(detectedSourceLanguage); const detectedLangText = localeCode2Text(detectedSourceLanguage);
@ -114,15 +70,13 @@ function TranslationBlock({
} }
setTranslatedContent(content); setTranslatedContent(content);
setUIState('default'); setUIState('default');
if (!mini && content.trim() !== text.trim()) { detailsRef.current.open = true;
detailsRef.current.open = true; detailsRef.current.scrollIntoView({
detailsRef.current.scrollIntoView({ behavior: 'smooth',
behavior: 'smooth', block: 'nearest',
block: 'nearest', });
});
}
} else { } else {
if (error) console.error(error); console.error(result);
setUIState('error'); setUIState('error');
} }
} catch (e) { } catch (e) {
@ -137,33 +91,6 @@ function TranslationBlock({
} }
}, [forceTranslate]); }, [forceTranslate]);
if (mini) {
if (
!!translatedContent &&
translatedContent.trim() !== text.trim() &&
detectedLang !== targetLangText
) {
return (
<LazyShazam>
<div class="status-translation-block-mini">
<Icon
icon="translate"
alt={`Auto-translated from ${sourceLangText}`}
/>
<output
lang={targetLang}
dir="auto"
title={pronunciationContent || ''}
>
{translatedContent}
</output>
</div>
</LazyShazam>
);
}
return null;
}
return ( return (
<div <div
class="status-translation-block" class="status-translation-block"
@ -188,9 +115,7 @@ function TranslationBlock({
{uiState === 'loading' {uiState === 'loading'
? 'Translating…' ? 'Translating…'
: sourceLanguage && sourceLangText && !detectedLang : sourceLanguage && sourceLangText && !detectedLang
? autoDetected ? `Translate from ${sourceLangText}`
? `Translate from ${sourceLangText} (auto-detected)`
: `Translate from ${sourceLangText}`
: `Translate`} : `Translate`}
</span> </span>
</button> </button>
@ -242,4 +167,4 @@ function TranslationBlock({
); );
} }
export default LINGVA_INSTANCES?.length ? TranslationBlock : () => null; export default TranslationBlock;

View file

@ -1,12 +1,11 @@
import './index.css'; import './index.css';
import './app.css'; import './app.css';
import './polyfills';
import { render } from 'preact'; import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import ComposeSuspense from './components/compose-suspense'; import Compose from './components/compose';
import { initStates } from './utils/states';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
if (window.opener) { if (window.opener) {
@ -28,10 +27,6 @@ function App() {
: 'Compose', : 'Compose',
); );
useEffect(() => {
initStates();
}, []);
useEffect(() => { useEffect(() => {
if (uiState === 'closed') { if (uiState === 'closed') {
try { try {
@ -62,7 +57,7 @@ function App() {
console.debug('OPEN COMPOSE'); console.debug('OPEN COMPOSE');
return ( return (
<ComposeSuspense <Compose
editStatus={editStatus} editStatus={editStatus}
replyToStatus={replyToStatus} replyToStatus={replyToStatus}
draftStatus={draftStatus} draftStatus={draftStatus}

View file

@ -1,8 +1,3 @@
{ {
"@mastodon/edit-media-attributes": ">=4.1", "@mastodon/edit-media-attributes": ">=4.1"
"@mastodon/list-exclusive": ">=4.2",
"@mastodon/filtered-notifications": "~4.3 || >=4.3",
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
"@mastodon/trending-link-posts": "~4.3 || >=4.3",
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
} }

View file

@ -1,384 +1,638 @@
[ [
"daystorm.netz.org",
"mastodon.social", "mastodon.social",
"mstdn.jp", "pawoo.net",
"mstdn.social", "mstdn.social",
"mstdn.jp",
"mas.to", "mas.to",
"mastodon.online",
"mastodon.world", "mastodon.world",
"infosec.exchange", "infosec.exchange",
"fosstodon.org",
"hachyderm.io", "hachyderm.io",
"troet.cafe", "troet.cafe",
"mastodon.uno",
"m.cmx.im", "m.cmx.im",
"fedibird.com",
"techhub.social", "techhub.social",
"piaille.fr",
"mastodon.gamedev.place",
"mastodonapp.uk", "mastodonapp.uk",
"mastodon.nl",
"social.vivaldi.net",
"universeodon.com", "universeodon.com",
"mastodon.uno",
"chaos.social",
"mastodon.gamedev.place",
"piaille.fr",
"mastodon.nl",
"mastodon.art",
"mastodon.cloud",
"mastodon.sdf.org", "mastodon.sdf.org",
"c.im",
"mstdn.ca",
"kolektiva.social", "kolektiva.social",
"mastodon-japan.net", "thu.closed.social",
"norden.social", "mstdn.ca",
"o3o.ca", "masto.ai",
"c.im",
"alive.bar",
"sfba.social", "sfba.social",
"nrw.social", "o3o.ca",
"tech.lgbt", "social.vivaldi.net",
"mastodon.scot", "norden.social",
"social.tchncs.de",
"noagendasocial.com",
"det.social",
"wxw.moe",
"mstdn.party", "mstdn.party",
"occm.cc",
"aus.social", "aus.social",
"mathstodon.xyz", "nrw.social",
"home.social",
"mastodon.scot",
"tech.lgbt",
"newsie.social",
"toot.community", "toot.community",
"ohai.social", "ohai.social",
"sueden.social",
"mastodon.ie",
"mastodon.top", "mastodon.top",
"mastodontech.de", "mastodon.ie",
"mastodon.nu", "mamot.fr",
"masto.es", "sueden.social",
"freemasonry.social",
"ioc.exchange",
"mindly.social", "mindly.social",
"mathstodon.xyz",
"meow.social",
"botsin.space",
"mastodontech.de",
"loforo.com",
"dice.camp",
"ro-mastodon.puyo.jp",
"twit.social",
"planet.moe",
"ioc.exchange",
"mastodon.au",
"mastodon.nu",
"hessen.social", "hessen.social",
"ruhr.social", "ruhr.social",
"nerdculture.de",
"muenchen.social",
"defcon.social",
"social.anoxinon.de",
"mastodon.green", "mastodon.green",
"mastouille.fr",
"social.linux.pizza",
"social.cologne", "social.cologne",
"indieweb.social",
"livellosegreto.it",
"ruby.social",
"ieji.de",
"mastodon.nz", "mastodon.nz",
"toot.io", "muenchen.social",
"tkz.one", "mastouille.fr",
"mastodont.cat", "qoto.org",
"social.tchncs.de", "social.anoxinon.de",
"mastodon.com.tr", "twingyeo.kr",
"mastodon.xyz",
"fediscience.org",
"framapiaf.org",
"akamdon.com",
"indieweb.social",
"social.linux.pizza",
"wandering.shop",
"me.dm",
"sigmoid.social",
"aethy.com",
"eldritch.cafe",
"zirk.us",
"ruby.social",
"mastodon-japan.net",
"mstdn.science",
"defcon.social",
"noc.social", "noc.social",
"sciences.social",
"toot.wales",
"masto.nu",
"phpc.social",
"social.dev-wiki.de",
"cyberplace.social",
"mastodontti.fi",
"climatejustice.social",
"urbanists.social",
"mstdn.plus",
"metalhead.club",
"ravenation.club", "ravenation.club",
"mastodon.ml", "social.librem.one",
"fairy.id", "mstdn.guru",
"feuerwehr.social", "mastodont.cat",
"dresden.network", "g0v.social",
"stranger.social", "ecoevo.social",
"tkz.one",
"livellosegreto.it",
"masto.nu",
"med-mastodon.com",
"toot.wales",
"ieji.de",
"bildung.social",
"octodon.social",
"urbanists.social",
"pouet.chapril.org",
"mastodon.com.tr",
"social.dev-wiki.de",
"toot.io",
"digitalcourage.social",
"econtwitter.net",
"climatejustice.social",
"kinky.business",
"mastodontti.fi",
"mastodon.radio",
"metalhead.club",
"sciences.social",
"mastodon.bida.im",
"phpc.social",
"mastodon.fun",
"berlin.social",
"mstdn.plus",
"mastodon.iriseden.eu", "mastodon.iriseden.eu",
"101010.pl",
"woof.group",
"obo.sh",
"geekdom.social",
"androiddev.social",
"rollenspiel.social", "rollenspiel.social",
"pol.social", "social.lol",
"mstdn.business", "genomic.social",
"mstdn.games",
"wien.rocks",
"h4.io",
"socel.net", "socel.net",
"mastodon.eus", "best-friends.chat",
"wehavecookies.social", "mastodonczech.cz",
"glasgow.social", "wien.rocks",
"mastodon.me.uk", "mastodon.me.uk",
"uri.life", "scholar.social",
"swiss.social",
"dresden.network",
"yiff.life",
"cyberplace.social",
"glasgow.social",
"masto.pt",
"sself.co",
"hostux.social", "hostux.social",
"theblower.au", "theblower.au",
"mastodon-uk.net", "openbiblio.social",
"masto.pt", "mstdn.games",
"awscommunity.social", "todon.eu",
"flipboard.social", "typo.social",
"mast.lat", "floss.social",
"tabletop.social",
"mastodon.ml",
"freiburg.social", "freiburg.social",
"writing.exchange",
"rubber.social",
"mstdn.io",
"paquita.masto.host",
"hci.social",
"snabelen.no", "snabelen.no",
"mastodon.zaclys.com", "astrodon.social",
"muenster.im",
"mastodon-belgium.be", "mastodon-belgium.be",
"geekdom.social", "queer.party",
"journa.host",
"hcommons.social", "hcommons.social",
"mastodonners.nl",
"toot.aquilenet.fr",
"awscommunity.social",
"mastodon.zaclys.com",
"dju.social",
"ursal.zone",
"stranger.social",
"tooot.im", "tooot.im",
"muenster.im",
"social.coop",
"tooting.ch", "tooting.ch",
"rheinneckar.social", "abdl.link",
"discuss.systems",
"sunny.garden",
"mapstodon.space",
"toad.social", "toad.social",
"lor.sh", "rheinneckar.social",
"social.treehouse.systems",
"shakedown.social",
"peoplemaking.games", "peoplemaking.games",
"lor.sh",
"union.place", "union.place",
"bark.lgbt", "witches.live",
"bonn.social", "vis.social",
"equestria.social",
"tilde.zone", "tilde.zone",
"lewdieheaven.com",
"wetdry.world",
"nofan.xyz",
"h4.io",
"photog.social",
"discuss.systems",
"mastoturk.org",
"bonn.social",
"vmst.io", "vmst.io",
"mastodon.berlin", "spore.social",
"emacs.ch", "pol.social",
"blorbo.social", "flipboard.social",
"furry.engineer", "imastodon.net",
"rivals.space", "cybre.space",
"bsd.network",
"mstdn.maud.io",
"girlcock.club",
"pettingzoo.co",
"mast.lat",
"cupoftea.social", "cupoftea.social",
"bark.lgbt",
"moth.social",
"toot.cat",
"furry.engineer",
"qdon.space", "qdon.space",
"graphics.social", "otadon.com",
"veganism.social", "gruene.social",
"ludosphere.fr",
"4bear.com",
"famichiki.jp",
"expressional.social",
"convo.casa",
"historians.social", "historians.social",
"mastorol.es", "mapstodon.space",
"retro.pizza", "douchi.space",
"shelter.moe", "vocalodon.net",
"layer8.space",
"todon.nl",
"types.pl",
"ludosphere.fr",
"merveilles.town",
"iosdev.space",
"feuerwehr.social",
"mast.dragon-fly.club", "mast.dragon-fly.club",
"sakurajima.moe", "kemonodon.club",
"mastodon.arch-linux.cz", "macaw.social",
"squawk.mytransponder.com", "oldbytes.space",
"mastodon.gal", "medibubble.org",
"expressional.social",
"disabled.social", "disabled.social",
"vkl.world", "bolha.us",
"eupolicy.social", "freeradical.zone",
"fandom.ink", "scicomm.xyz",
"toot.funami.tech", "graphics.social",
"mastodonbooks.net", "mona.do",
"lgbtqia.space", "toot.blue",
"witter.cz", "emacs.ch",
"planetearth.social", "lile.cl",
"oslo.town", "social.sciences.re",
"mastodon.com.pl", "ai.wiki",
"pawb.fun", "linuxrocks.online",
"darmstadt.social", "jorts.horse",
"masto.nobigtech.es",
"cr8r.gg",
"pnw.zone",
"hear-me.social",
"furries.club",
"gaygeek.social",
"birdon.social",
"mastodon.energy",
"mastodon-swiss.org",
"dizl.de",
"libretooth.gr",
"mustard.blog",
"machteburch.social",
"fulda.social",
"muri.network",
"babka.social",
"archaeo.social",
"mastodon.uy",
"xarxa.cloud",
"corteximplant.com",
"mastodon.london",
"urusai.social",
"thecanadian.social",
"federated.press",
"kanoa.de",
"opalstack.social",
"bahn.social",
"mograph.social",
"dmv.community",
"social.bau-ha.us",
"mastodon.free-solutions.org",
"masto.nyc",
"tyrol.social",
"burma.social",
"toot.kif.rocks",
"donphan.social",
"mast.hpc.social",
"musicians.today",
"drupal.community",
"hometech.social",
"norcal.social",
"social.politicaconciencia.org",
"social.seattle.wa.us",
"is.nota.live",
"genealysis.social",
"wargamers.social",
"guitar.rodeo",
"bookstodon.com",
"mstdn.dk",
"elizur.me",
"irsoluciones.social",
"h-net.social",
"mastoot.fr",
"qaf.men",
"est.social",
"kurry.social",
"mastodon.pnpde.social",
"ani.work",
"nederland.online",
"epicure.social",
"occitania.social",
"lgbt.io",
"mountains.social",
"persiansmastodon.com", "persiansmastodon.com",
"seocommunity.social", "mastodon.berlin",
"cyberfurz.social", "liker.social",
"fedi.at", "literatur.social",
"gamepad.club", "masto.bike",
"augsburg.social", "retro.pizza",
"mastodon.education", "climatejustice.rocks",
"toot.re", "neurodifferent.me",
"linux.social", "post.lurk.org",
"neovibe.app", "mastodon.coffee",
"mastodon.gal",
"oslo.town",
"neuromatch.social",
"ika.queloud.net",
"mstdn.beer",
"graz.social",
"libretooth.gr",
"mastodonbooks.net",
"xoxo.zone",
"mastodon.design",
"convo.casa",
"bitbang.social",
"freeatlantis.com",
"masto.nobigtech.es",
"eupolicy.social",
"sociale.network",
"famichiki.jp",
"pkm.social",
"4bear.com",
"freak.university",
"opalstack.social",
"chitter.xyz",
"sciencemastodon.com",
"lgbtqia.space",
"ffxiv-mastodon.com",
"mental.social",
"iztasocial.site",
"artisan.chat",
"vulpine.club",
"musician.social", "musician.social",
"esq.social", "sunny.garden",
"social.veraciousnetwork.com", "dizl.de",
"datasci.social", "glammr.us",
"tooters.org", "mastodo.fi",
"ciberlandia.pt", "kirche.social",
"cloud-native.social", "mastodon.energy",
"social.silicon.moe", "kind.social",
"cosocial.ca", "shelter.moe",
"arvr.social",
"hispagatos.space",
"friendsofdesoto.social",
"musicworld.social",
"aut.social",
"masto.yttrx.com",
"mastodon.wien",
"colorid.es",
"arsenalfc.social",
"allthingstech.social",
"mastodon.vlaanderen",
"mastodon.com.py",
"tooter.social",
"lounge.town",
"puntarella.party",
"earthstream.social",
"apobangpo.space",
"opencoaster.net",
"frikiverse.zone",
"airwaves.social",
"toot.garden",
"lewacki.space",
"gardenstate.social",
"theatl.social",
"maly.io",
"library.love",
"kfem.cat",
"ruhrpott.social",
"techtoots.com",
"furry.energy",
"mastodon.pirateparty.be",
"metalverse.social",
"indieauthors.social",
"tuiter.rocks",
"mastodon.africa",
"jvm.social",
"poweredbygay.social",
"fikaverse.club",
"gametoots.de",
"mastodon.cr",
"hoosier.social",
"khiar.net",
"seo.chat",
"drumstodon.net",
"raphus.social",
"toots.nu",
"k8s.social",
"mastodon.holeyfox.co",
"fribygda.no",
"x0r.be",
"fpl.social",
"toot.pizza",
"mastodon.cipherbliss.com",
"burningboard.net",
"synapse.cafe",
"cultur.social",
"vermont.masto.host",
"mastodon.bot",
"bologna.one",
"mastodon.sg",
"tchafia.be",
"rail.chat",
"mastodon.hosnet.fr",
"leipzig.town",
"wayne.social",
"rheinhessen.social",
"rap.social",
"cwb.social",
"mastodon.bachgau.social",
"cville.online",
"bzh.social",
"mastodon.escepticos.es",
"zenzone.social",
"mastodon.ee",
"lsbt.me",
"neurodiversity-in.au",
"fairmove.net",
"stereodon.social",
"mcr.wtf",
"mastodon.frl",
"mikumikudance.cloud",
"okla.social",
"camp.smolnet.org",
"ailbhean.co-shaoghal.net",
"clj.social",
"tu.social",
"nomanssky.social",
"mastodon.iow.social",
"frontrange.co",
"episcodon.net",
"devianze.city",
"paktodon.asia",
"travelpandas.fr",
"silversword.online",
"nwb.social",
"skastodon.com",
"kcmo.social",
"balkan.fedive.rs",
"openedtech.social",
"mastodon.ph",
"enshittification.social",
"spojnik.works",
"mastodon.conquestuniverse.com",
"nutmeg.social",
"social.sndevs.com",
"social.diva.exchange",
"growers.social",
"pdx.sh",
"nfld.me",
"cartersville.social",
"voi.social",
"mastodon.babb.no",
"kzoo.to",
"mastodon.vanlife.is",
"toot.works",
"sanjuans.life",
"dariox.club",
"xreality.social",
"social.ferrocarril.net",
"pool.social",
"polsci.social",
"mastodon.mg",
"23.illuminati.org",
"apotheke.social",
"jaxbeach.social",
"ceilidh.online",
"netsphere.one",
"biplus.social",
"bvb.social",
"ms.maritime.social",
"darticulate.com",
"persia.social",
"streamerchat.social",
"troet.fediverse.at",
"publishing.social",
"finsup.social",
"kjas.no",
"wxw.moe",
"learningdisability.social",
"mastodon.bida.im",
"computerfairi.es", "computerfairi.es",
"tea.codes" "mastodon.la",
"mastodon.org.uk",
"awoo.space",
"fulda.social",
"witter.cz",
"freemasonry.social",
"jawns.club",
"mao.mastodonhub.com",
"trpg.cloud",
"ramen-fsm.eu.org",
"toot.cafe",
"darmstadt.social",
"mstdn.mx",
"pokemon.mastportal.info",
"toot.lv",
"romancelandia.club",
"better.boston",
"pnw.zone",
"mastodon.content.town",
"rivals.space",
"thecanadian.social",
"cr8r.gg",
"plural.cafe",
"xarxa.cloud",
"esperanto.masto.host",
"federated.press",
"nnia.space",
"digipres.club",
"h5q.net",
"kinkyelephant.com",
"pawb.fun",
"data-folks.masto.host",
"mastodon.uy",
"worldkey.io",
"mastorol.es",
"zeroes.ca",
"mastodon.arch-linux.cz",
"mastodon.acm.org",
"social.bau-ha.us",
"bbq.snoot.com",
"akademienl.social",
"toot.bike",
"vtdon.com",
"uri.life",
"machteburch.social",
"mas.town",
"vkl.world",
"vt.social",
"mastodon.cat",
"podcastindex.social",
"artsio.com",
"dotnet.social",
"oc.todon.fr",
"functional.cafe",
"halifaxsocial.ca",
"babka.social",
"ichiji.social",
"ura-mstdn.com",
"eightpoint.app",
"liberdon.com",
"toot.portes-imaginaire.org",
"mograph.social",
"kirakiratter.com",
"mstdn.tokyocameraclub.com",
"gearheads.social",
"est.social",
"mastodon.mim-libre.fr",
"swiss-talk.net",
"donphan.social",
"masto.nyc",
"blorbo.social",
"qubit-social.xyz",
"en.osm.town",
"gulp.cafe",
"assemblag.es",
"mstdn.kemono-friends.info",
"tyrol.social",
"social.seattle.wa.us",
"toot.kif.rocks",
"twiukraine.com",
"social.politicaconciencia.org",
"icosahedron.website",
"toot.si",
"mastodon.in.th",
"norcal.social",
"warhammer.social",
"bookwor.ms",
"kanoa.de",
"veganism.social",
"cryptodon.lol",
"jasette.facil.services",
"is.nota.live",
"epicure.social",
"sauropods.win",
"kurry.social",
"hometech.social",
"kopiti.am",
"biplus.date",
"spacey.space",
"photodn.net",
"blabber.lu-rp.net",
"im-in.space",
"wargamers.social",
"toot.berlin",
"archaeo.social",
"col.social",
"h-net.social",
"social.kyiv.dcomm.net.ua",
"dobbs.town",
"mastodon.com.br",
"toot.funami.tech",
"nafo.uk",
"arsenalfc.social",
"social.edu.nl",
"sunbeam.city",
"federate.social",
"hello.2heng.xin",
"gensokyo.town",
"mastodon.tetaneutral.net",
"tablegame.mstdn.cloud",
"elekk.xyz",
"blacktwitter.io",
"burma.social",
"osna.social",
"seocommunity.social",
"otogamer.me",
"mstdn.fr",
"toki.social",
"colearn.social",
"cloud-native.social",
"mstdn-bike.net",
"mastodon.hypnoguys.com",
"lounge.town",
"guitar.rodeo",
"mastodon.mit.edu",
"hispagatos.space",
"mstdn.id",
"flower.afn.social",
"parfait.day",
"nederland.online",
"ani.work",
"mastodon.education",
"mastodon.gougere.fr",
"cztwitter.cz",
"uwu.social",
"mastodon.bayern",
"gameliberty.club",
"sukebe.hostdon.ne.jp",
"social.veraciousnetwork.com",
"mastodon.vlaanderen",
"earthstream.social",
"xn--lofll-1sat.is",
"social.datalabour.com",
"gametoots.de",
"mastodon.com.py",
"outdoors.lgbt",
"arvr.social",
"loðfíll.is",
"social.yesterweb.org",
"9kb.me",
"mstdn.dk",
"occitania.social",
"apobangpo.space",
"dingdash.com",
"mastodon.chasem.dev",
"oulipo.social",
"digforfire.org",
"mastodon.partipirate.org",
"mastodon.hk",
"mastoot.fr",
"eigadon.net",
"irsoluciones.social",
"maly.io",
"birds.town",
"kfem.cat",
"beekeeping.ninja",
"mastodon.juggler.jp",
"oransns.com",
"anticapitalist.party",
"deadinsi.de",
"gardenstate.social",
"mastodon.cc",
"piano.masto.host",
"eletusk.club",
"lewacki.space",
"mastodon.pirateparty.be",
"anarchism.space",
"mastodon.cisti.org",
"metalverse.social",
"truthsocial.co.in",
"baraag.net",
"yakyudon.net",
"lou.lt",
"social.slat.org",
"gensokyo.social",
"social.chinwag.org",
"tribe.net",
"lgbt.io",
"toots.social",
"pravda.me",
"aleph.land",
"poweredbygay.social",
"masto.yttrx.com",
"yttrx.com",
"toot.pizza",
"drumstodon.net",
"acg.mn",
"kpop.social",
"toolboxtalk.tech",
"bear.community",
"otoya.space",
"mastodon.triggerphra.se",
"mastodon.free-solutions.org",
"rcsocial.net",
"kith.kitchen",
"vocalounge.cafe",
"pieville.net",
"mstdn.osaka",
"mastodon.mnetwork.co.kr",
"mstdn.es",
"seo.chat",
"mastodol.jp",
"renkontu.com",
"mastodon.cipherbliss.com",
"toot.turbo.chat",
"catdon.life",
"social.coletivos.org",
"toot.thoughtworks.com",
"mastodon-swiss.org",
"social.targaryen.house",
"moe.cat",
"bologna.one",
"toot.site",
"e.fo",
"mastodon.holeyfox.co",
"m.rthome.me",
"stereodon.social",
"social.opendesktop.org",
"bgme.me",
"social.caa-ins.org",
"nojack.easydns.ca",
"mastodon.oeru.org",
"mastodon.elte.hu",
"nasface.cz",
"lilymagic.com",
"mast.moe",
"mastodon.librelabucm.org",
"fetswing.org",
"mastodon.cosmicanimal.jp",
"todon.ploud.fr",
"ephemeral.glitch.social",
"mikumikudance.cloud",
"summoners-riftodon.jp",
"kinbaku.club",
"www.mstddntfdn.online",
"dev.brighteon.social",
"jaxbeach.social",
"animalliberation.social",
"onmasto.com",
"pet123.club",
"ostatus.ikeji.ma",
"counter.social",
"the.resize.club",
"social.outsourcedmath.com",
"nerdculture.de",
"pewtix.com",
"med-mammoth.com",
"ping-pong-sandbox.herokuapp.com",
"id.cc",
"freespeechextremist.com",
"cawfee.club",
"1234.as",
"fedi.absturztau.be",
"fsmi.social",
"go5.dev",
"poa.st",
"patriot.online",
"stereophonic.space",
"kazv.moe",
"seaofog.com",
"libranet.de",
"tea.codes",
"pixelfed.social",
"shitposter.club",
"squeet.me",
"shared.graphics",
"glindr.org",
"devs.live",
"pxlmo.com",
"pixel.tchncs.de",
"pythondevs.social",
"pleroma.pibvt.net",
"books.theunseen.city",
"love.alicecomplex.com",
"mastodon.london",
"greenish.red",
"pixelfed.sdf.org",
"anar.chi.st",
"friendica.eskimo.com",
"meatbag.app",
"dudu.best",
"pix.diaspodon.fr",
"shpposter.club",
"pix.toot.wales",
"pleroma.noellabo.jp",
"fgc.network",
"bookrastinating.com",
"pixey.org",
"fe.disroot.org",
"pixelfed.tokyo",
"mastodon.wien",
"448c.net",
"freeframe.masto.host",
"pixelfed.photos",
"varishangout.net",
"pixelfed.fr",
"friendica.vrije-mens.org",
"mastodon.tech",
"bae.st",
"brighteon.social",
"pixelfed.nz",
"hayu.sh",
"pixelfed.uno",
"pixelfed.au",
"helladoge.com",
"miniwa.moe",
"genserver.social",
"bookwyrm.social",
"spinster.xyz",
"pixelfed.de",
"metapixl.com",
"neenster.org",
"venera.social",
"outerheaven.club",
"gleasonator.com",
"pixelfed.fi",
"blob.cat",
"pxl.roflcopter.fr",
"gc2.jp",
"kids.0px.io"
] ]

View file

@ -909,36 +909,11 @@
"Zulu", "Zulu",
"isiZulu" "isiZulu"
], ],
[
"zh-CN",
"Chinese (China)",
"简体中文"
],
[
"zh-HK",
"Chinese (Hong Kong)",
"繁體中文(香港)"
],
[
"zh-TW",
"Chinese (Taiwan)",
"繁體中文(臺灣)"
],
[
"zh-YUE",
"Cantonese",
"廣東話"
],
[ [
"ast", "ast",
"Asturian", "Asturian",
"Asturianu" "Asturianu"
], ],
[
"chr",
"Cherokee",
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ"
],
[ [
"ckb", "ckb",
"Sorani (Kurdish)", "Sorani (Kurdish)",
@ -959,6 +934,11 @@
"Kabyle", "Kabyle",
"Taqbaylit" "Taqbaylit"
], ],
[
"kmr",
"Kurmanji (Kurdish)",
"Kurmancî"
],
[ [
"ldn", "ldn",
"Láadan", "Láadan",
@ -994,11 +974,6 @@
"Toki Pona", "Toki Pona",
"toki pona" "toki pona"
], ],
[
"xal",
"Kalmyk",
"Хальмг келн"
],
[ [
"zba", "zba",
"Balaibalan", "Balaibalan",

View file

@ -8,40 +8,15 @@
--sai-left: env(safe-area-inset-left); --sai-left: env(safe-area-inset-left);
--text-size: 16px; --text-size: 16px;
--main-width: max(60dvw, 40em); --main-width: 40em;
text-size-adjust: none; text-size-adjust: none;
--hairline-width: 1px; --hairline-width: 1px;
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
Menlo, Courier, monospace;
--blue-color: royalblue; --blue-color: royalblue;
--purple-color: blueviolet; --purple-color: blueviolet;
--purple-fg-color: color-mix(
in srgb-linear,
var(--purple-color) 60%,
var(--text-color) 40%
);
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
--green-color: darkgreen; --green-color: darkgreen;
--orange-color: darkorange; --orange-color: darkorange;
--orange-light-bg-color: color-mix(
in srgb,
var(--orange-color) 20%,
transparent
);
--orange-fg-color: color-mix(
in srgb-linear,
var(--orange-color) 60%,
var(--text-color) 40%
);
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
--red-color: orangered; --red-color: orangered;
--red-text-color: color-mix(
in srgb-linear,
var(--red-color) 60%,
var(--text-color) 40%
);
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
--bg-color: #fff; --bg-color: #fff;
--bg-faded-color: #f0f2f5; --bg-faded-color: #f0f2f5;
--bg-blur-color: #fff9; --bg-blur-color: #fff9;
@ -49,16 +24,10 @@
--text-color: #1c1e21; --text-color: #1c1e21;
--text-insignificant-color: #1c1e2199; --text-insignificant-color: #1c1e2199;
--link-color: var(--blue-color); --link-color: var(--blue-color);
--link-bg-color: #4169e122;
--link-light-color: #4169e199; --link-light-color: #4169e199;
--link-faded-color: #4169e155; --link-faded-color: #4169e155;
--link-bg-hover-color: #f0f2f599; --link-bg-hover-color: #f0f2f599;
--link-visited-color: mediumslateblue; --link-visited-color: mediumslateblue;
--link-text-color: color-mix(
in lch,
var(--link-color) 60%,
var(--text-color) 40%
);
--focus-ring-color: var(--link-color); --focus-ring-color: var(--link-color);
--button-bg-color: var(--blue-color); --button-bg-color: var(--blue-color);
--button-bg-blur-color: #4169e1aa; --button-bg-blur-color: #4169e1aa;
@ -66,29 +35,15 @@
--button-plain-bg-hover-color: rgba(128, 128, 128, 0.1); --button-plain-bg-hover-color: rgba(128, 128, 128, 0.1);
--reblog-color: var(--purple-color); --reblog-color: var(--purple-color);
--reblog-faded-color: #892be220; --reblog-faded-color: #892be220;
--group-color: var(--green-color);
--group-faded-color: #00640020;
--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: #ffa60020; --reply-to-faded-color: #ffa60030;
--hashtag-color: LightSeaGreen;
--hashtag-faded-color: color-mix(
in srgb,
var(--hashtag-color) 15%,
transparent
);
--hashtag-text-color: color-mix(
in lch,
var(--hashtag-color) 40%,
var(--text-color) 60%
);
--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(0, 0, 0, 0.1); --backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-darker-color: rgba(0, 0, 0, 0.25); --backdrop-solid-color: #ccc;
--backdrop-solid-color: #eee;
--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;
@ -97,30 +52,8 @@
--close-button-bg-active-color: rgba(0, 0, 0, 0.2); --close-button-bg-active-color: rgba(0, 0, 0, 0.2);
--close-button-color: rgba(0, 0, 0, 0.5); --close-button-color: rgba(0, 0, 0, 0.5);
--close-button-hover-color: rgba(0, 0, 0, 1); --close-button-hover-color: rgba(0, 0, 0, 1);
--private-note-text-color: var(--text-color);
--private-note-bg-color: color-mix(in srgb, yellow 20%, var(--bg-color));
--private-note-border-color: rgba(0, 0, 0, 0.2);
/* Media colors won't change based on color scheme */
--media-fg-color: #f0f2f5;
--media-bg-color: #242526;
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--min-dimension: 88px;
--forward: right;
--backward: left;
--to-forward: to right;
--to-backward: to left;
&:dir(rtl) {
--forward: left;
--backward: right;
--to-forward: to left;
--to-backward: to right;
}
} }
@media (min-resolution: 2dppx) { @media (min-resolution: 2dppx) {
@ -144,18 +77,13 @@
--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;
--link-visited-color: color-mix(
in lch,
mediumslateblue 70%,
var(--text-color) 30%
);
--reblog-faded-color: #b190f141; --reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color); --reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60017; --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);
--backdrop-solid-color: #111; --backdrop-solid-color: #333;
--loader-color: #f0f2f599; --loader-color: #f0f2f599;
--comment-line-color: #565656; --comment-line-color: #565656;
--drop-shadow-color: rgba(0, 0, 0, 0.5); --drop-shadow-color: rgba(0, 0, 0, 0.5);
@ -163,7 +91,6 @@
--close-button-bg-active-color: rgba(255, 255, 255, 0.15); --close-button-bg-active-color: rgba(255, 255, 255, 0.15);
--close-button-color: rgba(255, 255, 255, 0.5); --close-button-color: rgba(255, 255, 255, 0.5);
--close-button-hover-color: rgba(255, 255, 255, 1); --close-button-hover-color: rgba(255, 255, 255, 1);
--private-note-border-color: rgba(255, 255, 255, 0.2);
} }
} }
@ -182,21 +109,12 @@ html {
} }
body { body {
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, font-family: ui-rounded, system-ui;
Ubuntu, Cantarell, Noto Sans, sans-serif;
font-size: var(--text-size); font-size: var(--text-size);
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/* Prevent pull-to-refresh on Chrome PWA */
@media (display-mode: standalone) {
html,
body {
overscroll-behavior-y: none;
}
}
a { a {
color: var(--link-color); color: var(--link-color);
text-decoration-color: var(--link-faded-color); text-decoration-color: var(--link-faded-color);
@ -248,16 +166,13 @@ button,
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
} }
button[hidden] {
display: none;
}
:is(button, .button) > * { :is(button, .button) > * {
vertical-align: middle; vertical-align: middle;
pointer-events: none; pointer-events: none;
} }
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) { :is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
cursor: pointer; cursor: pointer;
filter: brightness(1.05); filter: brightness(1.2);
} }
:is(button, .button):not(:disabled, .disabled):active { :is(button, .button):not(:disabled, .disabled):active {
filter: brightness(0.8); filter: brightness(0.8);
@ -288,23 +203,6 @@ button[hidden] {
:is(button, .button).plain4:not(:disabled, .disabled):is(:hover, :focus) { :is(button, .button).plain4:not(:disabled, .disabled):is(:hover, :focus) {
color: var(--text-color); color: var(--text-color);
} }
:is(button, .button).plain5 {
background-color: transparent;
color: var(--link-color);
text-decoration: underline;
text-decoration-color: var(--link-faded-color);
}
:is(button, .button).plain5:not(:disabled, .disabled):is(:hover, :focus) {
text-decoration: underline;
}
:is(button, .button).plain6 {
background-color: var(--bg-blur-color);
color: var(--link-color);
border: 1px solid var(--link-color);
}
:is(button, .button).plain6:not(:disabled, .disabled):is(:hover, :focus) {
background-color: var(--link-bg-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);
@ -358,7 +256,6 @@ button[hidden] {
} }
input[type='text'], input[type='text'],
input[type='search'],
textarea, textarea,
select { select {
color: var(--text-color); color: var(--text-color);
@ -368,7 +265,6 @@ select {
border-radius: 4px; border-radius: 4px;
} }
input[type='text']:focus, input[type='text']:focus,
input[type='search']:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--outline-color); border-color: var(--outline-color);
@ -380,26 +276,12 @@ button.large {
font-size: 125%; font-size: 125%;
padding: 12px; padding: 12px;
} }
textarea:disabled {
background-color: var(--bg-faded-color);
}
:is(input[type='text'], input[type='search'], textarea, select).block { button.small {
display: block;
width: 100%;
}
:is(button, .button).small {
font-size: 90%; font-size: 90%;
padding: 4px 8px; padding: 4px 8px;
} }
.button.centered {
display: inline-flex;
justify-content: center;
align-items: center;
}
select.plain { select.plain {
border: 0; border: 0;
background-color: transparent; background-color: transparent;
@ -409,10 +291,10 @@ pre {
tab-size: 2; tab-size: 2;
} }
pre code, pre code,
code, code {
kbd {
font-size: 90%; font-size: 90%;
font-family: var(--monospace-font); font-family: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
Menlo, Courier, monospace;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -453,11 +335,6 @@ kbd {
display: initial; display: initial;
} }
.bidi-isolate {
direction: initial;
unicode-bidi: isolate;
}
/* KEYFRAMES */ /* KEYFRAMES */
@keyframes appear { @keyframes appear {
@ -500,17 +377,6 @@ kbd {
} }
} }
@keyframes slide-up-smooth {
0% {
opacity: 0;
transform: translateY(100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes position-object { @keyframes position-object {
0% { 0% {
object-position: 50% 50%; object-position: 50% 50%;
@ -548,30 +414,3 @@ kbd {
.shazam-container-inner { .shazam-container-inner {
overflow: hidden; overflow: hidden;
} }
@keyframes shazam-horizontal {
0% {
grid-template-columns: 0fr;
}
100% {
grid-template-columns: 1fr;
}
}
.shazam-container-horizontal {
display: grid;
grid-template-columns: 1fr;
transition: grid-template-columns 0.5s ease-in-out;
white-space: nowrap;
}
.shazam-container-horizontal:not(.no-animation) {
animation: shazam-horizontal 0.5s ease-in-out both !important;
}
.shazam-container-horizontal[hidden] {
grid-template-columns: 0fr;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -1,10 +1,7 @@
import './index.css'; import './index.css';
import './cloak-mode.css';
import './polyfills';
// Polyfill needed for Firefox < 122 import './cloak-mode.css';
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
// import '@formatjs/intl-segmenter/polyfill';
import { render } from 'preact'; import { render } from 'preact';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';

Some files were not shown because too many files have changed in this diff Show more