mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-22 01:05:34 +03:00
Merge branch 'cheeaun:main' into feature/paste-attach
This commit is contained in:
commit
9e600ce31c
158 changed files with 118315 additions and 3890 deletions
4
.github/release.yml
vendored
Normal file
4
.github/release.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- 'i18n'
|
71
.github/workflows/i18n-automerge.yml
vendored
Normal file
71
.github/workflows/i18n-automerge.yml
vendored
Normal 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])"
|
||||
SUBJECT="i18n updates ($LOCALES_CHANGED)"
|
||||
|
||||
PR_NUMBER=$(echo ${{ github.event.pull_request.number }})
|
||||
gh pr merge $PR_NUMBER --author "github-actions[bot]@users.noreply.github.com" --squash --subject "$SUBJECT" || true
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
34
.github/workflows/i18n-update-readme.yml
vendored
Normal file
34
.github/workflows/i18n-update-readme.yml
vendored
Normal 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 }}
|
32
.github/workflows/update-catalogs.yml
vendored
Normal file
32
.github/workflows/update-catalogs.yml
vendored
Normal 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
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -26,4 +26,7 @@ dist-ssr
|
|||
# Custom
|
||||
.env.dev
|
||||
phanpy-dist.zip
|
||||
phanpy-dist.tar.gz
|
||||
phanpy-dist.tar.gz
|
||||
|
||||
# Compiled locale files
|
||||
src/locales/*.js
|
12
.prettierrc
12
.prettierrc
|
@ -3,18 +3,20 @@
|
|||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": [
|
||||
"^[^.].*.css$",
|
||||
"index.css$",
|
||||
".css$",
|
||||
"",
|
||||
"./polyfills",
|
||||
"",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"",
|
||||
"/assets/",
|
||||
"",
|
||||
"^../",
|
||||
"",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrderGroupNamespaceSpecifiers": true,
|
||||
"importOrderCaseInsensitive": true
|
||||
]
|
||||
}
|
||||
|
|
117
README.md
117
README.md
|
@ -100,11 +100,12 @@ Everything is designed and engineered following my taste and vision. This is a p
|
|||
Prerequisites: Node.js 18+
|
||||
|
||||
- `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 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 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
|
||||
|
||||
|
@ -115,10 +116,65 @@ Prerequisites: Node.js 18+
|
|||
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||
- [Iconify](https://iconify.design/) - Icon library
|
||||
- [MingCute icons](https://www.mingcute.com/)
|
||||
- [Lingui](https://lingui.dev/) - Internationalization
|
||||
- Vanilla CSS - _Yes, I'm old school._
|
||||
|
||||
Some of these may change in the future. The front-end world is ever-changing.
|
||||
|
||||
## Internationalization
|
||||
|
||||
All translations are available as [gettext](https://en.wikipedia.org/wiki/Gettext) `.po` files in the `src/locales` folder. The default language is English (`en`). [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) are used for pluralization. RTL (right-to-left) languages are also supported with proper text direction, icon rendering and layout.
|
||||
|
||||
On page load, default language is detected via these methods, in order (first match is used):
|
||||
|
||||
1. URL parameter `lang` e.g. `/?lang=zh-Hant`
|
||||
2. `localStorage` key `lang`
|
||||
3. Browser's `navigator.language`
|
||||
|
||||
Users can change the language in the settings, which sets the `localStorage` key `lang`.
|
||||
|
||||
### Guide for translators
|
||||
|
||||
*Inspired by [Translate WordPress Handbook](https://make.wordpress.org/polyglots/handbook/):
|
||||
|
||||
- [Don’t translate literally, translate organically](https://make.wordpress.org/polyglots/handbook/translating/expectations/#dont-translate-literally-translate-organically).
|
||||
- [Try to keep the same level of formality (or informality)](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
|
||||
- [Don’t use slang or audience-specific terms](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
|
||||
- Be attentive to placeholders for variables. Many strings have placesholders e.g. `{account}` (variable), `<0>{name}</0>` (tag with variable) and `#` (number placeholder).
|
||||
- [Ellipsis](https://en.wikipedia.org/wiki/Ellipsis) (…) is intentional. Don't remove it.
|
||||
- Nielsen Norman Group: ["Include Ellipses in Command Text to Indicate When More Information Is Required"](https://www.nngroup.com/articles/ui-copy/)
|
||||
- Apple Human Interface Guidelines: ["Append an ellipsis to a menu item’s label when the action requires more information before it can complete. The ellipsis character (…) signals that people need to input information or make additional choices, typically within another view."](https://developer.apple.com/design/human-interface-guidelines/menus)
|
||||
- Windows App Development: ["Ellipses mean incompleteness."](https://learn.microsoft.com/en-us/windows/win32/uxguide/text-ui)
|
||||
- Date timestamps, date ranges, numbers, language names and text segmentation are handled by the [ECMAScript Internationalization API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
|
||||
- [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) - e.g. "8 Aug", "08/08/2024"
|
||||
- [`Intl.RelativeTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat) - e.g. "2 days ago", "in 2 days"
|
||||
- [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) - e.g. "1,000", "10K"
|
||||
- [`Intl.DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames) - e.g. "English" (`en`) in Traditional Chinese (`zh-Hant`) is "英文"
|
||||
- [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) (with polyfill for older browsers)
|
||||
- [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) (with polyfill for older browsers)
|
||||
|
||||
### Technical notes
|
||||
|
||||
- IDs for strings are auto-generated instead of explicitly defined. Some of the [benefits](https://lingui.dev/tutorials/explicit-vs-generated-ids#benefits-of-generated-ids) are avoiding the "naming things" problem and avoiding duplicates.
|
||||
- Explicit IDs might be introduced in the future when requirements and priorities change. The library (Lingui) allows both.
|
||||
- Please report issues if certain strings are translated differently based on context, culture or region.
|
||||
- There are no strings for push notifications. The language is set on the instance server.
|
||||
- Native HTML date pickers, e.g. `<input type="month">` will always follow the system's locale and not the user's set locale.
|
||||
- "ALT" in ALT badge is not translated. It serves as a a recognizable standard across languages.
|
||||
- Custom emoji names are not localized, therefore searches don't work for non-English languages.
|
||||
- GIPHY API supports [a list of languages for searches](https://developers.giphy.com/docs/optional-settings/#language-support).
|
||||
- Unicode Right-to-left mark (RLM) (`U+200F`, `‏`) may need to be used for mixed RTL/LTR text, especially for [`<title>` element](https://www.w3.org/International/questions/qa-html-dir.en.html#title_element) (`document.title`).
|
||||
- On development, there's an additional `pseudo-LOCALE` locale, used for [pseudolocalization](https://en.wikipedia.org/wiki/Pseudolocalization). It's for testing and won't show up on production.
|
||||
- When building for production, English (`en`) catalog messages are not bundled separatedly. Other locales are bundled as separate files and loaded on demand. This ensures that `en` is always available as fallback.
|
||||
|
||||
### Volunteer translations
|
||||
|
||||
[![Crowdin](https://badges.crowdin.net/phanpy/localized.svg)](https://crowdin.com/project/phanpy)
|
||||
|
||||
Translations are managed on [Crowdin](https://crowdin.com/project/phanpy). You can help by volunteering translations.
|
||||
|
||||
Read the [intro documentation](https://support.crowdin.com/for-volunteer-translators/) to get started.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
This is a **pure static web app**. You can host it anywhere you want.
|
||||
|
@ -174,6 +230,9 @@ Available variables:
|
|||
- `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy):
|
||||
- URL of the privacy policy page
|
||||
- May specify the instance's own privacy policy
|
||||
- `PHANPY_DEFAULT_LANG` (optional):
|
||||
- Default language is English (`en`) if not specified.
|
||||
- Fallback language after multiple detection methods (`lang` query parameter, `lang` key in `localStorage` and `navigator.language`)
|
||||
- `PHANPY_LINGVA_INSTANCES` (optional, space-separated list, default: `lingva.phanpy.social [...hard-coded list of fallback instances]`):
|
||||
- Specify a space-separated list of instances. First will be used as default before falling back to the subsequent instances. If there's only 1 instance, means no fallback.
|
||||
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
|
||||
|
@ -199,7 +258,7 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
|
|||
|
||||
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)
|
||||
- [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)
|
||||
|
@ -211,6 +270,7 @@ These are self-hosted by other wonderful folks.
|
|||
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
|
||||
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
|
||||
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
|
||||
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
|
||||
|
||||
> Note: Add yours by creating a pull request.
|
||||
|
||||
|
@ -232,6 +292,59 @@ Costs involved in running and developing this web app:
|
|||
|
||||
[![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/13170041/medium/603136896af17fc005fd592ce3f48717_default.png" alt="" width="16" height="16" /> BoFFire (Arabic, French, Kabyle)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12898464/medium/d3758a76b894bade4bf271c9b32ea69b.png" alt="" width="16" height="16" /> Brawaru (Russian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15460040/medium/1cfcfe5f5511b783b5d9f2b968bad819.png" alt="" width="16" height="16" /> cbasje (Dutch)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15525631/medium/51293156034d0236f1a1020c10f7d539_default.png" alt="" width="16" height="16" /> cbo92 (French)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15910131/medium/67fab7eeab5551853450e76e2ef19e59.jpeg" alt="" width="16" height="16" /> CDN (Chinese Simplified)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16556801/medium/ed5e501ca1f3cc6525d2da28db646346.jpeg" alt="" width="16" height="16" /> dannypsnl (Chinese Traditional)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/3711/medium/d95ddd44e8dcb3a039f8a3463aed781d_default.png" alt="" width="16" height="16" /> databio (Catalan)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/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/13454728/medium/1f78b7124b3c962bc4ae55e8d701fc91_default.png" alt="" width="16" height="16" /> isard (Catalan)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16532403/medium/4cefb19623bcc44d7cdb9e25aebf5250.jpeg" alt="" width="16" height="16" /> karlafej (Czech)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15791971/medium/88bdda3090339f16f6083390d32bb434_default.png" alt="" width="16" height="16" /> katullo11 (Italian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14677260/medium/e53420d200961f48602324e18c091bdc.png" alt="" width="16" height="16" /> Kytta (German)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16529521/medium/ae6add93a901b0fefa2d9b1077920d73.png" alt="" width="16" height="16" /> llun (Thai)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16291756/medium/e1c4210f15537394cc764b8bc2dffe37.jpg" alt="" width="16" height="16" /> lucasofchirst (Occitan, Portuguese, Portuguese, Brazilian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16537713/medium/825f0bf1a14fc545a76891a52839d86e_default.png" alt="" width="16" height="16" /> marcin.kozinski (Polish)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/12882812/medium/77744d8db46e9a3e09030e1a02b7a572.jpeg" alt="" width="16" height="16" /> mojosoeun (Korean)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13613969/medium/c7834ddc0ada84a79671697a944bb274.png" alt="" width="16" height="16" /> moreal (Korean)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14158861/medium/ba1ff31dc5743b067ea6685f735229a5_default.png" alt="" width="16" height="16" /> MrWillCom (Chinese Simplified)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15652333/medium/7f36f289f9e2fe41d89ad534a1047f0e.png" alt="" width="16" height="16" /> nclm (French)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16539461/medium/2f41b9f0b802c1d200a6ab62167a7229_default.png" alt="" width="16" height="16" /> pazpi (Italian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/15106977/medium/54bf93b19af8bbfdee579ea51685bafa.jpeg" alt="" width="16" height="16" /> punkrockgirl (Basque)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16536247/medium/f010c8e718a36229733a8b58f6bad2a4_default.png" alt="" width="16" height="16" /> radecos (French)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16538917/medium/092ec03f56f9dd1cbce94379fa4d4d38.png" alt="" width="16" height="16" /> Razem (Czech)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14345134/medium/89a299239890c79a1d791d08ec3951dc.png" alt="" width="16" height="16" /> realpixelcode (German)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16527325/medium/37ebb27e7a50f7f85ae93beafc7028a2.jpg" alt="" width="16" height="16" /> rezahosseinzadeh (Persian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/13422319/medium/66632a98d73d48e36753d94ebcec9d4f.png" alt="" width="16" height="16" /> rwmpelstilzchen (Esperanto, Hebrew)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16538605/medium/bcdb6e3286b7d6237923f3a9383eed29.png" alt="" width="16" height="16" /> SadmL (Russian)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/14565190/medium/79100599131b7776e9803e4b696915a3_default.png" alt="" width="16" height="16" /> Sky_NiniKo (French)
|
||||
- <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/16533843/medium/7314c15492ef90118c33a80a427e6c87_default.png" alt="" width="16" height="16" /> Talos00 (Italian)
|
||||
- <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/16527851/medium/649e5a9a8a8cc61ced670d89e9cca082.png" alt="" width="16" height="16" /> tux93 (German)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16529833/medium/2991a65722acd721849656223014cd49.png" alt="" width="16" height="16" /> Urbestro (Esperanto, Spanish)
|
||||
- <img src="https://crowdin-static.downloads.crowdin.com/avatar/16539171/medium/db6fb87481026c72b895adfb94e17d2c_default.png" alt="" width="16" height="16" /> UsualUsername (Russian)
|
||||
- <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/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
|
||||
|
||||
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.
|
||||
|
|
7
crowdin.yml
Normal file
7
crowdin.yml
Normal 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
|
345
i18n-volunteers.json
Normal file
345
i18n-volunteers.json
Normal 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
20
lingui.config.js
Normal 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;
|
3973
package-lock.json
generated
3973
package-lock.json
generated
File diff suppressed because it is too large
Load diff
56
package.json
56
package.json
|
@ -6,8 +6,14 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
|
||||
"sourcemap": "npx source-map-explorer dist/assets/*.js"
|
||||
"fetch-instances": "node scripts/fetch-instances-list.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": {
|
||||
"@formatjs/intl-localematcher": "~0.5.4",
|
||||
|
@ -16,26 +22,28 @@
|
|||
"@github/text-expander-element": "~2.7.1",
|
||||
"@iconify-icons/mingcute": "~1.2.9",
|
||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||
"@szhsin/react-menu": "~4.1.0",
|
||||
"@uidotdev/usehooks": "~2.4.1",
|
||||
"compare-versions": "~6.1.0",
|
||||
"dayjs": "~1.11.11",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"@lingui/detect-locale": "~4.11.4",
|
||||
"@lingui/macro": "~4.11.4",
|
||||
"@lingui/react": "~4.11.4",
|
||||
"@szhsin/react-menu": "~4.2.2",
|
||||
"compare-versions": "~6.1.1",
|
||||
"fast-blurhash": "~1.1.4",
|
||||
"fast-equals": "~5.0.1",
|
||||
"fuse.js": "~7.0.0",
|
||||
"html-prettify": "~1.0.7",
|
||||
"idb-keyval": "~6.2.1",
|
||||
"intl-locale-textinfo-polyfill": "~2.1.1",
|
||||
"js-cookie": "~3.0.5",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~6.8.0",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.22.0",
|
||||
"p-throttle": "~6.2.0",
|
||||
"preact": "~10.23.2",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.10.3",
|
||||
"react-hotkeys-hook": "~4.5.1",
|
||||
"react-intersection-observer": "~9.13.0",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
"react-router-dom": "6.6.2",
|
||||
"string-length": "6.0.0",
|
||||
|
@ -43,23 +51,27 @@
|
|||
"tinyld": "~1.3.4",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~10.0.1",
|
||||
"use-debounce": "~10.0.3",
|
||||
"use-long-press": "~3.2.0",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "1.13.2"
|
||||
"valtio": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.8.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||
"postcss": "~8.4.38",
|
||||
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
|
||||
"@lingui/cli": "~4.11.4",
|
||||
"@lingui/vite-plugin": "~4.11.4",
|
||||
"@preact/preset-vite": "~2.9.0",
|
||||
"babel-plugin-macros": "~3.1.0",
|
||||
"postcss": "~8.4.45",
|
||||
"postcss-dark-theme-class": "~1.3.0",
|
||||
"postcss-preset-env": "~9.5.14",
|
||||
"postcss-preset-env": "~10.0.2",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~5.3.1",
|
||||
"vite-plugin-generate-file": "~0.1.1",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.20.0",
|
||||
"vite": "~5.4.3",
|
||||
"vite-plugin-generate-file": "~0.2.0",
|
||||
"vite-plugin-html-config": "~2.0.2",
|
||||
"vite-plugin-pwa": "~0.20.3",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"vite-plugin-run": "~0.5.2",
|
||||
"workbox-cacheable-response": "~7.1.0",
|
||||
"workbox-expiration": "~7.1.0",
|
||||
"workbox-routing": "~7.1.0",
|
||||
|
|
39
public/sw.js
39
public/sw.js
|
@ -96,24 +96,27 @@ const apiExtendedRoute = new RegExpRoute(
|
|||
);
|
||||
registerRoute(apiExtendedRoute);
|
||||
|
||||
const apiIntermediateRoute = new RegExpRoute(
|
||||
// Matches:
|
||||
// - trends/*
|
||||
// - timelines/link
|
||||
/^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'api-intermediate',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxAgeSeconds: 10 * 60, // 10 minutes
|
||||
}),
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
registerRoute(apiIntermediateRoute);
|
||||
// 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(
|
||||
// Matches:
|
||||
|
|
93
scripts/catalogs.js
Normal file
93
scripts/catalogs.js
Normal 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);
|
131
scripts/fetch-i18n-volunteers.js
Normal file
131
scripts/fetch-i18n-volunteers.js
Normal 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');
|
||||
}
|
27
scripts/update-i18n-volunteers-readme.js
Normal file
27
scripts/update-i18n-volunteers-readme.js
Normal 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');
|
412
src/app.css
412
src/app.css
|
@ -162,7 +162,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
white-space: nowrap;
|
||||
}
|
||||
.deck > header .header-grid > .header-side:last-of-type {
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
grid-column: 3;
|
||||
}
|
||||
.deck > header .header-grid :is(button, .button).plain {
|
||||
|
@ -181,8 +181,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
grid-template-columns: 1fr max-content;
|
||||
}
|
||||
.deck > header .header-grid-2 h1 {
|
||||
text-align: left;
|
||||
padding-left: 8px;
|
||||
text-align: start;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
.deck > header .header-grid h1:has(.ancestors-indicator) {
|
||||
display: flex;
|
||||
|
@ -217,6 +217,19 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
@keyframes indeterminate-bar-rtl {
|
||||
0% {
|
||||
transform: translateX(50%);
|
||||
opacity: 0.25;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.deck > header.loading:after {
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
|
@ -232,6 +245,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transparent
|
||||
);
|
||||
animation: indeterminate-bar 1s ease-in-out infinite alternate;
|
||||
&:dir(rtl) {
|
||||
animation-name: indeterminate-bar-rtl;
|
||||
}
|
||||
}
|
||||
@media (min-width: 40em) {
|
||||
.deck > header.loading:after {
|
||||
|
@ -268,6 +284,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||
&:dir(rtl) {
|
||||
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -346,6 +365,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||
--line-dir: var(--to-forward);
|
||||
}
|
||||
.timeline.flat > li {
|
||||
border-bottom: none;
|
||||
|
@ -362,10 +382,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
--avatar-size: 50px;
|
||||
--avatar-margin-start: 16px;
|
||||
--avatar-margin-end: 12px;
|
||||
--line-curve: 45deg;
|
||||
:dir(rtl) & {
|
||||
--line-curve: -45deg;
|
||||
}
|
||||
}
|
||||
.timeline.contextual > li {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--line-dir),
|
||||
transparent,
|
||||
transparent var(--line-start),
|
||||
var(--comment-line-color) var(--line-start),
|
||||
|
@ -394,7 +418,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> :is(.status-link, .status-focus) {
|
||||
padding-left: 40px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
.timeline.contextual .replies[data-scroll-left]:not([data-scroll-left='0']) {
|
||||
background-color: var(--bg-color);
|
||||
|
@ -408,7 +432,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
.timeline.contextual .replies[data-comments-level='4']:has(.replies) {
|
||||
overflow-x: auto;
|
||||
mask-image: linear-gradient(to left, transparent, black 32px);
|
||||
mask-image: linear-gradient(var(--to-backward), transparent, black 32px);
|
||||
}
|
||||
.timeline.contextual
|
||||
.replies[data-comments-level='4']:has(.replies)
|
||||
|
@ -426,145 +450,61 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
> :is(.status-link, .status-focus)
|
||||
+ .replies
|
||||
.replies-summary {
|
||||
margin-left: calc(
|
||||
margin-inline-start: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
(var(--line-margin-end) * (var(--comments-level) - 1))
|
||||
);
|
||||
}
|
||||
/* .timeline.contextual
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
> .replies-summary {
|
||||
margin-left: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
var(--line-margin-end)
|
||||
);
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.replies
|
||||
> .replies-summary {
|
||||
margin-left: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
(var(--line-margin-end) * 2)
|
||||
);
|
||||
} */
|
||||
.timeline.contextual
|
||||
> li.descendant.thread
|
||||
> :is(.status-link, .status-focus)
|
||||
+ .replies
|
||||
:is(.status-link, .status-focus) {
|
||||
padding-left: calc(
|
||||
padding-inline-start: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
(var(--line-margin-end) * (var(--comments-level) - 1))
|
||||
);
|
||||
}
|
||||
/* .timeline.contextual
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.status-link {
|
||||
padding-left: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
var(--line-margin-end)
|
||||
);
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant.thread
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.replies
|
||||
.status-link {
|
||||
padding-left: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||
(var(--line-margin-end) * 2)
|
||||
);
|
||||
} */
|
||||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> :is(.status-link, .status-focus)
|
||||
+ .replies
|
||||
.replies-summary {
|
||||
margin-left: calc(
|
||||
margin-inline-start: calc(
|
||||
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
|
||||
);
|
||||
}
|
||||
/* .timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
> .replies-summary {
|
||||
margin-left: calc(
|
||||
var(--thread-start) + var(--line-margin-end) + var(--line-margin-end)
|
||||
);
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.replies
|
||||
> .replies-summary {
|
||||
margin-left: calc(
|
||||
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
|
||||
);
|
||||
} */
|
||||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> :is(.status-link, .status-focus)
|
||||
+ .replies
|
||||
:is(.status-link, .status-focus) {
|
||||
padding-left: calc(
|
||||
padding-inline-start: calc(
|
||||
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
|
||||
);
|
||||
}
|
||||
/* .timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.status-link {
|
||||
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 2));
|
||||
}
|
||||
.timeline.contextual
|
||||
> li.descendant:not(.thread)
|
||||
> .status-link
|
||||
+ .replies
|
||||
.replies
|
||||
.replies
|
||||
.status-link {
|
||||
padding-left: calc(var(--thread-start) + (var(--line-margin-end) * 3));
|
||||
} */
|
||||
.timeline.contextual > li.descendant:not(.thread):before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: var(--line-start);
|
||||
inset-inline-start: var(--line-start);
|
||||
width: var(--line-diameter);
|
||||
height: var(--line-diameter);
|
||||
border-radius: var(--line-radius);
|
||||
border-style: solid;
|
||||
border-width: var(--line-width);
|
||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
transform: rotate(45deg);
|
||||
transform: rotate(var(--line-curve));
|
||||
}
|
||||
.timeline.contextual > li .replies-link {
|
||||
color: var(--text-insignificant-color);
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
margin-top: -12px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 90%;
|
||||
}
|
||||
.timeline.contextual > li.ancestor .replies-link {
|
||||
margin-left: calc(
|
||||
margin-inline-start: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||
);
|
||||
}
|
||||
|
@ -572,7 +512,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
> li.thread
|
||||
> :is(.status-link, .status-focus)
|
||||
.replies-link {
|
||||
margin-left: calc(
|
||||
margin-inline-start: calc(
|
||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
|
||||
);
|
||||
}
|
||||
|
@ -603,7 +543,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
list-style: none;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-right: calc(44px + 8px);
|
||||
margin-inline-end: calc(44px + 8px);
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
|
@ -618,7 +558,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transition: transform 0.3s ease;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin: 0 0 0 -4px;
|
||||
transform: rotate(0deg);
|
||||
margin: 0;
|
||||
margin-inline-start: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -637,7 +579,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
|
||||
.replies-parent-link {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
inset-inline-end: 4px;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
font-size: 16px;
|
||||
|
@ -648,8 +590,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
align-items: center;
|
||||
padding: var(--summary-padding) calc(var(--summary-padding) * 2);
|
||||
transform: translateX(100%);
|
||||
margin: calc(-1 * var(--summary-padding)) calc(-1 * var(--summary-padding))
|
||||
calc(-1 * var(--summary-padding)) 0;
|
||||
&:dir(rtl) {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
margin: calc(-1 * var(--summary-padding)) 0;
|
||||
margin-inline-end: calc(-1 * var(--summary-padding));
|
||||
border-radius: 8px;
|
||||
background-color: var(--link-bg-color);
|
||||
|
||||
|
@ -681,7 +626,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
color: var(--text-color);
|
||||
background-color: var(--comment-line-color);
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
to top var(--forward),
|
||||
var(--comment-line-color),
|
||||
var(--bg-faded-color)
|
||||
);
|
||||
|
@ -697,7 +642,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
}
|
||||
.timeline.contextual > li .replies[open] > .replies-summary {
|
||||
border-bottom-left-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
|
||||
.avatars {
|
||||
opacity: 0.5;
|
||||
|
@ -727,7 +672,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
);
|
||||
--line-end: calc(var(--line-start) + var(--line-width));
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--line-dir),
|
||||
transparent,
|
||||
transparent var(--line-start),
|
||||
var(--comment-line-color) var(--line-start),
|
||||
|
@ -768,14 +713,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: var(--line-start);
|
||||
inset-inline-start: var(--line-start);
|
||||
width: var(--line-diameter);
|
||||
height: var(--line-diameter);
|
||||
border-radius: var(--line-radius);
|
||||
border-style: solid;
|
||||
border-width: var(--line-width);
|
||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
transform: rotate(45deg);
|
||||
transform: rotate(var(--line-curve));
|
||||
}
|
||||
/* .timeline.contextual > li .replies .replies li:before {
|
||||
--line-start: calc(
|
||||
|
@ -814,8 +759,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
> ul > li:only-child {
|
||||
> .replies {
|
||||
> ul > li:only-child {
|
||||
margin-left: calc(-1 * var(--line-margin-end));
|
||||
background-position: calc(16px) 0;
|
||||
margin-inline-start: calc(-1 * var(--line-margin-end));
|
||||
background-position: 16px 0;
|
||||
&:dir(rtl) {
|
||||
background-position: -16px 0;
|
||||
}
|
||||
background-size: 100% calc(20px + 8px);
|
||||
|
||||
&:before {
|
||||
|
@ -856,7 +804,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
--line-width: 3px;
|
||||
--line-end: calc(var(--line-start) + var(--line-width));
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--line-dir),
|
||||
transparent,
|
||||
transparent var(--line-start),
|
||||
var(--comment-line-color) var(--line-start),
|
||||
|
@ -868,8 +816,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
.timeline:not(.flat) > li.timeline-item-container-start {
|
||||
margin-bottom: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
border-bottom: 0;
|
||||
background-position: 0 calc(16px + var(--avatar-size));
|
||||
}
|
||||
|
@ -882,8 +830,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
.timeline:not(.flat) > li.timeline-item-container-end {
|
||||
margin-top: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-top: 0;
|
||||
background-size: 100% 16px;
|
||||
|
||||
|
@ -909,8 +857,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
}
|
||||
|
||||
.timeline .show-more {
|
||||
padding-left: calc(var(--line-end) + var(--line-margin-end)) !important;
|
||||
text-align: left;
|
||||
padding-inline-start: calc(
|
||||
var(--line-end) + var(--line-margin-end)
|
||||
) !important;
|
||||
text-align: start;
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
position: relative;
|
||||
|
@ -918,7 +868,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
padding-block: 16px !important;
|
||||
|
||||
.avatars-bunch > .avatar:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
margin-inline-start: -4px;
|
||||
}
|
||||
}
|
||||
.timeline .show-more:hover {
|
||||
|
@ -930,14 +880,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: var(--line-start);
|
||||
inset-inline-start: var(--line-start);
|
||||
width: var(--line-diameter);
|
||||
height: var(--line-diameter);
|
||||
border-radius: var(--line-radius);
|
||||
border-style: solid;
|
||||
border-width: var(--line-width);
|
||||
border-color: transparent transparent var(--comment-line-color) transparent;
|
||||
transform: rotate(45deg);
|
||||
transform: rotate(var(--line-curve));
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
|
@ -988,7 +938,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
.status-carousel {
|
||||
--carousel-faded-color: var(--bg-faded-color);
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
to bottom var(--forward),
|
||||
var(--carousel-faded-color),
|
||||
transparent
|
||||
);
|
||||
|
@ -1058,12 +1008,12 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
display: none;
|
||||
}
|
||||
.status-carousel .status-carousel-beacon {
|
||||
margin-right: calc(-1 * var(--carousel-gap));
|
||||
margin-inline-end: calc(-1 * var(--carousel-gap));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
~ .status-carousel-beacon {
|
||||
margin-left: calc(-1 * var(--carousel-gap));
|
||||
margin-inline-start: calc(-1 * var(--carousel-gap));
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
@ -1107,12 +1057,21 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
.status-carousel.boosts-carousel > ul > li:before {
|
||||
content: counter(index);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-insignificant-color);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.status-carousel.boosts-carousel .timeline-item-carousel-group {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.ui-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
@ -1138,11 +1097,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
box-shadow: 0 1px var(--bg-color);
|
||||
|
||||
&:has(.status-badge:not(:empty)) {
|
||||
border-top-right-radius: 8px;
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
.status-carousel.boosts-carousel & {
|
||||
border-top-left-radius: 8px;
|
||||
.status-carousel.boosts-carousel &:not(.timeline-item-carousel-group &) {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
}
|
||||
.status-carousel-link::focus {
|
||||
|
@ -1180,14 +1139,29 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@keyframes slide-in-rtl {
|
||||
0% {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
.deck-backdrop .deck {
|
||||
width: var(--main-width);
|
||||
max-width: 100vw;
|
||||
background-color: var(--bg-color);
|
||||
box-shadow: -1px 0 var(--bg-color);
|
||||
&:dir(rtl) {
|
||||
box-shadow: 1px 0 var(--bg-color);
|
||||
}
|
||||
}
|
||||
.deck-backdrop .deck.slide-in:not(.deck-view-full) {
|
||||
animation: slide-in 0.5s var(--timing-function);
|
||||
|
||||
&:dir(rtl) {
|
||||
animation-name: slide-in-rtl;
|
||||
}
|
||||
}
|
||||
.deck-backdrop .deck .status {
|
||||
max-width: var(--main-width);
|
||||
|
@ -1231,7 +1205,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
content: '';
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
|
@ -1510,7 +1484,7 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
.media-modal-container + .status-deck {
|
||||
/* display: none; */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
@ -1538,8 +1512,8 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
)
|
||||
#modal-container
|
||||
> div {
|
||||
left: 0;
|
||||
right: 350px;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 350px;
|
||||
width: auto;
|
||||
}
|
||||
/* ✨ New */
|
||||
|
@ -1570,8 +1544,8 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
position: fixed;
|
||||
bottom: 16px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
right: max(16px, env(safe-area-inset-right));
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-end: max(16px, env(safe-area-inset-right));
|
||||
padding: 16px;
|
||||
background-color: var(--button-bg-blur-color);
|
||||
/* backdrop-filter: blur(16px); */
|
||||
|
@ -1620,7 +1594,7 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
|
@ -1687,6 +1661,10 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
border-radius: 0;
|
||||
padding: 0;
|
||||
right: env(safe-area-inset-right);
|
||||
&:dir(rtl) {
|
||||
right: auto;
|
||||
left: env(safe-area-inset-left);
|
||||
}
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
|
@ -1728,6 +1706,11 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
}
|
||||
.sheet .sheet-close:not(.outer) + header {
|
||||
padding-right: max(44px, env(safe-area-inset-right));
|
||||
|
||||
&:dir(rtl) {
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-left: max(44px, env(safe-area-inset-left));
|
||||
}
|
||||
}
|
||||
.sheet header :is(h1, h2, h3) {
|
||||
margin: 0;
|
||||
|
@ -1765,6 +1748,10 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:dir(rtl) &.rtl-flip {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* TAG */
|
||||
|
@ -1823,16 +1810,17 @@ body > .szh-menu-container {
|
|||
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
.szh-menu {
|
||||
padding: 8px 0;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-size);
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--outline-color);
|
||||
border: 1px solid var(--outline-stronger-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 16px -3px var(--drop-shadow-color);
|
||||
text-align: left;
|
||||
box-shadow: 0 3px 8px var(--drop-shadow-color),
|
||||
0 6px 32px -6px var(--drop-shadow-color);
|
||||
text-align: start;
|
||||
/* animation: appear-smooth 0.15s ease-in-out; */
|
||||
width: 16em;
|
||||
min-width: 16em;
|
||||
max-width: 90vw;
|
||||
/* overflow: hidden; */
|
||||
}
|
||||
|
@ -1887,13 +1875,16 @@ body > .szh-menu-container {
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
line-height: 1.3;
|
||||
padding: 8px 16px !important;
|
||||
/* transition: all 0.1s ease-in-out; */
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
--menu-item-bg-inset: 0 4px;
|
||||
--menu-item-bg-color: var(--button-bg-color);
|
||||
}
|
||||
.szh-menu .szh-menu__item--focusable {
|
||||
background-color: transparent;
|
||||
|
@ -1930,9 +1921,30 @@ body > .szh-menu-container {
|
|||
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
|
||||
color: var(--text-color);
|
||||
}
|
||||
.szh-menu .szh-menu__item:not(.menu-field) {
|
||||
position: relative;
|
||||
& > * {
|
||||
/* z-index: 1; */
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
background-color: var(--menu-item-bg-color);
|
||||
position: absolute;
|
||||
inset: var(--menu-item-bg-inset);
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.szh-menu .szh-menu__item--hover:not(.menu-field) {
|
||||
color: var(--button-text-color);
|
||||
background-color: var(--button-bg-color);
|
||||
/* background-color: var(--button-bg-color); */
|
||||
background-color: transparent;
|
||||
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.szh-menu__divider {
|
||||
background-color: var(--divider-color);
|
||||
|
@ -1966,7 +1978,7 @@ body > .szh-menu-container {
|
|||
}
|
||||
.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):first-child,
|
||||
.szh-menu .menu-horizontal > *:not(:only-child):first-child .szh-menu__item {
|
||||
padding-right: 4px !important;
|
||||
padding-inline-end: 4px !important;
|
||||
}
|
||||
.szh-menu
|
||||
.menu-horizontal
|
||||
|
@ -1975,12 +1987,12 @@ body > .szh-menu-container {
|
|||
.menu-horizontal
|
||||
> *:not(:only-child):not(:first-child):not(:last-child)
|
||||
.szh-menu__item {
|
||||
padding-left: 8px !important;
|
||||
padding-right: 4px !important;
|
||||
padding-inline-start: 8px !important;
|
||||
padding-inline-end: 4px !important;
|
||||
}
|
||||
.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):last-child,
|
||||
.szh-menu .menu-horizontal > *:not(:only-child):last-child .szh-menu__item {
|
||||
padding-left: 8px !important;
|
||||
padding-inline-start: 8px !important;
|
||||
}
|
||||
.szh-menu .szh-menu__item .menu-shortcut {
|
||||
opacity: 0.5;
|
||||
|
@ -2008,10 +2020,12 @@ body > .szh-menu-container {
|
|||
}
|
||||
.szh-menu
|
||||
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
|
||||
background-color: var(--red-text-color);
|
||||
/* background-color: var(--red-text-color); */
|
||||
--menu-item-bg-color: var(--red-text-color);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: var(--red-color);
|
||||
/* background-color: var(--red-color); */
|
||||
--menu-item-bg-color: var(--red-color);
|
||||
}
|
||||
}
|
||||
.szh-menu
|
||||
|
@ -2044,15 +2058,27 @@ body > .szh-menu-container {
|
|||
text-align: center;
|
||||
opacity: 0.5;
|
||||
text-overflow: clip;
|
||||
mask-image: linear-gradient(to left, transparent, black 16px);
|
||||
mask-image: linear-gradient(
|
||||
var(--to-backward),
|
||||
transparent,
|
||||
black 16px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
.szh-menu__item--hover {
|
||||
background-color: var(--menu-item-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-control-group-horizontal:first-child,
|
||||
li[aria-hidden='true'] + .menu-control-group-horizontal {
|
||||
margin-top: -8px;
|
||||
li[role='none'] + .menu-control-group-horizontal {
|
||||
margin-top: -4px;
|
||||
margin-bottom: -4px;
|
||||
|
||||
.szh-menu__item {
|
||||
|
@ -2060,10 +2086,10 @@ body > .szh-menu-container {
|
|||
}
|
||||
|
||||
> [class^='szh-menu']:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
> [class^='szh-menu']:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2087,6 +2113,8 @@ body > .szh-menu-container {
|
|||
}
|
||||
|
||||
.szh-menu .menu-wrap {
|
||||
min-width: 16em;
|
||||
width: min-content;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
@ -2101,11 +2129,10 @@ body > .szh-menu-container {
|
|||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(8px) saturate(3);
|
||||
border: var(--hairline-width) solid var(--bg-color);
|
||||
box-shadow: 0 3px 8px -1px var(--drop-shadow-color);
|
||||
text-shadow: 0 var(--hairline-width) var(--bg-color), 0 0 8px var(--bg-color);
|
||||
}
|
||||
.glass-menu .szh-menu__item--hover {
|
||||
background-color: var(--button-bg-blur-color);
|
||||
/* background-color: var(--button-bg-blur-color); */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
|
@ -2144,6 +2171,9 @@ body > .szh-menu-container {
|
|||
background-image: var(--middle-circle),
|
||||
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
|
||||
transform: scale(0.7);
|
||||
&:dir(rtl) {
|
||||
transform: scale(-0.7, 0.7);
|
||||
}
|
||||
transition: transform 0.2s ease-in-out;
|
||||
|
||||
&::-webkit-meter-inner-element,
|
||||
|
@ -2353,12 +2383,12 @@ ul.link-list li a {
|
|||
}
|
||||
}
|
||||
ul.link-list li:first-child a {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-top-right-radius: var(--radius);
|
||||
border-start-start-radius: var(--radius);
|
||||
border-start-end-radius: var(--radius);
|
||||
}
|
||||
ul.link-list li:last-child a {
|
||||
border-bottom-left-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
border-end-start-radius: var(--radius);
|
||||
border-end-end-radius: var(--radius);
|
||||
}
|
||||
ul.link-list li a:is(:hover, :focus) {
|
||||
color: var(--text-color);
|
||||
|
@ -2395,8 +2425,8 @@ ul.link-list li a .icon {
|
|||
}
|
||||
.nav-menu-button.with-avatar .icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
inset-block-end: 4px;
|
||||
inset-inline-end: 8px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
@ -2407,10 +2437,11 @@ ul.link-list li a .icon {
|
|||
/* COLUMNS */
|
||||
|
||||
#columns {
|
||||
--column-size: 360px;
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
/* scrollbar-width: none; */
|
||||
|
@ -2418,19 +2449,28 @@ ul.link-list li a .icon {
|
|||
overscroll-behavior-x: contain;
|
||||
/* This `transform` fixes horizontal scrolling for pointer devices on iPad */
|
||||
transform: translateZ(0);
|
||||
|
||||
/* 360px * 2 */
|
||||
@media (min-width: 720px) {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
}
|
||||
/* #columns::-webkit-scrollbar {
|
||||
display: none;
|
||||
} */
|
||||
#columns > * {
|
||||
overscroll-behavior: auto;
|
||||
scroll-snap-align: left;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
overscroll-behavior: auto;
|
||||
flex-basis: min(100vw, 360px);
|
||||
flex-basis: min(100vw, var(--column-size));
|
||||
flex-shrink: 0;
|
||||
box-shadow: -1px 0 var(--bg-color), -2px 0 var(--drop-shadow-color),
|
||||
-3px 0 var(--bg-color);
|
||||
&:dir(rtl) {
|
||||
box-shadow: 1px 0 var(--bg-color), 2px 0 var(--drop-shadow-color),
|
||||
3px 0 var(--bg-color);
|
||||
}
|
||||
}
|
||||
#columns:has(> :nth-child(3)) > *:nth-child(even),
|
||||
#columns:has(> :nth-child(3))
|
||||
|
@ -2563,7 +2603,7 @@ ul.link-list li a .icon {
|
|||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
transparent,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
|
@ -2577,6 +2617,9 @@ ul.link-list li a .icon {
|
|||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||
&:dir(rtl) {
|
||||
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2695,7 +2738,8 @@ ul.link-list li a .icon {
|
|||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
padding: 4px;
|
||||
margin: -4px -8px -4px 0;
|
||||
margin: -4px 0;
|
||||
margin-inline-end: -8px;
|
||||
background-color: var(--bg-faded-color);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
@ -2725,11 +2769,14 @@ ul.link-list li a .icon {
|
|||
.deck-container:has(~ .deck-backdrop .deck) {
|
||||
transition: transform 0.4s ease-out;
|
||||
transform: translate3d(-5vw, 0, 0);
|
||||
&:dir(rtl) {
|
||||
transform: translate3d(5vw, 0, 0);
|
||||
}
|
||||
}
|
||||
.deck-backdrop .deck {
|
||||
/* width: 50%;
|
||||
min-width: var(--main-width); */
|
||||
border-left: 1px solid var(--divider-color);
|
||||
border-inline-start: 1px solid var(--divider-color);
|
||||
}
|
||||
.timeline-deck {
|
||||
border: 0;
|
||||
|
@ -2790,16 +2837,19 @@ ul.link-list li a .icon {
|
|||
> li:not(.timeline-item-container-end, .timeline-item-container-middle):has(
|
||||
.status-badge:not(:empty)
|
||||
) {
|
||||
border-top-right-radius: 8px;
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
.timeline:not(.flat) > li:has(.status-link.is-active) {
|
||||
transition: var(--back-transition);
|
||||
transform: translate3d(-2.5vw, 0, 0);
|
||||
&:dir(rtl) {
|
||||
transform: translate3d(2.5vw, 0, 0);
|
||||
}
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> li.timeline-item-container:has(.status-link.is-active) {
|
||||
border-top-left-radius: var(--item-radius);
|
||||
border-bottom-left-radius: var(--item-radius);
|
||||
border-start-start-radius: var(--item-radius);
|
||||
border-end-start-radius: var(--item-radius);
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> li:not(:has(.status-carousel)):has(+ li .status-link.is-active),
|
||||
|
@ -2808,19 +2858,22 @@ ul.link-list li a .icon {
|
|||
+ li {
|
||||
transition: var(--back-transition);
|
||||
transform: translate3d(-1.25vw, 0, 0);
|
||||
&:dir(rtl) {
|
||||
transform: translate3d(1.25vw, 0, 0);
|
||||
}
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> li.timeline-item-container:not(:has(.status-carousel)):has(
|
||||
+ li .status-link.is-active
|
||||
) {
|
||||
border-top-left-radius: var(--item-radius);
|
||||
border-start-start-radius: var(--item-radius);
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> li.timeline-item-container:not(:has(.status-carousel)):has(
|
||||
.status-link.is-active
|
||||
)
|
||||
+ li.timeline-item-container {
|
||||
border-bottom-left-radius: var(--item-radius);
|
||||
border-end-start-radius: var(--item-radius);
|
||||
}
|
||||
.box {
|
||||
padding: 32px;
|
||||
|
@ -2832,5 +2885,20 @@ ul.link-list li a .icon {
|
|||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||
&:dir(rtl) {
|
||||
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* LANG SELECTOR */
|
||||
|
||||
.lang-selector {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
select {
|
||||
width: 10em;
|
||||
}
|
||||
}
|
||||
|
|
69
src/app.jsx
69
src/app.jsx
|
@ -1,5 +1,6 @@
|
|||
import './app.css';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import debounce from 'just-debounce-it';
|
||||
import {
|
||||
useEffect,
|
||||
|
@ -9,7 +10,9 @@ import {
|
|||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import 'swiped-events';
|
||||
|
||||
import { subscribe } from 'valtio';
|
||||
|
||||
import BackgroundService from './components/background-service';
|
||||
|
@ -53,7 +56,12 @@ import { getAccessToken } from './utils/auth';
|
|||
import focusDeck from './utils/focus-deck';
|
||||
import states, { initStates, statusKey } from './utils/states';
|
||||
import store from './utils/store';
|
||||
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
|
||||
import {
|
||||
getAccount,
|
||||
getCurrentAccount,
|
||||
setCurrentAccountID,
|
||||
} from './utils/store-utils';
|
||||
|
||||
import './utils/toast-alert';
|
||||
|
||||
window.__STATES__ = states;
|
||||
|
@ -129,6 +137,8 @@ setTimeout(() => {
|
|||
setTimeout(() => {
|
||||
if (Array.isArray(ICONS[icon])) {
|
||||
ICONS[icon][0]?.();
|
||||
} else if (typeof ICONS[icon] === 'object') {
|
||||
ICONS[icon].module?.();
|
||||
} else {
|
||||
ICONS[icon]?.();
|
||||
}
|
||||
|
@ -294,6 +304,7 @@ subscribe(states, (changes) => {
|
|||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
useLingui();
|
||||
|
||||
useEffect(() => {
|
||||
const instanceURL = store.local.get('instanceURL');
|
||||
|
@ -310,9 +321,10 @@ function App() {
|
|||
window.location.pathname || '/',
|
||||
);
|
||||
|
||||
const clientID = store.session.get('clientID');
|
||||
const clientSecret = store.session.get('clientSecret');
|
||||
const vapidKey = store.session.get('vapidKey');
|
||||
const clientID = store.sessionCookie.get('clientID');
|
||||
const clientSecret = store.sessionCookie.get('clientSecret');
|
||||
const vapidKey = store.sessionCookie.get('vapidKey');
|
||||
const verifier = store.sessionCookie.get('codeVerifier');
|
||||
|
||||
(async () => {
|
||||
setUIState('loading');
|
||||
|
@ -321,22 +333,46 @@ function App() {
|
|||
client_id: clientID,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: verifier || undefined,
|
||||
});
|
||||
|
||||
const client = initClient({ instance: instanceURL, accessToken });
|
||||
await Promise.allSettled([
|
||||
initPreferences(client),
|
||||
initInstance(client, instanceURL),
|
||||
initAccount(client, instanceURL, accessToken, vapidKey),
|
||||
]);
|
||||
initStates();
|
||||
if (accessToken) {
|
||||
const client = initClient({ instance: instanceURL, accessToken });
|
||||
await Promise.allSettled([
|
||||
initPreferences(client),
|
||||
initInstance(client, instanceURL),
|
||||
initAccount(client, instanceURL, accessToken, vapidKey),
|
||||
]);
|
||||
initStates();
|
||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
||||
|
||||
setIsLoggedIn(true);
|
||||
setUIState('default');
|
||||
setIsLoggedIn(true);
|
||||
setUIState('default');
|
||||
} else {
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
||||
const account = getCurrentAccount();
|
||||
const searchAccount = decodeURIComponent(
|
||||
(window.location.search.match(/account=([^&]+)/) || [, ''])[1],
|
||||
);
|
||||
let account;
|
||||
if (searchAccount) {
|
||||
account = getAccount(searchAccount);
|
||||
console.log('searchAccount', searchAccount, account);
|
||||
if (account) {
|
||||
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 });
|
||||
|
@ -358,6 +394,11 @@ function App() {
|
|||
setUIState('default');
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
store.sessionCookie.del('clientID');
|
||||
store.sessionCookie.del('clientSecret');
|
||||
store.sessionCookie.del('codeVerifier');
|
||||
}, []);
|
||||
|
||||
let location = useLocation();
|
||||
|
|
|
@ -9,14 +9,17 @@ body.cloak,
|
|||
.status .content-container,
|
||||
.status .content-container *,
|
||||
.status .content-compact > *,
|
||||
.account-container .actions small,
|
||||
.account-container :is(header, main > *:not(.actions)),
|
||||
.account-container :is(header, main > *:not(.actions)) *,
|
||||
.header-double-lines,
|
||||
.header-double-lines *,
|
||||
.account-block,
|
||||
.catchup-filters .filter-author *,
|
||||
.post-peek-html *,
|
||||
.post-peek-content > *,
|
||||
.request-notifications-account * {
|
||||
.request-notifications-account *,
|
||||
.status.compact-thread *,
|
||||
.status .content-compact {
|
||||
text-decoration-thickness: 1.1em;
|
||||
text-decoration-line: line-through;
|
||||
/* text-rendering: optimizeSpeed; */
|
||||
|
@ -50,10 +53,19 @@ body.cloak,
|
|||
|
||||
body.cloak,
|
||||
.cloak {
|
||||
.header-double-lines *,
|
||||
.account-container .profile-metadata b,
|
||||
.account-container .actions small,
|
||||
.account-container .stats *,
|
||||
.media-container figcaption,
|
||||
.media-container figcaption > *,
|
||||
.catchup-filters .filter-author *,
|
||||
.request-notifications-account * {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.account-container .actions small,
|
||||
.status .content-compact {
|
||||
background-color: currentColor !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,14 @@ export const ICONS = {
|
|||
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
|
||||
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
|
||||
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
|
||||
'arrow-left': () => import('@iconify-icons/mingcute/arrow-left-line'),
|
||||
'arrow-right': () => import('@iconify-icons/mingcute/arrow-right-line'),
|
||||
'arrow-left': {
|
||||
module: () => import('@iconify-icons/mingcute/arrow-left-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'arrow-right': {
|
||||
module: () => import('@iconify-icons/mingcute/arrow-right-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
|
||||
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
|
||||
earth: () => import('@iconify-icons/mingcute/earth-line'),
|
||||
|
@ -16,8 +22,14 @@ export const ICONS = {
|
|||
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
|
||||
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
|
||||
message: () => import('@iconify-icons/mingcute/mail-line'),
|
||||
comment: () => import('@iconify-icons/mingcute/chat-3-line'),
|
||||
comment2: () => import('@iconify-icons/mingcute/comment-2-line'),
|
||||
comment: {
|
||||
module: () => import('@iconify-icons/mingcute/chat-3-line'),
|
||||
rtl: true,
|
||||
},
|
||||
comment2: {
|
||||
module: () => import('@iconify-icons/mingcute/comment-2-line'),
|
||||
rtl: true,
|
||||
},
|
||||
home: () => import('@iconify-icons/mingcute/home-3-line'),
|
||||
notification: () => import('@iconify-icons/mingcute/notification-line'),
|
||||
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
|
||||
|
@ -31,23 +43,46 @@ export const ICONS = {
|
|||
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
|
||||
more: () => import('@iconify-icons/mingcute/more-3-line'),
|
||||
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
|
||||
external: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
popout: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
popin: [() => import('@iconify-icons/mingcute/external-link-line'), '180deg'],
|
||||
external: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rtl: true,
|
||||
},
|
||||
popout: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rtl: true,
|
||||
},
|
||||
popin: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rotate: '180deg',
|
||||
rtl: true,
|
||||
},
|
||||
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
|
||||
'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
|
||||
'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
|
||||
'chevron-left': {
|
||||
module: () => import('@iconify-icons/mingcute/left-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'chevron-right': {
|
||||
module: () => import('@iconify-icons/mingcute/right-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
|
||||
reply: [
|
||||
() => import('@iconify-icons/mingcute/share-forward-line'),
|
||||
'180deg',
|
||||
'horizontal',
|
||||
],
|
||||
reply: {
|
||||
module: () => import('@iconify-icons/mingcute/share-forward-line'),
|
||||
rotate: '180deg',
|
||||
flip: 'horizontal',
|
||||
rtl: true,
|
||||
},
|
||||
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'),
|
||||
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'),
|
||||
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
|
||||
info: () => import('@iconify-icons/mingcute/information-line'),
|
||||
|
@ -62,12 +97,21 @@ export const ICONS = {
|
|||
share: () => import('@iconify-icons/mingcute/share-2-line'),
|
||||
sparkles: () => import('@iconify-icons/mingcute/sparkles-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'),
|
||||
play: () => import('@iconify-icons/mingcute/play-fill'),
|
||||
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
|
||||
mute: () => import('@iconify-icons/mingcute/volume-mute-line'),
|
||||
unmute: () => import('@iconify-icons/mingcute/volume-line'),
|
||||
mute: {
|
||||
module: () => import('@iconify-icons/mingcute/volume-mute-line'),
|
||||
rtl: true,
|
||||
},
|
||||
unmute: {
|
||||
module: () => import('@iconify-icons/mingcute/volume-line'),
|
||||
rtl: true,
|
||||
},
|
||||
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||
unblock: [
|
||||
() => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||
|
@ -81,30 +125,51 @@ export const ICONS = {
|
|||
filters: () => import('@iconify-icons/mingcute/filter-line'),
|
||||
chart: () => import('@iconify-icons/mingcute/chart-line-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'),
|
||||
announce: () => import('@iconify-icons/mingcute/announcement-line'),
|
||||
announce: {
|
||||
module: () => import('@iconify-icons/mingcute/announcement-line'),
|
||||
rtl: true,
|
||||
},
|
||||
alert: () => import('@iconify-icons/mingcute/alert-line'),
|
||||
round: () => import('@iconify-icons/mingcute/round-fill'),
|
||||
'arrow-up-circle': () =>
|
||||
import('@iconify-icons/mingcute/arrow-up-circle-line'),
|
||||
'arrow-down-circle': () =>
|
||||
import('@iconify-icons/mingcute/arrow-down-circle-line'),
|
||||
clipboard: () => 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-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
|
||||
keyboard: () => import('@iconify-icons/mingcute/keyboard-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'),
|
||||
speak: () => import('@iconify-icons/mingcute/radar-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'),
|
||||
'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'),
|
||||
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'),
|
||||
|
|
|
@ -29,6 +29,8 @@
|
|||
line-clamp: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
unicode-bidi: isolate;
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './account-block.css';
|
||||
|
||||
import { Plural, t, Trans } from '@lingui/macro';
|
||||
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
|
@ -120,7 +122,7 @@ function AccountBlock({
|
|||
)}
|
||||
</>
|
||||
)}{' '}
|
||||
<span class="account-block-acct">
|
||||
<span class="account-block-acct bidi-isolate">
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
<wbr />
|
||||
|
@ -128,20 +130,23 @@ function AccountBlock({
|
|||
{locked && (
|
||||
<>
|
||||
{' '}
|
||||
<Icon icon="lock" size="s" alt="Locked" />
|
||||
<Icon icon="lock" size="s" alt={t`Locked`} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{showActivity && (
|
||||
<div class="account-block-stats">
|
||||
Posts: {shortenNumber(statusesCount)}
|
||||
<Trans>Posts: {shortenNumber(statusesCount)}</Trans>
|
||||
{!!lastStatusAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Last posted:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
·{' '}
|
||||
<Trans>
|
||||
Last posted:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</Trans>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -151,14 +156,14 @@ function AccountBlock({
|
|||
{bot && (
|
||||
<>
|
||||
<span class="tag collapsed">
|
||||
<Icon icon="bot" /> Automated
|
||||
<Icon icon="bot" /> <Trans>Automated</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!!group && (
|
||||
<>
|
||||
<span class="tag collapsed">
|
||||
<Icon icon="group" /> Group
|
||||
<Icon icon="group" /> <Trans>Group</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
@ -167,26 +172,37 @@ function AccountBlock({
|
|||
<div class="shazam-container-inner">
|
||||
{excludedRelationship.following &&
|
||||
excludedRelationship.followedBy ? (
|
||||
<span class="tag minimal">Mutual</span>
|
||||
<span class="tag minimal">
|
||||
<Trans>Mutual</Trans>
|
||||
</span>
|
||||
) : excludedRelationship.requested ? (
|
||||
<span class="tag minimal">Requested</span>
|
||||
<span class="tag minimal">
|
||||
<Trans>Requested</Trans>
|
||||
</span>
|
||||
) : excludedRelationship.following ? (
|
||||
<span class="tag minimal">Following</span>
|
||||
<span class="tag minimal">
|
||||
<Trans>Following</Trans>
|
||||
</span>
|
||||
) : excludedRelationship.followedBy ? (
|
||||
<span class="tag minimal">Follows you</span>
|
||||
<span class="tag minimal">
|
||||
<Trans>Follows you</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!followersCount && (
|
||||
<span class="ib">
|
||||
{shortenNumber(followersCount)}{' '}
|
||||
{followersCount === 1 ? 'follower' : 'followers'}
|
||||
<Plural
|
||||
value={followersCount}
|
||||
one="# follower"
|
||||
other="# followers"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!!verifiedField && (
|
||||
<span class="verified-field">
|
||||
<Icon icon="check-circle" size="s" />{' '}
|
||||
<Icon icon="check-circle" size="s" alt={t`Verified`} />{' '}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: enhanceContent(verifiedField.value, { emojis }),
|
||||
|
@ -201,12 +217,14 @@ function AccountBlock({
|
|||
!verifiedField &&
|
||||
!!createdAt && (
|
||||
<span class="created-at">
|
||||
Joined{' '}
|
||||
<time datetime={createdAt}>
|
||||
{niceDateTime(createdAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</time>
|
||||
<Trans>
|
||||
Joined{' '}
|
||||
<time datetime={createdAt}>
|
||||
{niceDateTime(createdAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</time>
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
background-repeat: no-repeat;
|
||||
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
var(--original-color) 0%,
|
||||
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
|
||||
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
|
||||
|
@ -181,8 +181,8 @@
|
|||
opacity: 1;
|
||||
}
|
||||
.sheet .account-container .header-banner {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
border-start-start-radius: 16px;
|
||||
border-start-end-radius: 16px;
|
||||
}
|
||||
.account-container .header-banner.header-is-avatar {
|
||||
mask-image: linear-gradient(
|
||||
|
@ -288,10 +288,17 @@
|
|||
align-self: center !important;
|
||||
/* clip a dog ear on top right */
|
||||
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
|
||||
&:dir(rtl) {
|
||||
/* top left */
|
||||
clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px);
|
||||
}
|
||||
/* 4x4px square on top right */
|
||||
background-size: 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top right;
|
||||
&:dir(rtl) {
|
||||
background-position: top left;
|
||||
}
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--private-note-border-color),
|
||||
|
@ -311,7 +318,7 @@
|
|||
box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
&:hover:not(:active) {
|
||||
|
@ -370,7 +377,8 @@
|
|||
animation: appear 1s both ease-in-out;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin: 0 0 0 -4px;
|
||||
margin: 0;
|
||||
margin-inline-start: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -422,15 +430,15 @@
|
|||
}
|
||||
|
||||
&:has(+ .account-metadata-box) {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-end-start-radius: 4px;
|
||||
border-end-end-radius: 4px;
|
||||
}
|
||||
|
||||
+ .account-metadata-box {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
border-start-start-radius: 4px;
|
||||
border-start-end-radius: 4px;
|
||||
border-end-start-radius: 16px;
|
||||
border-end-end-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -805,7 +813,7 @@
|
|||
width: 100%;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
color: var(--text-insignificant-color);
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
|||
import { t } from '@lingui/macro';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
@ -33,7 +34,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
|||
>
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<AccountInfo
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
@ -9,13 +10,24 @@ import useInterval from '../utils/useInterval';
|
|||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
|
||||
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 }) {
|
||||
// Notifications service
|
||||
// - WebSocket to receive notifications when page is visible
|
||||
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) => {
|
||||
if (states.notificationsLast) {
|
||||
const notificationsIterator = masto.v1.notifications.list({
|
||||
|
@ -46,6 +58,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
|||
|
||||
useEffect(() => {
|
||||
let sub;
|
||||
let streamTimeout;
|
||||
let pollNotifications;
|
||||
if (isLoggedIn && visible) {
|
||||
const { masto, streaming, instance } = api();
|
||||
|
@ -56,7 +69,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
|||
let hasStreaming = false;
|
||||
// 2. Start streaming
|
||||
if (streaming) {
|
||||
pollNotifications = setTimeout(() => {
|
||||
streamTimeout = setTimeout(() => {
|
||||
(async () => {
|
||||
try {
|
||||
hasStreaming = true;
|
||||
|
@ -94,7 +107,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
|||
return () => {
|
||||
sub?.unsubscribe?.();
|
||||
sub = null;
|
||||
clearTimeout(pollNotifications);
|
||||
clearTimeout(streamTimeout);
|
||||
clearInterval(pollNotifications);
|
||||
};
|
||||
}, [visible, isLoggedIn]);
|
||||
|
@ -133,7 +146,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
|||
const currentCloakMode = states.settings.cloakMode;
|
||||
states.settings.cloakMode = !currentCloakMode;
|
||||
showToast({
|
||||
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,
|
||||
text: currentCloakMode ? t`Cloak mode disabled` : t`Cloak mode enabled`,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -15,7 +16,7 @@ import states from '../utils/states';
|
|||
import useTitle from '../utils/useTitle';
|
||||
|
||||
function Columns() {
|
||||
useTitle('Home', '/');
|
||||
useTitle(t`Home`, '/');
|
||||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts } = snapStates;
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -45,7 +46,7 @@ export default function ComposeButton() {
|
|||
snapStates.composerState.publishing ? 'loading' : ''
|
||||
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
|
||||
>
|
||||
<Icon icon="quill" size="xl" alt="Compose" />
|
||||
<Icon icon="quill" size="xl" alt={t`Compose`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
}
|
||||
|
||||
#compose-container .compose-top {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
@ -62,7 +61,7 @@
|
|||
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
|
||||
}
|
||||
#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) {
|
||||
pointer-events: none;
|
||||
|
@ -208,7 +207,7 @@
|
|||
left: -100vw !important;
|
||||
}
|
||||
#compose-container .toolbar-button select {
|
||||
background-color: transparent;
|
||||
background-color: inherit;
|
||||
border: 0;
|
||||
padding: 0 0 0 8px;
|
||||
margin: 0;
|
||||
|
@ -216,8 +215,8 @@
|
|||
line-height: 1em;
|
||||
}
|
||||
#compose-container .toolbar-button:not(.show-field) select {
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
inset-inline-end: 0;
|
||||
inset-inline-start: auto !important;
|
||||
}
|
||||
#compose-container
|
||||
.toolbar-button:not(:disabled):is(
|
||||
|
@ -303,6 +302,9 @@
|
|||
}
|
||||
#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]) {
|
||||
|
@ -494,14 +496,14 @@
|
|||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
border-inline-start: 1px solid var(--outline-color);
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
|
||||
#compose-container .expires-in {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
border-inline-start: 1px solid var(--outline-color);
|
||||
padding-inline-start: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
|
@ -646,7 +648,7 @@
|
|||
|
||||
&:hover {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
transparent 75%,
|
||||
var(--link-bg-color)
|
||||
);
|
||||
|
@ -654,7 +656,7 @@
|
|||
|
||||
&.selected {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
var(--bg-faded-color) 75%,
|
||||
var(--link-bg-color)
|
||||
);
|
||||
|
@ -666,8 +668,8 @@
|
|||
border-top: var(--hairline-width) solid var(--divider-color);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 58px;
|
||||
right: 0;
|
||||
inset-inline-start: 58px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
&:has(+ li:is(.selected, :hover)):before,
|
||||
|
@ -951,7 +953,7 @@
|
|||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
transparent 2px,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import './compose.css';
|
||||
|
||||
import '@github/text-expander-element';
|
||||
|
||||
import { msg, plural, t, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import Fuse from 'fuse.js';
|
||||
import { memo } from 'preact/compat';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { forwardRef, memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -28,10 +29,14 @@ import urlRegex from '../data/url-regex';
|
|||
import { api } from '../utils/api';
|
||||
import db from '../utils/db';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import i18nDuration from '../utils/i18n-duration';
|
||||
import isRTL from '../utils/is-rtl';
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import localeCode2Text from '../utils/localeCode2Text';
|
||||
import mem from '../utils/mem';
|
||||
import openCompose from '../utils/open-compose';
|
||||
import pmem from '../utils/pmem';
|
||||
import prettyBytes from '../utils/pretty-bytes';
|
||||
import { fetchRelationships } from '../utils/relationships';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import showToast from '../utils/show-toast';
|
||||
|
@ -74,16 +79,15 @@ const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
|||
*/
|
||||
|
||||
const expiryOptions = {
|
||||
'5 minutes': 5 * 60,
|
||||
'30 minutes': 30 * 60,
|
||||
'1 hour': 60 * 60,
|
||||
'6 hours': 6 * 60 * 60,
|
||||
'12 hours': 12 * 60 * 60,
|
||||
'1 day': 24 * 60 * 60,
|
||||
'3 days': 3 * 24 * 60 * 60,
|
||||
'7 days': 7 * 24 * 60 * 60,
|
||||
300: i18nDuration(5, 'minute'),
|
||||
1_800: i18nDuration(30, 'minute'),
|
||||
3_600: i18nDuration(1, 'hour'),
|
||||
21_600: i18nDuration(6, 'hour'),
|
||||
86_400: i18nDuration(1, 'day'),
|
||||
259_200: i18nDuration(3, 'day'),
|
||||
604_800: i18nDuration(1, 'week'),
|
||||
};
|
||||
const expirySeconds = Object.values(expiryOptions);
|
||||
const expirySeconds = Object.keys(expiryOptions);
|
||||
const oneDay = 24 * 60 * 60;
|
||||
|
||||
const expiresInFromExpiresAt = (expiresAt) => {
|
||||
|
@ -104,7 +108,8 @@ const observer = new IntersectionObserver((entries) => {
|
|||
const { left, width } = entry.boundingClientRect;
|
||||
const { innerWidth } = window;
|
||||
if (left + width > innerWidth) {
|
||||
menu.style.left = innerWidth - width - windowMargin + 'px';
|
||||
const insetInlineStart = isRTL() ? 'right' : 'left';
|
||||
menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -148,23 +153,22 @@ const SCAN_RE = new RegExp(
|
|||
);
|
||||
|
||||
const segmenter = new Intl.Segmenter();
|
||||
function highlightText(text, { maxCharacters = Infinity }) {
|
||||
// Accept text string, return formatted HTML string
|
||||
// Escape all HTML special characters
|
||||
let html = text
|
||||
function escapeHTML(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
}
|
||||
function highlightText(text, { maxCharacters = Infinity }) {
|
||||
// Exceeded characters limit
|
||||
const { composerCharacterCount } = states;
|
||||
if (composerCharacterCount > maxCharacters) {
|
||||
// Highlight exceeded characters
|
||||
let withinLimitHTML = '',
|
||||
exceedLimitHTML = '';
|
||||
const htmlSegments = segmenter.segment(html);
|
||||
const htmlSegments = segmenter.segment(text);
|
||||
for (const { segment, index } of htmlSegments) {
|
||||
if (index < maxCharacters) {
|
||||
withinLimitHTML += segment;
|
||||
|
@ -175,13 +179,13 @@ function highlightText(text, { maxCharacters = Infinity }) {
|
|||
if (exceedLimitHTML) {
|
||||
exceedLimitHTML =
|
||||
'<mark class="compose-highlight-exceeded">' +
|
||||
exceedLimitHTML +
|
||||
escapeHTML(exceedLimitHTML) +
|
||||
'</mark>';
|
||||
}
|
||||
return withinLimitHTML + exceedLimitHTML;
|
||||
return escapeHTML(withinLimitHTML) + exceedLimitHTML;
|
||||
}
|
||||
|
||||
return html
|
||||
return escapeHTML(text)
|
||||
.replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs
|
||||
.replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions
|
||||
.replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags
|
||||
|
@ -191,7 +195,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
|
|||
); // Emoji shortcodes
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat();
|
||||
// const rtf = new Intl.RelativeTimeFormat();
|
||||
const RTF = mem((locale) => new Intl.RelativeTimeFormat(locale || undefined));
|
||||
|
||||
const CUSTOM_EMOJIS_COUNT = 100;
|
||||
|
||||
|
@ -203,6 +208,9 @@ function Compose({
|
|||
standalone,
|
||||
hasOpener,
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const rtf = RTF(i18n.locale);
|
||||
|
||||
console.warn('RENDER COMPOSER');
|
||||
const { masto, instance } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -289,10 +297,14 @@ function Compose({
|
|||
focusTextarea();
|
||||
setVisibility(
|
||||
visibility === 'public' && prefs['posting:default:visibility']
|
||||
? prefs['posting:default:visibility']
|
||||
? prefs['posting:default:visibility'].toLowerCase()
|
||||
: visibility,
|
||||
);
|
||||
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
|
||||
setLanguage(
|
||||
language ||
|
||||
prefs['posting:default:language']?.toLowerCase() ||
|
||||
DEFAULT_LANG,
|
||||
);
|
||||
setSensitive(sensitive && !!spoilerText);
|
||||
} else if (editStatus) {
|
||||
const { visibility, language, sensitive, poll, mediaAttachments } =
|
||||
|
@ -316,7 +328,11 @@ function Compose({
|
|||
focusTextarea();
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
|
||||
setLanguage(
|
||||
language ||
|
||||
prefs['posting:default:language']?.toLowerCase() ||
|
||||
DEFAULT_LANG,
|
||||
);
|
||||
setSensitive(sensitive);
|
||||
if (composablePoll) setPoll(composablePoll);
|
||||
setMediaAttachments(mediaAttachments);
|
||||
|
@ -331,13 +347,13 @@ function Compose({
|
|||
focusTextarea();
|
||||
console.log('Apply prefs', prefs);
|
||||
if (prefs['posting:default:visibility']) {
|
||||
setVisibility(prefs['posting:default:visibility']);
|
||||
setVisibility(prefs['posting:default:visibility'].toLowerCase());
|
||||
}
|
||||
if (prefs['posting:default:language']) {
|
||||
setLanguage(prefs['posting:default:language']);
|
||||
setLanguage(prefs['posting:default:language'].toLowerCase());
|
||||
}
|
||||
if (prefs['posting:default:sensitive']) {
|
||||
setSensitive(prefs['posting:default:sensitive']);
|
||||
setSensitive(!!prefs['posting:default:sensitive']);
|
||||
}
|
||||
}
|
||||
if (draftStatus) {
|
||||
|
@ -360,7 +376,11 @@ function Compose({
|
|||
focusTextarea();
|
||||
if (spoilerText) spoilerTextRef.current.value = spoilerText;
|
||||
if (visibility) setVisibility(visibility);
|
||||
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
|
||||
setLanguage(
|
||||
language ||
|
||||
prefs['posting:default:language']?.toLowerCase() ||
|
||||
DEFAULT_LANG,
|
||||
);
|
||||
if (sensitive !== null) setSensitive(sensitive);
|
||||
if (composablePoll) setPoll(composablePoll);
|
||||
if (mediaAttachments) setMediaAttachments(mediaAttachments);
|
||||
|
@ -369,7 +389,7 @@ function Compose({
|
|||
|
||||
const formRef = useRef();
|
||||
|
||||
const beforeUnloadCopy = 'You have unsaved changes. Discard this post?';
|
||||
const beforeUnloadCopy = t`You have unsaved changes. Discard this post?`;
|
||||
const canClose = () => {
|
||||
const { value, dataset } = textareaRef.current;
|
||||
|
||||
|
@ -598,7 +618,12 @@ function Compose({
|
|||
}
|
||||
}
|
||||
if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) {
|
||||
alert(`You can only attach up to ${maxMediaAttachments} files.`);
|
||||
alert(
|
||||
plural(maxMediaAttachments, {
|
||||
one: 'You can only attach up to 1 file.',
|
||||
other: 'You can only attach up to # files.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log({ files });
|
||||
|
@ -609,7 +634,12 @@ function Compose({
|
|||
const max = maxMediaAttachments - mediaAttachments.length;
|
||||
const allowedFiles = files.slice(0, max);
|
||||
if (allowedFiles.length <= 0) {
|
||||
alert(`You can only attach up to ${maxMediaAttachments} files.`);
|
||||
alert(
|
||||
plural(maxMediaAttachments, {
|
||||
one: 'You can only attach up to 1 file.',
|
||||
other: 'You can only attach up to # files.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mediaFiles = allowedFiles.map((file) => ({
|
||||
|
@ -753,14 +783,14 @@ function Compose({
|
|||
onClose();
|
||||
}}
|
||||
>
|
||||
<Icon icon="popout" alt="Pop out" />
|
||||
<Icon icon="popout" alt={t`Pop out`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="plain4 min-button"
|
||||
onClick={onMinimize}
|
||||
>
|
||||
<Icon icon="minimize" alt="Minimize" />
|
||||
<Icon icon="minimize" alt={t`Minimize`} />
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
|
@ -772,7 +802,7 @@ function Compose({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
|
@ -796,20 +826,19 @@ function Compose({
|
|||
// }
|
||||
|
||||
if (!window.opener) {
|
||||
alert('Looks like you closed the parent window.');
|
||||
alert(t`Looks like you closed the parent window.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.opener.__STATES__.showCompose) {
|
||||
if (window.opener.__STATES__.composerState?.publishing) {
|
||||
alert(
|
||||
'Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.',
|
||||
t`Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let confirmText =
|
||||
'Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?';
|
||||
let confirmText = t`Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?`;
|
||||
const yes = confirm(confirmText);
|
||||
if (!yes) return;
|
||||
}
|
||||
|
@ -851,7 +880,7 @@ function Compose({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="popin" alt="Pop in" />
|
||||
<Icon icon="popin" alt={t`Pop in`} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
@ -860,18 +889,22 @@ function Compose({
|
|||
<div class="status-preview">
|
||||
<Status status={replyToStatus} size="s" previewMode />
|
||||
<div class="status-preview-legend reply-to">
|
||||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
’s post
|
||||
{replyToStatusMonthsAgo >= 3 && (
|
||||
<>
|
||||
{' '}
|
||||
(
|
||||
{replyToStatusMonthsAgo > 0 ? (
|
||||
<Trans>
|
||||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
’s post (
|
||||
<strong>
|
||||
{rtf.format(-replyToStatusMonthsAgo, 'month')}
|
||||
</strong>
|
||||
)
|
||||
</>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
’s post
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -879,7 +912,9 @@ function Compose({
|
|||
{!!editStatus && (
|
||||
<div class="status-preview">
|
||||
<Status status={editStatus} size="s" previewMode />
|
||||
<div class="status-preview-legend">Editing source post</div>
|
||||
<div class="status-preview-legend">
|
||||
<Trans>Editing source post</Trans>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
|
@ -925,11 +960,11 @@ function Compose({
|
|||
*/
|
||||
if (poll) {
|
||||
if (poll.options.length < 2) {
|
||||
alert('Poll must have at least 2 options');
|
||||
alert(t`Poll must have at least 2 options`);
|
||||
return;
|
||||
}
|
||||
if (poll.options.some((option) => option === '')) {
|
||||
alert('Some poll choices are empty');
|
||||
alert(t`Some poll choices are empty`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -942,7 +977,7 @@ function Compose({
|
|||
);
|
||||
if (hasNoDescriptions) {
|
||||
const yes = confirm(
|
||||
'Some media have no descriptions. Continue?',
|
||||
t`Some media have no descriptions. Continue?`,
|
||||
);
|
||||
if (!yes) return;
|
||||
}
|
||||
|
@ -994,7 +1029,7 @@ function Compose({
|
|||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(result);
|
||||
alert(result.reason || `Attachment #${i} failed`);
|
||||
alert(result.reason || t`Attachment #${i} failed`);
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
@ -1088,7 +1123,7 @@ function Compose({
|
|||
ref={spoilerTextRef}
|
||||
type="text"
|
||||
name="spoilerText"
|
||||
placeholder="Content warning"
|
||||
placeholder={t`Content warning`}
|
||||
disabled={uiState === 'loading'}
|
||||
class="spoiler-text-field"
|
||||
lang={language}
|
||||
|
@ -1104,7 +1139,7 @@ function Compose({
|
|||
/>
|
||||
<label
|
||||
class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
|
||||
title="Content warning or sensitive media"
|
||||
title={t`Content warning or sensitive media`}
|
||||
>
|
||||
<input
|
||||
name="sensitive"
|
||||
|
@ -1127,7 +1162,7 @@ function Compose({
|
|||
class={`toolbar-button ${
|
||||
visibility !== 'public' && !sensitive ? 'show-field' : ''
|
||||
} ${visibility !== 'public' ? 'highlight' : ''}`}
|
||||
title={`Visibility: ${visibility}`}
|
||||
title={visibility}
|
||||
>
|
||||
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
|
||||
<select
|
||||
|
@ -1137,13 +1172,20 @@ function Compose({
|
|||
setVisibility(e.target.value);
|
||||
}}
|
||||
disabled={uiState === 'loading' || !!editStatus}
|
||||
dir="auto"
|
||||
>
|
||||
<option value="public">
|
||||
Public <Icon icon="earth" />
|
||||
<Trans>Public</Trans>
|
||||
</option>
|
||||
<option value="unlisted">
|
||||
<Trans>Unlisted</Trans>
|
||||
</option>
|
||||
<option value="private">
|
||||
<Trans>Followers only</Trans>
|
||||
</option>
|
||||
<option value="direct">
|
||||
<Trans>Private mention</Trans>
|
||||
</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="private">Followers only</option>
|
||||
<option value="direct">Private mention</option>
|
||||
</select>
|
||||
</label>{' '}
|
||||
</div>
|
||||
|
@ -1151,10 +1193,10 @@ function Compose({
|
|||
ref={textareaRef}
|
||||
placeholder={
|
||||
replyToStatus
|
||||
? 'Post your reply'
|
||||
? t`Post your reply`
|
||||
: editStatus
|
||||
? 'Edit your post'
|
||||
: 'What are you doing?'
|
||||
? t`Edit your post`
|
||||
: t`What are you doing?`
|
||||
}
|
||||
required={mediaAttachments?.length === 0}
|
||||
disabled={uiState === 'loading'}
|
||||
|
@ -1228,7 +1270,9 @@ function Compose({
|
|||
setSensitive(sensitive);
|
||||
}}
|
||||
/>{' '}
|
||||
<span>Mark media as sensitive</span>{' '}
|
||||
<span>
|
||||
<Trans>Mark media as sensitive</Trans>
|
||||
</span>{' '}
|
||||
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
|
||||
</label>
|
||||
</div>
|
||||
|
@ -1289,7 +1333,10 @@ function Compose({
|
|||
maxMediaAttachments
|
||||
) {
|
||||
alert(
|
||||
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||
plural(maxMediaAttachments, {
|
||||
one: 'You can only attach up to 1 file.',
|
||||
other: 'You can only attach up to # files.',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setMediaAttachments((attachments) => {
|
||||
|
@ -1322,7 +1369,7 @@ function Compose({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="poll" alt="Add poll" />
|
||||
<Icon icon="poll" alt={t`Add poll`} />
|
||||
</button>
|
||||
</>
|
||||
))}
|
||||
|
@ -1344,7 +1391,7 @@ function Compose({
|
|||
setShowEmoji2Picker(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="emoji2" />
|
||||
<Icon icon="emoji2" alt={t`Add custom emoji`} />
|
||||
</button>
|
||||
{!!states.settings.composerGIFPicker && (
|
||||
<button
|
||||
|
@ -1393,18 +1440,33 @@ function Compose({
|
|||
store.session.set('currentLanguage', value || DEFAULT_LANG);
|
||||
}}
|
||||
disabled={uiState === 'loading'}
|
||||
dir="auto"
|
||||
>
|
||||
{topSupportedLanguages.map(([code, common, native]) => (
|
||||
<option value={code} key={code}>
|
||||
{common} ({native})
|
||||
</option>
|
||||
))}
|
||||
{topSupportedLanguages.map(([code, common, native]) => {
|
||||
const commonText = localeCode2Text({
|
||||
code,
|
||||
fallback: common,
|
||||
});
|
||||
const showCommon = commonText !== native;
|
||||
return (
|
||||
<option value={code} key={code}>
|
||||
{showCommon ? `${native} - ${commonText}` : commonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<hr />
|
||||
{restSupportedLanguages.map(([code, common, native]) => (
|
||||
<option value={code} key={code}>
|
||||
{common} ({native})
|
||||
</option>
|
||||
))}
|
||||
{restSupportedLanguages.map(([code, common, native]) => {
|
||||
const commonText = localeCode2Text({
|
||||
code,
|
||||
fallback: common,
|
||||
});
|
||||
const showCommon = commonText !== native;
|
||||
return (
|
||||
<option value={code} key={code}>
|
||||
{showCommon ? `${native} - ${commonText}` : commonText}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>{' '}
|
||||
<button
|
||||
|
@ -1412,7 +1474,14 @@ function Compose({
|
|||
class="large"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
||||
{replyToStatus
|
||||
? t`Reply`
|
||||
: editStatus
|
||||
? t`Update`
|
||||
: t({
|
||||
message: 'Post',
|
||||
context: 'Submit button in composer',
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1525,7 +1594,10 @@ function Compose({
|
|||
console.log('GIF URL', url);
|
||||
if (mediaAttachments.length >= maxMediaAttachments) {
|
||||
alert(
|
||||
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||
plural(maxMediaAttachments, {
|
||||
one: 'You can only attach up to 1 file.',
|
||||
other: 'You can only attach up to # files.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -1534,7 +1606,7 @@ function Compose({
|
|||
let theToast;
|
||||
try {
|
||||
theToast = showToast({
|
||||
text: 'Downloading GIF…',
|
||||
text: t`Downloading GIF…`,
|
||||
duration: -1,
|
||||
});
|
||||
const blob = await fetch(url, {
|
||||
|
@ -1562,7 +1634,7 @@ function Compose({
|
|||
} catch (err) {
|
||||
console.error(err);
|
||||
theToast?.hideToast?.();
|
||||
showToast('Failed to download GIF');
|
||||
showToast(t`Failed to download GIF`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
|
@ -1673,7 +1745,7 @@ const Textarea = forwardRef((props, ref) => {
|
|||
${encodeHTML(shortcode)}
|
||||
</li>`;
|
||||
});
|
||||
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
|
||||
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
|
||||
// console.log({ emojis, html });
|
||||
menu.innerHTML = html;
|
||||
provide(
|
||||
|
@ -1726,7 +1798,9 @@ const Textarea = forwardRef((props, ref) => {
|
|||
</span>
|
||||
<span>
|
||||
<b>${displayNameWithEmoji || username}</b>
|
||||
<br>@${encodeHTML(acct)}
|
||||
<br><span class="bidi-isolate">@${encodeHTML(
|
||||
acct,
|
||||
)}</span>
|
||||
</span>
|
||||
</li>
|
||||
`;
|
||||
|
@ -1748,7 +1822,7 @@ const Textarea = forwardRef((props, ref) => {
|
|||
}
|
||||
});
|
||||
if (type === 'accounts') {
|
||||
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
|
||||
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
|
||||
}
|
||||
menu.innerHTML = html;
|
||||
console.log('MENU', results, menu);
|
||||
|
@ -2021,16 +2095,6 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
|
|||
);
|
||||
}
|
||||
|
||||
function prettyBytes(bytes) {
|
||||
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
let unitIndex = 0;
|
||||
while (bytes >= 1024) {
|
||||
bytes /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${bytes.toFixed(0).toLocaleString()} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function scaleDimension(matrix, matrixLimit, width, height) {
|
||||
// matrix = number of pixels
|
||||
// matrixLimit = max number of pixels
|
||||
|
@ -2048,6 +2112,7 @@ function MediaAttachment({
|
|||
onDescriptionChange = () => {},
|
||||
onRemove = () => {},
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const supportsEdit = supports('@mastodon/edit-media-attributes');
|
||||
const { type, id, file } = attachment;
|
||||
|
@ -2159,7 +2224,9 @@ function MediaAttachment({
|
|||
<>
|
||||
{!!id && !supportsEdit ? (
|
||||
<div class="media-desc">
|
||||
<span class="tag">Uploaded</span>
|
||||
<span class="tag">
|
||||
<Trans>Uploaded</Trans>
|
||||
</span>
|
||||
<p title={description}>
|
||||
{attachment.description || <i>No description</i>}
|
||||
</p>
|
||||
|
@ -2171,9 +2238,9 @@ function MediaAttachment({
|
|||
lang={lang}
|
||||
placeholder={
|
||||
{
|
||||
image: 'Image description',
|
||||
video: 'Video description',
|
||||
audio: 'Audio description',
|
||||
image: t`Image description`,
|
||||
video: t`Video description`,
|
||||
audio: t`Audio description`,
|
||||
}[suffixType]
|
||||
}
|
||||
autoCapitalize="sentences"
|
||||
|
@ -2209,7 +2276,7 @@ function MediaAttachment({
|
|||
switch (type) {
|
||||
case 'imageSizeLimit': {
|
||||
const { imageSize, imageSizeLimit } = details;
|
||||
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||||
return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||||
imageSize,
|
||||
)} to ${prettyBytes(imageSizeLimit)} or lower.`;
|
||||
}
|
||||
|
@ -2221,11 +2288,15 @@ function MediaAttachment({
|
|||
width,
|
||||
height,
|
||||
);
|
||||
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
|
||||
return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number(
|
||||
width,
|
||||
)}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number(
|
||||
newHeight,
|
||||
)}px.`;
|
||||
}
|
||||
case 'videoSizeLimit': {
|
||||
const { videoSize, videoSizeLimit } = details;
|
||||
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||||
return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||||
videoSize,
|
||||
)} to ${prettyBytes(videoSizeLimit)} or lower.`;
|
||||
}
|
||||
|
@ -2237,11 +2308,15 @@ function MediaAttachment({
|
|||
width,
|
||||
height,
|
||||
);
|
||||
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
|
||||
return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number(
|
||||
width,
|
||||
)}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number(
|
||||
newHeight,
|
||||
)}px.`;
|
||||
}
|
||||
case 'videoFrameRateLimit': {
|
||||
// Not possible to detect this on client-side for now
|
||||
return 'Frame rate too high. Uploading might encounter issues.';
|
||||
return t`Frame rate too high. Uploading might encounter issues.`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -2301,7 +2376,7 @@ function MediaAttachment({
|
|||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Remove`} />
|
||||
</button>
|
||||
{!!maxError && (
|
||||
<button
|
||||
|
@ -2318,17 +2393,15 @@ function MediaAttachment({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="alert" />
|
||||
<Icon icon="alert" alt={t`Error`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showModal && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowModal(false);
|
||||
}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
>
|
||||
<div id="media-sheet" class="sheet sheet-max">
|
||||
|
@ -2339,15 +2412,15 @@ function MediaAttachment({
|
|||
setShowModal(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
<header>
|
||||
<h2>
|
||||
{
|
||||
{
|
||||
image: 'Edit image description',
|
||||
video: 'Edit video description',
|
||||
audio: 'Edit audio description',
|
||||
image: t`Edit image description`,
|
||||
video: t`Edit video description`,
|
||||
audio: t`Edit audio description`,
|
||||
}[suffixType]
|
||||
}
|
||||
</h2>
|
||||
|
@ -2382,8 +2455,8 @@ function MediaAttachment({
|
|||
position="anchor"
|
||||
overflow="auto"
|
||||
menuButton={
|
||||
<button type="button" title="More" class="plain">
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
<button type="button" class="plain">
|
||||
<Icon icon="more" size="l" alt={t`More`} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
|
@ -2392,7 +2465,7 @@ function MediaAttachment({
|
|||
onClick={() => {
|
||||
setUIState('loading');
|
||||
toastRef.current = showToast({
|
||||
text: 'Generating description. Please wait...',
|
||||
text: t`Generating description. Please wait…`,
|
||||
duration: -1,
|
||||
});
|
||||
// POST with multipart
|
||||
|
@ -2411,9 +2484,9 @@ function MediaAttachment({
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(
|
||||
`Failed to generate description${
|
||||
e?.message ? `: ${e.message}` : ''
|
||||
}`,
|
||||
e.message
|
||||
? t`Failed to generate description: ${e.message}`
|
||||
: t`Failed to generate description`,
|
||||
);
|
||||
} finally {
|
||||
setUIState('default');
|
||||
|
@ -2425,12 +2498,14 @@ function MediaAttachment({
|
|||
<Icon icon="sparkles2" />
|
||||
{lang && lang !== 'en' ? (
|
||||
<small>
|
||||
Generate description…
|
||||
<Trans>Generate description…</Trans>
|
||||
<br />
|
||||
(English)
|
||||
</small>
|
||||
) : (
|
||||
<span>Generate description…</span>
|
||||
<span>
|
||||
<Trans>Generate description…</Trans>
|
||||
</span>
|
||||
)}
|
||||
</MenuItem>
|
||||
{!!lang && lang !== 'en' && (
|
||||
|
@ -2439,7 +2514,7 @@ function MediaAttachment({
|
|||
onClick={() => {
|
||||
setUIState('loading');
|
||||
toastRef.current = showToast({
|
||||
text: 'Generating description. Please wait...',
|
||||
text: t`Generating description. Please wait…`,
|
||||
duration: -1,
|
||||
});
|
||||
// POST with multipart
|
||||
|
@ -2462,7 +2537,7 @@ function MediaAttachment({
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(
|
||||
`Failed to generate description${
|
||||
t`Failed to generate description${
|
||||
e?.message ? `: ${e.message}` : ''
|
||||
}`,
|
||||
);
|
||||
|
@ -2475,11 +2550,14 @@ function MediaAttachment({
|
|||
>
|
||||
<Icon icon="sparkles2" />
|
||||
<small>
|
||||
Generate description…
|
||||
<br />({localeCode2Text(lang)}){' '}
|
||||
<span class="more-insignificant">
|
||||
— experimental
|
||||
</span>
|
||||
<Trans>Generate description…</Trans>
|
||||
<br />
|
||||
<Trans>
|
||||
({localeCode2Text(lang)}){' '}
|
||||
<span class="more-insignificant">
|
||||
— experimental
|
||||
</span>
|
||||
</Trans>
|
||||
</small>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
@ -2493,7 +2571,7 @@ function MediaAttachment({
|
|||
}}
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Done
|
||||
<Trans>Done</Trans>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
@ -2515,6 +2593,7 @@ function Poll({
|
|||
minExpiration,
|
||||
maxCharactersPerOption,
|
||||
}) {
|
||||
const { _ } = useLingui();
|
||||
const { options, expiresIn, multiple } = poll;
|
||||
|
||||
return (
|
||||
|
@ -2528,7 +2607,7 @@ function Poll({
|
|||
value={option}
|
||||
disabled={disabled}
|
||||
maxlength={maxCharactersPerOption}
|
||||
placeholder={`Choice ${i + 1}`}
|
||||
placeholder={t`Choice ${i + 1}`}
|
||||
lang={lang}
|
||||
spellCheck="true"
|
||||
dir="auto"
|
||||
|
@ -2547,7 +2626,7 @@ function Poll({
|
|||
onInput(poll);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" size="s" />
|
||||
<Icon icon="x" size="s" alt={t`Remove`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
@ -2575,10 +2654,10 @@ function Poll({
|
|||
onInput(poll);
|
||||
}}
|
||||
/>{' '}
|
||||
Multiple choices
|
||||
<Trans>Multiple choices</Trans>
|
||||
</label>
|
||||
<label class="expires-in">
|
||||
Duration{' '}
|
||||
<Trans>Duration</Trans>{' '}
|
||||
<select
|
||||
value={expiresIn}
|
||||
disabled={disabled}
|
||||
|
@ -2589,12 +2668,12 @@ function Poll({
|
|||
}}
|
||||
>
|
||||
{Object.entries(expiryOptions)
|
||||
.filter(([label, value]) => {
|
||||
.filter(([value]) => {
|
||||
return value >= minExpiration && value <= maxExpiration;
|
||||
})
|
||||
.map(([label, value]) => (
|
||||
.map(([value, label]) => (
|
||||
<option value={value} key={value}>
|
||||
{label}
|
||||
{label()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
@ -2609,7 +2688,7 @@ function Poll({
|
|||
onInput(null);
|
||||
}}
|
||||
>
|
||||
Remove poll
|
||||
<Trans>Remove poll</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2806,7 +2885,7 @@ function MentionModal({
|
|||
<div id="mention-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
|
@ -2823,7 +2902,7 @@ function MentionModal({
|
|||
required
|
||||
type="search"
|
||||
class="block"
|
||||
placeholder="Search accounts"
|
||||
placeholder={t`Search accounts`}
|
||||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
debouncedLoadAccounts(value);
|
||||
|
@ -2864,7 +2943,7 @@ function MentionModal({
|
|||
selectAccount(account);
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" size="xl" />
|
||||
<Icon icon="plus" size="xl" alt={t`Add`} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
@ -2876,7 +2955,9 @@ function MentionModal({
|
|||
</div>
|
||||
) : uiState === 'error' ? (
|
||||
<div class="ui-state">
|
||||
<p>Error loading accounts</p>
|
||||
<p>
|
||||
<Trans>Error loading accounts</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
@ -3012,12 +3093,14 @@ function CustomEmojisModal({
|
|||
<div id="custom-emojis-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<div>
|
||||
<b>Custom emojis</b>{' '}
|
||||
<b>
|
||||
<Trans>Custom emojis</Trans>
|
||||
</b>{' '}
|
||||
{uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
|
@ -3036,7 +3119,7 @@ function CustomEmojisModal({
|
|||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search emoji"
|
||||
placeholder={t`Search emoji`}
|
||||
onInput={onFind}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
|
@ -3066,7 +3149,9 @@ function CustomEmojisModal({
|
|||
<div class="custom-emojis-list">
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading custom emojis</p>
|
||||
<p>
|
||||
<Trans>Error loading custom emojis</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'default' &&
|
||||
|
@ -3076,8 +3161,8 @@ function CustomEmojisModal({
|
|||
<>
|
||||
<div class="section-header">
|
||||
{{
|
||||
'--recent--': 'Recently used',
|
||||
'--others--': 'Others',
|
||||
'--recent--': t`Recently used`,
|
||||
'--others--': t`Others`,
|
||||
}[category] || category}
|
||||
</div>
|
||||
<CustomEmojisList
|
||||
|
@ -3095,6 +3180,7 @@ function CustomEmojisModal({
|
|||
}
|
||||
|
||||
const CustomEmojisList = memo(({ emojis, onSelect }) => {
|
||||
const { i18n } = useLingui();
|
||||
const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT);
|
||||
const showMore = emojis.length > max;
|
||||
return (
|
||||
|
@ -3114,7 +3200,7 @@ const CustomEmojisList = memo(({ emojis, onSelect }) => {
|
|||
class="plain small"
|
||||
onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)}
|
||||
>
|
||||
{(emojis.length - max).toLocaleString()} more…
|
||||
<Trans>{i18n.number(emojis.length - max)} more…</Trans>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
@ -3181,6 +3267,7 @@ const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => {
|
|||
|
||||
const GIFS_PER_PAGE = 20;
|
||||
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
||||
const { i18n } = useLingui();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [results, setResults] = useState([]);
|
||||
const formRef = useRef(null);
|
||||
|
@ -3206,6 +3293,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
limit: GIFS_PER_PAGE,
|
||||
bundle: 'messaging_non_clips',
|
||||
offset,
|
||||
lang: i18n.locale || 'en',
|
||||
};
|
||||
const response = await fetch(
|
||||
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
|
||||
|
@ -3235,7 +3323,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
<div id="gif-picker-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
|
@ -3250,7 +3338,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
ref={qRef}
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search GIFs"
|
||||
placeholder={t`Search GIFs`}
|
||||
required
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
|
@ -3265,13 +3353,16 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
src={poweredByGiphyURL}
|
||||
width="86"
|
||||
height="30"
|
||||
alt={t`Powered by GIPHY`}
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
|
||||
{uiState === 'default' && (
|
||||
<div class="ui-state">
|
||||
<p class="insignificant">Type to search GIFs</p>
|
||||
<p class="insignificant">
|
||||
<Trans>Type to search GIFs</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'loading' && !results?.data?.length && (
|
||||
|
@ -3367,7 +3458,9 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="chevron-left" />
|
||||
<span>Previous</span>
|
||||
<span>
|
||||
<Trans>Previous</Trans>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
|
@ -3383,7 +3476,10 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<span>Next</span> <Icon icon="chevron-right" />
|
||||
<span>
|
||||
<Trans>Next</Trans>
|
||||
</span>{' '}
|
||||
<Icon icon="chevron-right" />
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
|
@ -3397,7 +3493,9 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
|||
)}
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading GIFs</p>
|
||||
<p>
|
||||
<Trans>Error loading GIFs</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
@ -27,7 +27,7 @@ button.draft-item {
|
|||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--link-faded-color);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
padding: 0;
|
||||
}
|
||||
button.draft-item:is(:hover, :focus) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './drafts.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
@ -54,17 +55,20 @@ function Drafts({ onClose }) {
|
|||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>
|
||||
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
|
||||
<Trans>Unsent drafts</Trans>{' '}
|
||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||
</h2>
|
||||
{hasDrafts && (
|
||||
<div class="insignificant">
|
||||
Looks like you have unsent drafts. Let's continue where you left
|
||||
off.
|
||||
<Trans>
|
||||
Looks like you have unsent drafts. Let's continue where you left
|
||||
off.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
@ -83,7 +87,9 @@ function Drafts({ onClose }) {
|
|||
<time>
|
||||
{!!replyTo && (
|
||||
<>
|
||||
@{replyTo.account.acct}
|
||||
<span class="bidi-isolate">
|
||||
@{replyTo.account.acct}
|
||||
</span>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
|
@ -91,7 +97,11 @@ function Drafts({ onClose }) {
|
|||
</time>
|
||||
</b>
|
||||
<MenuConfirm
|
||||
confirmLabel={<span>Delete this draft?</span>}
|
||||
confirmLabel={
|
||||
<span>
|
||||
<Trans>Delete this draft?</Trans>
|
||||
</span>
|
||||
}
|
||||
menuItemClassName="danger"
|
||||
align="end"
|
||||
disabled={uiState === 'loading'}
|
||||
|
@ -104,7 +114,7 @@ function Drafts({ onClose }) {
|
|||
reload();
|
||||
// }
|
||||
} 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"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete…
|
||||
<Trans>Delete…</Trans>
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
</div>
|
||||
|
@ -133,7 +143,7 @@ function Drafts({ onClose }) {
|
|||
.fetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error fetching reply-to status!');
|
||||
alert(t`Error fetching reply-to status!`);
|
||||
setUIState('default');
|
||||
return;
|
||||
}
|
||||
|
@ -156,7 +166,11 @@ function Drafts({ onClose }) {
|
|||
{drafts.length > 1 && (
|
||||
<p>
|
||||
<MenuConfirm
|
||||
confirmLabel={<span>Delete all drafts?</span>}
|
||||
confirmLabel={
|
||||
<span>
|
||||
<Trans>Delete all drafts?</Trans>
|
||||
</span>
|
||||
}
|
||||
menuItemClassName="danger"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
|
@ -172,7 +186,7 @@ function Drafts({ onClose }) {
|
|||
reload();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error deleting drafts! Please try again.');
|
||||
alert(t`Error deleting drafts! Please try again.`);
|
||||
setUIState('error');
|
||||
}
|
||||
// }
|
||||
|
@ -184,14 +198,16 @@ function Drafts({ onClose }) {
|
|||
class="light danger"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete all…
|
||||
<Trans>Delete all…</Trans>
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>No drafts found.</p>
|
||||
<p>
|
||||
<Trans>No drafts found.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
@ -226,10 +242,10 @@ function MiniDraft({ draft }) {
|
|||
: {}
|
||||
}
|
||||
>
|
||||
{hasPoll && <Icon icon="poll" />}
|
||||
{hasPoll && <Icon icon="poll" alt={t`Poll`} />}
|
||||
{hasMedia && (
|
||||
<span>
|
||||
<Icon icon="attachment" />{' '}
|
||||
<Icon icon="attachment" alt={t`Media`} />{' '}
|
||||
<small>{mediaAttachments?.length}</small>
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './embed-modal.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
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="top-controls">
|
||||
<button type="button" class="light" onClick={() => onClose()}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
{url && (
|
||||
<a
|
||||
|
@ -16,7 +18,10 @@ function EmbedModal({ html, url, width, height, onClose = () => {} }) {
|
|||
rel="noopener noreferrer"
|
||||
class="button plain"
|
||||
>
|
||||
<span>Open link</span> <Icon icon="external" />
|
||||
<span>
|
||||
<Trans>Open in new window</Trans>
|
||||
</span>{' '}
|
||||
<Icon icon="external" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
@ -38,7 +39,7 @@ function FollowRequestButtons({ accountID, onChange }) {
|
|||
})();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
<Trans>Accept</Trans>
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
|
@ -64,14 +65,18 @@ function FollowRequestButtons({ accountID, onChange }) {
|
|||
})();
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
<Trans>Reject</Trans>
|
||||
</button>
|
||||
<span class="follow-request-states">
|
||||
{hasRelationship && requestState ? (
|
||||
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'} />
|
||||
|
|
|
@ -62,13 +62,13 @@
|
|||
border-top: var(--hairline-width) solid var(--divider-color);
|
||||
position: absolute;
|
||||
bottom: calc(-1 * var(--list-gap) / 2);
|
||||
left: 40px;
|
||||
right: 0;
|
||||
inset-inline-start: 40px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
&:has(.reactions-block):before {
|
||||
/* avatar + reactions + gap */
|
||||
left: calc(40px + 16px + 8px);
|
||||
inset-inline-start: calc(40px + 16px + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './generic-accounts.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -20,7 +21,7 @@ export default function GenericAccounts({
|
|||
excludeRelationshipAttrs = [],
|
||||
postID,
|
||||
onClose = () => {},
|
||||
blankCopy = 'Nothing to show',
|
||||
blankCopy = t`Nothing to show`,
|
||||
}) {
|
||||
const { masto, instance: currentInstance } = api();
|
||||
const isCurrentInstance = instance ? instance === currentInstance : true;
|
||||
|
@ -138,10 +139,10 @@ export default function GenericAccounts({
|
|||
return (
|
||||
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
<header>
|
||||
<h2>{heading || 'Accounts'}</h2>
|
||||
<h2>{heading || t`Accounts`}</h2>
|
||||
</header>
|
||||
<main>
|
||||
{post && (
|
||||
|
@ -201,11 +202,13 @@ export default function GenericAccounts({
|
|||
class="plain block"
|
||||
onClick={() => loadAccounts()}
|
||||
>
|
||||
Show more…
|
||||
<Trans>Show more…</Trans>
|
||||
</button>
|
||||
</InView>
|
||||
) : (
|
||||
<p class="ui-state insignificant">The end.</p>
|
||||
<p class="ui-state insignificant">
|
||||
<Trans>The end.</Trans>
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
uiState === 'loading' && (
|
||||
|
@ -220,7 +223,9 @@ export default function GenericAccounts({
|
|||
<Loader abrupt />
|
||||
</p>
|
||||
) : 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">{blankCopy}</p>
|
||||
)}
|
||||
|
|
|
@ -53,9 +53,14 @@ function Icon({
|
|||
return null;
|
||||
}
|
||||
|
||||
let rotate, flip;
|
||||
let rotate,
|
||||
flip,
|
||||
rtl = false;
|
||||
if (Array.isArray(iconBlock)) {
|
||||
[iconBlock, rotate, flip] = iconBlock;
|
||||
} else if (typeof iconBlock === 'object') {
|
||||
({ rotate, flip, rtl } = iconBlock);
|
||||
iconBlock = iconBlock.module;
|
||||
}
|
||||
|
||||
const [iconData, setIconData] = useState(ICONDATA[icon]);
|
||||
|
@ -72,13 +77,14 @@ function Icon({
|
|||
|
||||
return (
|
||||
<span
|
||||
class={`icon ${className}`}
|
||||
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`}
|
||||
title={title || alt}
|
||||
style={{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
...style,
|
||||
}}
|
||||
data-icon={icon}
|
||||
>
|
||||
{iconData && (
|
||||
// <svg
|
||||
|
|
29
src/components/intersection-view.jsx
Normal file
29
src/components/intersection-view.jsx
Normal 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;
|
|
@ -1,5 +1,6 @@
|
|||
import './keyboard-shortcuts-help.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -35,153 +36,157 @@ export default memo(function KeyboardShortcutsHelp() {
|
|||
<Modal onClose={onClose}>
|
||||
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
<header>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<h2>
|
||||
<Trans>Keyboard shortcuts</Trans>
|
||||
</h2>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
{[
|
||||
{
|
||||
action: 'Keyboard shortcuts help',
|
||||
keys: <kbd>?</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Next post',
|
||||
keys: <kbd>j</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Previous post',
|
||||
keys: <kbd>k</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Skip carousel to next post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>j</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Skip carousel to previous post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>k</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Load new posts',
|
||||
keys: <kbd>.</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Open post details',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Enter</kbd> or <kbd>o</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: (
|
||||
<>
|
||||
Expand content warning or
|
||||
<br />
|
||||
toggle expanded/collapsed thread
|
||||
</>
|
||||
),
|
||||
keys: <kbd>x</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Close post or dialogs',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Focus column in multi-column mode',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>1</kbd> to <kbd>9</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Compose new post',
|
||||
keys: <kbd>c</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Compose new post (new window)',
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>c</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Send post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd>⌘</kbd> +{' '}
|
||||
<kbd>Enter</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Search',
|
||||
keys: <kbd>/</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Reply',
|
||||
keys: <kbd>r</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Reply (new window)',
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>r</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Like (favourite)',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>l</kbd> or <kbd>f</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Boost',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>b</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Bookmark',
|
||||
keys: <kbd>d</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Toggle Cloak mode',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
].map(({ action, className, keys }) => (
|
||||
<tr key={action}>
|
||||
<th class={className}>{action}</th>
|
||||
<td>{keys}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tbody>
|
||||
{[
|
||||
{
|
||||
action: t`Keyboard shortcuts help`,
|
||||
keys: <kbd>?</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Next post`,
|
||||
keys: <kbd>j</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Previous post`,
|
||||
keys: <kbd>k</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Skip carousel to next post`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>j</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Skip carousel to previous post`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>k</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Load new posts`,
|
||||
keys: <kbd>.</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Open post details`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Enter</kbd> or <kbd>o</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: (
|
||||
<Trans>
|
||||
Expand content warning or
|
||||
<br />
|
||||
toggle expanded/collapsed thread
|
||||
</Trans>
|
||||
),
|
||||
keys: <kbd>x</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Close post or dialogs`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Focus column in multi-column mode`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>1</kbd> to <kbd>9</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Compose new post`,
|
||||
keys: <kbd>c</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Compose new post (new window)`,
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>c</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Send post`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd>⌘</kbd> +{' '}
|
||||
<kbd>Enter</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Search`,
|
||||
keys: <kbd>/</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Reply`,
|
||||
keys: <kbd>r</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Reply (new window)`,
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>r</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Like (favourite)`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>l</kbd> or <kbd>f</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Boost`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>b</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: t`Bookmark`,
|
||||
keys: <kbd>d</kbd>,
|
||||
},
|
||||
{
|
||||
action: t`Toggle Cloak mode`,
|
||||
keys: (
|
||||
<Trans>
|
||||
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
].map(({ action, className, keys }) => (
|
||||
<tr key={action}>
|
||||
<th class={className}>{action}</th>
|
||||
<td>{keys}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
|
|
115
src/components/lang-selector.jsx
Normal file
115
src/components/lang-selector.jsx
Normal 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} ‎[{completion}%]
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
overflow-x: auto;
|
||||
background-color: var(--bg-faded-color);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
transparent,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
|
@ -20,6 +20,9 @@
|
|||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||
&:dir(rtl) {
|
||||
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,12 +41,16 @@
|
|||
color: var(--text-insignificant-color);
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
transform-origin: top left;
|
||||
transform: rotate(-90deg) translateX(-100%);
|
||||
&:dir(rtl) {
|
||||
transform-origin: top right;
|
||||
transform: rotate(90deg) translateX(100%);
|
||||
}
|
||||
user-select: none;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
var(--to-backward),
|
||||
var(--text-color),
|
||||
var(--link-color)
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
@ -29,11 +30,11 @@ function ListAddEdit({ list, onClose }) {
|
|||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}{' '}
|
||||
<header>
|
||||
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
|
||||
<h2>{editMode ? t`Edit list` : t`New list`}</h2>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
|
@ -88,7 +89,9 @@ function ListAddEdit({ list, onClose }) {
|
|||
console.error(e);
|
||||
setUIState('error');
|
||||
alert(
|
||||
editMode ? 'Unable to edit list.' : 'Unable to create list.',
|
||||
editMode
|
||||
? t`Unable to edit list.`
|
||||
: t`Unable to create list.`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
@ -96,7 +99,7 @@ function ListAddEdit({ list, onClose }) {
|
|||
>
|
||||
<div class="list-form-row">
|
||||
<label for="list-title">
|
||||
Name{' '}
|
||||
<Trans>Name</Trans>{' '}
|
||||
<input
|
||||
ref={nameFieldRef}
|
||||
type="text"
|
||||
|
@ -115,9 +118,15 @@ function ListAddEdit({ list, onClose }) {
|
|||
required
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
<option value="list">Show replies to list members</option>
|
||||
<option value="followed">Show replies to people I follow</option>
|
||||
<option value="none">Don't show replies</option>
|
||||
<option value="list">
|
||||
<Trans>Show replies to list members</Trans>
|
||||
</option>
|
||||
<option value="followed">
|
||||
<Trans>Show replies to people I follow</Trans>
|
||||
</option>
|
||||
<option value="none">
|
||||
<Trans>Don't show replies</Trans>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{supportsExclusive && (
|
||||
|
@ -129,20 +138,20 @@ function ListAddEdit({ list, onClose }) {
|
|||
name="exclusive"
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
Hide posts on this list from Home/Following
|
||||
<Trans>Hide posts on this list from Home/Following</Trans>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div class="list-form-footer">
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
{editMode ? 'Save' : 'Create'}
|
||||
{editMode ? t`Save` : t`Create`}
|
||||
</button>
|
||||
{editMode && (
|
||||
<MenuConfirm
|
||||
disabled={uiState === 'loading'}
|
||||
align="end"
|
||||
menuItemClassName="danger"
|
||||
confirmLabel="Delete this list?"
|
||||
confirmLabel={t`Delete this list?`}
|
||||
onClick={() => {
|
||||
// const yes = confirm('Delete this list?');
|
||||
// if (!yes) return;
|
||||
|
@ -161,7 +170,7 @@ function ListAddEdit({ list, onClose }) {
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
alert('Unable to delete list.');
|
||||
alert(t`Unable to delete list.`);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
|
@ -171,7 +180,7 @@ function ListAddEdit({ list, onClose }) {
|
|||
class="light danger"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete…
|
||||
<Trans>Delete…</Trans>
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
)}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -29,17 +30,19 @@ export default function MediaAltModal({ alt, lang, onClose }) {
|
|||
<div class="sheet" tabindex="-1">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header class="header-grid">
|
||||
<h2>Media description</h2>
|
||||
<h2>
|
||||
<Trans>Media description</Trans>
|
||||
</h2>
|
||||
<div class="header-side">
|
||||
<Menu2
|
||||
align="end"
|
||||
menuButton={
|
||||
<button type="button" class="plain4">
|
||||
<Icon icon="more" alt="More" size="xl" />
|
||||
<Icon icon="more" alt={t`More`} size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
|
@ -50,7 +53,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
<span>
|
||||
<Trans>Translate</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
{supportsTTS && (
|
||||
<MenuItem
|
||||
|
@ -59,7 +64,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
<span>
|
||||
<Trans>Speak</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu2>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import {
|
||||
|
@ -10,14 +11,15 @@ import {
|
|||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
||||
import isRTL from '../utils/is-rtl';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Media from './media';
|
||||
import Menu2 from './menu2';
|
||||
import MenuLink from './menu-link';
|
||||
import Menu2 from './menu2';
|
||||
|
||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
||||
|
||||
|
@ -53,11 +55,11 @@ function MediaModal({
|
|||
const scrollLeft = index * carouselRef.current.clientWidth;
|
||||
const differentStatusID = prevStatusID.current !== statusID;
|
||||
if (differentStatusID) prevStatusID.current = statusID;
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: scrollLeft,
|
||||
left: scrollLeft * (isRTL() ? -1 : 1),
|
||||
behavior: differentStatusID ? 'auto' : 'smooth',
|
||||
});
|
||||
carouselRef.current.focus();
|
||||
}, [index, statusID]);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
|
@ -91,7 +93,7 @@ function MediaModal({
|
|||
useEffect(() => {
|
||||
let handleScroll = () => {
|
||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||
const index = Math.round(scrollLeft / clientWidth);
|
||||
const index = Math.round(Math.abs(scrollLeft) / clientWidth);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
|
@ -178,7 +180,7 @@ function MediaModal({
|
|||
? {
|
||||
backgroundAttachment: 'local',
|
||||
backgroundImage: `linear-gradient(
|
||||
to right, ${mediaAccentGradient})`,
|
||||
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
@ -242,7 +244,7 @@ function MediaModal({
|
|||
class="carousel-button"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
</span>
|
||||
{mediaAttachments?.length > 1 ? (
|
||||
|
@ -256,14 +258,13 @@ function MediaModal({
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * i,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
const left =
|
||||
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1);
|
||||
carouselRef.current.scrollTo({ left, behavior: 'smooth' });
|
||||
carouselRef.current.focus();
|
||||
}}
|
||||
>
|
||||
<Icon icon="round" size="s" />
|
||||
<Icon icon="round" size="s" alt="⸱" />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
|
@ -279,7 +280,7 @@ function MediaModal({
|
|||
menuClassName="glass-menu"
|
||||
menuButton={
|
||||
<button type="button" class="carousel-button">
|
||||
<Icon icon="more" alt="More" />
|
||||
<Icon icon="more" alt={t`More`} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
|
@ -290,10 +291,12 @@ function MediaModal({
|
|||
}
|
||||
class="carousel-button"
|
||||
target="_blank"
|
||||
title="Open original media in new window"
|
||||
title={t`Open original media in new window`}
|
||||
>
|
||||
<Icon icon="popout" />
|
||||
<span>Open original media</span>
|
||||
<span>
|
||||
<Trans>Open original media</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
{import.meta.env.DEV && // Only dev for now
|
||||
!!states.settings.mediaAltGenerator &&
|
||||
|
@ -308,7 +311,7 @@ function MediaModal({
|
|||
onClick={() => {
|
||||
setUIState('loading');
|
||||
toastRef.current = showToast({
|
||||
text: 'Attempting to describe image. Please wait...',
|
||||
text: t`Attempting to describe image. Please wait…`,
|
||||
duration: -1,
|
||||
});
|
||||
(async function () {
|
||||
|
@ -323,7 +326,7 @@ function MediaModal({
|
|||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Failed to describe image');
|
||||
showToast(t`Failed to describe image`);
|
||||
} finally {
|
||||
setUIState('default');
|
||||
toastRef.current?.hideToast?.();
|
||||
|
@ -332,7 +335,9 @@ function MediaModal({
|
|||
}}
|
||||
>
|
||||
<Icon icon="sparkles2" />
|
||||
<span>Describe image…</span>
|
||||
<span>
|
||||
<Trans>Describe image…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
@ -353,7 +358,10 @@ function MediaModal({
|
|||
// }
|
||||
// }}
|
||||
>
|
||||
<span class="button-label">View post </span>»
|
||||
<span class="button-label">
|
||||
<Trans>View post</Trans>{' '}
|
||||
</span>
|
||||
»
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -368,12 +376,15 @@ function MediaModal({
|
|||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||
left:
|
||||
carouselRef.current.clientWidth *
|
||||
(currentIndex - 1) *
|
||||
(isRTL() ? -1 : 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" />
|
||||
<Icon icon="arrow-left" alt={t`Previous`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -384,12 +395,15 @@ function MediaModal({
|
|||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||
left:
|
||||
carouselRef.current.clientWidth *
|
||||
(currentIndex + 1) *
|
||||
(isRTL() ? -1 : 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-right" />
|
||||
<Icon icon="arrow-right" alt={t`Next`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bg-blur-color);
|
||||
margin: 8px;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './media-post.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useContext, useMemo } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -108,7 +109,7 @@ function MediaPost({
|
|||
const readingExpandMedia = useMemo(() => {
|
||||
// default | show_all | hide_all
|
||||
const prefs = store.account.get('preferences') || {};
|
||||
return prefs['reading:expand:media'] || 'default';
|
||||
return prefs['reading:expand:media']?.toLowerCase() || 'default';
|
||||
}, []);
|
||||
const showSpoilerMedia = readingExpandMedia === 'show_all';
|
||||
|
||||
|
@ -123,11 +124,13 @@ function MediaPost({
|
|||
onMouseEnter={debugHover}
|
||||
key={mediaKey}
|
||||
data-spoiler-text={
|
||||
spoilerText || (sensitive ? 'Sensitive media' : undefined)
|
||||
spoilerText || (sensitive ? t`Sensitive media` : undefined)
|
||||
}
|
||||
data-filtered-text={
|
||||
filterInfo
|
||||
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
|
||||
? filterTitleStr
|
||||
? t`Filtered: ${filterTitleStr}`
|
||||
: t`Filtered`
|
||||
: undefined
|
||||
}
|
||||
class={`
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
|
@ -45,7 +47,7 @@ const AltBadge = (props) => {
|
|||
lang,
|
||||
};
|
||||
}}
|
||||
title="Media description"
|
||||
title={t`Media description`}
|
||||
>
|
||||
{dataAltLabel}
|
||||
{!!index && <sup>{index}</sup>}
|
||||
|
@ -614,7 +616,7 @@ function Media({
|
|||
/>
|
||||
)}
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xl" />
|
||||
<Icon icon="play" size="xl" alt="▶" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
@ -658,7 +660,7 @@ function Media({
|
|||
{!showOriginal && (
|
||||
<>
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xl" />
|
||||
<Icon icon="play" size="xl" alt="▶" />
|
||||
</div>
|
||||
{!showInlineDesc && (
|
||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
||||
|
@ -676,4 +678,14 @@ function getURLObj(url) {
|
|||
return URL.parse(url, location.origin);
|
||||
}
|
||||
|
||||
export default Media;
|
||||
export default memo(Media, (oldProps, newProps) => {
|
||||
const oldMedia = oldProps.media || {};
|
||||
const newMedia = newProps.media || {};
|
||||
|
||||
return (
|
||||
oldMedia?.id === newMedia?.id &&
|
||||
oldMedia.url === newMedia.url &&
|
||||
oldProps.to === newProps.to &&
|
||||
oldProps.class === newProps.class
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,21 +1,33 @@
|
|||
import { Menu } from '@szhsin/react-menu';
|
||||
import { useWindowSize } from '@uidotdev/usehooks';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import isRTL from '../utils/is-rtl';
|
||||
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.
|
||||
function Menu2(props) {
|
||||
const { containerProps, instanceRef: _instanceRef } = props;
|
||||
const { containerProps, instanceRef: _instanceRef, align } = props;
|
||||
const size = useWindowSize();
|
||||
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 (
|
||||
<Menu
|
||||
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||
repositionFlag={`${size.width}x${size.height}`}
|
||||
unmountOnClose
|
||||
{...props}
|
||||
align={rtlAlign}
|
||||
instanceRef={instanceRef}
|
||||
containerProps={{
|
||||
onClick: (e) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#modal-container > div {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
|
@ -26,21 +26,30 @@
|
|||
user-select: none;
|
||||
overflow: hidden;
|
||||
transform: scale(0);
|
||||
--right: max(
|
||||
--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-right: calc(
|
||||
100% - var(--compose-button-dimension-half) - var(--right)
|
||||
--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-right) var(--origin-bottom);
|
||||
transform-origin: var(--origin-end) var(--origin-bottom);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { subscribe, useSnapshot } from 'valtio';
|
||||
|
@ -68,9 +69,9 @@ export default function Modals() {
|
|||
states.reloadStatusPage++;
|
||||
showToast({
|
||||
text: {
|
||||
post: 'Post published. Check it out.',
|
||||
reply: 'Reply posted. Check it out.',
|
||||
edit: 'Post updated. Check it out.',
|
||||
post: t`Post published. Check it out.`,
|
||||
reply: t`Reply posted. Check it out.`,
|
||||
edit: t`Post updated. Check it out.`,
|
||||
}[type || 'post'],
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
import './name-text.css';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import mem from '../utils/mem';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
|
||||
const nameCollator = new Intl.Collator('en', {
|
||||
sensitivity: 'base',
|
||||
const nameCollator = mem((locale) => {
|
||||
const options = {
|
||||
sensitivity: 'base',
|
||||
};
|
||||
try {
|
||||
return new Intl.Collator(locale || undefined, options);
|
||||
} catch (e) {
|
||||
return new Intl.Collator(undefined, options);
|
||||
}
|
||||
});
|
||||
|
||||
function NameText({
|
||||
|
@ -21,6 +30,7 @@ function NameText({
|
|||
external,
|
||||
onClick,
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const {
|
||||
acct,
|
||||
avatar,
|
||||
|
@ -51,7 +61,10 @@ function NameText({
|
|||
(trimmedUsername === trimmedDisplayName ||
|
||||
trimmedUsername === shortenedDisplayName ||
|
||||
trimmedUsername === shortenedAlphaNumericDisplayName ||
|
||||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) ||
|
||||
nameCollator(i18n.locale).compare(
|
||||
trimmedUsername,
|
||||
shortenedDisplayName,
|
||||
) === 0)) ||
|
||||
shortenedAlphaNumericDisplayName === acct.toLowerCase();
|
||||
|
||||
return (
|
||||
|
@ -88,13 +101,13 @@ function NameText({
|
|||
)}
|
||||
{displayName && !short ? (
|
||||
<>
|
||||
<b>
|
||||
<b dir="auto">
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
{!showAcct && !hideUsername && (
|
||||
<>
|
||||
{' '}
|
||||
<i>@{username}</i>
|
||||
<i class="bidi-isolate">@{username}</i>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -106,7 +119,7 @@ function NameText({
|
|||
{showAcct && (
|
||||
<>
|
||||
<br />
|
||||
<i>
|
||||
<i class="bidi-isolate">
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
{!!acct2 && <span class="ib">{acct2}</span>}
|
||||
|
|
|
@ -1,28 +1,39 @@
|
|||
.nav-menu section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
margin-bottom: -8px;
|
||||
padding-bottom: 8px;
|
||||
.nav-menu {
|
||||
overflow: hidden;
|
||||
|
||||
section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
margin-bottom: -4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.szh-menu__item:before {
|
||||
z-index: 0;
|
||||
}
|
||||
.szh-menu__item > * {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 23em) {
|
||||
.nav-menu {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 50% 50%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'left right';
|
||||
padding: 0;
|
||||
width: 22em;
|
||||
/* min-width: 22em; */
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
.nav-menu .top-menu {
|
||||
grid-area: top;
|
||||
padding-top: 8px;
|
||||
margin-bottom: -8px;
|
||||
padding-top: 4px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
.nav-menu section {
|
||||
padding: 8px 0;
|
||||
padding: 4px 0;
|
||||
/* width: 50%; */
|
||||
}
|
||||
@keyframes phanpying {
|
||||
|
@ -35,11 +46,15 @@
|
|||
}
|
||||
.nav-menu section:last-child {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
var(--divider-color) 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);
|
||||
background-repeat: no-repeat;
|
||||
/* background-size: auto, auto, 200%; */
|
||||
|
@ -49,8 +64,8 @@
|
|||
position: sticky;
|
||||
top: 0;
|
||||
animation: phanpying 0.2s ease-in-out both;
|
||||
border-top-right-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
border-start-end-radius: inherit;
|
||||
border-end-end-radius: inherit;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './nav-menu.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
@ -122,7 +123,7 @@ function NavMenu(props) {
|
|||
squircle={currentAccount?.info?.bot}
|
||||
/>
|
||||
)}
|
||||
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />
|
||||
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} alt={t`Menu`} />
|
||||
</button>
|
||||
<ControlledMenu
|
||||
menuClassName="nav-menu"
|
||||
|
@ -158,7 +159,7 @@ function NavMenu(props) {
|
|||
<div class="top-menu">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const yes = confirm('Reload page now to update?');
|
||||
const yes = confirm(t`Reload page now to update?`);
|
||||
if (yes) {
|
||||
(async () => {
|
||||
try {
|
||||
|
@ -169,35 +170,51 @@ function NavMenu(props) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
|
||||
<span>New update available…</span>
|
||||
<span>
|
||||
<Trans>New update available…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<MenuLink to="/">
|
||||
<Icon icon="home" size="l" /> <span>Home</span>
|
||||
<Icon icon="home" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Home</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
{authenticated ? (
|
||||
<>
|
||||
{showFollowing && (
|
||||
<MenuLink to="/following">
|
||||
<Icon icon="following" size="l" /> <span>Following</span>
|
||||
<Icon icon="following" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Following</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/catchup">
|
||||
<Icon icon="history2" size="l" />
|
||||
<span>Catch-up</span>
|
||||
<span>
|
||||
<Trans>Catch-up</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
{supports('@mastodon/mentions') && (
|
||||
<MenuLink to="/mentions">
|
||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||
<Icon icon="at" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Mentions</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/notifications">
|
||||
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
||||
<Icon icon="notification" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Notifications</Trans>
|
||||
</span>
|
||||
{snapStates.notificationsShowNew && (
|
||||
<sup title="New" style={{ opacity: 0.5 }}>
|
||||
<sup title={t`New`} style={{ opacity: 0.5 }}>
|
||||
{' '}
|
||||
•
|
||||
</sup>
|
||||
|
@ -206,7 +223,10 @@ function NavMenu(props) {
|
|||
<MenuDivider />
|
||||
{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>
|
||||
)}
|
||||
{lists?.length > 0 ? (
|
||||
|
@ -217,13 +237,17 @@ function NavMenu(props) {
|
|||
label={
|
||||
<>
|
||||
<Icon icon="list" size="l" />
|
||||
<span class="menu-grow">Lists</span>
|
||||
<span class="menu-grow">
|
||||
<Trans>Lists</Trans>
|
||||
</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
<span>
|
||||
<Trans>All Lists</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
{lists?.length > 0 && (
|
||||
<>
|
||||
|
@ -240,12 +264,17 @@ function NavMenu(props) {
|
|||
supportsLists && (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
<span>Lists</span>
|
||||
<span>
|
||||
<Trans>Lists</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
)
|
||||
)}
|
||||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
<Icon icon="bookmark" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Bookmarks</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
|
@ -254,49 +283,63 @@ function NavMenu(props) {
|
|||
label={
|
||||
<>
|
||||
<Icon icon="more" size="l" />
|
||||
<span class="menu-grow">More…</span>
|
||||
<span class="menu-grow">
|
||||
<Trans>More…</Trans>
|
||||
</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/f">
|
||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||
<Icon icon="heart" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Likes</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/fh">
|
||||
<Icon icon="hashtag" size="l" />{' '}
|
||||
<span>Followed Hashtags</span>
|
||||
<span>
|
||||
<Trans>Followed Hashtags</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
{supports('@mastodon/filters') && (
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="filters" size="l" />
|
||||
Filters
|
||||
<Icon icon="filters" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Filters</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'mute',
|
||||
heading: 'Muted users',
|
||||
heading: t`Muted users`,
|
||||
fetchAccounts: fetchMutes,
|
||||
excludeRelationshipAttrs: ['muting'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="mute" size="l" /> Muted users…
|
||||
<Icon icon="mute" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Muted users…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'block',
|
||||
heading: 'Blocked users',
|
||||
heading: t`Blocked users`,
|
||||
fetchAccounts: fetchBlocks,
|
||||
excludeRelationshipAttrs: ['blocking'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
<Icon icon="block" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Blocked users…</Trans>
|
||||
</span>
|
||||
</MenuItem>{' '}
|
||||
</SubMenu2>
|
||||
<MenuDivider />
|
||||
|
@ -305,14 +348,20 @@ function NavMenu(props) {
|
|||
states.showAccounts = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||
<Icon icon="group" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Accounts…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/login">
|
||||
<Icon icon="user" size="l" /> <span>Log in</span>
|
||||
<Icon icon="user" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Log in</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
</>
|
||||
)}
|
||||
|
@ -320,16 +369,28 @@ function NavMenu(props) {
|
|||
<section>
|
||||
<MenuDivider />
|
||||
<MenuLink to={`/search`}>
|
||||
<Icon icon="search" size="l" /> <span>Search</span>
|
||||
<Icon icon="search" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Search</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/trending`}>
|
||||
<Icon icon="chart" size="l" /> <span>Trending</span>
|
||||
<Icon icon="chart" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Trending</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<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 to={`/${instance}/p`}>
|
||||
<Icon icon="earth" size="l" /> <span>Federated</span>
|
||||
<Icon icon="earth" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Federated</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
{authenticated ? (
|
||||
<>
|
||||
|
@ -340,7 +401,9 @@ function NavMenu(props) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="keyboard" size="l" />{' '}
|
||||
<span>Keyboard shortcuts</span>
|
||||
<span>
|
||||
<Trans>Keyboard shortcuts</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
@ -348,14 +411,19 @@ function NavMenu(props) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="shortcut" size="l" />{' '}
|
||||
<span>Shortcuts / Columns…</span>
|
||||
<span>
|
||||
<Trans>Shortcuts / Columns…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="gear" size="l" /> <span>Settings…</span>
|
||||
<Icon icon="gear" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Settings…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
|
@ -366,7 +434,10 @@ function NavMenu(props) {
|
|||
states.showSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="gear" size="l" /> <span>Settings…</span>
|
||||
<Icon icon="gear" size="l" />{' '}
|
||||
<span>
|
||||
<Trans>Settings…</Trans>
|
||||
</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -152,14 +153,18 @@ export default memo(function NotificationService() {
|
|||
>
|
||||
<div class="sheet" tabIndex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
<header>
|
||||
<b>Notification</b>
|
||||
<b>
|
||||
<Trans>Notification</Trans>
|
||||
</b>
|
||||
</header>
|
||||
<main>
|
||||
{!sameInstance && (
|
||||
<p>This notification is from your other account.</p>
|
||||
<p>
|
||||
<Trans>This notification is from your other account.</Trans>
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
class="notification-peek"
|
||||
|
@ -186,7 +191,10 @@ export default memo(function NotificationService() {
|
|||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { msg, Plural, Select, t, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import useTruncated from '../utils/useTruncated';
|
||||
|
||||
|
@ -13,7 +14,6 @@ import FollowRequestButtons from './follow-request-buttons';
|
|||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import NameText from './name-text';
|
||||
import RelativeTime from './relative-time';
|
||||
import Status from './status';
|
||||
|
||||
const NOTIFICATION_ICONS = {
|
||||
|
@ -50,7 +50,7 @@ severed_relationships = Severed relationships
|
|||
moderation_warning = Moderation warning
|
||||
*/
|
||||
|
||||
function emojiText(emoji, emoji_url) {
|
||||
function emojiText({ account, emoji, emoji_url }) {
|
||||
let url;
|
||||
let staticUrl;
|
||||
if (typeof emoji_url === 'string') {
|
||||
|
@ -59,42 +59,204 @@ function emojiText(emoji, emoji_url) {
|
|||
url = emoji_url?.url;
|
||||
staticUrl = emoji_url?.staticUrl;
|
||||
}
|
||||
return url ? (
|
||||
<>
|
||||
reacted to your post with{' '}
|
||||
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
||||
</>
|
||||
const emojiObject = url ? (
|
||||
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
||||
) : (
|
||||
`reacted to your post with ${emoji}.`
|
||||
emoji
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
{account} reacted to your post with {emojiObject}
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
const contentText = {
|
||||
mention: 'mentioned you in their post.',
|
||||
status: 'published a post.',
|
||||
reblog: 'boosted your post.',
|
||||
'reblog+account': (count) => `boosted ${count} of your posts.`,
|
||||
reblog_reply: 'boosted your reply.',
|
||||
follow: 'followed you.',
|
||||
follow_request: 'requested to follow you.',
|
||||
favourite: 'liked your post.',
|
||||
'favourite+account': (count) => `liked ${count} of your posts.`,
|
||||
favourite_reply: 'liked your reply.',
|
||||
poll: 'A poll you have voted in or created has ended.',
|
||||
'poll-self': 'A poll you have created has ended.',
|
||||
'poll-voted': 'A poll you have voted in has ended.',
|
||||
update: 'A post you interacted with has been edited.',
|
||||
'favourite+reblog': 'boosted & liked your post.',
|
||||
'favourite+reblog+account': (count) =>
|
||||
`boosted & liked ${count} of your posts.`,
|
||||
'favourite+reblog_reply': 'boosted & liked your reply.',
|
||||
'admin.sign_up': 'signed up.',
|
||||
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
||||
severed_relationships: (name) => (
|
||||
<>
|
||||
Lost connections with <i>{name}</i>.
|
||||
</>
|
||||
status: ({ account }) => <Trans>{account} published a post.</Trans>,
|
||||
reblog: ({
|
||||
count,
|
||||
account,
|
||||
postsCount,
|
||||
postType,
|
||||
components: { Subject },
|
||||
}) => (
|
||||
<Plural
|
||||
value={count}
|
||||
_1={
|
||||
<Plural
|
||||
value={postsCount}
|
||||
_1={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={<Trans>{account} boosted your reply.</Trans>}
|
||||
other={<Trans>{account} boosted your post.</Trans>}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
{account} boosted {postsCount} of your posts.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
boosted your reply.
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
boosted your post.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
follow: ({ account, count, components: { Subject } }) => (
|
||||
<Plural
|
||||
value={count}
|
||||
_1={<Trans>{account} followed you.</Trans>}
|
||||
other={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
followed you.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
),
|
||||
follow_request: ({ account }) => (
|
||||
<Trans>{account} requested to follow you.</Trans>
|
||||
),
|
||||
favourite: ({
|
||||
account,
|
||||
count,
|
||||
postsCount,
|
||||
postType,
|
||||
components: { Subject },
|
||||
}) => (
|
||||
<Plural
|
||||
value={count}
|
||||
_1={
|
||||
<Plural
|
||||
value={postsCount}
|
||||
_1={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={<Trans>{account} liked your reply.</Trans>}
|
||||
other={<Trans>{account} liked your post.</Trans>}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
{account} liked {postsCount} of your posts.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
liked your reply.
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
liked your post.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
poll: () => t`A poll you have voted in or created has ended.`,
|
||||
'poll-self': () => t`A poll you have created has ended.`,
|
||||
'poll-voted': () => t`A poll you have voted in has ended.`,
|
||||
update: () => t`A post you interacted with has been edited.`,
|
||||
'favourite+reblog': ({
|
||||
count,
|
||||
account,
|
||||
postsCount,
|
||||
postType,
|
||||
components: { Subject },
|
||||
}) => (
|
||||
<Plural
|
||||
value={count}
|
||||
_1={
|
||||
<Plural
|
||||
value={postsCount}
|
||||
_1={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={<Trans>{account} boosted & liked your reply.</Trans>}
|
||||
other={<Trans>{account} boosted & liked your post.</Trans>}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
{account} boosted & liked {postsCount} of your posts.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
other={
|
||||
<Select
|
||||
value={postType}
|
||||
_reply={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
boosted & liked your reply.
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
<Subject clickable={count > 1}>
|
||||
<span title={count}>{shortenNumber(count)}</span> people
|
||||
</Subject>{' '}
|
||||
boosted & liked your post.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
'admin.sign_up': ({ account }) => <Trans>{account} signed up.</Trans>,
|
||||
'admin.report': ({ account, targetAccount }) => (
|
||||
<Trans>
|
||||
{account} reported {targetAccount}
|
||||
</Trans>
|
||||
),
|
||||
severed_relationships: ({ name }) => (
|
||||
<Trans>
|
||||
Lost connections with <i>{name}</i>.
|
||||
</Trans>
|
||||
),
|
||||
moderation_warning: () => (
|
||||
<b>
|
||||
<Trans>Moderation warning</Trans>
|
||||
</b>
|
||||
),
|
||||
moderation_warning: <b>Moderation warning</b>,
|
||||
emoji_reaction: emojiText,
|
||||
'pleroma:emoji_reaction': emojiText,
|
||||
};
|
||||
|
@ -102,34 +264,33 @@ const contentText = {
|
|||
// 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: 'Your account has received a moderation warning.',
|
||||
disable: 'Your account has been disabled.',
|
||||
mark_statuses_as_sensitive:
|
||||
'Some of your posts have been marked as sensitive.',
|
||||
delete_statuses: 'Some of your posts have been deleted.',
|
||||
sensitive: 'Your posts will be marked as sensitive from now on.',
|
||||
silence: 'Your account has been limited.',
|
||||
suspend: 'Your account has been suspended.',
|
||||
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;
|
||||
|
@ -140,6 +301,7 @@ function Notification({
|
|||
isStatic,
|
||||
disableContextMenu,
|
||||
}) {
|
||||
const { _ } = useLingui();
|
||||
const {
|
||||
id,
|
||||
status,
|
||||
|
@ -147,11 +309,21 @@ function Notification({
|
|||
report,
|
||||
event,
|
||||
moderation_warning,
|
||||
// Client-side grouped notification
|
||||
_ids,
|
||||
_accounts,
|
||||
_statuses,
|
||||
// Server-side grouped notification
|
||||
sampleAccounts,
|
||||
notificationsCount,
|
||||
} = 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
|
||||
const actualStatus = status?.reblog || status;
|
||||
const actualStatusID = actualStatus?.id;
|
||||
|
@ -167,12 +339,14 @@ function Notification({
|
|||
let favsCount = 0;
|
||||
let reblogsCount = 0;
|
||||
if (type === 'favourite+reblog') {
|
||||
for (const account of _accounts) {
|
||||
if (account._types?.includes('favourite')) {
|
||||
favsCount++;
|
||||
}
|
||||
if (account._types?.includes('reblog')) {
|
||||
reblogsCount++;
|
||||
if (_accounts) {
|
||||
for (const account of _accounts) {
|
||||
if (account._types?.includes('favourite')) {
|
||||
favsCount++;
|
||||
}
|
||||
if (account._types?.includes('reblog')) {
|
||||
reblogsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reblogsCount && favsCount) type = 'favourite';
|
||||
|
@ -182,37 +356,37 @@ function Notification({
|
|||
let text;
|
||||
if (type === '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]) {
|
||||
text = contentText[type];
|
||||
} else {
|
||||
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
|
||||
// This surfaces the error to the user, hoping that users will report it
|
||||
text = `[Unknown notification type: ${type}]`;
|
||||
text = t`[Unknown notification type: ${type}]`;
|
||||
}
|
||||
|
||||
const Subject = ({ clickable, ...props }) =>
|
||||
clickable ? (
|
||||
<b tabIndex="0" onClick={handleOpenGenericAccounts} {...props} />
|
||||
) : (
|
||||
<b {...props} />
|
||||
);
|
||||
|
||||
if (typeof text === 'function') {
|
||||
const count = _statuses?.length || _accounts?.length;
|
||||
const count =
|
||||
_accounts?.length || sampleAccounts?.length || (account ? 1 : 0);
|
||||
const postsCount = _statuses?.length || 0;
|
||||
if (type === 'admin.report') {
|
||||
const targetAccount = report?.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(targetName);
|
||||
text = text({ name: targetName });
|
||||
}
|
||||
} else if (
|
||||
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
|
||||
|
@ -225,27 +399,34 @@ function Notification({
|
|||
emoji?.shortcode ===
|
||||
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
|
||||
); // Emoji object instead of string
|
||||
text = text(notification.emoji, emojiURL);
|
||||
} else if (count) {
|
||||
text = text(count);
|
||||
text = text({ emoji: notification.emoji, emojiURL });
|
||||
} else {
|
||||
text = text({
|
||||
account: account ? (
|
||||
<NameText account={account} showAvatar />
|
||||
) : (
|
||||
sampleAccounts?.[0] && (
|
||||
<NameText account={sampleAccounts[0]} showAvatar />
|
||||
)
|
||||
),
|
||||
count,
|
||||
postsCount,
|
||||
postType: isReplyToOthers ? 'reply' : 'post',
|
||||
components: { Subject },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'mention' && !status) {
|
||||
// Could be deleted
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedCreatedAt =
|
||||
notification.createdAt && new Date(notification.createdAt).toLocaleString();
|
||||
|
||||
const genericAccountsHeading =
|
||||
{
|
||||
'favourite+reblog': 'Boosted/Liked by…',
|
||||
favourite: 'Liked by…',
|
||||
reblog: 'Boosted by…',
|
||||
follow: 'Followed by…',
|
||||
}[type] || 'Accounts';
|
||||
'favourite+reblog': t`Boosted/Liked by…`,
|
||||
favourite: t`Liked by…`,
|
||||
reblog: t`Boosted by…`,
|
||||
follow: t`Followed by…`,
|
||||
}[type] || t`Accounts`;
|
||||
const handleOpenGenericAccounts = () => {
|
||||
states.showGenericAccounts = {
|
||||
heading: genericAccountsHeading,
|
||||
|
@ -261,7 +442,7 @@ function Notification({
|
|||
return (
|
||||
<div
|
||||
class={`notification notification-${type}`}
|
||||
data-notification-id={id}
|
||||
data-notification-id={_ids || id}
|
||||
tabIndex="0"
|
||||
>
|
||||
<div
|
||||
|
@ -284,39 +465,7 @@ function Notification({
|
|||
<div class="notification-content">
|
||||
{type !== 'mention' && (
|
||||
<>
|
||||
<p>
|
||||
{!/poll|update/i.test(type) && (
|
||||
<>
|
||||
{_accounts?.length > 1 ? (
|
||||
<>
|
||||
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
|
||||
<span title={_accounts.length}>
|
||||
{shortenNumber(_accounts.length)}
|
||||
</span>{' '}
|
||||
people
|
||||
</b>{' '}
|
||||
</>
|
||||
) : (
|
||||
account && (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{text}
|
||||
{type === 'mention' && (
|
||||
<span class="insignificant">
|
||||
{' '}
|
||||
•{' '}
|
||||
<RelativeTime
|
||||
datetime={notification.createdAt}
|
||||
format="micro"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>{text}</p>
|
||||
{type === 'follow_request' && (
|
||||
<FollowRequestButtons accountID={account.id} />
|
||||
)}
|
||||
|
@ -332,23 +481,26 @@ function Notification({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
<Trans>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
</Trans>
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
{type === 'moderation_warning' && !!moderation_warning && (
|
||||
<div>
|
||||
{MODERATION_WARNING_TEXT[moderation_warning.action]}
|
||||
{_(MODERATION_WARNING_TEXT[moderation_warning.action]())}
|
||||
<br />
|
||||
<a
|
||||
href={`/disputes/strikes/${moderation_warning.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
<Trans>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
</Trans>
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@ -405,6 +557,54 @@ function Notification({
|
|||
</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>
|
||||
)}
|
||||
{_statuses?.length > 1 && (
|
||||
<ul class="notification-group-statuses">
|
||||
{_statuses.map((status) => (
|
||||
|
@ -477,7 +677,7 @@ function Notification({
|
|||
|
||||
function TruncatedLink(props) {
|
||||
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) => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Plural, plural, t, Trans } from '@lingui/macro';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
|
@ -75,11 +76,15 @@ export default function Poll({
|
|||
<div class="poll-options">
|
||||
{options.map((option, i) => {
|
||||
const { title, votesCount: optionVotesCount } = option;
|
||||
const percentage = pollVotesCount
|
||||
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
|
||||
roundPrecision,
|
||||
)
|
||||
: 0; // check if current poll choice is the leading one
|
||||
const ratio = pollVotesCount
|
||||
? optionVotesCount / pollVotesCount
|
||||
: 0;
|
||||
const percentage = ratio
|
||||
? ratio.toLocaleString(i18n.locale || undefined, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: roundPrecision,
|
||||
})
|
||||
: '0%';
|
||||
|
||||
const isLeading =
|
||||
optionVotesCount > 0 &&
|
||||
|
@ -92,7 +97,7 @@ export default function Poll({
|
|||
isLeading ? 'poll-option-leading' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--percentage': `${percentage}%`,
|
||||
'--percentage': `${ratio * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div class="poll-option-title">
|
||||
|
@ -102,17 +107,18 @@ export default function Poll({
|
|||
{voted && ownVotes.includes(i) && (
|
||||
<>
|
||||
{' '}
|
||||
<Icon icon="check-circle" />
|
||||
<Icon icon="check-circle" alt={t`Voted`} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="poll-option-votes"
|
||||
title={`${optionVotesCount} vote${
|
||||
optionVotesCount === 1 ? '' : 's'
|
||||
}`}
|
||||
title={plural(optionVotesCount, {
|
||||
one: `# vote`,
|
||||
other: `# votes`,
|
||||
})}
|
||||
>
|
||||
{percentage}%
|
||||
{percentage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -127,7 +133,7 @@ export default function Poll({
|
|||
setShowResults(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" size="s" /> Hide results
|
||||
<Icon icon="arrow-left" size="s" /> <Trans>Hide results</Trans>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
@ -176,7 +182,7 @@ export default function Poll({
|
|||
type="submit"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Vote
|
||||
<Trans>Vote</Trans>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
@ -187,9 +193,6 @@ export default function Poll({
|
|||
type="button"
|
||||
class="plain small"
|
||||
disabled={uiState === 'loading'}
|
||||
style={{
|
||||
marginLeft: -8,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setUIState('loading');
|
||||
|
@ -199,9 +202,9 @@ export default function Poll({
|
|||
setUIState('default');
|
||||
})();
|
||||
}}
|
||||
title="Refresh"
|
||||
title={t`Refresh`}
|
||||
>
|
||||
<Icon icon="refresh" alt="Refresh" />
|
||||
<Icon icon="refresh" alt={t`Refresh`} />
|
||||
</button>
|
||||
)}
|
||||
{!voted && !expired && !readOnly && optionsHaveVoteCounts && (
|
||||
|
@ -213,30 +216,66 @@ export default function Poll({
|
|||
e.preventDefault();
|
||||
setShowResults(!showResults);
|
||||
}}
|
||||
title={showResults ? 'Hide results' : 'Show results'}
|
||||
title={showResults ? t`Hide results` : t`Show results`}
|
||||
>
|
||||
<Icon
|
||||
icon={showResults ? 'eye-open' : 'eye-close'}
|
||||
alt={showResults ? 'Hide results' : 'Show results'}
|
||||
alt={showResults ? t`Hide results` : t`Show results`}
|
||||
/>{' '}
|
||||
</button>
|
||||
)}
|
||||
{!expired && !readOnly && ' '}
|
||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||
{votesCount === 1 ? '' : 's'}
|
||||
<Plural
|
||||
value={votesCount}
|
||||
one={
|
||||
<Trans>
|
||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> votes
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
{!!votersCount && votersCount !== votesCount && (
|
||||
<>
|
||||
{' '}
|
||||
• <span title={votersCount}>
|
||||
{shortenNumber(votersCount)}
|
||||
</span>{' '}
|
||||
voter
|
||||
{votersCount === 1 ? '' : 's'}
|
||||
•{' '}
|
||||
<Plural
|
||||
value={votersCount}
|
||||
one={
|
||||
<Trans>
|
||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||
voter
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||
voters
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}{' '}
|
||||
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
||||
</p>{' '}
|
||||
•{' '}
|
||||
{expired ? (
|
||||
!!expiresAtDate ? (
|
||||
<Trans>
|
||||
Ended <RelativeTime datetime={expiresAtDate} />
|
||||
</Trans>
|
||||
) : (
|
||||
t`Ended`
|
||||
)
|
||||
) : !!expiresAtDate ? (
|
||||
<Trans>
|
||||
Ending <RelativeTime datetime={expiresAtDate} />
|
||||
</Trans>
|
||||
) : (
|
||||
t`Ending`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,44 +1,108 @@
|
|||
// Twitter-style relative time component
|
||||
// Seconds = 1s
|
||||
// Minutes = 1m
|
||||
// Hours = 1h
|
||||
// Days = 1d
|
||||
// After 7 days, use DD/MM/YYYY or MM/DD/YYYY
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsTwitter from 'dayjs-twitter';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||
|
||||
dayjs.extend(dayjsTwitter);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(relativeTime);
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import mem from '../utils/mem';
|
||||
|
||||
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 = 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 }) {
|
||||
if (!datetime) return null;
|
||||
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||
const date = useMemo(() => new Date(datetime), [datetime]);
|
||||
const [dateStr, dt, title] = useMemo(() => {
|
||||
if (!date.isValid()) return ['' + datetime, '', ''];
|
||||
if (!isValidDate(date)) return ['' + datetime, '', ''];
|
||||
let str;
|
||||
if (format === 'micro') {
|
||||
// If date <= 1 day ago or day is within this year
|
||||
const now = dayjs();
|
||||
const dayDiff = now.diff(date, 'day');
|
||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||
str = date.twitter();
|
||||
const now = new Date();
|
||||
const dayDiff = (now.getTime() - date.getTime()) / 1000 / day;
|
||||
if (dayDiff <= 1) {
|
||||
str = twitterFromNow(date);
|
||||
} else {
|
||||
str = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!str) str = date.fromNow();
|
||||
return [str, date.toISOString(), date.format('LLLL')];
|
||||
if (!str) str = rtfFromNow(date);
|
||||
return [str, date.toISOString(), date.toLocaleString()];
|
||||
}, [date, format, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!date.isValid()) return;
|
||||
if (!isValidDate(date)) return;
|
||||
let timeout;
|
||||
let raf;
|
||||
function rafRerender() {
|
||||
|
@ -51,9 +115,10 @@ export default function RelativeTime({ datetime, format }) {
|
|||
// If less than 1 minute, rerender every 10s
|
||||
// If less than 1 hour rerender every 1m
|
||||
// Else, don't need to rerender
|
||||
if (date.diff(dayjs(), 'minute', true) < 1) {
|
||||
const seconds = (Date.now() - date.getTime()) / 1000;
|
||||
if (seconds < minute) {
|
||||
timeout = setTimeout(rafRerender, 10_000);
|
||||
} else if (date.diff(dayjs(), 'hour', true) < 1) {
|
||||
} else if (seconds < hour) {
|
||||
timeout = setTimeout(rafRerender, 60_000);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
margin-top: -48px;
|
||||
animation: rubber-stamp 0.3s ease-in both;
|
||||
position: absolute;
|
||||
|
@ -148,7 +148,7 @@
|
|||
}
|
||||
|
||||
.report-rules {
|
||||
margin-left: 1.75em;
|
||||
margin-inline-start: 1.75em;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './report-modal.css';
|
||||
|
||||
import { msg, t, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Fragment } from 'preact';
|
||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
|
@ -24,26 +26,27 @@ const CATEGORIES_INFO = {
|
|||
// description: 'Not something you want to see',
|
||||
// },
|
||||
spam: {
|
||||
label: 'Spam',
|
||||
description: 'Malicious links, fake engagement, or repetitive replies',
|
||||
label: msg`Spam`,
|
||||
description: msg`Malicious links, fake engagement, or repetitive replies`,
|
||||
},
|
||||
legal: {
|
||||
label: 'Illegal',
|
||||
description: "Violates the law of your or the server's country",
|
||||
label: msg`Illegal`,
|
||||
description: msg`Violates the law of your or the server's country`,
|
||||
},
|
||||
violation: {
|
||||
label: 'Server rule violation',
|
||||
description: 'Breaks specific server rules',
|
||||
stampLabel: 'Violation',
|
||||
label: msg`Server rule violation`,
|
||||
description: msg`Breaks specific server rules`,
|
||||
stampLabel: msg`Violation`,
|
||||
},
|
||||
other: {
|
||||
label: 'Other',
|
||||
description: "Issue doesn't fit other categories",
|
||||
label: msg`Other`,
|
||||
description: msg`Issue doesn't fit other categories`,
|
||||
excludeStamp: true,
|
||||
},
|
||||
};
|
||||
|
||||
function ReportModal({ account, post, onClose }) {
|
||||
const { _ } = useLingui();
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [username, domain] = account.acct.split('@');
|
||||
|
@ -62,14 +65,14 @@ function ReportModal({ account, post, onClose }) {
|
|||
return (
|
||||
<div class="report-modal-container">
|
||||
<div class="top-controls">
|
||||
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
|
||||
<h1>{post ? t`Report Post` : t`Report @${username}`}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="plain4 small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" size="xl" />
|
||||
<Icon icon="x" size="xl" alt={t`Close`} />
|
||||
</button>
|
||||
</div>
|
||||
<main>
|
||||
|
@ -93,9 +96,13 @@ function ReportModal({ account, post, onClose }) {
|
|||
key={selectedCategory}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{CATEGORIES_INFO[selectedCategory].stampLabel ||
|
||||
CATEGORIES_INFO[selectedCategory].label}
|
||||
<small>Pending review</small>
|
||||
{_(
|
||||
CATEGORIES_INFO[selectedCategory].stampLabel ||
|
||||
_(CATEGORIES_INFO[selectedCategory].label),
|
||||
)}
|
||||
<small>
|
||||
<Trans>Pending review</Trans>
|
||||
</small>
|
||||
</span>
|
||||
)}
|
||||
<form
|
||||
|
@ -136,7 +143,7 @@ function ReportModal({ account, post, onClose }) {
|
|||
forward,
|
||||
});
|
||||
setUIState('success');
|
||||
showToast(post ? 'Post reported' : 'Profile reported');
|
||||
showToast(post ? t`Post reported` : t`Profile reported`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
@ -144,8 +151,8 @@ function ReportModal({ account, post, onClose }) {
|
|||
showToast(
|
||||
error?.message ||
|
||||
(post
|
||||
? 'Unable to report post'
|
||||
: 'Unable to report profile'),
|
||||
? t`Unable to report post`
|
||||
: t`Unable to report profile`),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
@ -153,8 +160,8 @@ function ReportModal({ account, post, onClose }) {
|
|||
>
|
||||
<p>
|
||||
{post
|
||||
? `What's the issue with this post?`
|
||||
: `What's the issue with this profile?`}
|
||||
? t`What's the issue with this post?`
|
||||
: t`What's the issue with this profile?`}
|
||||
</p>
|
||||
<section class="report-categories">
|
||||
{CATEGORIES.map((category) =>
|
||||
|
@ -173,9 +180,9 @@ function ReportModal({ account, post, onClose }) {
|
|||
}}
|
||||
/>
|
||||
<span>
|
||||
{CATEGORIES_INFO[category].label}
|
||||
{_(CATEGORIES_INFO[category].label)}
|
||||
<small class="ib insignificant">
|
||||
{CATEGORIES_INFO[category].description}
|
||||
{_(CATEGORIES_INFO[category].description)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -222,7 +229,9 @@ function ReportModal({ account, post, onClose }) {
|
|||
</section>
|
||||
<section class="report-comment">
|
||||
<p>
|
||||
<label for="report-comment">Additional info</label>
|
||||
<label for="report-comment">
|
||||
<Trans>Additional info</Trans>
|
||||
</label>
|
||||
</p>
|
||||
<textarea
|
||||
maxlength="1000"
|
||||
|
@ -230,6 +239,7 @@ function ReportModal({ account, post, onClose }) {
|
|||
name="comment"
|
||||
id="report-comment"
|
||||
disabled={uiState === 'loading'}
|
||||
required={!post} // Required if not reporting a post
|
||||
/>
|
||||
</section>
|
||||
{!!domain && domain !== currentDomain && (
|
||||
|
@ -243,7 +253,9 @@ function ReportModal({ account, post, onClose }) {
|
|||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
<span>
|
||||
Forward to <i>{domain}</i>
|
||||
<Trans>
|
||||
Forward to <i>{domain}</i>
|
||||
</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</p>
|
||||
|
@ -251,7 +263,7 @@ function ReportModal({ account, post, onClose }) {
|
|||
)}
|
||||
<footer>
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
Send Report
|
||||
<Trans>Send Report</Trans>
|
||||
</button>{' '}
|
||||
<button
|
||||
type="submit"
|
||||
|
@ -260,15 +272,17 @@ function ReportModal({ account, post, onClose }) {
|
|||
onClick={async () => {
|
||||
try {
|
||||
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
|
||||
showToast(`Muted ${username}`);
|
||||
showToast(t`Muted ${username}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(`Unable to mute ${username}`);
|
||||
showToast(t`Unable to mute ${username}`);
|
||||
}
|
||||
// onSubmit will still run
|
||||
}}
|
||||
>
|
||||
Send Report <small class="ib">+ Mute profile</small>
|
||||
<Trans>
|
||||
Send Report <small class="ib">+ Mute profile</small>
|
||||
</Trans>
|
||||
</button>{' '}
|
||||
<button
|
||||
type="submit"
|
||||
|
@ -277,15 +291,17 @@ function ReportModal({ account, post, onClose }) {
|
|||
onClick={async () => {
|
||||
try {
|
||||
await masto.v1.accounts.$select(account.id).block();
|
||||
showToast(`Blocked ${username}`);
|
||||
showToast(t`Blocked ${username}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(`Unable to block ${username}`);
|
||||
showToast(t`Unable to block ${username}`);
|
||||
}
|
||||
// onSubmit will still run
|
||||
}}
|
||||
>
|
||||
Send Report <small class="ib">+ Block profile</small>
|
||||
<Trans>
|
||||
Send Report <small class="ib">+ Block profile</small>
|
||||
</Trans>
|
||||
</button>
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
</footer>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
@ -68,7 +69,7 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
name="q"
|
||||
type="search"
|
||||
// autofocus
|
||||
placeholder="Search"
|
||||
placeholder={t`Search`}
|
||||
dir="auto"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
|
@ -198,12 +199,12 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
[
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<Trans>
|
||||
{query}{' '}
|
||||
<small class="insignificant">
|
||||
‒ accounts, hashtags & posts
|
||||
</small>
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}`,
|
||||
top: !type && !/\s/.test(query),
|
||||
|
@ -211,9 +212,9 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<Trans>
|
||||
Posts with <q>{query}</q>
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
|
||||
hidden: /^https?:/.test(query),
|
||||
|
@ -223,9 +224,9 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<Trans>
|
||||
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
to: `/${instance}/t/${query.replace(/^#/, '')}`,
|
||||
hidden:
|
||||
|
@ -237,9 +238,9 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<Trans>
|
||||
Look up <mark>{query}</mark>
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
to: `/${query}`,
|
||||
hidden: !/^https?:/.test(query),
|
||||
|
@ -248,9 +249,9 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<Trans>
|
||||
Accounts with <q>{query}</q>
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
|
||||
icon: 'group',
|
||||
|
@ -273,6 +274,7 @@ const SearchForm = forwardRef((props, ref) => {
|
|||
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
|
||||
// hidden={hidden}
|
||||
onClick={(e) => {
|
||||
console.log('onClick', e);
|
||||
props?.onSubmit?.(e);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -18,8 +18,8 @@
|
|||
counter-increment: index;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: right;
|
||||
margin-right: 8px;
|
||||
text-align: end;
|
||||
margin-inline-end: 8px;
|
||||
color: var(--text-insignificant-color);
|
||||
font-size: 90%;
|
||||
flex-shrink: 0;
|
||||
|
@ -55,15 +55,19 @@
|
|||
justify-content: center;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
|
||||
border-top-left-radius: 16px;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-start-start-radius: 16px;
|
||||
border-end-start-radius: 16px;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
|
||||
border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
border-start-end-radius: 16px;
|
||||
border-end-end-radius: 16px;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label img {
|
||||
max-height: 64px;
|
||||
|
||||
&:dir(rtl) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#shortcuts-settings-container .shortcuts-view-mode label img {
|
||||
|
@ -82,9 +86,7 @@
|
|||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label input ~ * {
|
||||
opacity: 0.5;
|
||||
transform-origin: bottom;
|
||||
transform: scale(0.975);
|
||||
transition: all 0.2s ease-out;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label.checked {
|
||||
box-shadow: inset 0 0 0 3px var(--link-color),
|
||||
|
@ -95,7 +97,6 @@
|
|||
label
|
||||
input:is(:hover, :active, :checked)
|
||||
~ * {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
@ -114,7 +115,7 @@
|
|||
}
|
||||
#shortcut-settings-form label > span:first-child {
|
||||
flex-basis: 5em;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
#shortcut-settings-form :is(input[type='text'], select) {
|
||||
flex-grow: 1;
|
||||
|
@ -185,8 +186,8 @@
|
|||
counter-increment: index;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: right;
|
||||
margin-right: 8px;
|
||||
text-align: end;
|
||||
margin-inline-end: 8px;
|
||||
color: var(--text-insignificant-color);
|
||||
font-size: 90%;
|
||||
flex-shrink: 0;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import './shortcuts-settings.css';
|
||||
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/preact';
|
||||
import { msg, Plural, t, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
|
@ -43,55 +45,55 @@ const TYPES = [
|
|||
// 'account-statuses', // Need @acct search first
|
||||
];
|
||||
const TYPE_TEXT = {
|
||||
following: 'Home / Following',
|
||||
notifications: 'Notifications',
|
||||
list: 'Lists',
|
||||
public: 'Public (Local / Federated)',
|
||||
search: 'Search',
|
||||
'account-statuses': 'Account',
|
||||
bookmarks: 'Bookmarks',
|
||||
favourites: 'Likes',
|
||||
hashtag: 'Hashtag',
|
||||
trending: 'Trending',
|
||||
mentions: 'Mentions',
|
||||
following: msg`Home / Following`,
|
||||
notifications: msg`Notifications`,
|
||||
list: msg`Lists`,
|
||||
public: msg`Public (Local / Federated)`,
|
||||
search: msg`Search`,
|
||||
'account-statuses': msg`Account`,
|
||||
bookmarks: msg`Bookmarks`,
|
||||
favourites: msg`Likes`,
|
||||
hashtag: msg`Hashtag`,
|
||||
trending: msg`Trending`,
|
||||
mentions: msg`Mentions`,
|
||||
};
|
||||
const TYPE_PARAMS = {
|
||||
list: [
|
||||
{
|
||||
text: 'List ID',
|
||||
text: msg`List ID`,
|
||||
name: 'id',
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
public: [
|
||||
{
|
||||
text: 'Local only',
|
||||
text: msg`Local only`,
|
||||
name: 'local',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
text: 'Instance',
|
||||
text: msg`Instance`,
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
placeholder: msg`Optional, e.g. mastodon.social`,
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
trending: [
|
||||
{
|
||||
text: 'Instance',
|
||||
text: msg`Instance`,
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
placeholder: msg`Optional, e.g. mastodon.social`,
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
search: [
|
||||
{
|
||||
text: 'Search term',
|
||||
text: msg`Search term`,
|
||||
name: 'query',
|
||||
type: 'text',
|
||||
placeholder: 'Optional, unless for multi-column mode',
|
||||
placeholder: msg`Optional, unless for multi-column mode`,
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
|
@ -108,19 +110,19 @@ const TYPE_PARAMS = {
|
|||
text: '#',
|
||||
name: 'hashtag',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. PixelArt (Max 5, space-separated)',
|
||||
placeholder: msg`e.g. PixelArt (Max 5, space-separated)`,
|
||||
pattern: '[^#]+',
|
||||
},
|
||||
{
|
||||
text: 'Media only',
|
||||
text: msg`Media only`,
|
||||
name: 'media',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
text: 'Instance',
|
||||
text: msg`Instance`,
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
placeholder: msg`Optional, e.g. mastodon.social`,
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
|
@ -132,46 +134,46 @@ const fetchAccountTitle = pmem(async ({ id }) => {
|
|||
export const SHORTCUTS_META = {
|
||||
following: {
|
||||
id: 'home',
|
||||
title: (_, index) => (index === 0 ? 'Home' : 'Following'),
|
||||
title: (_, index) => (index === 0 ? t`Home` : t`Following`),
|
||||
path: '/',
|
||||
icon: 'home',
|
||||
},
|
||||
mentions: {
|
||||
id: 'mentions',
|
||||
title: 'Mentions',
|
||||
title: msg`Mentions`,
|
||||
path: '/mentions',
|
||||
icon: 'at',
|
||||
},
|
||||
notifications: {
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
title: msg`Notifications`,
|
||||
path: '/notifications',
|
||||
icon: 'notification',
|
||||
},
|
||||
list: {
|
||||
id: ({ id }) => (id ? 'list' : 'lists'),
|
||||
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
|
||||
title: ({ id }) => (id ? getListTitle(id) : t`Lists`),
|
||||
path: ({ id }) => (id ? `/l/${id}` : '/l'),
|
||||
icon: 'list',
|
||||
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
|
||||
},
|
||||
public: {
|
||||
id: 'public',
|
||||
title: ({ local }) => (local ? 'Local' : 'Federated'),
|
||||
title: ({ local }) => (local ? t`Local` : t`Federated`),
|
||||
subtitle: ({ instance }) => instance || api().instance,
|
||||
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
|
||||
icon: ({ local }) => (local ? 'building' : 'earth'),
|
||||
},
|
||||
trending: {
|
||||
id: 'trending',
|
||||
title: 'Trending',
|
||||
title: msg`Trending`,
|
||||
subtitle: ({ instance }) => instance || api().instance,
|
||||
path: ({ instance }) => `/${instance}/trending`,
|
||||
icon: 'chart',
|
||||
},
|
||||
search: {
|
||||
id: 'search',
|
||||
title: ({ query }) => (query ? `“${query}”` : 'Search'),
|
||||
title: ({ query }) => (query ? `“${query}”` : t`Search`),
|
||||
path: ({ query }) =>
|
||||
query
|
||||
? `/search?q=${encodeURIComponent(query)}&type=statuses`
|
||||
|
@ -187,13 +189,13 @@ export const SHORTCUTS_META = {
|
|||
},
|
||||
bookmarks: {
|
||||
id: 'bookmarks',
|
||||
title: 'Bookmarks',
|
||||
title: msg`Bookmarks`,
|
||||
path: '/b',
|
||||
icon: 'bookmark',
|
||||
},
|
||||
favourites: {
|
||||
id: 'favourites',
|
||||
title: 'Likes',
|
||||
title: msg`Likes`,
|
||||
path: '/f',
|
||||
icon: 'heart',
|
||||
},
|
||||
|
@ -210,6 +212,7 @@ export const SHORTCUTS_META = {
|
|||
};
|
||||
|
||||
function ShortcutsSettings({ onClose }) {
|
||||
const { _ } = useLingui();
|
||||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts } = snapStates;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
@ -221,12 +224,12 @@ function ShortcutsSettings({ onClose }) {
|
|||
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>
|
||||
<Icon icon="shortcut" /> Shortcuts{' '}
|
||||
<Icon icon="shortcut" /> <Trans>Shortcuts</Trans>{' '}
|
||||
<sup
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
@ -234,27 +237,29 @@ function ShortcutsSettings({ onClose }) {
|
|||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
beta
|
||||
<Trans>beta</Trans>
|
||||
</sup>
|
||||
</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p>Specify a list of shortcuts that'll appear as:</p>
|
||||
<p>
|
||||
<Trans>Specify a list of shortcuts that'll appear as:</Trans>
|
||||
</p>
|
||||
<div class="shortcuts-view-mode">
|
||||
{[
|
||||
{
|
||||
value: 'float-button',
|
||||
label: 'Floating button',
|
||||
label: t`Floating button`,
|
||||
imgURL: floatingButtonUrl,
|
||||
},
|
||||
{
|
||||
value: 'tab-menu-bar',
|
||||
label: 'Tab/Menu bar',
|
||||
label: t`Tab/Menu bar`,
|
||||
imgURL: tabMenuBarUrl,
|
||||
},
|
||||
{
|
||||
value: 'multi-column',
|
||||
label: 'Multi-column',
|
||||
label: t`Multi-column`,
|
||||
imgURL: multiColumnUrl,
|
||||
},
|
||||
].map(({ value, label, imgURL }) => {
|
||||
|
@ -291,9 +296,13 @@ function ShortcutsSettings({ onClose }) {
|
|||
SHORTCUTS_META[type];
|
||||
if (typeof title === 'function') {
|
||||
title = title(shortcut, i);
|
||||
} else {
|
||||
title = _(title);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(shortcut, i);
|
||||
} else {
|
||||
subtitle = _(subtitle);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(shortcut, i);
|
||||
|
@ -317,7 +326,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
)}
|
||||
{excludedViewMode && (
|
||||
<span class="tag">
|
||||
Not available in current view mode
|
||||
<Trans>Not available in current view mode</Trans>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
@ -336,7 +345,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-up" alt="Move up" />
|
||||
<Icon icon="arrow-up" alt={t`Move up`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -352,7 +361,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-down" alt="Move down" />
|
||||
<Icon icon="arrow-down" alt={t`Move down`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -364,7 +373,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" alt="Edit" />
|
||||
<Icon icon="pencil" alt={t`Edit`} />
|
||||
</button>
|
||||
{/* <button
|
||||
type="button"
|
||||
|
@ -385,7 +394,9 @@ function ShortcutsSettings({ onClose }) {
|
|||
<div class="ui-state insignificant">
|
||||
<Icon icon="info" />{' '}
|
||||
<small>
|
||||
Add more than one shortcut/column to make this work.
|
||||
<Trans>
|
||||
Add more than one shortcut/column to make this work.
|
||||
</Trans>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
@ -394,38 +405,40 @@ function ShortcutsSettings({ onClose }) {
|
|||
<div class="ui-state insignificant">
|
||||
<p>
|
||||
{snapStates.settings.shortcutsViewMode === 'multi-column'
|
||||
? 'No columns yet. Tap on the Add column button.'
|
||||
: 'No shortcuts yet. Tap on the Add shortcut button.'}
|
||||
? t`No columns yet. Tap on the Add column button.`
|
||||
: t`No shortcuts yet. Tap on the Add shortcut button.`}
|
||||
</p>
|
||||
<p>
|
||||
Not sure what to add?
|
||||
<br />
|
||||
Try adding{' '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
states.shortcuts = [
|
||||
{
|
||||
type: 'following',
|
||||
},
|
||||
{
|
||||
type: 'notifications',
|
||||
},
|
||||
];
|
||||
}}
|
||||
>
|
||||
Home / Following and Notifications
|
||||
</a>{' '}
|
||||
first.
|
||||
<Trans>
|
||||
Not sure what to add?
|
||||
<br />
|
||||
Try adding{' '}
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
states.shortcuts = [
|
||||
{
|
||||
type: 'following',
|
||||
},
|
||||
{
|
||||
type: 'notifications',
|
||||
},
|
||||
];
|
||||
}}
|
||||
>
|
||||
Home / Following and Notifications
|
||||
</a>{' '}
|
||||
first.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p class="insignificant">
|
||||
{shortcuts.length >= SHORTCUTS_LIMIT &&
|
||||
(snapStates.settings.shortcutsViewMode === 'multi-column'
|
||||
? `Max ${SHORTCUTS_LIMIT} columns`
|
||||
: `Max ${SHORTCUTS_LIMIT} shortcuts`)}
|
||||
? t`Max ${SHORTCUTS_LIMIT} columns`
|
||||
: t`Max ${SHORTCUTS_LIMIT} shortcuts`)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
|
@ -439,7 +452,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
class="light"
|
||||
onClick={() => setShowImportExport(true)}
|
||||
>
|
||||
Import/export
|
||||
<Trans>Import/export</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -449,8 +462,8 @@ function ShortcutsSettings({ onClose }) {
|
|||
<Icon icon="plus" />{' '}
|
||||
<span>
|
||||
{snapStates.settings.shortcutsViewMode === 'multi-column'
|
||||
? 'Add column…'
|
||||
: 'Add shortcut…'}
|
||||
? t`Add column…`
|
||||
: t`Add shortcut…`}
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
|
@ -497,9 +510,9 @@ function ShortcutsSettings({ onClose }) {
|
|||
}
|
||||
|
||||
const FORM_NOTES = {
|
||||
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
|
||||
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
||||
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
||||
list: msg`Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
|
||||
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({
|
||||
|
@ -509,10 +522,10 @@ function ShortcutForm({
|
|||
shortcutIndex,
|
||||
onClose,
|
||||
}) {
|
||||
const { _ } = useLingui();
|
||||
console.log('shortcut', shortcut);
|
||||
const editMode = !!shortcut;
|
||||
const [currentType, setCurrentType] = useState(shortcut?.type || null);
|
||||
const { masto } = api();
|
||||
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [lists, setLists] = useState([]);
|
||||
|
@ -564,11 +577,11 @@ function ShortcutForm({
|
|||
<div id="shortcut-settings-form" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>{editMode ? 'Edit' : 'Add'} shortcut</h2>
|
||||
<h2>{editMode ? t`Edit shortcut` : t`Add shortcut`}</h2>
|
||||
</header>
|
||||
<main tabindex="-1">
|
||||
<form
|
||||
|
@ -603,7 +616,9 @@ function ShortcutForm({
|
|||
>
|
||||
<p>
|
||||
<label>
|
||||
<span>Timeline</span>
|
||||
<span>
|
||||
<Trans>Timeline</Trans>
|
||||
</span>
|
||||
<select
|
||||
required
|
||||
disabled={disabled}
|
||||
|
@ -612,10 +627,11 @@ function ShortcutForm({
|
|||
}}
|
||||
defaultValue={editMode ? shortcut.type : undefined}
|
||||
name="type"
|
||||
dir="auto"
|
||||
>
|
||||
<option></option>
|
||||
{TYPES.map((type) => (
|
||||
<option value={type}>{TYPE_TEXT[type]}</option>
|
||||
<option value={type}>{_(TYPE_TEXT[type])}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
@ -626,12 +642,15 @@ function ShortcutForm({
|
|||
return (
|
||||
<p>
|
||||
<label>
|
||||
<span>List</span>
|
||||
<span>
|
||||
<Trans>List</Trans>
|
||||
</span>
|
||||
<select
|
||||
name="id"
|
||||
required={!notRequired}
|
||||
disabled={disabled || uiState === 'loading'}
|
||||
defaultValue={editMode ? shortcut.id : undefined}
|
||||
dir="auto"
|
||||
>
|
||||
<option value=""></option>
|
||||
{lists.map((list) => (
|
||||
|
@ -646,12 +665,12 @@ function ShortcutForm({
|
|||
return (
|
||||
<p>
|
||||
<label>
|
||||
<span>{text}</span>{' '}
|
||||
<span>{_(text)}</span>{' '}
|
||||
<input
|
||||
type={type}
|
||||
switch={type === 'checkbox' || undefined}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
placeholder={_(placeholder)}
|
||||
required={type === 'text' && !notRequired}
|
||||
disabled={disabled}
|
||||
list={
|
||||
|
@ -663,6 +682,7 @@ function ShortcutForm({
|
|||
autocapitalize="off"
|
||||
spellCheck={false}
|
||||
pattern={pattern}
|
||||
dir="auto"
|
||||
/>
|
||||
{currentType === 'hashtag' &&
|
||||
followedHashtags.length > 0 && (
|
||||
|
@ -680,7 +700,7 @@ function ShortcutForm({
|
|||
{!!FORM_NOTES[currentType] && (
|
||||
<p class="form-note insignificant">
|
||||
<Icon icon="info" />
|
||||
{FORM_NOTES[currentType]}
|
||||
{_(FORM_NOTES[currentType])}
|
||||
</p>
|
||||
)}
|
||||
<footer>
|
||||
|
@ -689,7 +709,7 @@ function ShortcutForm({
|
|||
class="block"
|
||||
disabled={disabled || uiState === 'loading'}
|
||||
>
|
||||
{editMode ? 'Save' : 'Add'}
|
||||
{editMode ? t`Save` : t`Add`}
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
|
@ -700,7 +720,7 @@ function ShortcutForm({
|
|||
onClose?.();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
<Trans>Remove</Trans>
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
@ -711,6 +731,7 @@ function ShortcutForm({
|
|||
}
|
||||
|
||||
function ImportExport({ shortcuts, onClose }) {
|
||||
const { _ } = useLingui();
|
||||
const { masto } = api();
|
||||
const shortcutsStr = useMemo(() => {
|
||||
if (!shortcuts) return '';
|
||||
|
@ -756,30 +777,35 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
<div id="import-export-container" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
<Icon icon="x" alt={t`Close`} />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>
|
||||
Import/Export <small class="ib insignificant">Shortcuts</small>
|
||||
<Trans>
|
||||
Import/Export <small class="ib insignificant">Shortcuts</small>
|
||||
</Trans>
|
||||
</h2>
|
||||
</header>
|
||||
<main tabindex="-1">
|
||||
<section>
|
||||
<h3>
|
||||
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
|
||||
<span>Import</span>
|
||||
<span>
|
||||
<Trans>Import</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
<p class="field-button">
|
||||
<input
|
||||
ref={shortcutsImportFieldRef}
|
||||
type="text"
|
||||
name="import"
|
||||
placeholder="Paste shortcuts here"
|
||||
placeholder={t`Paste shortcuts here`}
|
||||
class="block"
|
||||
onInput={(e) => {
|
||||
setImportShortcutStr(e.target.value);
|
||||
}}
|
||||
dir="auto"
|
||||
/>
|
||||
{states.settings.shortcutSettingsCloudImportExport && (
|
||||
<button
|
||||
|
@ -790,7 +816,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
setImportUIState('cloud-downloading');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
showToast(
|
||||
'Downloading saved shortcuts from instance server…',
|
||||
t`Downloading saved shortcuts from instance server…`,
|
||||
);
|
||||
try {
|
||||
const relationships =
|
||||
|
@ -819,10 +845,10 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
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="arrow-down" />
|
||||
|
@ -857,7 +883,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
*
|
||||
</span>
|
||||
<span>
|
||||
{TYPE_TEXT[shortcut.type]}
|
||||
{_(TYPE_TEXT[shortcut.type])}
|
||||
{shortcut.type === 'list' && ' ⚠️'}{' '}
|
||||
{TYPE_PARAMS[shortcut.type]?.map?.(
|
||||
({ text, name, type }) =>
|
||||
|
@ -879,28 +905,37 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
))}
|
||||
</ol>
|
||||
<p>
|
||||
<small>* Exists in current shortcuts</small>
|
||||
<small>
|
||||
<Trans>* Exists in current shortcuts</Trans>
|
||||
</small>
|
||||
<br />
|
||||
<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>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{importUIState === 'error' && (
|
||||
<p class="error">
|
||||
<small>⚠️ Invalid settings format</small>
|
||||
<small>
|
||||
⚠️ <Trans>Invalid settings format</Trans>
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{hasCurrentSettings && (
|
||||
<>
|
||||
<MenuConfirm
|
||||
confirmLabel="Append to current shortcuts?"
|
||||
confirmLabel={t`Append to current shortcuts?`}
|
||||
menuFooter={
|
||||
<div class="footer">
|
||||
Only shortcuts that don’t exist in current shortcuts will
|
||||
be appended.
|
||||
<Trans>
|
||||
Only shortcuts that don’t exist in current shortcuts
|
||||
will be appended.
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
|
@ -919,7 +954,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
),
|
||||
);
|
||||
if (!nonUniqueShortcuts.length) {
|
||||
showToast('No new shortcuts to import');
|
||||
showToast(t`No new shortcuts to import`);
|
||||
return;
|
||||
}
|
||||
let newShortcuts = [
|
||||
|
@ -934,8 +969,8 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
states.shortcuts = newShortcuts;
|
||||
showToast(
|
||||
exceededLimit
|
||||
? `Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
|
||||
: 'Shortcuts imported',
|
||||
? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
|
||||
: t`Shortcuts imported`,
|
||||
);
|
||||
onClose?.();
|
||||
}}
|
||||
|
@ -945,7 +980,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
class="plain2"
|
||||
disabled={!parsedImportShortcutStr}
|
||||
>
|
||||
Import & append…
|
||||
<Trans>Import & append…</Trans>
|
||||
</button>
|
||||
</MenuConfirm>{' '}
|
||||
</>
|
||||
|
@ -953,13 +988,13 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
<MenuConfirm
|
||||
confirmLabel={
|
||||
hasCurrentSettings
|
||||
? 'Override current shortcuts?'
|
||||
: 'Import shortcuts?'
|
||||
? t`Override current shortcuts?`
|
||||
: t`Import shortcuts?`
|
||||
}
|
||||
menuItemClassName={hasCurrentSettings ? 'danger' : undefined}
|
||||
onClick={() => {
|
||||
states.shortcuts = parsedImportShortcutStr;
|
||||
showToast('Shortcuts imported');
|
||||
showToast(t`Shortcuts imported`);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
|
@ -968,7 +1003,7 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
class="plain2"
|
||||
disabled={!parsedImportShortcutStr}
|
||||
>
|
||||
{hasCurrentSettings ? 'or override…' : 'Import…'}
|
||||
{hasCurrentSettings ? t`or override…` : t`Import…`}
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
</p>
|
||||
|
@ -976,7 +1011,9 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
<section>
|
||||
<h3>
|
||||
<Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '}
|
||||
<span>Export</span>
|
||||
<span>
|
||||
<Trans>Export</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
<p>
|
||||
<input
|
||||
|
@ -990,12 +1027,13 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
// Copy url to clipboard
|
||||
try {
|
||||
navigator.clipboard.writeText(e.target.value);
|
||||
showToast('Shortcuts copied');
|
||||
showToast(t`Shortcuts copied`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Unable to copy shortcuts');
|
||||
showToast(t`Unable to copy shortcuts`);
|
||||
}
|
||||
}}
|
||||
dir="auto"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
|
@ -1006,14 +1044,17 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
onClick={() => {
|
||||
try {
|
||||
navigator.clipboard.writeText(shortcutsStr);
|
||||
showToast('Shortcut settings copied');
|
||||
showToast(t`Shortcut settings copied`);
|
||||
} catch (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>{' '}
|
||||
{navigator?.share &&
|
||||
navigator?.canShare?.({
|
||||
|
@ -1030,11 +1071,14 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
});
|
||||
} catch (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>
|
||||
)}{' '}
|
||||
{states.settings.shortcutSettingsCloudImportExport && (
|
||||
|
@ -1055,16 +1099,16 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
const { note = '' } = relationship;
|
||||
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
|
||||
let newNote = '';
|
||||
const settingsJSON = JSON.stringify({
|
||||
v: '1', // version
|
||||
dt: Date.now(), // datetime stamp
|
||||
data: shortcutsStr, // shortcuts settings string
|
||||
});
|
||||
if (
|
||||
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
|
||||
note,
|
||||
)
|
||||
) {
|
||||
const settingsJSON = JSON.stringify({
|
||||
v: '1', // version
|
||||
dt: Date.now(), // datetime stamp
|
||||
data: shortcutsStr, // shortcuts settings string
|
||||
});
|
||||
newNote = note.replace(
|
||||
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
|
||||
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,
|
||||
|
@ -1072,22 +1116,22 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
} else {
|
||||
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
|
||||
.$select(currentAccount)
|
||||
.note.create({
|
||||
comment: newNote,
|
||||
});
|
||||
setImportUIState('default');
|
||||
showToast('Shortcuts saved');
|
||||
showToast(t`Shortcuts saved`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
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="arrow-up" />
|
||||
|
@ -1095,14 +1139,20 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
)}{' '}
|
||||
{shortcutsStr.length > 0 && (
|
||||
<small class="insignificant ib">
|
||||
{shortcutsStr.length} characters
|
||||
<Plural
|
||||
value={shortcutsStr.length}
|
||||
one="# character"
|
||||
other="# characters"
|
||||
/>
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
{!!shortcutsStr && (
|
||||
<details>
|
||||
<summary class="insignificant">
|
||||
<small>Raw Shortcuts JSON</small>
|
||||
<small>
|
||||
<Trans>Raw Shortcuts JSON</Trans>
|
||||
</small>
|
||||
</summary>
|
||||
<textarea style={{ width: '100%' }} rows={10} readOnly>
|
||||
{JSON.stringify(shortcuts.filter(Boolean), null, 2)}
|
||||
|
@ -1113,8 +1163,11 @@ function ImportExport({ shortcuts, onClose }) {
|
|||
{states.settings.shortcutSettingsCloudImportExport && (
|
||||
<footer>
|
||||
<p>
|
||||
<Icon icon="cloud" /> Import/export settings from/to instance
|
||||
server (Very experimental)
|
||||
<Icon icon="cloud" />{' '}
|
||||
<Trans>
|
||||
Import/export settings from/to instance server (Very
|
||||
experimental)
|
||||
</Trans>
|
||||
</p>
|
||||
</footer>
|
||||
)}
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
position: fixed;
|
||||
bottom: 16px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
left: 16px;
|
||||
left: max(16px, env(safe-area-inset-left));
|
||||
inset-inline-start: 16px;
|
||||
inset-inline-start: max(16px, env(safe-area-inset-left));
|
||||
padding: 16px;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
z-index: 101;
|
||||
|
@ -34,9 +34,9 @@
|
|||
|
||||
@media (min-width: calc(40em + 56px + 8px)) {
|
||||
#shortcuts-button {
|
||||
right: 16px;
|
||||
right: max(16px, env(safe-area-inset-right));
|
||||
left: auto;
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-end: max(16px, env(safe-area-inset-right));
|
||||
inset-inline-start: auto;
|
||||
top: 16px;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
bottom: auto;
|
||||
|
@ -121,13 +121,31 @@
|
|||
text-overflow: ellipsis;
|
||||
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 {
|
||||
color: var(--link-color);
|
||||
background-image: radial-gradient(
|
||||
/* background-image: radial-gradient(
|
||||
closest-side at 50% 50%,
|
||||
var(--bg-color),
|
||||
transparent
|
||||
);
|
||||
); */
|
||||
&:before {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden])
|
||||
#shortcuts
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './shortcuts.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { MenuDivider } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
|
@ -15,11 +17,12 @@ import states from '../utils/states';
|
|||
import AsyncText from './AsyncText';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Menu2 from './menu2';
|
||||
import MenuLink from './menu-link';
|
||||
import Menu2 from './menu2';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function Shortcuts() {
|
||||
const { _ } = useLingui();
|
||||
const { instance } = api();
|
||||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts, settings } = snapStates;
|
||||
|
@ -57,9 +60,13 @@ function Shortcuts() {
|
|||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
} else {
|
||||
title = _(title);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
} else {
|
||||
subtitle = _(subtitle);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
|
@ -176,7 +183,7 @@ function Shortcuts() {
|
|||
} catch (e) {}
|
||||
}}
|
||||
>
|
||||
<Icon icon="shortcut" size="xl" alt="Shortcuts" />
|
||||
<Icon icon="shortcut" size="xl" alt={t`Shortcuts`} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
|
@ -198,7 +205,9 @@ function Shortcuts() {
|
|||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
<span>
|
||||
<Trans>All Lists</Trans>
|
||||
</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
{lists?.map((list) => (
|
||||
|
|
|
@ -1,22 +1,31 @@
|
|||
/* REBLOG + REPLY-TO */
|
||||
|
||||
:root {
|
||||
--post-gradient-angle: 160deg;
|
||||
--post-gradient-chip-angle: -20deg;
|
||||
&:dir(rtl) {
|
||||
--post-gradient-angle: -160deg;
|
||||
--post-gradient-chip-angle: 20deg;
|
||||
}
|
||||
}
|
||||
|
||||
.status-reblog {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--post-gradient-angle),
|
||||
var(--reblog-faded-color),
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
.status-group {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--post-gradient-angle),
|
||||
var(--group-faded-color),
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
.status-followed-tags {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--post-gradient-angle),
|
||||
var(--hashtag-faded-color),
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
|
@ -33,14 +42,14 @@
|
|||
}
|
||||
.status-reply-to {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--post-gradient-angle),
|
||||
var(--reply-to-faded-color),
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
}
|
||||
:is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to {
|
||||
background: linear-gradient(
|
||||
-20deg,
|
||||
var(--post-gradient-chip-angle),
|
||||
var(--reply-to-faded-color),
|
||||
transparent min(160px, 50%)
|
||||
);
|
||||
|
@ -72,12 +81,12 @@
|
|||
}
|
||||
.status-reblog .status-pre-meta .icon {
|
||||
color: var(--reblog-color);
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
.status-group .status-pre-meta .icon {
|
||||
color: var(--group-color);
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
.status-followed-tags {
|
||||
|
@ -91,7 +100,7 @@
|
|||
|
||||
.icon {
|
||||
color: var(--hashtag-color);
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
a {
|
||||
|
@ -208,7 +217,7 @@
|
|||
/* filter: drop-shadow(0 2px 4px var(--bg-faded-color)); */
|
||||
}
|
||||
.status-card:has(.status-badge:not(:empty)) {
|
||||
border-top-right-radius: 8px;
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
.status-card > * {
|
||||
pointer-events: none;
|
||||
|
@ -276,7 +285,8 @@
|
|||
align-items: center;
|
||||
|
||||
.status-carousel & {
|
||||
padding: 16px 16px 16px 24px;
|
||||
padding: 16px;
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
}
|
||||
.status.filtered .status-filtered-info {
|
||||
|
@ -286,7 +296,7 @@
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
mask-image: linear-gradient(to right, black 90%, transparent);
|
||||
mask-image: linear-gradient(var(--to-forward), black 90%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
.status.filtered .avatar {
|
||||
|
@ -312,7 +322,7 @@
|
|||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
.status.filtered:is(:hover, :focus, :active) .status-filtered-info-2 {
|
||||
opacity: 0.75;
|
||||
|
@ -353,7 +363,7 @@
|
|||
padding-bottom: 0;
|
||||
margin-bottom: calc(-1 * var(--top-padding) / 2);
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
var(--post-gradient-angle),
|
||||
transparent 2.5%,
|
||||
var(--reply-to-faded-color) 10%,
|
||||
transparent
|
||||
|
@ -381,7 +391,7 @@
|
|||
content: '';
|
||||
position: absolute;
|
||||
top: calc(var(--top-padding) + var(--avatar-size));
|
||||
left: var(--line-start);
|
||||
inset-inline-start: var(--line-start);
|
||||
width: var(--line-width);
|
||||
height: calc(
|
||||
100% - var(--top-padding) - var(--avatar-size) + (var(--top-padding) / 2)
|
||||
|
@ -392,7 +402,7 @@
|
|||
}
|
||||
|
||||
.avatar {
|
||||
margin-left: calc((50px - var(--avatar-size)) / 2);
|
||||
margin-inline-start: calc((50px - var(--avatar-size)) / 2);
|
||||
justify-self: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
@ -420,6 +430,7 @@
|
|||
|
||||
> span + span {
|
||||
position: static;
|
||||
width: auto;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
@ -433,7 +444,7 @@
|
|||
min-width: 0;
|
||||
}
|
||||
.status:not(.small) > .container {
|
||||
padding-left: 12px;
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
.status > .container > .meta {
|
||||
|
@ -451,7 +462,7 @@
|
|||
/* text-overflow: ellipsis; */
|
||||
}
|
||||
.status > .container > .meta .meta-name {
|
||||
mask-image: linear-gradient(to left, transparent, black 16px);
|
||||
mask-image: linear-gradient(var(--to-backward), transparent, black 16px);
|
||||
flex-grow: 1;
|
||||
|
||||
.name-text b {
|
||||
|
@ -470,7 +481,7 @@
|
|||
text-align: end;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status > .container > .meta a.time {
|
||||
|
@ -482,7 +493,7 @@
|
|||
font-size: 90%;
|
||||
|
||||
.more {
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
@ -509,7 +520,8 @@
|
|||
|
||||
.status-reply-badge {
|
||||
display: inline-flex;
|
||||
margin: 2px 0 2px 4px;
|
||||
margin: 2px 0;
|
||||
margin-inline-start: 4px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
|
@ -587,6 +599,7 @@
|
|||
position: relative;
|
||||
top: calc((9px + 2px) / 2 * -1);
|
||||
min-width: 50px;
|
||||
max-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-filtered-badge.clickable:hover {
|
||||
|
@ -596,6 +609,8 @@
|
|||
background: var(--bg-color);
|
||||
}
|
||||
.status-filtered-badge:not(.horizontal).badge-meta > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-filtered-badge:not(.horizontal).badge-meta > span + span {
|
||||
|
@ -609,7 +624,7 @@
|
|||
position: absolute;
|
||||
width: 100%;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.status-filtered-badge.horizontal.badge-meta > span + span {
|
||||
|
@ -618,7 +633,7 @@
|
|||
}
|
||||
|
||||
.status.large > .container > .content-container {
|
||||
margin-left: calc(-50px - 16px);
|
||||
margin-inline-start: calc(-50px - 16px);
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
@ -1005,13 +1020,13 @@
|
|||
.media-gt2
|
||||
) {
|
||||
/* 50px = avatar size */
|
||||
margin-left: calc(-1 * ((50px / 2)));
|
||||
margin-inline-start: calc(-1 * ((50px / 2)));
|
||||
/*
|
||||
outer padding = 16px
|
||||
gap = 12px
|
||||
so... 16 - 12 = 4
|
||||
*/
|
||||
margin-right: -4px;
|
||||
margin-inline-end: -4px;
|
||||
}
|
||||
.status.large :is(.media-container, .media-container.media-gt2) {
|
||||
height: auto;
|
||||
|
@ -1121,40 +1136,46 @@
|
|||
}
|
||||
/* Special media borders */
|
||||
.status .media-container.media-eq2 .media:first-of-type {
|
||||
border-radius: var(--media-radius) var(--media-radius-inner)
|
||||
var(--media-radius-inner) var(--media-radius);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq2 .media:last-of-type {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius)
|
||||
var(--media-radius) var(--media-radius-inner);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq3 .media:first-of-type {
|
||||
border-radius: var(--media-radius) var(--media-radius-inner)
|
||||
var(--media-radius-inner) var(--media-radius);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq3 .media:nth-of-type(2) {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius)
|
||||
var(--media-radius-inner) var(--media-radius-inner);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq3 .media:last-of-type {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius-inner)
|
||||
var(--media-radius) var(--media-radius-inner);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq4 .media:first-of-type {
|
||||
border-radius: var(--media-radius) var(--media-radius-inner)
|
||||
var(--media-radius-inner) var(--media-radius-inner);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq4 .media:nth-of-type(2) {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius)
|
||||
var(--media-radius-inner) var(--media-radius-inner);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq4 .media:nth-of-type(3) {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius-inner)
|
||||
var(--media-radius-inner) var(--media-radius);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-end-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media-container.media-eq4 .media:last-of-type {
|
||||
border-radius: var(--media-radius-inner) var(--media-radius-inner)
|
||||
var(--media-radius) var(--media-radius-inner);
|
||||
border-start-start-radius: var(--media-radius-inner);
|
||||
border-start-end-radius: var(--media-radius-inner);
|
||||
border-end-start-radius: var(--media-radius-inner);
|
||||
}
|
||||
.status .media:only-child {
|
||||
grid-area: span 2 / span 2;
|
||||
|
@ -1207,7 +1228,7 @@
|
|||
.alt-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
inset-inline-start: 8px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
|
@ -1266,7 +1287,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
content: attr(data-formatted-duration);
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
color: var(--media-fg-color);
|
||||
background-color: var(--media-bg-color);
|
||||
border: var(--hairline-width) solid var(--media-outline-color);
|
||||
|
@ -1283,7 +1304,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
content: attr(data-label);
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
color: var(--media-fg-color);
|
||||
background-color: var(--media-bg-color);
|
||||
border: var(--hairline-width) solid var(--media-outline-color);
|
||||
|
@ -1456,7 +1477,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
color: var(--media-fg-color);
|
||||
background-color: var(--media-bg-color);
|
||||
padding: 2px 8px;
|
||||
|
@ -1484,8 +1505,8 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
}
|
||||
|
||||
+ .carousel-button {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1655,7 +1676,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
display: none;
|
||||
|
||||
+ * {
|
||||
margin-left: 1ex;
|
||||
margin-inline-start: 1ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1746,9 +1767,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
--bottom: 16px;
|
||||
bottom: var(--bottom);
|
||||
bottom: calc(var(--bottom) + env(safe-area-inset-bottom));
|
||||
left: 16px;
|
||||
left: calc(16px + env(safe-area-inset-left));
|
||||
text-align: left;
|
||||
inset-inline-start: 16px;
|
||||
inset-inline-start: calc(16px + env(safe-area-inset-left));
|
||||
text-align: start;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
padding: 4px 8px;
|
||||
|
@ -1875,6 +1896,7 @@ a:focus-visible .card img {
|
|||
.meta-container {
|
||||
align-self: flex-start;
|
||||
flex-grow: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.card .title {
|
||||
line-height: 1.25;
|
||||
|
@ -1949,13 +1971,17 @@ a.card:is(:hover, :focus):visited {
|
|||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
-webkit-line-clamp: 5;
|
||||
line-clamp: 5;
|
||||
opacity: 1;
|
||||
font-size: inherit;
|
||||
/* font-size: inherit; */
|
||||
}
|
||||
}
|
||||
.status.large .card.large.card-post,
|
||||
|
@ -2019,8 +2045,8 @@ a.card:is(:hover, :focus):visited {
|
|||
z-index: 0;
|
||||
}
|
||||
.poll-option:first-child:after {
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
border-start-start-radius: 12px;
|
||||
border-start-end-radius: 12px;
|
||||
}
|
||||
.poll-option:hover:after {
|
||||
opacity: 1;
|
||||
|
@ -2039,7 +2065,8 @@ a.card:is(:hover, :focus):visited {
|
|||
.poll-label input:is([type='radio'], [type='checkbox']) {
|
||||
flex-shrink: 0;
|
||||
margin: 0 3px;
|
||||
min-height: 0.9em;
|
||||
min-height: 1.15em;
|
||||
accent-color: var(--link-color);
|
||||
}
|
||||
.poll-option-votes {
|
||||
flex-shrink: 0;
|
||||
|
@ -2052,7 +2079,9 @@ a.card:is(:hover, :focus):visited {
|
|||
opacity: 1;
|
||||
}
|
||||
.poll-vote-button {
|
||||
margin: 8px 8px 0 12px;
|
||||
margin: 8px 0 0;
|
||||
margin-inline-start: 12px;
|
||||
margin-inline-end: 8px;
|
||||
/* padding-inline: 24px; */
|
||||
min-width: 160px;
|
||||
}
|
||||
|
@ -2061,6 +2090,10 @@ a.card:is(:hover, :focus):visited {
|
|||
margin: 8px 16px;
|
||||
font-size: 90%;
|
||||
user-select: none;
|
||||
|
||||
> button:first-child {
|
||||
margin-inline-start: -8px;
|
||||
}
|
||||
}
|
||||
.poll-option-title {
|
||||
text-shadow: 0 1px var(--bg-color);
|
||||
|
@ -2096,14 +2129,14 @@ a.card:is(:hover, :focus):visited {
|
|||
}
|
||||
.status.large .extra-meta {
|
||||
padding-top: 0;
|
||||
margin-left: calc(-50px - 16px);
|
||||
margin-inline-start: calc(-50px - 16px);
|
||||
}
|
||||
|
||||
/* EMOJI REACTIONS */
|
||||
|
||||
.status.large .emoji-reactions {
|
||||
cursor: default;
|
||||
margin-left: calc(-50px - 16px);
|
||||
margin-inline-start: calc(-50px - 16px);
|
||||
}
|
||||
|
||||
/* ACTIONS */
|
||||
|
@ -2115,7 +2148,7 @@ a.card:is(:hover, :focus):visited {
|
|||
.status.large .actions {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 16px;
|
||||
margin-left: calc(-50px - 16px);
|
||||
margin-inline-start: calc(-50px - 16px);
|
||||
color: var(--text-insignificant-color);
|
||||
border-top: var(--hairline-width) solid var(--outline-color);
|
||||
margin-top: 8px;
|
||||
|
@ -2274,7 +2307,7 @@ a.card:is(:hover, :focus):visited {
|
|||
width: 100%;
|
||||
border: 1px solid var(--outline-color);
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
to bottom var(--forward),
|
||||
var(--bg-faded-color),
|
||||
transparent 160px
|
||||
);
|
||||
|
@ -2292,7 +2325,7 @@ a.card:is(:hover, :focus):visited {
|
|||
display: flex;
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
z-index: 1;
|
||||
|
@ -2302,7 +2335,7 @@ a.card:is(:hover, :focus):visited {
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate3d(0, 6px, 0);
|
||||
transform-origin: right center;
|
||||
transform-origin: var(--forward) center;
|
||||
transition: all 0.15s ease-out 0.3s, border-color 0.3s ease-out;
|
||||
|
||||
.timeline.contextual .replies[data-comments-level='4'] & {
|
||||
|
@ -2381,8 +2414,13 @@ a.card:is(:hover, :focus):visited {
|
|||
}
|
||||
}
|
||||
.timeline.contextual .descendant .status {
|
||||
--bg-gradient-rotation: -140deg;
|
||||
:dir(rtl) & {
|
||||
--bg-gradient-rotation: 140deg;
|
||||
}
|
||||
|
||||
--bg-gradient: linear-gradient(
|
||||
-140deg,
|
||||
var(--bg-gradient-rotation),
|
||||
var(--bg-faded-color),
|
||||
transparent 75%
|
||||
);
|
||||
|
@ -2409,7 +2447,7 @@ a.card:is(:hover, :focus):visited {
|
|||
.status-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
inset-inline-end: 4px;
|
||||
line-height: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
|
@ -2436,8 +2474,21 @@ a.card:is(:hover, :focus):visited {
|
|||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes swoosh-from-left {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-300%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.status-badge > * {
|
||||
animation: swoosh-from-right 1s cubic-bezier(0.51, 0.28, 0.16, 1.26) both;
|
||||
:dir(rtl) & {
|
||||
animation-name: swoosh-from-left;
|
||||
}
|
||||
}
|
||||
.status-badge > *:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
|
@ -2452,7 +2503,8 @@ a.card:is(:hover, :focus):visited {
|
|||
/* MISC */
|
||||
|
||||
.status-aside {
|
||||
padding: 0 16px 16px 80px;
|
||||
padding: 0 16px 16px;
|
||||
padding-inline-start: 80px;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
|
@ -2471,24 +2523,39 @@ a.card:is(:hover, :focus):visited {
|
|||
#edit-history {
|
||||
min-height: 50vh;
|
||||
min-height: 50dvh;
|
||||
}
|
||||
|
||||
#edit-history h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#edit-history ol,
|
||||
#edit-history ol li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ol,
|
||||
ol li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#edit-history .history-item .status {
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
.history-item .status {
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
.invisible {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
.hashtag-stuffing {
|
||||
white-space: normal;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* EMBED */
|
||||
|
@ -2720,7 +2787,7 @@ a.card:is(:hover, :focus):visited {
|
|||
vertical-align: super;
|
||||
font-weight: normal;
|
||||
line-height: 0;
|
||||
padding-left: 2px;
|
||||
padding-inline-start: 2px;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
|||
import { t, Trans } from '@lingui/macro';
|
||||
import { memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
|
@ -13,6 +14,8 @@ import { useSnapshot } from 'valtio';
|
|||
|
||||
import FilterContext from '../utils/filter-context';
|
||||
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 statusPeek from '../utils/status-peek';
|
||||
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||
|
@ -119,6 +122,9 @@ function Timeline({
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
if (firstLoad && !items.length && errorText) {
|
||||
showToast(errorText);
|
||||
}
|
||||
} finally {
|
||||
loadItems.cancel();
|
||||
}
|
||||
|
@ -388,6 +394,17 @@ function Timeline({
|
|||
dotRef.current = node;
|
||||
}}
|
||||
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">
|
||||
<header
|
||||
|
@ -415,7 +432,7 @@ function Timeline({
|
|||
headerStart
|
||||
) : (
|
||||
<Link to="/" class="button plain home-button">
|
||||
<Icon icon="home" size="l" />
|
||||
<Icon icon="home" size="l" alt={t`Home`} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
@ -431,7 +448,7 @@ function Timeline({
|
|||
type="button"
|
||||
onClick={handleLoadNewPosts}
|
||||
>
|
||||
<Icon icon="arrow-up" /> New posts
|
||||
<Icon icon="arrow-up" /> <Trans>New posts</Trans>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
@ -497,11 +514,13 @@ function Timeline({
|
|||
onClick={() => loadItems()}
|
||||
style={{ marginBlockEnd: '6em' }}
|
||||
>
|
||||
Show more…
|
||||
<Trans>Show more…</Trans>
|
||||
</button>
|
||||
</InView>
|
||||
) : (
|
||||
<p class="ui-state insignificant">The end.</p>
|
||||
<p class="ui-state insignificant">
|
||||
<Trans>The end.</Trans>
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
) : uiState === 'loading' ? (
|
||||
|
@ -530,7 +549,7 @@ function Timeline({
|
|||
<br />
|
||||
<br />
|
||||
<button type="button" onClick={() => loadItems(!items.length)}>
|
||||
Try again
|
||||
<Trans>Try again</Trans>
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
@ -561,7 +580,7 @@ const TimelineItem = memo(
|
|||
: `/s/${actualStatusID}`;
|
||||
|
||||
if (items) {
|
||||
const fItems = filteredItems(items, filterContext);
|
||||
let fItems = filteredItems(items, filterContext);
|
||||
let title = '';
|
||||
if (type === 'boosts') {
|
||||
title = `${fItems.length} Boosts`;
|
||||
|
@ -570,6 +589,7 @@ const TimelineItem = memo(
|
|||
}
|
||||
const isCarousel = type === 'boosts' || type === 'pinned';
|
||||
if (isCarousel) {
|
||||
const filteredItemsIDs = new Set();
|
||||
// Here, we don't hide filtered posts, but we sort them last
|
||||
fItems.sort((a, b) => {
|
||||
// if (a._filtered && !b._filtered) {
|
||||
|
@ -580,6 +600,8 @@ const TimelineItem = memo(
|
|||
// }
|
||||
const aFiltered = isFiltered(a.filtered, filterContext);
|
||||
const bFiltered = isFiltered(b.filtered, filterContext);
|
||||
if (aFiltered) filteredItemsIDs.add(a.id);
|
||||
if (bFiltered) filteredItemsIDs.add(b.id);
|
||||
if (aFiltered && !bFiltered) {
|
||||
return 1;
|
||||
}
|
||||
|
@ -588,11 +610,69 @@ const TimelineItem = memo(
|
|||
}
|
||||
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 (
|
||||
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
|
||||
<StatusCarousel title={title} class={`${type}-carousel`}>
|
||||
{fItems.map((item) => {
|
||||
const { id: statusID, reblog, _pinned } = 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 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 url = instance
|
||||
? `/${instance}/s/${actualStatusID}`
|
||||
|
@ -792,13 +872,16 @@ function StatusCarousel({ title, class: className, children }) {
|
|||
class="small plain2"
|
||||
// disabled={reachStart}
|
||||
onClick={() => {
|
||||
const left =
|
||||
Math.min(320, carouselRef.current?.offsetWidth) *
|
||||
(isRTL() ? 1 : -1);
|
||||
carouselRef.current?.scrollBy({
|
||||
left: -Math.min(320, carouselRef.current?.offsetWidth),
|
||||
left,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="chevron-left" />
|
||||
<Icon icon="chevron-left" alt={t`Previous`} />
|
||||
</button>{' '}
|
||||
<button
|
||||
ref={endButtonRef}
|
||||
|
@ -806,13 +889,16 @@ function StatusCarousel({ title, class: className, children }) {
|
|||
class="small plain2"
|
||||
// disabled={reachEnd}
|
||||
onClick={() => {
|
||||
const left =
|
||||
Math.min(320, carouselRef.current?.offsetWidth) *
|
||||
(isRTL() ? -1 : 1);
|
||||
carouselRef.current?.scrollBy({
|
||||
left: Math.min(320, carouselRef.current?.offsetWidth),
|
||||
left,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="chevron-right" />
|
||||
<Icon icon="chevron-right" alt={t`Next`} />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
@ -852,14 +938,14 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
|
|||
>
|
||||
{!!snapStates.statusThreadNumber[sKey] ? (
|
||||
<div class="status-thread-badge">
|
||||
<Icon icon="thread" size="s" />
|
||||
<Icon icon="thread" size="s" alt={t`Thread`} />
|
||||
{snapStates.statusThreadNumber[sKey]
|
||||
? ` ${snapStates.statusThreadNumber[sKey]}/X`
|
||||
: ''}
|
||||
</div>
|
||||
) : (
|
||||
<div class="status-thread-badge">
|
||||
<Icon icon="thread" size="s" />
|
||||
<Icon icon="thread" size="s" alt={t`Thread`} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
@ -873,7 +959,15 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
|
|||
class="status-filtered-badge badge-meta horizontal"
|
||||
title={filterInfo?.titlesStr || ''}
|
||||
>
|
||||
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
|
||||
{filterInfo?.titlesStr ? (
|
||||
<Trans>
|
||||
<span>Filtered</span>: <span>{filterInfo.titlesStr}</span>
|
||||
</Trans>
|
||||
) : (
|
||||
<span>
|
||||
<Trans>Filtered</Trans>
|
||||
</span>
|
||||
)}
|
||||
</b>
|
||||
) : (
|
||||
<>
|
||||
|
@ -882,7 +976,7 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
|
|||
<>
|
||||
{' '}
|
||||
<span class="spoiler-badge">
|
||||
<Icon icon="eye-close" size="s" />
|
||||
<Icon icon="eye-close" size="s" alt={t`Content warning`} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
border-bottom: 0;
|
||||
margin-bottom: -1px;
|
||||
background-image: linear-gradient(
|
||||
to top left,
|
||||
to top var(--backward),
|
||||
var(--bg-color) 50%,
|
||||
var(--bg-faded-blur-color)
|
||||
);
|
||||
|
@ -44,12 +44,13 @@
|
|||
.status-translation-block .translated-block {
|
||||
border: 1px solid var(--outline-color);
|
||||
line-height: 1.3;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
border-radius: 8px;
|
||||
border-start-start-radius: 0;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--bg-color);
|
||||
background-image: linear-gradient(
|
||||
to bottom right,
|
||||
to bottom var(--forward),
|
||||
var(--bg-color),
|
||||
var(--bg-faded-blur-color)
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import './translation-block.css';
|
||||
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import pRetry from 'p-retry';
|
||||
import pThrottle from 'p-throttle';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
@ -148,7 +149,7 @@ function TranslationBlock({
|
|||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
alt={t`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
|
@ -186,12 +187,12 @@ function TranslationBlock({
|
|||
<Icon icon="translate" />{' '}
|
||||
<span>
|
||||
{uiState === 'loading'
|
||||
? 'Translating…'
|
||||
? t`Translating…`
|
||||
: sourceLanguage && sourceLangText && !detectedLang
|
||||
? autoDetected
|
||||
? `Translate from ${sourceLangText} (auto-detected)`
|
||||
: `Translate from ${sourceLangText}`
|
||||
: `Translate`}
|
||||
? t`Translate from ${sourceLangText} (auto-detected)`
|
||||
: t`Translate from ${sourceLangText}`
|
||||
: t`Translate`}
|
||||
</span>
|
||||
</button>
|
||||
</summary>
|
||||
|
@ -205,17 +206,34 @@ function TranslationBlock({
|
|||
translate();
|
||||
}}
|
||||
>
|
||||
{sourceLanguages.map((l) => (
|
||||
<option value={l.code}>
|
||||
{l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name}
|
||||
</option>
|
||||
))}
|
||||
{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}>
|
||||
{l.code === 'auto'
|
||||
? t`Auto (${detectedLang ?? '…'})`
|
||||
: showCommon
|
||||
? `${native} - ${common}`
|
||||
: native}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>{' '}
|
||||
<span>→ {targetLangText}</span>
|
||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||
</div>
|
||||
{uiState === 'error' ? (
|
||||
<p class="ui-state">Failed to translate</p>
|
||||
<p class="ui-state">
|
||||
<Trans>Failed to translate</Trans>
|
||||
</p>
|
||||
) : (
|
||||
!!translatedContent && (
|
||||
<>
|
||||
|
|
|
@ -1,37 +1,48 @@
|
|||
import './index.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 { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
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';
|
||||
|
||||
initActivateLang();
|
||||
|
||||
if (window.opener) {
|
||||
console = window.opener.console;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(null);
|
||||
|
||||
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
|
||||
|
||||
useTitle(
|
||||
editStatus
|
||||
? 'Editing source status'
|
||||
? t`Editing source status`
|
||||
: replyToStatus
|
||||
? `Replying to @${
|
||||
? t`Replying to @${
|
||||
replyToStatus.account?.acct || replyToStatus.account?.username
|
||||
}`
|
||||
: 'Compose',
|
||||
: t`Compose`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initStates();
|
||||
const account = getCurrentAccount();
|
||||
setIsLoggedIn(!!account);
|
||||
if (account) {
|
||||
initStates();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -47,14 +58,16 @@ function App() {
|
|||
if (uiState === 'closed') {
|
||||
return (
|
||||
<div class="box">
|
||||
<p>You may close this page now.</p>
|
||||
<p>
|
||||
<Trans>You may close this page now.</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
>
|
||||
Close window
|
||||
<Trans>Close window</Trans>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -63,25 +76,56 @@ function App() {
|
|||
|
||||
console.debug('OPEN COMPOSE');
|
||||
|
||||
if (isLoggedIn === false) {
|
||||
return (
|
||||
<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}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
hasOpener={window.opener}
|
||||
onClose={(results) => {
|
||||
const { newStatus, fn = () => {} } = results || {};
|
||||
try {
|
||||
if (newStatus) {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
}
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposeSuspense
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
hasOpener={window.opener}
|
||||
onClose={(results) => {
|
||||
const { newStatus, fn = () => {} } = results || {};
|
||||
try {
|
||||
if (newStatus) {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
}
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
<div class="box">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app-standalone'));
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nProvider>,
|
||||
document.getElementById('app-standalone'),
|
||||
);
|
||||
|
|
158
src/data/catalogs.json
Normal file
158
src/data/catalogs.json
Normal file
|
@ -0,0 +1,158 @@
|
|||
[
|
||||
{
|
||||
"code": "ar-SA",
|
||||
"nativeName": "العربية",
|
||||
"name": "Arabic",
|
||||
"completion": 26
|
||||
},
|
||||
{
|
||||
"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": 96
|
||||
},
|
||||
{
|
||||
"code": "eo-UY",
|
||||
"nativeName": "Esperanto",
|
||||
"name": "Esperanto",
|
||||
"completion": 30
|
||||
},
|
||||
{
|
||||
"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": 73
|
||||
},
|
||||
{
|
||||
"code": "fi-FI",
|
||||
"nativeName": "suomi",
|
||||
"name": "Finnish",
|
||||
"completion": 100
|
||||
},
|
||||
{
|
||||
"code": "fr-FR",
|
||||
"nativeName": "français",
|
||||
"name": "French",
|
||||
"completion": 99
|
||||
},
|
||||
{
|
||||
"code": "gl-ES",
|
||||
"nativeName": "galego",
|
||||
"name": "Galician",
|
||||
"completion": 100
|
||||
},
|
||||
{
|
||||
"code": "he-IL",
|
||||
"nativeName": "עברית",
|
||||
"name": "Hebrew",
|
||||
"completion": 12
|
||||
},
|
||||
{
|
||||
"code": "it-IT",
|
||||
"nativeName": "italiano",
|
||||
"name": "Italian",
|
||||
"completion": 34
|
||||
},
|
||||
{
|
||||
"code": "ja-JP",
|
||||
"nativeName": "日本語",
|
||||
"name": "Japanese",
|
||||
"completion": 31
|
||||
},
|
||||
{
|
||||
"code": "kab",
|
||||
"nativeName": "Taqbaylit",
|
||||
"name": "Kabyle",
|
||||
"completion": 100
|
||||
},
|
||||
{
|
||||
"code": "ko-KR",
|
||||
"nativeName": "한국어",
|
||||
"name": "Korean",
|
||||
"completion": 86
|
||||
},
|
||||
{
|
||||
"code": "lt-LT",
|
||||
"nativeName": "lietuvių",
|
||||
"name": "Lithuanian",
|
||||
"completion": 43
|
||||
},
|
||||
{
|
||||
"code": "nl-NL",
|
||||
"nativeName": "Nederlands",
|
||||
"name": "Dutch",
|
||||
"completion": 48
|
||||
},
|
||||
{
|
||||
"code": "pl-PL",
|
||||
"nativeName": "polski",
|
||||
"name": "Polish",
|
||||
"completion": 1
|
||||
},
|
||||
{
|
||||
"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": 3
|
||||
},
|
||||
{
|
||||
"code": "uk-UA",
|
||||
"nativeName": "українська",
|
||||
"name": "Ukrainian",
|
||||
"completion": 26
|
||||
},
|
||||
{
|
||||
"code": "zh-CN",
|
||||
"nativeName": "简体中文",
|
||||
"name": "Simplified Chinese",
|
||||
"completion": 100
|
||||
},
|
||||
{
|
||||
"code": "zh-TW",
|
||||
"nativeName": "繁體中文",
|
||||
"name": "Traditional Chinese",
|
||||
"completion": 14
|
||||
}
|
||||
]
|
|
@ -3,5 +3,6 @@
|
|||
"@mastodon/list-exclusive": ">=4.2",
|
||||
"@mastodon/filtered-notifications": "~4.3 || >=4.3",
|
||||
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
|
||||
"@mastodon/trending-link-posts": "~4.3 || >=4.3"
|
||||
"@mastodon/trending-link-posts": "~4.3 || >=4.3",
|
||||
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
|
||||
}
|
||||
|
|
|
@ -2,383 +2,365 @@
|
|||
"mastodon.social",
|
||||
"mstdn.jp",
|
||||
"mstdn.social",
|
||||
"infosec.exchange",
|
||||
"mas.to",
|
||||
"mastodon.world",
|
||||
"infosec.exchange",
|
||||
"hachyderm.io",
|
||||
"troet.cafe",
|
||||
"mastodon.uno",
|
||||
"m.cmx.im",
|
||||
"troet.cafe",
|
||||
"techhub.social",
|
||||
"piaille.fr",
|
||||
"mastodon.uno",
|
||||
"mastodon.gamedev.place",
|
||||
"mastodonapp.uk",
|
||||
"mastodon.nl",
|
||||
"social.vivaldi.net",
|
||||
"mastodonapp.uk",
|
||||
"universeodon.com",
|
||||
"mastodon.sdf.org",
|
||||
"c.im",
|
||||
"mstdn.ca",
|
||||
"mastodon.nl",
|
||||
"social.tchncs.de",
|
||||
"kolektiva.social",
|
||||
"mastodon-japan.net",
|
||||
"mastodon.sdf.org",
|
||||
"tech.lgbt",
|
||||
"c.im",
|
||||
"norden.social",
|
||||
"o3o.ca",
|
||||
"mstdn.ca",
|
||||
"occm.cc",
|
||||
"mastodon.scot",
|
||||
"sfba.social",
|
||||
"nrw.social",
|
||||
"tech.lgbt",
|
||||
"mastodon.scot",
|
||||
"mstdn.party",
|
||||
"occm.cc",
|
||||
"aus.social",
|
||||
"mathstodon.xyz",
|
||||
"mastodon-japan.net",
|
||||
"mstdn.party",
|
||||
"det.social",
|
||||
"toot.community",
|
||||
"ohai.social",
|
||||
"sueden.social",
|
||||
"mstdn.business",
|
||||
"mastodon.ie",
|
||||
"mastodon.top",
|
||||
"sueden.social",
|
||||
"defcon.social",
|
||||
"masto.es",
|
||||
"mastodontech.de",
|
||||
"mastodon.nu",
|
||||
"masto.es",
|
||||
"freemasonry.social",
|
||||
"ioc.exchange",
|
||||
"mindly.social",
|
||||
"hessen.social",
|
||||
"ruhr.social",
|
||||
"mastodon.au",
|
||||
"nerdculture.de",
|
||||
"muenchen.social",
|
||||
"defcon.social",
|
||||
"social.anoxinon.de",
|
||||
"mastodon.green",
|
||||
"mastouille.fr",
|
||||
"social.linux.pizza",
|
||||
"social.cologne",
|
||||
"muenchen.social",
|
||||
"indieweb.social",
|
||||
"livellosegreto.it",
|
||||
"ruby.social",
|
||||
"ieji.de",
|
||||
"social.linux.pizza",
|
||||
"feuerwehr.social",
|
||||
"social.anoxinon.de",
|
||||
"mastodon.nz",
|
||||
"ruby.social",
|
||||
"livellosegreto.it",
|
||||
"fairy.id",
|
||||
"ieji.de",
|
||||
"toot.io",
|
||||
"tkz.one",
|
||||
"mastouille.fr",
|
||||
"mastodont.cat",
|
||||
"social.tchncs.de",
|
||||
"mastodon.com.tr",
|
||||
"noc.social",
|
||||
"sciences.social",
|
||||
"tkz.one",
|
||||
"toot.wales",
|
||||
"masto.nu",
|
||||
"pouet.chapril.org",
|
||||
"phpc.social",
|
||||
"social.dev-wiki.de",
|
||||
"cyberplace.social",
|
||||
"sciences.social",
|
||||
"noc.social",
|
||||
"mastodon.com.tr",
|
||||
"ravenation.club",
|
||||
"masto.nu",
|
||||
"metalhead.club",
|
||||
"mastodon.ml",
|
||||
"urbanists.social",
|
||||
"mastodontti.fi",
|
||||
"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",
|
||||
"mast.lat",
|
||||
"freiburg.social",
|
||||
"mstdn.plus",
|
||||
"dresden.network",
|
||||
"pol.social",
|
||||
"mastodon.bida.im",
|
||||
"mastodon.eus",
|
||||
"mstdn.games",
|
||||
"snabelen.no",
|
||||
"mastodon.zaclys.com",
|
||||
"muenster.im",
|
||||
"mastodon-belgium.be",
|
||||
"geekdom.social",
|
||||
"hcommons.social",
|
||||
"tooot.im",
|
||||
"tooting.ch",
|
||||
"rheinneckar.social",
|
||||
"discuss.systems",
|
||||
"sunny.garden",
|
||||
"mapstodon.space",
|
||||
"toad.social",
|
||||
"lor.sh",
|
||||
"peoplemaking.games",
|
||||
"union.place",
|
||||
"mastodon.me.uk",
|
||||
"rollenspiel.social",
|
||||
"todon.eu",
|
||||
"bark.lgbt",
|
||||
"bonn.social",
|
||||
"tilde.zone",
|
||||
"vmst.io",
|
||||
"mastodon.berlin",
|
||||
"emacs.ch",
|
||||
"blorbo.social",
|
||||
"hostux.social",
|
||||
"furry.engineer",
|
||||
"rivals.space",
|
||||
"cupoftea.social",
|
||||
"sunny.garden",
|
||||
"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",
|
||||
"graphics.social",
|
||||
"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",
|
||||
"todon.nl",
|
||||
"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",
|
||||
"cr8r.gg",
|
||||
"pnw.zone",
|
||||
"hear-me.social",
|
||||
"furries.club",
|
||||
"witter.cz",
|
||||
"eupolicy.social",
|
||||
"gaygeek.social",
|
||||
"birdon.social",
|
||||
"mastodon.energy",
|
||||
"mastodon-swiss.org",
|
||||
"dizl.de",
|
||||
"libretooth.gr",
|
||||
"mustard.blog",
|
||||
"machteburch.social",
|
||||
"fulda.social",
|
||||
"furries.club",
|
||||
"muri.network",
|
||||
"babka.social",
|
||||
"archaeo.social",
|
||||
"corteximplant.com",
|
||||
"cr8r.gg",
|
||||
"toot.aquilenet.fr",
|
||||
"mastodon.uy",
|
||||
"xarxa.cloud",
|
||||
"corteximplant.com",
|
||||
"mastodon.london",
|
||||
"urusai.social",
|
||||
"thecanadian.social",
|
||||
"federated.press",
|
||||
"pnw.zone",
|
||||
"libretooth.gr",
|
||||
"machteburch.social",
|
||||
"dizl.de",
|
||||
"mustard.blog",
|
||||
"babka.social",
|
||||
"vkl.world",
|
||||
"kanoa.de",
|
||||
"opalstack.social",
|
||||
"bahn.social",
|
||||
"mograph.social",
|
||||
"dmv.community",
|
||||
"social.bau-ha.us",
|
||||
"mastodon.free-solutions.org",
|
||||
"masto.nyc",
|
||||
"tyrol.social",
|
||||
"burma.social",
|
||||
"toot.kif.rocks",
|
||||
"donphan.social",
|
||||
"mast.hpc.social",
|
||||
"musicians.today",
|
||||
"drupal.community",
|
||||
"hometech.social",
|
||||
"norcal.social",
|
||||
"social.politicaconciencia.org",
|
||||
"social.seattle.wa.us",
|
||||
"is.nota.live",
|
||||
"genealysis.social",
|
||||
"wargamers.social",
|
||||
"guitar.rodeo",
|
||||
"bookstodon.com",
|
||||
"mstdn.dk",
|
||||
"elizur.me",
|
||||
"irsoluciones.social",
|
||||
"h-net.social",
|
||||
"mastoot.fr",
|
||||
"qaf.men",
|
||||
"est.social",
|
||||
"kurry.social",
|
||||
"mastodon.pnpde.social",
|
||||
"ani.work",
|
||||
"nederland.online",
|
||||
"epicure.social",
|
||||
"occitania.social",
|
||||
"lgbt.io",
|
||||
"fulda.social",
|
||||
"archaeo.social",
|
||||
"spojnik.works",
|
||||
"dmv.community",
|
||||
"bookstodon.com",
|
||||
"mastodon.energy",
|
||||
"thecanadian.social",
|
||||
"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",
|
||||
"persiansmastodon.com",
|
||||
"seocommunity.social",
|
||||
"cyberfurz.social",
|
||||
"fedi.at",
|
||||
"federated.press",
|
||||
"mstdn.dk",
|
||||
"mast.hpc.social",
|
||||
"social.seattle.wa.us",
|
||||
"mastodon.pnpde.social",
|
||||
"norcal.social",
|
||||
"hometech.social",
|
||||
"is.nota.live",
|
||||
"ani.work",
|
||||
"tyrol.social",
|
||||
"gamepad.club",
|
||||
"augsburg.social",
|
||||
"mastodon.education",
|
||||
"toot.re",
|
||||
"linux.social",
|
||||
"neovibe.app",
|
||||
"wargamers.social",
|
||||
"social.politicaconciencia.org",
|
||||
"mastodon.com.pl",
|
||||
"mastodon.london",
|
||||
"musician.social",
|
||||
"esq.social",
|
||||
"social.veraciousnetwork.com",
|
||||
"datasci.social",
|
||||
"tooters.org",
|
||||
"ciberlandia.pt",
|
||||
"cloud-native.social",
|
||||
"social.silicon.moe",
|
||||
"epicure.social",
|
||||
"genealysis.social",
|
||||
"cosocial.ca",
|
||||
"arvr.social",
|
||||
"hispagatos.space",
|
||||
"friendsofdesoto.social",
|
||||
"mastoot.fr",
|
||||
"toot.si",
|
||||
"kurry.social",
|
||||
"esq.social",
|
||||
"est.social",
|
||||
"bahn.social",
|
||||
"musicworld.social",
|
||||
"aut.social",
|
||||
"masto.yttrx.com",
|
||||
"mastodon.wien",
|
||||
"colorid.es",
|
||||
"arsenalfc.social",
|
||||
"allthingstech.social",
|
||||
"mastodon.vlaanderen",
|
||||
"mastodon.com.py",
|
||||
"mastodon.mnetwork.co.kr",
|
||||
"lgbt.io",
|
||||
"h-net.social",
|
||||
"social.silicon.moe",
|
||||
"tooter.social",
|
||||
"lounge.town",
|
||||
"puntarella.party",
|
||||
"earthstream.social",
|
||||
"apobangpo.space",
|
||||
"opencoaster.net",
|
||||
"fedi.at",
|
||||
"frikiverse.zone",
|
||||
"airwaves.social",
|
||||
"toot.garden",
|
||||
"lewacki.space",
|
||||
"gardenstate.social",
|
||||
"datasci.social",
|
||||
"augsburg.social",
|
||||
"opencoaster.net",
|
||||
"hispagatos.space",
|
||||
"neovibe.app",
|
||||
"friendsofdesoto.social",
|
||||
"elekk.xyz",
|
||||
"cyberfurz.social",
|
||||
"guitar.rodeo",
|
||||
"khiar.net",
|
||||
"seocommunity.social",
|
||||
"theatl.social",
|
||||
"maly.io",
|
||||
"library.love",
|
||||
"kfem.cat",
|
||||
"ruhrpott.social",
|
||||
"techtoots.com",
|
||||
"furry.energy",
|
||||
"mastodon.pirateparty.be",
|
||||
"metalverse.social",
|
||||
"colorid.es",
|
||||
"puntarella.party",
|
||||
"aut.social",
|
||||
"toot.garden",
|
||||
"apobangpo.space",
|
||||
"mastodon.vlaanderen",
|
||||
"gardenstate.social",
|
||||
"opalstack.social",
|
||||
"mastodon.education",
|
||||
"occitania.social",
|
||||
"earthstream.social",
|
||||
"indieauthors.social",
|
||||
"tuiter.rocks",
|
||||
"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",
|
||||
"fikaverse.club",
|
||||
"gametoots.de",
|
||||
"mastodon.cr",
|
||||
"hoosier.social",
|
||||
"khiar.net",
|
||||
"seo.chat",
|
||||
"drumstodon.net",
|
||||
"raphus.social",
|
||||
"toots.nu",
|
||||
"k8s.social",
|
||||
"mastodon.holeyfox.co",
|
||||
"fribygda.no",
|
||||
"jvm.social",
|
||||
"rail.chat",
|
||||
"mastodon-swiss.org",
|
||||
"elizur.me",
|
||||
"metalverse.social",
|
||||
"x0r.be",
|
||||
"fpl.social",
|
||||
"toot.pizza",
|
||||
"mastodon.cipherbliss.com",
|
||||
"burningboard.net",
|
||||
"library.love",
|
||||
"drumstodon.net",
|
||||
"mastodon.sg",
|
||||
"rheinhessen.social",
|
||||
"synapse.cafe",
|
||||
"fribygda.no",
|
||||
"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",
|
||||
"bologna.one",
|
||||
"mastodon.sg",
|
||||
"tchafia.be",
|
||||
"rail.chat",
|
||||
"mastodon.hosnet.fr",
|
||||
"leipzig.town",
|
||||
"wayne.social",
|
||||
"rheinhessen.social",
|
||||
"rap.social",
|
||||
"cwb.social",
|
||||
"mastodon.bachgau.social",
|
||||
"cville.online",
|
||||
"bzh.social",
|
||||
"mastodon.escepticos.es",
|
||||
"zenzone.social",
|
||||
"mastodon.ee",
|
||||
"lsbt.me",
|
||||
"neurodiversity-in.au",
|
||||
"fairmove.net",
|
||||
"stereodon.social",
|
||||
"mcr.wtf",
|
||||
"mastodon.frl",
|
||||
"mikumikudance.cloud",
|
||||
"okla.social",
|
||||
"camp.smolnet.org",
|
||||
"ailbhean.co-shaoghal.net",
|
||||
"clj.social",
|
||||
"tu.social",
|
||||
"nomanssky.social",
|
||||
"mastodon.iow.social",
|
||||
"vermont.masto.host",
|
||||
"squawk.mytransponder.com",
|
||||
"freemasonry.social",
|
||||
"frontrange.co",
|
||||
"episcodon.net",
|
||||
"devianze.city",
|
||||
"paktodon.asia",
|
||||
"travelpandas.fr",
|
||||
"silversword.online",
|
||||
"nwb.social",
|
||||
"skastodon.com",
|
||||
"kcmo.social",
|
||||
"balkan.fedive.rs",
|
||||
"openedtech.social",
|
||||
"mastodon.ph",
|
||||
"enshittification.social",
|
||||
"spojnik.works",
|
||||
"mastodon.conquestuniverse.com",
|
||||
"nutmeg.social",
|
||||
"social.sndevs.com",
|
||||
"social.diva.exchange",
|
||||
"tchafia.be",
|
||||
"k8s.social",
|
||||
"planetearth.social",
|
||||
"tu.social",
|
||||
"growers.social",
|
||||
"pdx.sh",
|
||||
"nfld.me",
|
||||
"cartersville.social",
|
||||
"voi.social",
|
||||
"mastodon.babb.no",
|
||||
"kzoo.to",
|
||||
"mastodon.vanlife.is",
|
||||
"toot.works",
|
||||
"sanjuans.life",
|
||||
"dariox.club",
|
||||
"toots.nu",
|
||||
"clj.social",
|
||||
"paktodon.asia",
|
||||
"devianze.city",
|
||||
"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",
|
||||
"pool.social",
|
||||
"polsci.social",
|
||||
"mastodon.mg",
|
||||
"23.illuminati.org",
|
||||
"apotheke.social",
|
||||
"jaxbeach.social",
|
||||
"ceilidh.online",
|
||||
"netsphere.one",
|
||||
"neurodiversity-in.au",
|
||||
"biplus.social",
|
||||
"bvb.social",
|
||||
"mastodon.mg",
|
||||
"mastodon.vanlife.is",
|
||||
"ms.maritime.social",
|
||||
"darticulate.com",
|
||||
"bvb.social",
|
||||
"netsphere.one",
|
||||
"ceilidh.online",
|
||||
"persia.social",
|
||||
"streamerchat.social",
|
||||
"troet.fediverse.at",
|
||||
"jaxbeach.social",
|
||||
"publishing.social",
|
||||
"finsup.social",
|
||||
"wayne.social",
|
||||
"troet.fediverse.at",
|
||||
"kjas.no",
|
||||
"wxw.moe",
|
||||
"learningdisability.social",
|
||||
"mastodon.bida.im",
|
||||
"darticulate.com",
|
||||
"computerfairi.es",
|
||||
"learningdisability.social",
|
||||
"wxw.moe",
|
||||
"tea.codes"
|
||||
]
|
|
@ -534,6 +534,11 @@
|
|||
"Malay",
|
||||
"Bahasa Melayu"
|
||||
],
|
||||
[
|
||||
"ms-Arab",
|
||||
"Jawi Malay",
|
||||
"بهاس ملايو"
|
||||
],
|
||||
[
|
||||
"mt",
|
||||
"Maltese",
|
||||
|
@ -626,7 +631,7 @@
|
|||
],
|
||||
[
|
||||
"pa",
|
||||
"Panjabi",
|
||||
"Punjabi",
|
||||
"ਪੰਜਾਬੀ"
|
||||
],
|
||||
[
|
||||
|
@ -949,6 +954,11 @@
|
|||
"Montenegrin",
|
||||
"crnogorski"
|
||||
],
|
||||
[
|
||||
"csb",
|
||||
"Kashubian",
|
||||
"Kaszëbsczi"
|
||||
],
|
||||
[
|
||||
"jbo",
|
||||
"Lojban",
|
||||
|
@ -969,6 +979,21 @@
|
|||
"Lingua Franca Nova",
|
||||
"lingua franca nova"
|
||||
],
|
||||
[
|
||||
"moh",
|
||||
"Mohawk",
|
||||
"Kanienʼkéha"
|
||||
],
|
||||
[
|
||||
"nds",
|
||||
"Low German",
|
||||
"Plattdüütsch"
|
||||
],
|
||||
[
|
||||
"pdc",
|
||||
"Pennsylvania Dutch",
|
||||
"Pennsilfaani-Deitsch"
|
||||
],
|
||||
[
|
||||
"sco",
|
||||
"Scots",
|
||||
|
@ -994,6 +1019,11 @@
|
|||
"Toki Pona",
|
||||
"toki pona"
|
||||
],
|
||||
[
|
||||
"vai",
|
||||
"Vai",
|
||||
"ꕙꔤ"
|
||||
],
|
||||
[
|
||||
"xal",
|
||||
"Kalmyk",
|
||||
|
|
|
@ -84,6 +84,7 @@
|
|||
var(--text-color) 60%
|
||||
);
|
||||
--outline-color: rgba(128, 128, 128, 0.2);
|
||||
--outline-stronger-color: rgba(128, 128, 128, 0.4);
|
||||
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
||||
--divider-color: rgba(0, 0, 0, 0.1);
|
||||
--backdrop-color: rgba(0, 0, 0, 0.1);
|
||||
|
@ -110,6 +111,17 @@
|
|||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
--min-dimension: 88px;
|
||||
|
||||
--forward: right;
|
||||
--backward: left;
|
||||
--to-forward: to right;
|
||||
--to-backward: to left;
|
||||
&:dir(rtl) {
|
||||
--forward: left;
|
||||
--backward: right;
|
||||
--to-forward: to left;
|
||||
--to-backward: to right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
|
@ -138,6 +150,11 @@
|
|||
mediumslateblue 70%,
|
||||
var(--text-color) 30%
|
||||
);
|
||||
--button-bg-color: color-mix(
|
||||
in srgb,
|
||||
var(--blue-color) 80%,
|
||||
var(--bg-color) 20%
|
||||
);
|
||||
--reblog-faded-color: #b190f141;
|
||||
--reply-to-text-color: var(--reply-to-color);
|
||||
--reply-to-faded-color: #ffa60017;
|
||||
|
@ -378,11 +395,17 @@ textarea:disabled {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
button.small {
|
||||
:is(button, .button, select).small {
|
||||
font-size: 90%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.button.centered {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
select.plain {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
|
@ -436,6 +459,11 @@ kbd {
|
|||
display: initial;
|
||||
}
|
||||
|
||||
.bidi-isolate {
|
||||
direction: initial;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* KEYFRAMES */
|
||||
|
||||
@keyframes appear {
|
||||
|
|
26
src/locales.js
Normal file
26
src/locales.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import catalogs from './data/catalogs.json';
|
||||
|
||||
export const DEFAULT_LANG = 'en';
|
||||
export const CATALOGS = catalogs;
|
||||
|
||||
// Get locales that's >= X% translated
|
||||
const PERCENTAGE_THRESHOLD = 50;
|
||||
|
||||
const locales = [
|
||||
DEFAULT_LANG,
|
||||
...catalogs
|
||||
.filter(({ completion }) => completion >= PERCENTAGE_THRESHOLD)
|
||||
.map(({ code }) => code),
|
||||
];
|
||||
export const LOCALES = locales;
|
||||
|
||||
let devLocales = [];
|
||||
if (import.meta.env?.DEV || import.meta.env?.PHANPY_SHOW_DEV_LOCALES) {
|
||||
devLocales = catalogs
|
||||
.filter(({ completion }) => completion < PERCENTAGE_THRESHOLD)
|
||||
.map(({ code }) => code);
|
||||
devLocales.push('pseudo-LOCALE');
|
||||
}
|
||||
export const DEV_LOCALES = devLocales;
|
||||
|
||||
export const ALL_LOCALES = [...locales, ...devLocales];
|
3732
src/locales/ar-SA.po
Normal file
3732
src/locales/ar-SA.po
Normal file
File diff suppressed because it is too large
Load diff
3734
src/locales/ca-ES.po
Normal file
3734
src/locales/ca-ES.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/cs-CZ.po
Normal file
3732
src/locales/cs-CZ.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/de-DE.po
Normal file
3732
src/locales/de-DE.po
Normal file
File diff suppressed because it is too large
Load diff
3726
src/locales/en.po
Normal file
3726
src/locales/en.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/eo-UY.po
Normal file
3732
src/locales/eo-UY.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/es-ES.po
Normal file
3732
src/locales/es-ES.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/eu-ES.po
Normal file
3732
src/locales/eu-ES.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/fa-IR.po
Normal file
3732
src/locales/fa-IR.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/fi-FI.po
Normal file
3732
src/locales/fi-FI.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/fr-FR.po
Normal file
3732
src/locales/fr-FR.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/gl-ES.po
Normal file
3732
src/locales/gl-ES.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/he-IL.po
Normal file
3732
src/locales/he-IL.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/it-IT.po
Normal file
3732
src/locales/it-IT.po
Normal file
File diff suppressed because it is too large
Load diff
3733
src/locales/ja-JP.po
Normal file
3733
src/locales/ja-JP.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/kab.po
Normal file
3732
src/locales/kab.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/ko-KR.po
Normal file
3732
src/locales/ko-KR.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/lt-LT.po
Normal file
3732
src/locales/lt-LT.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/nl-NL.po
Normal file
3732
src/locales/nl-NL.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/oc-FR.po
Normal file
3732
src/locales/oc-FR.po
Normal file
File diff suppressed because it is too large
Load diff
3732
src/locales/pl-PL.po
Normal file
3732
src/locales/pl-PL.po
Normal file
File diff suppressed because it is too large
Load diff
3633
src/locales/pseudo-LOCALE.po
Normal file
3633
src/locales/pseudo-LOCALE.po
Normal file
File diff suppressed because it is too large
Load diff
3733
src/locales/pt-BR.po
Normal file
3733
src/locales/pt-BR.po
Normal file
File diff suppressed because it is too large
Load diff
3733
src/locales/pt-PT.po
Normal file
3733
src/locales/pt-PT.po
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue