Merge branch 'main' into main

This commit is contained in:
Helge 2024-12-04 16:22:13 +01:00 committed by GitHub
commit 5f905022ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
212 changed files with 135926 additions and 7727 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.po linguist-generated
readme-assets/** linguist-documentation

View file

@ -1,40 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
- Which instance: [e.g. mastodon.social]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

81
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: "Bug report"
description: "Create a report to help us improve"
labels:
- "bug"
body:
- type: input
id: "site"
attributes:
label: "Site"
description: |-
What site(s) did you encounter this bug on?
placeholder: |-
phanpy.social
- type: input
id: "version"
attributes:
label: "Version"
description: |-
Which Phanpy version(s) did you encounter this bug on?
You can see and copy your current version by opening the Settings menu and scrolling down to the About section.
placeholder: |-
2024.10.08.0a176e2
- type: input
id: "instance"
attributes:
label: "Instance"
description: |-
Which instance(s) did you encounter this bug on?
placeholder: |-
mastodon.social
- type: textarea
id: "Browser"
attributes:
label: "Browser"
description: |-
Which browser(s) did you encounter this bug on?
placeholder: |-
- Firefox 132.0b5 on Windows 11
- Safari 18 on iOS 18 on iPhone 16 Pro Max
- type: textarea
id: "description"
attributes:
label: "Bug description"
description: |-
A concise description of what the bug is.
If applicable, add screenshots to help explain your problem.
You can paste screenshots here and GitHub will convert them to Markdown for you.
- type: textarea
id: "steps"
attributes:
label: "To reproduce"
description: |-
A list of steps that can be performed to make the bug happen again.
If possible, add screenshots to help demonstrate the steps.
You can paste screenshots here and GitHub will convert them to Markdown for you.
placeholder: |-
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
- type: textarea
id: "behavior"
attributes:
label: "Expected behavior"
description: |-
A concise description of what you expected to happen.
- type: textarea
id: "other"
attributes:
label: "Other"
description: |-
Anything you want to add?

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: true

View file

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

View file

@ -0,0 +1,37 @@
name: "Feature request"
description: "Suggest an idea for this project"
labels:
- "enhancement"
body:
- type: textarea
id: "problem"
attributes:
label: "Problem I have"
description: |-
If your request is related to a problem, please provide a clear and concise description of what the problem is.
placeholder: |-
I'm always frustrated when [...]
- type: textarea
id: "solution"
attributes:
label: "Solution I'd like"
description: |-
A clear and concise description of what you want to happen.
- type: textarea
id: "alternatives"
attributes:
label: "Alternatives considered"
description: |-
A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: "other"
attributes:
label: "Other"
description: |-
Anything you want to add?

4
.github/release.yml vendored Normal file
View file

@ -0,0 +1,4 @@
changelog:
exclude:
labels:
- 'i18n'

71
.github/workflows/i18n-automerge.yml vendored Normal file
View file

@ -0,0 +1,71 @@
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 }}

View file

@ -0,0 +1,34 @@
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 }}

View file

@ -7,6 +7,7 @@ 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 Normal file
View file

@ -0,0 +1,19 @@
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/update-catalogs.yml vendored Normal file
View file

@ -0,0 +1,32 @@
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

4
.gitignore vendored
View file

@ -27,3 +27,7 @@ dist-ssr
.env.dev .env.dev
phanpy-dist.zip phanpy-dist.zip
phanpy-dist.tar.gz phanpy-dist.tar.gz
sonda-report.html
# Compiled locale files
src/locales/*.js

View file

@ -3,17 +3,20 @@
"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/", "/assets/",
"",
"^../", "^../",
"",
"^[./]" "^[./]"
], ]
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true,
"importOrderCaseInsensitive": true
} }

159
README.md
View file

@ -100,11 +100,12 @@ Everything is designed and engineered following my taste and vision. This is a p
Prerequisites: Node.js 18+ Prerequisites: Node.js 18+
- `npm install` - Install dependencies - `npm install` - Install dependencies
- `npm run dev` - Start development server - `npm run dev` - Start development server and `messages:extract` (`clean` + ``watch`) in parallel
- `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 [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json`
- `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
## Tech stack ## Tech stack
@ -115,10 +116,65 @@ Prerequisites: Node.js 18+
- [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/) - [MingCute icons](https://www.mingcute.com/)
- [Lingui](https://lingui.dev/) - Internationalization
- Vanilla CSS - _Yes, I'm old school._ - Vanilla CSS - _Yes, I'm old school._
Some of these may change in the future. The front-end world is ever-changing. Some of these may change in the future. The front-end world is ever-changing.
## 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/):
- [Dont 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)
- [Dont 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 items 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`, `&rlm;`) 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 ## Self-hosting
This is a **pure static web app**. You can host it anywhere you want. This is a **pure static web app**. You can host it anywhere you want.
@ -138,7 +194,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
Customization can be done by passing environment variables to the build command. Examples: Customization can be done by passing environment variables to the build command. Examples:
```bash ```bash
PHANPY_APP_TITLE="Phanpy Dev" \ PHANPY_CLIENT_NAME="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \ PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build npm run build
``` ```
@ -174,11 +230,21 @@ Available variables:
- `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy): - `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy):
- URL of the privacy policy page - URL of the privacy policy page
- May specify the instance's own privacy policy - 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]`): - `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. - 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) - 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 fallback instances hard-coded in `/.env`
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) - [↗️ 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 ### Static site hosting
@ -192,14 +258,21 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
These are self-hosted by other wonderful folks. These are self-hosted by other wonderful folks.
- [ferengi.one](https://m.ferengi.one/) by [@david@collantes.social](https://collantes.social/@david) - [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) - [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
- [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.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.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff) - [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
- [phanpy.mastodon.world](https://phanpy.mastodon.world) by [@ruud@mastodon.world](https://mastodon.world/@ruud)
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
- [phanpy.tilde.zone](https://phanpy.tilde.zone) by [@ben@tilde.zone](https://tilde.zone/@ben)
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
> Note: Add yours by creating a pull request. > Note: Add yours by creating a pull request.
@ -221,6 +294,72 @@ Costs involved in running and developing this web app:
[![Contributors](https://contrib.rocks/image?repo=cheeaun/phanpy)](https://github.com/cheeaun/phanpy/graphs/contributors) [![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/c97239bb54623a50eb43cc6b801bb156.jpg" 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/16646485/medium/5d76c44212a4048a815ab437fb170856_default.png" alt="" width="16" height="16" /> kaliuwu (Polish)
- <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/c008af10bc117fa9c9dcb70f2b291ee6.jpg" alt="" width="16" height="16" /> lucasofchirst (Occitan, Portuguese, Portuguese, Brazilian)
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16640089/medium/4b7d8d275d7a7bff564adde51e09b473_default.png" alt="" width="16" height="16" /> LukeHong (Chinese Traditional)
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12822971/medium/4ecbe6d1248536084902925beb0b63e4.png" alt="" width="16" height="16" /> Mannivu (Italian)
- <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/15000639/medium/ebbf0bb7d5027a1903d11b7f5f34f65b.jpeg" alt="" width="16" height="16" /> nycterent (Lithuanian)
- <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/13954917/medium/56a2cba267eb1b5d122bf62ddc0dd732_default.png" alt="" width="16" height="16" /> PPNplus (Thai)
- <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/30871da23d51d7e41bb02f3c92d7f104.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.
@ -235,6 +374,8 @@ And here I am. Building a Mastodon web client.
## Alternative web clients ## Alternative web clients
- Phanpy forks ↓
- [Agora](https://agorasocial.app/)
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓ - [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/) - [Semaphore](https://semaphore.social/)
- [Enafore](https://enafore.social/) - [Enafore](https://enafore.social/)
@ -250,6 +391,8 @@ And here I am. Building a Mastodon web client.
- [Statuzer](https://statuzer.com/) - [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/) - [Tusked](https://tusked.app/)
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone) - [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

7
crowdin.yml Normal file
View file

@ -0,0 +1,7 @@
pull_request_labels:
- i18n
commit_message: New translations (%language%)
append_commit_message: false
files:
- source: /src/locales/en.po
translation: /src/locales/%locale%.po

BIN
design/logo-bw-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

12
design/logo-bw-4.svg Normal file
View file

@ -0,0 +1,12 @@
<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>

After

Width:  |  Height:  |  Size: 954 B

BIN
design/logo-wb-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

12
design/logo-wb-4.svg Normal file
View file

@ -0,0 +1,12 @@
<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>

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

345
i18n-volunteers.json Normal file
View file

@ -0,0 +1,345 @@
[
{
"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"
]
}
]

20
lingui.config.js Normal file
View file

@ -0,0 +1,20 @@
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;

7390
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,61 +6,80 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js", "fetch-instances": "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.5.8",
"@formatjs/intl-segmenter": "~11.5.5", "@formatjs/intl-segmenter": "~11.7.4",
"@formkit/auto-animate": "~0.8.1", "@formkit/auto-animate": "~0.8.2",
"@github/text-expander-element": "~2.6.1", "@github/text-expander-element": "~2.8.0",
"@iconify-icons/mingcute": "~1.2.9", "@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.6.0",
"@szhsin/react-menu": "~4.1.0", "@lingui/detect-locale": "~4.14.0",
"@uidotdev/usehooks": "~2.4.1", "@lingui/macro": "~4.14.0",
"compare-versions": "~6.1.0", "@lingui/react": "~4.14.0",
"dayjs": "~1.11.10", "@szhsin/react-menu": "~4.2.3",
"dayjs-twitter": "~0.5.0", "chroma-js": "~3.1.2",
"fast-blurhash": "~1.1.2", "compare-versions": "~6.1.1",
"fast-blurhash": "~1.1.4",
"fast-equals": "~5.0.1", "fast-equals": "~5.0.1",
"html-prettify": "^1.0.7", "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", "lz-string": "~1.5.0",
"masto": "~6.6.4", "masto": "~6.10.1",
"moize": "~6.1.6", "moize": "~6.1.6",
"p-retry": "~6.2.0", "p-retry": "~6.2.1",
"p-throttle": "~6.1.0", "p-throttle": "~6.2.0",
"preact": "~10.19.6", "preact": "~10.24.3",
"react-hotkeys-hook": "~4.5.0", "punycode": "~2.3.1",
"react-intersection-observer": "~9.8.1", "react-hotkeys-hook": "~4.6.1",
"react-intersection-observer": "~9.13.1",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2", "react-router-dom": "6.6.2",
"string-length": "6.0.0", "string-length": "6.0.0",
"swiped-events": "~1.1.9", "swiped-events": "~1.2.0",
"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.0", "use-debounce": "~10.0.4",
"use-long-press": "~3.2.0", "use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.13.2" "valtio": "2.1.2"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "~2.8.1", "@ianvs/prettier-plugin-sort-imports": "~4.4.0",
"@trivago/prettier-plugin-sort-imports": "~4.3.0", "@lingui/cli": "~4.14.0",
"postcss": "~8.4.35", "@lingui/vite-plugin": "~4.14.0",
"postcss-dark-theme-class": "~1.2.1", "@preact/preset-vite": "~2.9.1",
"postcss-preset-env": "~9.4.0", "babel-plugin-macros": "~3.1.0",
"postcss": "~8.4.49",
"postcss-dark-theme-class": "~1.3.0",
"postcss-preset-env": "~10.1.1",
"prettier": "3.4.1",
"sonda": "~0.6.1",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~5.1.5", "vite": "~5.4.11",
"vite-plugin-generate-file": "~0.1.1", "vite-plugin-generate-file": "~0.2.0",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~2.0.2",
"vite-plugin-pwa": "~0.19.2", "vite-plugin-pwa": "~0.21.0",
"vite-plugin-remove-console": "~2.2.0", "vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0", "vite-plugin-run": "~0.6.1",
"workbox-expiration": "~7.0.0", "workbox-cacheable-response": "~7.3.0",
"workbox-routing": "~7.0.0", "workbox-expiration": "~7.3.0",
"workbox-strategies": "~7.0.0" "workbox-navigation-preload": "~7.3.0",
"workbox-routing": "~7.3.0",
"workbox-strategies": "~7.3.0"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {

View file

@ -1,5 +1,6 @@
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,
@ -7,19 +8,48 @@ import {
StaleWhileRevalidate, StaleWhileRevalidate,
} from 'workbox-strategies'; } from 'workbox-strategies';
navigationPreload.enable();
self.__WB_DISABLE_DEV_LOGS = true; self.__WB_DISABLE_DEV_LOGS = true;
const iconsRoute = new Route(
({ request, sameOrigin }) => {
const isIcon = request.url.includes('/icons/');
return sameOrigin && isIcon;
},
new CacheFirst({
cacheName: 'icons',
plugins: [
new ExpirationPlugin({
// Weirdly high maxEntries number, due to some old icons suddenly disappearing and not rendering
// NOTE: Temporary fix
maxEntries: 300,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true,
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(iconsRoute);
const assetsRoute = new Route( const assetsRoute = new Route(
({ request, sameOrigin }) => { ({ request, sameOrigin }) => {
const isAsset = const isAsset =
request.destination === 'style' || request.destination === 'script'; request.destination === 'style' || request.destination === 'script';
const hasHash = /-[0-9a-f]{4,}\./i.test(request.url); const hasHash = /-[0-9a-z-]{4,}\./i.test(request.url);
return sameOrigin && isAsset && hasHash; return sameOrigin && isAsset && hasHash;
}, },
new NetworkFirst({ new NetworkFirst({
cacheName: 'assets', cacheName: 'assets',
networkTimeoutSeconds: 5, networkTimeoutSeconds: 5,
plugins: [ plugins: [
new ExpirationPlugin({
maxEntries: 30,
purgeOnQuotaError: true,
}),
new CacheableResponsePlugin({ new CacheableResponsePlugin({
statuses: [0, 200], statuses: [0, 200],
}), }),
@ -41,8 +71,7 @@ const imageRoute = new Route(
cacheName: 'remote-images', cacheName: 'remote-images',
plugins: [ plugins: [
new ExpirationPlugin({ new ExpirationPlugin({
maxEntries: 50, maxEntries: 30,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true, purgeOnQuotaError: true,
}), }),
new CacheableResponsePlugin({ new CacheableResponsePlugin({
@ -53,17 +82,17 @@ const imageRoute = new Route(
); );
registerRoute(imageRoute); registerRoute(imageRoute);
const iconsRoute = new Route( // 1-day cache for
({ request, sameOrigin }) => { // - /api/v1/custom_emojis
const isIcon = request.url.includes('/icons/'); // - /api/v1/lists/:id
return sameOrigin && isIcon; // - /api/v1/announcements
}, const apiExtendedRoute = new RegExpRoute(
new CacheFirst({ /^https?:\/\/[^\/]+\/api\/v\d+\/(custom_emojis|lists\/\d+|announcements)$/,
cacheName: 'icons', new StaleWhileRevalidate({
cacheName: 'api-extended',
plugins: [ plugins: [
new ExpirationPlugin({ new ExpirationPlugin({
maxEntries: 50, maxAgeSeconds: 12 * 60 * 60, // 12 hours
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true, purgeOnQuotaError: true,
}), }),
new CacheableResponsePlugin({ new CacheableResponsePlugin({
@ -72,30 +101,30 @@ const iconsRoute = new Route(
], ],
}), }),
); );
registerRoute(iconsRoute);
// 1-day cache for
// - /api/v1/instance
// - /api/v1/custom_emojis
// - /api/v1/preferences
// - /api/v1/lists/:id
// - /api/v1/announcements
const apiExtendedRoute = new RegExpRoute(
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+|announcements)$/,
new StaleWhileRevalidate({
cacheName: 'api-extended',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60, // 1 day
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
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
@ -105,7 +134,9 @@ 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],

93
scripts/catalogs.js Normal file
View file

@ -0,0 +1,93 @@
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);

View file

@ -0,0 +1,131 @@
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');
}

View file

@ -0,0 +1,27 @@
// 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');

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import './app.css'; import './app.css';
import { useLingui } from '@lingui/react';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { lazy, Suspense } from 'preact/compat'; import { memo } from 'preact/compat';
import { import {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -10,7 +11,9 @@ import {
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events'; import 'swiped-events';
import { subscribe } from 'valtio'; import { subscribe } from 'valtio';
import BackgroundService from './components/background-service'; import BackgroundService from './components/background-service';
@ -18,15 +21,17 @@ import ComposeButton from './components/compose-button';
import { ICONS } from './components/ICONS'; import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help'; import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader'; import Loader from './components/loader';
// import Modals from './components/modals'; import Modals from './components/modals';
import NotificationService from './components/notification-service'; import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command'; import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts'; import Shortcuts from './components/shortcuts';
import NotFound from './pages/404'; import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses'; import AccountStatuses from './pages/account-statuses';
import AnnualReport from './pages/annual-report';
import Bookmarks from './pages/bookmarks'; import Bookmarks from './pages/bookmarks';
// import Catchup from './pages/catchup'; 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';
@ -44,6 +49,8 @@ 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,
@ -53,11 +60,13 @@ import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck'; import focusDeck from './utils/focus-deck';
import states, { initStates, statusKey } from './utils/states'; import states, { initStates, statusKey } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils'; import {
import './utils/toast-alert'; getAccount,
getCurrentAccount,
setCurrentAccountID,
} from './utils/store-utils';
const Catchup = lazy(() => import('./pages/catchup')); import './utils/toast-alert';
const Modals = lazy(() => import('./components/modals'));
window.__STATES__ = states; window.__STATES__ = states;
window.__STATES_STATS__ = () => { window.__STATES_STATS__ = () => {
@ -90,7 +99,8 @@ window.__STATES_STATS__ = () => {
// Experimental "garbage collection" for states // Experimental "garbage collection" for states
// Every 15 minutes // Every 15 minutes
// Only posts for now // Only posts for now
setInterval(() => { setInterval(
() => {
if (!window.__IDLE__) return; if (!window.__IDLE__) return;
const { statuses, unfurledLinks, notifications } = states; const { statuses, unfurledLinks, notifications } = states;
let keysCount = 0; let keysCount = 0;
@ -122,20 +132,24 @@ setInterval(() => {
if (keysCount) { if (keysCount) {
console.info(`GC: Removed ${keysCount} keys`); console.info(`GC: Removed ${keysCount} keys`);
} }
}, 15 * 60 * 1000); },
15 * 60 * 1000,
);
// Preload icons // Preload icons
// There's probably a better way to do this // There's probably a better way to do this
// Related: https://github.com/vitejs/vite/issues/10600 // Related: https://github.com/vitejs/vite/issues/10600
setTimeout(() => { setTimeout(() => {
for (const icon in ICONS) { for (const icon in ICONS) {
queueMicrotask(() => { setTimeout(() => {
if (Array.isArray(ICONS[icon])) { if (Array.isArray(ICONS[icon])) {
ICONS[icon][0]?.(); ICONS[icon][0]?.();
} else if (typeof ICONS[icon] === 'object') {
ICONS[icon].module?.();
} else { } else {
ICONS[icon]?.(); ICONS[icon]?.();
} }
}); }, 1);
} }
}, 5000); }, 5000);
@ -200,6 +214,12 @@ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) { if (isIOS) {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
// Don't reset theme color if media modal is showing
// Media modal will set its own theme color based on the media's color
const showingMediaModal =
document.getElementsByClassName('media-modal-container').length > 0;
if (showingMediaModal) return;
const theme = store.local.get('theme'); const theme = store.local.get('theme');
let $meta; let $meta;
if (theme) { if (theme) {
@ -294,9 +314,36 @@ subscribe(states, (changes) => {
} }
}); });
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() { function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading'); 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');
@ -313,9 +360,10 @@ function App() {
window.location.pathname || '/', window.location.pathname || '/',
); );
const clientID = store.session.get('clientID'); const clientID = store.sessionCookie.get('clientID');
const clientSecret = store.session.get('clientSecret'); const clientSecret = store.sessionCookie.get('clientSecret');
const vapidKey = store.session.get('vapidKey'); const vapidKey = store.sessionCookie.get('vapidKey');
const verifier = store.sessionCookie.get('codeVerifier');
(async () => { (async () => {
setUIState('loading'); setUIState('loading');
@ -324,43 +372,83 @@ function App() {
client_id: clientID, client_id: clientID,
client_secret: clientSecret, client_secret: clientSecret,
code, code,
code_verifier: verifier || undefined,
}); });
if (accessToken) {
const client = initClient({ instance: instanceURL, accessToken }); const client = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initPreferences(client),
initInstance(client, instanceURL), initInstance(client, instanceURL),
initAccount(client, instanceURL, accessToken, vapidKey), initAccount(client, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initStates();
initPreferences(client); 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; 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) {
store.session.set('currentAccount', account.info.id); setCurrentAccountID(account.info.id);
window.history.replaceState(
{},
document.title,
window.location.pathname || '/',
);
}
}
if (!account) {
account = getCurrentAccount();
}
if (account) {
setCurrentAccountID(account.info.id);
const { client } = api({ account }); const { client } = api({ account });
const { instance } = client; const { instance } = client;
// console.log('masto', masto); // console.log('masto', masto);
initStates(); initStates();
initPreferences(client);
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
await initInstance(client, instance); if (hasPreferences() && hasInstance(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();
@ -375,29 +463,36 @@ function App() {
return <HttpRoute />; return <HttpRoute />;
} }
if (uiState === 'loading') {
return <Loader id="loader-root" />;
}
return ( return (
<> <>
<PrimaryRoutes isLoggedIn={isLoggedIn} loading={uiState === 'loading'} /> <PrimaryRoutes isLoggedIn={isLoggedIn} />
<SecondaryRoutes isLoggedIn={isLoggedIn} /> <SecondaryRoutes isLoggedIn={isLoggedIn} />
{uiState === 'default' && (
<Routes> <Routes>
<Route path="/:instance?/s/:id" element={<StatusRoute />} /> <Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes> </Routes>
)}
{isLoggedIn && <ComposeButton />} {isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />} {isLoggedIn && <Shortcuts />}
<Suspense>
<Modals /> <Modals />
</Suspense>
{isLoggedIn && <NotificationService />} {isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} <SearchCommand onClose={focusDeck} />
<KeyboardShortcutsHelp /> <KeyboardShortcutsHelp />
</> </>
); );
} }
function PrimaryRoutes({ isLoggedIn, loading }) { function Root({ isLoggedIn }) {
if (isLoggedIn) {
__BENCHMARK.end('time-to-isLoggedIn');
}
return isLoggedIn ? <Home /> : <Welcome />;
}
const PrimaryRoutes = memo(({ isLoggedIn }) => {
const location = useLocation(); const location = useLocation();
const nonRootLocation = useMemo(() => { const nonRootLocation = useMemo(() => {
const { pathname } = location; const { pathname } = location;
@ -406,23 +501,12 @@ function PrimaryRoutes({ isLoggedIn, loading }) {
return ( return (
<Routes location={nonRootLocation || location}> <Routes location={nonRootLocation || location}>
<Route <Route path="/" element={<Root isLoggedIn={isLoggedIn} />} />
path="/"
element={
isLoggedIn ? (
<Home />
) : loading ? (
<Loader id="loader-root" />
) : (
<Welcome />
)
}
/>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} /> <Route path="/welcome" element={<Welcome />} />
</Routes> </Routes>
); );
} });
function getPrevLocation() { function getPrevLocation() {
return states.prevLocation || null; return states.prevLocation || null;
@ -463,15 +547,10 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route index element={<Lists />} /> <Route index element={<Lists />} />
<Route path=":id" element={<List />} /> <Route path=":id" element={<List />} />
</Route> </Route>
<Route path="/ft" element={<FollowedHashtags />} /> <Route path="/fh" element={<FollowedHashtags />} />
<Route <Route path="/ft" element={<Filters />} />
path="/catchup" <Route path="/catchup" element={<Catchup />} />
element={ <Route path="/annual_report/:year" element={<AnnualReport />} />
<Suspense>
<Catchup />
</Suspense>
}
/>
</> </>
)} )}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> <Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -9,13 +9,17 @@ body.cloak,
.status .content-container, .status .content-container,
.status .content-container *, .status .content-container *,
.status .content-compact > *, .status .content-compact > *,
.account-container .actions small,
.account-container :is(header, main > *:not(.actions)), .account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *, .account-container :is(header, main > *:not(.actions)) *,
.header-double-lines, .header-double-lines *,
.account-block, .account-block,
.catchup-filters .filter-author *, .catchup-filters .filter-author *,
.post-peek-html *, .post-peek-html *,
.post-peek-content > * { .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; */
@ -49,9 +53,19 @@ body.cloak,
body.cloak, body.cloak,
.cloak { .cloak {
.header-double-lines *,
.account-container .profile-metadata b,
.account-container .actions small,
.account-container .stats *,
.media-container figcaption, .media-container figcaption,
.media-container figcaption > *, .media-container figcaption > *,
.catchup-filters .filter-author * { .catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
.account-container .actions small,
.status .content-compact {
background-color: currentColor !important;
}
} }

View file

@ -6,8 +6,14 @@ export const ICONS = {
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'), 'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'), transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
rocket: () => import('@iconify-icons/mingcute/rocket-line'), rocket: () => import('@iconify-icons/mingcute/rocket-line'),
'arrow-left': () => import('@iconify-icons/mingcute/arrow-left-line'), 'arrow-left': {
'arrow-right': () => import('@iconify-icons/mingcute/arrow-right-line'), 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-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'), 'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'), earth: () => import('@iconify-icons/mingcute/earth-line'),
@ -16,8 +22,14 @@ export const ICONS = {
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'), 'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'), 'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
message: () => import('@iconify-icons/mingcute/mail-line'), message: () => import('@iconify-icons/mingcute/mail-line'),
comment: () => import('@iconify-icons/mingcute/chat-3-line'), comment: {
comment2: () => import('@iconify-icons/mingcute/comment-2-line'), 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'), home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'), notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'), follow: () => import('@iconify-icons/mingcute/user-follow-line'),
@ -31,23 +43,46 @@ export const ICONS = {
gear: () => import('@iconify-icons/mingcute/settings-3-line'), gear: () => import('@iconify-icons/mingcute/settings-3-line'),
more: () => import('@iconify-icons/mingcute/more-3-line'), more: () => import('@iconify-icons/mingcute/more-3-line'),
more2: () => import('@iconify-icons/mingcute/more-1-fill'), more2: () => import('@iconify-icons/mingcute/more-1-fill'),
external: () => import('@iconify-icons/mingcute/external-link-line'), external: {
popout: () => import('@iconify-icons/mingcute/external-link-line'), module: () => import('@iconify-icons/mingcute/external-link-line'),
popin: [() => import('@iconify-icons/mingcute/external-link-line'), '180deg'], 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'), plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': () => import('@iconify-icons/mingcute/left-line'), 'chevron-left': {
'chevron-right': () => import('@iconify-icons/mingcute/right-line'), 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'), 'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [ reply: {
() => import('@iconify-icons/mingcute/share-forward-line'), module: () => import('@iconify-icons/mingcute/share-forward-line'),
'180deg', rotate: '180deg',
'horizontal', flip: 'horizontal',
], rtl: true,
},
thread: () => import('@iconify-icons/mingcute/route-line'), thread: () => import('@iconify-icons/mingcute/route-line'),
group: () => import('@iconify-icons/mingcute/group-line'), group: {
module: () => import('@iconify-icons/mingcute/group-line'),
rtl: true,
},
bot: () => import('@iconify-icons/mingcute/android-2-line'), bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'), menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: () => import('@iconify-icons/mingcute/list-check-line'), list: {
module: () => import('@iconify-icons/mingcute/list-check-line'),
rtl: true,
},
search: () => import('@iconify-icons/mingcute/search-2-line'), search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'), hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'), info: () => import('@iconify-icons/mingcute/information-line'),
@ -62,12 +97,21 @@ export const ICONS = {
share: () => import('@iconify-icons/mingcute/share-2-line'), share: () => import('@iconify-icons/mingcute/share-2-line'),
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'), sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'), sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
exit: () => import('@iconify-icons/mingcute/exit-line'), exit: {
module: () => import('@iconify-icons/mingcute/exit-line'),
rtl: true,
},
translate: () => import('@iconify-icons/mingcute/translate-line'), translate: () => import('@iconify-icons/mingcute/translate-line'),
play: () => import('@iconify-icons/mingcute/play-fill'), play: () => import('@iconify-icons/mingcute/play-fill'),
trash: () => import('@iconify-icons/mingcute/delete-2-line'), trash: () => import('@iconify-icons/mingcute/delete-2-line'),
mute: () => import('@iconify-icons/mingcute/volume-mute-line'), mute: {
unmute: () => import('@iconify-icons/mingcute/volume-line'), 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'), block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [ unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'), () => import('@iconify-icons/mingcute/forbid-circle-line'),
@ -78,30 +122,56 @@ export const ICONS = {
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'), refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'), emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-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'), chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'), react: () => import('@iconify-icons/mingcute/react-line'),
layout4: () => import('@iconify-icons/mingcute/layout-4-line'), layout4: {
module: () => import('@iconify-icons/mingcute/layout-4-line'),
rtl: true,
},
layout5: () => import('@iconify-icons/mingcute/layout-5-line'), layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: () => import('@iconify-icons/mingcute/announcement-line'), announce: {
module: () => import('@iconify-icons/mingcute/announcement-line'),
rtl: true,
},
alert: () => import('@iconify-icons/mingcute/alert-line'), alert: () => import('@iconify-icons/mingcute/alert-line'),
round: () => import('@iconify-icons/mingcute/round-fill'), round: () => import('@iconify-icons/mingcute/round-fill'),
'arrow-up-circle': () => 'arrow-up-circle': () =>
import('@iconify-icons/mingcute/arrow-up-circle-line'), import('@iconify-icons/mingcute/arrow-up-circle-line'),
'arrow-down-circle': () => 'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'), import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: () => import('@iconify-icons/mingcute/clipboard-line'), clipboard: {
module: () => import('@iconify-icons/mingcute/clipboard-line'),
rtl: true,
},
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'), 'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'), 'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'), cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'), month: {
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
rtl: true,
},
media: () => import('@iconify-icons/mingcute/photo-album-line'), media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'), speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'), building: () => import('@iconify-icons/mingcute/building-5-line'),
history2: () => import('@iconify-icons/mingcute/history-2-line'), history2: {
module: () => import('@iconify-icons/mingcute/history-2-line'),
rtl: true,
},
document: () => import('@iconify-icons/mingcute/document-line'), document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'), 'arrows-right': {
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
rtl: true,
},
code: () => import('@iconify-icons/mingcute/code-line'), code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'), copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: () => import('@iconify-icons/mingcute/quote-left-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'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
}; };

