Compare commits
68 commits
dtth-fork
...
production
Author | SHA1 | Date | |
---|---|---|---|
375da8d173 | |||
2c31e8e04c | |||
534c4c97cd | |||
482a64cfac | |||
2dc1343f54 | |||
5e52fa87e0 | |||
6b03ae1fee | |||
c763d8b954 | |||
0a5d7267d5 | |||
5ee926481a | |||
0cd9a2db6e | |||
69f9b750c2 | |||
f5955ef258 | |||
27a999f733 | |||
54271101c1 | |||
d0cbb0812d | |||
ad45bf9d19 | |||
982f7b3ec4 | |||
4e50f227d8 | |||
546e77d3e1 | |||
e29f14bbcf | |||
05e87e084a | |||
01f10d3daa | |||
fc615e0c0d | |||
25e9771754 | |||
5e916559b3 | |||
883fe39b6c | |||
9933d83846 | |||
7d806301f2 | |||
faf9cbf23d | |||
a0f79e7eea | |||
0b1974e94b | |||
b4a4615b9a | |||
dda14587c0 | |||
ed9289d8c6 | |||
6274f2f24f | |||
b4e8ba820c | |||
29896dfe0e | |||
69c3f1a082 | |||
451dc57a69 | |||
4fbee9168d | |||
6ecc015199 | |||
a7a3d5605b | |||
ad4ed66cd6 | |||
4277992773 | |||
6bcf6b143c | |||
9e9f7a6ea1 | |||
f0014cb26a | |||
b0e118fcab | |||
f51201a787 | |||
5a035089ab | |||
206f00af40 | |||
13de3d9263 | |||
eb41ddf2de | |||
940e8f5376 | |||
77ba42dba9 | |||
95e204c439 | |||
82770e8035 | |||
818c8e61cd | |||
3b8592e946 | |||
c0c7d65034 | |||
5631126e8d | |||
bd2ed53f32 | |||
694fa22942 | |||
15c3979815 | |||
ab5f53273f | |||
19c2f9b048 | |||
a45250ac96 |
7
.env
|
@ -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"
|
|
2
.gitattributes
vendored
|
@ -1,2 +0,0 @@
|
||||||
*.po linguist-generated
|
|
||||||
readme-assets/** linguist-documentation
|
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -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:
|
||||||
|
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -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.
|
|
4
.github/release.yml
vendored
|
@ -1,4 +0,0 @@
|
||||||
changelog:
|
|
||||||
exclude:
|
|
||||||
labels:
|
|
||||||
- 'i18n'
|
|
71
.github/workflows/i18n-automerge.yml
vendored
|
@ -1,71 +0,0 @@
|
||||||
name: i18n PR auto-merge
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened, labeled]
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-and-merge:
|
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'i18n') &&
|
|
||||||
github.event.pull_request.base.ref == 'main' &&
|
|
||||||
github.event.pull_request.head.ref == 'l10n_main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- run: sleep 15
|
|
||||||
|
|
||||||
- name: Check if the branch is dirty
|
|
||||||
run: |
|
|
||||||
git fetch origin ${{ github.event.pull_request.head.ref }}
|
|
||||||
if [ $(git rev-parse HEAD) != $(git rev-parse origin/${{ github.event.pull_request.head.ref }}) ]; then
|
|
||||||
echo "Branch is dirty. Exiting..."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check auto-merge conditions
|
|
||||||
run: |
|
|
||||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
|
||||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
|
||||||
|
|
||||||
# Debug: Show the base and head SHA
|
|
||||||
echo "Base SHA: $BASE_SHA"
|
|
||||||
echo "Head SHA: $HEAD_SHA"
|
|
||||||
|
|
||||||
# Check if the commits exist
|
|
||||||
if ! git cat-file -e $BASE_SHA || ! git cat-file -e $HEAD_SHA; then
|
|
||||||
echo "ERROR: One or both of the commits are not available."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Calculate the total number of lines changed (added, removed, or modified)
|
|
||||||
LINES_CHANGED=$(git diff --shortstat $BASE_SHA $HEAD_SHA | awk '{print $4 + $6 + $8}')
|
|
||||||
|
|
||||||
if [ -z "$LINES_CHANGED" ]; then
|
|
||||||
LINES_CHANGED=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Total lines changed: $LINES_CHANGED"
|
|
||||||
|
|
||||||
# Check if the number of lines changed is more than 50
|
|
||||||
if [ "$LINES_CHANGED" -le 50 ]; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "More than 50 lines have been changed. Merging pull request."
|
|
||||||
|
|
||||||
# List of locales changed
|
|
||||||
LOCALES_CHANGED=$(git diff --name-only $BASE_SHA $HEAD_SHA | grep '\.po$' | awk -F '/' '{print $NF}' | sed 's/\.po$//' | tr '\n' ',' | sed 's/,$//')
|
|
||||||
|
|
||||||
# Better subject
|
|
||||||
# "i18n updates ([LOCALES_CHANGED])"
|
|
||||||
PR_NUMBER=$(echo ${{ github.event.pull_request.number }})
|
|
||||||
SUBJECT="i18n updates ($LOCALES_CHANGED) (#$PR_NUMBER)"
|
|
||||||
|
|
||||||
gh pr merge $PR_NUMBER --squash --subject "$SUBJECT" || true
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
34
.github/workflows/i18n-update-readme.yml
vendored
|
@ -1,34 +0,0 @@
|
||||||
name: Update README with list of i18n volunteers
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Every week
|
|
||||||
- cron: '0 0 * * 0'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-readme:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- run: npm ci
|
|
||||||
- run: |
|
|
||||||
npm run fetch-i18n-volunteers
|
|
||||||
npm run readme:i18n-volunteers
|
|
||||||
|
|
||||||
# Commit & push if there are changes
|
|
||||||
if git diff --quiet README.md; then
|
|
||||||
echo "No changes to README.md"
|
|
||||||
else
|
|
||||||
echo "Changes to README.md"
|
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git config --global user.name "github-actions[bot]"
|
|
||||||
git add README.md
|
|
||||||
git commit -m "Update README.md"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
CROWDIN_ACCESS_TOKEN: ${{ secrets.CROWDIN_ACCESS_TOKEN }}
|
|
1
.github/workflows/main2prod.yml
vendored
|
@ -7,7 +7,6 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
auto-pull-request:
|
auto-pull-request:
|
||||||
if: github.repository == 'cheeaun/phanpy'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: vsoch/pull-request-action@master
|
- uses: vsoch/pull-request-action@master
|
||||||
|
|
19
.github/workflows/prettier-pr.yml
vendored
|
@ -1,19 +0,0 @@
|
||||||
name: Prettier on pull requests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prettier:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# Need node to install prettier plugin(s)
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- run: npm ci
|
|
||||||
- run: |
|
|
||||||
echo "Prettier-ing files"
|
|
||||||
npx prettier "src/**/*.{js,jsx}" --check
|
|
32
.github/workflows/prodtag.yml
vendored
|
@ -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
|
|
32
.github/workflows/update-catalogs.yml
vendored
|
@ -1,32 +0,0 @@
|
||||||
name: Update Catalogs
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- l10n_main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-catalogs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: l10n_main
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- run: npm ci
|
|
||||||
- name: Update catalogs.json
|
|
||||||
run: |
|
|
||||||
node scripts/catalogs.js
|
|
||||||
if git diff --quiet src/data/catalogs.json; then
|
|
||||||
echo "No changes to catalogs.json"
|
|
||||||
else
|
|
||||||
echo "Changes to catalogs.json"
|
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git config --global user.name "github-actions[bot]"
|
|
||||||
git add src/data/catalogs.json
|
|
||||||
git commit -m "Update catalogs.json"
|
|
||||||
git push origin HEAD:l10n_main || true
|
|
||||||
fi
|
|
9
.gitignore
vendored
|
@ -25,12 +25,3 @@ dist-ssr
|
||||||
|
|
||||||
# Custom
|
# Custom
|
||||||
.env.dev
|
.env.dev
|
||||||
phanpy-dist.zip
|
|
||||||
phanpy-dist.tar.gz
|
|
||||||
|
|
||||||
# Compiled locale files
|
|
||||||
src/locales/*.js
|
|
||||||
|
|
||||||
# Nix
|
|
||||||
.direnv
|
|
||||||
result
|
|
||||||
|
|
14
.prettierrc
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
275
README.md
|
@ -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,19 +81,25 @@ 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
|
||||||
|
|
||||||
Prerequisites: Node.js 18+
|
Prerequisites: Node.js 18+
|
||||||
|
|
||||||
- `npm install` - Install dependencies
|
- `npm install` - Install dependencies
|
||||||
- `npm run dev` - Start development server and `messages:extract` (`clean` + ``watch`) in parallel
|
- `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
|
||||||
- `npm run messages:extract` - Extract messages from source files and update the locale message catalogs
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
@ -115,244 +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.*
|
||||||
- [Lingui](https://lingui.dev/) - Internationalization
|
|
||||||
- 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.
|
||||||
|
|
||||||
## Internationalization
|
|
||||||
|
|
||||||
All translations are available as [gettext](https://en.wikipedia.org/wiki/Gettext) `.po` files in the `src/locales` folder. The default language is English (`en`). [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) are used for pluralization. RTL (right-to-left) languages are also supported with proper text direction, icon rendering and layout.
|
|
||||||
|
|
||||||
On page load, default language is detected via these methods, in order (first match is used):
|
|
||||||
|
|
||||||
1. URL parameter `lang` e.g. `/?lang=zh-Hant`
|
|
||||||
2. `localStorage` key `lang`
|
|
||||||
3. Browser's `navigator.language`
|
|
||||||
|
|
||||||
Users can change the language in the settings, which sets the `localStorage` key `lang`.
|
|
||||||
|
|
||||||
### Guide for translators
|
|
||||||
|
|
||||||
*Inspired by [Translate WordPress Handbook](https://make.wordpress.org/polyglots/handbook/):
|
|
||||||
|
|
||||||
- [Don’t translate literally, translate organically](https://make.wordpress.org/polyglots/handbook/translating/expectations/#dont-translate-literally-translate-organically).
|
|
||||||
- [Try to keep the same level of formality (or informality)](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
|
|
||||||
- [Don’t use slang or audience-specific terms](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
|
|
||||||
- Be attentive to placeholders for variables. Many strings have placesholders e.g. `{account}` (variable), `<0>{name}</0>` (tag with variable) and `#` (number placeholder).
|
|
||||||
- [Ellipsis](https://en.wikipedia.org/wiki/Ellipsis) (…) is intentional. Don't remove it.
|
|
||||||
- Nielsen Norman Group: ["Include Ellipses in Command Text to Indicate When More Information Is Required"](https://www.nngroup.com/articles/ui-copy/)
|
|
||||||
- Apple Human Interface Guidelines: ["Append an ellipsis to a menu item’s label when the action requires more information before it can complete. The ellipsis character (…) signals that people need to input information or make additional choices, typically within another view."](https://developer.apple.com/design/human-interface-guidelines/menus)
|
|
||||||
- Windows App Development: ["Ellipses mean incompleteness."](https://learn.microsoft.com/en-us/windows/win32/uxguide/text-ui)
|
|
||||||
- Date timestamps, date ranges, numbers, language names and text segmentation are handled by the [ECMAScript Internationalization API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
|
|
||||||
- [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) - e.g. "8 Aug", "08/08/2024"
|
|
||||||
- [`Intl.RelativeTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat) - e.g. "2 days ago", "in 2 days"
|
|
||||||
- [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) - e.g. "1,000", "10K"
|
|
||||||
- [`Intl.DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames) - e.g. "English" (`en`) in Traditional Chinese (`zh-Hant`) is "英文"
|
|
||||||
- [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) (with polyfill for older browsers)
|
|
||||||
- [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) (with polyfill for older browsers)
|
|
||||||
|
|
||||||
### Technical notes
|
|
||||||
|
|
||||||
- IDs for strings are auto-generated instead of explicitly defined. Some of the [benefits](https://lingui.dev/tutorials/explicit-vs-generated-ids#benefits-of-generated-ids) are avoiding the "naming things" problem and avoiding duplicates.
|
|
||||||
- Explicit IDs might be introduced in the future when requirements and priorities change. The library (Lingui) allows both.
|
|
||||||
- Please report issues if certain strings are translated differently based on context, culture or region.
|
|
||||||
- There are no strings for push notifications. The language is set on the instance server.
|
|
||||||
- Native HTML date pickers, e.g. `<input type="month">` will always follow the system's locale and not the user's set locale.
|
|
||||||
- "ALT" in ALT badge is not translated. It serves as a a recognizable standard across languages.
|
|
||||||
- Custom emoji names are not localized, therefore searches don't work for non-English languages.
|
|
||||||
- GIPHY API supports [a list of languages for searches](https://developers.giphy.com/docs/optional-settings/#language-support).
|
|
||||||
- Unicode Right-to-left mark (RLM) (`U+200F`, `‏`) may need to be used for mixed RTL/LTR text, especially for [`<title>` element](https://www.w3.org/International/questions/qa-html-dir.en.html#title_element) (`document.title`).
|
|
||||||
- On development, there's an additional `pseudo-LOCALE` locale, used for [pseudolocalization](https://en.wikipedia.org/wiki/Pseudolocalization). It's for testing and won't show up on production.
|
|
||||||
- When building for production, English (`en`) catalog messages are not bundled separatedly. Other locales are bundled as separate files and loaded on demand. This ensures that `en` is always available as fallback.
|
|
||||||
|
|
||||||
### Volunteer translations
|
|
||||||
|
|
||||||
[![Crowdin](https://badges.crowdin.net/phanpy/localized.svg)](https://crowdin.com/project/phanpy)
|
|
||||||
|
|
||||||
Translations are managed on [Crowdin](https://crowdin.com/project/phanpy). You can help by volunteering translations.
|
|
||||||
|
|
||||||
Read the [intro documentation](https://support.crowdin.com/for-volunteer-translators/) to get started.
|
|
||||||
|
|
||||||
## 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_DEFAULT_LANG` (optional):
|
|
||||||
- Default language is English (`en`) if not specified.
|
|
||||||
- Fallback language after multiple detection methods (`lang` query parameter, `lang` key in `localStorage` and `navigator.language`)
|
|
||||||
- `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)
|
|
||||||
|
|
||||||
### Translation volunteers
|
|
||||||
|
|
||||||
<!-- i18n volunteers start -->
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12571163/medium/9f3ea938f4243f5ffe2a43f814ddc9e8_default.png" alt="" width="16" height="16" /> alidsds11 (Arabic)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16180744/medium/5b04ae975b23895635130d7a176515cb_default.png" alt="" width="16" height="16" /> alternative (Korean)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13170041/medium/603136896af17fc005fd592ce3f48717_default.png" alt="" width="16" height="16" /> BoFFire (Arabic, French, Kabyle)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12898464/medium/d3758a76b894bade4bf271c9b32ea69b.png" alt="" width="16" height="16" /> Brawaru (Russian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15460040/medium/1cfcfe5f5511b783b5d9f2b968bad819.png" alt="" width="16" height="16" /> cbasje (Dutch)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15525631/medium/51293156034d0236f1a1020c10f7d539_default.png" alt="" width="16" height="16" /> cbo92 (French)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15910131/medium/67fab7eeab5551853450e76e2ef19e59.jpeg" alt="" width="16" height="16" /> CDN (Chinese Simplified)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16556801/medium/ed5e501ca1f3cc6525d2da28db646346.jpeg" alt="" width="16" height="16" /> dannypsnl (Chinese Traditional)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/3711/medium/d95ddd44e8dcb3a039f8a3463aed781d_default.png" alt="" width="16" height="16" /> databio (Catalan)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16533843/medium/ac7af8776858a992d992cf6702d1aaae.jpg" alt="" width="16" height="16" /> Dizro (Italian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16574625/medium/f2ac3a4f32f104a3a6d4085d4bcb3924_default.png" alt="" width="16" height="16" /> Drift6944 (Czech)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12618120/medium/ccb11bd042bbf4c7189033f7af2dbd32_default.png" alt="" width="16" height="16" /> drydenwu (Chinese Traditional)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13557465/medium/8feebf3677fa80c01e8c54c4fbe097e0_default.png" alt="" width="16" height="16" /> elissarc (French)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16528627/medium/9036f6eced0257f4e1ea4c5bd499de2d_default.png" alt="" width="16" height="16" /> ElPamplina (Spanish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14277386/medium/29b30d2c73a214000e3941c9978f49e4_default.png" alt="" width="16" height="16" /> Fitik (Esperanto, Hebrew)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14444512/medium/99d0e7a3076deccbdfe0aa0b0612308c.jpeg" alt="" width="16" height="16" /> Freeesia (Japanese)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12617257/medium/a201650da44fed28890b0e0d8477a663.jpg" alt="" width="16" height="16" /> ghose (Galician)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15248754/medium/0dac6334ea0f4e8d4194a605c0a5594a.jpeg" alt="" width="16" height="16" /> hongminhee (Korean)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16529833/medium/2122d0c5d61c00786ab6d5e5672d4098.png" alt="" width="16" height="16" /> Hugoglyph (Esperanto, Spanish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13454728/medium/1f78b7124b3c962bc4ae55e8d701fc91_default.png" alt="" width="16" height="16" /> isard (Catalan)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16532403/medium/4cefb19623bcc44d7cdb9e25aebf5250.jpeg" alt="" width="16" height="16" /> karlafej (Czech)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15791971/medium/88bdda3090339f16f6083390d32bb434_default.png" alt="" width="16" height="16" /> katullo11 (Italian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14677260/medium/e53420d200961f48602324e18c091bdc.png" alt="" width="16" height="16" /> Kytta (German)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16529521/medium/ae6add93a901b0fefa2d9b1077920d73.png" alt="" width="16" height="16" /> llun (Thai)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16291756/medium/2366972cc86287353708aff1ded3f3c1.jpg" alt="" width="16" height="16" /> lucasofchirst (Occitan, Portuguese, Portuguese, Brazilian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16537713/medium/825f0bf1a14fc545a76891a52839d86e_default.png" alt="" width="16" height="16" /> marcin.kozinski (Polish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13521465/medium/76cb9aa6b753ce900a70478bff7fcea0.png" alt="" width="16" height="16" /> mkljczkk (Polish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12882812/medium/77744d8db46e9a3e09030e1a02b7a572.jpeg" alt="" width="16" height="16" /> mojosoeun (Korean)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13613969/medium/c7834ddc0ada84a79671697a944bb274.png" alt="" width="16" height="16" /> moreal (Korean)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14158861/medium/ba1ff31dc5743b067ea6685f735229a5_default.png" alt="" width="16" height="16" /> MrWillCom (Chinese Simplified)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15652333/medium/7f36f289f9e2fe41d89ad534a1047f0e.png" alt="" width="16" height="16" /> nclm (French)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16539461/medium/2f41b9f0b802c1d200a6ab62167a7229_default.png" alt="" width="16" height="16" /> pazpi (Italian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15106977/medium/54bf93b19af8bbfdee579ea51685bafa.jpeg" alt="" width="16" height="16" /> punkrockgirl (Basque)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16536247/medium/f010c8e718a36229733a8b58f6bad2a4_default.png" alt="" width="16" height="16" /> radecos (French)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16538917/medium/092ec03f56f9dd1cbce94379fa4d4d38.png" alt="" width="16" height="16" /> Razem (Czech)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14345134/medium/89a299239890c79a1d791d08ec3951dc.png" alt="" width="16" height="16" /> realpixelcode (German)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16527325/medium/37ebb27e7a50f7f85ae93beafc7028a2.jpg" alt="" width="16" height="16" /> rezahosseinzadeh (Persian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13422319/medium/66632a98d73d48e36753d94ebcec9d4f.png" alt="" width="16" height="16" /> rwmpelstilzchen (Esperanto, Hebrew)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16538605/medium/bcdb6e3286b7d6237923f3a9383eed29.png" alt="" width="16" height="16" /> SadmL (Russian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16539171/medium/0ce95ef6b3b0566136191fbedc1563d0.png" alt="" width="16" height="16" /> SadmL_AI (Russian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12381015/medium/35e3557fd61d85f9a5b84545d9e3feb4.png" alt="" width="16" height="16" /> shuuji3 (Japanese)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14565190/medium/79100599131b7776e9803e4b696915a3_default.png" alt="" width="16" height="16" /> Sky_NiniKo (French)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13143526/medium/2f15fa6d8e1703c7b82bb608b116a30a.png" alt="" width="16" height="16" /> Steffo99 (Italian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16532441/medium/1a47e8d80c95636e02d2260f6e233ca5.png" alt="" width="16" height="16" /> Su5hicz (Czech)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16530049/medium/683f3581620c6b4a5c753b416ed695a7.jpeg" alt="" width="16" height="16" /> tferrermo (Spanish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15752199/medium/7e9efd828c4691368d063b19d19eb894.png" alt="" width="16" height="16" /> tkbremnes (Norwegian Bokmal)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16527851/medium/649e5a9a8a8cc61ced670d89e9cca082.png" alt="" width="16" height="16" /> tux93 (German)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14427566/medium/ab733b5044c21867fc5a9d1b22cd2c03.png" alt="" width="16" height="16" /> Vac31. (Lithuanian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16026914/medium/e3ca187f354a298ef0c9d02a0ed17be7.jpg" alt="" width="16" height="16" /> valtlai (Finnish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16608515/medium/85506c21dce8df07843ca11908ee3951.jpeg" alt="" width="16" height="16" /> vasiriri (Polish)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16563757/medium/af4556c13862d1fd593b51084a159b75_default.png" alt="" width="16" height="16" /> voyagercy (Chinese Traditional)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15982109/medium/9c03062bdc1d3c6d384dbfead97c26ba.jpeg" alt="" width="16" height="16" /> xabi_itzultzaile (Basque)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16556017/medium/216e0f7a0c35b079920366939a3aaca7_default.png" alt="" width="16" height="16" /> xen4n (Ukrainian)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16532657/medium/f309f319266e1ff95f3070eab0c9a9d9_default.png" alt="" width="16" height="16" /> xqueralt (Catalan)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14041603/medium/6ab77a0467b06aeb49927c6d9c409f89.jpg" alt="" width="16" height="16" /> ZiriSut (Kabyle)
|
|
||||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16530601/medium/e1b6d5c24953b6405405c1ab33c0fa46.jpeg" alt="" width="16" height="16" /> zkreml (Czech)
|
|
||||||
<!-- i18n volunteers end -->
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
@ -367,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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
pull_request_labels:
|
|
||||||
- i18n
|
|
||||||
commit_message: New translations (%language%)
|
|
||||||
append_commit_message: false
|
|
||||||
files:
|
|
||||||
- source: /src/locales/en.po
|
|
||||||
translation: /src/locales/%locale%.po
|
|
Before Width: | Height: | Size: 17 KiB |
|
@ -1,12 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 64 64">
|
|
||||||
<path fill="none" d="M0 0h63.99v63.99H0z"/>
|
|
||||||
<clipPath id="a">
|
|
||||||
<path d="M0 0h63.99v63.99H0z"/>
|
|
||||||
</clipPath>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path d="M0 0h64.25v63.99H0z"/>
|
|
||||||
<path fill="#fff" d="M37.77 11.47c14.64 3.75 19.04 16.56 15.9 31.3a12.55 12.55 0 0 1-6.36 8.7c-3.2 1.71-8.07 2.53-15.34.55l-9.64-2.4c-10.68-2.63-13.95-10.89-12.3-17.8 3.62-15.2 15.54-23.48 27.74-20.35Z"/>
|
|
||||||
<path d="M36.76 15.43c12.29 3.15 15.55 14.11 12.9 26.5-.94 4.43-4.93 9.36-16.66 6.13l-9.68-2.41c-7.85-1.93-10.53-7.8-9.32-12.88 3.02-12.64 12.61-19.94 22.76-17.34Z"/>
|
|
||||||
<path fill="#fff" d="M27.47 25c-1.46-.7-7.23 3.2-7.66 8.92-.18 2.39 4.55 3.23 5.07-.17.72-4.74 3.71-8.22 2.6-8.76Zm10.75 2c-2.09.32-.39 5.9-.6 10.72-.12 2.8 4.39 3.47 4.7 2.01 1.1-5.07-2.06-13.05-4.1-12.73Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 954 B |
Before Width: | Height: | Size: 17 KiB |
|
@ -1,12 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 64 64">
|
|
||||||
<path fill="none" d="M0 0h63.99v63.99H0z"/>
|
|
||||||
<clipPath id="a">
|
|
||||||
<path d="M0 0h63.99v63.99H0z"/>
|
|
||||||
</clipPath>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path fill="#fff" d="M0 0h64.25v63.99H0z"/>
|
|
||||||
<path d="M37.77 11.47c14.64 3.75 19.04 16.56 15.9 31.3a12.55 12.55 0 0 1-6.36 8.7c-3.2 1.71-8.07 2.53-15.34.55l-9.64-2.4c-10.68-2.63-13.95-10.89-12.3-17.8 3.62-15.2 15.54-23.48 27.74-20.35Z"/>
|
|
||||||
<path fill="#fff" d="M36.76 15.43c12.29 3.15 15.55 14.11 12.9 26.5-.94 4.43-4.93 9.36-16.66 6.13l-9.68-2.41c-7.85-1.93-10.53-7.8-9.32-12.88 3.02-12.64 12.61-19.94 22.76-17.34Z"/>
|
|
||||||
<path d="M27.47 25c-1.46-.7-7.23 3.2-7.66 8.92-.18 2.39 4.55 3.23 5.07-.17.72-4.74 3.71-8.22 2.6-8.76Zm10.75 2c-2.09.32-.39 5.9-.6 10.72-.12 2.8 4.39 3.47 4.7 2.01 1.1-5.07-2.06-13.05-4.1-12.73Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 954 B |
61
flake.lock
|
@ -1,61 +0,0 @@
|
||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1726560853,
|
|
||||||
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1728492678,
|
|
||||||
"narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=",
|
|
||||||
"owner": "nixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7",
|
|
||||||
"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
|
|
||||||
}
|
|
60
flake.nix
|
@ -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-VYC6kBCu6Kh9PM93UDrT6ThGWNga9JZ6sTiYjL35nx4=";
|
|
||||||
# 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 ];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,345 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12571163/medium/9f3ea938f4243f5ffe2a43f814ddc9e8_default.png",
|
|
||||||
"username": "alidsds11",
|
|
||||||
"languages": [
|
|
||||||
"Arabic"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13170041/medium/603136896af17fc005fd592ce3f48717_default.png",
|
|
||||||
"username": "BoFFire",
|
|
||||||
"languages": [
|
|
||||||
"Arabic",
|
|
||||||
"French",
|
|
||||||
"Kabyle"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12898464/medium/d3758a76b894bade4bf271c9b32ea69b.png",
|
|
||||||
"username": "Brawaru",
|
|
||||||
"languages": [
|
|
||||||
"Russian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15460040/medium/1cfcfe5f5511b783b5d9f2b968bad819.png",
|
|
||||||
"username": "cbasje",
|
|
||||||
"languages": [
|
|
||||||
"Dutch"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15525631/medium/51293156034d0236f1a1020c10f7d539_default.png",
|
|
||||||
"username": "cbo92",
|
|
||||||
"languages": [
|
|
||||||
"French"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15910131/medium/67fab7eeab5551853450e76e2ef19e59.jpeg",
|
|
||||||
"username": "CDN",
|
|
||||||
"languages": [
|
|
||||||
"Chinese Simplified"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16556801/medium/ed5e501ca1f3cc6525d2da28db646346.jpeg",
|
|
||||||
"username": "dannypsnl",
|
|
||||||
"languages": [
|
|
||||||
"Chinese Traditional"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/3711/medium/d95ddd44e8dcb3a039f8a3463aed781d_default.png",
|
|
||||||
"username": "databio",
|
|
||||||
"languages": [
|
|
||||||
"Catalan"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12618120/medium/ccb11bd042bbf4c7189033f7af2dbd32_default.png",
|
|
||||||
"username": "drydenwu",
|
|
||||||
"languages": [
|
|
||||||
"Chinese Traditional"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13557465/medium/8feebf3677fa80c01e8c54c4fbe097e0_default.png",
|
|
||||||
"username": "elissarc",
|
|
||||||
"languages": [
|
|
||||||
"French"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16528627/medium/9036f6eced0257f4e1ea4c5bd499de2d_default.png",
|
|
||||||
"username": "ElPamplina",
|
|
||||||
"languages": [
|
|
||||||
"Spanish"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14277386/medium/29b30d2c73a214000e3941c9978f49e4_default.png",
|
|
||||||
"username": "Fitik",
|
|
||||||
"languages": [
|
|
||||||
"Esperanto",
|
|
||||||
"Hebrew"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14444512/medium/99d0e7a3076deccbdfe0aa0b0612308c.jpeg",
|
|
||||||
"username": "Freeesia",
|
|
||||||
"languages": [
|
|
||||||
"Japanese"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12617257/medium/a201650da44fed28890b0e0d8477a663.jpg",
|
|
||||||
"username": "ghose",
|
|
||||||
"languages": [
|
|
||||||
"Galician"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15248754/medium/0dac6334ea0f4e8d4194a605c0a5594a.jpeg",
|
|
||||||
"username": "hongminhee",
|
|
||||||
"languages": [
|
|
||||||
"Korean"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13454728/medium/1f78b7124b3c962bc4ae55e8d701fc91_default.png",
|
|
||||||
"username": "isard",
|
|
||||||
"languages": [
|
|
||||||
"Catalan"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532403/medium/4cefb19623bcc44d7cdb9e25aebf5250.jpeg",
|
|
||||||
"username": "karlafej",
|
|
||||||
"languages": [
|
|
||||||
"Czech"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15791971/medium/88bdda3090339f16f6083390d32bb434_default.png",
|
|
||||||
"username": "katullo11",
|
|
||||||
"languages": [
|
|
||||||
"Italian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14677260/medium/e53420d200961f48602324e18c091bdc.png",
|
|
||||||
"username": "Kytta",
|
|
||||||
"languages": [
|
|
||||||
"German"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16529521/medium/ae6add93a901b0fefa2d9b1077920d73.png",
|
|
||||||
"username": "llun",
|
|
||||||
"languages": [
|
|
||||||
"Thai"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16291756/medium/e1c4210f15537394cc764b8bc2dffe37.jpg",
|
|
||||||
"username": "lucasofchirst",
|
|
||||||
"languages": [
|
|
||||||
"Occitan",
|
|
||||||
"Portuguese",
|
|
||||||
"Portuguese, Brazilian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16537713/medium/825f0bf1a14fc545a76891a52839d86e_default.png",
|
|
||||||
"username": "marcin.kozinski",
|
|
||||||
"languages": [
|
|
||||||
"Polish"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12882812/medium/77744d8db46e9a3e09030e1a02b7a572.jpeg",
|
|
||||||
"username": "mojosoeun",
|
|
||||||
"languages": [
|
|
||||||
"Korean"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13613969/medium/c7834ddc0ada84a79671697a944bb274.png",
|
|
||||||
"username": "moreal",
|
|
||||||
"languages": [
|
|
||||||
"Korean"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14158861/medium/ba1ff31dc5743b067ea6685f735229a5_default.png",
|
|
||||||
"username": "MrWillCom",
|
|
||||||
"languages": [
|
|
||||||
"Chinese Simplified"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15652333/medium/7f36f289f9e2fe41d89ad534a1047f0e.png",
|
|
||||||
"username": "nclm",
|
|
||||||
"languages": [
|
|
||||||
"French"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16539461/medium/2f41b9f0b802c1d200a6ab62167a7229_default.png",
|
|
||||||
"username": "pazpi",
|
|
||||||
"languages": [
|
|
||||||
"Italian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15106977/medium/54bf93b19af8bbfdee579ea51685bafa.jpeg",
|
|
||||||
"username": "punkrockgirl",
|
|
||||||
"languages": [
|
|
||||||
"Basque"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16536247/medium/f010c8e718a36229733a8b58f6bad2a4_default.png",
|
|
||||||
"username": "radecos",
|
|
||||||
"languages": [
|
|
||||||
"French"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16538917/medium/092ec03f56f9dd1cbce94379fa4d4d38.png",
|
|
||||||
"username": "Razem",
|
|
||||||
"languages": [
|
|
||||||
"Czech"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14345134/medium/89a299239890c79a1d791d08ec3951dc.png",
|
|
||||||
"username": "realpixelcode",
|
|
||||||
"languages": [
|
|
||||||
"German"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16527325/medium/37ebb27e7a50f7f85ae93beafc7028a2.jpg",
|
|
||||||
"username": "rezahosseinzadeh",
|
|
||||||
"languages": [
|
|
||||||
"Persian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13422319/medium/66632a98d73d48e36753d94ebcec9d4f.png",
|
|
||||||
"username": "rwmpelstilzchen",
|
|
||||||
"languages": [
|
|
||||||
"Esperanto",
|
|
||||||
"Hebrew"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16538605/medium/bcdb6e3286b7d6237923f3a9383eed29.png",
|
|
||||||
"username": "SadmL",
|
|
||||||
"languages": [
|
|
||||||
"Russian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14565190/medium/79100599131b7776e9803e4b696915a3_default.png",
|
|
||||||
"username": "Sky_NiniKo",
|
|
||||||
"languages": [
|
|
||||||
"French"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532441/medium/1a47e8d80c95636e02d2260f6e233ca5.png",
|
|
||||||
"username": "Su5hicz",
|
|
||||||
"languages": [
|
|
||||||
"Czech"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16533843/medium/7314c15492ef90118c33a80a427e6c87_default.png",
|
|
||||||
"username": "Talos00",
|
|
||||||
"languages": [
|
|
||||||
"Italian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16530049/medium/683f3581620c6b4a5c753b416ed695a7.jpeg",
|
|
||||||
"username": "tferrermo",
|
|
||||||
"languages": [
|
|
||||||
"Spanish"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16527851/medium/649e5a9a8a8cc61ced670d89e9cca082.png",
|
|
||||||
"username": "tux93",
|
|
||||||
"languages": [
|
|
||||||
"German"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16529833/medium/2991a65722acd721849656223014cd49.png",
|
|
||||||
"username": "Urbestro",
|
|
||||||
"languages": [
|
|
||||||
"Esperanto",
|
|
||||||
"Spanish"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16539171/medium/db6fb87481026c72b895adfb94e17d2c_default.png",
|
|
||||||
"username": "UsualUsername",
|
|
||||||
"languages": [
|
|
||||||
"Russian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14427566/medium/ab733b5044c21867fc5a9d1b22cd2c03.png",
|
|
||||||
"username": "Vac31.",
|
|
||||||
"languages": [
|
|
||||||
"Lithuanian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16026914/medium/e3ca187f354a298ef0c9d02a0ed17be7.jpg",
|
|
||||||
"username": "valtlai",
|
|
||||||
"languages": [
|
|
||||||
"Finnish"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15982109/medium/9c03062bdc1d3c6d384dbfead97c26ba.jpeg",
|
|
||||||
"username": "xabi_itzultzaile",
|
|
||||||
"languages": [
|
|
||||||
"Basque"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16556017/medium/216e0f7a0c35b079920366939a3aaca7_default.png",
|
|
||||||
"username": "xen4n",
|
|
||||||
"languages": [
|
|
||||||
"Ukrainian"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532657/medium/f309f319266e1ff95f3070eab0c9a9d9_default.png",
|
|
||||||
"username": "xqueralt",
|
|
||||||
"languages": [
|
|
||||||
"Catalan"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14041603/medium/6ab77a0467b06aeb49927c6d9c409f89.jpg",
|
|
||||||
"username": "ZiriSut",
|
|
||||||
"languages": [
|
|
||||||
"Kabyle"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16530601/medium/e1b6d5c24953b6405405c1ab33c0fa46.jpeg",
|
|
||||||
"username": "zkreml",
|
|
||||||
"languages": [
|
|
||||||
"Czech"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
27
index.html
|
@ -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>
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { ALL_LOCALES } from './src/locales';
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
locales: ALL_LOCALES,
|
|
||||||
sourceLocale: 'en',
|
|
||||||
pseudoLocale: 'pseudo-LOCALE',
|
|
||||||
fallbackLocales: {
|
|
||||||
default: 'en',
|
|
||||||
},
|
|
||||||
catalogs: [
|
|
||||||
{
|
|
||||||
path: '<rootDir>/src/locales/{locale}',
|
|
||||||
include: ['src'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// compileNamespace: 'es',
|
|
||||||
orderBy: 'origin',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
12853
package-lock.json
generated
103
package.json
|
@ -6,77 +6,56 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"fetch-instances": "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",
|
|
||||||
"messages:extract": "lingui extract",
|
|
||||||
"messages:extract:clean": "lingui extract --locale en --clean",
|
|
||||||
"messages:compile": "lingui compile",
|
|
||||||
"fetch-i18n-volunteers": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-i18n-volunteers.js",
|
|
||||||
"readme:i18n-volunteers": "node scripts/update-i18n-volunteers-readme.js"
|
|
||||||
},
|
},
|
||||||
"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.2",
|
|
||||||
"@iconify-icons/mingcute": "~1.2.9",
|
|
||||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||||
"@lingui/detect-locale": "~4.11.4",
|
"@szhsin/react-menu": "~4.0.0",
|
||||||
"@lingui/macro": "~4.11.4",
|
"@uidotdev/usehooks": "~2.0.1",
|
||||||
"@lingui/react": "~4.11.4",
|
"dayjs": "~1.11.8",
|
||||||
"@szhsin/react-menu": "~4.2.2",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"compare-versions": "~6.1.1",
|
"fast-blurhash": "~1.1.2",
|
||||||
"fast-blurhash": "~1.1.4",
|
"fast-deep-equal": "~3.1.3",
|
||||||
"fast-equals": "~5.0.1",
|
|
||||||
"fuse.js": "~7.0.0",
|
|
||||||
"html-prettify": "~1.0.7",
|
|
||||||
"idb-keyval": "~6.2.1",
|
"idb-keyval": "~6.2.1",
|
||||||
"intl-locale-textinfo-polyfill": "~2.1.1",
|
|
||||||
"js-cookie": "~3.0.5",
|
|
||||||
"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.2.0",
|
"preact": "~10.15.1",
|
||||||
"preact": "~10.24.2",
|
"react-hotkeys-hook": "~4.4.0",
|
||||||
"punycode": "~2.3.1",
|
"react-intersection-observer": "~9.4.4",
|
||||||
"react-hotkeys-hook": "~4.5.1",
|
"react-quick-pinch-zoom": "~4.9.0",
|
||||||
"react-intersection-observer": "~9.13.1",
|
|
||||||
"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.3",
|
"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": "2.0.0"
|
"valtio": "1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
|
"@preact/preset-vite": "~2.5.0",
|
||||||
"@lingui/cli": "~4.11.4",
|
"@trivago/prettier-plugin-sort-imports": "~4.1.1",
|
||||||
"@lingui/vite-plugin": "~4.11.4",
|
"postcss": "~8.4.24",
|
||||||
"@preact/preset-vite": "~2.9.1",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
"babel-plugin-macros": "~3.1.0",
|
"postcss-preset-env": "~8.5.0",
|
||||||
"postcss": "~8.4.47",
|
|
||||||
"postcss-dark-theme-class": "~1.3.0",
|
|
||||||
"postcss-preset-env": "~10.0.6",
|
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~5.4.8",
|
"vite": "~4.3.9",
|
||||||
"vite-plugin-generate-file": "~0.2.0",
|
"vite-plugin-generate-file": "~0.0.4",
|
||||||
"vite-plugin-html-config": "~2.0.2",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-pwa": "~0.20.5",
|
"vite-plugin-pwa": "~0.16.4",
|
||||||
"vite-plugin-remove-console": "~2.2.0",
|
"vite-plugin-remove-console": "~2.1.1",
|
||||||
"vite-plugin-run": "~0.6.0",
|
"workbox-cacheable-response": "~7.0.0",
|
||||||
"workbox-cacheable-response": "~7.1.0",
|
"workbox-expiration": "~7.0.0",
|
||||||
"workbox-expiration": "~7.1.0",
|
"workbox-routing": "~7.0.0",
|
||||||
"workbox-navigation-preload": "~7.1.0",
|
"workbox-strategies": "~7.0.0"
|
||||||
"workbox-routing": "~7.1.0",
|
|
||||||
"workbox-strategies": "~7.1.0"
|
|
||||||
},
|
},
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
@ -88,12 +67,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"vite": {
|
|
||||||
"rollup": ">=4.5.1"
|
|
||||||
},
|
|
||||||
"esbuild": "0.21.5"
|
|
||||||
},
|
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"defaults",
|
"defaults",
|
||||||
"android >= 4"
|
"android >= 4"
|
||||||
|
|
|
@ -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>
|
|
Before Width: | Height: | Size: 1.1 KiB |
194
public/sw.js
|
@ -1,6 +1,5 @@
|
||||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||||
import { ExpirationPlugin } from 'workbox-expiration';
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
import * as navigationPreload from 'workbox-navigation-preload';
|
|
||||||
import { RegExpRoute, registerRoute, Route } from 'workbox-routing';
|
import { RegExpRoute, registerRoute, Route } from 'workbox-routing';
|
||||||
import {
|
import {
|
||||||
CacheFirst,
|
CacheFirst,
|
||||||
|
@ -8,10 +7,32 @@ import {
|
||||||
StaleWhileRevalidate,
|
StaleWhileRevalidate,
|
||||||
} from 'workbox-strategies';
|
} from 'workbox-strategies';
|
||||||
|
|
||||||
navigationPreload.enable();
|
|
||||||
|
|
||||||
self.__WB_DISABLE_DEV_LOGS = true;
|
self.__WB_DISABLE_DEV_LOGS = true;
|
||||||
|
|
||||||
|
const imageRoute = new Route(
|
||||||
|
({ request, sameOrigin }) => {
|
||||||
|
const isRemote = !sameOrigin;
|
||||||
|
const isImage = request.destination === 'image';
|
||||||
|
const isAvatar = request.url.includes('/avatars/');
|
||||||
|
const isEmoji = request.url.includes('/emoji/');
|
||||||
|
return isRemote && isImage && (isAvatar || isEmoji);
|
||||||
|
},
|
||||||
|
new CacheFirst({
|
||||||
|
cacheName: 'remote-images',
|
||||||
|
plugins: [
|
||||||
|
new ExpirationPlugin({
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
||||||
|
purgeOnQuotaError: true,
|
||||||
|
}),
|
||||||
|
new CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
registerRoute(imageRoute);
|
||||||
|
|
||||||
const iconsRoute = new Route(
|
const iconsRoute = new Route(
|
||||||
({ request, sameOrigin }) => {
|
({ request, sameOrigin }) => {
|
||||||
const isIcon = request.url.includes('/icons/');
|
const isIcon = request.url.includes('/icons/');
|
||||||
|
@ -21,9 +42,7 @@ const iconsRoute = new Route(
|
||||||
cacheName: 'icons',
|
cacheName: 'icons',
|
||||||
plugins: [
|
plugins: [
|
||||||
new ExpirationPlugin({
|
new ExpirationPlugin({
|
||||||
// Weirdly high maxEntries number, due to some old icons suddenly disappearing and not rendering
|
maxEntries: 50,
|
||||||
// NOTE: Temporary fix
|
|
||||||
maxEntries: 300,
|
|
||||||
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
||||||
purgeOnQuotaError: true,
|
purgeOnQuotaError: true,
|
||||||
}),
|
}),
|
||||||
|
@ -35,65 +54,19 @@ const iconsRoute = new Route(
|
||||||
);
|
);
|
||||||
registerRoute(iconsRoute);
|
registerRoute(iconsRoute);
|
||||||
|
|
||||||
const assetsRoute = new Route(
|
|
||||||
({ request, sameOrigin }) => {
|
|
||||||
const isAsset =
|
|
||||||
request.destination === 'style' || request.destination === 'script';
|
|
||||||
const hasHash = /-[0-9a-z-]{4,}\./i.test(request.url);
|
|
||||||
return sameOrigin && isAsset && hasHash;
|
|
||||||
},
|
|
||||||
new NetworkFirst({
|
|
||||||
cacheName: 'assets',
|
|
||||||
networkTimeoutSeconds: 5,
|
|
||||||
plugins: [
|
|
||||||
new ExpirationPlugin({
|
|
||||||
maxEntries: 30,
|
|
||||||
purgeOnQuotaError: true,
|
|
||||||
}),
|
|
||||||
new CacheableResponsePlugin({
|
|
||||||
statuses: [0, 200],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
registerRoute(assetsRoute);
|
|
||||||
|
|
||||||
const imageRoute = new Route(
|
|
||||||
({ request, sameOrigin }) => {
|
|
||||||
const isRemote = !sameOrigin;
|
|
||||||
const isImage = request.destination === 'image';
|
|
||||||
const isAvatar = request.url.includes('/avatars/');
|
|
||||||
const isCustomEmoji = request.url.includes('/custom/_emojis');
|
|
||||||
const isEmoji = request.url.includes('/emoji/');
|
|
||||||
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji);
|
|
||||||
},
|
|
||||||
new CacheFirst({
|
|
||||||
cacheName: 'remote-images',
|
|
||||||
plugins: [
|
|
||||||
new ExpirationPlugin({
|
|
||||||
maxEntries: 30,
|
|
||||||
purgeOnQuotaError: true,
|
|
||||||
}),
|
|
||||||
new CacheableResponsePlugin({
|
|
||||||
statuses: [0, 200],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
registerRoute(imageRoute);
|
|
||||||
|
|
||||||
// 1-day cache for
|
// 1-day cache for
|
||||||
|
// - /api/v1/instance
|
||||||
// - /api/v1/custom_emojis
|
// - /api/v1/custom_emojis
|
||||||
|
// - /api/v1/preferences
|
||||||
// - /api/v1/lists/:id
|
// - /api/v1/lists/:id
|
||||||
// - /api/v1/announcements
|
// - /api/v1/announcements
|
||||||
const apiExtendedRoute = new RegExpRoute(
|
const apiExtendedRoute = new RegExpRoute(
|
||||||
/^https?:\/\/[^\/]+\/api\/v\d+\/(custom_emojis|lists\/\d+|announcements)$/,
|
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+|announcements)$/,
|
||||||
new StaleWhileRevalidate({
|
new StaleWhileRevalidate({
|
||||||
cacheName: 'api-extended',
|
cacheName: 'api-extended',
|
||||||
plugins: [
|
plugins: [
|
||||||
new ExpirationPlugin({
|
new ExpirationPlugin({
|
||||||
maxAgeSeconds: 12 * 60 * 60, // 12 hours
|
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||||
purgeOnQuotaError: true,
|
|
||||||
}),
|
}),
|
||||||
new CacheableResponsePlugin({
|
new CacheableResponsePlugin({
|
||||||
statuses: [0, 200],
|
statuses: [0, 200],
|
||||||
|
@ -103,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
|
||||||
|
@ -134,9 +85,7 @@ const apiRoute = new RegExpRoute(
|
||||||
networkTimeoutSeconds: 5,
|
networkTimeoutSeconds: 5,
|
||||||
plugins: [
|
plugins: [
|
||||||
new ExpirationPlugin({
|
new ExpirationPlugin({
|
||||||
maxEntries: 30,
|
|
||||||
maxAgeSeconds: 5 * 60, // 5 minutes
|
maxAgeSeconds: 5 * 60, // 5 minutes
|
||||||
purgeOnQuotaError: true,
|
|
||||||
}),
|
}),
|
||||||
new CacheableResponsePlugin({
|
new CacheableResponsePlugin({
|
||||||
statuses: [0, 200],
|
statuses: [0, 200],
|
||||||
|
@ -145,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();
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
Before Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 58 KiB |
|
@ -1,93 +0,0 @@
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
// Dependency from Lingui, not listed in package.json
|
|
||||||
import PO from 'pofile';
|
|
||||||
|
|
||||||
const DEFAULT_LANG = 'en';
|
|
||||||
const IGNORE_LANGS = [DEFAULT_LANG, 'pseudo-LOCALE'];
|
|
||||||
|
|
||||||
const files = fs.readdirSync('src/locales');
|
|
||||||
const catalogs = {};
|
|
||||||
|
|
||||||
const enCatalog = files.find((file) => file.endsWith('en.po'));
|
|
||||||
const enContent = fs.readFileSync(`src/locales/${enCatalog}`, 'utf8');
|
|
||||||
const enPo = PO.parse(enContent);
|
|
||||||
const total = enPo.items.length;
|
|
||||||
console.log('Total strings:', total);
|
|
||||||
|
|
||||||
const codeMaps = {
|
|
||||||
'kab-KAB': 'kab',
|
|
||||||
};
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file.endsWith('.po')) {
|
|
||||||
const code = file.replace(/\.po$/, '');
|
|
||||||
if (IGNORE_LANGS.includes(code)) return;
|
|
||||||
const content = fs.readFileSync(`src/locales/${file}`, 'utf8');
|
|
||||||
const po = PO.parse(content);
|
|
||||||
const { items } = po;
|
|
||||||
// Percentage of translated strings
|
|
||||||
const translated = items.filter(
|
|
||||||
(item) => item.msgstr !== '' && item.msgstr[0] !== '',
|
|
||||||
).length;
|
|
||||||
const percentage = Math.round((translated / total) * 100);
|
|
||||||
po.percentage = percentage;
|
|
||||||
if (percentage > 0) {
|
|
||||||
// Ignore empty catalogs
|
|
||||||
catalogs[codeMaps[code] || code] = percentage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const regionMaps = {
|
|
||||||
'zh-CN': 'zh-Hans',
|
|
||||||
'zh-TW': 'zh-Hant',
|
|
||||||
};
|
|
||||||
|
|
||||||
function IDN(inputCode, outputCode) {
|
|
||||||
let result;
|
|
||||||
const regionlessInputCode =
|
|
||||||
regionMaps[inputCode] || inputCode.replace(/-[a-z]+$/i, '');
|
|
||||||
const regionlessOutputCode =
|
|
||||||
regionMaps[outputCode] || outputCode.replace(/-[a-z]+$/i, '');
|
|
||||||
const inputCodes =
|
|
||||||
regionlessInputCode !== inputCode
|
|
||||||
? [inputCode, regionlessInputCode]
|
|
||||||
: [inputCode];
|
|
||||||
const outputCodes =
|
|
||||||
regionlessOutputCode !== outputCode
|
|
||||||
? [regionlessOutputCode, outputCode]
|
|
||||||
: [outputCode];
|
|
||||||
|
|
||||||
for (const inputCode of inputCodes) {
|
|
||||||
for (const outputCode of outputCodes) {
|
|
||||||
try {
|
|
||||||
result = new Intl.DisplayNames([inputCode], {
|
|
||||||
type: 'language',
|
|
||||||
}).of(outputCode);
|
|
||||||
break;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
if (result) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullCatalogs = Object.entries(catalogs)
|
|
||||||
// sort by key
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
.map(([code, completion]) => {
|
|
||||||
const nativeName = IDN(code, code);
|
|
||||||
const name = IDN('en', code);
|
|
||||||
return { code, nativeName, name, completion };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by completion
|
|
||||||
const sortedCatalogs = [...fullCatalogs].sort(
|
|
||||||
(a, b) => b.completion - a.completion,
|
|
||||||
);
|
|
||||||
console.table(sortedCatalogs);
|
|
||||||
|
|
||||||
const path = 'src/data/catalogs.json';
|
|
||||||
fs.writeFileSync(path, JSON.stringify(fullCatalogs, null, 2));
|
|
||||||
console.log('File written:', path);
|
|
|
@ -1,131 +0,0 @@
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
const { CROWDIN_ACCESS_TOKEN } = process.env;
|
|
||||||
|
|
||||||
const PROJECT_ID = '703337';
|
|
||||||
|
|
||||||
if (!CROWDIN_ACCESS_TOKEN) {
|
|
||||||
throw new Error('CROWDIN_ACCESS_TOKEN is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Report
|
|
||||||
|
|
||||||
let REPORT_ID = null;
|
|
||||||
{
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: 'top-members',
|
|
||||||
schema: {
|
|
||||||
format: 'json',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const json = await response.json();
|
|
||||||
console.log(`Report ID: ${json?.data?.identifier}`);
|
|
||||||
REPORT_ID = json?.data?.identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!REPORT_ID) {
|
|
||||||
throw new Error('Report ID is not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Report Generation Status
|
|
||||||
let finished = false;
|
|
||||||
{
|
|
||||||
let maxPolls = 10;
|
|
||||||
do {
|
|
||||||
maxPolls--;
|
|
||||||
if (maxPolls < 0) break;
|
|
||||||
|
|
||||||
// Wait for 1 second
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const status = await fetch(
|
|
||||||
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports/${REPORT_ID}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const json = await status.json();
|
|
||||||
const progress = json?.data?.progress;
|
|
||||||
console.log(`Progress: ${progress}% (${maxPolls} retries left)`);
|
|
||||||
finished = json?.data?.status === 'finished';
|
|
||||||
} while (!finished);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finished) {
|
|
||||||
throw new Error('Failed to generate report');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download Report
|
|
||||||
let reportURL = null;
|
|
||||||
{
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports/${REPORT_ID}/download`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const json = await response.json();
|
|
||||||
reportURL = json?.data?.url;
|
|
||||||
console.log(`Report URL: ${reportURL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reportURL) {
|
|
||||||
throw new Error('Report URL is not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actually download the report
|
|
||||||
let members = null;
|
|
||||||
{
|
|
||||||
const response = await fetch(reportURL);
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
const { data } = json;
|
|
||||||
|
|
||||||
if (!data?.length) {
|
|
||||||
throw new Error('No data found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by 'user.fullName'
|
|
||||||
data.sort((a, b) => a.user.username.localeCompare(b.user.username));
|
|
||||||
members = data
|
|
||||||
.filter((item) => {
|
|
||||||
const isMyself = item.user.username === 'cheeaun';
|
|
||||||
const translatedMoreThanZero = item.translated > 0;
|
|
||||||
|
|
||||||
return !isMyself && translatedMoreThanZero;
|
|
||||||
})
|
|
||||||
.map((item) => ({
|
|
||||||
avatarUrl: item.user.avatarUrl,
|
|
||||||
username: item.user.username,
|
|
||||||
languages: item.languages.map((lang) => lang.name),
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(members);
|
|
||||||
|
|
||||||
if (members?.length) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
'i18n-volunteers.json',
|
|
||||||
JSON.stringify(members, null, '\t'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!members?.length) {
|
|
||||||
throw new Error('No members found');
|
|
||||||
}
|
|
|
@ -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');
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
// Find for <!-- i18n volunteers start --><!-- i18n volunteers end --> and inject list of i18n volunteers in between
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
const i18nVolunteers = JSON.parse(fs.readFileSync('i18n-volunteers.json'));
|
|
||||||
|
|
||||||
const readme = fs.readFileSync('README.md', 'utf8');
|
|
||||||
|
|
||||||
const i18nVolunteersStart = '<!-- i18n volunteers start -->';
|
|
||||||
const i18nVolunteersEnd = '<!-- i18n volunteers end -->';
|
|
||||||
|
|
||||||
const i18nVolunteersList = i18nVolunteers
|
|
||||||
.map((member) => {
|
|
||||||
return `- <img src="${member.avatarUrl}" alt="" width="16" height="16" /> ${
|
|
||||||
member.username
|
|
||||||
} (${member.languages.join(', ')})`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const readmeUpdated = readme.replace(
|
|
||||||
new RegExp(`${i18nVolunteersStart}.*${i18nVolunteersEnd}`, 's'),
|
|
||||||
`${i18nVolunteersStart}\n${i18nVolunteersList}\n${i18nVolunteersEnd}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync('README.md', readmeUpdated);
|
|
||||||
|
|
||||||
console.log('Updated README.md');
|
|
1484
src/app.css
820
src/app.jsx
|
@ -1,8 +1,5 @@
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import debounce from 'just-debounce-it';
|
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
@ -10,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';
|
||||||
|
@ -43,297 +44,50 @@ 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 {
|
||||||
api,
|
api,
|
||||||
hasInstance,
|
|
||||||
hasPreferences,
|
|
||||||
initAccount,
|
initAccount,
|
||||||
initClient,
|
initClient,
|
||||||
initInstance,
|
initInstance,
|
||||||
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 {
|
import { getCurrentAccount } from './utils/store-utils';
|
||||||
getAccount,
|
import useInterval from './utils/useInterval';
|
||||||
getCurrentAccount,
|
import usePageVisibility from './utils/usePageVisibility';
|
||||||
setCurrentAccountID,
|
|
||||||
} from './utils/store-utils';
|
|
||||||
|
|
||||||
import './utils/toast-alert';
|
|
||||||
|
|
||||||
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;
|
function App() {
|
||||||
const unmountedPosts = [];
|
const snapStates = useSnapshot(states);
|
||||||
for (const key in statuses) {
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const $post = document.querySelector(
|
const [uiState, setUIState] = useState('loading');
|
||||||
`[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
|
const navigate = useNavigate();
|
||||||
);
|
|
||||||
if (!$post) {
|
|
||||||
unmountedPosts.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Experimental "garbage collection" for states
|
useLayoutEffect(() => {
|
||||||
// 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');
|
const theme = store.local.get('theme');
|
||||||
let $meta;
|
|
||||||
if (theme) {
|
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.documentElement.classList.add(`is-${theme}`);
|
||||||
document
|
document
|
||||||
.querySelector('meta[name="color-scheme"]')
|
.querySelector('meta[name="color-scheme"]')
|
||||||
.setAttribute('content', theme || 'dark light');
|
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
|
||||||
|
|
||||||
// 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');
|
const textSize = store.local.get('textSize');
|
||||||
if (textSize) {
|
if (textSize) {
|
||||||
document.documentElement.style.setProperty('--text-size', `${textSize}px`);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const BENCHES = new Map();
|
|
||||||
window.__BENCH_RESULTS = new Map();
|
|
||||||
window.__BENCHMARK = {
|
|
||||||
start(name) {
|
|
||||||
if (!import.meta.env.DEV && !import.meta.env.PHANPY_DEV) return;
|
|
||||||
// If already started, ignore
|
|
||||||
if (BENCHES.has(name)) return;
|
|
||||||
const start = performance.now();
|
|
||||||
BENCHES.set(name, start);
|
|
||||||
},
|
|
||||||
end(name) {
|
|
||||||
if (!import.meta.env.DEV && !import.meta.env.PHANPY_DEV) return;
|
|
||||||
const start = BENCHES.get(name);
|
|
||||||
if (start) {
|
|
||||||
const end = performance.now();
|
|
||||||
const duration = end - start;
|
|
||||||
__BENCH_RESULTS.set(name, duration);
|
|
||||||
BENCHES.delete(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
||||||
const [uiState, setUIState] = useState('loading');
|
|
||||||
__BENCHMARK.start('app-init');
|
|
||||||
__BENCHMARK.start('time-to-following');
|
|
||||||
__BENCHMARK.start('time-to-home');
|
|
||||||
__BENCHMARK.start('time-to-isLoggedIn');
|
|
||||||
useLingui();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const instanceURL = store.local.get('instanceURL');
|
const instanceURL = store.local.get('instanceURL');
|
||||||
|
@ -344,16 +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.sessionCookie.get('clientID');
|
const clientID = store.session.get('clientID');
|
||||||
const clientSecret = store.sessionCookie.get('clientSecret');
|
const clientSecret = store.session.get('clientSecret');
|
||||||
const vapidKey = store.sessionCookie.get('vapidKey');
|
|
||||||
const verifier = store.sessionCookie.get('codeVerifier');
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
|
@ -362,160 +110,84 @@ function App() {
|
||||||
client_id: clientID,
|
client_id: clientID,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
code,
|
code,
|
||||||
code_verifier: verifier || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accessToken) {
|
const masto = initClient({ instance: instanceURL, accessToken });
|
||||||
const client = 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);
|
||||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
|
||||||
|
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} else {
|
|
||||||
setUIState('error');
|
|
||||||
}
|
|
||||||
__BENCHMARK.end('app-init');
|
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
const account = getCurrentAccount();
|
||||||
const searchAccount = decodeURIComponent(
|
|
||||||
(window.location.search.match(/account=([^&]+)/) || [, ''])[1],
|
|
||||||
);
|
|
||||||
let account;
|
|
||||||
if (searchAccount) {
|
|
||||||
account = getAccount(searchAccount);
|
|
||||||
console.log('searchAccount', searchAccount, account);
|
|
||||||
if (account) {
|
if (account) {
|
||||||
setCurrentAccountID(account.info.id);
|
store.session.set('currentAccount', account.info.id);
|
||||||
window.history.replaceState(
|
const { masto, instance } = api({ account });
|
||||||
{},
|
console.log('masto', masto);
|
||||||
document.title,
|
initPreferences(masto);
|
||||||
window.location.pathname || '/',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!account) {
|
|
||||||
account = getCurrentAccount();
|
|
||||||
}
|
|
||||||
if (account) {
|
|
||||||
setCurrentAccountID(account.info.id);
|
|
||||||
const { client } = api({ account });
|
|
||||||
const { instance } = client;
|
|
||||||
// console.log('masto', masto);
|
|
||||||
initStates();
|
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (hasPreferences() && hasInstance(instance)) {
|
await initInstance(masto, instance);
|
||||||
// Non-blocking
|
|
||||||
initPreferences(client);
|
|
||||||
initInstance(client, instance);
|
|
||||||
} else {
|
|
||||||
await Promise.allSettled([
|
|
||||||
initPreferences(client),
|
|
||||||
initInstance(client, instance),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
__BENCHMARK.end('app-init');
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
__BENCHMARK.end('app-init');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
store.sessionCookie.del('clientID');
|
|
||||||
store.sessionCookie.del('clientSecret');
|
|
||||||
store.sessionCookie.del('codeVerifier');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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?.();
|
||||||
|
} else {
|
||||||
|
const backDrop = document.querySelector('.deck-backdrop');
|
||||||
|
if (backDrop) return;
|
||||||
|
// Focus last deck
|
||||||
|
const pages = document.querySelectorAll('.deck-container');
|
||||||
|
const page = pages[pages.length - 1]; // last one
|
||||||
|
if (page && page.tabIndex === -1) {
|
||||||
|
console.log('FOCUS', page);
|
||||||
|
page.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState === 'loading') {
|
|
||||||
return <Loader id="loader-root" />;
|
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
useEffect(focusDeck, [location]);
|
||||||
|
const showModal =
|
||||||
|
snapStates.showCompose ||
|
||||||
|
snapStates.showSettings ||
|
||||||
|
snapStates.showAccounts ||
|
||||||
|
snapStates.showAccount ||
|
||||||
|
snapStates.showDrafts ||
|
||||||
|
snapStates.showMediaModal ||
|
||||||
|
snapStates.showShortcutsSettings;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showModal) focusDeck();
|
||||||
|
}, [showModal]);
|
||||||
|
|
||||||
return (
|
const { prevLocation } = snapStates;
|
||||||
<>
|
const backgroundLocation = useRef(prevLocation || null);
|
||||||
<PrimaryRoutes isLoggedIn={isLoggedIn} />
|
const isModalPage =
|
||||||
<SecondaryRoutes isLoggedIn={isLoggedIn} />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
|
||||||
</Routes>
|
|
||||||
{isLoggedIn && <ComposeButton />}
|
|
||||||
{isLoggedIn && <Shortcuts />}
|
|
||||||
<Modals />
|
|
||||||
{isLoggedIn && <NotificationService />}
|
|
||||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
|
||||||
<SearchCommand onClose={focusDeck} />
|
|
||||||
<KeyboardShortcutsHelp />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Root({ isLoggedIn }) {
|
|
||||||
if (isLoggedIn) {
|
|
||||||
__BENCHMARK.end('time-to-isLoggedIn');
|
|
||||||
}
|
|
||||||
return isLoggedIn ? <Home /> : <Welcome />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PrimaryRoutes = memo(({ isLoggedIn }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const nonRootLocation = useMemo(() => {
|
|
||||||
const { pathname } = location;
|
|
||||||
return !/^\/(login|welcome)/i.test(pathname);
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Routes location={nonRootLocation || location}>
|
|
||||||
<Route path="/" element={<Root isLoggedIn={isLoggedIn} />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/welcome" element={<Welcome />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function getPrevLocation() {
|
|
||||||
return states.prevLocation || null;
|
|
||||||
}
|
|
||||||
function SecondaryRoutes({ isLoggedIn }) {
|
|
||||||
// const snapStates = useSnapshot(states);
|
|
||||||
const location = useLocation();
|
|
||||||
// const prevLocation = snapStates.prevLocation;
|
|
||||||
const backgroundLocation = useRef(getPrevLocation());
|
|
||||||
|
|
||||||
const isModalPage = useMemo(() => {
|
|
||||||
return (
|
|
||||||
matchPath('/:instance/s/:id', location.pathname) ||
|
matchPath('/:instance/s/:id', location.pathname) ||
|
||||||
matchPath('/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;
|
||||||
}
|
}
|
||||||
|
@ -524,24 +196,62 @@ 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={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}>
|
<Routes location={backgroundLocation.current || location}>
|
||||||
{isLoggedIn && (
|
{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 />} />
|
|
||||||
<Route path="/catchup" element={<Catchup />} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
{isLoggedIn && <Route path="/ft" element={<FollowedHashtags />} />}
|
||||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||||
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
|
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
|
||||||
<Route path="/:instance?/p">
|
<Route path="/:instance?/p">
|
||||||
|
@ -552,7 +262,271 @@ function SecondaryRoutes({ isLoggedIn }) {
|
||||||
<Route path="/:instance?/search" element={<Search />} />
|
<Route path="/:instance?/search" element={<Search />} />
|
||||||
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||||
</Routes>
|
</Routes>
|
||||||
|
{uiState === 'default' && (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
|
{isLoggedIn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="compose-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
const newWin = openCompose();
|
||||||
|
if (!newWin) {
|
||||||
|
alert('Looks like your browser is blocking popups.');
|
||||||
|
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 };
|
||||||
|
|
Before Width: | Height: | Size: 25 KiB |
|
@ -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 |
|
@ -1,71 +1,39 @@
|
||||||
body.cloak,
|
body.cloak a {
|
||||||
.cloak {
|
|
||||||
a {
|
|
||||||
text-decoration-color: var(--link-color);
|
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,
|
|
||||||
.catchup-filters .filter-author *,
|
|
||||||
.post-peek-html *,
|
|
||||||
.post-peek-content > *,
|
|
||||||
.request-notifications-account *,
|
|
||||||
.status.compact-thread *,
|
|
||||||
.status .content-compact {
|
|
||||||
text-decoration-thickness: 1.1em;
|
text-decoration-thickness: 1.1em;
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
/* text-rendering: optimizeSpeed; */
|
text-rendering: optimizeSpeed;
|
||||||
filter: opacity(0.5);
|
filter: opacity(0.5);
|
||||||
}
|
}
|
||||||
.name-text *,
|
body.cloak .name-text *,
|
||||||
.status .content-container *,
|
body.cloak .status .content-container *,
|
||||||
.account-container :is(header, main > *:not(.actions)) *,
|
body.cloak .account-container :is(header, main > *:not(.actions)) * {
|
||||||
.post-peek-content > * {
|
|
||||||
filter: none;
|
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,
|
|
||||||
.post-peek-media {
|
|
||||||
filter: contrast(0) !important;
|
filter: contrast(0) !important;
|
||||||
background-color: #000 !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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'),
|
|
||||||
};
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,32 +1,22 @@
|
||||||
import './account-block.css';
|
import './account-block.css';
|
||||||
|
|
||||||
import { Plural, t, Trans } from '@lingui/macro';
|
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 (
|
||||||
|
@ -35,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,
|
||||||
|
@ -59,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,
|
||||||
|
@ -105,16 +68,8 @@ function AccountBlock({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="avatar-container">
|
<Avatar url={avatar} size={avatarSize} squircle={bot} />
|
||||||
<Avatar
|
<span>
|
||||||
url={useAvatarStatic ? avatarStatic : avatar || avatarStatic}
|
|
||||||
size={avatarSize}
|
|
||||||
squircle={bot}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="account-block-content">
|
|
||||||
{!hideDisplayName && (
|
|
||||||
<>
|
|
||||||
{displayName ? (
|
{displayName ? (
|
||||||
<b>
|
<b>
|
||||||
<EmojiText text={displayName} emojis={emojis} />
|
<EmojiText text={displayName} emojis={emojis} />
|
||||||
|
@ -122,115 +77,29 @@ function AccountBlock({
|
||||||
) : (
|
) : (
|
||||||
<b>{username}</b>
|
<b>{username}</b>
|
||||||
)}
|
)}
|
||||||
</>
|
<br />
|
||||||
)}{' '}
|
<span class="account-block-acct">
|
||||||
<span class="account-block-acct bidi-isolate">
|
@{acct1}
|
||||||
{acct2 ? '' : '@'}
|
|
||||||
{acct1}
|
|
||||||
<wbr />
|
<wbr />
|
||||||
{acct2}
|
{acct2}
|
||||||
{locked && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<Icon icon="lock" size="s" alt={t`Locked`} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{showActivity && (
|
{showActivity && (
|
||||||
<div class="account-block-stats">
|
<>
|
||||||
<Trans>Posts: {shortenNumber(statusesCount)}</Trans>
|
<br />
|
||||||
|
<small class="last-status-at insignificant">
|
||||||
|
Posts: {statusesCount}
|
||||||
{!!lastStatusAt && (
|
{!!lastStatusAt && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
· Last posted:{' '}
|
||||||
<Trans>
|
|
||||||
Last posted:{' '}
|
|
||||||
{niceDateTime(lastStatusAt, {
|
{niceDateTime(lastStatusAt, {
|
||||||
hideTime: true,
|
hideTime: true,
|
||||||
})}
|
})}
|
||||||
</Trans>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</small>
|
||||||
)}
|
|
||||||
{showStats && (
|
|
||||||
<div class="account-block-stats">
|
|
||||||
{bot && (
|
|
||||||
<>
|
|
||||||
<span class="tag collapsed">
|
|
||||||
<Icon icon="bot" /> <Trans>Automated</Trans>
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!!group && (
|
|
||||||
<>
|
|
||||||
<span class="tag collapsed">
|
|
||||||
<Icon icon="group" /> <Trans>Group</Trans>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasRelationship && (
|
|
||||||
<div key={relationship.id} class="shazam-container-horizontal">
|
|
||||||
<div class="shazam-container-inner">
|
|
||||||
{excludedRelationship.following &&
|
|
||||||
excludedRelationship.followedBy ? (
|
|
||||||
<span class="tag minimal">
|
|
||||||
<Trans>Mutual</Trans>
|
|
||||||
</span>
|
|
||||||
) : excludedRelationship.requested ? (
|
|
||||||
<span class="tag minimal">
|
|
||||||
<Trans>Requested</Trans>
|
|
||||||
</span>
|
|
||||||
) : excludedRelationship.following ? (
|
|
||||||
<span class="tag minimal">
|
|
||||||
<Trans>Following</Trans>
|
|
||||||
</span>
|
|
||||||
) : excludedRelationship.followedBy ? (
|
|
||||||
<span class="tag minimal">
|
|
||||||
<Trans>Follows you</Trans>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!!followersCount && (
|
|
||||||
<span class="ib">
|
|
||||||
<Plural
|
|
||||||
value={followersCount}
|
|
||||||
one="# follower"
|
|
||||||
other="# followers"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!!verifiedField && (
|
|
||||||
<span class="verified-field">
|
|
||||||
<Icon icon="check-circle" size="s" alt={t`Verified`} />{' '}
|
|
||||||
<span
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: enhanceContent(verifiedField.value, { emojis }),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!bot &&
|
|
||||||
!group &&
|
|
||||||
!hasRelationship &&
|
|
||||||
!followersCount &&
|
|
||||||
!verifiedField &&
|
|
||||||
!!createdAt && (
|
|
||||||
<span class="created-at">
|
|
||||||
<Trans>
|
|
||||||
Joined{' '}
|
|
||||||
<time datetime={createdAt}>
|
|
||||||
{niceDateTime(createdAt, {
|
|
||||||
hideTime: true,
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,152 +1,16 @@
|
||||||
.account-container {
|
.account-container {
|
||||||
/* display: flex; */
|
display: flex;
|
||||||
/* flex-direction: column; */
|
flex-direction: column;
|
||||||
/* overflow: hidden; */
|
|
||||||
overflow-y: auto;
|
|
||||||
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;
|
overflow: hidden;
|
||||||
margin: 8px 0;
|
max-width: 100%;
|
||||||
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,27 +76,19 @@
|
||||||
}
|
}
|
||||||
.account-container .header-banner:active {
|
.account-container .header-banner:active {
|
||||||
mask-image: none;
|
mask-image: none;
|
||||||
|
|
||||||
& + header {
|
|
||||||
background-image: none;
|
|
||||||
}
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar + * {
|
||||||
& + header .avatar-container + * {
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
transition: opacity 0.3s ease-in-out;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar {
|
||||||
&,
|
|
||||||
& + header .avatar-container {
|
|
||||||
transition: filter 0.3s ease-in-out;
|
transition: filter 0.3s ease-in-out;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
.account-container .header-banner:active + header .avatar img {
|
||||||
& + header .avatar img {
|
|
||||||
transition: border-radius 0.3s ease-in-out;
|
transition: border-radius 0.3s ease-in-out;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-height: 480px) {
|
@media (min-height: 480px) {
|
||||||
.account-container .header-banner:not(.header-is-avatar) {
|
.account-container .header-banner:not(.header-is-avatar) {
|
||||||
|
@ -250,22 +106,17 @@
|
||||||
-8px 0 24px var(--header-color-3, --bg-color),
|
-8px 0 24px var(--header-color-3, --bg-color),
|
||||||
8px 0 24px var(--header-color-4, --bg-color);
|
8px 0 24px var(--header-color-4, --bg-color);
|
||||||
animation: fade-in 0.3s both ease-in-out 0.1s;
|
animation: fade-in 0.3s both ease-in-out 0.1s;
|
||||||
|
}
|
||||||
.avatar-container {
|
.account-container header .avatar {
|
||||||
|
/* box-shadow: -8px 0 24px var(--header-color-3, --bg-color),
|
||||||
|
8px 0 24px var(--header-color-4, --bg-color); */
|
||||||
|
overflow: initial;
|
||||||
filter: drop-shadow(-2px 0 4px var(--header-color-3, --bg-color))
|
filter: drop-shadow(-2px 0 4px var(--header-color-3, --bg-color))
|
||||||
drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
|
drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
|
||||||
}
|
}
|
||||||
|
.account-container header .avatar:not(.has-alpha) img {
|
||||||
.avatar {
|
|
||||||
/* box-shadow: -8px 0 24px var(--header-color-3, --bg-color),
|
|
||||||
8px 0 24px var(--header-color-4, --bg-color); */
|
|
||||||
/* overflow: initial; */
|
|
||||||
|
|
||||||
&:not(.has-alpha) img {
|
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-container main > *:first-child {
|
.account-container main > *:first-child {
|
||||||
animation: fade-in 0.3s both ease-in-out 0.15s;
|
animation: fade-in 0.3s both ease-in-out 0.15s;
|
||||||
|
@ -274,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;
|
||||||
}
|
|
||||||
|
|
||||||
.private-note-tag {
|
|
||||||
z-index: 1;
|
|
||||||
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 .note:not(:has(p)):not(:empty) {
|
||||||
|
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||||
|
padding: 1em 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;
|
||||||
|
@ -415,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;
|
||||||
|
@ -466,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 {
|
||||||
|
@ -496,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% {
|
|
||||||
filter: blur(0) opacity(1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
filter: blur(16px) opacity(0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes surface-header {
|
|
||||||
0% {
|
0% {
|
||||||
border-bottom-color: transparent;
|
left: -100%;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
border-bottom-color: var(--outline-color);
|
left: 100%;
|
||||||
box-shadow: 0 8px 16px -8px var(--drop-shadow-color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes shrink-avatar {
|
.timeline-start .account-container {
|
||||||
0% {
|
position: relative;
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
width: 2.5em;
|
|
||||||
height: 2.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet .account-container {
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
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;
|
overflow: hidden;
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
}
|
}
|
||||||
}
|
.timeline-start .account-container:before {
|
||||||
|
content: '';
|
||||||
.faux-header-bg {
|
position: absolute;
|
||||||
display: block;
|
z-index: 2;
|
||||||
height: var(--banner-overlap);
|
width: 100%;
|
||||||
position: sticky;
|
height: 100%;
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
margin-top: calc(-1 * var(--banner-overlap));
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
|
|
||||||
.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(
|
background-image: linear-gradient(
|
||||||
to bottom,
|
100deg,
|
||||||
transparent 30%,
|
rgba(255, 255, 255, 0) 30%,
|
||||||
var(--bg-color) var(--banner-overlap)
|
rgba(255, 255, 255, 0.25),
|
||||||
|
rgba(255, 255, 255, 0) 70%
|
||||||
);
|
);
|
||||||
border-bottom: 1px solid transparent;
|
top: 0;
|
||||||
animation: surface-header 1s linear both;
|
left: -100%;
|
||||||
animation-timeline: --account-scroll;
|
pointer-events: none;
|
||||||
animation-range: 0 150px;
|
|
||||||
}
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
header .avatar {
|
.timeline-start .account-container:before {
|
||||||
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 {
|
|
||||||
display: flex;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
.timeline-start .account-container:hover:before {
|
||||||
|
animation: shine 1s ease-in-out 1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#list-add-remove-container .list-add-remove {
|
#list-add-remove-container .list-add-remove {
|
||||||
|
@ -732,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;
|
||||||
|
@ -749,157 +313,21 @@
|
||||||
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;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
}
|
||||||
.avatar-container {
|
.timeline-start .account-container header .account-block .avatar {
|
||||||
|
width: 112px !important;
|
||||||
|
height: 112px !important;
|
||||||
filter: drop-shadow(-8px 0 8px var(--header-color-3, --bg-color))
|
filter: drop-shadow(-8px 0 8px var(--header-color-3, --bg-color))
|
||||||
drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
|
drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 112px !important;
|
|
||||||
height: 112px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
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';
|
||||||
|
@ -12,29 +11,30 @@ 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}>
|
||||||
<Icon icon="x" alt={t`Close`} />
|
<Icon icon="x" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AccountInfo
|
<AccountInfo
|
||||||
|
@ -50,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,
|
||||||
|
@ -58,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 {
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
||||||
img {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
}
|
||||||
}
|
.avatar:not(.has-alpha).squircle {
|
||||||
&:not(.has-alpha).squircle {
|
|
||||||
border-radius: 25%;
|
border-radius: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.avatar img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background-color: var(--img-bg-color);
|
background-color: var(--img-bg-color);
|
||||||
contain: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-loaded],
|
.avatar[data-loaded],
|
||||||
&[data-loaded] img {
|
.avatar[data-loaded] img {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -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,17 +16,12 @@ 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;
|
|
||||||
|
|
||||||
const MISSING_IMAGE_PATH_REGEX = /missing\.png$/;
|
|
||||||
|
|
||||||
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;
|
||||||
const avatarRef = useRef();
|
const avatarRef = useRef();
|
||||||
const isMissing = MISSING_IMAGE_PATH_REGEX.test(url);
|
const isMissing = /missing\.png$/.test(url);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
ref={avatarRef}
|
ref={avatarRef}
|
||||||
|
@ -50,7 +43,6 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchPriority="low"
|
|
||||||
crossOrigin={
|
crossOrigin={
|
||||||
alphaCache[url] === undefined && !isMissing
|
alphaCache[url] === undefined && !isMissing
|
||||||
? 'anonymous'
|
? 'anonymous'
|
||||||
|
@ -66,7 +58,6 @@ 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;
|
||||||
|
@ -76,9 +67,8 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
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(
|
allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
|
||||||
(pixel, i) => i % 4 === 3 && pixel <= 128,
|
.length /
|
||||||
).length /
|
|
||||||
(allPixels.data.length / 4) >
|
(allPixels.data.length / 4) >
|
||||||
0.1;
|
0.1;
|
||||||
if (hasAlpha) {
|
if (hasAlpha) {
|
||||||
|
@ -91,7 +81,6 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
// Silent fail
|
// Silent fail
|
||||||
alphaCache[url] = false;
|
alphaCache[url] = false;
|
||||||
}
|
}
|
||||||
}, 1);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -99,4 +88,4 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default mem(Avatar);
|
export default Avatar;
|
||||||
|
|
|
@ -1,154 +0,0 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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);
|
|
||||||
const visibleTimeout = useRef();
|
|
||||||
usePageVisibility((visible) => {
|
|
||||||
clearTimeout(visibleTimeout.current);
|
|
||||||
if (visible) {
|
|
||||||
setVisible(true);
|
|
||||||
} else {
|
|
||||||
visibleTimeout.current = setTimeout(() => {
|
|
||||||
setVisible(false);
|
|
||||||
}, POLL_INTERVAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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: currentCloakMode ? t`Cloak mode disabled` : t`Cloak mode enabled`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -10,18 +9,15 @@ 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';
|
||||||
|
|
||||||
function Columns() {
|
function Columns() {
|
||||||
useTitle(t`Home`, '/');
|
useTitle('Home', '/');
|
||||||
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;
|
||||||
|
@ -35,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) => {
|
||||||
|
@ -56,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;
|
||||||
|
|
|
@ -1,52 +0,0 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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={t`Compose`} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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 />;
|
|
||||||
}
|
|
|
@ -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'] {
|
|
||||||
border-radius: 8px;
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
|
#compose-container button[type='submit'] {
|
||||||
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,464 +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;
|
|
||||||
}
|
}
|
||||||
|
#custom-emojis-sheet main {
|
||||||
form {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
mask-image: none;
|
mask-image: none;
|
||||||
min-height: 40vh;
|
|
||||||
padding-bottom: 88px;
|
|
||||||
}
|
}
|
||||||
|
#custom-emojis-sheet .custom-emojis-list .section-header {
|
||||||
.custom-emojis-matches {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-emojis-list {
|
|
||||||
.section-container {
|
|
||||||
position: relative;
|
|
||||||
content-visibility: auto;
|
|
||||||
content-intrinsic-size: auto 88px;
|
|
||||||
}
|
|
||||||
.section-header {
|
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-blur-color);
|
||||||
z-index: 1;
|
backdrop-filter: blur(1px);
|
||||||
display: inline-block;
|
|
||||||
padding-inline-end: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
border-end-end-radius: 8px;
|
|
||||||
}
|
}
|
||||||
section {
|
#custom-emojis-sheet .custom-emojis-list section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
button {
|
#custom-emojis-sheet .custom-emojis-list button {
|
||||||
color: var(--text-color);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
closest-side,
|
closest-side,
|
||||||
var(--img-bg-color),
|
var(--img-bg-color),
|
||||||
transparent
|
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 {
|
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
|
||||||
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;
|
filter: none;
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
|
|
||||||
&[data-title]:after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
}
|
#custom-emojis-sheet .custom-emojis-list button img {
|
||||||
|
|
||||||
img {
|
|
||||||
transition: transform 0.1s ease-out;
|
transition: transform 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
|
||||||
&:is(:hover, :focus) img {
|
transform: scale(1.5);
|
||||||
transform: scale(2);
|
|
||||||
}
|
|
||||||
&.edge-left img {
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
&.edge-right img {
|
|
||||||
transform-origin: right center;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compose-field-container {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gif-shake {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: rotate(5deg);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: rotate(-5deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gif-picker-button {
|
|
||||||
span {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 11.5px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:is(:hover, :focus) {
|
|
||||||
span {
|
|
||||||
animation: gif-shake 0.3s 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#gif-picker-sheet {
|
|
||||||
height: 50vh;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
input[type='search'] {
|
|
||||||
flex-grow: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +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"
|
|
||||||
fetchPriority="low"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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) {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import './drafts.css';
|
import './drafts.css';
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
@ -11,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();
|
||||||
|
@ -55,20 +53,17 @@ function Drafts({ onClose }) {
|
||||||
<div class="sheet">
|
<div class="sheet">
|
||||||
{!!onClose && (
|
{!!onClose && (
|
||||||
<button type="button" class="sheet-close" onClick={onClose}>
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
<Icon icon="x" alt={t`Close`} />
|
<Icon icon="x" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<header>
|
<header>
|
||||||
<h2>
|
<h2>
|
||||||
<Trans>Unsent drafts</Trans>{' '}
|
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
|
||||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
|
||||||
</h2>
|
</h2>
|
||||||
{hasDrafts && (
|
{hasDrafts && (
|
||||||
<div class="insignificant">
|
<div class="insignificant">
|
||||||
<Trans>
|
|
||||||
Looks like you have unsent drafts. Let's continue where you left
|
Looks like you have unsent drafts. Let's continue where you left
|
||||||
off.
|
off.
|
||||||
</Trans>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
@ -87,46 +82,33 @@ function Drafts({ onClose }) {
|
||||||
<time>
|
<time>
|
||||||
{!!replyTo && (
|
{!!replyTo && (
|
||||||
<>
|
<>
|
||||||
<span class="bidi-isolate">
|
|
||||||
@{replyTo.account.acct}
|
@{replyTo.account.acct}
|
||||||
</span>
|
|
||||||
<br />
|
<br />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{niceDateTime(updatedAtDate)}
|
{niceDateTime(updatedAtDate)}
|
||||||
</time>
|
</time>
|
||||||
</b>
|
</b>
|
||||||
<MenuConfirm
|
|
||||||
confirmLabel={
|
|
||||||
<span>
|
|
||||||
<Trans>Delete this draft?</Trans>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
menuItemClassName="danger"
|
|
||||||
align="end"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
onClick={() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// const yes = confirm('Delete this draft?');
|
|
||||||
// if (yes) {
|
|
||||||
await db.drafts.del(key);
|
|
||||||
reload();
|
|
||||||
// }
|
|
||||||
} catch (e) {
|
|
||||||
alert(t`Error deleting draft! Please try again.`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="small light"
|
class="small light"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const yes = confirm('Delete this draft?');
|
||||||
|
if (yes) {
|
||||||
|
await db.drafts.del(key);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error deleting draft! Please try again.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Delete…</Trans>
|
Delete…
|
||||||
</button>
|
</button>
|
||||||
</MenuConfirm>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -138,12 +120,12 @@ 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(t`Error fetching reply-to status!`);
|
alert('Error fetching reply-to status!');
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -163,20 +145,15 @@ function Drafts({ onClose }) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{drafts.length > 1 && (
|
|
||||||
<p>
|
<p>
|
||||||
<MenuConfirm
|
<button
|
||||||
confirmLabel={
|
type="button"
|
||||||
<span>
|
class="light danger"
|
||||||
<Trans>Delete all drafts?</Trans>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
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(
|
||||||
|
@ -186,28 +163,19 @@ function Drafts({ onClose }) {
|
||||||
reload();
|
reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert(t`Error deleting drafts! Please try again.`);
|
alert('Error deleting drafts! Please try again.');
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
}
|
}
|
||||||
// }
|
}
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
Delete all drafts…
|
||||||
type="button"
|
|
||||||
class="light danger"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
>
|
|
||||||
<Trans>Delete all…</Trans>
|
|
||||||
</button>
|
</button>
|
||||||
</MenuConfirm>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>No drafts found.</p>
|
||||||
<Trans>No drafts found.</Trans>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -242,10 +210,10 @@ function MiniDraft({ draft }) {
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasPoll && <Icon icon="poll" alt={t`Poll`} />}
|
{hasPoll && <Icon icon="poll" />}
|
||||||
{hasMedia && (
|
{hasMedia && (
|
||||||
<span>
|
<span>
|
||||||
<Icon icon="attachment" alt={t`Media`} />{' '}
|
<Icon icon="attachment" />{' '}
|
||||||
<small>{mediaAttachments?.length}</small>
|
<small>{mediaAttachments?.length}</small>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import './embed-modal.css';
|
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
|
|
||||||
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" alt={t`Close`} />
|
|
||||||
</button>
|
|
||||||
{url && (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="button plain"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Trans>Open in new window</Trans>
|
|
||||||
</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;
|
|
|
@ -1,45 +1,42 @@
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
|
|
||||||
import mem from '../utils/mem';
|
|
||||||
|
|
||||||
import CustomEmoji from './custom-emoji';
|
|
||||||
|
|
||||||
const shortcodesRegexp = mem((shortcodes) => {
|
|
||||||
return new RegExp(`:(${shortcodes.join('|')}):`, 'g');
|
|
||||||
});
|
|
||||||
|
|
||||||
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 regex = shortcodesRegexp(emojis.map((e) => e.shortcode));
|
emojis.forEach((shortcodeObj) => {
|
||||||
const elements = text.split(regex).map((word, i) => {
|
const { shortcode, staticUrl, url } = shortcodeObj;
|
||||||
const emoji = emojis.find((e) => e.shortcode === word);
|
const regex = new RegExp(`:${shortcode}:`, 'g');
|
||||||
if (emoji) {
|
let match;
|
||||||
const { url, staticUrl } = emoji;
|
|
||||||
return (
|
while ((match = regex.exec(text))) {
|
||||||
<CustomEmoji
|
const beforeText = text.substring(lastIndex, match.index);
|
||||||
staticUrl={staticUrl}
|
if (beforeText) {
|
||||||
alt={word}
|
components.push(beforeText);
|
||||||
url={url}
|
}
|
||||||
key={word + '-' + i} // Handle >= 2 same shortcodes
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default mem(EmojiText);
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
// export default memo(
|
export default EmojiText;
|
||||||
// EmojiText,
|
|
||||||
// (oldProps, newProps) =>
|
|
||||||
// oldProps.text === newProps.text &&
|
|
||||||
// oldProps.emojis?.length === newProps.emojis?.length,
|
|
||||||
// );
|
|
||||||
|
|
|
@ -1,62 +1,41 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
import { useState } from 'preact/hooks';
|
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');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Accept</Trans>
|
Accept
|
||||||
</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);
|
||||||
|
@ -65,23 +44,9 @@ function FollowRequestButtons({ accountID, onChange }) {
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Reject</Trans>
|
Reject
|
||||||
</button>
|
</button>
|
||||||
<span class="follow-request-states">
|
|
||||||
{hasRelationship && requestState ? (
|
|
||||||
requestState === 'accept' ? (
|
|
||||||
<Icon
|
|
||||||
icon="check-circle"
|
|
||||||
alt={t`Accepted`}
|
|
||||||
class="follow-accepted"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon icon="x-circle" alt={t`Rejected`} class="follow-rejected" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,235 +0,0 @@
|
||||||
import './generic-accounts.css';
|
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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 = t`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" alt={t`Close`} />
|
|
||||||
</button>
|
|
||||||
<header>
|
|
||||||
<h2>{heading || t`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()}
|
|
||||||
>
|
|
||||||
<Trans>Show more…</Trans>
|
|
||||||
</button>
|
|
||||||
</InView>
|
|
||||||
) : (
|
|
||||||
<p class="ui-state insignificant">
|
|
||||||
<Trans>The end.</Trans>
|
|
||||||
</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">
|
|
||||||
<Trans>Error loading accounts</Trans>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p class="ui-state insignificant">{blankCopy}</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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 />
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,196 +0,0 @@
|
||||||
import './keyboard-shortcuts-help.css';
|
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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" alt={t`Close`} />
|
|
||||||
</button>
|
|
||||||
<header>
|
|
||||||
<h2>
|
|
||||||
<Trans>Keyboard shortcuts</Trans>
|
|
||||||
</h2>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
action: t`Keyboard shortcuts help`,
|
|
||||||
keys: <kbd>?</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Next post`,
|
|
||||||
keys: <kbd>j</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Previous post`,
|
|
||||||
keys: <kbd>k</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Skip carousel to next post`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>j</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Skip carousel to previous post`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>k</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Load new posts`,
|
|
||||||
keys: <kbd>.</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Open post details`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Enter</kbd> or <kbd>o</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: (
|
|
||||||
<Trans>
|
|
||||||
Expand content warning or
|
|
||||||
<br />
|
|
||||||
toggle expanded/collapsed thread
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
keys: <kbd>x</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Close post or dialogs`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Focus column in multi-column mode`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>1</kbd> to <kbd>9</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Compose new post`,
|
|
||||||
keys: <kbd>c</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Compose new post (new window)`,
|
|
||||||
className: 'insignificant',
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>c</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Send post`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd>⌘</kbd> +{' '}
|
|
||||||
<kbd>Enter</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Search`,
|
|
||||||
keys: <kbd>/</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Reply`,
|
|
||||||
keys: <kbd>r</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Reply (new window)`,
|
|
||||||
className: 'insignificant',
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>r</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Like (favourite)`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>l</kbd> or <kbd>f</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Boost`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>b</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Bookmark`,
|
|
||||||
keys: <kbd>d</kbd>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: t`Toggle Cloak mode`,
|
|
||||||
keys: (
|
|
||||||
<Trans>
|
|
||||||
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
].map(({ action, className, keys }) => (
|
|
||||||
<tr key={action}>
|
|
||||||
<th class={className}>{action}</th>
|
|
||||||
<td>{keys}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
|
@ -1,115 +0,0 @@
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { useMemo } from 'preact/hooks';
|
|
||||||
|
|
||||||
import { CATALOGS, DEFAULT_LANG, DEV_LOCALES, LOCALES } from '../locales';
|
|
||||||
import { activateLang } from '../utils/lang';
|
|
||||||
import localeCode2Text from '../utils/localeCode2Text';
|
|
||||||
import store from '../utils/store';
|
|
||||||
|
|
||||||
const regionMaps = {
|
|
||||||
'zh-CN': 'zh-Hans',
|
|
||||||
'zh-TW': 'zh-Hant',
|
|
||||||
'pt-BR': 'pt-BR',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LangSelector() {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
// Sorted on render, so the order won't suddenly change based on current locale
|
|
||||||
const populatedLocales = useMemo(() => {
|
|
||||||
return LOCALES.map((lang) => {
|
|
||||||
// Don't need regions for now, it makes text too noisy
|
|
||||||
// Wait till there's too many languages and there are regional clashes
|
|
||||||
const regionlessCode = regionMaps[lang] || lang.replace(/-[a-z]+$/i, '');
|
|
||||||
|
|
||||||
const native = localeCode2Text({
|
|
||||||
code: regionlessCode,
|
|
||||||
locale: lang,
|
|
||||||
fallback: CATALOGS.find((c) => c.code === lang)?.nativeName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Not used when rendering because it'll change based on current locale
|
|
||||||
// Only used for sorting on render
|
|
||||||
const _common = localeCode2Text({
|
|
||||||
code: regionlessCode,
|
|
||||||
locale: i18n.locale,
|
|
||||||
fallback: CATALOGS.find((c) => c.code === lang)?.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: lang,
|
|
||||||
regionlessCode,
|
|
||||||
_common,
|
|
||||||
native,
|
|
||||||
};
|
|
||||||
}).sort((a, b) => {
|
|
||||||
// Sort by common name
|
|
||||||
const order = a._common.localeCompare(b._common, i18n.locale);
|
|
||||||
if (order !== 0) return order;
|
|
||||||
// Sort by code (fallback)
|
|
||||||
if (a.code < b.code) return -1;
|
|
||||||
if (a.code > b.code) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label class="lang-selector">
|
|
||||||
🌐{' '}
|
|
||||||
<select
|
|
||||||
class="small"
|
|
||||||
value={i18n.locale || DEFAULT_LANG}
|
|
||||||
onChange={(e) => {
|
|
||||||
store.local.set('lang', e.target.value);
|
|
||||||
activateLang(e.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{populatedLocales.map(({ code, regionlessCode, native }) => {
|
|
||||||
// Common name changes based on current locale
|
|
||||||
const common = localeCode2Text({
|
|
||||||
code: regionlessCode,
|
|
||||||
locale: i18n.locale,
|
|
||||||
fallback: CATALOGS.find((c) => c.code === code)?.name,
|
|
||||||
});
|
|
||||||
const showCommon = !!common && common !== native;
|
|
||||||
return (
|
|
||||||
<option
|
|
||||||
value={code}
|
|
||||||
data-regionless-code={regionlessCode}
|
|
||||||
key={code}
|
|
||||||
>
|
|
||||||
{showCommon ? `${native} - ${common}` : native}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(import.meta.env.DEV || import.meta.env.PHANPY_SHOW_DEV_LOCALES) && (
|
|
||||||
<optgroup label="🚧 Development (<50% translated)">
|
|
||||||
{DEV_LOCALES.map((code) => {
|
|
||||||
if (code === 'pseudo-LOCALE') {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<hr />
|
|
||||||
<option value={code} key={code}>
|
|
||||||
Pseudolocalization (test)
|
|
||||||
</option>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const nativeName = CATALOGS.find(
|
|
||||||
(c) => c.code === code,
|
|
||||||
)?.nativeName;
|
|
||||||
const completion = CATALOGS.find(
|
|
||||||
(c) => c.code === code,
|
|
||||||
)?.completion;
|
|
||||||
return (
|
|
||||||
<option value={code} key={code}>
|
|
||||||
{nativeName || code} ‎[{completion}%]
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,251 +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.link-block {
|
|
||||||
width: 240px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
/* 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: 340px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.byline {
|
|
||||||
transition-duration: 0.3s;
|
|
||||||
opacity: 0.75;
|
|
||||||
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%;
|
|
||||||
|
|
||||||
&.more-lines {
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.byline {
|
|
||||||
white-space: nowrap;
|
|
||||||
mask-image: linear-gradient(var(--to-backward), transparent, black 32px);
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 16px !important;
|
|
||||||
height: 16px !important;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,40 +1,30 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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 && (
|
||||||
<button type="button" class="sheet-close" onClick={onClose}>
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
<Icon icon="x" alt={t`Close`} />
|
<Icon icon="x" />
|
||||||
</button>
|
</button>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
<header>
|
<header>
|
||||||
<h2>{editMode ? t`Edit list` : t`New list`}</h2>
|
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<form
|
<form
|
||||||
|
@ -45,53 +35,39 @@ 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
|
editMode ? 'Unable to edit list.' : 'Unable to create list.',
|
||||||
? t`Unable to edit list.`
|
|
||||||
: t`Unable to create list.`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
@ -99,7 +75,7 @@ function ListAddEdit({ list, onClose }) {
|
||||||
>
|
>
|
||||||
<div class="list-form-row">
|
<div class="list-form-row">
|
||||||
<label for="list-title">
|
<label for="list-title">
|
||||||
<Trans>Name</Trans>{' '}
|
Name{' '}
|
||||||
<input
|
<input
|
||||||
ref={nameFieldRef}
|
ref={nameFieldRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -107,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>
|
||||||
|
@ -118,71 +93,42 @@ function ListAddEdit({ list, onClose }) {
|
||||||
required
|
required
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
>
|
>
|
||||||
<option value="list">
|
<option value="list">Show replies to list members</option>
|
||||||
<Trans>Show replies to list members</Trans>
|
<option value="followed">Show replies to people I follow</option>
|
||||||
</option>
|
<option value="none">Don't show replies</option>
|
||||||
<option value="followed">
|
|
||||||
<Trans>Show replies to people I follow</Trans>
|
|
||||||
</option>
|
|
||||||
<option value="none">
|
|
||||||
<Trans>Don't show replies</Trans>
|
|
||||||
</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'}
|
|
||||||
/>{' '}
|
|
||||||
<Trans>Hide posts on this list from Home/Following</Trans>
|
|
||||||
</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 ? t`Save` : t`Create`}
|
{editMode ? 'Save' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
{editMode && (
|
{editMode && (
|
||||||
<MenuConfirm
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
align="end"
|
|
||||||
menuItemClassName="danger"
|
|
||||||
confirmLabel={t`Delete this list?`}
|
|
||||||
onClick={() => {
|
|
||||||
// const yes = confirm('Delete this list?');
|
|
||||||
// if (!yes) return;
|
|
||||||
setUIState('loading');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await masto.v1.lists.$select(list.id).remove();
|
|
||||||
setUIState('default');
|
|
||||||
onClose?.({
|
|
||||||
state: 'deleted',
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
deleteListStore(list.id);
|
|
||||||
}, 1);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setUIState('error');
|
|
||||||
alert(t`Unable to delete list.`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="light danger"
|
class="light danger"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
const yes = confirm('Delete this list?');
|
||||||
|
if (!yes) return;
|
||||||
|
setUiState('loading');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.lists.remove(list.id);
|
||||||
|
setUiState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'deleted',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUiState('error');
|
||||||
|
alert('Unable to delete list.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Delete…</Trans>
|
Delete…
|
||||||
</button>
|
</button>
|
||||||
</MenuConfirm>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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" alt={t`Close`} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<header class="header-grid">
|
|
||||||
<h2>
|
|
||||||
<Trans>Media description</Trans>
|
|
||||||
</h2>
|
|
||||||
<div class="header-side">
|
|
||||||
<Menu2
|
|
||||||
align="end"
|
|
||||||
menuButton={
|
|
||||||
<button type="button" class="plain4">
|
|
||||||
<Icon icon="more" alt={t`More`} size="xl" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
disabled={forceTranslate}
|
|
||||||
onClick={() => {
|
|
||||||
setForceTranslate(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="translate" />
|
|
||||||
<span>
|
|
||||||
<Trans>Translate</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
{supportsTTS && (
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
speak(alt, lang);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="speak" />
|
|
||||||
<span>
|
|
||||||
<Trans>Speak</Trans>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,37 +1,22 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||||
import { MenuDivider, 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,9 +40,8 @@ function MediaModal({
|
||||||
const scrollLeft = index * carouselRef.current.clientWidth;
|
const scrollLeft = index * carouselRef.current.clientWidth;
|
||||||
const differentStatusID = prevStatusID.current !== statusID;
|
const differentStatusID = prevStatusID.current !== statusID;
|
||||||
if (differentStatusID) prevStatusID.current = statusID;
|
if (differentStatusID) prevStatusID.current = statusID;
|
||||||
carouselRef.current.focus();
|
|
||||||
carouselRef.current.scrollTo({
|
carouselRef.current.scrollTo({
|
||||||
left: scrollLeft * (isRTL() ? -1 : 1),
|
left: scrollLeft,
|
||||||
behavior: differentStatusID ? 'auto' : 'smooth',
|
behavior: differentStatusID ? 'auto' : 'smooth',
|
||||||
});
|
});
|
||||||
}, [index, statusID]);
|
}, [index, statusID]);
|
||||||
|
@ -78,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) {
|
||||||
|
@ -115,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 (
|
return (
|
||||||
mediaAccentColors
|
<div class="media-modal-container">
|
||||||
?.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 (
|
|
||||||
<div
|
|
||||||
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) => {
|
||||||
|
@ -175,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(
|
|
||||||
',',
|
',',
|
||||||
)}, 0.4)`,
|
)}, .5)`,
|
||||||
}
|
}}
|
||||||
: {}
|
|
||||||
}
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -217,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -241,10 +153,10 @@ function MediaModal({
|
||||||
<span>
|
<span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="carousel-button"
|
class="carousel-button plain3"
|
||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
>
|
>
|
||||||
<Icon icon="x" alt={t`Close`} />
|
<Icon icon="x" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
{mediaAttachments?.length > 1 ? (
|
{mediaAttachments?.length > 1 ? (
|
||||||
|
@ -254,17 +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();
|
||||||
const left =
|
carouselRef.current.scrollTo({
|
||||||
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1);
|
left: carouselRef.current.clientWidth * i,
|
||||||
carouselRef.current.scrollTo({ left, behavior: 'smooth' });
|
behavior: 'smooth',
|
||||||
carouselRef.current.focus();
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="round" size="s" alt="⸱" />
|
•
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
@ -272,15 +186,16 @@ 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={t`More`} />
|
<Icon icon="more" alt="More" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -289,66 +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={t`Open original media in new window`}
|
title="Open original media in new window"
|
||||||
>
|
>
|
||||||
<Icon icon="popout" />
|
<Icon icon="popout" />
|
||||||
<span>
|
<span>Open original media</span>
|
||||||
<Trans>Open original media</Trans>
|
|
||||||
</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: t`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(t`Failed to describe image`);
|
|
||||||
} finally {
|
|
||||||
setUIState('default');
|
|
||||||
toastRef.current?.hideToast?.();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="sparkles2" />
|
|
||||||
<span>
|
|
||||||
<Trans>Describe image…</Trans>
|
|
||||||
</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 (
|
||||||
|
@ -358,10 +228,7 @@ function MediaModal({
|
||||||
// }
|
// }
|
||||||
// }}
|
// }}
|
||||||
>
|
>
|
||||||
<span class="button-label">
|
<span class="button-label">See post </span>»
|
||||||
<Trans>View post</Trans>{' '}
|
|
||||||
</span>
|
|
||||||
»
|
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -369,44 +236,99 @@ 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',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="arrow-left" alt={t`Previous`} />
|
<Icon icon="arrow-left" />
|
||||||
</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',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="arrow-right" alt={t`Next`} />
|
<Icon icon="arrow-right" />
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,157 +0,0 @@
|
||||||
import './media-post.css';
|
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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']?.toLowerCase() || '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 ? t`Sensitive media` : undefined)
|
|
||||||
}
|
|
||||||
data-filtered-text={
|
|
||||||
filterInfo
|
|
||||||
? filterTitleStr
|
|
||||||
? t`Filtered: ${filterTitleStr}`
|
|
||||||
: t`Filtered`
|
|
||||||
: 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);
|
|
|
@ -1,7 +1,4 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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,
|
||||||
|
@ -11,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
|
||||||
|
|
||||||
|
@ -30,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={t`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,
|
||||||
|
@ -86,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
|
||||||
|
@ -121,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();
|
||||||
|
@ -147,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: {
|
||||||
|
@ -168,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);
|
||||||
|
@ -192,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';
|
||||||
|
@ -270,24 +134,17 @@ function Media({
|
||||||
}, [mediaURL]);
|
}, [mediaURL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Figure>
|
|
||||||
<Parent
|
<Parent
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
class={`media media-image ${className}`}
|
class={`media media-image`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-orientation={orientation}
|
|
||||||
data-has-alt={!showInlineDesc || undefined}
|
|
||||||
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
|
|
||||||
style={
|
style={
|
||||||
showOriginal
|
showOriginal && {
|
||||||
? {
|
|
||||||
backgroundImage: `url(${previewUrl})`,
|
backgroundImage: `url(${previewUrl})`,
|
||||||
backgroundSize: imageSmallerThanParent
|
backgroundSize: imageSmallerThanParent
|
||||||
? `${width}px ${height}px`
|
? `${width}px ${height}px`
|
||||||
: undefined,
|
: undefined,
|
||||||
...averageColorStyle,
|
|
||||||
}
|
}
|
||||||
: mediaStyles
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{showOriginal ? (
|
{showOriginal ? (
|
||||||
|
@ -308,101 +165,40 @@ function Media({
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
if (
|
if (src === mediaURL) {
|
||||||
src === mediaURL &&
|
|
||||||
remoteMediaURL &&
|
|
||||||
mediaURL !== remoteMediaURL
|
|
||||||
) {
|
|
||||||
e.target.src = remoteMediaURL;
|
e.target.src = remoteMediaURL;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</QuickPinchZoom>
|
</QuickPinchZoom>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<img
|
<img
|
||||||
src={mediaURL}
|
src={mediaURL}
|
||||||
alt={showInlineDesc ? '' : description}
|
alt={description}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
style={{
|
style={{
|
||||||
// backgroundColor:
|
backgroundColor:
|
||||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
// backgroundPosition: focalBackgroundPosition || 'center',
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
// Duration based on width or height in pixels
|
|
||||||
objectPosition: focalPosition || 'center',
|
|
||||||
// 100px per second (rough estimate)
|
|
||||||
// Clamp between 5s and 120s
|
|
||||||
'--anim-duration': `${Math.min(
|
|
||||||
Math.max(Math.max(width, height) / 100, 5),
|
|
||||||
120,
|
|
||||||
)}s`,
|
|
||||||
}}
|
}}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
// e.target.closest('.media-image').style.backgroundImage = '';
|
e.target.closest('.media-image').style.backgroundImage = '';
|
||||||
e.target.dataset.loaded = true;
|
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) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
if (src === mediaURL && mediaURL !== remoteMediaURL) {
|
if (src === mediaURL) {
|
||||||
e.target.src = remoteMediaURL;
|
e.target.src = remoteMediaURL;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!showInlineDesc && (
|
|
||||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Parent>
|
</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
|
||||||
|
@ -410,29 +206,6 @@ 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
|
||||||
|
@ -443,31 +216,25 @@ function Media({
|
||||||
data-orientation="${orientation}"
|
data-orientation="${orientation}"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
autoplay
|
autoplay
|
||||||
|
muted="${isGIF}"
|
||||||
|
${isGIF ? '' : 'controls'}
|
||||||
playsinline
|
playsinline
|
||||||
${loopable ? 'loop' : ''}
|
loop="${loopable}"
|
||||||
controls
|
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
|
||||||
></video>
|
></video>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Figure>
|
|
||||||
<Parent
|
<Parent
|
||||||
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${
|
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||||
autoGIFAnimate ? 'media-contain' : ''
|
autoGIFAnimate ? 'media-contain' : ''
|
||||||
} ${hoverAnimate ? 'media-hover-animate' : ''}`}
|
}`}
|
||||||
data-orientation={orientation}
|
data-formatted-duration={formattedDuration}
|
||||||
data-formatted-duration={
|
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||||
!showOriginal ? formattedDuration : undefined
|
|
||||||
}
|
|
||||||
data-label={
|
|
||||||
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined
|
|
||||||
}
|
|
||||||
data-has-alt={!showInlineDesc || undefined}
|
|
||||||
// style={{
|
// style={{
|
||||||
// backgroundColor:
|
// backgroundColor:
|
||||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
// }}
|
// }}
|
||||||
style={!showOriginal && mediaStyles}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (hoverAnimate) {
|
if (hoverAnimate) {
|
||||||
try {
|
try {
|
||||||
|
@ -490,20 +257,6 @@ function Media({
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
|
||||||
if (hoverAnimate) {
|
|
||||||
try {
|
|
||||||
videoRef.current.play();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (hoverAnimate) {
|
|
||||||
try {
|
|
||||||
videoRef.current.pause();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{showOriginal || autoGIFAnimate ? (
|
{showOriginal || autoGIFAnimate ? (
|
||||||
isGIF && showOriginal ? (
|
isGIF && showOriginal ? (
|
||||||
|
@ -511,21 +264,16 @@ function Media({
|
||||||
<div
|
<div
|
||||||
ref={mediaRef}
|
ref={mediaRef}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: gifHTML,
|
__html: videoHTML,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</QuickPinchZoom>
|
</QuickPinchZoom>
|
||||||
) : isGIF ? (
|
|
||||||
<div
|
|
||||||
class="video-container"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: gifHTML,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
class="video-container"
|
class="video-container"
|
||||||
dangerouslySetInnerHTML={{ __html: videoHTML }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: videoHTML,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : isGIF ? (
|
) : isGIF ? (
|
||||||
|
@ -541,151 +289,52 @@ function Media({
|
||||||
playsinline
|
playsinline
|
||||||
loop
|
loop
|
||||||
muted
|
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
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{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"
|
||||||
decoding="async"
|
|
||||||
onLoad={(e) => {
|
|
||||||
if (!hasDimensions) {
|
|
||||||
const $media = e.target.closest('.media');
|
|
||||||
if ($media) {
|
|
||||||
const { naturalHeight, naturalWidth } = 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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<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">
|
<div class="media-play">
|
||||||
<Icon icon="play" size="xl" alt="▶" />
|
<Icon icon="play" size="xxl" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!showOriginal && !showInlineDesc && (
|
|
||||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
|
||||||
)}
|
|
||||||
</Parent>
|
</Parent>
|
||||||
</Figure>
|
|
||||||
);
|
);
|
||||||
} else if (type === 'audio' || isAudioMaybe) {
|
} else if (type === 'audio') {
|
||||||
const formattedDuration = formatDuration(original.duration);
|
const formattedDuration = formatDuration(original.duration);
|
||||||
return (
|
return (
|
||||||
<Figure>
|
|
||||||
<Parent
|
<Parent
|
||||||
class={`media media-audio ${className}`}
|
class="media media-audio"
|
||||||
data-formatted-duration={
|
data-formatted-duration={formattedDuration}
|
||||||
!showOriginal ? formattedDuration : undefined
|
|
||||||
}
|
|
||||||
data-has-alt={!showInlineDesc || undefined}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={!showOriginal && mediaStyles}
|
|
||||||
>
|
>
|
||||||
{showOriginal ? (
|
{showOriginal ? (
|
||||||
<audio src={remoteUrl || url} preload="none" controls autoPlay />
|
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||||
) : previewUrl ? (
|
) : 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}
|
) : null}
|
||||||
{!showOriginal && (
|
{!showOriginal && (
|
||||||
<>
|
|
||||||
<div class="media-play">
|
<div class="media-play">
|
||||||
<Icon icon="play" size="xl" alt="▶" />
|
<Icon icon="play" size="xxl" />
|
||||||
</div>
|
</div>
|
||||||
{!showInlineDesc && (
|
|
||||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Parent>
|
</Parent>
|
||||||
</Figure>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
#modal-container > .light {
|
||||||
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
|
backdrop-filter: saturate(0.75);
|
||||||
--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)) {
|
|
||||||
#app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min {
|
|
||||||
border: 2px solid red;
|
|
||||||
|
|
||||||
--bottom: calc(
|
|
||||||
var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) +
|
|
||||||
52px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,247 +0,0 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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: t`Post published. Check it out.`,
|
|
||||||
reply: t`Reply posted. Check it out.`,
|
|
||||||
edit: t`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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -1,31 +1,10 @@
|
||||||
import './name-text.css';
|
import './name-text.css';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
|
||||||
import mem from '../utils/mem';
|
|
||||||
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 = mem((locale) => {
|
|
||||||
const options = {
|
|
||||||
sensitivity: 'base',
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
return new Intl.Collator(locale || undefined, options);
|
|
||||||
} catch (e) {
|
|
||||||
return new Intl.Collator(undefined, options);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ACCT_REGEX = /([^@]+)(@.+)/i;
|
|
||||||
const SHORTCODES_REGEX = /(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g;
|
|
||||||
const SPACES_REGEX = /\s+/g;
|
|
||||||
const NON_ALPHA_NUMERIC_REGEX = /[^a-z0-9@\.]/gi;
|
|
||||||
|
|
||||||
function NameText({
|
function NameText({
|
||||||
account,
|
account,
|
||||||
instance,
|
instance,
|
||||||
|
@ -35,64 +14,35 @@ function NameText({
|
||||||
external,
|
external,
|
||||||
onClick,
|
onClick,
|
||||||
}) {
|
}) {
|
||||||
const { i18n } = useLingui();
|
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
|
||||||
const {
|
account;
|
||||||
acct,
|
let { username } = account;
|
||||||
avatar,
|
|
||||||
avatarStatic,
|
|
||||||
id,
|
|
||||||
url,
|
|
||||||
displayName,
|
|
||||||
emojis,
|
|
||||||
bot,
|
|
||||||
username,
|
|
||||||
} = account;
|
|
||||||
const [_, acct1, acct2] = acct.match(ACCT_REGEX) || [, 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(SHORTCODES_REGEX, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
||||||
.replace(SPACES_REGEX, ''); // 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
|
||||||
NON_ALPHA_NUMERIC_REGEX,
|
|
||||||
'',
|
|
||||||
); // Remove non-alphanumeric characters
|
|
||||||
|
|
||||||
const hideUsername =
|
if (
|
||||||
(!short &&
|
!short &&
|
||||||
(trimmedUsername === trimmedDisplayName ||
|
(trimmedUsername === trimmedDisplayName ||
|
||||||
trimmedUsername === shortenedDisplayName ||
|
trimmedUsername === shortenedDisplayName)
|
||||||
trimmedUsername === shortenedAlphaNumericDisplayName ||
|
) {
|
||||||
nameCollator(i18n.locale).compare(
|
username = null;
|
||||||
trimmedUsername,
|
}
|
||||||
shortenedDisplayName,
|
|
||||||
) === 0)) ||
|
|
||||||
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,
|
||||||
|
@ -106,41 +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 mem(NameText);
|
export default NameText;
|
||||||
|
|
||||||
// export default memo(NameText, (oldProps, newProps) => {
|
|
||||||
// // Only care about account.id, the other props usually don't change
|
|
||||||
// const { account } = oldProps;
|
|
||||||
// const { account: newAccount } = newProps;
|
|
||||||
// return account?.acct === newAccount?.acct;
|
|
||||||
// });
|
|
||||||
|
|
|
@ -1,40 +1,12 @@
|
||||||
.nav-menu {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
section:last-child {
|
|
||||||
background-color: var(--bg-faded-color);
|
|
||||||
margin-bottom: -4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
|
|
||||||
.szh-menu__item:before {
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
.szh-menu__item > * {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 23em) {
|
@media (min-width: 23em) {
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 50% 50%;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
grid-template-areas:
|
|
||||||
'top top'
|
|
||||||
'left right';
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
/* min-width: 22em; */
|
width: 22em;
|
||||||
max-width: calc(100vw - 16px);
|
|
||||||
}
|
|
||||||
.nav-menu .top-menu {
|
|
||||||
grid-area: top;
|
|
||||||
padding-top: 4px;
|
|
||||||
margin-bottom: -4px;
|
|
||||||
}
|
}
|
||||||
.nav-menu section {
|
.nav-menu section {
|
||||||
padding: 4px 0;
|
padding: 8px 0;
|
||||||
/* width: 50%; */
|
width: 50%;
|
||||||
}
|
}
|
||||||
@keyframes phanpying {
|
@keyframes phanpying {
|
||||||
0% {
|
0% {
|
||||||
|
@ -45,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%; */
|
||||||
|
@ -64,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;
|
||||||
|
@ -89,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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,36 +1,33 @@
|
||||||
import './nav-menu.css';
|
import './nav-menu.css';
|
||||||
|
|
||||||
import { t, Trans } from '@lingui/macro';
|
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
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
|
||||||
|
@ -38,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(
|
||||||
|
@ -64,48 +60,16 @@ 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
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
class={`button plain nav-menu-button ${moreThanOneAccount ? 'with-avatar' : ''
|
class={`button plain nav-menu-button ${
|
||||||
} ${menuState === 'open' ? 'active' : ''}`}
|
moreThanOneAccount ? 'with-avatar' : ''
|
||||||
|
} ${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) => {
|
||||||
|
@ -123,7 +87,7 @@ function NavMenu(props) {
|
||||||
squircle={currentAccount?.info?.bot}
|
squircle={currentAccount?.info?.bot}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} alt={t`Menu`} />
|
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />
|
||||||
</button>
|
</button>
|
||||||
<ControlledMenu
|
<ControlledMenu
|
||||||
menuClassName="nav-menu"
|
menuClassName="nav-menu"
|
||||||
|
@ -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,12 +115,13 @@ function NavMenu(props) {
|
||||||
boundingBoxPadding={boundingBoxPadding}
|
boundingBoxPadding={boundingBoxPadding}
|
||||||
unmountOnClose
|
unmountOnClose
|
||||||
>
|
>
|
||||||
|
<section>
|
||||||
{!!snapStates.appVersion?.commitHash &&
|
{!!snapStates.appVersion?.commitHash &&
|
||||||
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
|
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
|
||||||
<div class="top-menu">
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const yes = confirm(t`Reload page now to update?`);
|
const yes = confirm('Reload page now to update?');
|
||||||
if (yes) {
|
if (yes) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -169,245 +131,78 @@ function NavMenu(props) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
|
<Icon icon="sparkles" size="l" />{' '}
|
||||||
<span>
|
<span>New update available…</span>
|
||||||
<Trans>New update available…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
<section>
|
|
||||||
<MenuLink to="/">
|
<MenuLink to="/">
|
||||||
<Icon icon="home" size="l" />{' '}
|
<Icon icon="home" size="l" /> <span>Home</span>
|
||||||
<span>
|
|
||||||
<Trans>Home</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
{authenticated ? (
|
{authenticated && (
|
||||||
<>
|
<>
|
||||||
{showFollowing && (
|
{showFollowing && (
|
||||||
<MenuLink to="/following">
|
<MenuLink to="/following">
|
||||||
<Icon icon="following" size="l" />{' '}
|
<Icon icon="following" size="l" /> <span>Following</span>
|
||||||
<span>
|
|
||||||
<Trans>Following</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
)}
|
)}
|
||||||
<MenuLink to="/catchup">
|
|
||||||
<Icon icon="history2" size="l" />
|
|
||||||
<span>
|
|
||||||
<Trans>Catch-up</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
{supports('@mastodon/mentions') && (
|
|
||||||
<MenuLink to="/mentions">
|
<MenuLink to="/mentions">
|
||||||
<Icon icon="at" size="l" />{' '}
|
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||||
<span>
|
|
||||||
<Trans>Mentions</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
)}
|
|
||||||
<MenuLink to="/notifications">
|
<MenuLink to="/notifications">
|
||||||
<Icon icon="notification" size="l" />{' '}
|
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
||||||
<span>
|
|
||||||
<Trans>Notifications</Trans>
|
|
||||||
</span>
|
|
||||||
{snapStates.notificationsShowNew && (
|
{snapStates.notificationsShowNew && (
|
||||||
<sup title={t`New`} style={{ opacity: 0.5 }}>
|
<sup title="New" style={{ opacity: 0.5 }}>
|
||||||
{' '}
|
{' '}
|
||||||
•
|
•
|
||||||
</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" />{' '}
|
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||||
<span>
|
|
||||||
<Trans>Profile</Trans>
|
|
||||||
</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…</span></a>
|
|
||||||
</FocusableItem>}
|
|
||||||
{lists?.length > 0 ? (
|
|
||||||
<SubMenu2
|
|
||||||
menuClassName="nav-submenu"
|
|
||||||
overflow="auto"
|
|
||||||
gap={-8}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<Icon icon="list" size="l" />
|
|
||||||
<span class="menu-grow">
|
|
||||||
<Trans>Lists</Trans>
|
|
||||||
</span>
|
|
||||||
<Icon icon="chevron-right" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuLink to="/l">
|
|
||||||
<span>
|
|
||||||
<Trans>All Lists</Trans>
|
|
||||||
</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>
|
|
||||||
<Trans>Lists</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<MenuLink to="/b">
|
|
||||||
<Icon icon="bookmark" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Bookmarks</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<SubMenu2
|
|
||||||
menuClassName="nav-submenu"
|
|
||||||
overflow="auto"
|
|
||||||
gap={-8}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<Icon icon="more" size="l" />
|
|
||||||
<span class="menu-grow">
|
|
||||||
<Trans>More…</Trans>
|
|
||||||
</span>
|
|
||||||
<Icon icon="chevron-right" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuLink to="/f">
|
|
||||||
<Icon icon="heart" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Likes</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<MenuLink to="/fh">
|
|
||||||
<Icon icon="hashtag" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Followed Hashtags</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<MenuDivider />
|
|
||||||
{supports('@mastodon/filters') && (
|
|
||||||
<MenuLink to="/ft">
|
|
||||||
<Icon icon="filters" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Filters</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
states.showGenericAccounts = {
|
|
||||||
id: 'mute',
|
|
||||||
heading: t`Muted users`,
|
|
||||||
fetchAccounts: fetchMutes,
|
|
||||||
excludeRelationshipAttrs: ['muting'],
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="mute" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Muted users…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
states.showGenericAccounts = {
|
|
||||||
id: 'block',
|
|
||||||
heading: t`Blocked users`,
|
|
||||||
fetchAccounts: fetchBlocks,
|
|
||||||
excludeRelationshipAttrs: ['blocking'],
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="block" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Blocked users…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>{' '}
|
|
||||||
</SubMenu2>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showAccounts = true;
|
states.showAccounts = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="group" size="l" />{' '}
|
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||||
<span>
|
|
||||||
<Trans>Accounts…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuLink to="/login">
|
|
||||||
<Icon icon="user" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Log in</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<MenuDivider />
|
|
||||||
<MenuLink to={`/search`}>
|
|
||||||
<Icon icon="search" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Search</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<MenuLink to={`/${instance}/trending`}>
|
|
||||||
<Icon icon="chart" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Trending</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<MenuLink to={`/${instance}/p/l`}>
|
|
||||||
<Icon icon="building" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Local</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
<MenuLink to={`/${instance}/p`}>
|
|
||||||
<Icon icon="earth" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Federated</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuLink>
|
|
||||||
{authenticated ? (
|
|
||||||
<>
|
|
||||||
<MenuDivider className="divider-grow" />
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
states.showKeyboardShortcutsHelp = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="keyboard" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Keyboard shortcuts</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -415,34 +210,22 @@ function NavMenu(props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="shortcut" size="l" />{' '}
|
<Icon icon="shortcut" size="l" />{' '}
|
||||||
<span>
|
<span>Shortcuts Settings…</span>
|
||||||
<Trans>Shortcuts / Columns…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showSettings = true;
|
states.showSettings = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="gear" size="l" />{' '}
|
<Icon icon="gear" size="l" /> <span>Settings…</span>
|
||||||
<span>
|
|
||||||
<Trans>Settings…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItem
|
<MenuLink to="/login">
|
||||||
onClick={() => {
|
<Icon icon="user" size="l" /> <span>Log in</span>
|
||||||
states.showSettings = true;
|
</MenuLink>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="gear" size="l" />{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>Settings…</Trans>
|
|
||||||
</span>
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
@ -451,4 +234,4 @@ function NavMenu(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(NavMenu);
|
export default NavMenu;
|
||||||
|
|
|
@ -1,207 +0,0 @@
|
||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
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" alt={t`Close`} />
|
|
||||||
</button>
|
|
||||||
<header>
|
|
||||||
<b>
|
|
||||||
<Trans>Notification</Trans>
|
|
||||||
</b>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
{!sameInstance && (
|
|
||||||
<p>
|
|
||||||
<Trans>This notification is from your other account.</Trans>
|
|
||||||
</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>
|
|
||||||
<Trans>View all notifications</Trans>
|
|
||||||
</span>{' '}
|
|
||||||
<Icon icon="arrow-right" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
|
@ -1,21 +1,12 @@
|
||||||
import { msg, Plural, Select, t, Trans } from '@lingui/macro';
|
import states from '../utils/states';
|
||||||
import { useLingui } from '@lingui/react';
|
import store from '../utils/store';
|
||||||
import { Fragment } from 'preact';
|
|
||||||
import { memo } from 'preact/compat';
|
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
|
||||||
import { isFiltered } from '../utils/filters';
|
|
||||||
import shortenNumber from '../utils/shorten-number';
|
|
||||||
import states, { statusKey } from '../utils/states';
|
|
||||||
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';
|
||||||
import NameText from './name-text';
|
import NameText from './name-text';
|
||||||
|
import RelativeTime from './relative-time';
|
||||||
import Status from './status';
|
import Status from './status';
|
||||||
|
|
||||||
const NOTIFICATION_ICONS = {
|
const NOTIFICATION_ICONS = {
|
||||||
|
@ -27,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',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -48,303 +33,36 @@ 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({ account, emoji, emoji_url }) {
|
|
||||||
let url;
|
|
||||||
let staticUrl;
|
|
||||||
if (typeof emoji_url === 'string') {
|
|
||||||
url = emoji_url;
|
|
||||||
} else {
|
|
||||||
url = emoji_url?.url;
|
|
||||||
staticUrl = emoji_url?.staticUrl;
|
|
||||||
}
|
|
||||||
const emojiObject = url ? (
|
|
||||||
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
|
||||||
) : (
|
|
||||||
emoji
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Trans>
|
|
||||||
{account} reacted to your post with {emojiObject}
|
|
||||||
</Trans>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentText = {
|
const contentText = {
|
||||||
status: ({ account }) => <Trans>{account} published a post.</Trans>,
|
mention: 'mentioned you in their post.',
|
||||||
reblog: ({
|
status: 'published a post.',
|
||||||
count,
|
reblog: 'boosted your post.',
|
||||||
account,
|
follow: 'followed you.',
|
||||||
postsCount,
|
follow_request: 'requested to follow you.',
|
||||||
postType,
|
favourite: 'favourited your post.',
|
||||||
components: { Subject },
|
poll: 'A poll you have voted in or created has ended.',
|
||||||
}) => (
|
'poll-self': 'A poll you have created has ended.',
|
||||||
<Plural
|
'poll-voted': 'A poll you have voted in has ended.',
|
||||||
value={count}
|
update: 'A post you interacted with has been edited.',
|
||||||
_1={
|
'favourite+reblog': 'boosted & favourited your post.',
|
||||||
<Plural
|
|
||||||
value={postsCount}
|
|
||||||
_1={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={<Trans>{account} boosted your reply.</Trans>}
|
|
||||||
other={<Trans>{account} boosted your post.</Trans>}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
{account} boosted {postsCount} of your posts.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
boosted your reply.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
boosted your post.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
follow: ({ account, count, components: { Subject } }) => (
|
|
||||||
<Plural
|
|
||||||
value={count}
|
|
||||||
_1={<Trans>{account} followed you.</Trans>}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
followed you.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
follow_request: ({ account }) => (
|
|
||||||
<Trans>{account} requested to follow you.</Trans>
|
|
||||||
),
|
|
||||||
favourite: ({
|
|
||||||
account,
|
|
||||||
count,
|
|
||||||
postsCount,
|
|
||||||
postType,
|
|
||||||
components: { Subject },
|
|
||||||
}) => (
|
|
||||||
<Plural
|
|
||||||
value={count}
|
|
||||||
_1={
|
|
||||||
<Plural
|
|
||||||
value={postsCount}
|
|
||||||
_1={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={<Trans>{account} liked your reply.</Trans>}
|
|
||||||
other={<Trans>{account} liked your post.</Trans>}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
{account} liked {postsCount} of your posts.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
liked your reply.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
liked your post.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
poll: () => t`A poll you have voted in or created has ended.`,
|
|
||||||
'poll-self': () => t`A poll you have created has ended.`,
|
|
||||||
'poll-voted': () => t`A poll you have voted in has ended.`,
|
|
||||||
update: () => t`A post you interacted with has been edited.`,
|
|
||||||
'favourite+reblog': ({
|
|
||||||
count,
|
|
||||||
account,
|
|
||||||
postsCount,
|
|
||||||
postType,
|
|
||||||
components: { Subject },
|
|
||||||
}) => (
|
|
||||||
<Plural
|
|
||||||
value={count}
|
|
||||||
_1={
|
|
||||||
<Plural
|
|
||||||
value={postsCount}
|
|
||||||
_1={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={<Trans>{account} boosted & liked your reply.</Trans>}
|
|
||||||
other={<Trans>{account} boosted & liked your post.</Trans>}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
{account} boosted & liked {postsCount} of your posts.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Select
|
|
||||||
value={postType}
|
|
||||||
_reply={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
boosted & liked your reply.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<Subject clickable={count > 1}>
|
|
||||||
<span title={count}>{shortenNumber(count)}</span> people
|
|
||||||
</Subject>{' '}
|
|
||||||
boosted & liked your post.
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
'admin.sign_up': ({ account }) => <Trans>{account} signed up.</Trans>,
|
|
||||||
'admin.report': ({ account, targetAccount }) => (
|
|
||||||
<Trans>
|
|
||||||
{account} reported {targetAccount}
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
severed_relationships: ({ name }) => (
|
|
||||||
<Trans>
|
|
||||||
Lost connections with <i>{name}</i>.
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
moderation_warning: () => (
|
|
||||||
<b>
|
|
||||||
<Trans>Moderation warning</Trans>
|
|
||||||
</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 }) => (
|
|
||||||
<Trans>
|
|
||||||
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.
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
domain_block: ({ from, targetName, followersCount, followingCount }) => (
|
|
||||||
<Trans>
|
|
||||||
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
|
|
||||||
followers: {followersCount}, followings: {followingCount}.
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
user_domain_block: ({ targetName, followersCount, followingCount }) => (
|
|
||||||
<Trans>
|
|
||||||
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
|
|
||||||
followings: {followingCount}.
|
|
||||||
</Trans>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MODERATION_WARNING_TEXT = {
|
|
||||||
none: msg`Your account has received a moderation warning.`,
|
|
||||||
disable: msg`Your account has been disabled.`,
|
|
||||||
mark_statuses_as_sensitive: msg`Some of your posts have been marked as sensitive.`,
|
|
||||||
delete_statuses: msg`Some of your posts have been deleted.`,
|
|
||||||
sensitive: msg`Your posts will be marked as sensitive from now on.`,
|
|
||||||
silence: msg`Your account has been limited.`,
|
|
||||||
suspend: msg`Your account has been suspended.`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AVATARS_LIMIT = 30;
|
|
||||||
|
|
||||||
function Notification({
|
|
||||||
notification,
|
|
||||||
instance,
|
|
||||||
isStatic,
|
|
||||||
disableContextMenu,
|
|
||||||
}) {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { masto } = api();
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
status,
|
|
||||||
account,
|
|
||||||
report,
|
|
||||||
event,
|
|
||||||
moderation_warning,
|
|
||||||
// Client-side grouped notification
|
|
||||||
_ids,
|
|
||||||
_accounts,
|
|
||||||
_statuses,
|
|
||||||
_groupKeys,
|
|
||||||
// Server-side grouped notification
|
|
||||||
sampleAccounts,
|
|
||||||
notificationsCount,
|
|
||||||
groupKey,
|
|
||||||
} = notification;
|
|
||||||
let { type } = notification;
|
let { type } = notification;
|
||||||
|
|
||||||
if (type === 'mention' && !status) {
|
|
||||||
// Could be deleted
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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++;
|
||||||
|
@ -353,125 +71,25 @@ function Notification({
|
||||||
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]) {
|
: 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 = t`[Unknown notification type: ${type}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Subject = ({ clickable, ...props }) =>
|
if (type === 'mention' && !status) {
|
||||||
clickable ? (
|
// Could be deleted
|
||||||
<b tabIndex="0" onClick={handleOpenGenericAccounts} {...props} />
|
|
||||||
) : (
|
|
||||||
<b {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof text === 'function') {
|
|
||||||
const count =
|
|
||||||
_accounts?.length || sampleAccounts?.length || (account ? 1 : 0);
|
|
||||||
const postsCount = _statuses?.length || (status ? 1 : 0);
|
|
||||||
if (type === 'admin.report') {
|
|
||||||
const targetAccount = report?.targetAccount;
|
|
||||||
if (targetAccount) {
|
|
||||||
text = text({
|
|
||||||
account: <NameText account={account} showAvatar />,
|
|
||||||
targetAccount: <NameText account={targetAccount} showAvatar />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (type === 'severed_relationships') {
|
|
||||||
const targetName = event?.targetName;
|
|
||||||
if (targetName) {
|
|
||||||
text = text({ name: 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({
|
|
||||||
account: <NameText account={account} showAvatar />,
|
|
||||||
emoji: notification.emoji,
|
|
||||||
emojiURL,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
text = text({
|
|
||||||
account: account ? (
|
|
||||||
<NameText account={account} showAvatar />
|
|
||||||
) : (
|
|
||||||
sampleAccounts?.[0] && (
|
|
||||||
<NameText account={sampleAccounts[0]} showAvatar />
|
|
||||||
)
|
|
||||||
),
|
|
||||||
count,
|
|
||||||
postsCount,
|
|
||||||
postType: isReplyToOthers ? 'reply' : 'post',
|
|
||||||
components: { Subject },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedCreatedAt =
|
|
||||||
notification.createdAt && new Date(notification.createdAt).toLocaleString();
|
|
||||||
|
|
||||||
const genericAccountsHeading =
|
|
||||||
{
|
|
||||||
'favourite+reblog': t`Boosted/Liked by…`,
|
|
||||||
favourite: t`Liked by…`,
|
|
||||||
reblog: t`Boosted by…`,
|
|
||||||
follow: t`Followed by…`,
|
|
||||||
}[type] || t`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);
|
|
||||||
|
|
||||||
const diffCount =
|
|
||||||
notificationsCount > 0 && notificationsCount > sampleAccounts?.length;
|
|
||||||
const expandAccounts = diffCount ? 'remote' : 'local';
|
|
||||||
|
|
||||||
// If there's a status and filter action is 'hide', then the notification is hidden
|
|
||||||
// TODO: Handle 'warn' action one day
|
|
||||||
if (!!status?.filtered) {
|
|
||||||
const isOwnPost = status?.account?.id === currentAccount;
|
|
||||||
const filterInfo = isFiltered(status.filtered, 'notifications');
|
|
||||||
if (!isSelf && !isOwnPost && filterInfo?.action === 'hide') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={`notification notification-${type}`} tabIndex="0">
|
||||||
class={`notification notification-${type}`}
|
|
||||||
data-notification-id={_ids || id}
|
|
||||||
data-group-key={_groupKeys?.join(' ') || groupKey}
|
|
||||||
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' ? (
|
||||||
<>
|
<>
|
||||||
|
@ -489,52 +107,47 @@ function Notification({
|
||||||
<div class="notification-content">
|
<div class="notification-content">
|
||||||
{type !== 'mention' && (
|
{type !== 'mention' && (
|
||||||
<>
|
<>
|
||||||
<p>{text}</p>
|
<p>
|
||||||
|
{!/poll|update/i.test(type) && (
|
||||||
|
<>
|
||||||
|
{_accounts?.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<b>{_accounts.length} people</b>{' '}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NameText account={account} showAvatar />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{text}
|
||||||
|
{type === 'mention' && (
|
||||||
|
<span class="insignificant">
|
||||||
|
{' '}
|
||||||
|
•{' '}
|
||||||
|
<RelativeTime
|
||||||
|
datetime={notification.createdAt}
|
||||||
|
format="micro"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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"
|
|
||||||
>
|
|
||||||
<Trans>
|
|
||||||
Learn more <Icon icon="external" size="s" />
|
|
||||||
</Trans>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Trans>
|
|
||||||
Learn more <Icon icon="external" size="s" />
|
|
||||||
</Trans>
|
|
||||||
</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"
|
||||||
|
@ -548,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}`}
|
||||||
|
@ -568,194 +185,25 @@ function Notification({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
</Fragment>
|
</>
|
||||||
))}
|
))}
|
||||||
{type === 'favourite+reblog' && expandAccounts === 'remote' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="small plain"
|
|
||||||
data-group-keys={_groupKeys?.join(' ')}
|
|
||||||
onClick={() => {
|
|
||||||
states.showGenericAccounts = {
|
|
||||||
heading: genericAccountsHeading,
|
|
||||||
fetchAccounts: async () => {
|
|
||||||
const keyAccounts = await Promise.allSettled(
|
|
||||||
_groupKeys.map(async (gKey) => {
|
|
||||||
const iterator = masto.v2.notifications
|
|
||||||
.$select(gKey)
|
|
||||||
.accounts.list();
|
|
||||||
return [gKey, (await iterator.next()).value];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const accounts = [];
|
|
||||||
for (const keyAccount of keyAccounts) {
|
|
||||||
const [key, _accounts] = keyAccount.value;
|
|
||||||
const type = /^favourite/.test(key)
|
|
||||||
? 'favourite'
|
|
||||||
: /^reblog/.test(key)
|
|
||||||
? 'reblog'
|
|
||||||
: null;
|
|
||||||
if (!type) continue;
|
|
||||||
for (const account of _accounts) {
|
|
||||||
const theAccount = accounts.find(
|
|
||||||
(a) => a.id === account.id,
|
|
||||||
);
|
|
||||||
if (theAccount) {
|
|
||||||
theAccount._types.push(type);
|
|
||||||
} else {
|
|
||||||
account._types = [type];
|
|
||||||
accounts.push(account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
done: true,
|
|
||||||
value: accounts,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
showReactions: true,
|
|
||||||
postID: statusKey(actualStatusID, instance),
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="chevron-down" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
{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
|
<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={t`Read more →`} ref={ref} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(Notification, (oldProps, newProps) => {
|
|
||||||
return oldProps.notification?.id === newProps.notification?.id;
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Plural, plural, t, Trans } from '@lingui/macro';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useState } from 'preact/hooks';
|
|
||||||
|
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
|
||||||
|
@ -49,7 +48,7 @@ export default function Poll({
|
||||||
// };
|
// };
|
||||||
// }, [expired, expiresAtDate]);
|
// }, [expired, expiresAtDate]);
|
||||||
|
|
||||||
const pollVotesCount = multiple ? votersCount : votesCount;
|
const pollVotesCount = votersCount || votesCount;
|
||||||
let roundPrecision = 0;
|
let roundPrecision = 0;
|
||||||
|
|
||||||
if (pollVotesCount <= 1000) {
|
if (pollVotesCount <= 1000) {
|
||||||
|
@ -63,28 +62,40 @@ 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 ? (
|
||||||
<>
|
<>
|
||||||
<div class="poll-options">
|
<div class="poll-options">
|
||||||
{options.map((option, i) => {
|
{options.map((option, i) => {
|
||||||
const { title, votesCount: optionVotesCount } = option;
|
const { title, votesCount: optionVotesCount } = option;
|
||||||
const ratio = pollVotesCount
|
const percentage = pollVotesCount
|
||||||
? optionVotesCount / pollVotesCount
|
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
|
||||||
: 0;
|
roundPrecision,
|
||||||
const percentage = ratio
|
)
|
||||||
? ratio.toLocaleString(i18n.locale || undefined, {
|
: 0; // check if current poll choice is the leading one
|
||||||
style: 'percent',
|
|
||||||
maximumFractionDigits: roundPrecision,
|
|
||||||
})
|
|
||||||
: '0%';
|
|
||||||
|
|
||||||
const isLeading =
|
const isLeading =
|
||||||
optionVotesCount > 0 &&
|
optionVotesCount > 0 &&
|
||||||
|
@ -97,7 +108,7 @@ export default function Poll({
|
||||||
isLeading ? 'poll-option-leading' : ''
|
isLeading ? 'poll-option-leading' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
'--percentage': `${ratio * 100}%`,
|
'--percentage': `${percentage}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="poll-option-title">
|
<div class="poll-option-title">
|
||||||
|
@ -107,18 +118,17 @@ export default function Poll({
|
||||||
{voted && ownVotes.includes(i) && (
|
{voted && ownVotes.includes(i) && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
<Icon icon="check-circle" alt={t`Voted`} />
|
<Icon icon="check-circle" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="poll-option-votes"
|
class="poll-option-votes"
|
||||||
title={plural(optionVotesCount, {
|
title={`${optionVotesCount} vote${
|
||||||
one: `# vote`,
|
optionVotesCount === 1 ? '' : 's'
|
||||||
other: `# votes`,
|
}`}
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{percentage}
|
{percentage}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -128,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" /> <Trans>Hide results</Trans>
|
<Icon icon="arrow-left" /> Hide results
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -182,16 +191,18 @@ export default function Poll({
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
>
|
>
|
||||||
<Trans>Vote</Trans>
|
Vote
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
<p class="poll-meta">
|
<p class="poll-meta">
|
||||||
{!expired && !readOnly && (
|
{!expired && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plain small"
|
class="textual"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -202,80 +213,27 @@ export default function Poll({
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
title={t`Refresh`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="refresh" alt={t`Refresh`} />
|
Refresh
|
||||||
</button>
|
</button>{' '}
|
||||||
|
•{' '}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!voted && !expired && !readOnly && optionsHaveVoteCounts && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="plain small"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowResults(!showResults);
|
|
||||||
}}
|
|
||||||
title={showResults ? t`Hide results` : t`Show results`}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={showResults ? 'eye-open' : 'eye-close'}
|
|
||||||
alt={showResults ? t`Hide results` : t`Show results`}
|
|
||||||
/>{' '}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!expired && !readOnly && ' '}
|
|
||||||
<Plural
|
|
||||||
value={votesCount}
|
|
||||||
one={
|
|
||||||
<Trans>
|
|
||||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||||
</Trans>
|
{votesCount === 1 ? '' : 's'}
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> votes
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!!votersCount && votersCount !== votesCount && (
|
{!!votersCount && votersCount !== votesCount && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
•{' '}
|
•{' '}
|
||||||
<Plural
|
|
||||||
value={votersCount}
|
|
||||||
one={
|
|
||||||
<Trans>
|
|
||||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||||
voter
|
voter
|
||||||
</Trans>
|
{votersCount === 1 ? '' : 's'}
|
||||||
}
|
|
||||||
other={
|
|
||||||
<Trans>
|
|
||||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
|
||||||
voters
|
|
||||||
</Trans>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
•{' '}
|
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||||
{expired ? (
|
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
||||||
!!expiresAtDate ? (
|
|
||||||
<Trans>
|
|
||||||
Ended <RelativeTime datetime={expiresAtDate} />
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
t`Ended`
|
|
||||||
)
|
|
||||||
) : !!expiresAtDate ? (
|
|
||||||
<Trans>
|
|
||||||
Ending <RelativeTime datetime={expiresAtDate} />
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
t`Ending`
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,138 +1,57 @@
|
||||||
import { i18n } from '@lingui/core';
|
// Twitter-style relative time component
|
||||||
import { t, Trans } from '@lingui/macro';
|
// Seconds = 1s
|
||||||
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
// Minutes = 1m
|
||||||
|
// Hours = 1h
|
||||||
|
// Days = 1d
|
||||||
|
// After 7 days, use DD/MM/YYYY or MM/DD/YYYY
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import dayjsTwitter from 'dayjs-twitter';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import localeMatch from '../utils/locale-match';
|
dayjs.extend(dayjsTwitter);
|
||||||
import mem from '../utils/mem';
|
dayjs.extend(localizedFormat);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
function isValidDate(value) {
|
const dtf = new Intl.DateTimeFormat();
|
||||||
if (value instanceof Date) {
|
|
||||||
return !isNaN(value.getTime());
|
|
||||||
} else {
|
|
||||||
const date = new Date(value);
|
|
||||||
return !isNaN(date.getTime());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedLocale = mem(
|
|
||||||
() => new Intl.DateTimeFormat().resolvedOptions().locale,
|
|
||||||
);
|
|
||||||
const DTF = mem((locale, opts = {}) => {
|
|
||||||
const regionlessLocale = locale.replace(/-[a-z]+$/i, '');
|
|
||||||
const lang = localeMatch([regionlessLocale], [resolvedLocale()], locale);
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat(lang, opts);
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat(locale, opts);
|
|
||||||
} catch (e) {}
|
|
||||||
return new Intl.DateTimeFormat(undefined, opts);
|
|
||||||
});
|
|
||||||
const RTF = mem((locale) => new Intl.RelativeTimeFormat(locale || undefined));
|
|
||||||
|
|
||||||
const minute = 60;
|
|
||||||
const hour = 60 * minute;
|
|
||||||
const day = 24 * hour;
|
|
||||||
|
|
||||||
const rtfFromNow = (date) => {
|
|
||||||
// date = Date object
|
|
||||||
const rtf = RTF(i18n.locale);
|
|
||||||
const seconds = (date.getTime() - Date.now()) / 1000;
|
|
||||||
const absSeconds = Math.abs(seconds);
|
|
||||||
if (absSeconds < minute) {
|
|
||||||
return rtf.format(seconds, 'second');
|
|
||||||
} else if (absSeconds < hour) {
|
|
||||||
return rtf.format(Math.floor(seconds / minute), 'minute');
|
|
||||||
} else if (absSeconds < day) {
|
|
||||||
return rtf.format(Math.floor(seconds / hour), 'hour');
|
|
||||||
} else {
|
|
||||||
return rtf.format(Math.floor(seconds / day), 'day');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const twitterFromNow = (date) => {
|
|
||||||
// date = Date object
|
|
||||||
const seconds = (Date.now() - date.getTime()) / 1000;
|
|
||||||
if (seconds < minute) {
|
|
||||||
return t({
|
|
||||||
comment: 'Relative time in seconds, as short as possible',
|
|
||||||
message: `${seconds < 1 ? 1 : Math.floor(seconds)}s`,
|
|
||||||
});
|
|
||||||
} else if (seconds < hour) {
|
|
||||||
return t({
|
|
||||||
comment: 'Relative time in minutes, as short as possible',
|
|
||||||
message: `${Math.floor(seconds / minute)}m`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return t({
|
|
||||||
comment: 'Relative time in hours, as short as possible',
|
|
||||||
message: `${Math.floor(seconds / hour)}h`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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(() => new Date(datetime), [datetime]);
|
const [dateStr, setDateStr] = useState('');
|
||||||
const [dateStr, dt, title] = useMemo(() => {
|
|
||||||
if (!isValidDate(date)) return ['' + datetime, '', ''];
|
useEffect(() => {
|
||||||
|
let timer, raf;
|
||||||
|
const update = () => {
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
let str;
|
let str;
|
||||||
if (format === 'micro') {
|
if (format === 'micro') {
|
||||||
// If date <= 1 day ago or day is within this year
|
// If date <= 1 day ago or day is within this year
|
||||||
const now = new Date();
|
const now = dayjs();
|
||||||
const dayDiff = (now.getTime() - date.getTime()) / 1000 / day;
|
const dayDiff = now.diff(date, 'day');
|
||||||
if (dayDiff <= 1) {
|
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||||
str = twitterFromNow(date);
|
str = date.twitter();
|
||||||
} else {
|
} else {
|
||||||
const sameYear = now.getFullYear() === date.getFullYear();
|
str = dtf.format(date.toDate());
|
||||||
if (sameYear) {
|
}
|
||||||
str = DTF(i18n.locale, {
|
|
||||||
year: undefined,
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(date);
|
|
||||||
} else {
|
} else {
|
||||||
str = DTF(i18n.locale, {
|
str = date.fromNow();
|
||||||
dateStyle: 'short',
|
|
||||||
}).format(date);
|
|
||||||
}
|
}
|
||||||
}
|
setDateStr(str);
|
||||||
}
|
|
||||||
if (!str) str = rtfFromNow(date);
|
|
||||||
return [str, date.toISOString(), date.toLocaleString()];
|
|
||||||
}, [date, format, renderCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
timer = setTimeout(update, 30_000);
|
||||||
if (!isValidDate(date)) return;
|
|
||||||
let timeout;
|
|
||||||
let raf;
|
|
||||||
function rafRerender() {
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
rerender();
|
|
||||||
scheduleRerender();
|
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
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
|
|
||||||
const seconds = (Date.now() - date.getTime()) / 1000;
|
|
||||||
if (seconds < minute) {
|
|
||||||
timeout = setTimeout(rafRerender, 10_000);
|
|
||||||
} else if (seconds < hour) {
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,314 +0,0 @@
|
||||||
import './report-modal.css';
|
|
||||||
|
|
||||||
import { msg, t, Trans } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
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: msg`Spam`,
|
|
||||||
description: msg`Malicious links, fake engagement, or repetitive replies`,
|
|
||||||
},
|
|
||||||
legal: {
|
|
||||||
label: msg`Illegal`,
|
|
||||||
description: msg`Violates the law of your or the server's country`,
|
|
||||||
},
|
|
||||||
violation: {
|
|
||||||
label: msg`Server rule violation`,
|
|
||||||
description: msg`Breaks specific server rules`,
|
|
||||||
stampLabel: msg`Violation`,
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
label: msg`Other`,
|
|
||||||
description: msg`Issue doesn't fit other categories`,
|
|
||||||
excludeStamp: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function ReportModal({ account, post, onClose }) {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
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 ? t`Report Post` : t`Report @${username}`}</h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="plain4 small"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
onClick={() => onClose()}
|
|
||||||
>
|
|
||||||
<Icon icon="x" size="xl" alt={t`Close`} />
|
|
||||||
</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>
|
|
||||||
<Trans>Pending review</Trans>
|
|
||||||
</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 ? t`Post reported` : t`Profile reported`);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setUIState('error');
|
|
||||||
showToast(
|
|
||||||
error?.message ||
|
|
||||||
(post
|
|
||||||
? t`Unable to report post`
|
|
||||||
: t`Unable to report profile`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{post
|
|
||||||
? t`What's the issue with this post?`
|
|
||||||
: t`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)}
|
|
||||||
<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">
|
|
||||||
<Trans>Additional info</Trans>
|
|
||||||
</label>
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
maxlength="1000"
|
|
||||||
rows="1"
|
|
||||||
name="comment"
|
|
||||||
id="report-comment"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
required={!post} // Required if not reporting a post
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
{!!domain && domain !== currentDomain && (
|
|
||||||
<section>
|
|
||||||
<p>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
switch
|
|
||||||
name="forward"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
/>{' '}
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
Forward to <i>{domain}</i>
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<footer>
|
|
||||||
<button type="submit" disabled={uiState === 'loading'}>
|
|
||||||
<Trans>Send Report</Trans>
|
|
||||||
</button>{' '}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="plain2"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
|
|
||||||
showToast(t`Muted ${username}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showToast(t`Unable to mute ${username}`);
|
|
||||||
}
|
|
||||||
// onSubmit will still run
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>
|
|
||||||
Send Report <small class="ib">+ Mute profile</small>
|
|
||||||
</Trans>
|
|
||||||
</button>{' '}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="plain2"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await masto.v1.accounts.$select(account.id).block();
|
|
||||||
showToast(t`Blocked ${username}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showToast(t`Unable to block ${username}`);
|
|
||||||
}
|
|
||||||
// onSubmit will still run
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>
|
|
||||||
Send Report <small class="ib">+ Block profile</small>
|
|
||||||
</Trans>
|
|
||||||
</button>
|
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportModal;
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|