View file

@ -29,6 +29,8 @@
line-clamp: 1; line-clamp: 1;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
unicode-bidi: isolate;
direction: initial;
} }
a { a {

View file

@ -1,5 +1,7 @@
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 enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
@ -33,7 +35,7 @@ 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>
); );
@ -87,7 +89,7 @@ function AccountBlock({
class="account-block" class="account-block"
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={`@${acct}`} title={acct2 ? acct : `@${acct}`}
onClick={(e) => { onClick={(e) => {
if (external) return; if (external) return;
e.preventDefault(); e.preventDefault();
@ -103,11 +105,13 @@ function AccountBlock({
} }
}} }}
> >
<div class="avatar-container">
<Avatar <Avatar
url={useAvatarStatic ? avatarStatic : avatar || avatarStatic} url={useAvatarStatic ? avatarStatic : avatar || avatarStatic}
size={avatarSize} size={avatarSize}
squircle={bot} squircle={bot}
/> />
</div>
<span class="account-block-content"> <span class="account-block-content">
{!hideDisplayName && ( {!hideDisplayName && (
<> <>
@ -120,47 +124,48 @@ function AccountBlock({
)} )}
</> </>
)}{' '} )}{' '}
<span class="account-block-acct"> <span class="account-block-acct bidi-isolate">
@{acct1} {acct2 ? '' : '@'}
{acct1}
<wbr /> <wbr />
{acct2} {acct2}
{locked && ( {locked && (
<> <>
{' '} {' '}
<Icon icon="lock" size="s" alt="Locked" /> <Icon icon="lock" size="s" alt={t`Locked`} />
</> </>
)} )}
</span> </span>
{showActivity && ( {showActivity && (
<> <div class="account-block-stats">
<br /> <Trans>Posts: {shortenNumber(statusesCount)}</Trans>
<small class="last-status-at insignificant">
Posts: {statusesCount}
{!!lastStatusAt && ( {!!lastStatusAt && (
<> <>
{' '} {' '}
&middot; Last posted:{' '} &middot;{' '}
<Trans>
Last posted:{' '}
{niceDateTime(lastStatusAt, { {niceDateTime(lastStatusAt, {
hideTime: true, hideTime: true,
})} })}
</Trans>
</> </>
)} )}
</small> </div>
</>
)} )}
{showStats && ( {showStats && (
<div class="account-block-stats"> <div class="account-block-stats">
{bot && ( {bot && (
<> <>
<span class="tag collapsed"> <span class="tag collapsed">
<Icon icon="bot" /> Automated <Icon icon="bot" /> <Trans>Automated</Trans>
</span> </span>
</> </>
)} )}
{!!group && ( {!!group && (
<> <>
<span class="tag collapsed"> <span class="tag collapsed">
<Icon icon="group" /> Group <Icon icon="group" /> <Trans>Group</Trans>
</span> </span>
</> </>
)} )}
@ -169,26 +174,37 @@ function AccountBlock({
<div class="shazam-container-inner"> <div class="shazam-container-inner">
{excludedRelationship.following && {excludedRelationship.following &&
excludedRelationship.followedBy ? ( excludedRelationship.followedBy ? (
<span class="tag minimal">Mutual</span> <span class="tag minimal">
<Trans>Mutual</Trans>
</span>
) : excludedRelationship.requested ? ( ) : excludedRelationship.requested ? (
<span class="tag minimal">Requested</span> <span class="tag minimal">
<Trans>Requested</Trans>
</span>
) : excludedRelationship.following ? ( ) : excludedRelationship.following ? (
<span class="tag minimal">Following</span> <span class="tag minimal">
<Trans>Following</Trans>
</span>
) : excludedRelationship.followedBy ? ( ) : excludedRelationship.followedBy ? (
<span class="tag minimal">Follows you</span> <span class="tag minimal">
<Trans>Follows you</Trans>
</span>
) : null} ) : null}
</div> </div>
</div> </div>
)} )}
{!!followersCount && ( {!!followersCount && (
<span class="ib"> <span class="ib">
{shortenNumber(followersCount)}{' '} <Plural
{followersCount === 1 ? 'follower' : 'followers'} value={followersCount}
one="# follower"
other="# followers"
/>
</span> </span>
)} )}
{!!verifiedField && ( {!!verifiedField && (
<span class="verified-field"> <span class="verified-field">
<Icon icon="check-circle" size="s" />{' '} <Icon icon="check-circle" size="s" alt={t`Verified`} />{' '}
<span <span
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: enhanceContent(verifiedField.value, { emojis }), __html: enhanceContent(verifiedField.value, { emojis }),
@ -203,12 +219,14 @@ function AccountBlock({
!verifiedField && !verifiedField &&
!!createdAt && ( !!createdAt && (
<span class="created-at"> <span class="created-at">
<Trans>
Joined{' '} Joined{' '}
<time datetime={createdAt}> <time datetime={createdAt}>
{niceDateTime(createdAt, { {niceDateTime(createdAt, {
hideTime: true, hideTime: true,
})} })}
</time> </time>
</Trans>
</span> </span>
)} )}
</div> </div>

View file

@ -57,7 +57,7 @@
background-repeat: no-repeat; background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both; animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient( background-image: linear-gradient(
to right, var(--to-forward),
var(--original-color) 0%, var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)), 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)),
@ -181,8 +181,8 @@
opacity: 1; opacity: 1;
} }
.sheet .account-container .header-banner { .sheet .account-container .header-banner {
border-top-left-radius: 16px; border-start-start-radius: 16px;
border-top-right-radius: 16px; border-start-end-radius: 16px;
} }
.account-container .header-banner.header-is-avatar { .account-container .header-banner.header-is-avatar {
mask-image: linear-gradient( mask-image: linear-gradient(
@ -217,13 +217,13 @@
background-image: none; background-image: none;
} }
& + 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;
} }
&, &,
& + 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;
} }
@ -250,16 +250,21 @@
-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;
}
.account-container header .avatar { .avatar-container {
/* 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 {
@ -288,10 +293,17 @@
align-self: center !important; align-self: center !important;
/* clip a dog ear on top right */ /* clip a dog ear on top right */
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%); 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 */ /* 4x4px square on top right */
background-size: 4px 4px; background-size: 4px 4px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: top right; background-position: top right;
&:dir(rtl) {
background-position: top left;
}
background-image: linear-gradient( background-image: linear-gradient(
to bottom, to bottom,
var(--private-note-border-color), var(--private-note-border-color),
@ -311,7 +323,7 @@
box-orient: vertical; box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2; line-clamp: 2;
text-align: left; text-align: start;
} }
&:hover:not(:active) { &:hover:not(:active) {
@ -370,7 +382,8 @@
animation: appear 1s both ease-in-out; animation: appear 1s both ease-in-out;
> *:not(:first-child) { > *:not(:first-child) {
margin: 0 0 0 -4px; margin: 0;
margin-inline-start: -4px;
} }
} }
} }
@ -422,15 +435,15 @@
} }
&:has(+ .account-metadata-box) { &:has(+ .account-metadata-box) {
border-bottom-left-radius: 4px; border-end-start-radius: 4px;
border-bottom-right-radius: 4px; border-end-end-radius: 4px;
} }
+ .account-metadata-box { + .account-metadata-box {
border-top-left-radius: 4px; border-start-start-radius: 4px;
border-top-right-radius: 4px; border-start-end-radius: 4px;
border-bottom-left-radius: 16px; border-end-start-radius: 16px;
border-bottom-right-radius: 16px; border-end-end-radius: 16px;
} }
} }
@ -577,7 +590,7 @@
margin-top: calc(-1 * var(--banner-overlap)); margin-top: calc(-1 * var(--banner-overlap));
} }
@supports (animation-timeline: scroll()) { @supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) {
.header-banner:not(.header-is-avatar):not(:hover):not(:active) { .header-banner:not(.header-is-avatar):not(:hover):not(:active) {
animation: bye-banner 1s linear both; animation: bye-banner 1s linear both;
animation-timeline: view(); animation-timeline: view();
@ -746,13 +759,17 @@
letter-spacing: -0.5px; letter-spacing: -0.5px;
mix-blend-mode: multiply; mix-blend-mode: multiply;
gap: 12px; gap: 12px;
}
.timeline-start .account-container header .account-block .avatar { .avatar-container {
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 { #private-note-container {
@ -781,3 +798,108 @@
} }
} }
} }
#edit-profile-container {
p {
margin-block: 8px;
}
label {
input,
textarea {
display: block;
width: 100%;
}
textarea {
resize: vertical;
min-height: 5em;
max-height: 50vh;
}
}
table {
width: 100%;
th {
text-align: start;
color: var(--text-insignificant-color);
font-weight: normal;
font-size: 0.8em;
text-transform: uppercase;
}
tbody tr td:first-child {
width: 40%;
}
input {
width: 100%;
}
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
.handle-info {
.handle-handle {
display: inline-block;
margin-block: 5px;
b {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
display: inline-block;
box-shadow: 0 0 0 5px var(--bg-blur-color);
&.handle-username {
color: var(--orange-fg-color);
background-color: var(--orange-bg-color);
}
&.handle-server {
color: var(--purple-fg-color);
background-color: var(--purple-bg-color);
}
}
}
.handle-at {
display: inline-block;
margin-inline: -3px;
position: relative;
z-index: 1;
}
.handle-legend {
margin-top: 0.25em;
}
.handle-legend-icon {
overflow: hidden;
display: inline-block;
width: 14px;
height: 14px;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
&.username {
background-color: var(--orange-fg-color);
border-color: var(--orange-bg-color);
}
&.server {
background-color: var(--purple-fg-color);
border-color: var(--purple-bg-color);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import { t } from '@lingui/macro';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
@ -33,7 +34,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
> >
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}> <button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<AccountInfo <AccountInfo
@ -58,7 +59,7 @@ 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)) { } else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account); const accountURL = URL.parse(account);
const { hostname, pathname } = accountURL; const { hostname, pathname } = accountURL;
const acct = const acct =
pathname.replace(/^\//, '').replace(/\/$/, '') + pathname.replace(/^\//, '').replace(/\/$/, '') +

View file

@ -21,11 +21,14 @@ const canvas = window.OffscreenCanvas
const ctx = canvas.getContext('2d', { const ctx = canvas.getContext('2d', {
willReadFrequently: true, 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\.png$/.test(url); const isMissing = MISSING_IMAGE_PATH_REGEX.test(url);
return ( return (
<span <span
ref={avatarRef} ref={avatarRef}
@ -47,6 +50,7 @@ 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'
@ -62,7 +66,7 @@ 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;
queueMicrotask(() => { 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;
@ -87,7 +91,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
// Silent fail // Silent fail
alphaCache[url] = false; alphaCache[url] = false;
} }
}); }, 1);
}} }}
/> />
)} )}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -9,13 +10,24 @@ import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
const POLL_INTERVAL = 15_000; // 15 seconds const POLL_INTERVAL = 20_000; // 20 seconds
export default memo(function BackgroundService({ isLoggedIn }) { export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service // Notifications service
// - WebSocket to receive notifications when page is visible // - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
usePageVisibility(setVisible); 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) => { const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {
if (states.notificationsLast) { if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({ const notificationsIterator = masto.v1.notifications.list({
@ -46,6 +58,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
useEffect(() => { useEffect(() => {
let sub; let sub;
let streamTimeout;
let pollNotifications; let pollNotifications;
if (isLoggedIn && visible) { if (isLoggedIn && visible) {
const { masto, streaming, instance } = api(); const { masto, streaming, instance } = api();
@ -56,7 +69,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
let hasStreaming = false; let hasStreaming = false;
// 2. Start streaming // 2. Start streaming
if (streaming) { if (streaming) {
pollNotifications = setTimeout(() => { streamTimeout = setTimeout(() => {
(async () => { (async () => {
try { try {
hasStreaming = true; hasStreaming = true;
@ -94,7 +107,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
return () => { return () => {
sub?.unsubscribe?.(); sub?.unsubscribe?.();
sub = null; sub = null;
clearTimeout(pollNotifications); clearTimeout(streamTimeout);
clearInterval(pollNotifications); clearInterval(pollNotifications);
}; };
}, [visible, isLoggedIn]); }, [visible, isLoggedIn]);
@ -133,7 +146,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
const currentCloakMode = states.settings.cloakMode; const currentCloakMode = states.settings.cloakMode;
states.settings.cloakMode = !currentCloakMode; states.settings.cloakMode = !currentCloakMode;
showToast({ showToast({
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`, text: currentCloakMode ? t`Cloak mode disabled` : t`Cloak mode enabled`,
}); });
}); });

View file

@ -1,3 +1,4 @@
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';
@ -11,11 +12,18 @@ 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 Trending from '../pages/trending'; import Trending from '../pages/trending';
import isRTL from '../utils/is-rtl';
import states from '../utils/states'; import states from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
};
function Columns() { function Columns() {
useTitle('Home', '/'); useTitle(t`Home`, '/');
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
@ -39,6 +47,8 @@ function Columns() {
if (!Component) return null; if (!Component) return null;
// Don't show Search column with no query, for now // Don't show Search column with no query, for now
if (type === 'search' && !params.query) return null; 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 ( return (
<Component key={type + JSON.stringify(params)} {...params} columnMode /> <Component key={type + JSON.stringify(params)} {...params} columnMode />
); );
@ -47,12 +57,42 @@ function Columns() {
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
try { try {
const index = parseInt(handler.keys[0], 10) - 1; const index = parseInt(handler.keys[0], 10) - 1;
document.querySelectorAll('#columns > *')[index].focus(); const $column = document.querySelectorAll('#columns > *')[index];
if ($column) {
$column.focus();
$column.scrollIntoView(scrollIntoViewOptions);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}); });
useHotkeys(['[', ']'], (e, handler) => {
const key = handler.keys[0];
const currentFocusedColumn = document.activeElement.closest('#columns > *');
const rtl = isRTL();
const prevColKey = rtl ? ']' : '[';
const nextColKey = rtl ? '[' : ']';
let $column;
if (key === prevColKey) {
// If [, focus on left of focused column, else first column
$column = currentFocusedColumn
? currentFocusedColumn.previousElementSibling
: document.querySelectorAll('#columns > *')[0];
} else if (key === nextColKey) {
// If ], focus on right of focused column, else 2nd column
$column = currentFocusedColumn
? currentFocusedColumn.nextElementSibling
: document.querySelectorAll('#columns > *')[1];
}
if ($column) {
$column.focus();
$column.scrollIntoView(scrollIntoViewOptions);
}
});
return ( return (
<div <div
id="columns" id="columns"
@ -67,6 +107,18 @@ function Columns() {
states.showShortcutsSettings = true; states.showShortcutsSettings = true;
} }
}} }}
onFocus={() => {
// Get current focused column
const currentFocusedColumn =
document.activeElement.closest('#columns > *');
if (currentFocusedColumn) {
// Remove focus classes from all columns
// Add focus class to current focused column
document.querySelectorAll('#columns > *').forEach((column) => {
column.classList.toggle('focus', column === currentFocusedColumn);
});
}
}}
> >
{components} {components}
</div> </div>

View file

@ -1,12 +1,23 @@
import { t, Trans } from '@lingui/macro';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import openCompose from '../utils/open-compose'; import openCompose from '../utils/open-compose';
import openOSK from '../utils/open-osk';
import states from '../utils/states'; import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
export default function ComposeButton() { export default function ComposeButton() {
const snapStates = useSnapshot(states);
function handleButton(e) { function handleButton(e) {
if (snapStates.composerState.minimized) {
states.composerState.minimized = false;
openOSK();
return;
}
if (e.shiftKey) { if (e.shiftKey) {
const newWin = openCompose(); const newWin = openCompose();
@ -14,6 +25,7 @@ export default function ComposeButton() {
states.showCompose = true; states.showCompose = true;
} }
} else { } else {
openOSK();
states.showCompose = true; states.showCompose = true;
} }
} }
@ -26,8 +38,15 @@ export default function ComposeButton() {
}); });
return ( return (
<button type="button" id="compose-button" onClick={handleButton}> <button
<Icon icon="quill" size="xl" alt="Compose" /> 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> </button>
); );
} }

View file

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

View file

@ -16,16 +16,19 @@
} }
#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;
align-items: center; align-items: center;
padding: 16px; padding: 8px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
white-space: nowrap; white-space: nowrap;
@media (min-width: 480px) {
padding: 16px;
}
} }
#compose-container .compose-top .account-block { #compose-container .compose-top .account-block {
text-align: start; text-align: start;
@ -62,7 +65,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-top-right-radius: 8px; border-start-end-radius: 8px;
} }
#compose-container .status-preview :is(.content-container, .time) { #compose-container .status-preview :is(.content-container, .time) {
pointer-events: none; pointer-events: none;
@ -95,6 +98,10 @@
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);
@ -107,10 +114,10 @@
} }
#compose-container form { #compose-container form {
--form-padding-inline: 12px; --form-spacing-inline: 4px;
--form-padding-block: 8px; --form-spacing-block: 0;
/* border-radius: 16px; */ /* border-radius: 16px; */
padding: var(--form-padding-block) var(--form-padding-inline); padding: var(--form-spacing-block) var(--form-spacing-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;
@ -118,6 +125,10 @@
--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: 480px) {
--form-spacing-inline: 8px;
}
@media (min-width: 40em) { @media (min-width: 40em) {
border-radius: 16px; border-radius: 16px;
} }
@ -150,8 +161,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 0; padding: var(--form-spacing-inline) 0;
gap: 8px; gap: var(--form-spacing-inline);
} }
#compose-container .toolbar.wrap { #compose-container .toolbar.wrap {
flex-wrap: wrap; flex-wrap: wrap;
@ -178,6 +189,11 @@
white-space: nowrap; white-space: nowrap;
border: 2px solid transparent; border: 2px solid transparent;
vertical-align: middle; vertical-align: middle;
&.active {
filter: brightness(0.8);
background-color: var(--bg-color);
}
} }
#compose-container .toolbar-button > * { #compose-container .toolbar-button > * {
vertical-align: middle; vertical-align: middle;
@ -204,7 +220,7 @@
left: -100vw !important; left: -100vw !important;
} }
#compose-container .toolbar-button select { #compose-container .toolbar-button select {
background-color: transparent; background-color: inherit;
border: 0; border: 0;
padding: 0 0 0 8px; padding: 0 0 0 8px;
margin: 0; margin: 0;
@ -212,8 +228,8 @@
line-height: 1em; line-height: 1em;
} }
#compose-container .toolbar-button:not(.show-field) select { #compose-container .toolbar-button:not(.show-field) select {
right: 0; inset-inline-end: 0;
left: auto !important; inset-inline-start: auto !important;
} }
#compose-container #compose-container
.toolbar-button:not(:disabled):is( .toolbar-button:not(:disabled):is(
@ -243,6 +259,39 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
max-width: 100%; max-width: 100%;
min-width: 4ch;
}
#compose-container .compose-footer {
.add-toolbar-button-group {
display: flex;
overflow: auto;
}
.add-sub-toolbar-button-group {
flex-grow: 1;
display: flex;
overflow: auto;
transition: 0.5s ease-in-out;
transition-property: opacity, width;
scrollbar-width: none;
padding-inline-end: 16px;
mask-image: linear-gradient(
var(--to-backward),
transparent 0,
black 16px,
black 100%
);
&::-webkit-scrollbar {
display: none;
}
&[hidden] {
opacity: 0;
pointer-events: none;
width: 0;
}
}
} }
#compose-container text-expander { #compose-container text-expander {
@ -294,19 +343,28 @@
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]) {
color: var(--bg-color); background-color: var(--link-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(
-45deg, 135deg,
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,
@ -330,6 +388,21 @@
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;
@ -469,14 +542,14 @@
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
border-left: 1px solid var(--outline-color); border-inline-start: 1px solid var(--outline-color);
padding-left: 8px; padding-inline-start: 8px;
} }
#compose-container .expires-in { #compose-container .expires-in {
flex-grow: 1; flex-grow: 1;
border-left: 1px solid var(--outline-color); border-inline-start: 1px solid var(--outline-color);
padding-left: 8px; padding-inline-start: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
@ -489,6 +562,37 @@
color: var(--red-color); color: var(--red-color);
} }
.compose-menu-add-media {
position: relative;
.compose-menu-add-media-field {
position: absolute;
inset: 0;
opacity: 0;
cursor: inherit;
}
}
.icon-gif {
display: inline-block !important;
min-width: 16px;
height: 16px;
font-size: 10px !important;
letter-spacing: -0.5px;
font-size-adjust: none;
overflow: hidden;
white-space: nowrap;
text-align: center;
line-height: 16px;
font-weight: bold;
text-rendering: optimizeSpeed;
&:after {
display: block;
content: 'GIF';
}
}
@media (display-mode: standalone) { @media (display-mode: standalone) {
/* No popping in standalone mode */ /* No popping in standalone mode */
#compose-container .pop-button { #compose-container .pop-button {
@ -496,9 +600,12 @@
} }
} }
@media (min-width: 480px) { #compose-container button[type='submit'] {
#compose-container button[type='submit'] { border-radius: 8px;
@media (min-width: 480px) {
padding-inline: 24px; padding-inline: 24px;
font-size: 125%;
} }
} }
@ -590,51 +697,210 @@
} */ } */
} }
#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;
}
#custom-emojis-sheet main { header {
.loader-container {
margin: 0;
}
form {
margin: 8px 0 0;
input {
width: 100%;
min-width: 0;
}
}
}
main {
mask-image: none; mask-image: none;
} min-height: 40vh;
#custom-emojis-sheet .custom-emojis-list .section-header { padding-bottom: 88px;
}
.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-blur-color); background-color: var(--bg-color);
backdrop-filter: blur(1px); z-index: 1;
} display: inline-block;
#custom-emojis-sheet .custom-emojis-list section { padding-inline-end: 8px;
pointer-events: none;
border-end-end-radius: 8px;
}
section {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
#custom-emojis-sheet .custom-emojis-list button { 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);
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) { position: relative;
min-width: 44px;
min-height: 44px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
&[data-title]:after {
max-width: 50vw;
pointer-events: none;
position: absolute;
content: attr(data-title);
left: 50%;
top: 0;
background-color: var(--bg-color);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--text-color);
transform: translate(-50%, -110%);
opacity: 0;
transition: opacity 0.1s ease-out 0.1s;
font-family: var(--monospace-font);
line-height: 1;
}
&.edge-left[data-title]:after {
left: 0;
transform: translate(0, -110%);
}
&.edge-right[data-title]:after {
left: 100%;
transform: translate(-100%, -110%);
}
&:is(:hover, :focus) {
z-index: 1;
filter: none; filter: none;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
}
#custom-emojis-sheet .custom-emojis-list button img { &[data-title]:after {
opacity: 1;
}
}
img {
transition: transform 0.1s ease-out; transition: transform 0.1s ease-out;
} }
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
transform: scale(1.5); &:is(:hover, :focus) img {
transform: scale(2);
}
&.edge-left img {
transform-origin: left center;
}
&.edge-right img {
transform-origin: right center;
}
code {
font-size: 0.8em;
}
}
}
} }
.compose-field-container { .compose-field-container {
display: grid !important; display: grid !important;
@media (width < 30em) { @media (width < 480px) {
margin-inline: calc(-1 * var(--form-padding-inline)); margin-inline: calc(-1 * var(--form-spacing-inline));
width: 100vw !important; width: 100vw !important;
max-width: 100vw; max-width: 100vw;
@ -723,3 +989,205 @@
} }
} }
} }
@keyframes gif-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(5deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
@keyframes jump-scare {
from {
opacity: 0.5;
transform: scale(0.25) translateX(80px);
}
to {
opacity: 1;
transform: scale(1) translateX(0);
}
}
@keyframes jump-scare-rtl {
from {
opacity: 0.5;
transform: scale(0.25) translateX(-80px);
}
to {
opacity: 1;
transform: scale(1) translateX(0);
}
}
.add-button {
transform-origin: var(--forward) center;
background-color: var(--bg-blur-color) !important;
animation: jump-scare 0.2s ease-in-out both;
:dir(rtl) & {
animation-name: jump-scare-rtl;
}
.icon {
transition: transform 0.3s ease-in-out;
}
&.active {
.icon {
transform: rotate(135deg);
}
}
}
.gif-picker-button {
/* span {
font-weight: bold;
font-size: 11.5px;
display: block;
line-height: 1;
} */
&:is(:hover, :focus) {
.icon {
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;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
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>
);
}

View file

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

View file

@ -1,5 +1,6 @@
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';
@ -54,17 +55,20 @@ 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" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<header> <header>
<h2> <h2>
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} /> <Trans>Unsent drafts</Trans>{' '}
<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>
@ -83,7 +87,9 @@ function Drafts({ onClose }) {
<time> <time>
{!!replyTo && ( {!!replyTo && (
<> <>
<span class="bidi-isolate">
@{replyTo.account.acct} @{replyTo.account.acct}
</span>
<br /> <br />
</> </>
)} )}
@ -91,7 +97,11 @@ function Drafts({ onClose }) {
</time> </time>
</b> </b>
<MenuConfirm <MenuConfirm
confirmLabel={<span>Delete this draft?</span>} confirmLabel={
<span>
<Trans>Delete this draft?</Trans>
</span>
}
menuItemClassName="danger" menuItemClassName="danger"
align="end" align="end"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
@ -104,7 +114,7 @@ function Drafts({ onClose }) {
reload(); reload();
// } // }
} catch (e) { } catch (e) {
alert('Error deleting draft! Please try again.'); alert(t`Error deleting draft! Please try again.`);
} }
})(); })();
}} }}
@ -114,7 +124,7 @@ function Drafts({ onClose }) {
class="small light" class="small light"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
Delete&hellip; <Trans>Delete</Trans>
</button> </button>
</MenuConfirm> </MenuConfirm>
</div> </div>
@ -133,7 +143,7 @@ function Drafts({ onClose }) {
.fetch(); .fetch();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Error fetching reply-to status!'); alert(t`Error fetching reply-to status!`);
setUIState('default'); setUIState('default');
return; return;
} }
@ -156,7 +166,11 @@ function Drafts({ onClose }) {
{drafts.length > 1 && ( {drafts.length > 1 && (
<p> <p>
<MenuConfirm <MenuConfirm
confirmLabel={<span>Delete all drafts?</span>} confirmLabel={
<span>
<Trans>Delete all drafts?</Trans>
</span>
}
menuItemClassName="danger" menuItemClassName="danger"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
@ -172,7 +186,7 @@ function Drafts({ onClose }) {
reload(); reload();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('Error deleting drafts! Please try again.'); alert(t`Error deleting drafts! Please try again.`);
setUIState('error'); setUIState('error');
} }
// } // }
@ -184,14 +198,16 @@ function Drafts({ onClose }) {
class="light danger" class="light danger"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
Delete all&hellip; <Trans>Delete all</Trans>
</button> </button>
</MenuConfirm> </MenuConfirm>
</p> </p>
)} )}
</> </>
) : ( ) : (
<p>No drafts found.</p> <p>
<Trans>No drafts found.</Trans>
</p>
)} )}
</main> </main>
</div> </div>
@ -226,10 +242,10 @@ function MiniDraft({ draft }) {
: {} : {}
} }
> >
{hasPoll && <Icon icon="poll" />} {hasPoll && <Icon icon="poll" alt={t`Poll`} />}
{hasMedia && ( {hasMedia && (
<span> <span>
<Icon icon="attachment" />{' '} <Icon icon="attachment" alt={t`Media`} />{' '}
<small>{mediaAttachments?.length}</small> <small>{mediaAttachments?.length}</small>
</span> </span>
)} )}

View file

@ -23,7 +23,7 @@
pointer-events: auto; pointer-events: auto;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
width: max(var(--width), 480px); width: max(var(--width), var(--main-width));
height: auto; height: auto;
aspect-ratio: var(--aspect-ratio); aspect-ratio: var(--aspect-ratio);
} }

View file

@ -1,5 +1,7 @@
import './embed-modal.css'; import './embed-modal.css';
import { t, Trans } from '@lingui/macro';
import Icon from './icon'; import Icon from './icon';
function EmbedModal({ html, url, width, height, onClose = () => {} }) { function EmbedModal({ html, url, width, height, onClose = () => {} }) {
@ -7,7 +9,7 @@ function EmbedModal({ html, url, width, height, onClose = () => {} }) {
<div class="embed-modal-container"> <div class="embed-modal-container">
<div class="top-controls"> <div class="top-controls">
<button type="button" class="light" onClick={() => onClose()}> <button type="button" class="light" onClick={() => onClose()}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
{url && ( {url && (
<a <a
@ -16,7 +18,10 @@ function EmbedModal({ html, url, width, height, onClose = () => {} }) {
rel="noopener noreferrer" rel="noopener noreferrer"
class="button plain" class="button plain"
> >
<span>Open link</span> <Icon icon="external" /> <span>
<Trans>Open in new window</Trans>
</span>{' '}
<Icon icon="external" />
</a> </a>
)} )}
</div> </div>

View file

@ -1,31 +1,33 @@
import { memo } from 'preact/compat'; 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( // const regex = new RegExp(
`:(${emojis.map((e) => e.shortcode).join('|')}):`, // `:(${emojis.map((e) => e.shortcode).join('|')}):`,
'g', // 'g',
); // );
const elements = text.split(regex).map((word) => { const regex = shortcodesRegexp(emojis.map((e) => e.shortcode));
const elements = text.split(regex).map((word, i) => {
const emoji = emojis.find((e) => e.shortcode === word); const emoji = emojis.find((e) => e.shortcode === word);
if (emoji) { if (emoji) {
const { url, staticUrl } = emoji; const { url, staticUrl } = emoji;
return ( return (
<picture> <CustomEmoji
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" /> staticUrl={staticUrl}
<img
key={word}
src={url}
alt={word} alt={word}
class="shortcode-emoji emoji" url={url}
width="16" key={word + '-' + i} // Handle >= 2 same shortcodes
height="16"
loading="lazy"
decoding="async"
/> />
</picture>
); );
} }
return word; return word;
@ -33,9 +35,11 @@ function EmojiText({ text, emojis }) {
return elements; return elements;
} }
export default memo( export default mem(EmojiText);
EmojiText,
(oldProps, newProps) => // export default memo(
oldProps.text === newProps.text && // EmojiText,
oldProps.emojis?.length === newProps.emojis?.length, // (oldProps, newProps) =>
); // oldProps.text === newProps.text &&
// oldProps.emojis?.length === newProps.emojis?.length,
// );

View file

@ -1,3 +1,4 @@
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';
@ -38,7 +39,7 @@ function FollowRequestButtons({ accountID, onChange }) {
})(); })();
}} }}
> >
Accept <Trans>Accept</Trans>
</button>{' '} </button>{' '}
<button <button
type="button" type="button"
@ -64,14 +65,18 @@ function FollowRequestButtons({ accountID, onChange }) {
})(); })();
}} }}
> >
Reject <Trans>Reject</Trans>
</button> </button>
<span class="follow-request-states"> <span class="follow-request-states">
{hasRelationship && requestState ? ( {hasRelationship && requestState ? (
requestState === 'accept' ? ( requestState === 'accept' ? (
<Icon icon="check-circle" alt="Accepted" class="follow-accepted" /> <Icon
icon="check-circle"
alt={t`Accepted`}
class="follow-accepted"
/>
) : ( ) : (
<Icon icon="x-circle" alt="Rejected" class="follow-rejected" /> <Icon icon="x-circle" alt={t`Rejected`} class="follow-rejected" />
) )
) : ( ) : (
<Loader hidden={uiState !== 'loading'} /> <Loader hidden={uiState !== 'loading'} />

View file

@ -1,4 +1,39 @@
#generic-accounts-container { #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 { .accounts-list {
--list-gap: 16px; --list-gap: 16px;
list-style: none; list-style: none;
@ -27,13 +62,13 @@
border-top: var(--hairline-width) solid var(--divider-color); border-top: var(--hairline-width) solid var(--divider-color);
position: absolute; position: absolute;
bottom: calc(-1 * var(--list-gap) / 2); bottom: calc(-1 * var(--list-gap) / 2);
left: 40px; inset-inline-start: 40px;
right: 0; inset-inline-end: 0;
} }
&:has(.reactions-block):before { &:has(.reactions-block):before {
/* avatar + reactions + gap */ /* avatar + reactions + gap */
left: calc(40px + 16px + 8px); inset-inline-start: calc(40px + 16px + 8px);
} }
} }

View file

@ -1,5 +1,6 @@
import './generic-accounts.css'; import './generic-accounts.css';
import { t, Trans } from '@lingui/macro';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -11,12 +12,16 @@ import useLocationChange from '../utils/useLocationChange';
import AccountBlock from './account-block'; import AccountBlock from './account-block';
import Icon from './icon'; import Icon from './icon';
import Link from './link';
import Loader from './loader'; import Loader from './loader';
import Status from './status';
export default function GenericAccounts({ export default function GenericAccounts({
instance, instance,
excludeRelationshipAttrs = [], excludeRelationshipAttrs = [],
postID,
onClose = () => {}, onClose = () => {},
blankCopy = t`Nothing to show`,
}) { }) {
const { masto, instance: currentInstance } = api(); const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true; const isCurrentInstance = instance ? instance === currentInstance : true;
@ -129,15 +134,25 @@ export default function GenericAccounts({
} }
}, [snapStates.reloadGenericAccounts.counter]); }, [snapStates.reloadGenericAccounts.counter]);
const post = states.statuses[postID];
return ( return (
<div id="generic-accounts-container" class="sheet" tabindex="-1"> <div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
<header> <header>
<h2>{heading || 'Accounts'}</h2> <h2>{heading || t`Accounts`}</h2>
</header> </header>
<main> <main>
{post && (
<Link
to={`/${instance || currentInstance}/s/${post.id}`}
class="post-preview"
>
<Status status={post} size="s" readOnly />
</Link>
)}
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<> <>
<ul class="accounts-list"> <ul class="accounts-list">
@ -187,11 +202,13 @@ export default function GenericAccounts({
class="plain block" class="plain block"
onClick={() => loadAccounts()} onClick={() => loadAccounts()}
> >
Show more&hellip; <Trans>Show more</Trans>
</button> </button>
</InView> </InView>
) : ( ) : (
<p class="ui-state insignificant">The end.</p> <p class="ui-state insignificant">
<Trans>The end.</Trans>
</p>
) )
) : ( ) : (
uiState === 'loading' && ( uiState === 'loading' && (
@ -206,9 +223,11 @@ export default function GenericAccounts({
<Loader abrupt /> <Loader abrupt />
</p> </p>
) : uiState === 'error' ? ( ) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p> <p class="ui-state">
<Trans>Error loading accounts</Trans>
</p>
) : ( ) : (
<p class="ui-state insignificant">Nothing to show</p> <p class="ui-state insignificant">{blankCopy}</p>
)} )}
</main> </main>
</div> </div>

View file

@ -53,9 +53,14 @@ function Icon({
return null; return null;
} }
let rotate, flip; let rotate,
flip,
rtl = false;
if (Array.isArray(iconBlock)) { if (Array.isArray(iconBlock)) {
[iconBlock, rotate, flip] = iconBlock; [iconBlock, rotate, flip] = iconBlock;
} else if (typeof iconBlock === 'object') {
({ rotate, flip, rtl } = iconBlock);
iconBlock = iconBlock.module;
} }
const [iconData, setIconData] = useState(ICONDATA[icon]); const [iconData, setIconData] = useState(ICONDATA[icon]);
@ -72,13 +77,14 @@ function Icon({
return ( return (
<span <span
class={`icon ${className}`} class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`}
title={title || alt} title={title || alt}
style={{ style={{
width: `${iconSize}px`, width: `${iconSize}px`,
height: `${iconSize}px`, height: `${iconSize}px`,
...style, ...style,
}} }}
data-icon={icon}
> >
{iconData && ( {iconData && (
// <svg // <svg

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import './keyboard-shortcuts-help.css'; import './keyboard-shortcuts-help.css';
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -35,145 +36,156 @@ export default memo(function KeyboardShortcutsHelp() {
<Modal onClose={onClose}> <Modal onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1"> <div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
<header> <header>
<h2>Keyboard shortcuts</h2> <h2>
<Trans>Keyboard shortcuts</Trans>
</h2>
</header> </header>
<main> <main>
<table> <table>
<tbody>
{[ {[
{ {
action: 'Keyboard shortcuts help', action: t`Keyboard shortcuts help`,
keys: <kbd>?</kbd>, keys: <kbd>?</kbd>,
}, },
{ {
action: 'Next post', action: t`Next post`,
keys: <kbd>j</kbd>, keys: <kbd>j</kbd>,
}, },
{ {
action: 'Previous post', action: t`Previous post`,
keys: <kbd>k</kbd>, keys: <kbd>k</kbd>,
}, },
{ {
action: 'Skip carousel to next post', action: t`Skip carousel to next post`,
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>j</kbd> <kbd>Shift</kbd> + <kbd>j</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Skip carousel to previous post', action: t`Skip carousel to previous post`,
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>k</kbd> <kbd>Shift</kbd> + <kbd>k</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Load new posts', action: t`Load new posts`,
keys: <kbd>.</kbd>, keys: <kbd>.</kbd>,
}, },
{ {
action: 'Open post details', action: t`Open post details`,
keys: ( keys: (
<> <Trans>
<kbd>Enter</kbd> or <kbd>o</kbd> <kbd>Enter</kbd> or <kbd>o</kbd>
</> </Trans>
), ),
}, },
{ {
action: ( action: (
<> <Trans>
Expand content warning or Expand content warning or
<br /> <br />
toggle expanded/collapsed thread toggle expanded/collapsed thread
</> </Trans>
), ),
keys: <kbd>x</kbd>, keys: <kbd>x</kbd>,
}, },
{ {
action: 'Close post or dialogs', action: t`Close post or dialogs`,
keys: ( keys: (
<> <Trans>
<kbd>Esc</kbd> or <kbd>Backspace</kbd> <kbd>Esc</kbd> or <kbd>Backspace</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Focus column in multi-column mode', action: t`Focus column in multi-column mode`,
keys: ( keys: (
<> <Trans>
<kbd>1</kbd> to <kbd>9</kbd> <kbd>1</kbd> to <kbd>9</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Compose new post', action: t`Focus next column in multi-column mode`,
keys: <kbd>]</kbd>,
},
{
action: t`Focus previous column in multi-column mode`,
keys: <kbd>[</kbd>,
},
{
action: t`Compose new post`,
keys: <kbd>c</kbd>, keys: <kbd>c</kbd>,
}, },
{ {
action: 'Compose new post (new window)', action: t`Compose new post (new window)`,
className: 'insignificant', className: 'insignificant',
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>c</kbd> <kbd>Shift</kbd> + <kbd>c</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Send post', action: t`Send post`,
keys: ( keys: (
<> <Trans>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '} <kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd> <kbd>Enter</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Search', action: t`Search`,
keys: <kbd>/</kbd>, keys: <kbd>/</kbd>,
}, },
{ {
action: 'Reply', action: t`Reply`,
keys: <kbd>r</kbd>, keys: <kbd>r</kbd>,
}, },
{ {
action: 'Reply (new window)', action: t`Reply (new window)`,
className: 'insignificant', className: 'insignificant',
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>r</kbd> <kbd>Shift</kbd> + <kbd>r</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Like (favourite)', action: t`Like (favourite)`,
keys: ( keys: (
<> <Trans>
<kbd>l</kbd> or <kbd>f</kbd> <kbd>l</kbd> or <kbd>f</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Boost', action: t`Boost`,
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>b</kbd> <kbd>Shift</kbd> + <kbd>b</kbd>
</> </Trans>
), ),
}, },
{ {
action: 'Bookmark', action: t`Bookmark`,
keys: <kbd>d</kbd>, keys: <kbd>d</kbd>,
}, },
{ {
action: 'Toggle Cloak mode', action: t`Toggle Cloak mode`,
keys: ( keys: (
<> <Trans>
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd> <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
</> </Trans>
), ),
}, },
].map(({ action, className, keys }) => ( ].map(({ action, className, keys }) => (
@ -182,6 +194,7 @@ export default memo(function KeyboardShortcutsHelp() {
<td>{keys}</td> <td>{keys}</td>
</tr> </tr>
))} ))}
</tbody>
</table> </table>
</main> </main>
</div> </div>

View file

@ -0,0 +1,115 @@
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} &lrm;[{completion}%]
</option>
);
})}
</optgroup>
)}
</select>
</label>
);
}

View file

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

View file

@ -22,15 +22,13 @@ const Link = forwardRef((props, ref) => {
// Handle encodeURIComponent of searchParams values // Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) { if (!!hash && hash !== '/' && hash.includes('?')) {
try { const parsedHash = URL.parse(hash, location.origin); // Fake base URL
const parsedHash = new URL(hash, location.origin); // Fake base URL if (parsedHash?.searchParams?.size) {
if (parsedHash.searchParams.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries()) const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&'); .join('&');
hash = parsedHash.pathname + '?' + searchParamsStr; hash = parsedHash.pathname + '?' + searchParamsStr;
} }
} catch (e) {}
} }
const isActive = hash === to || decodeURIComponent(hash) === to; const isActive = hash === to || decodeURIComponent(hash) === to;

View file

@ -6,7 +6,7 @@
overflow-x: auto; overflow-x: auto;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
mask-image: linear-gradient( mask-image: linear-gradient(
to right, var(--to-forward),
transparent, transparent,
black 16px, black 16px,
black calc(100% - 16px), black calc(100% - 16px),
@ -20,6 +20,9 @@
width: 95vw; width: 95vw;
max-width: calc(320px * 3.3); max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2)); transform: translateX(calc(-50% + var(--main-width) / 2));
&:dir(rtl) {
transform: translateX(calc(50% - var(--main-width) / 2));
}
} }
} }
@ -38,12 +41,16 @@
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
position: absolute; position: absolute;
top: 8px; top: 8px;
left: 0; inset-inline-start: 0;
transform-origin: top left; transform-origin: top left;
transform: rotate(-90deg) translateX(-100%); transform: rotate(-90deg) translateX(-100%);
&:dir(rtl) {
transform-origin: top right;
transform: rotate(90deg) translateX(100%);
}
user-select: none; user-select: none;
background-image: linear-gradient( background-image: linear-gradient(
to left, var(--to-backward),
var(--text-color), var(--text-color),
var(--link-color) var(--link-color)
); );
@ -53,10 +60,10 @@
} }
} }
a { a.link-block {
min-width: 240px; width: 240px;
flex-grow: 1; flex-shrink: 0;
max-width: 320px; /* max-width: 320px; */
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border-radius: 16px; border-radius: 16px;
@ -74,7 +81,7 @@
background-clip: border-box; background-clip: border-box;
background-origin: border-box; background-origin: border-box;
min-height: 160px; min-height: 160px;
height: 320px; height: 340px;
max-height: 50vh; max-height: 50vh;
&:not(:active):is(:hover, :focus-visible) { &:not(:active):is(:hover, :focus-visible) {
@ -95,6 +102,35 @@
filter: brightness(0.8); 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 { article {
width: 100%; width: 100%;
display: flex; display: flex;
@ -187,10 +223,29 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
font-size: 90%; font-size: 90%;
&.more-lines {
-webkit-line-clamp: 3;
}
} }
hr { hr {
margin: 4px 0; 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;
}
}
} }
} }

View file

@ -1,6 +1,8 @@
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 supports from '../utils/supports';
import Icon from './icon'; import Icon from './icon';
@ -22,17 +24,19 @@ function ListAddEdit({ list, onClose }) {
} }
} }
}, [editMode]); }, [editMode]);
const supportsExclusive = supports('@mastodon/list-exclusive'); const supportsExclusive =
supports('@mastodon/list-exclusive') ||
supports('@gotosocial/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" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)}{' '} )}{' '}
<header> <header>
<h2>{editMode ? 'Edit list' : 'New list'}</h2> <h2>{editMode ? t`Edit list` : t`New list`}</h2>
</header> </header>
<main> <main>
<form <form
@ -75,11 +79,21 @@ function ListAddEdit({ list, onClose }) {
state: 'success', state: 'success',
list: listResult, list: listResult,
}); });
setTimeout(() => {
if (editMode) {
updateListStore(listResult);
} else {
addListStore(listResult);
}
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');
alert( alert(
editMode ? 'Unable to edit list.' : 'Unable to create list.', editMode
? t`Unable to edit list.`
: t`Unable to create list.`,
); );
} }
})(); })();
@ -87,7 +101,7 @@ function ListAddEdit({ list, onClose }) {
> >
<div class="list-form-row"> <div class="list-form-row">
<label for="list-title"> <label for="list-title">
Name{' '} <Trans>Name</Trans>{' '}
<input <input
ref={nameFieldRef} ref={nameFieldRef}
type="text" type="text"
@ -106,9 +120,15 @@ function ListAddEdit({ list, onClose }) {
required required
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
<option value="list">Show replies to list members</option> <option value="list">
<option value="followed">Show replies to people I follow</option> <Trans>Show replies to list members</Trans>
<option value="none">Don't show replies</option> </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 && ( {supportsExclusive && (
@ -120,20 +140,20 @@ function ListAddEdit({ list, onClose }) {
name="exclusive" name="exclusive"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
/>{' '} />{' '}
Hide posts on this list from Home/Following <Trans>Hide posts on this list from Home/Following</Trans>
</label> </label>
</div> </div>
)} )}
<div class="list-form-footer"> <div class="list-form-footer">
<button type="submit" disabled={uiState === 'loading'}> <button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'} {editMode ? t`Save` : t`Create`}
</button> </button>
{editMode && ( {editMode && (
<MenuConfirm <MenuConfirm
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
align="end" align="end"
menuItemClassName="danger" menuItemClassName="danger"
confirmLabel="Delete this list?" confirmLabel={t`Delete this list?`}
onClick={() => { onClick={() => {
// const yes = confirm('Delete this list?'); // const yes = confirm('Delete this list?');
// if (!yes) return; // if (!yes) return;
@ -146,10 +166,13 @@ function ListAddEdit({ list, onClose }) {
onClose?.({ onClose?.({
state: 'deleted', state: 'deleted',
}); });
setTimeout(() => {
deleteListStore(list.id);
}, 1);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');
alert('Unable to delete list.'); alert(t`Unable to delete list.`);
} }
})(); })();
}} }}
@ -159,7 +182,7 @@ function ListAddEdit({ list, onClose }) {
class="light danger" class="light danger"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
Delete <Trans>Delete</Trans>
</button> </button>
</MenuConfirm> </MenuConfirm>
)} )}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { Menu, MenuItem } from '@szhsin/react-menu'; import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -29,17 +30,19 @@ export default function MediaAltModal({ alt, lang, onClose }) {
<div class="sheet" tabindex="-1"> <div class="sheet" tabindex="-1">
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}> <button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<header class="header-grid"> <header class="header-grid">
<h2>Media description</h2> <h2>
<Trans>Media description</Trans>
</h2>
<div class="header-side"> <div class="header-side">
<Menu2 <Menu2
align="end" align="end"
menuButton={ menuButton={
<button type="button" class="plain4"> <button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" /> <Icon icon="more" alt={t`More`} size="xl" />
</button> </button>
} }
> >
@ -50,7 +53,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
}} }}
> >
<Icon icon="translate" /> <Icon icon="translate" />
<span>Translate</span> <span>
<Trans>Translate</Trans>
</span>
</MenuItem> </MenuItem>
{supportsTTS && ( {supportsTTS && (
<MenuItem <MenuItem
@ -59,7 +64,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
}} }}
> >
<Icon icon="speak" /> <Icon icon="speak" />
<span>Speak</span> <span>
<Trans>Speak</Trans>
</span>
</MenuItem> </MenuItem>
)} )}
</Menu2> </Menu2>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { MenuDivider, MenuItem } from '@szhsin/react-menu'; import { MenuDivider, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { import {
@ -9,15 +10,17 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; import { oklch2rgb, rgb2oklch } from '../utils/color-utils';
import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store';
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 Menu2 from './menu2';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import Menu2 from './menu2';
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
@ -53,11 +56,11 @@ 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, left: scrollLeft * (isRTL() ? -1 : 1),
behavior: differentStatusID ? 'auto' : 'smooth', behavior: differentStatusID ? 'auto' : 'smooth',
}); });
carouselRef.current.focus();
}, [index, statusID]); }, [index, statusID]);
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
@ -91,7 +94,7 @@ function MediaModal({
useEffect(() => { useEffect(() => {
let handleScroll = () => { let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current; const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(scrollLeft / clientWidth); const index = Math.round(Math.abs(scrollLeft) / clientWidth);
setCurrentIndex(index); setCurrentIndex(index);
}; };
if (carouselRef.current) { if (carouselRef.current) {
@ -113,39 +116,64 @@ function MediaModal({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const mediaAccentColors = useMemo(() => { const mediaOkColors = useMemo(() => {
return mediaAttachments?.map((media) => { return mediaAttachments?.map((media) => {
const { blurhash } = media; const { blurhash } = media;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor); return rgb2oklch(averageColor);
return oklab2rgb([0.6, labAverageColor[1], labAverageColor[2]]);
} }
return null; return null;
}); });
}, [mediaAttachments]); }, [mediaAttachments]);
const mediaAccentGradient = useMemo(() => { const mediaAccentColors = useMemo(() => {
return mediaOkColors?.map((okColor) => {
if (okColor) {
return {
light: oklch2rgb([0.95, 0.01, okColor[2]]),
dark: oklch2rgb([0.35, 0.01, okColor[2]]),
default: oklch2rgb([0.6, okColor[1], okColor[2]]),
};
}
return null;
});
});
const mediaAccentGradients = useMemo(() => {
const gap = 5; const gap = 5;
const range = 100 / mediaAccentColors.length; const range = 100 / mediaAccentColors.length;
return ( const colors = mediaAccentColors.map((color, i) => {
mediaAccentColors
?.map((color, i) => {
const start = i * range + gap; const start = i * range + gap;
const end = (i + 1) * range - gap; const end = (i + 1) * range - gap;
if (color) { if (color?.light && color?.dark) {
return ` return {
rgba(${color?.join(',')}, 0.4) ${start}%, light: `
rgba(${color?.join(',')}, 0.4) ${end}% rgb(${color.light?.join(',')}) ${start}%,
`; rgb(${color.light?.join(',')}) ${end}%
`,
dark: `
rgb(${color.dark?.join(',')}) ${start}%,
rgb(${color.dark?.join(',')}) ${end}%
`,
};
} }
return ` return {
light: `
transparent ${start}%, transparent ${start}%,
transparent ${end}% transparent ${end}%
`; `,
}) dark: `
?.join(', ') || 'transparent' transparent ${start}%,
); transparent ${end}%
`,
};
});
const lightGradient = colors.map((color) => color.light).join(', ');
const darkGradient = colors.map((color) => color.dark).join(', ');
return {
light: lightGradient,
dark: darkGradient,
};
}, [mediaAccentColors]); }, [mediaAccentColors]);
let toastRef = useRef(null); let toastRef = useRef(null);
@ -155,6 +183,46 @@ function MediaModal({
}; };
}, []); }, []);
useLayoutEffect(() => {
const currentColor = mediaAccentColors[currentIndex];
let $meta;
let metaColor;
if (currentColor) {
const theme = store.local.get('theme');
if (theme) {
const mediaColor = `rgb(${currentColor[theme].join(',')})`;
console.log({ mediaColor });
$meta = document.querySelector(
`meta[name="theme-color"][data-theme-setting="manual"]`,
);
if ($meta) {
metaColor = $meta.content;
$meta.content = mediaColor;
}
} else {
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
const mediaColor = `rgb(${currentColor[colorScheme].join(',')})`;
console.log({ mediaColor });
$meta = document.querySelector(
`meta[name="theme-color"][media*="${colorScheme}"]`,
);
if ($meta) {
metaColor = $meta.content;
$meta.content = mediaColor;
}
}
}
return () => {
// Reset meta color
if ($meta && metaColor) {
$meta.content = metaColor;
}
};
}, [currentIndex, mediaAccentColors]);
return ( return (
<div <div
class={`media-modal-container media-modal-count-${mediaAttachments?.length}`} class={`media-modal-container media-modal-count-${mediaAttachments?.length}`}
@ -177,8 +245,10 @@ function MediaModal({
mediaAttachments.length > 1 mediaAttachments.length > 1
? { ? {
backgroundAttachment: 'local', backgroundAttachment: 'local',
backgroundImage: `linear-gradient( '--accent-gradient-light': mediaAccentGradients?.light,
to right, ${mediaAccentGradient})`, '--accent-gradient-dark': mediaAccentGradients?.dark,
// backgroundImage: `linear-gradient(
// to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
} }
: {} : {}
} }
@ -192,8 +262,14 @@ function MediaModal({
style={ style={
accentColor accentColor
? { ? {
'--accent-color': `rgb(${accentColor?.join(',')})`, '--accent-color': `rgb(${accentColor.default.join(',')})`,
'--accent-alpha-color': `rgba(${accentColor?.join( '--accent-light-color': `rgb(${accentColor.light?.join(
',',
)})`,
'--accent-dark-color': `rgb(${accentColor.dark?.join(
',',
)})`,
'--accent-alpha-color': `rgba(${accentColor.default.join(
',', ',',
)}, 0.4)`, )}, 0.4)`,
} }
@ -242,7 +318,7 @@ function MediaModal({
class="carousel-button" class="carousel-button"
onClick={() => onClose()} onClick={() => onClose()}
> >
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
</span> </span>
{mediaAttachments?.length > 1 ? ( {mediaAttachments?.length > 1 ? (
@ -256,14 +332,13 @@ function MediaModal({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
carouselRef.current.scrollTo({ const left =
left: carouselRef.current.clientWidth * i, carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1);
behavior: 'smooth',
});
carouselRef.current.focus(); carouselRef.current.focus();
carouselRef.current.scrollTo({ left, behavior: 'smooth' });
}} }}
> >
<Icon icon="round" size="s" /> <Icon icon="round" size="s" alt="⸱" />
</button> </button>
))} ))}
</span> </span>
@ -279,7 +354,7 @@ function MediaModal({
menuClassName="glass-menu" menuClassName="glass-menu"
menuButton={ menuButton={
<button type="button" class="carousel-button"> <button type="button" class="carousel-button">
<Icon icon="more" alt="More" /> <Icon icon="more" alt={t`More`} />
</button> </button>
} }
> >
@ -290,10 +365,12 @@ function MediaModal({
} }
class="carousel-button" class="carousel-button"
target="_blank" target="_blank"
title="Open original media in new window" title={t`Open original media in new window`}
> >
<Icon icon="popout" /> <Icon icon="popout" />
<span>Open original media</span> <span>
<Trans>Open original media</Trans>
</span>
</MenuLink> </MenuLink>
{import.meta.env.DEV && // Only dev for now {import.meta.env.DEV && // Only dev for now
!!states.settings.mediaAltGenerator && !!states.settings.mediaAltGenerator &&
@ -308,7 +385,7 @@ function MediaModal({
onClick={() => { onClick={() => {
setUIState('loading'); setUIState('loading');
toastRef.current = showToast({ toastRef.current = showToast({
text: 'Attempting to describe image. Please wait...', text: t`Attempting to describe image. Please wait…`,
duration: -1, duration: -1,
}); });
(async function () { (async function () {
@ -323,7 +400,7 @@ function MediaModal({
}; };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('Failed to describe image'); showToast(t`Failed to describe image`);
} finally { } finally {
setUIState('default'); setUIState('default');
toastRef.current?.hideToast?.(); toastRef.current?.hideToast?.();
@ -332,7 +409,9 @@ function MediaModal({
}} }}
> >
<Icon icon="sparkles2" /> <Icon icon="sparkles2" />
<span>Describe image</span> <span>
<Trans>Describe image</Trans>
</span>
</MenuItem> </MenuItem>
</> </>
)} )}
@ -353,7 +432,10 @@ function MediaModal({
// } // }
// }} // }}
> >
<span class="button-label">View post </span>&raquo; <span class="button-label">
<Trans>View post</Trans>{' '}
</span>
&raquo;
</Link> </Link>
</span> </span>
</div> </div>
@ -368,12 +450,15 @@ function MediaModal({
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus(); carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex - 1), left:
carouselRef.current.clientWidth *
(currentIndex - 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
> >
<Icon icon="arrow-left" /> <Icon icon="arrow-left" alt={t`Previous`} />
</button> </button>
<button <button
type="button" type="button"
@ -384,12 +469,15 @@ function MediaModal({
e.stopPropagation(); e.stopPropagation();
carouselRef.current.focus(); carouselRef.current.focus();
carouselRef.current.scrollTo({ carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex + 1), left:
carouselRef.current.clientWidth *
(currentIndex + 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
> >
<Icon icon="arrow-right" /> <Icon icon="arrow-right" alt={t`Next`} />
</button> </button>
</div> </div>
)} )}

View file

@ -23,7 +23,7 @@
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; inset-inline-start: 0;
z-index: 1; z-index: 1;
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
margin: 8px; margin: 8px;

View file

@ -1,5 +1,6 @@
import './media-post.css'; import './media-post.css';
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useContext, useMemo } from 'preact/hooks'; import { useContext, useMemo } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -8,6 +9,7 @@ import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters'; import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import Media from './media'; import Media from './media';
@ -88,7 +90,7 @@ function MediaPost({
}; };
const currentAccount = useMemo(() => { const currentAccount = useMemo(() => {
return store.session.get('currentAccount'); return getCurrentAccountID();
}, []); }, []);
const isSelf = useMemo(() => { const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId; return currentAccount && currentAccount === accountId;
@ -107,7 +109,7 @@ function MediaPost({
const readingExpandMedia = useMemo(() => { const readingExpandMedia = useMemo(() => {
// default | show_all | hide_all // default | show_all | hide_all
const prefs = store.account.get('preferences') || {}; const prefs = store.account.get('preferences') || {};
return prefs['reading:expand:media'] || 'default'; return prefs['reading:expand:media']?.toLowerCase() || 'default';
}, []); }, []);
const showSpoilerMedia = readingExpandMedia === 'show_all'; const showSpoilerMedia = readingExpandMedia === 'show_all';
@ -122,11 +124,13 @@ function MediaPost({
onMouseEnter={debugHover} onMouseEnter={debugHover}
key={mediaKey} key={mediaKey}
data-spoiler-text={ data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined) spoilerText || (sensitive ? t`Sensitive media` : undefined)
} }
data-filtered-text={ data-filtered-text={
filterInfo filterInfo
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}` ? filterTitleStr
? t`Filtered: ${filterTitleStr}`
: t`Filtered`
: undefined : undefined
} }
class={` class={`

View file

@ -1,5 +1,7 @@
import { t, Trans } from '@lingui/macro';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { import {
useCallback, useCallback,
useLayoutEffect, useLayoutEffect,
@ -9,12 +11,12 @@ 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 mem from '../utils/mem';
import states from '../utils/states'; 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
@ -45,7 +47,7 @@ const AltBadge = (props) => {
lang, lang,
}; };
}} }}
title="Media description" title={t`Media description`}
> >
{dataAltLabel} {dataAltLabel}
{!!index && <sup>{index}</sup>} {!!index && <sup>{index}</sup>}
@ -72,9 +74,10 @@ function Media({
showCaption, showCaption,
allowLongerCaption, allowLongerCaption,
altIndex, altIndex,
checkAspectRatio = true,
onClick = () => {}, onClick = () => {},
}) { }) {
const { let {
blurhash, blurhash,
description, description,
meta, meta,
@ -84,15 +87,27 @@ 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 ? original?.width : small?.width; const width = showOriginal
const height = showOriginal ? original?.height : small?.height; ? original?.width
: 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 orientation = width >= height ? 'landscape' : 'portrait'; const hasDimensions = width && height;
const orientation = hasDimensions
? width > height
? 'landscape'
: 'portrait'
: null;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -133,7 +148,8 @@ function Media({
enabled: pinchZoomEnabled, enabled: pinchZoomEnabled,
draggableUnZoomed: false, draggableUnZoomed: false,
inertiaFriction: 0.9, inertiaFriction: 0.9,
doubleTapZoomOutOnMaxScale: true, tapZoomFactor: 2,
doubleTapToggleZoom: true,
containerProps: { containerProps: {
className: 'media-zoom', className: 'media-zoom',
style: { style: {
@ -153,7 +169,7 @@ function Media({
[to], [to],
); );
const remoteMediaURLObj = remoteMediaURL ? new URL(remoteMediaURL) : null; const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null;
const isVideoMaybe = const isVideoMaybe =
type === 'unknown' && type === 'unknown' &&
remoteMediaURLObj && remoteMediaURLObj &&
@ -235,6 +251,8 @@ function Media({
); );
}; };
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';
@ -259,7 +277,8 @@ function Media({
class={`media media-image ${className}`} class={`media media-image ${className}`}
onClick={onClick} onClick={onClick}
data-orientation={orientation} data-orientation={orientation}
data-has-alt={!showInlineDesc} data-has-alt={!showInlineDesc || undefined}
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
style={ style={
showOriginal showOriginal
? { ? {
@ -290,7 +309,11 @@ function Media({
}} }}
onError={(e) => { onError={(e) => {
const { src } = e.target; const { src } = e.target;
if (src === mediaURL && mediaURL !== remoteMediaURL) { if (
src === mediaURL &&
remoteMediaURL &&
mediaURL !== remoteMediaURL
) {
e.target.src = remoteMediaURL; e.target.src = remoteMediaURL;
} }
}} }}
@ -321,6 +344,48 @@ function Media({
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 (checkAspectRatio && $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;
@ -338,6 +403,7 @@ function Media({
</Figure> </Figure>
); );
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) { } else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
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
@ -347,6 +413,28 @@ function Media({
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
const showProgress = original.duration > 5; const showProgress = original.duration > 5;
// This string is only for autoplay + muted to work on Mobile Safari
const gifHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
muted
playsinline
${loopable ? 'loop' : ''}
ondblclick="this.paused ? this.play() : this.pause()"
${
showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
`;
const videoHTML = ` const videoHTML = `
<video <video
src="${url}" src="${url}"
@ -356,16 +444,9 @@ function Media({
data-orientation="${orientation}" data-orientation="${orientation}"
preload="auto" preload="auto"
autoplay autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
playsinline playsinline
loop="${loopable}" ${loopable ? 'loop' : ''}
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''} controls
${
isGIF && showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video> ></video>
`; `;
@ -379,8 +460,10 @@ function Media({
data-formatted-duration={ data-formatted-duration={
!showOriginal ? formattedDuration : undefined !showOriginal ? formattedDuration : undefined
} }
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''} data-label={
data-has-alt={!showInlineDesc} isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined
}
data-has-alt={!showInlineDesc || undefined}
// style={{ // style={{
// backgroundColor: // backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, // rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -429,17 +512,22 @@ function Media({
<div <div
ref={mediaRef} ref={mediaRef}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: videoHTML, __html: gifHTML,
}} }}
/> />
</QuickPinchZoom> </QuickPinchZoom>
) : ( ) : isGIF ? (
<div <div
class="video-container" class="video-container"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: videoHTML, __html: gifHTML,
}} }}
/> />
) : (
<div
class="video-container"
dangerouslySetInnerHTML={{ __html: videoHTML }}
/>
) )
) : isGIF ? ( ) : isGIF ? (
<video <video
@ -473,6 +561,7 @@ function Media({
/> />
) : ( ) : (
<> <>
{previewUrl ? (
<img <img
src={previewUrl} src={previewUrl}
alt={showInlineDesc ? '' : description} alt={showInlineDesc ? '' : description}
@ -480,9 +569,55 @@ function Media({
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" /> <Icon icon="play" size="xl" alt="▶" />
</div> </div>
</> </>
)} )}
@ -501,12 +636,12 @@ function Media({
data-formatted-duration={ data-formatted-duration={
!showOriginal ? formattedDuration : undefined !showOriginal ? formattedDuration : undefined
} }
data-has-alt={!showInlineDesc} data-has-alt={!showInlineDesc || undefined}
onClick={onClick} onClick={onClick}
style={!showOriginal && mediaStyles} 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}
@ -526,7 +661,7 @@ function Media({
{!showOriginal && ( {!showOriginal && (
<> <>
<div class="media-play"> <div class="media-play">
<Icon icon="play" size="xl" /> <Icon icon="play" size="xl" alt="▶" />
</div> </div>
{!showInlineDesc && ( {!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} /> <AltBadge alt={description} lang={lang} index={altIndex} />
@ -539,4 +674,19 @@ function Media({
} }
} }
export default Media; function getURLObj(url) {
// Fake base URL if url doesn't have https:// prefix
return URL.parse(url, location.origin);
}
export default memo(Media, (oldProps, newProps) => {
const oldMedia = oldProps.media || {};
const newMedia = newProps.media || {};
return (
oldMedia?.id === newMedia?.id &&
oldMedia.url === newMedia.url &&
oldProps.to === newProps.to &&
oldProps.class === newProps.class
);
});

View file

@ -1,8 +1,8 @@
import { MenuItem, SubMenu } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { cloneElement } from 'preact'; import { cloneElement } from 'preact';
import { useRef } from 'preact/hooks';
import Menu2 from './menu2'; import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function MenuConfirm({ function MenuConfirm({
subMenu = false, subMenu = false,
@ -23,11 +23,9 @@ function MenuConfirm({
} }
return children; return children;
} }
const Parent = subMenu ? SubMenu : Menu2; const Parent = subMenu ? SubMenu2 : Menu2;
const menuRef = useRef();
return ( return (
<Parent <Parent
instanceRef={menuRef}
openTrigger="clickOnly" openTrigger="clickOnly"
direction="bottom" direction="bottom"
overflow="auto" overflow="auto"
@ -37,19 +35,6 @@ function MenuConfirm({
{...restProps} {...restProps}
menuButton={subMenu ? undefined : children} menuButton={subMenu ? undefined : children}
label={subMenu ? children : undefined} label={subMenu ? children : undefined}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
> >
<MenuItem className={menuItemClassName} onClick={onClick}> <MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel} {confirmLabel}

View file

@ -1,21 +1,33 @@
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 } = props; const { containerProps, instanceRef: _instanceRef, align } = props;
const size = useWindowSize(); const size = useWindowSize();
const instanceRef = _instanceRef?.current ? _instanceRef : useRef(); const instanceRef = _instanceRef?.current ? _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 unmountOnClose
{...props} {...props}
align={rtlAlign}
instanceRef={instanceRef} instanceRef={instanceRef}
containerProps={{ containerProps={{
onClick: (e) => { onClick: (e) => {

View file

@ -1,7 +1,7 @@
#modal-container > div { #modal-container > div {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; inset-inline-end: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
@ -10,17 +10,65 @@
align-items: center; align-items: center;
background-color: var(--backdrop-color); background-color: var(--backdrop-color);
animation: appear 0.5s var(--timing-function) both; animation: appear 0.5s var(--timing-function) both;
transition: all 0.5s var(--timing-function);
&.solid { &.solid {
background-color: var(--backdrop-solid-color); background-color: var(--backdrop-solid-color);
} }
--compose-button-dimension: 56px;
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
--compose-button-dimension-margin: 16px;
&.min {
/* Minimized */
pointer-events: none;
user-select: none;
overflow: hidden;
transform: scale(0);
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-right)
);
:dir(rtl) & {
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-left)
);
}
--bottom: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-bottom)
);
--origin-end: calc(
100% - var(--compose-button-dimension-half) - var(--end)
);
:dir(rtl) & {
--origin-end: calc(var(--compose-button-dimension-half) + var(--end));
}
--origin-bottom: calc(
100% - var(--compose-button-dimension-half) - var(--bottom)
);
transform-origin: var(--origin-end) var(--origin-bottom);
}
.sheet { .sheet {
transition: transform 0.3s var(--timing-function); transition: transform 0.3s var(--timing-function);
transform-origin: center bottom; transform-origin: 80% 80%;
} }
&:has(~ div) .sheet { &:has(~ div) .sheet {
transform: scale(0.975); 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
);
}
}

View file

@ -1,14 +1,21 @@
import './modal.css'; import './modal.css';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useLayoutEffect, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import store from '../utils/store';
import useCloseWatcher from '../utils/useCloseWatcher'; import useCloseWatcher from '../utils/useCloseWatcher';
const $modalContainer = document.getElementById('modal-container'); const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClose, onClick, class: className }) { function getBackdropThemeColor() {
return getComputedStyle(document.documentElement).getPropertyValue(
'--backdrop-theme-color',
);
}
function Modal({ children, onClose, onClick, class: className, minimized }) {
if (!children) return null; if (!children) return null;
const modalRef = useRef(); const modalRef = useRef();
@ -41,11 +48,82 @@ function Modal({ children, onClose, onClick, class: className }) {
); );
useCloseWatcher(onClose, [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 $meta = useRef();
const metaColor = useRef();
useLayoutEffect(() => {
if (children && !minimized) {
const theme = store.local.get('theme');
if (theme) {
const backdropColor = getBackdropThemeColor();
console.log({ backdropColor });
$meta.current = document.querySelector(
`meta[name="theme-color"][data-theme-setting="manual"]`,
);
if ($meta.current) {
metaColor.current = $meta.current.content;
$meta.current.content = backdropColor;
}
} else {
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
const backdropColor = getBackdropThemeColor();
console.log({ backdropColor });
$meta.current = document.querySelector(
`meta[name="theme-color"][media*="${colorScheme}"]`,
);
if ($meta.current) {
metaColor.current = $meta.current.content;
$meta.current.content = backdropColor;
}
}
} else {
// Reset meta color
if ($meta.current && metaColor.current) {
$meta.current.content = metaColor.current;
}
}
return () => {
// Reset meta color
if ($meta.current && metaColor.current) {
$meta.current.content = metaColor.current;
}
};
}, [children, minimized]);
const Modal = ( const Modal = (
<div <div
ref={(node) => { ref={(node) => {
modalRef.current = node; modalRef.current = node;
escRef.current = node?.querySelector?.('[tabindex="-1"]') || node; escRef(node?.querySelector?.('[tabindex="-1"]') || node);
}} }}
className={className} className={className}
onClick={(e) => { onClick={(e) => {
@ -54,7 +132,8 @@ function Modal({ children, onClose, onClick, class: className }) {
onClose?.(e); onClose?.(e);
} }
}} }}
tabIndex="-1" tabIndex={minimized ? 0 : '-1'}
inert={minimized}
onFocus={(e) => { onFocus={(e) => {
try { try {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {

View file

@ -1,3 +1,5 @@
import { t, Trans } from '@lingui/macro';
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio'; import { subscribe, useSnapshot } from 'valtio';
@ -8,7 +10,7 @@ import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import AccountSheet from './account-sheet'; import AccountSheet from './account-sheet';
import Compose from './compose'; import ComposeSuspense, { preload } from './compose-suspense';
import Drafts from './drafts'; import Drafts from './drafts';
import EmbedModal from './embed-modal'; import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts'; import GenericAccounts from './generic-accounts';
@ -32,11 +34,18 @@ export default function Modals() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useEffect(() => {
setTimeout(preload, 1000);
}, []);
return ( return (
<> <>
{!!snapStates.showCompose && ( {!!snapStates.showCompose && (
<Modal class="solid"> <Modal
<Compose class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
minimized={!!snapStates.composerState.minimized}
>
<ComposeSuspense
replyToStatus={ replyToStatus={
typeof snapStates.showCompose !== 'boolean' typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus ? snapStates.showCompose.replyToStatus
@ -60,9 +69,9 @@ export default function Modals() {
states.reloadStatusPage++; states.reloadStatusPage++;
showToast({ showToast({
text: { text: {
post: 'Post published. Check it out.', post: t`Post published. Check it out.`,
reply: 'Reply posted. Check it out.', reply: t`Reply posted. Check it out.`,
edit: 'Post updated. Check it out.', edit: t`Post updated. Check it out.`,
}[type || 'post'], }[type || 'post'],
delay: 1000, delay: 1000,
duration: 10_000, // 10 seconds duration: 10_000, // 10 seconds
@ -179,7 +188,9 @@ export default function Modals() {
excludeRelationshipAttrs={ excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs snapStates.showGenericAccounts.excludeRelationshipAttrs
} }
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)} onClose={() => (states.showGenericAccounts = false)}
blankCopy={snapStates.showGenericAccounts.blankCopy}
/> />
</Modal> </Modal>
)} )}

View file

@ -5,9 +5,14 @@
unicode-bidi: isolate; unicode-bidi: isolate;
b { b {
font-weight: 500; font-weight: 600;
unicode-bidi: isolate; 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;

View file

@ -1,16 +1,31 @@
import './name-text.css'; import './name-text.css';
import { useLingui } from '@lingui/react';
import { memo } from 'preact/compat'; 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 = new Intl.Collator('en', { const nameCollator = mem((locale) => {
const options = {
sensitivity: 'base', 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,
@ -20,42 +35,64 @@ function NameText({
external, external,
onClick, onClick,
}) { }) {
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } = const { i18n } = useLingui();
account; const {
let { username } = account; acct,
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; 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(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1 .replace(SHORTCODES_REGEX, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, ''); // E.g. "My name" === "myname" .replace(SPACES_REGEX, ''); // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace( const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9]/gi, NON_ALPHA_NUMERIC_REGEX,
'', '',
); // Remove non-alphanumeric characters ); // Remove non-alphanumeric characters
if ( const hideUsername =
!short && (!short &&
(trimmedUsername === trimmedDisplayName || (trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName || trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName || trimmedUsername === shortenedAlphaNumericDisplayName ||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0) nameCollator(i18n.locale).compare(
) { trimmedUsername,
username = null; 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={`${displayName ? `${displayName} ` : ''}@${acct}`} title={
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(); 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,
@ -69,13 +106,13 @@ function NameText({
)} )}
{displayName && !short ? ( {displayName && !short ? (
<> <>
<b> <b dir="auto">
<EmojiText text={displayName} emojis={emojis} /> <EmojiText text={displayName} emojis={emojis} />
</b> </b>
{!showAcct && username && ( {!showAcct && !hideUsername && (
<> <>
{' '} {' '}
<i>@{username}</i> <i class="bidi-isolate">@{username}</i>
</> </>
)} )}
</> </>
@ -87,9 +124,10 @@ function NameText({
{showAcct && ( {showAcct && (
<> <>
<br /> <br />
<i> <i class="bidi-isolate">
@{acct1} {acct2 ? '' : '@'}
<span class="ib">{acct2}</span> {acct1}
{!!acct2 && <span class="ib">{acct2}</span>}
</i> </i>
</> </>
)} )}
@ -97,9 +135,11 @@ function NameText({
); );
} }
export default memo(NameText, (oldProps, newProps) => { export default mem(NameText);
// Only care about account.id, the other props usually don't change
const { account } = oldProps; // export default memo(NameText, (oldProps, newProps) => {
const { account: newAccount } = newProps; // // Only care about account.id, the other props usually don't change
return account?.acct === newAccount?.acct; // const { account } = oldProps;
}); // const { account: newAccount } = newProps;
// return account?.acct === newAccount?.acct;
// });

View file

@ -1,28 +1,39 @@
.nav-menu section:last-child { .nav-menu {
overflow: hidden;
section:last-child {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
margin-bottom: -8px; margin-bottom: -4px;
padding-bottom: 8px; 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: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 50% 50%;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
grid-template-areas: grid-template-areas:
'top top' 'top top'
'left right'; 'left right';
padding: 0; padding: 0;
width: 22em; /* min-width: 22em; */
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
} }
.nav-menu .top-menu { .nav-menu .top-menu {
grid-area: top; grid-area: top;
padding-top: 8px; padding-top: 4px;
margin-bottom: -8px; margin-bottom: -4px;
} }
.nav-menu section { .nav-menu section {
padding: 8px 0; padding: 4px 0;
/* width: 50%; */ /* width: 50%; */
} }
@keyframes phanpying { @keyframes phanpying {
@ -35,11 +46,15 @@
} }
.nav-menu section:last-child { .nav-menu section:last-child {
background-image: linear-gradient( background-image: linear-gradient(
to right, var(--to-forward),
var(--divider-color) 1px, var(--divider-color) 1px,
transparent 1px transparent 1px
), ),
linear-gradient(to bottom left, var(--bg-blur-color), transparent), linear-gradient(
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%; */
@ -49,8 +64,8 @@
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-top-right-radius: inherit; border-start-end-radius: inherit;
border-bottom-right-radius: inherit; border-end-end-radius: inherit;
margin-bottom: 0; margin-bottom: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -88,3 +103,7 @@
.sparkle-icon { .sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate; animation: sparkle-icon 0.3s ease-in-out infinite alternate;
} }
.nav-submenu {
max-width: 14em;
}

View file

@ -1,39 +1,35 @@
import './nav-menu.css'; import './nav-menu.css';
import { import { t, Trans } from '@lingui/macro';
ControlledMenu, import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
MenuDivider,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import supports from '../utils/supports';
import Avatar from './avatar'; import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import SubMenu2 from './submenu2';
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api(); const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState(); const [currentAccount, moreThanOneAccount] = useMemo(() => {
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
const acc = accounts.find( const acc =
(account) => account.info.id === store.session.get('currentAccount'), accounts.find((account) => account.info.id === getCurrentAccountID()) ||
); accounts[0];
if (acc) setCurrentAccount(acc); return [acc, accounts.length > 1];
setMoreThanOneAccount(accounts.length > 1);
}, []); }, []);
// Home = Following // Home = Following
@ -97,7 +93,7 @@ function NavMenu(props) {
type="button" type="button"
class={`button plain nav-menu-button ${ class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : '' moreThanOneAccount ? 'with-avatar' : ''
} ${open ? 'active' : ''}`} } ${menuState === 'open' ? 'active' : ''}`}
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={() => { onClick={() => {
buttonClickTS.current = Date.now(); buttonClickTS.current = Date.now();
@ -118,7 +114,7 @@ function NavMenu(props) {
squircle={currentAccount?.info?.bot} squircle={currentAccount?.info?.bot}
/> />
)} )}
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} /> <Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} alt={t`Menu`} />
</button> </button>
<ControlledMenu <ControlledMenu
menuClassName="nav-menu" menuClassName="nav-menu"
@ -154,7 +150,7 @@ function NavMenu(props) {
<div class="top-menu"> <div class="top-menu">
<MenuItem <MenuItem
onClick={() => { onClick={() => {
const yes = confirm('Reload page now to update?'); const yes = confirm(t`Reload page now to update?`);
if (yes) { if (yes) {
(async () => { (async () => {
try { try {
@ -165,33 +161,51 @@ function NavMenu(props) {
}} }}
> >
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '} <Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
<span>New update available</span> <span>
<Trans>New update available</Trans>
</span>
</MenuItem> </MenuItem>
<MenuDivider /> <MenuDivider />
</div> </div>
)} )}
<section> <section>
<MenuLink to="/"> <MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span> <Icon icon="home" size="l" />{' '}
<span>
<Trans>Home</Trans>
</span>
</MenuLink> </MenuLink>
{authenticated ? ( {authenticated ? (
<> <>
{showFollowing && ( {showFollowing && (
<MenuLink to="/following"> <MenuLink to="/following">
<Icon icon="following" size="l" /> <span>Following</span> <Icon icon="following" size="l" />{' '}
<span>
<Trans id="following.title">Following</Trans>
</span>
</MenuLink> </MenuLink>
)} )}
<MenuLink to="/catchup"> <MenuLink to="/catchup">
<Icon icon="history2" size="l" /> <Icon icon="history2" size="l" />
<span>Catch-up</span> <span>
<Trans>Catch-up</Trans>
</span>
</MenuLink> </MenuLink>
{supports('@mastodon/mentions') && (
<MenuLink to="/mentions"> <MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span> <Icon icon="at" size="l" />{' '}
<span>
<Trans>Mentions</Trans>
</span>
</MenuLink> </MenuLink>
)}
<MenuLink to="/notifications"> <MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span> <Icon icon="notification" size="l" />{' '}
<span>
<Trans>Notifications</Trans>
</span>
{snapStates.notificationsShowNew && ( {snapStates.notificationsShowNew && (
<sup title="New" style={{ opacity: 0.5 }}> <sup title={t`New`} style={{ opacity: 0.5 }}>
{' '} {' '}
&bull; &bull;
</sup> </sup>
@ -200,74 +214,105 @@ function NavMenu(props) {
<MenuDivider /> <MenuDivider />
{currentAccount?.info?.id && ( {currentAccount?.info?.id && (
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}> <MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
<Icon icon="user" size="l" /> <span>Profile</span> <Icon icon="user" size="l" />{' '}
<span>
<Trans>Profile</Trans>
</span>
</MenuLink> </MenuLink>
)} )}
<MenuLink to="/l"> <ListMenu menuState={menuState} />
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
<MenuLink to="/b"> <MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span> <Icon icon="bookmark" size="l" />{' '}
<span>
<Trans>Bookmarks</Trans>
</span>
</MenuLink> </MenuLink>
<SubMenu <SubMenu2
menuClassName="nav-submenu"
overflow="auto" overflow="auto"
gap={-8} gap={-8}
label={ label={
<> <>
<Icon icon="more" size="l" /> <Icon icon="more" size="l" />
<span class="menu-grow">More</span> <span class="menu-grow">
<Trans>More</Trans>
</span>
<Icon icon="chevron-right" /> <Icon icon="chevron-right" />
</> </>
} }
> >
<MenuLink to="/f"> <MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span> <Icon icon="heart" size="l" />{' '}
<span>
<Trans>Likes</Trans>
</span>
</MenuLink> </MenuLink>
<MenuLink to="/ft"> <MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '} <Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span> <span>
<Trans>Followed Hashtags</Trans>
</span>
</MenuLink> </MenuLink>
<MenuDivider /> <MenuDivider />
{supports('@mastodon/filters') && (
<MenuLink to="/ft">
<Icon icon="filters" size="l" />{' '}
<span>
<Trans>Filters</Trans>
</span>
</MenuLink>
)}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showGenericAccounts = { states.showGenericAccounts = {
id: 'mute', id: 'mute',
heading: 'Muted users', heading: t`Muted users`,
fetchAccounts: fetchMutes, fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'], excludeRelationshipAttrs: ['muting'],
}; };
}} }}
> >
<Icon icon="mute" size="l" /> Muted users&hellip; <Icon icon="mute" size="l" />{' '}
<span>
<Trans>Muted users</Trans>
</span>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showGenericAccounts = { states.showGenericAccounts = {
id: 'block', id: 'block',
heading: 'Blocked users', heading: t`Blocked users`,
fetchAccounts: fetchBlocks, fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'], excludeRelationshipAttrs: ['blocking'],
}; };
}} }}
> >
<Icon icon="block" size="l" /> <Icon icon="block" size="l" />{' '}
Blocked users&hellip; <span>
<Trans>Blocked users</Trans>
</span>
</MenuItem>{' '} </MenuItem>{' '}
</SubMenu> </SubMenu2>
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showAccounts = true; states.showAccounts = true;
}} }}
> >
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span> <Icon icon="group" size="l" />{' '}
<span>
<Trans>Accounts</Trans>
</span>
</MenuItem> </MenuItem>
</> </>
) : ( ) : (
<> <>
<MenuDivider /> <MenuDivider />
<MenuLink to="/login"> <MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span> <Icon icon="user" size="l" />{' '}
<span>
<Trans>Log in</Trans>
</span>
</MenuLink> </MenuLink>
</> </>
)} )}
@ -275,16 +320,28 @@ function NavMenu(props) {
<section> <section>
<MenuDivider /> <MenuDivider />
<MenuLink to={`/search`}> <MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span> <Icon icon="search" size="l" />{' '}
<span>
<Trans>Search</Trans>
</span>
</MenuLink> </MenuLink>
<MenuLink to={`/${instance}/trending`}> <MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span> <Icon icon="chart" size="l" />{' '}
<span>
<Trans>Trending</Trans>
</span>
</MenuLink> </MenuLink>
<MenuLink to={`/${instance}/p/l`}> <MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span> <Icon icon="building" size="l" />{' '}
<span>
<Trans>Local</Trans>
</span>
</MenuLink> </MenuLink>
<MenuLink to={`/${instance}/p`}> <MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span> <Icon icon="earth" size="l" />{' '}
<span>
<Trans>Federated</Trans>
</span>
</MenuLink> </MenuLink>
{authenticated ? ( {authenticated ? (
<> <>
@ -295,7 +352,9 @@ function NavMenu(props) {
}} }}
> >
<Icon icon="keyboard" size="l" />{' '} <Icon icon="keyboard" size="l" />{' '}
<span>Keyboard shortcuts</span> <span>
<Trans>Keyboard shortcuts</Trans>
</span>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
@ -303,14 +362,19 @@ function NavMenu(props) {
}} }}
> >
<Icon icon="shortcut" size="l" />{' '} <Icon icon="shortcut" size="l" />{' '}
<span>Shortcuts / Columns&hellip;</span> <span>
<Trans>Shortcuts / Columns</Trans>
</span>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showSettings = true; states.showSettings = true;
}} }}
> >
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span> <Icon icon="gear" size="l" />{' '}
<span>
<Trans>Settings</Trans>
</span>
</MenuItem> </MenuItem>
</> </>
) : ( ) : (
@ -321,7 +385,10 @@ function NavMenu(props) {
states.showSettings = true; states.showSettings = true;
}} }}
> >
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span> <Icon icon="gear" size="l" />{' '}
<span>
<Trans>Settings</Trans>
</span>
</MenuItem> </MenuItem>
</> </>
)} )}
@ -331,4 +398,57 @@ function NavMenu(props) {
); );
} }
function ListMenu({ menuState }) {
const supportsLists = supports('@mastodon/lists');
const [lists, setLists] = useState([]);
useEffect(() => {
if (!supportsLists) return;
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState, supportsLists]);
return 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>
)
);
}
export default memo(NavMenu); export default memo(NavMenu);

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useLayoutEffect, useState } from 'preact/hooks'; import { useLayoutEffect, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -152,14 +153,18 @@ export default memo(function NotificationService() {
> >
<div class="sheet" tabIndex="-1"> <div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
<header> <header>
<b>Notification</b> <b>
<Trans>Notification</Trans>
</b>
</header> </header>
<main> <main>
{!sameInstance && ( {!sameInstance && (
<p>This notification is from your other account.</p> <p>
<Trans>This notification is from your other account.</Trans>
</p>
)} )}
<div <div
class="notification-peek" class="notification-peek"
@ -186,7 +191,10 @@ export default memo(function NotificationService() {
}} }}
> >
<Link to="/notifications" class="button light" onClick={onClose}> <Link to="/notifications" class="button light" onClick={onClose}>
<span>View all notifications</span> <Icon icon="arrow-right" /> <span>
<Trans>View all notifications</Trans>
</span>{' '}
<Icon icon="arrow-right" />
</Link> </Link>
</div> </div>
</main> </main>

View file

@ -1,17 +1,21 @@
import { msg, Plural, Select, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { api } from '../utils/api';
import { isFiltered } from '../utils/filters';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states, { statusKey } from '../utils/states';
import store from '../utils/store'; import { getCurrentAccountID } from '../utils/store-utils';
import useTruncated from '../utils/useTruncated'; 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 = {
@ -25,6 +29,10 @@ const NOTIFICATION_ICONS = {
update: 'pencil', update: 'pencil',
'admin.signup': 'account-edit', 'admin.signup': 'account-edit',
'admin.report': 'account-warning', 'admin.report': 'account-warning',
severed_relationships: 'heart-break',
moderation_warning: 'alert',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
}; };
/* /*
@ -40,32 +48,255 @@ 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 = {
mention: 'mentioned you in their post.', status: ({ account }) => <Trans>{account} published a post.</Trans>,
status: 'published a post.', reblog: ({
reblog: 'boosted your post.', count,
'reblog+account': (count) => `boosted ${count} of your posts.`, account,
reblog_reply: 'boosted your reply.', postsCount,
follow: 'followed you.', postType,
follow_request: 'requested to follow you.', components: { Subject },
favourite: 'liked your post.', }) => (
'favourite+account': (count) => `liked ${count} of your posts.`, <Plural
favourite_reply: 'liked your reply.', value={count}
poll: 'A poll you have voted in or created has ended.', _1={
'poll-self': 'A poll you have created has ended.', <Plural
'poll-voted': 'A poll you have voted in has ended.', value={postsCount}
update: 'A post you interacted with has been edited.', _1={
'favourite+reblog': 'boosted & liked your post.', <Select
'favourite+reblog+account': (count) => value={postType}
`boosted & liked ${count} of your posts.`, _reply={<Trans>{account} boosted your reply.</Trans>}
'favourite+reblog_reply': 'boosted & liked your reply.', other={<Trans>{account} boosted your post.</Trans>}
'admin.sign_up': 'signed up.', />
'admin.report': (targetAccount) => <>reported {targetAccount}</>, }
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,
annual_report: ({ year }) => <Trans>Your {year} #Wrapstodon is here!</Trans>,
}; };
const AVATARS_LIMIT = 50; // account_suspension, domain_block, user_domain_block
const SEVERED_RELATIONSHIPS_TEXT = {
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({ function Notification({
notification, notification,
@ -73,14 +304,38 @@ function Notification({
isStatic, isStatic,
disableContextMenu, disableContextMenu,
}) { }) {
const { id, status, account, report, _accounts, _statuses } = notification; const { _ } = useLingui();
const { masto } = api();
const {
id,
status,
account,
report,
event,
moderation_warning,
annualReport,
// 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 actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id; const actualStatusID = actualStatus?.id;
const currentAccount = store.session.get('currentAccount'); const currentAccount = getCurrentAccountID();
const isSelf = currentAccount === account?.id; const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted; const isVoted = status?.poll?.voted;
const isReplyToOthers = const isReplyToOthers =
@ -91,6 +346,7 @@ function Notification({
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++;
@ -99,6 +355,7 @@ function Notification({
reblogsCount++; reblogsCount++;
} }
} }
}
if (!reblogsCount && favsCount) type = 'favourite'; if (!reblogsCount && favsCount) type = 'favourite';
if (!favsCount && reblogsCount) type = 'reblog'; if (!favsCount && reblogsCount) type = 'reblog';
} }
@ -106,41 +363,73 @@ function Notification({
let text; let text;
if (type === 'poll') { if (type === 'poll') {
text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']; text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll'];
} else if (
type === 'reblog' ||
type === 'favourite' ||
type === 'favourite+reblog'
) {
if (_statuses?.length > 1) {
text = contentText[`${type}+account`];
} else if (isReplyToOthers) {
text = contentText[`${type}_reply`];
} else {
text = contentText[type];
}
} else if (contentText[type]) { } else if (contentText[type]) {
text = contentText[type]; text = contentText[type];
} else { } else {
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances // 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 // This surfaces the error to the user, hoping that users will report it
text = `[Unknown notification type: ${type}]`; text = t`[Unknown notification type: ${type}]`;
} }
const Subject = ({ clickable, ...props }) =>
clickable ? (
<b tabIndex="0" onClick={handleOpenGenericAccounts} {...props} />
) : (
<b {...props} />
);
if (typeof text === 'function') { if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length; const count =
if (count) { _accounts?.length || sampleAccounts?.length || (account ? 1 : 0);
text = text(count); const postsCount = _statuses?.length || (status ? 1 : 0);
} else if (type === 'admin.report') { if (type === 'admin.report') {
const targetAccount = report?.targetAccount; const targetAccount = report?.targetAccount;
if (targetAccount) { if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />); 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 if (type === 'annual_report') {
text = text({
...notification.annualReport,
});
} else {
text = text({
account: account ? (
<NameText account={account} showAvatar />
) : (
sampleAccounts?.[0] && (
<NameText account={sampleAccounts[0]} showAvatar />
)
),
count,
postsCount,
postType: isReplyToOthers ? 'reply' : 'post',
components: { Subject },
});
} }
if (type === 'mention' && !status) {
// Could be deleted
return null;
} }
const formattedCreatedAt = const formattedCreatedAt =
@ -148,26 +437,42 @@ function Notification({
const genericAccountsHeading = const genericAccountsHeading =
{ {
'favourite+reblog': 'Boosted/Liked by…', 'favourite+reblog': t`Boosted/Liked by…`,
favourite: 'Liked by…', favourite: t`Liked by…`,
reblog: 'Boosted by…', reblog: t`Boosted by…`,
follow: 'Followed by…', follow: t`Followed by…`,
}[type] || 'Accounts'; }[type] || t`Accounts`;
const handleOpenGenericAccounts = () => { const handleOpenGenericAccounts = () => {
states.showGenericAccounts = { states.showGenericAccounts = {
heading: genericAccountsHeading, heading: genericAccountsHeading,
accounts: _accounts, accounts: _accounts,
showReactions: type === 'favourite+reblog', showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [], excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
}; };
}; };
console.debug('RENDER Notification', notification.id); 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 ( return (
<div <div
class={`notification notification-${type}`} class={`notification notification-${type}`}
data-notification-id={id} data-notification-id={_ids || id}
data-group-key={_groupKeys?.join(' ') || groupKey}
tabIndex="0" tabIndex="0"
> >
<div <div
@ -190,40 +495,51 @@ function Notification({
<div class="notification-content"> <div class="notification-content">
{type !== 'mention' && ( {type !== 'mention' && (
<> <>
<p> <p>{text}</p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
<span title={_accounts.length}>
{shortenNumber(_accounts.length)}
</span>{' '}
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' && (
<div>
{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>
)}
{type === 'annual_report' && (
<div>
<Link to={`/annual_report/${annualReport?.year}`}>
<Trans>View #Wrapstodon</Trans>
</Link>
</div>
)}
</> </>
)} )}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (
@ -247,11 +563,7 @@ function Notification({
? 'xxl' ? 'xxl'
: _accounts.length < 20 : _accounts.length < 20
? 'xl' ? 'xl'
: _accounts.length < 30 : 'l'
? 'l'
: _accounts.length < 40
? 'm'
: 's' // My god, this person is popular!
} }
key={account.id} key={account.id}
alt={`${account.displayName} @${account.acct}`} alt={`${account.displayName} @${account.acct}`}
@ -271,6 +583,57 @@ function Notification({
</a>{' '} </a>{' '}
</Fragment> </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 <button
type="button" type="button"
class="small plain" class="small plain"
@ -280,6 +643,55 @@ function Notification({
`+${_accounts.length - AVATARS_LIMIT}`} `+${_accounts.length - AVATARS_LIMIT}`}
<Icon icon="chevron-down" /> <Icon icon="chevron-down" />
</button> </button>
)}
</p>
)}
{!_accounts?.length && sampleAccounts?.length > 1 && (
<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
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
class="button small plain centered"
>
+{notificationsCount - sampleAccounts.length}
<Icon icon="chevron-right" />
</Link>
)}
</p> </p>
)} )}
{_statuses?.length > 1 && ( {_statuses?.length > 1 && (
@ -354,7 +766,7 @@ function Notification({
function TruncatedLink(props) { function TruncatedLink(props) {
const ref = useTruncated(); const ref = useTruncated();
return <Link {...props} data-read-more="Read more →" ref={ref} />; return <Link {...props} data-read-more={t`Read more →`} ref={ref} />;
} }
export default memo(Notification, (oldProps, newProps) => { export default memo(Notification, (oldProps, newProps) => {

View file

@ -1,3 +1,4 @@
import { Plural, plural, t, Trans } from '@lingui/macro';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
@ -48,7 +49,7 @@ export default function Poll({
// }; // };
// }, [expired, expiresAtDate]); // }, [expired, expiresAtDate]);
const pollVotesCount = votersCount || votesCount; const pollVotesCount = multiple ? votersCount : votesCount;
let roundPrecision = 0; let roundPrecision = 0;
if (pollVotesCount <= 1000) { if (pollVotesCount <= 1000) {
@ -75,11 +76,15 @@ export default function Poll({
<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 percentage = pollVotesCount const ratio = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed( ? optionVotesCount / pollVotesCount
roundPrecision, : 0;
) const percentage = ratio
: 0; // check if current poll choice is the leading one ? ratio.toLocaleString(i18n.locale || undefined, {
style: 'percent',
maximumFractionDigits: roundPrecision,
})
: '0%';
const isLeading = const isLeading =
optionVotesCount > 0 && optionVotesCount > 0 &&
@ -92,7 +97,7 @@ export default function Poll({
isLeading ? 'poll-option-leading' : '' isLeading ? 'poll-option-leading' : ''
}`} }`}
style={{ style={{
'--percentage': `${percentage}%`, '--percentage': `${ratio * 100}%`,
}} }}
> >
<div class="poll-option-title"> <div class="poll-option-title">
@ -102,17 +107,18 @@ export default function Poll({
{voted && ownVotes.includes(i) && ( {voted && ownVotes.includes(i) && (
<> <>
{' '} {' '}
<Icon icon="check-circle" /> <Icon icon="check-circle" alt={t`Voted`} />
</> </>
)} )}
</div> </div>
<div <div
class="poll-option-votes" class="poll-option-votes"
title={`${optionVotesCount} vote${ title={plural(optionVotesCount, {
optionVotesCount === 1 ? '' : 's' one: `# vote`,
}`} other: `# votes`,
})}
> >
{percentage}% {percentage}
</div> </div>
</div> </div>
); );
@ -127,7 +133,7 @@ export default function Poll({
setShowResults(false); setShowResults(false);
}} }}
> >
<Icon icon="arrow-left" size="s" /> Hide results <Icon icon="arrow-left" size="s" /> <Trans>Hide results</Trans>
</button> </button>
)} )}
</> </>
@ -176,7 +182,7 @@ export default function Poll({
type="submit" type="submit"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
Vote <Trans>Vote</Trans>
</button> </button>
)} )}
</form> </form>
@ -187,9 +193,6 @@ export default function Poll({
type="button" type="button"
class="plain small" class="plain small"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
style={{
marginLeft: -8,
}}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setUIState('loading'); setUIState('loading');
@ -199,9 +202,9 @@ export default function Poll({
setUIState('default'); setUIState('default');
})(); })();
}} }}
title="Refresh" title={t`Refresh`}
> >
<Icon icon="refresh" alt="Refresh" /> <Icon icon="refresh" alt={t`Refresh`} />
</button> </button>
)} )}
{!voted && !expired && !readOnly && optionsHaveVoteCounts && ( {!voted && !expired && !readOnly && optionsHaveVoteCounts && (
@ -213,30 +216,66 @@ export default function Poll({
e.preventDefault(); e.preventDefault();
setShowResults(!showResults); setShowResults(!showResults);
}} }}
title={showResults ? 'Hide results' : 'Show results'} title={showResults ? t`Hide results` : t`Show results`}
> >
<Icon <Icon
icon={showResults ? 'eye-open' : 'eye-close'} icon={showResults ? 'eye-open' : 'eye-close'}
alt={showResults ? 'Hide results' : 'Show results'} alt={showResults ? t`Hide results` : t`Show results`}
/>{' '} />{' '}
</button> </button>
)} )}
{!expired && !readOnly && ' '} {!expired && !readOnly && ' '}
<Plural
value={votesCount}
one={
<Trans>
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote <span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'} </Trans>
}
other={
<Trans>
<span title={votesCount}>{shortenNumber(votesCount)}</span> votes
</Trans>
}
/>
{!!votersCount && votersCount !== votesCount && ( {!!votersCount && votersCount !== votesCount && (
<> <>
{' '} {' '}
&bull; <span title={votersCount}> &bull;{' '}
{shortenNumber(votersCount)} <Plural
</span>{' '} value={votersCount}
one={
<Trans>
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter voter
{votersCount === 1 ? '' : 's'} </Trans>
}
other={
<Trans>
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voters
</Trans>
}
/>
</> </>
)}{' '} )}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '} &bull;{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />} {expired ? (
</p>{' '} !!expiresAtDate ? (
<Trans>
Ended <RelativeTime datetime={expiresAtDate} />
</Trans>
) : (
t`Ended`
)
) : !!expiresAtDate ? (
<Trans>
Ending <RelativeTime datetime={expiresAtDate} />
</Trans>
) : (
t`Ending`
)}
</p>
</div> </div>
); );
} }

View file

@ -1,39 +1,135 @@
// Twitter-style relative time component import { i18n } from '@lingui/core';
// Seconds = 1s import { t, Trans } from '@lingui/macro';
// Minutes = 1m import { useEffect, useMemo, useReducer } from 'preact/hooks';
// 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 { useMemo } from 'preact/hooks';
dayjs.extend(dayjsTwitter); import localeMatch from '../utils/locale-match';
dayjs.extend(localizedFormat); import mem from '../utils/mem';
dayjs.extend(relativeTime);
const dtf = new Intl.DateTimeFormat(); function isValidDate(value) {
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 date = useMemo(() => dayjs(datetime), [datetime]); const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const dateStr = useMemo(() => { const date = useMemo(() => new Date(datetime), [datetime]);
const [dateStr, dt, title] = useMemo(() => {
if (!isValidDate(date)) return ['' + datetime, '', ''];
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 = dayjs(); const now = new Date();
const dayDiff = now.diff(date, 'day'); const dayDiff = (now.getTime() - date.getTime()) / 1000 / day;
if (dayDiff <= 1 || now.year() === date.year()) { if (dayDiff <= 1) {
return date.twitter(); str = twitterFromNow(date);
} else { } else {
return dtf.format(date.toDate()); const sameYear = now.getFullYear() === date.getFullYear();
if (sameYear) {
str = DTF(i18n.locale, {
year: undefined,
month: 'short',
day: 'numeric',
}).format(date);
} else {
str = DTF(i18n.locale, {
dateStyle: 'short',
}).format(date);
} }
} }
return date.fromNow(); }
}, [date, format]); if (!str) str = rtfFromNow(date);
const dt = useMemo(() => date.toISOString(), [date]); return [str, date.toISOString(), date.toLocaleString()];
const title = useMemo(() => date.format('LLLL'), [date]); }, [date, format, renderCount]);
useEffect(() => {
if (!isValidDate(date)) return;
let timeout;
let raf;
function rafRerender() {
raf = requestAnimationFrame(() => {
rerender();
scheduleRerender();
});
}
function scheduleRerender() {
// 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 () => {
clearTimeout(timeout);
cancelAnimationFrame(raf);
};
}, []);
return ( return (
<time datetime={dt} title={title}> <time datetime={dt} title={title}>

View file

@ -26,6 +26,8 @@
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
padding: 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; display: flex;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
@ -41,6 +43,8 @@
main { main {
padding: 0 16px 16px; 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; /* display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; */ gap: 16px; */
@ -88,7 +92,7 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
position: absolute; position: absolute;
right: 32px; inset-inline-end: 32px;
margin-top: -48px; margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both; animation: rubber-stamp 0.3s ease-in both;
position: absolute; position: absolute;
@ -144,7 +148,7 @@
} }
.report-rules { .report-rules {
margin-left: 1.75em; margin-inline-start: 1.75em;
} }
} }

View file

@ -1,5 +1,7 @@
import './report-modal.css'; import './report-modal.css';
import { msg, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Fragment } from 'preact'; import { Fragment } from 'preact';
import { useMemo, useRef, useState } from 'preact/hooks'; import { useMemo, useRef, useState } from 'preact/hooks';
@ -24,26 +26,27 @@ const CATEGORIES_INFO = {
// description: 'Not something you want to see', // description: 'Not something you want to see',
// }, // },
spam: { spam: {
label: 'Spam', label: msg`Spam`,
description: 'Malicious links, fake engagement, or repetitive replies', description: msg`Malicious links, fake engagement, or repetitive replies`,
}, },
legal: { legal: {
label: 'Illegal', label: msg`Illegal`,
description: "Violates the law of your or the server's country", description: msg`Violates the law of your or the server's country`,
}, },
violation: { violation: {
label: 'Server rule violation', label: msg`Server rule violation`,
description: 'Breaks specific server rules', description: msg`Breaks specific server rules`,
stampLabel: 'Violation', stampLabel: msg`Violation`,
}, },
other: { other: {
label: 'Other', label: msg`Other`,
description: "Issue doesn't fit other categories", description: msg`Issue doesn't fit other categories`,
excludeStamp: true, excludeStamp: true,
}, },
}; };
function ReportModal({ account, post, onClose }) { function ReportModal({ account, post, onClose }) {
const { _ } = useLingui();
const { masto } = api(); const { masto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [username, domain] = account.acct.split('@'); const [username, domain] = account.acct.split('@');
@ -62,14 +65,14 @@ function ReportModal({ account, post, onClose }) {
return ( return (
<div class="report-modal-container"> <div class="report-modal-container">
<div class="top-controls"> <div class="top-controls">
<h1>{post ? 'Report Post' : `Report @${username}`}</h1> <h1>{post ? t`Report Post` : t`Report @${username}`}</h1>
<button <button
type="button" type="button"
class="plain4 small" class="plain4 small"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
onClick={() => onClose()} onClick={() => onClose()}
> >
<Icon icon="x" size="xl" /> <Icon icon="x" size="xl" alt={t`Close`} />
</button> </button>
</div> </div>
<main> <main>
@ -93,9 +96,13 @@ function ReportModal({ account, post, onClose }) {
key={selectedCategory} key={selectedCategory}
aria-hidden="true" aria-hidden="true"
> >
{CATEGORIES_INFO[selectedCategory].stampLabel || {_(
CATEGORIES_INFO[selectedCategory].label} CATEGORIES_INFO[selectedCategory].stampLabel ||
<small>Pending review</small> _(CATEGORIES_INFO[selectedCategory].label),
)}
<small>
<Trans>Pending review</Trans>
</small>
</span> </span>
)} )}
<form <form
@ -136,7 +143,7 @@ function ReportModal({ account, post, onClose }) {
forward, forward,
}); });
setUIState('success'); setUIState('success');
showToast(post ? 'Post reported' : 'Profile reported'); showToast(post ? t`Post reported` : t`Profile reported`);
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -144,8 +151,8 @@ function ReportModal({ account, post, onClose }) {
showToast( showToast(
error?.message || error?.message ||
(post (post
? 'Unable to report post' ? t`Unable to report post`
: 'Unable to report profile'), : t`Unable to report profile`),
); );
} }
})(); })();
@ -153,8 +160,8 @@ function ReportModal({ account, post, onClose }) {
> >
<p> <p>
{post {post
? `What's the issue with this post?` ? t`What's the issue with this post?`
: `What's the issue with this profile?`} : t`What's the issue with this profile?`}
</p> </p>
<section class="report-categories"> <section class="report-categories">
{CATEGORIES.map((category) => {CATEGORIES.map((category) =>
@ -173,9 +180,9 @@ function ReportModal({ account, post, onClose }) {
}} }}
/> />
<span> <span>
{CATEGORIES_INFO[category].label} &nbsp; {_(CATEGORIES_INFO[category].label)} &nbsp;
<small class="ib insignificant"> <small class="ib insignificant">
{CATEGORIES_INFO[category].description} {_(CATEGORIES_INFO[category].description)}
</small> </small>
</span> </span>
</label> </label>
@ -222,7 +229,9 @@ function ReportModal({ account, post, onClose }) {
</section> </section>
<section class="report-comment"> <section class="report-comment">
<p> <p>
<label for="report-comment">Additional info</label> <label for="report-comment">
<Trans>Additional info</Trans>
</label>
</p> </p>
<textarea <textarea
maxlength="1000" maxlength="1000"
@ -230,6 +239,7 @@ function ReportModal({ account, post, onClose }) {
name="comment" name="comment"
id="report-comment" id="report-comment"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
required={!post} // Required if not reporting a post
/> />
</section> </section>
{!!domain && domain !== currentDomain && ( {!!domain && domain !== currentDomain && (
@ -243,7 +253,9 @@ function ReportModal({ account, post, onClose }) {
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
/>{' '} />{' '}
<span> <span>
<Trans>
Forward to <i>{domain}</i> Forward to <i>{domain}</i>
</Trans>
</span> </span>
</label> </label>
</p> </p>
@ -251,7 +263,7 @@ function ReportModal({ account, post, onClose }) {
)} )}
<footer> <footer>
<button type="submit" disabled={uiState === 'loading'}> <button type="submit" disabled={uiState === 'loading'}>
Send Report <Trans>Send Report</Trans>
</button>{' '} </button>{' '}
<button <button
type="submit" type="submit"
@ -260,15 +272,17 @@ function ReportModal({ account, post, onClose }) {
onClick={async () => { onClick={async () => {
try { try {
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
showToast(`Muted ${username}`); showToast(t`Muted ${username}`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast(`Unable to mute ${username}`); showToast(t`Unable to mute ${username}`);
} }
// onSubmit will still run // onSubmit will still run
}} }}
> >
<Trans>
Send Report <small class="ib">+ Mute profile</small> Send Report <small class="ib">+ Mute profile</small>
</Trans>
</button>{' '} </button>{' '}
<button <button
type="submit" type="submit"
@ -277,15 +291,17 @@ function ReportModal({ account, post, onClose }) {
onClick={async () => { onClick={async () => {
try { try {
await masto.v1.accounts.$select(account.id).block(); await masto.v1.accounts.$select(account.id).block();
showToast(`Blocked ${username}`); showToast(t`Blocked ${username}`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast(`Unable to block ${username}`); showToast(t`Unable to block ${username}`);
} }
// onSubmit will still run // onSubmit will still run
}} }}
> >
<Trans>
Send Report <small class="ib">+ Block profile</small> Send Report <small class="ib">+ Block profile</small>
</Trans>
</button> </button>
<Loader hidden={uiState !== 'loading'} /> <Loader hidden={uiState !== 'loading'} />
</footer> </footer>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks'; import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@ -68,7 +69,7 @@ const SearchForm = forwardRef((props, ref) => {
name="q" name="q"
type="search" type="search"
// autofocus // autofocus
placeholder="Search" placeholder={t`Search`}
dir="auto" dir="auto"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
@ -198,12 +199,12 @@ const SearchForm = forwardRef((props, ref) => {
[ [
{ {
label: ( label: (
<> <Trans>
{query}{' '} {query}{' '}
<small class="insignificant"> <small class="insignificant">
accounts, hashtags &amp; posts accounts, hashtags &amp; posts
</small> </small>
</> </Trans>
), ),
to: `/search?q=${encodeURIComponent(query)}`, to: `/search?q=${encodeURIComponent(query)}`,
top: !type && !/\s/.test(query), top: !type && !/\s/.test(query),
@ -211,9 +212,9 @@ const SearchForm = forwardRef((props, ref) => {
}, },
{ {
label: ( label: (
<> <Trans>
Posts with <q>{query}</q> Posts with <q>{query}</q>
</> </Trans>
), ),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`, to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query), hidden: /^https?:/.test(query),
@ -223,9 +224,9 @@ const SearchForm = forwardRef((props, ref) => {
}, },
{ {
label: ( label: (
<> <Trans>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark> Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</> </Trans>
), ),
to: `/${instance}/t/${query.replace(/^#/, '')}`, to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden: hidden:
@ -237,9 +238,9 @@ const SearchForm = forwardRef((props, ref) => {
}, },
{ {
label: ( label: (
<> <Trans>
Look up <mark>{query}</mark> Look up <mark>{query}</mark>
</> </Trans>
), ),
to: `/${query}`, to: `/${query}`,
hidden: !/^https?:/.test(query), hidden: !/^https?:/.test(query),
@ -248,9 +249,9 @@ const SearchForm = forwardRef((props, ref) => {
}, },
{ {
label: ( label: (
<> <Trans>
Accounts with <q>{query}</q> Accounts with <q>{query}</q>
</> </Trans>
), ),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`, to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
icon: 'group', icon: 'group',
@ -273,6 +274,7 @@ const SearchForm = forwardRef((props, ref) => {
class={`search-popover-item ${i === 0 ? 'focus' : ''}`} class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
// hidden={hidden} // hidden={hidden}
onClick={(e) => { onClick={(e) => {
console.log('onClick', e);
props?.onSubmit?.(e); props?.onSubmit?.(e);
}} }}
> >

View file

@ -18,8 +18,8 @@
counter-increment: index; counter-increment: index;
display: inline-block; display: inline-block;
width: 1.2em; width: 1.2em;
text-align: right; text-align: end;
margin-right: 8px; margin-inline-end: 8px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-size: 90%; font-size: 90%;
flex-shrink: 0; flex-shrink: 0;
@ -55,15 +55,19 @@
justify-content: center; justify-content: center;
} }
#shortcuts-settings-container .shortcuts-view-mode label:first-child { #shortcuts-settings-container .shortcuts-view-mode label:first-child {
border-top-left-radius: 16px; border-start-start-radius: 16px;
border-bottom-left-radius: 16px; border-end-start-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label:last-child { #shortcuts-settings-container .shortcuts-view-mode label:last-child {
border-top-right-radius: 16px; border-start-end-radius: 16px;
border-bottom-right-radius: 16px; border-end-end-radius: 16px;
} }
#shortcuts-settings-container .shortcuts-view-mode label img { #shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px; max-height: 64px;
&:dir(rtl) {
transform: scaleX(-1);
}
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
#shortcuts-settings-container .shortcuts-view-mode label img { #shortcuts-settings-container .shortcuts-view-mode label img {
@ -82,9 +86,7 @@
} }
#shortcuts-settings-container .shortcuts-view-mode label input ~ * { #shortcuts-settings-container .shortcuts-view-mode label input ~ * {
opacity: 0.5; opacity: 0.5;
transform-origin: bottom; transition: opacity 0.2s ease-out;
transform: scale(0.975);
transition: all 0.2s ease-out;
} }
#shortcuts-settings-container .shortcuts-view-mode label.checked { #shortcuts-settings-container .shortcuts-view-mode label.checked {
box-shadow: inset 0 0 0 3px var(--link-color), box-shadow: inset 0 0 0 3px var(--link-color),
@ -95,7 +97,6 @@
label label
input:is(:hover, :active, :checked) input:is(:hover, :active, :checked)
~ * { ~ * {
transform: scale(1);
opacity: 1; opacity: 1;
} }
@ -114,7 +115,7 @@
} }
#shortcut-settings-form label > span:first-child { #shortcut-settings-form label > span:first-child {
flex-basis: 5em; flex-basis: 5em;
text-align: right; text-align: end;
} }
#shortcut-settings-form :is(input[type='text'], select) { #shortcut-settings-form :is(input[type='text'], select) {
flex-grow: 1; flex-grow: 1;
@ -185,8 +186,8 @@
counter-increment: index; counter-increment: index;
display: inline-block; display: inline-block;
width: 1.2em; width: 1.2em;
text-align: right; text-align: end;
margin-right: 8px; margin-inline-end: 8px;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-size: 90%; font-size: 90%;
flex-shrink: 0; flex-shrink: 0;

View file

@ -1,6 +1,8 @@
import './shortcuts-settings.css'; import './shortcuts-settings.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact'; import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { msg, Plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { import {
compressToEncodedURIComponent, compressToEncodedURIComponent,
decompressFromEncodedURIComponent, decompressFromEncodedURIComponent,
@ -14,10 +16,12 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags'; import { fetchFollowedTags } from '../utils/followed-tags';
import { getLists, getListTitle } from '../utils/lists';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
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 AsyncText from './AsyncText'; import AsyncText from './AsyncText';
import Icon from './icon'; import Icon from './icon';
@ -41,54 +45,55 @@ const TYPES = [
// 'account-statuses', // Need @acct search first // 'account-statuses', // Need @acct search first
]; ];
const TYPE_TEXT = { const TYPE_TEXT = {
following: 'Home / Following', following: msg`Home / Following`,
notifications: 'Notifications', notifications: msg`Notifications`,
list: 'List', list: msg`Lists`,
public: 'Public (Local / Federated)', public: msg`Public (Local / Federated)`,
search: 'Search', search: msg`Search`,
'account-statuses': 'Account', 'account-statuses': msg`Account`,
bookmarks: 'Bookmarks', bookmarks: msg`Bookmarks`,
favourites: 'Likes', favourites: msg`Likes`,
hashtag: 'Hashtag', hashtag: msg`Hashtag`,
trending: 'Trending', trending: msg`Trending`,
mentions: 'Mentions', mentions: msg`Mentions`,
}; };
const TYPE_PARAMS = { const TYPE_PARAMS = {
list: [ list: [
{ {
text: 'List ID', text: msg`List ID`,
name: 'id', name: 'id',
notRequired: true,
}, },
], ],
public: [ public: [
{ {
text: 'Local only', text: msg`Local only`,
name: 'local', name: 'local',
type: 'checkbox', type: 'checkbox',
}, },
{ {
text: 'Instance', text: msg`Instance`,
name: 'instance', name: 'instance',
type: 'text', type: 'text',
placeholder: 'Optional, e.g. mastodon.social', placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true, notRequired: true,
}, },
], ],
trending: [ trending: [
{ {
text: 'Instance', text: msg`Instance`,
name: 'instance', name: 'instance',
type: 'text', type: 'text',
placeholder: 'Optional, e.g. mastodon.social', placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true, notRequired: true,
}, },
], ],
search: [ search: [
{ {
text: 'Search term', text: msg`Search term`,
name: 'query', name: 'query',
type: 'text', type: 'text',
placeholder: 'Optional, unless for multi-column mode', placeholder: msg`Optional, unless for multi-column mode`,
notRequired: true, notRequired: true,
}, },
], ],
@ -105,27 +110,23 @@ const TYPE_PARAMS = {
text: '#', text: '#',
name: 'hashtag', name: 'hashtag',
type: 'text', type: 'text',
placeholder: 'e.g. PixelArt (Max 5, space-separated)', placeholder: msg`e.g. PixelArt (Max 5, space-separated)`,
pattern: '[^#]+', pattern: '[^#]+',
}, },
{ {
text: 'Media only', text: msg`Media only`,
name: 'media', name: 'media',
type: 'checkbox', type: 'checkbox',
}, },
{ {
text: 'Instance', text: msg`Instance`,
name: 'instance', name: 'instance',
type: 'text', type: 'text',
placeholder: 'Optional, e.g. mastodon.social', placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true, notRequired: true,
}, },
], ],
}; };
const fetchListTitle = pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
});
const fetchAccountTitle = pmem(async ({ id }) => { const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch(); const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName; return account.username || account.acct || account.displayName;
@ -133,45 +134,49 @@ const fetchAccountTitle = pmem(async ({ id }) => {
export const SHORTCUTS_META = { export const SHORTCUTS_META = {
following: { following: {
id: 'home', id: 'home',
title: (_, index) => (index === 0 ? 'Home' : 'Following'), title: (_, index) =>
index === 0
? t`Home`
: t({ id: 'following.title', message: 'Following' }),
path: '/', path: '/',
icon: 'home', icon: 'home',
}, },
mentions: { mentions: {
id: 'mentions', id: 'mentions',
title: 'Mentions', title: msg`Mentions`,
path: '/mentions', path: '/mentions',
icon: 'at', icon: 'at',
}, },
notifications: { notifications: {
id: 'notifications', id: 'notifications',
title: 'Notifications', title: msg`Notifications`,
path: '/notifications', path: '/notifications',
icon: 'notification', icon: 'notification',
}, },
list: { list: {
id: 'list', id: ({ id }) => (id ? 'list' : 'lists'),
title: fetchListTitle, title: ({ id }) => (id ? getListTitle(id) : t`Lists`),
path: ({ id }) => `/l/${id}`, path: ({ id }) => (id ? `/l/${id}` : '/l'),
icon: 'list', icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
}, },
public: { public: {
id: 'public', id: 'public',
title: ({ local }) => (local ? 'Local' : 'Federated'), title: ({ local }) => (local ? t`Local` : t`Federated`),
subtitle: ({ instance }) => instance || api().instance, subtitle: ({ instance }) => instance || api().instance,
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
icon: ({ local }) => (local ? 'building' : 'earth'), icon: ({ local }) => (local ? 'building' : 'earth'),
}, },
trending: { trending: {
id: 'trending', id: 'trending',
title: 'Trending', title: msg`Trending`,
subtitle: ({ instance }) => instance || api().instance, subtitle: ({ instance }) => instance || api().instance,
path: ({ instance }) => `/${instance}/trending`, path: ({ instance }) => `/${instance}/trending`,
icon: 'chart', icon: 'chart',
}, },
search: { search: {
id: 'search', id: 'search',
title: ({ query }) => (query ? `${query}` : 'Search'), title: ({ query }) => (query ? `${query}` : t`Search`),
path: ({ query }) => path: ({ query }) =>
query query
? `/search?q=${encodeURIComponent(query)}&type=statuses` ? `/search?q=${encodeURIComponent(query)}&type=statuses`
@ -187,13 +192,13 @@ export const SHORTCUTS_META = {
}, },
bookmarks: { bookmarks: {
id: 'bookmarks', id: 'bookmarks',
title: 'Bookmarks', title: msg`Bookmarks`,
path: '/b', path: '/b',
icon: 'bookmark', icon: 'bookmark',
}, },
favourites: { favourites: {
id: 'favourites', id: 'favourites',
title: 'Likes', title: msg`Likes`,
path: '/f', path: '/f',
icon: 'heart', icon: 'heart',
}, },
@ -210,6 +215,7 @@ export const SHORTCUTS_META = {
}; };
function ShortcutsSettings({ onClose }) { function ShortcutsSettings({ onClose }) {
const { _ } = useLingui();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -221,12 +227,12 @@ function ShortcutsSettings({ onClose }) {
<div id="shortcuts-settings-container" class="sheet" tabindex="-1"> <div id="shortcuts-settings-container" class="sheet" tabindex="-1">
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<header> <header>
<h2> <h2>
<Icon icon="shortcut" /> Shortcuts{' '} <Icon icon="shortcut" /> <Trans>Shortcuts</Trans>{' '}
<sup <sup
style={{ style={{
fontSize: 12, fontSize: 12,
@ -234,27 +240,29 @@ function ShortcutsSettings({ onClose }) {
textTransform: 'uppercase', textTransform: 'uppercase',
}} }}
> >
beta <Trans>beta</Trans>
</sup> </sup>
</h2> </h2>
</header> </header>
<main> <main>
<p>Specify a list of shortcuts that'll appear&nbsp;as:</p> <p>
<Trans>Specify a list of shortcuts that'll appear&nbsp;as:</Trans>
</p>
<div class="shortcuts-view-mode"> <div class="shortcuts-view-mode">
{[ {[
{ {
value: 'float-button', value: 'float-button',
label: 'Floating button', label: t`Floating button`,
imgURL: floatingButtonUrl, imgURL: floatingButtonUrl,
}, },
{ {
value: 'tab-menu-bar', value: 'tab-menu-bar',
label: 'Tab/Menu bar', label: t`Tab/Menu bar`,
imgURL: tabMenuBarUrl, imgURL: tabMenuBarUrl,
}, },
{ {
value: 'multi-column', value: 'multi-column',
label: 'Multi-column', label: t`Multi-column`,
imgURL: multiColumnUrl, imgURL: multiColumnUrl,
}, },
].map(({ value, label, imgURL }) => { ].map(({ value, label, imgURL }) => {
@ -291,9 +299,13 @@ function ShortcutsSettings({ onClose }) {
SHORTCUTS_META[type]; SHORTCUTS_META[type];
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(shortcut, i); title = title(shortcut, i);
} else {
title = _(title);
} }
if (typeof subtitle === 'function') { if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i); subtitle = subtitle(shortcut, i);
} else {
subtitle = _(subtitle);
} }
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(shortcut, i); icon = icon(shortcut, i);
@ -317,7 +329,7 @@ function ShortcutsSettings({ onClose }) {
)} )}
{excludedViewMode && ( {excludedViewMode && (
<span class="tag"> <span class="tag">
Not available in current view mode <Trans>Not available in current view mode</Trans>
</span> </span>
)} )}
</span> </span>
@ -336,7 +348,7 @@ function ShortcutsSettings({ onClose }) {
} }
}} }}
> >
<Icon icon="arrow-up" alt="Move up" /> <Icon icon="arrow-up" alt={t`Move up`} />
</button> </button>
<button <button
type="button" type="button"
@ -352,7 +364,7 @@ function ShortcutsSettings({ onClose }) {
} }
}} }}
> >
<Icon icon="arrow-down" alt="Move down" /> <Icon icon="arrow-down" alt={t`Move down`} />
</button> </button>
<button <button
type="button" type="button"
@ -364,7 +376,7 @@ function ShortcutsSettings({ onClose }) {
}); });
}} }}
> >
<Icon icon="pencil" alt="Edit" /> <Icon icon="pencil" alt={t`Edit`} />
</button> </button>
{/* <button {/* <button
type="button" type="button"
@ -385,7 +397,9 @@ function ShortcutsSettings({ onClose }) {
<div class="ui-state insignificant"> <div class="ui-state insignificant">
<Icon icon="info" />{' '} <Icon icon="info" />{' '}
<small> <small>
<Trans>
Add more than one shortcut/column to make this work. Add more than one shortcut/column to make this work.
</Trans>
</small> </small>
</div> </div>
)} )}
@ -394,10 +408,11 @@ function ShortcutsSettings({ onClose }) {
<div class="ui-state insignificant"> <div class="ui-state insignificant">
<p> <p>
{snapStates.settings.shortcutsViewMode === 'multi-column' {snapStates.settings.shortcutsViewMode === 'multi-column'
? 'No columns yet. Tap on the Add column button.' ? t`No columns yet. Tap on the Add column button.`
: 'No shortcuts yet. Tap on the Add shortcut button.'} : t`No shortcuts yet. Tap on the Add shortcut button.`}
</p> </p>
<p> <p>
<Trans>
Not sure what to add? Not sure what to add?
<br /> <br />
Try adding{' '} Try adding{' '}
@ -418,14 +433,15 @@ function ShortcutsSettings({ onClose }) {
Home / Following and Notifications Home / Following and Notifications
</a>{' '} </a>{' '}
first. first.
</Trans>
</p> </p>
</div> </div>
)} )}
<p class="insignificant"> <p class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT && {shortcuts.length >= SHORTCUTS_LIMIT &&
(snapStates.settings.shortcutsViewMode === 'multi-column' (snapStates.settings.shortcutsViewMode === 'multi-column'
? `Max ${SHORTCUTS_LIMIT} columns` ? t`Max ${SHORTCUTS_LIMIT} columns`
: `Max ${SHORTCUTS_LIMIT} shortcuts`)} : t`Max ${SHORTCUTS_LIMIT} shortcuts`)}
</p> </p>
<p <p
style={{ style={{
@ -439,7 +455,7 @@ function ShortcutsSettings({ onClose }) {
class="light" class="light"
onClick={() => setShowImportExport(true)} onClick={() => setShowImportExport(true)}
> >
Import/export <Trans>Import/export</Trans>
</button> </button>
<button <button
type="button" type="button"
@ -449,8 +465,8 @@ function ShortcutsSettings({ onClose }) {
<Icon icon="plus" />{' '} <Icon icon="plus" />{' '}
<span> <span>
{snapStates.settings.shortcutsViewMode === 'multi-column' {snapStates.settings.shortcutsViewMode === 'multi-column'
? 'Add column…' ? t`Add column…`
: 'Add shortcut…'} : t`Add shortcut…`}
</span> </span>
</button> </button>
</p> </p>
@ -496,20 +512,10 @@ function ShortcutsSettings({ onClose }) {
); );
} }
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const fetchLists = pmem(
() => {
const { masto } = api();
return masto.v1.lists.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
const FORM_NOTES = { const FORM_NOTES = {
search: `For multi-column mode, search term is required, else the column will not be shown.`, list: msg`Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.', search: msg`For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: msg`Multiple hashtags are supported. Space-separated.`,
}; };
function ShortcutForm({ function ShortcutForm({
@ -519,10 +525,10 @@ function ShortcutForm({
shortcutIndex, shortcutIndex,
onClose, onClose,
}) { }) {
const { _ } = useLingui();
console.log('shortcut', shortcut); console.log('shortcut', shortcut);
const editMode = !!shortcut; const editMode = !!shortcut;
const [currentType, setCurrentType] = useState(shortcut?.type || null); const [currentType, setCurrentType] = useState(shortcut?.type || null);
const { masto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [lists, setLists] = useState([]); const [lists, setLists] = useState([]);
@ -532,8 +538,7 @@ function ShortcutForm({
if (currentType !== 'list') return; if (currentType !== 'list') return;
try { try {
setUIState('loading'); setUIState('loading');
const lists = await fetchLists(); const lists = await getLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists); setLists(lists);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -575,11 +580,11 @@ function ShortcutForm({
<div id="shortcut-settings-form" class="sheet"> <div id="shortcut-settings-form" class="sheet">
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<header> <header>
<h2>{editMode ? 'Edit' : 'Add'} shortcut</h2> <h2>{editMode ? t`Edit shortcut` : t`Add shortcut`}</h2>
</header> </header>
<main tabindex="-1"> <main tabindex="-1">
<form <form
@ -614,7 +619,9 @@ function ShortcutForm({
> >
<p> <p>
<label> <label>
<span>Timeline</span> <span>
<Trans>Timeline</Trans>
</span>
<select <select
required required
disabled={disabled} disabled={disabled}
@ -623,10 +630,11 @@ function ShortcutForm({
}} }}
defaultValue={editMode ? shortcut.type : undefined} defaultValue={editMode ? shortcut.type : undefined}
name="type" name="type"
dir="auto"
> >
<option></option> <option></option>
{TYPES.map((type) => ( {TYPES.map((type) => (
<option value={type}>{TYPE_TEXT[type]}</option> <option value={type}>{_(TYPE_TEXT[type])}</option>
))} ))}
</select> </select>
</label> </label>
@ -637,13 +645,17 @@ function ShortcutForm({
return ( return (
<p> <p>
<label> <label>
<span>List</span> <span>
<Trans>List</Trans>
</span>
<select <select
name="id" name="id"
required={!notRequired} required={!notRequired}
disabled={disabled || uiState === 'loading'} disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined} defaultValue={editMode ? shortcut.id : undefined}
dir="auto"
> >
<option value=""></option>
{lists.map((list) => ( {lists.map((list) => (
<option value={list.id}>{list.title}</option> <option value={list.id}>{list.title}</option>
))} ))}
@ -656,12 +668,12 @@ function ShortcutForm({
return ( return (
<p> <p>
<label> <label>
<span>{text}</span>{' '} <span>{_(text)}</span>{' '}
<input <input
type={type} type={type}
switch={type === 'checkbox' || undefined} switch={type === 'checkbox' || undefined}
name={name} name={name}
placeholder={placeholder} placeholder={_(placeholder)}
required={type === 'text' && !notRequired} required={type === 'text' && !notRequired}
disabled={disabled} disabled={disabled}
list={ list={
@ -673,6 +685,7 @@ function ShortcutForm({
autocapitalize="off" autocapitalize="off"
spellCheck={false} spellCheck={false}
pattern={pattern} pattern={pattern}
dir="auto"
/> />
{currentType === 'hashtag' && {currentType === 'hashtag' &&
followedHashtags.length > 0 && ( followedHashtags.length > 0 && (
@ -690,7 +703,7 @@ function ShortcutForm({
{!!FORM_NOTES[currentType] && ( {!!FORM_NOTES[currentType] && (
<p class="form-note insignificant"> <p class="form-note insignificant">
<Icon icon="info" /> <Icon icon="info" />
{FORM_NOTES[currentType]} {_(FORM_NOTES[currentType])}
</p> </p>
)} )}
<footer> <footer>
@ -699,7 +712,7 @@ function ShortcutForm({
class="block" class="block"
disabled={disabled || uiState === 'loading'} disabled={disabled || uiState === 'loading'}
> >
{editMode ? 'Save' : 'Add'} {editMode ? t`Save` : t`Add`}
</button> </button>
{editMode && ( {editMode && (
<button <button
@ -710,7 +723,7 @@ function ShortcutForm({
onClose?.(); onClose?.();
}} }}
> >
Remove <Trans>Remove</Trans>
</button> </button>
)} )}
</footer> </footer>
@ -721,6 +734,7 @@ function ShortcutForm({
} }
function ImportExport({ shortcuts, onClose }) { function ImportExport({ shortcuts, onClose }) {
const { _ } = useLingui();
const { masto } = api(); const { masto } = api();
const shortcutsStr = useMemo(() => { const shortcutsStr = useMemo(() => {
if (!shortcuts) return ''; if (!shortcuts) return '';
@ -766,30 +780,35 @@ function ImportExport({ shortcuts, onClose }) {
<div id="import-export-container" class="sheet"> <div id="import-export-container" class="sheet">
{!!onClose && ( {!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}> <button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" /> <Icon icon="x" alt={t`Close`} />
</button> </button>
)} )}
<header> <header>
<h2> <h2>
<Trans>
Import/Export <small class="ib insignificant">Shortcuts</small> Import/Export <small class="ib insignificant">Shortcuts</small>
</Trans>
</h2> </h2>
</header> </header>
<main tabindex="-1"> <main tabindex="-1">
<section> <section>
<h3> <h3>
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '} <Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
<span>Import</span> <span>
<Trans>Import</Trans>
</span>
</h3> </h3>
<p class="field-button"> <p class="field-button">
<input <input
ref={shortcutsImportFieldRef} ref={shortcutsImportFieldRef}
type="text" type="text"
name="import" name="import"
placeholder="Paste shortcuts here" placeholder={t`Paste shortcuts here`}
class="block" class="block"
onInput={(e) => { onInput={(e) => {
setImportShortcutStr(e.target.value); setImportShortcutStr(e.target.value);
}} }}
dir="auto"
/> />
{states.settings.shortcutSettingsCloudImportExport && ( {states.settings.shortcutSettingsCloudImportExport && (
<button <button
@ -798,9 +817,9 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-downloading'} disabled={importUIState === 'cloud-downloading'}
onClick={async () => { onClick={async () => {
setImportUIState('cloud-downloading'); setImportUIState('cloud-downloading');
const currentAccount = store.session.get('currentAccount'); const currentAccount = getCurrentAccountID();
showToast( showToast(
'Downloading saved shortcuts from instance server…', t`Downloading saved shortcuts from instance server…`,
); );
try { try {
const relationships = const relationships =
@ -829,10 +848,10 @@ function ImportExport({ shortcuts, onClose }) {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setImportUIState('error'); setImportUIState('error');
showToast('Unable to download shortcuts'); showToast(t`Unable to download shortcuts`);
} }
}} }}
title="Download shortcuts from instance server" title={t`Download shortcuts from instance server`}
> >
<Icon icon="cloud" /> <Icon icon="cloud" />
<Icon icon="arrow-down" /> <Icon icon="arrow-down" />
@ -867,7 +886,7 @@ function ImportExport({ shortcuts, onClose }) {
* *
</span> </span>
<span> <span>
{TYPE_TEXT[shortcut.type]} {_(TYPE_TEXT[shortcut.type])}
{shortcut.type === 'list' && ' ⚠️'}{' '} {shortcut.type === 'list' && ' ⚠️'}{' '}
{TYPE_PARAMS[shortcut.type]?.map?.( {TYPE_PARAMS[shortcut.type]?.map?.(
({ text, name, type }) => ({ text, name, type }) =>
@ -889,28 +908,37 @@ function ImportExport({ shortcuts, onClose }) {
))} ))}
</ol> </ol>
<p> <p>
<small>* Exists in current shortcuts</small> <small>
<Trans>* Exists in current shortcuts</Trans>
</small>
<br /> <br />
<small> <small>
List may not work if it's from a different account. {' '}
<Trans>
List may not work if it's from a different account.
</Trans>
</small> </small>
</p> </p>
</> </>
)} )}
{importUIState === 'error' && ( {importUIState === 'error' && (
<p class="error"> <p class="error">
<small> Invalid settings format</small> <small>
<Trans>Invalid settings format</Trans>
</small>
</p> </p>
)} )}
<p> <p>
{hasCurrentSettings && ( {hasCurrentSettings && (
<> <>
<MenuConfirm <MenuConfirm
confirmLabel="Append to current shortcuts?" confirmLabel={t`Append to current shortcuts?`}
menuFooter={ menuFooter={
<div class="footer"> <div class="footer">
Only shortcuts that dont exist in current shortcuts will <Trans>
be appended. Only shortcuts that dont exist in current shortcuts
will be appended.
</Trans>
</div> </div>
} }
onClick={() => { onClick={() => {
@ -929,7 +957,7 @@ function ImportExport({ shortcuts, onClose }) {
), ),
); );
if (!nonUniqueShortcuts.length) { if (!nonUniqueShortcuts.length) {
showToast('No new shortcuts to import'); showToast(t`No new shortcuts to import`);
return; return;
} }
let newShortcuts = [ let newShortcuts = [
@ -944,8 +972,8 @@ function ImportExport({ shortcuts, onClose }) {
states.shortcuts = newShortcuts; states.shortcuts = newShortcuts;
showToast( showToast(
exceededLimit exceededLimit
? `Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.` ? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
: 'Shortcuts imported', : t`Shortcuts imported`,
); );
onClose?.(); onClose?.();
}} }}
@ -955,7 +983,7 @@ function ImportExport({ shortcuts, onClose }) {
class="plain2" class="plain2"
disabled={!parsedImportShortcutStr} disabled={!parsedImportShortcutStr}
> >
Import & append <Trans>Import & append</Trans>
</button> </button>
</MenuConfirm>{' '} </MenuConfirm>{' '}
</> </>
@ -963,13 +991,13 @@ function ImportExport({ shortcuts, onClose }) {
<MenuConfirm <MenuConfirm
confirmLabel={ confirmLabel={
hasCurrentSettings hasCurrentSettings
? 'Override current shortcuts?' ? t`Override current shortcuts?`
: 'Import shortcuts?' : t`Import shortcuts?`
} }
menuItemClassName={hasCurrentSettings ? 'danger' : undefined} menuItemClassName={hasCurrentSettings ? 'danger' : undefined}
onClick={() => { onClick={() => {
states.shortcuts = parsedImportShortcutStr; states.shortcuts = parsedImportShortcutStr;
showToast('Shortcuts imported'); showToast(t`Shortcuts imported`);
onClose?.(); onClose?.();
}} }}
> >
@ -978,7 +1006,7 @@ function ImportExport({ shortcuts, onClose }) {
class="plain2" class="plain2"
disabled={!parsedImportShortcutStr} disabled={!parsedImportShortcutStr}
> >
{hasCurrentSettings ? 'or override…' : 'Import…'} {hasCurrentSettings ? t`or override…` : t`Import…`}
</button> </button>
</MenuConfirm> </MenuConfirm>
</p> </p>
@ -986,7 +1014,9 @@ function ImportExport({ shortcuts, onClose }) {
<section> <section>
<h3> <h3>
<Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '} <Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '}
<span>Export</span> <span>
<Trans>Export</Trans>
</span>
</h3> </h3>
<p> <p>
<input <input
@ -1000,12 +1030,13 @@ function ImportExport({ shortcuts, onClose }) {
// Copy url to clipboard // Copy url to clipboard
try { try {
navigator.clipboard.writeText(e.target.value); navigator.clipboard.writeText(e.target.value);
showToast('Shortcuts copied'); showToast(t`Shortcuts copied`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('Unable to copy shortcuts'); showToast(t`Unable to copy shortcuts`);
} }
}} }}
dir="auto"
/> />
</p> </p>
<p> <p>
@ -1016,14 +1047,17 @@ function ImportExport({ shortcuts, onClose }) {
onClick={() => { onClick={() => {
try { try {
navigator.clipboard.writeText(shortcutsStr); navigator.clipboard.writeText(shortcutsStr);
showToast('Shortcut settings copied'); showToast(t`Shortcut settings copied`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('Unable to copy shortcut settings'); showToast(t`Unable to copy shortcut settings`);
} }
}} }}
> >
<Icon icon="clipboard" /> <span>Copy</span> <Icon icon="clipboard" />{' '}
<span>
<Trans>Copy</Trans>
</span>
</button>{' '} </button>{' '}
{navigator?.share && {navigator?.share &&
navigator?.canShare?.({ navigator?.canShare?.({
@ -1040,11 +1074,14 @@ function ImportExport({ shortcuts, onClose }) {
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Sharing doesn't seem to work."); alert(t`Sharing doesn't seem to work.`);
} }
}} }}
> >
<Icon icon="share" /> <span>Share</span> <Icon icon="share" />{' '}
<span>
<Trans>Share</Trans>
</span>
</button> </button>
)}{' '} )}{' '}
{states.settings.shortcutSettingsCloudImportExport && ( {states.settings.shortcutSettingsCloudImportExport && (
@ -1054,7 +1091,7 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-uploading'} disabled={importUIState === 'cloud-uploading'}
onClick={async () => { onClick={async () => {
setImportUIState('cloud-uploading'); setImportUIState('cloud-uploading');
const currentAccount = store.session.get('currentAccount'); const currentAccount = getCurrentAccountID();
try { try {
const relationships = const relationships =
await masto.v1.accounts.relationships.fetch({ await masto.v1.accounts.relationships.fetch({
@ -1065,16 +1102,16 @@ function ImportExport({ shortcuts, onClose }) {
const { note = '' } = relationship; const { note = '' } = relationship;
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`; // const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
let newNote = ''; let newNote = '';
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
const settingsJSON = JSON.stringify({ const settingsJSON = JSON.stringify({
v: '1', // version v: '1', // version
dt: Date.now(), // datetime stamp dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string data: shortcutsStr, // shortcuts settings string
}); });
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
newNote = note.replace( newNote = note.replace(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/, /<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`, `<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,
@ -1082,22 +1119,22 @@ function ImportExport({ shortcuts, onClose }) {
} else { } else {
newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`; newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`;
} }
showToast('Saving shortcuts to instance server…'); showToast(t`Saving shortcuts to instance server…`);
await masto.v1.accounts await masto.v1.accounts
.$select(currentAccount) .$select(currentAccount)
.note.create({ .note.create({
comment: newNote, comment: newNote,
}); });
setImportUIState('default'); setImportUIState('default');
showToast('Shortcuts saved'); showToast(t`Shortcuts saved`);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setImportUIState('error'); setImportUIState('error');
showToast('Unable to save shortcuts'); showToast(t`Unable to save shortcuts`);
} }
}} }}
title="Sync to instance server" title={t`Sync to instance server`}
> >
<Icon icon="cloud" /> <Icon icon="cloud" />
<Icon icon="arrow-up" /> <Icon icon="arrow-up" />
@ -1105,14 +1142,20 @@ function ImportExport({ shortcuts, onClose }) {
)}{' '} )}{' '}
{shortcutsStr.length > 0 && ( {shortcutsStr.length > 0 && (
<small class="insignificant ib"> <small class="insignificant ib">
{shortcutsStr.length} characters <Plural
value={shortcutsStr.length}
one="# character"
other="# characters"
/>
</small> </small>
)} )}
</p> </p>
{!!shortcutsStr && ( {!!shortcutsStr && (
<details> <details>
<summary class="insignificant"> <summary class="insignificant">
<small>Raw Shortcuts JSON</small> <small>
<Trans>Raw Shortcuts JSON</Trans>
</small>
</summary> </summary>
<textarea style={{ width: '100%' }} rows={10} readOnly> <textarea style={{ width: '100%' }} rows={10} readOnly>
{JSON.stringify(shortcuts.filter(Boolean), null, 2)} {JSON.stringify(shortcuts.filter(Boolean), null, 2)}
@ -1123,8 +1166,11 @@ function ImportExport({ shortcuts, onClose }) {
{states.settings.shortcutSettingsCloudImportExport && ( {states.settings.shortcutSettingsCloudImportExport && (
<footer> <footer>
<p> <p>
<Icon icon="cloud" /> Import/export settings from/to instance <Icon icon="cloud" />{' '}
server (Very experimental) <Trans>
Import/export settings from/to instance server (Very
experimental)
</Trans>
</p> </p>
</footer> </footer>
)} )}

View file

@ -2,8 +2,8 @@
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom)); bottom: max(16px, env(safe-area-inset-bottom));
left: 16px; inset-inline-start: 16px;
left: max(16px, env(safe-area-inset-left)); inset-inline-start: max(16px, env(safe-area-inset-left));
padding: 16px; padding: 16px;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
z-index: 101; z-index: 101;
@ -34,9 +34,9 @@
@media (min-width: calc(40em + 56px + 8px)) { @media (min-width: calc(40em + 56px + 8px)) {
#shortcuts-button { #shortcuts-button {
right: 16px; inset-inline-end: 16px;
right: max(16px, env(safe-area-inset-right)); inset-inline-end: max(16px, env(safe-area-inset-right));
left: auto; inset-inline-start: auto;
top: 16px; top: 16px;
top: max(16px, env(safe-area-inset-top)); top: max(16px, env(safe-area-inset-top));
bottom: auto; bottom: auto;
@ -121,13 +121,31 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
#shortcuts .tab-bar li a {
position: relative;
&:before {
content: '';
position: absolute;
inset: 4px 0;
border-radius: 8px;
background-color: var(--bg-color);
z-index: -1;
transform: scale(0.5);
opacity: 0;
transition: all 0.1s ease-in-out;
}
}
#shortcuts .tab-bar li a.is-active { #shortcuts .tab-bar li a.is-active {
color: var(--link-color); color: var(--link-color);
background-image: radial-gradient( /* background-image: radial-gradient(
closest-side at 50% 50%, closest-side at 50% 50%,
var(--bg-color), var(--bg-color),
transparent transparent
); ); */
&:before {
transform: scale(1);
opacity: 1;
}
} }
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden]) #app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden])
#shortcuts #shortcuts

View file

@ -1,23 +1,28 @@
import './shortcuts.css'; import './shortcuts.css';
import { Menu, MenuItem } from '@szhsin/react-menu'; import { t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { MenuDivider } from '@szhsin/react-menu';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useMemo, useRef } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings'; import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import states from '../utils/states'; import states from '../utils/states';
import AsyncText from './AsyncText'; import AsyncText from './AsyncText';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import Menu2 from './menu2';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function Shortcuts() { function Shortcuts() {
const { _ } = useLingui();
const { instance } = api(); const { instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts, settings } = snapStates; const { shortcuts, settings } = snapStates;
@ -25,18 +30,17 @@ function Shortcuts() {
if (!shortcuts.length) { if (!shortcuts.length) {
return null; return null;
} }
if ( const isMultiColumnMode =
settings.shortcutsViewMode === 'multi-column' || settings.shortcutsViewMode === 'multi-column' ||
(!settings.shortcutsViewMode && settings.shortcutsColumnsMode) (!settings.shortcutsViewMode && settings.shortcutsColumnsMode);
) { if (isMultiColumnMode) {
return null; return null;
} }
const menuRef = useRef(); const menuRef = useRef();
const formattedShortcuts = useMemo( const hasLists = useRef(false);
() => const formattedShortcuts = shortcuts
shortcuts
.map((pin, i) => { .map((pin, i) => {
const { type, ...data } = pin; const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null; if (!SHORTCUTS_META[type]) return null;
@ -56,14 +60,22 @@ function Shortcuts() {
} }
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(data, i); title = title(data, i);
} else {
title = _(title);
} }
if (typeof subtitle === 'function') { if (typeof subtitle === 'function') {
subtitle = subtitle(data, i); subtitle = subtitle(data, i);
} else {
subtitle = _(subtitle);
} }
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(data, i); icon = icon(data, i);
} }
if (id === 'lists') {
hasLists.current = true;
}
return { return {
id, id,
path, path,
@ -72,12 +84,12 @@ function Shortcuts() {
icon, icon,
}; };
}) })
.filter(Boolean), .filter(Boolean);
[shortcuts],
);
const navigate = useNavigate(); const navigate = useNavigate();
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => { useHotkeys(
['1', '2', '3', '4', '5', '6', '7', '8', '9'],
(e, handler) => {
const index = parseInt(handler.keys[0], 10) - 1; const index = parseInt(handler.keys[0], 10) - 1;
if (index < formattedShortcuts.length) { if (index < formattedShortcuts.length) {
const { path } = formattedShortcuts[index]; const { path } = formattedShortcuts[index];
@ -86,7 +98,13 @@ function Shortcuts() {
menuRef.current?.closeMenu?.(); menuRef.current?.closeMenu?.();
} }
} }
}); },
{
enabled: !isMultiColumnMode,
},
);
const [lists, setLists] = useState([]);
return ( return (
<div id="shortcuts"> <div id="shortcuts">
@ -147,6 +165,11 @@ function Shortcuts() {
menuClassName="glass-menu shortcuts-menu" menuClassName="glass-menu shortcuts-menu"
gap={8} gap={8}
position="anchor" position="anchor"
onMenuChange={(e) => {
if (e.open && hasLists.current) {
getLists().then(setLists);
}
}}
menuButton={ menuButton={
<button <button
type="button" type="button"
@ -166,11 +189,42 @@ function Shortcuts() {
} catch (e) {} } catch (e) {}
}} }}
> >
<Icon icon="shortcut" size="xl" alt="Shortcuts" /> <Icon icon="shortcut" size="xl" alt={t`Shortcuts`} />
</button> </button>
} }
> >
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => { {formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
if (id === 'lists') {
return (
<SubMenu2
menuClassName="glass-menu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon={icon} size="l" />
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>
<Trans>All Lists</Trans>
</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</SubMenu2>
);
}
return ( return (
<MenuLink <MenuLink
to={path} to={path}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +1,12 @@
import { plural, t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
@ -7,8 +14,11 @@ import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context'; import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters'; import { filteredItems, isFiltered } from '../utils/filters';
import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import { isMediaFirstInstance } from '../utils/store-utils';
import { groupBoosts, groupContext } from '../utils/timeline-utils'; import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval'; import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
@ -48,26 +58,33 @@ function Timeline({
filterContext, filterContext,
showFollowedTags, showFollowedTags,
showReplyParent, showReplyParent,
clearWhenRefresh,
}) { }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('start');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const scrollableRef = useRef(); const scrollableRef = useRef();
console.debug('RENDER Timeline', id, refresh); console.debug('RENDER Timeline', id, refresh);
__BENCHMARK.start(`timeline-${id}-load`);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const allowGrouping = view !== 'media'; const allowGrouping = view !== 'media';
const loadItemsTS = useRef(0); // Ensures only one loadItems at a time
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
setShowNew(false); setShowNew(false);
if (uiState === 'loading') return; // if (uiState === 'loading') return;
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const ts = (loadItemsTS.current = Date.now());
let { done, value } = await fetchItems(firstLoad); let { done, value } = await fetchItems(firstLoad);
if (ts !== loadItemsTS.current) return;
if (Array.isArray(value)) { if (Array.isArray(value)) {
// Avoid grouping for pinned posts // Avoid grouping for pinned posts
const [pinnedPosts, otherPosts] = value.reduce( const [pinnedPosts, otherPosts] = value.reduce(
@ -103,18 +120,22 @@ function Timeline({
setShowMore(false); setShowMore(false);
} }
setUIState('default'); setUIState('default');
__BENCHMARK.end(`timeline-${id}-load`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');
if (firstLoad && !items.length && errorText) {
showToast(errorText);
}
} finally { } finally {
loadItems.cancel(); loadItems.cancel();
} }
})(); })();
}, },
1500, 1_000,
{ {
leading: true, leading: true,
trailing: false, // trailing: false,
}, },
); );
@ -200,8 +221,8 @@ function Timeline({
const oRef = useHotkeys(['enter', 'o'], () => { const oRef = useHotkeys(['enter', 'o'], () => {
// open active status // open active status
const activeItem = document.activeElement.closest(itemsSelector); const activeItem = document.activeElement;
if (activeItem) { if (activeItem?.matches(itemsSelector)) {
activeItem.click(); activeItem.click();
} }
}); });
@ -209,17 +230,13 @@ function Timeline({
const showNewPostsIndicator = const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew; items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => { const handleLoadNewPosts = useCallback(() => {
loadItems(true); if (showNewPostsIndicator) loadItems(true);
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
top: 0, top: 0,
behavior: 'smooth', behavior: 'smooth',
}); });
}, [loadItems]); }, [loadItems, showNewPostsIndicator]);
const dotRef = useHotkeys('.', () => { const dotRef = useHotkeys('.', handleLoadNewPosts);
if (showNewPostsIndicator) {
handleLoadNewPosts();
}
});
// const { // const {
// scrollDirection, // scrollDirection,
@ -268,9 +285,18 @@ function Timeline({
scrollableRef.current?.scrollTo({ top: 0 }); scrollableRef.current?.scrollTo({ top: 0 });
loadItems(true); loadItems(true);
}, []); }, []);
const firstLoad = useRef(true);
useEffect(() => { useEffect(() => {
if (firstLoad.current) {
firstLoad.current = false;
return;
}
if (clearWhenRefresh && items?.length) {
loadItems.cancel?.();
setItems([]);
}
loadItems(true); loadItems(true);
}, [refresh]); }, [clearWhenRefresh, refresh]);
// useEffect(() => { // useEffect(() => {
// if (reachStart) { // if (reachStart) {
@ -359,14 +385,28 @@ function Timeline({
<FilterContext.Provider value={filterContext}> <FilterContext.Provider value={filterContext}>
<div <div
id={`${id}-page`} id={`${id}-page`}
class="deck-container" class={`deck-container ${
mediaFirst ? 'deck-container-media-first' : ''
}`}
ref={(node) => { ref={(node) => {
scrollableRef.current = node; scrollableRef.current = node;
jRef.current = node; jRef(node);
kRef.current = node; kRef(node);
oRef.current = node; oRef(node);
dotRef(node);
}} }}
tabIndex="-1" tabIndex="-1"
onClick={(e) => {
// If click on timeline item, unhide header
if (
headerRef.current &&
e.target.closest('.timeline-item, .timeline-item-alt')
) {
setTimeout(() => {
headerRef.current.hidden = false;
}, 250);
}
}}
> >
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
@ -394,7 +434,7 @@ function Timeline({
headerStart headerStart
) : ( ) : (
<Link to="/" class="button plain home-button"> <Link to="/" class="button plain home-button">
<Icon icon="home" size="l" /> <Icon icon="home" size="l" alt={t`Home`} />
</Link> </Link>
)} )}
</div> </div>
@ -410,7 +450,7 @@ function Timeline({
type="button" type="button"
onClick={handleLoadNewPosts} onClick={handleLoadNewPosts}
> >
<Icon icon="arrow-up" /> New posts <Icon icon="arrow-up" /> <Trans>New posts</Trans>
</button> </button>
)} )}
</header> </header>
@ -435,6 +475,7 @@ function Timeline({
view={view} view={view}
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
mediaFirst={mediaFirst}
/> />
))} ))}
{showMore && {showMore &&
@ -446,14 +487,14 @@ function Timeline({
height: '20vh', height: '20vh',
}} }}
> >
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
<li <li
style={{ style={{
height: '25vh', height: '25vh',
}} }}
> >
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
</> </>
))} ))}
@ -475,11 +516,13 @@ function Timeline({
onClick={() => loadItems()} onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }} style={{ marginBlockEnd: '6em' }}
> >
Show more&hellip; <Trans>Show more</Trans>
</button> </button>
</InView> </InView>
) : ( ) : (
<p class="ui-state insignificant">The end.</p> <p class="ui-state insignificant">
<Trans>The end.</Trans>
</p>
))} ))}
</> </>
) : uiState === 'loading' ? ( ) : uiState === 'loading' ? (
@ -493,13 +536,14 @@ function Timeline({
/> />
) : ( ) : (
<li key={i}> <li key={i}>
<Status skeleton /> <Status skeleton mediaFirst={mediaFirst} />
</li> </li>
), ),
)} )}
</ul> </ul>
) : ( ) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p> uiState !== 'error' &&
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
)} )}
{uiState === 'error' && ( {uiState === 'error' && (
<p class="ui-state"> <p class="ui-state">
@ -507,7 +551,7 @@ function Timeline({
<br /> <br />
<br /> <br />
<button type="button" onClick={() => loadItems(!items.length)}> <button type="button" onClick={() => loadItems(!items.length)}>
Try again <Trans>Try again</Trans>
</button> </button>
</p> </p>
)} )}
@ -527,6 +571,7 @@ const TimelineItem = memo(
view, view,
showFollowedTags, showFollowedTags,
showReplyParent, showReplyParent,
mediaFirst,
}) => { }) => {
console.debug('RENDER TimelineItem', status.id); console.debug('RENDER TimelineItem', status.id);
const { id: statusID, reblog, items, type, _pinned } = status; const { id: statusID, reblog, items, type, _pinned } = status;
@ -535,16 +580,21 @@ const TimelineItem = memo(
const url = instance const url = instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`; : `/s/${actualStatusID}`;
if (items) {
let fItems = filteredItems(items, filterContext);
let title = ''; let title = '';
if (type === 'boosts') { if (type === 'boosts') {
title = `${items.length} Boosts`; title = plural(fItems.length, {
one: '# Boost',
other: '# Boosts',
});
} else if (type === 'pinned') { } else if (type === 'pinned') {
title = 'Pinned posts'; title = t`Pinned posts`;
} }
const isCarousel = type === 'boosts' || type === 'pinned'; const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
const fItems = filteredItems(items, filterContext);
if (isCarousel) { if (isCarousel) {
const filteredItemsIDs = new Set();
// Here, we don't hide filtered posts, but we sort them last // Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => { fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) { // if (a._filtered && !b._filtered) {
@ -555,6 +605,8 @@ const TimelineItem = memo(
// } // }
const aFiltered = isFiltered(a.filtered, filterContext); const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext); const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered) filteredItemsIDs.add(a.id);
if (bFiltered) filteredItemsIDs.add(b.id);
if (aFiltered && !bFiltered) { if (aFiltered && !bFiltered) {
return 1; return 1;
} }
@ -563,11 +615,69 @@ const TimelineItem = memo(
} }
return 0; return 0;
}); });
if (filteredItemsIDs.size >= 2) {
const GROUP_SIZE = 5;
// If 2 or more, group filtered items into one, limit to GROUP_SIZE in a group
const unfiltered = [];
const filtered = [];
fItems.forEach((item) => {
if (filteredItemsIDs.has(item.id)) {
filtered.push(item);
} else {
unfiltered.push(item);
}
});
const filteredItems = [];
for (let i = 0; i < filtered.length; i += GROUP_SIZE) {
filteredItems.push({
_grouped: true,
posts: filtered.slice(i, i + GROUP_SIZE),
});
}
fItems = unfiltered.concat(filteredItems);
}
return ( return (
<li key={`timeline-${statusID}`} class="timeline-item-carousel"> <li key={`timeline-${statusID}`} class="timeline-item-carousel">
<StatusCarousel title={title} class={`${type}-carousel`}> <StatusCarousel title={title} class={`${type}-carousel`}>
{fItems.map((item) => { {fItems.map((item) => {
const { id: statusID, reblog, _pinned, _grouped } = item;
if (_grouped) {
return (
<li key={statusID} class="timeline-item-carousel-group">
{item.posts.map((item) => {
const { id: statusID, reblog, _pinned } = item; const { id: statusID, reblog, _pinned } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (_pinned) useItemID = false;
return (
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
/>
) : (
<Status
status={item}
instance={instance}
size="s"
/>
)}
</Link>
);
})}
</li>
);
}
const actualStatusID = reblog?.id || statusID; const actualStatusID = reblog?.id || statusID;
const url = instance const url = instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
@ -587,6 +697,7 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -596,6 +707,7 @@ const TimelineItem = memo(
contentTextWeight contentTextWeight
enableCommentHint enableCommentHint
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>
@ -632,7 +744,11 @@ const TimelineItem = memo(
> >
<Link class="status-link timeline-item" to={url}> <Link class="status-link timeline-item" to={url}>
{showCompact ? ( {showCompact ? (
<TimelineStatusCompact status={item} instance={instance} /> <TimelineStatusCompact
status={item}
instance={instance}
filterContext={filterContext}
/>
) : useItemID ? ( ) : useItemID ? (
<Status <Status
statusID={statusID} statusID={statusID}
@ -691,6 +807,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
) : ( ) : (
<Status <Status
@ -700,6 +817,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags} showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent} showReplyParent={showReplyParent}
// allowFilters={allowFilters} // allowFilters={allowFilters}
mediaFirst={mediaFirst}
/> />
)} )}
</Link> </Link>
@ -759,13 +877,16 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2" class="small plain2"
// disabled={reachStart} // disabled={reachStart}
onClick={() => { onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? 1 : -1);
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth), left,
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
> >
<Icon icon="chevron-left" /> <Icon icon="chevron-left" alt={t`Previous`} />
</button>{' '} </button>{' '}
<button <button
ref={endButtonRef} ref={endButtonRef}
@ -773,13 +894,16 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2" class="small plain2"
// disabled={reachEnd} // disabled={reachEnd}
onClick={() => { onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? -1 : 1);
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth), left,
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
> >
<Icon icon="chevron-right" /> <Icon icon="chevron-right" alt={t`Next`} />
</button> </button>
</span> </span>
</header> </header>
@ -804,11 +928,12 @@ function StatusCarousel({ title, class: className, children }) {
); );
} }
function TimelineStatusCompact({ status, instance }) { function TimelineStatusCompact({ status, instance, filterContext }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { id, visibility, language } = status; const { id, visibility, language } = status;
const statusPeekText = statusPeek(status); const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance); const sKey = statusKey(id, instance);
const filterInfo = isFiltered(status.filtered, filterContext);
return ( return (
<article <article
class={`status compact-thread ${ class={`status compact-thread ${
@ -818,14 +943,14 @@ function TimelineStatusCompact({ status, instance }) {
> >
{!!snapStates.statusThreadNumber[sKey] ? ( {!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge"> <div class="status-thread-badge">
<Icon icon="thread" size="s" /> <Icon icon="thread" size="s" alt={t`Thread`} />
{snapStates.statusThreadNumber[sKey] {snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X` ? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''} : ''}
</div> </div>
) : ( ) : (
<div class="status-thread-badge"> <div class="status-thread-badge">
<Icon icon="thread" size="s" /> <Icon icon="thread" size="s" alt={t`Thread`} />
</div> </div>
)} )}
<div <div
@ -834,15 +959,34 @@ function TimelineStatusCompact({ status, instance }) {
lang={language} lang={language}
dir="auto" dir="auto"
> >
{!!filterInfo ? (
<b
class="status-filtered-badge badge-meta horizontal"
title={filterInfo?.titlesStr || ''}
>
{filterInfo?.titlesStr ? (
<Trans>
<span>Filtered</span>: <span>{filterInfo.titlesStr}</span>
</Trans>
) : (
<span>
<Trans>Filtered</Trans>
</span>
)}
</b>
) : (
<>
{statusPeekText} {statusPeekText}
{status.sensitive && status.spoilerText && ( {status.sensitive && status.spoilerText && (
<> <>
{' '} {' '}
<span class="spoiler-badge"> <span class="spoiler-badge">
<Icon icon="eye-close" size="s" /> <Icon icon="eye-close" size="s" alt={t`Content warning`} />
</span> </span>
</> </>
)} )}
</>
)}
</div> </div>
</article> </article>
); );

View file

@ -35,7 +35,7 @@
border-bottom: 0; border-bottom: 0;
margin-bottom: -1px; margin-bottom: -1px;
background-image: linear-gradient( background-image: linear-gradient(
to top left, to top var(--backward),
var(--bg-color) 50%, var(--bg-color) 50%,
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );
@ -44,12 +44,13 @@
.status-translation-block .translated-block { .status-translation-block .translated-block {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
line-height: 1.3; line-height: 1.3;
border-radius: 0 8px 8px 8px; border-radius: 8px;
border-start-start-radius: 0;
margin: 0; margin: 0;
padding: 8px; padding: 8px;
background-color: var(--bg-color); background-color: var(--bg-color);
background-image: linear-gradient( background-image: linear-gradient(
to bottom right, to bottom var(--forward),
var(--bg-color), var(--bg-color),
var(--bg-faded-blur-color) var(--bg-faded-blur-color)
); );

View file

@ -1,5 +1,6 @@
import './translation-block.css'; import './translation-block.css';
import { t, Trans } from '@lingui/macro';
import pRetry from 'p-retry'; import pRetry from 'p-retry';
import pThrottle from 'p-throttle'; import pThrottle from 'p-throttle';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
@ -10,6 +11,7 @@ import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem'; import pmem from '../utils/pmem';
import Icon from './icon'; import Icon from './icon';
import LazyShazam from './lazy-shazam';
import Loader from './loader'; import Loader from './loader';
const { PHANPY_LINGVA_INSTANCES } = import.meta.env; const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
@ -76,6 +78,7 @@ function TranslationBlock({
onTranslate, onTranslate,
text = '', text = '',
mini, mini,
autoDetected,
}) { }) {
const targetLang = getTranslateTargetLanguage(true); const targetLang = getTranslateTargetLanguage(true);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -142,12 +145,11 @@ function TranslationBlock({
detectedLang !== targetLangText detectedLang !== targetLangText
) { ) {
return ( return (
<div class="shazam-container"> <LazyShazam>
<div class="shazam-container-inner">
<div class="status-translation-block-mini"> <div class="status-translation-block-mini">
<Icon <Icon
icon="translate" icon="translate"
alt={`Auto-translated from ${sourceLangText}`} alt={t`Auto-translated from ${sourceLangText}`}
/> />
<output <output
lang={targetLang} lang={targetLang}
@ -157,8 +159,7 @@ function TranslationBlock({
{translatedContent} {translatedContent}
</output> </output>
</div> </div>
</div> </LazyShazam>
</div>
); );
} }
return null; return null;
@ -186,10 +187,12 @@ function TranslationBlock({
<Icon icon="translate" />{' '} <Icon icon="translate" />{' '}
<span> <span>
{uiState === 'loading' {uiState === 'loading'
? 'Translating…' ? t`Translating…`
: sourceLanguage && sourceLangText && !detectedLang : sourceLanguage && sourceLangText && !detectedLang
? `Translate from ${sourceLangText}` ? autoDetected
: `Translate`} ? t`Translate from ${sourceLangText} (auto-detected)`
: t`Translate from ${sourceLangText}`
: t`Translate`}
</span> </span>
</button> </button>
</summary> </summary>
@ -203,17 +206,34 @@ function TranslationBlock({
translate(); translate();
}} }}
> >
{sourceLanguages.map((l) => ( {sourceLanguages.map((l) => {
const common = localeCode2Text({
code: l.code,
fallback: l.name,
});
const native = localeCode2Text({
code: l.code,
locale: l.code,
});
const showCommon = common !== native;
return (
<option value={l.code}> <option value={l.code}>
{l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name} {l.code === 'auto'
? t`Auto (${detectedLang ?? '…'})`
: showCommon
? `${native} - ${common}`
: native}
</option> </option>
))} );
})}
</select>{' '} </select>{' '}
<span> {targetLangText}</span> <span> {targetLangText}</span>
<Loader abrupt hidden={uiState !== 'loading'} /> <Loader abrupt hidden={uiState !== 'loading'} />
</div> </div>
{uiState === 'error' ? ( {uiState === 'error' ? (
<p class="ui-state">Failed to translate</p> <p class="ui-state">
<Trans>Failed to translate</Trans>
</p>
) : ( ) : (
!!translatedContent && ( !!translatedContent && (
<> <>

View file

@ -1,32 +1,50 @@
import './index.css'; import './index.css';
import './app.css'; import './app.css';
import './polyfills';
import { i18n } from '@lingui/core';
import { t, Trans } from '@lingui/macro';
import { I18nProvider } from '@lingui/react';
import { render } from 'preact'; import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose'; import ComposeSuspense from './components/compose-suspense';
import Loader from './components/loader';
import { initActivateLang } from './utils/lang';
import { initStates } from './utils/states';
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
initActivateLang();
if (window.opener) { if (window.opener) {
console = window.opener.console; console = window.opener.console;
} }
function App() { function App() {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [isLoggedIn, setIsLoggedIn] = useState(null);
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {}; const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
useTitle( useTitle(
editStatus editStatus
? 'Editing source status' ? t`Editing source status`
: replyToStatus : replyToStatus
? `Replying to @${ ? t`Replying to @${
replyToStatus.account?.acct || replyToStatus.account?.username replyToStatus.account?.acct || replyToStatus.account?.username
}` }`
: 'Compose', : t`Compose`,
); );
useEffect(() => {
const account = getCurrentAccount();
setIsLoggedIn(!!account);
if (account) {
initStates();
}
}, []);
useEffect(() => { useEffect(() => {
if (uiState === 'closed') { if (uiState === 'closed') {
try { try {
@ -40,14 +58,16 @@ function App() {
if (uiState === 'closed') { if (uiState === 'closed') {
return ( return (
<div class="box"> <div class="box">
<p>You may close this page now.</p> <p>
<Trans>You may close this page now.</Trans>
</p>
<p> <p>
<button <button
onClick={() => { onClick={() => {
window.close(); window.close();
}} }}
> >
Close window <Trans>Close window</Trans>
</button> </button>
</p> </p>
</div> </div>
@ -56,8 +76,27 @@ function App() {
console.debug('OPEN COMPOSE'); console.debug('OPEN COMPOSE');
if (isLoggedIn === false) {
return ( return (
<Compose <div class="box">
<h1>
<Trans>Error</Trans>
</h1>
<p>
<Trans>Login required.</Trans>
</p>
<p>
<a href="/">
<Trans>Go home</Trans>
</a>
</p>
</div>
);
}
if (isLoggedIn) {
return (
<ComposeSuspense
editStatus={editStatus} editStatus={editStatus}
replyToStatus={replyToStatus} replyToStatus={replyToStatus}
draftStatus={draftStatus} draftStatus={draftStatus}
@ -75,6 +114,18 @@ function App() {
}} }}
/> />
); );
}
return (
<div class="box">
<Loader />
</div>
);
} }
render(<App />, document.getElementById('app-standalone')); render(
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>,
document.getElementById('app-standalone'),
);

164
src/data/catalogs.json Normal file
View file

@ -0,0 +1,164 @@
[
{
"code": "ar-SA",
"nativeName": "العربية",
"name": "Arabic",
"completion": 25
},
{
"code": "ca-ES",
"nativeName": "català",
"name": "Catalan",
"completion": 100
},
{
"code": "cs-CZ",
"nativeName": "čeština",
"name": "Czech",
"completion": 79
},
{
"code": "de-DE",
"nativeName": "Deutsch",
"name": "German",
"completion": 94
},
{
"code": "eo-UY",
"nativeName": "Esperanto",
"name": "Esperanto",
"completion": 100
},
{
"code": "es-ES",
"nativeName": "español",
"name": "Spanish",
"completion": 100
},
{
"code": "eu-ES",
"nativeName": "euskara",
"name": "Basque",
"completion": 100
},
{
"code": "fa-IR",
"nativeName": "فارسی",
"name": "Persian",
"completion": 78
},
{
"code": "fi-FI",
"nativeName": "suomi",
"name": "Finnish",
"completion": 100
},
{
"code": "fr-FR",
"nativeName": "français",
"name": "French",
"completion": 97
},
{
"code": "gl-ES",
"nativeName": "galego",
"name": "Galician",
"completion": 99
},
{
"code": "he-IL",
"nativeName": "עברית",
"name": "Hebrew",
"completion": 12
},
{
"code": "it-IT",
"nativeName": "italiano",
"name": "Italian",
"completion": 100
},
{
"code": "ja-JP",
"nativeName": "日本語",
"name": "Japanese",
"completion": 51
},
{
"code": "kab",
"nativeName": "Taqbaylit",
"name": "Kabyle",
"completion": 98
},
{
"code": "ko-KR",
"nativeName": "한국어",
"name": "Korean",
"completion": 95
},
{
"code": "lt-LT",
"nativeName": "lietuvių",
"name": "Lithuanian",
"completion": 98
},
{
"code": "nb-NO",
"nativeName": "norsk bokmål",
"name": "Norwegian Bokmål",
"completion": 52
},
{
"code": "nl-NL",
"nativeName": "Nederlands",
"name": "Dutch",
"completion": 82
},
{
"code": "pl-PL",
"nativeName": "polski",
"name": "Polish",
"completion": 10
},
{
"code": "pt-BR",
"nativeName": "português",
"name": "Portuguese",
"completion": 100
},
{
"code": "pt-PT",
"nativeName": "português",
"name": "Portuguese",
"completion": 100
},
{
"code": "ru-RU",
"nativeName": "русский",
"name": "Russian",
"completion": 100
},
{
"code": "th-TH",
"nativeName": "ไทย",
"name": "Thai",
"completion": 9
},
{
"code": "uk-UA",
"nativeName": "українська",
"name": "Ukrainian",
"completion": 100
},
{
"code": "zh-CN",
"nativeName": "简体中文",
"name": "Simplified Chinese",
"completion": 100
},
{
"code": "zh-TW",
"nativeName": "繁體中文",
"name": "Traditional Chinese",
"completion": 32
}
]

View file

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

View file

@ -2,383 +2,365 @@
"mastodon.social", "mastodon.social",
"mstdn.jp", "mstdn.jp",
"mstdn.social", "mstdn.social",
"infosec.exchange",
"mas.to", "mas.to",
"mastodon.world", "mastodon.world",
"infosec.exchange",
"hachyderm.io", "hachyderm.io",
"troet.cafe",
"mastodon.uno",
"m.cmx.im", "m.cmx.im",
"troet.cafe",
"techhub.social", "techhub.social",
"piaille.fr", "piaille.fr",
"mastodon.uno",
"mastodon.gamedev.place", "mastodon.gamedev.place",
"mastodonapp.uk",
"mastodon.nl",
"social.vivaldi.net", "social.vivaldi.net",
"mastodonapp.uk",
"universeodon.com", "universeodon.com",
"mastodon.sdf.org", "mastodon.nl",
"c.im", "social.tchncs.de",
"mstdn.ca",
"kolektiva.social", "kolektiva.social",
"mastodon-japan.net", "mastodon.sdf.org",
"tech.lgbt",
"c.im",
"norden.social", "norden.social",
"o3o.ca", "mstdn.ca",
"occm.cc",
"mastodon.scot",
"sfba.social", "sfba.social",
"nrw.social", "nrw.social",
"tech.lgbt",
"mastodon.scot",
"mstdn.party",
"occm.cc",
"aus.social", "aus.social",
"mathstodon.xyz", "mathstodon.xyz",
"mastodon-japan.net",
"mstdn.party",
"det.social",
"toot.community", "toot.community",
"ohai.social", "ohai.social",
"sueden.social", "mstdn.business",
"mastodon.ie", "mastodon.ie",
"mastodon.top", "sueden.social",
"defcon.social",
"masto.es",
"mastodontech.de", "mastodontech.de",
"mastodon.nu", "mastodon.nu",
"masto.es",
"freemasonry.social",
"ioc.exchange", "ioc.exchange",
"mindly.social", "mindly.social",
"hessen.social", "hessen.social",
"ruhr.social", "ruhr.social",
"mastodon.au",
"nerdculture.de", "nerdculture.de",
"muenchen.social",
"defcon.social",
"social.anoxinon.de",
"mastodon.green", "mastodon.green",
"mastouille.fr",
"social.linux.pizza",
"social.cologne", "social.cologne",
"muenchen.social",
"indieweb.social", "indieweb.social",
"livellosegreto.it", "social.linux.pizza",
"ruby.social", "feuerwehr.social",
"ieji.de", "social.anoxinon.de",
"mastodon.nz", "mastodon.nz",
"ruby.social",
"livellosegreto.it",
"fairy.id",
"ieji.de",
"toot.io", "toot.io",
"tkz.one", "mastouille.fr",
"mastodont.cat", "mastodont.cat",
"social.tchncs.de", "tkz.one",
"mastodon.com.tr",
"noc.social",
"sciences.social",
"toot.wales", "toot.wales",
"masto.nu", "pouet.chapril.org",
"phpc.social", "phpc.social",
"social.dev-wiki.de",
"cyberplace.social", "cyberplace.social",
"sciences.social",
"noc.social",
"mastodon.com.tr",
"ravenation.club",
"masto.nu",
"metalhead.club",
"mastodon.ml",
"urbanists.social",
"mastodontti.fi", "mastodontti.fi",
"climatejustice.social", "climatejustice.social",
"urbanists.social",
"mstdn.plus",
"metalhead.club",
"ravenation.club",
"mastodon.ml",
"fairy.id",
"feuerwehr.social",
"dresden.network",
"stranger.social",
"mastodon.iriseden.eu",
"rollenspiel.social",
"pol.social",
"mstdn.business",
"mstdn.games",
"wien.rocks",
"h4.io",
"socel.net",
"mastodon.eus",
"wehavecookies.social",
"glasgow.social",
"mastodon.me.uk",
"uri.life",
"hostux.social",
"theblower.au",
"mastodon-uk.net",
"masto.pt",
"awscommunity.social",
"flipboard.social", "flipboard.social",
"mast.lat", "mstdn.plus",
"freiburg.social", "dresden.network",
"pol.social",
"mastodon.bida.im",
"mastodon.eus",
"mstdn.games",
"snabelen.no", "snabelen.no",
"mastodon.zaclys.com", "mastodon.me.uk",
"muenster.im", "rollenspiel.social",
"mastodon-belgium.be", "todon.eu",
"geekdom.social",
"hcommons.social",
"tooot.im",
"tooting.ch",
"rheinneckar.social",
"discuss.systems",
"sunny.garden",
"mapstodon.space",
"toad.social",
"lor.sh",
"peoplemaking.games",
"union.place",
"bark.lgbt", "bark.lgbt",
"bonn.social", "hostux.social",
"tilde.zone",
"vmst.io",
"mastodon.berlin",
"emacs.ch",
"blorbo.social",
"furry.engineer", "furry.engineer",
"rivals.space", "sunny.garden",
"cupoftea.social", "uri.life",
"mast.lat",
"wien.rocks",
"mastodon.zaclys.com",
"emacs.ch",
"freiburg.social",
"discuss.systems",
"mapstodon.space",
"masto.pt",
"hcommons.social",
"tooting.ch",
"socel.net",
"theblower.au",
"glasgow.social",
"lor.sh",
"stranger.social",
"tilde.zone",
"rheinneckar.social",
"peoplemaking.games",
"geekdom.social",
"bonn.social",
"mastodon-belgium.be",
"wehavecookies.social",
"toad.social",
"mastodon.iriseden.eu",
"vmst.io",
"muenster.im",
"union.place",
"h4.io",
"awscommunity.social",
"blorbo.social",
"qdon.space", "qdon.space",
"graphics.social", "todon.nl",
"veganism.social",
"ludosphere.fr",
"4bear.com",
"famichiki.jp",
"expressional.social",
"convo.casa",
"historians.social",
"mastorol.es",
"retro.pizza",
"shelter.moe",
"mast.dragon-fly.club",
"sakurajima.moe",
"mastodon.arch-linux.cz",
"squawk.mytransponder.com",
"mastodon.gal",
"disabled.social",
"vkl.world",
"eupolicy.social",
"fandom.ink",
"toot.funami.tech",
"mastodonbooks.net",
"lgbtqia.space",
"witter.cz",
"planetearth.social",
"oslo.town",
"mastodon.com.pl",
"pawb.fun", "pawb.fun",
"darmstadt.social", "tooot.im",
"rivals.space",
"ludosphere.fr",
"expressional.social",
"mast.dragon-fly.club",
"mastorol.es",
"cupoftea.social",
"veganism.social",
"mastodon.berlin",
"shelter.moe",
"famichiki.jp",
"lgbtqia.space",
"graphics.social",
"mastodon.gal",
"retro.pizza",
"sakurajima.moe",
"historians.social",
"fandom.ink",
"4bear.com",
"oslo.town",
"disabled.social",
"convo.casa",
"urusai.social",
"freeradical.zone",
"masto.nobigtech.es", "masto.nobigtech.es",
"cr8r.gg", "witter.cz",
"pnw.zone", "eupolicy.social",
"hear-me.social",
"furries.club",
"gaygeek.social", "gaygeek.social",
"birdon.social", "furries.club",
"mastodon.energy",
"mastodon-swiss.org",
"dizl.de",
"libretooth.gr",
"mustard.blog",
"machteburch.social",
"fulda.social",
"muri.network", "muri.network",
"babka.social", "corteximplant.com",
"archaeo.social", "cr8r.gg",
"toot.aquilenet.fr",
"mastodon.uy", "mastodon.uy",
"xarxa.cloud", "xarxa.cloud",
"corteximplant.com", "pnw.zone",
"mastodon.london", "libretooth.gr",
"urusai.social", "machteburch.social",
"thecanadian.social", "dizl.de",
"federated.press", "mustard.blog",
"babka.social",
"vkl.world",
"kanoa.de", "kanoa.de",
"opalstack.social",
"bahn.social",
"mograph.social",
"dmv.community",
"social.bau-ha.us",
"mastodon.free-solutions.org",
"masto.nyc",
"tyrol.social",
"burma.social",
"toot.kif.rocks",
"donphan.social",
"mast.hpc.social",
"musicians.today",
"drupal.community",
"hometech.social",
"norcal.social",
"social.politicaconciencia.org",
"social.seattle.wa.us",
"is.nota.live",
"genealysis.social",
"wargamers.social",
"guitar.rodeo",
"bookstodon.com",
"mstdn.dk",
"elizur.me",
"irsoluciones.social",
"h-net.social",
"mastoot.fr",
"qaf.men", "qaf.men",
"est.social", "fulda.social",
"kurry.social", "archaeo.social",
"mastodon.pnpde.social", "spojnik.works",
"ani.work", "dmv.community",
"nederland.online", "bookstodon.com",
"epicure.social", "mastodon.energy",
"occitania.social", "thecanadian.social",
"lgbt.io", "mastodon.arch-linux.cz",
"social.bau-ha.us",
"drupal.community",
"donphan.social",
"hear-me.social",
"toot.funami.tech",
"toot.kif.rocks",
"musicians.today",
"mograph.social",
"masto.nyc",
"mountains.social", "mountains.social",
"persiansmastodon.com", "federated.press",
"seocommunity.social", "mstdn.dk",
"cyberfurz.social", "mast.hpc.social",
"fedi.at", "social.seattle.wa.us",
"mastodon.pnpde.social",
"norcal.social",
"hometech.social",
"is.nota.live",
"ani.work",
"tyrol.social",
"gamepad.club", "gamepad.club",
"augsburg.social", "wargamers.social",
"mastodon.education", "social.politicaconciencia.org",
"toot.re", "mastodon.com.pl",
"linux.social", "mastodon.london",
"neovibe.app",
"musician.social", "musician.social",
"esq.social", "epicure.social",
"social.veraciousnetwork.com", "genealysis.social",
"datasci.social",
"tooters.org",
"ciberlandia.pt",
"cloud-native.social",
"social.silicon.moe",
"cosocial.ca", "cosocial.ca",
"arvr.social", "mastoot.fr",
"hispagatos.space", "toot.si",
"friendsofdesoto.social", "kurry.social",
"esq.social",
"est.social",
"bahn.social",
"musicworld.social", "musicworld.social",
"aut.social", "mastodon.mnetwork.co.kr",
"masto.yttrx.com", "lgbt.io",
"mastodon.wien", "h-net.social",
"colorid.es", "social.silicon.moe",
"arsenalfc.social",
"allthingstech.social",
"mastodon.vlaanderen",
"mastodon.com.py",
"tooter.social", "tooter.social",
"lounge.town", "fedi.at",
"puntarella.party",
"earthstream.social",
"apobangpo.space",
"opencoaster.net",
"frikiverse.zone", "frikiverse.zone",
"airwaves.social", "datasci.social",
"toot.garden", "augsburg.social",
"lewacki.space", "opencoaster.net",
"gardenstate.social", "hispagatos.space",
"neovibe.app",
"friendsofdesoto.social",
"elekk.xyz",
"cyberfurz.social",
"guitar.rodeo",
"khiar.net",
"seocommunity.social",
"theatl.social", "theatl.social",
"maly.io", "colorid.es",
"library.love", "puntarella.party",
"kfem.cat", "aut.social",
"ruhrpott.social", "toot.garden",
"techtoots.com", "apobangpo.space",
"furry.energy", "mastodon.vlaanderen",
"mastodon.pirateparty.be", "gardenstate.social",
"metalverse.social", "opalstack.social",
"mastodon.education",
"occitania.social",
"earthstream.social",
"indieauthors.social", "indieauthors.social",
"tuiter.rocks",
"mastodon.africa", "mastodon.africa",
"jvm.social", "masto.yttrx.com",
"arvr.social",
"allthingstech.social",
"furry.energy",
"tuiter.rocks",
"beekeeping.ninja",
"lounge.town",
"mastodon.wien",
"lewacki.space",
"mastodon.pirateparty.be",
"kfem.cat",
"burningboard.net",
"social.veraciousnetwork.com",
"raphus.social",
"lsbt.me",
"poweredbygay.social", "poweredbygay.social",
"fikaverse.club", "fikaverse.club",
"gametoots.de", "jvm.social",
"mastodon.cr", "rail.chat",
"hoosier.social", "mastodon-swiss.org",
"khiar.net", "elizur.me",
"seo.chat", "metalverse.social",
"drumstodon.net",
"raphus.social",
"toots.nu",
"k8s.social",
"mastodon.holeyfox.co",
"fribygda.no",
"x0r.be", "x0r.be",
"fpl.social", "library.love",
"toot.pizza", "drumstodon.net",
"mastodon.cipherbliss.com", "mastodon.sg",
"burningboard.net", "rheinhessen.social",
"synapse.cafe", "synapse.cafe",
"fribygda.no",
"cultur.social", "cultur.social",
"vermont.masto.host", "mastodon.cr",
"mastodon.free-solutions.org",
"mastodon.cipherbliss.com",
"cwb.social",
"mastodon.holeyfox.co",
"hoosier.social",
"toot.re",
"techtoots.com",
"mastodon.escepticos.es",
"seo.chat",
"leipzig.town",
"bzh.social",
"mastodon.bot", "mastodon.bot",
"bologna.one", "bologna.one",
"mastodon.sg", "vermont.masto.host",
"tchafia.be", "squawk.mytransponder.com",
"rail.chat", "freemasonry.social",
"mastodon.hosnet.fr",
"leipzig.town",
"wayne.social",
"rheinhessen.social",
"rap.social",
"cwb.social",
"mastodon.bachgau.social",
"cville.online",
"bzh.social",
"mastodon.escepticos.es",
"zenzone.social",
"mastodon.ee",
"lsbt.me",
"neurodiversity-in.au",
"fairmove.net",
"stereodon.social",
"mcr.wtf",
"mastodon.frl",
"mikumikudance.cloud",
"okla.social",
"camp.smolnet.org",
"ailbhean.co-shaoghal.net",
"clj.social",
"tu.social",
"nomanssky.social",
"mastodon.iow.social",
"frontrange.co", "frontrange.co",
"episcodon.net", "tchafia.be",
"devianze.city", "k8s.social",
"paktodon.asia", "planetearth.social",
"travelpandas.fr", "tu.social",
"silversword.online",
"nwb.social",
"skastodon.com",
"kcmo.social",
"balkan.fedive.rs",
"openedtech.social",
"mastodon.ph",
"enshittification.social",
"spojnik.works",
"mastodon.conquestuniverse.com",
"nutmeg.social",
"social.sndevs.com",
"social.diva.exchange",
"growers.social", "growers.social",
"pdx.sh", "toots.nu",
"nfld.me", "clj.social",
"cartersville.social", "paktodon.asia",
"voi.social", "devianze.city",
"mastodon.babb.no",
"kzoo.to",
"mastodon.vanlife.is",
"toot.works",
"sanjuans.life",
"dariox.club",
"xreality.social", "xreality.social",
"camp.smolnet.org",
"episcodon.net",
"okla.social",
"mastodon.hosnet.fr",
"balkan.fedive.rs",
"stereodon.social",
"mastodon.bachgau.social",
"nomanssky.social",
"sanjuans.life",
"cville.online",
"t.chadole.com",
"mastodon.conquestuniverse.com",
"skastodon.com",
"mastodon.babb.no",
"travelpandas.fr",
"mastodon.iow.social",
"rap.social",
"masr.social",
"silversword.online",
"kcmo.social",
"ailbhean.co-shaoghal.net",
"mikumikudance.cloud",
"toot.works",
"mastodon.ph",
"mcr.wtf",
"social.diva.exchange",
"fpl.social",
"kzoo.to",
"mastodon.ee",
"pdx.sh",
"23.illuminati.org",
"social.sndevs.com",
"voi.social",
"mastodon.frl",
"nwb.social",
"polsci.social",
"nfld.me",
"mastodon.fedi.quebec",
"social.ferrocarril.net", "social.ferrocarril.net",
"pool.social", "pool.social",
"polsci.social", "neurodiversity-in.au",
"mastodon.mg",
"23.illuminati.org",
"apotheke.social",
"jaxbeach.social",
"ceilidh.online",
"netsphere.one",
"biplus.social", "biplus.social",
"bvb.social", "mastodon.mg",
"mastodon.vanlife.is",
"ms.maritime.social", "ms.maritime.social",
"darticulate.com", "bvb.social",
"netsphere.one",
"ceilidh.online",
"persia.social", "persia.social",
"streamerchat.social", "jaxbeach.social",
"troet.fediverse.at",
"publishing.social", "publishing.social",
"finsup.social", "wayne.social",
"troet.fediverse.at",
"kjas.no", "kjas.no",
"wxw.moe", "darticulate.com",
"learningdisability.social",
"mastodon.bida.im",
"computerfairi.es", "computerfairi.es",
"learningdisability.social",
"wxw.moe",
"tea.codes" "tea.codes"
] ]

View file

@ -534,6 +534,11 @@
"Malay", "Malay",
"Bahasa Melayu" "Bahasa Melayu"
], ],
[
"ms-Arab",
"Jawi Malay",
"بهاس ملايو"
],
[ [
"mt", "mt",
"Maltese", "Maltese",
@ -626,7 +631,7 @@
], ],
[ [
"pa", "pa",
"Panjabi", "Punjabi",
"ਪੰਜਾਬੀ" "ਪੰਜਾਬੀ"
], ],
[ [
@ -949,6 +954,11 @@
"Montenegrin", "Montenegrin",
"crnogorski" "crnogorski"
], ],
[
"csb",
"Kashubian",
"Kaszëbsczi"
],
[ [
"jbo", "jbo",
"Lojban", "Lojban",
@ -969,6 +979,21 @@
"Lingua Franca Nova", "Lingua Franca Nova",
"lingua franca nova" "lingua franca nova"
], ],
[
"moh",
"Mohawk",
"Kanienʼkéha"
],
[
"nds",
"Low German",
"Plattdüütsch"
],
[
"pdc",
"Pennsylvania Dutch",
"Pennsilfaani-Deitsch"
],
[ [
"sco", "sco",
"Scots", "Scots",
@ -994,6 +1019,11 @@
"Toki Pona", "Toki Pona",
"toki pona" "toki pona"
], ],
[
"vai",
"Vai",
"ꕙꔤ"
],
[ [
"xal", "xal",
"Kalmyk", "Kalmyk",

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