Merge remote-tracking branch 'origin/main' into feature/akkoma-local-only

# Conflicts:
#	src/locales/en.po
This commit is contained in:
Stefano Pigozzi 2024-09-05 15:46:56 +02:00
commit 7f06187d75
No known key found for this signature in database
GPG key ID: 5ADA3868646C3FC0
74 changed files with 28158 additions and 14722 deletions

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

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

View file

@ -3,6 +3,8 @@ name: i18n PR auto-merge
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened, labeled] types: [opened, synchronize, reopened, labeled]
branches:
- main
jobs: jobs:
run-and-merge: run-and-merge:
@ -10,14 +12,21 @@ jobs:
github.event.pull_request.base.ref == 'main' && github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.head.ref == 'l10n_main' github.event.pull_request.head.ref == 'l10n_main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# concurrency:
# group: ${{ github.workflow }}-${{ github.ref }}
# cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 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 - name: Check auto-merge conditions
run: | run: |
BASE_SHA="${{ github.event.pull_request.base.sha }}" BASE_SHA="${{ github.event.pull_request.base.sha }}"
@ -47,8 +56,16 @@ jobs:
exit 0 exit 0
else else
echo "More than 50 lines have been changed. Merging pull request." 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 }}) PR_NUMBER=$(echo ${{ github.event.pull_request.number }})
gh pr merge $PR_NUMBER --auto --squash || true gh pr merge $PR_NUMBER --author "github-actions[bot]@users.noreply.github.com" --squash --subject "$SUBJECT" || true
fi fi
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,34 @@
name: Update README with list of i18n volunteers
on:
schedule:
# Every week
- cron: '0 0 * * 0'
workflow_dispatch:
jobs:
update-readme:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: |
npm run fetch-i18n-volunteers
npm run readme:i18n-volunteers
# Commit & push if there are changes
if git diff --quiet README.md; then
echo "No changes to README.md"
else
echo "Changes to README.md"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git add README.md
git commit -m "Update README.md"
git push
fi
env:
CROWDIN_ACCESS_TOKEN: ${{ secrets.CROWDIN_ACCESS_TOKEN }}

View file

@ -1,17 +1,18 @@
name: Update catalogs name: Update Catalogs
on: on:
pull_request_target: push:
types: branches:
- closed - l10n_main
workflow_dispatch: workflow_dispatch:
jobs: jobs:
update-catalogs: update-catalogs:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: l10n_main
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
@ -27,5 +28,5 @@ jobs:
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git add src/data/catalogs.json git add src/data/catalogs.json
git commit -m "Update catalogs.json" git commit -m "Update catalogs.json"
git push origin HEAD:main git push origin HEAD:l10n_main || true
fi fi

View file

@ -292,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) [![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 ## Backstory
I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006. I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006.

345
i18n-volunteers.json Normal file
View file

@ -0,0 +1,345 @@
[
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12571163/medium/9f3ea938f4243f5ffe2a43f814ddc9e8_default.png",
"username": "alidsds11",
"languages": [
"Arabic"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13170041/medium/603136896af17fc005fd592ce3f48717_default.png",
"username": "BoFFire",
"languages": [
"Arabic",
"French",
"Kabyle"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12898464/medium/d3758a76b894bade4bf271c9b32ea69b.png",
"username": "Brawaru",
"languages": [
"Russian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15460040/medium/1cfcfe5f5511b783b5d9f2b968bad819.png",
"username": "cbasje",
"languages": [
"Dutch"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15525631/medium/51293156034d0236f1a1020c10f7d539_default.png",
"username": "cbo92",
"languages": [
"French"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15910131/medium/67fab7eeab5551853450e76e2ef19e59.jpeg",
"username": "CDN",
"languages": [
"Chinese Simplified"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16556801/medium/ed5e501ca1f3cc6525d2da28db646346.jpeg",
"username": "dannypsnl",
"languages": [
"Chinese Traditional"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/3711/medium/d95ddd44e8dcb3a039f8a3463aed781d_default.png",
"username": "databio",
"languages": [
"Catalan"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12618120/medium/ccb11bd042bbf4c7189033f7af2dbd32_default.png",
"username": "drydenwu",
"languages": [
"Chinese Traditional"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13557465/medium/8feebf3677fa80c01e8c54c4fbe097e0_default.png",
"username": "elissarc",
"languages": [
"French"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16528627/medium/9036f6eced0257f4e1ea4c5bd499de2d_default.png",
"username": "ElPamplina",
"languages": [
"Spanish"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14277386/medium/29b30d2c73a214000e3941c9978f49e4_default.png",
"username": "Fitik",
"languages": [
"Esperanto",
"Hebrew"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14444512/medium/99d0e7a3076deccbdfe0aa0b0612308c.jpeg",
"username": "Freeesia",
"languages": [
"Japanese"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12617257/medium/a201650da44fed28890b0e0d8477a663.jpg",
"username": "ghose",
"languages": [
"Galician"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15248754/medium/0dac6334ea0f4e8d4194a605c0a5594a.jpeg",
"username": "hongminhee",
"languages": [
"Korean"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13454728/medium/1f78b7124b3c962bc4ae55e8d701fc91_default.png",
"username": "isard",
"languages": [
"Catalan"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532403/medium/4cefb19623bcc44d7cdb9e25aebf5250.jpeg",
"username": "karlafej",
"languages": [
"Czech"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15791971/medium/88bdda3090339f16f6083390d32bb434_default.png",
"username": "katullo11",
"languages": [
"Italian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14677260/medium/e53420d200961f48602324e18c091bdc.png",
"username": "Kytta",
"languages": [
"German"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16529521/medium/ae6add93a901b0fefa2d9b1077920d73.png",
"username": "llun",
"languages": [
"Thai"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16291756/medium/e1c4210f15537394cc764b8bc2dffe37.jpg",
"username": "lucasofchirst",
"languages": [
"Occitan",
"Portuguese",
"Portuguese, Brazilian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16537713/medium/825f0bf1a14fc545a76891a52839d86e_default.png",
"username": "marcin.kozinski",
"languages": [
"Polish"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/12882812/medium/77744d8db46e9a3e09030e1a02b7a572.jpeg",
"username": "mojosoeun",
"languages": [
"Korean"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13613969/medium/c7834ddc0ada84a79671697a944bb274.png",
"username": "moreal",
"languages": [
"Korean"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14158861/medium/ba1ff31dc5743b067ea6685f735229a5_default.png",
"username": "MrWillCom",
"languages": [
"Chinese Simplified"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15652333/medium/7f36f289f9e2fe41d89ad534a1047f0e.png",
"username": "nclm",
"languages": [
"French"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16539461/medium/2f41b9f0b802c1d200a6ab62167a7229_default.png",
"username": "pazpi",
"languages": [
"Italian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15106977/medium/54bf93b19af8bbfdee579ea51685bafa.jpeg",
"username": "punkrockgirl",
"languages": [
"Basque"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16536247/medium/f010c8e718a36229733a8b58f6bad2a4_default.png",
"username": "radecos",
"languages": [
"French"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16538917/medium/092ec03f56f9dd1cbce94379fa4d4d38.png",
"username": "Razem",
"languages": [
"Czech"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14345134/medium/89a299239890c79a1d791d08ec3951dc.png",
"username": "realpixelcode",
"languages": [
"German"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16527325/medium/37ebb27e7a50f7f85ae93beafc7028a2.jpg",
"username": "rezahosseinzadeh",
"languages": [
"Persian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/13422319/medium/66632a98d73d48e36753d94ebcec9d4f.png",
"username": "rwmpelstilzchen",
"languages": [
"Esperanto",
"Hebrew"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16538605/medium/bcdb6e3286b7d6237923f3a9383eed29.png",
"username": "SadmL",
"languages": [
"Russian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14565190/medium/79100599131b7776e9803e4b696915a3_default.png",
"username": "Sky_NiniKo",
"languages": [
"French"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532441/medium/1a47e8d80c95636e02d2260f6e233ca5.png",
"username": "Su5hicz",
"languages": [
"Czech"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16533843/medium/7314c15492ef90118c33a80a427e6c87_default.png",
"username": "Talos00",
"languages": [
"Italian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16530049/medium/683f3581620c6b4a5c753b416ed695a7.jpeg",
"username": "tferrermo",
"languages": [
"Spanish"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16527851/medium/649e5a9a8a8cc61ced670d89e9cca082.png",
"username": "tux93",
"languages": [
"German"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16529833/medium/2991a65722acd721849656223014cd49.png",
"username": "Urbestro",
"languages": [
"Esperanto",
"Spanish"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16539171/medium/db6fb87481026c72b895adfb94e17d2c_default.png",
"username": "UsualUsername",
"languages": [
"Russian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14427566/medium/ab733b5044c21867fc5a9d1b22cd2c03.png",
"username": "Vac31.",
"languages": [
"Lithuanian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16026914/medium/e3ca187f354a298ef0c9d02a0ed17be7.jpg",
"username": "valtlai",
"languages": [
"Finnish"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/15982109/medium/9c03062bdc1d3c6d384dbfead97c26ba.jpeg",
"username": "xabi_itzultzaile",
"languages": [
"Basque"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16556017/medium/216e0f7a0c35b079920366939a3aaca7_default.png",
"username": "xen4n",
"languages": [
"Ukrainian"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16532657/medium/f309f319266e1ff95f3070eab0c9a9d9_default.png",
"username": "xqueralt",
"languages": [
"Catalan"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/14041603/medium/6ab77a0467b06aeb49927c6d9c409f89.jpg",
"username": "ZiriSut",
"languages": [
"Kabyle"
]
},
{
"avatarUrl": "https://crowdin-static.downloads.crowdin.com/avatar/16530601/medium/e1b6d5c24953b6405405c1ab33c0fa46.jpeg",
"username": "zkreml",
"languages": [
"Czech"
]
}
]

View file

@ -1,7 +1,8 @@
import { LOCALES } from './src/locales'; import { ALL_LOCALES } from './src/locales';
const config = { const config = {
locales: LOCALES, locales: ALL_LOCALES,
sourceLocale: 'en',
pseudoLocale: 'pseudo-LOCALE', pseudoLocale: 'pseudo-LOCALE',
fallbackLocales: { fallbackLocales: {
default: 'en', default: 'en',

545
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,9 @@
"bundle-visualizer": "npx vite-bundle-visualizer", "bundle-visualizer": "npx vite-bundle-visualizer",
"messages:extract": "lingui extract", "messages:extract": "lingui extract",
"messages:extract:clean": "lingui extract --locale en --clean", "messages:extract:clean": "lingui extract --locale en --clean",
"messages:compile": "lingui compile" "messages:compile": "lingui compile",
"fetch-i18n-volunteers": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-i18n-volunteers.js",
"readme:i18n-volunteers": "node scripts/update-i18n-volunteers-readme.js"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.5.4", "@formatjs/intl-localematcher": "~0.5.4",
@ -20,9 +22,9 @@
"@github/text-expander-element": "~2.7.1", "@github/text-expander-element": "~2.7.1",
"@iconify-icons/mingcute": "~1.2.9", "@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
"@lingui/detect-locale": "~4.11.3", "@lingui/detect-locale": "~4.11.4",
"@lingui/macro": "~4.11.3", "@lingui/macro": "~4.11.4",
"@lingui/react": "~4.11.3", "@lingui/react": "~4.11.4",
"@szhsin/react-menu": "~4.2.2", "@szhsin/react-menu": "~4.2.2",
"compare-versions": "~6.1.1", "compare-versions": "~6.1.1",
"fast-blurhash": "~1.1.4", "fast-blurhash": "~1.1.4",
@ -31,6 +33,7 @@
"html-prettify": "~1.0.7", "html-prettify": "~1.0.7",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"intl-locale-textinfo-polyfill": "~2.1.1", "intl-locale-textinfo-polyfill": "~2.1.1",
"js-cookie": "~3.0.5",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "lz-string": "~1.5.0",
"masto": "~6.8.0", "masto": "~6.8.0",
@ -39,7 +42,7 @@
"p-throttle": "~6.2.0", "p-throttle": "~6.2.0",
"preact": "~10.23.2", "preact": "~10.23.2",
"punycode": "~2.3.1", "punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0", "react-hotkeys-hook": "~4.5.1",
"react-intersection-observer": "~9.13.0", "react-intersection-observer": "~9.13.0",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2", "react-router-dom": "6.6.2",
@ -48,25 +51,25 @@
"tinyld": "~1.3.4", "tinyld": "~1.3.4",
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.2", "uid": "~2.0.2",
"use-debounce": "~10.0.2", "use-debounce": "~10.0.3",
"use-long-press": "~3.2.0", "use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.13.2" "valtio": "2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "~4.3.1", "@ianvs/prettier-plugin-sort-imports": "~4.3.1",
"@lingui/cli": "~4.11.3", "@lingui/cli": "~4.11.4",
"@lingui/vite-plugin": "~4.11.3", "@lingui/vite-plugin": "~4.11.4",
"@preact/preset-vite": "~2.9.0", "@preact/preset-vite": "~2.9.0",
"babel-plugin-macros": "~3.1.0", "babel-plugin-macros": "~3.1.0",
"postcss": "~8.4.41", "postcss": "~8.4.45",
"postcss-dark-theme-class": "~1.3.0", "postcss-dark-theme-class": "~1.3.0",
"postcss-preset-env": "~10.0.1", "postcss-preset-env": "~10.0.2",
"twitter-text": "~3.1.0", "twitter-text": "~3.1.0",
"vite": "~5.4.1", "vite": "~5.4.3",
"vite-plugin-generate-file": "~0.2.0", "vite-plugin-generate-file": "~0.2.0",
"vite-plugin-html-config": "~1.0.11", "vite-plugin-html-config": "~2.0.2",
"vite-plugin-pwa": "~0.20.1", "vite-plugin-pwa": "~0.20.3",
"vite-plugin-remove-console": "~2.2.0", "vite-plugin-remove-console": "~2.2.0",
"vite-plugin-run": "~0.5.2", "vite-plugin-run": "~0.5.2",
"workbox-cacheable-response": "~7.1.0", "workbox-cacheable-response": "~7.1.0",

View file

@ -73,18 +73,21 @@ function IDN(inputCode, outputCode) {
return result; return result;
} }
// Sort by percentage const fullCatalogs = Object.entries(catalogs)
const sortedCatalogs = Object.entries(catalogs) // sort by key
.sort((a, b) => b[1] - a[1]) .sort((a, b) => a[0].localeCompare(b[0]))
.map(([code, completion]) => { .map(([code, completion]) => {
const nativeName = IDN(code, code); const nativeName = IDN(code, code);
const name = IDN('en', code); const name = IDN('en', code);
// let names = {};
return { code, nativeName, name, completion }; return { code, nativeName, name, completion };
}); });
// Sort by completion
const sortedCatalogs = [...fullCatalogs].sort(
(a, b) => b.completion - a.completion,
);
console.table(sortedCatalogs); console.table(sortedCatalogs);
const path = 'src/data/catalogs.json'; const path = 'src/data/catalogs.json';
fs.writeFileSync(path, JSON.stringify(sortedCatalogs, null, 2)); fs.writeFileSync(path, JSON.stringify(fullCatalogs, null, 2));
console.log('File written:', path); console.log('File written:', path);

View file

@ -0,0 +1,131 @@
import fs from 'fs';
const { CROWDIN_ACCESS_TOKEN } = process.env;
const PROJECT_ID = '703337';
if (!CROWDIN_ACCESS_TOKEN) {
throw new Error('CROWDIN_ACCESS_TOKEN is not set');
}
// Generate Report
let REPORT_ID = null;
{
const response = await fetch(
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports`,
{
headers: {
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
name: 'top-members',
schema: {
format: 'json',
},
}),
},
);
const json = await response.json();
console.log(`Report ID: ${json?.data?.identifier}`);
REPORT_ID = json?.data?.identifier;
}
if (!REPORT_ID) {
throw new Error('Report ID is not found');
}
// Check Report Generation Status
let finished = false;
{
let maxPolls = 10;
do {
maxPolls--;
if (maxPolls < 0) break;
// Wait for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
const status = await fetch(
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports/${REPORT_ID}`,
{
headers: {
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
},
);
const json = await status.json();
const progress = json?.data?.progress;
console.log(`Progress: ${progress}% (${maxPolls} retries left)`);
finished = json?.data?.status === 'finished';
} while (!finished);
}
if (!finished) {
throw new Error('Failed to generate report');
}
// Download Report
let reportURL = null;
{
const response = await fetch(
`https://api.crowdin.com/api/v2/projects/${PROJECT_ID}/reports/${REPORT_ID}/download`,
{
headers: {
Authorization: `Bearer ${CROWDIN_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
},
);
const json = await response.json();
reportURL = json?.data?.url;
console.log(`Report URL: ${reportURL}`);
}
if (!reportURL) {
throw new Error('Report URL is not found');
}
// Actually download the report
let members = null;
{
const response = await fetch(reportURL);
const json = await response.json();
const { data } = json;
if (!data?.length) {
throw new Error('No data found');
}
// Sort by 'user.fullName'
data.sort((a, b) => a.user.username.localeCompare(b.user.username));
members = data
.filter((item) => {
const isMyself = item.user.username === 'cheeaun';
const translatedMoreThanZero = item.translated > 0;
return !isMyself && translatedMoreThanZero;
})
.map((item) => ({
avatarUrl: item.user.avatarUrl,
username: item.user.username,
languages: item.languages.map((lang) => lang.name),
}));
console.log(members);
if (members?.length) {
fs.writeFileSync(
'i18n-volunteers.json',
JSON.stringify(members, null, '\t'),
);
}
}
if (!members?.length) {
throw new Error('No members found');
}

View file

@ -0,0 +1,27 @@
// Find for <!-- i18n volunteers start --><!-- i18n volunteers end --> and inject list of i18n volunteers in between
import fs from 'fs';
const i18nVolunteers = JSON.parse(fs.readFileSync('i18n-volunteers.json'));
const readme = fs.readFileSync('README.md', 'utf8');
const i18nVolunteersStart = '<!-- i18n volunteers start -->';
const i18nVolunteersEnd = '<!-- i18n volunteers end -->';
const i18nVolunteersList = i18nVolunteers
.map((member) => {
return `- <img src="${member.avatarUrl}" alt="" width="16" height="16" /> ${
member.username
} (${member.languages.join(', ')})`;
})
.join('\n');
const readmeUpdated = readme.replace(
new RegExp(`${i18nVolunteersStart}.*${i18nVolunteersEnd}`, 's'),
`${i18nVolunteersStart}\n${i18nVolunteersList}\n${i18nVolunteersEnd}`,
);
fs.writeFileSync('README.md', readmeUpdated);
console.log('Updated README.md');

View file

@ -1810,16 +1810,17 @@ body > .szh-menu-container {
env(safe-area-inset-bottom) env(safe-area-inset-left); env(safe-area-inset-bottom) env(safe-area-inset-left);
} }
.szh-menu { .szh-menu {
padding: 8px 0; padding: 4px 0;
margin: 0; margin: 0;
font-size: var(--text-size); font-size: var(--text-size);
background-color: var(--bg-color); background-color: var(--bg-color);
border: 1px solid var(--outline-color); border: 1px solid var(--outline-stronger-color);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 3px 16px -3px var(--drop-shadow-color); box-shadow: 0 3px 8px var(--drop-shadow-color),
0 6px 32px -6px var(--drop-shadow-color);
text-align: start; text-align: start;
/* animation: appear-smooth 0.15s ease-in-out; */ /* animation: appear-smooth 0.15s ease-in-out; */
width: 16em; min-width: 16em;
max-width: 90vw; max-width: 90vw;
/* overflow: hidden; */ /* overflow: hidden; */
} }
@ -1874,13 +1875,16 @@ body > .szh-menu-container {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
line-height: 1.1; line-height: 1.3;
padding: 8px 16px !important; padding: 8px 16px !important;
/* transition: all 0.1s ease-in-out; */ /* transition: all 0.1s ease-in-out; */
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
--menu-item-bg-inset: 0 4px;
--menu-item-bg-color: var(--button-bg-color);
} }
.szh-menu .szh-menu__item--focusable { .szh-menu .szh-menu__item--focusable {
background-color: transparent; background-color: transparent;
@ -1917,9 +1921,30 @@ body > .szh-menu-container {
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) { .szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
color: var(--text-color); 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) { .szh-menu .szh-menu__item--hover:not(.menu-field) {
color: var(--button-text-color); 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 { .szh-menu__divider {
background-color: var(--divider-color); background-color: var(--divider-color);
@ -1995,10 +2020,12 @@ body > .szh-menu-container {
} }
.szh-menu .szh-menu
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover { .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) { @media (prefers-color-scheme: dark) {
background-color: var(--red-color); /* background-color: var(--red-color); */
--menu-item-bg-color: var(--red-color);
} }
} }
.szh-menu .szh-menu
@ -2038,12 +2065,20 @@ body > .szh-menu-container {
); );
} }
} }
&:before {
content: '';
}
}
.szh-menu__item--hover {
background-color: var(--menu-item-bg-color);
} }
} }
.menu-control-group-horizontal:first-child, .menu-control-group-horizontal:first-child,
li[role='none'] + .menu-control-group-horizontal { li[role='none'] + .menu-control-group-horizontal {
margin-top: -8px; margin-top: -4px;
margin-bottom: -4px; margin-bottom: -4px;
.szh-menu__item { .szh-menu__item {
@ -2078,6 +2113,8 @@ body > .szh-menu-container {
} }
.szh-menu .menu-wrap { .szh-menu .menu-wrap {
min-width: 16em;
width: min-content;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -2092,11 +2129,10 @@ body > .szh-menu-container {
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
backdrop-filter: blur(8px) saturate(3); backdrop-filter: blur(8px) saturate(3);
border: var(--hairline-width) solid var(--bg-color); 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); text-shadow: 0 var(--hairline-width) var(--bg-color), 0 0 8px var(--bg-color);
} }
.glass-menu .szh-menu__item--hover { .glass-menu .szh-menu__item--hover {
background-color: var(--button-bg-blur-color); /* background-color: var(--button-bg-blur-color); */
text-shadow: none; text-shadow: none;
} }

View file

@ -56,7 +56,11 @@ import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck'; import focusDeck from './utils/focus-deck';
import states, { initStates, statusKey } from './utils/states'; import states, { initStates, statusKey } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils'; import {
getAccount,
getCurrentAccount,
setCurrentAccountID,
} from './utils/store-utils';
import './utils/toast-alert'; import './utils/toast-alert';
@ -317,9 +321,10 @@ function App() {
window.location.pathname || '/', window.location.pathname || '/',
); );
const clientID = store.session.get('clientID'); const clientID = store.sessionCookie.get('clientID');
const clientSecret = store.session.get('clientSecret'); const clientSecret = store.sessionCookie.get('clientSecret');
const vapidKey = store.session.get('vapidKey'); const vapidKey = store.sessionCookie.get('vapidKey');
const verifier = store.sessionCookie.get('codeVerifier');
(async () => { (async () => {
setUIState('loading'); setUIState('loading');
@ -328,8 +333,10 @@ function App() {
client_id: clientID, client_id: clientID,
client_secret: clientSecret, client_secret: clientSecret,
code, code,
code_verifier: verifier || undefined,
}); });
if (accessToken) {
const client = initClient({ instance: instanceURL, accessToken }); const client = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initPreferences(client), initPreferences(client),
@ -337,13 +344,35 @@ function App() {
initAccount(client, instanceURL, accessToken, vapidKey), initAccount(client, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initStates();
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
setIsLoggedIn(true); setIsLoggedIn(true);
setUIState('default'); setUIState('default');
} else {
setUIState('error');
}
})(); })();
} else { } else {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true; window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount(); const searchAccount = decodeURIComponent(
(window.location.search.match(/account=([^&]+)/) || [, ''])[1],
);
let account;
if (searchAccount) {
account = getAccount(searchAccount);
console.log('searchAccount', searchAccount, account);
if (account) {
setCurrentAccountID(account.info.id);
window.history.replaceState(
{},
document.title,
window.location.pathname || '/',
);
}
}
if (!account) {
account = getCurrentAccount();
}
if (account) { if (account) {
setCurrentAccountID(account.info.id); setCurrentAccountID(account.info.id);
const { client } = api({ account }); const { client } = api({ account });
@ -365,6 +394,11 @@ function App() {
setUIState('default'); setUIState('default');
} }
} }
// Cleanup
store.sessionCookie.del('clientID');
store.sessionCookie.del('clientSecret');
store.sessionCookie.del('codeVerifier');
}, []); }, []);
let location = useLocation(); let location = useLocation();

View file

@ -63,7 +63,6 @@ const MUTE_DURATIONS_LABELS = {
259_200: i18nDuration(3, 'day'), 259_200: i18nDuration(3, 'day'),
604_800: i18nDuration(1, 'week'), 604_800: i18nDuration(1, 'week'),
}; };
console.log({ MUTE_DURATIONS_LABELS });
const LIMIT = 80; const LIMIT = 80;

View file

@ -16,7 +16,18 @@ export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service // Notifications service
// - WebSocket to receive notifications when page is visible // - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
usePageVisibility(setVisible); const visibleTimeout = useRef();
usePageVisibility((visible) => {
clearTimeout(visibleTimeout.current);
if (visible) {
setVisible(true);
} else {
visibleTimeout.current = setTimeout(() => {
setVisible(false);
}, POLL_INTERVAL);
}
});
const checkLatestNotification = async (masto, instance, skipCheckMarkers) => { const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {
if (states.notificationsLast) { if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({ const notificationsIterator = masto.v1.notifications.list({

View file

@ -1154,7 +1154,7 @@ function Compose({
class={`toolbar-button ${ class={`toolbar-button ${
visibility !== 'public' && !sensitive ? 'show-field' : '' visibility !== 'public' && !sensitive ? 'show-field' : ''
} ${visibility !== 'public' ? 'highlight' : ''}`} } ${visibility !== 'public' ? 'highlight' : ''}`}
title={`Visibility: ${visibility}`} title={visibility}
> >
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} /> <Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
<select <select
@ -1471,7 +1471,14 @@ function Compose({
class="large" class="large"
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
{replyToStatus ? t`Reply` : editStatus ? t`Update` : t`Post`} {replyToStatus
? t`Reply`
: editStatus
? t`Update`
: t({
message: 'Post',
context: 'Submit button in composer',
})}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,13 +1,15 @@
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { CATALOGS, DEFAULT_LANG, LOCALES } from '../locales'; import { CATALOGS, DEFAULT_LANG, DEV_LOCALES, LOCALES } from '../locales';
import { activateLang } from '../utils/lang'; import { activateLang } from '../utils/lang';
import localeCode2Text from '../utils/localeCode2Text'; import localeCode2Text from '../utils/localeCode2Text';
import store from '../utils/store';
const regionMaps = { const regionMaps = {
'zh-CN': 'zh-Hans', 'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant', 'zh-TW': 'zh-Hant',
'pt-BR': 'pt-BR',
}; };
export default function LangSelector() { export default function LangSelector() {
@ -16,10 +18,6 @@ export default function LangSelector() {
// Sorted on render, so the order won't suddenly change based on current locale // Sorted on render, so the order won't suddenly change based on current locale
const populatedLocales = useMemo(() => { const populatedLocales = useMemo(() => {
return LOCALES.map((lang) => { return LOCALES.map((lang) => {
if (lang === 'pseudo-LOCALE') {
return { code: lang, native: 'Pseudolocalization (test)' };
}
// Don't need regions for now, it makes text too noisy // Don't need regions for now, it makes text too noisy
// Wait till there's too many languages and there are regional clashes // Wait till there's too many languages and there are regional clashes
const regionlessCode = regionMaps[lang] || lang.replace(/-[a-z]+$/i, ''); const regionlessCode = regionMaps[lang] || lang.replace(/-[a-z]+$/i, '');
@ -45,9 +43,6 @@ export default function LangSelector() {
native, native,
}; };
}).sort((a, b) => { }).sort((a, b) => {
// If pseudo-LOCALE, always put it at the bottom
if (a.code === 'pseudo-LOCALE') return 1;
if (b.code === 'pseudo-LOCALE') return -1;
// Sort by common name // Sort by common name
const order = a._common.localeCompare(b._common, i18n.locale); const order = a._common.localeCompare(b._common, i18n.locale);
if (order !== 0) return order; if (order !== 0) return order;
@ -65,21 +60,11 @@ export default function LangSelector() {
class="small" class="small"
value={i18n.locale || DEFAULT_LANG} value={i18n.locale || DEFAULT_LANG}
onChange={(e) => { onChange={(e) => {
localStorage.setItem('lang', e.target.value); store.local.set('lang', e.target.value);
activateLang(e.target.value); activateLang(e.target.value);
}} }}
> >
{populatedLocales.map(({ code, regionlessCode, native }) => { {populatedLocales.map(({ code, regionlessCode, native }) => {
if (code === 'pseudo-LOCALE') {
return (
<>
<hr />
<option value={code} key={code}>
{native}
</option>
</>
);
}
// Common name changes based on current locale // Common name changes based on current locale
const common = localeCode2Text({ const common = localeCode2Text({
code: regionlessCode, code: regionlessCode,
@ -97,6 +82,33 @@ export default function LangSelector() {
</option> </option>
); );
})} })}
{(import.meta.env.DEV || import.meta.env.PHANPY_SHOW_DEV_LOCALES) && (
<optgroup label="🚧 Development (<50% translated)">
{DEV_LOCALES.map((code) => {
if (code === 'pseudo-LOCALE') {
return (
<>
<hr />
<option value={code} key={code}>
Pseudolocalization (test)
</option>
</>
);
}
const nativeName = CATALOGS.find(
(c) => c.code === code,
)?.nativeName;
const completion = CATALOGS.find(
(c) => c.code === code,
)?.completion;
return (
<option value={code} key={code}>
{nativeName || code} &lrm;[{completion}%]
</option>
);
})}
</optgroup>
)}
</select> </select>
</label> </label>
); );

View file

@ -1,7 +1,18 @@
.nav-menu section:last-child { .nav-menu {
overflow: hidden;
section:last-child {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
margin-bottom: -8px; margin-bottom: -4px;
padding-bottom: 8px; padding-bottom: 4px;
.szh-menu__item:before {
z-index: 0;
}
.szh-menu__item > * {
z-index: 1;
}
}
} }
@media (min-width: 23em) { @media (min-width: 23em) {
@ -13,16 +24,16 @@
'top top' 'top top'
'left right'; 'left right';
padding: 0; padding: 0;
width: 22em; /* min-width: 22em; */
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
} }
.nav-menu .top-menu { .nav-menu .top-menu {
grid-area: top; grid-area: top;
padding-top: 8px; padding-top: 4px;
margin-bottom: -8px; margin-bottom: -4px;
} }
.nav-menu section { .nav-menu section {
padding: 8px 0; padding: 4px 0;
/* width: 50%; */ /* width: 50%; */
} }
@keyframes phanpying { @keyframes phanpying {

View file

@ -1,4 +1,4 @@
import { Plural, t, Trans } from '@lingui/macro'; import { Plural, plural, t, Trans } from '@lingui/macro';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
@ -113,9 +113,10 @@ export default function Poll({
</div> </div>
<div <div
class="poll-option-votes" class="poll-option-votes"
title={`${optionVotesCount} vote${ title={plural(optionVotesCount, {
optionVotesCount === 1 ? '' : 's' one: `# vote`,
}`} other: `# votes`,
})}
> >
{percentage} {percentage}
</div> </div>

View file

@ -16,7 +16,8 @@ function isValidDate(value) {
const resolvedLocale = new Intl.DateTimeFormat().resolvedOptions().locale; const resolvedLocale = new Intl.DateTimeFormat().resolvedOptions().locale;
const DTF = mem((locale, opts = {}) => { const DTF = mem((locale, opts = {}) => {
const lang = localeMatch([locale], [resolvedLocale]); const regionlessLocale = locale.replace(/-[a-z]+$/i, '');
const lang = localeMatch([regionlessLocale], [resolvedLocale], locale);
try { try {
return new Intl.DateTimeFormat(lang, opts); return new Intl.DateTimeFormat(lang, opts);
} catch (e) {} } catch (e) {}

View file

@ -121,13 +121,31 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
#shortcuts .tab-bar li a {
position: relative;
&:before {
content: '';
position: absolute;
inset: 4px 0;
border-radius: 8px;
background-color: var(--bg-color);
z-index: -1;
transform: scale(0.5);
opacity: 0;
transition: all 0.1s ease-in-out;
}
}
#shortcuts .tab-bar li a.is-active { #shortcuts .tab-bar li a.is-active {
color: var(--link-color); color: var(--link-color);
background-image: radial-gradient( /* background-image: radial-gradient(
closest-side at 50% 50%, closest-side at 50% 50%,
var(--bg-color), var(--bg-color),
transparent transparent
); ); */
&:before {
transform: scale(1);
opacity: 1;
}
} }
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden]) #app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden])
#shortcuts #shortcuts

View file

@ -430,6 +430,7 @@
> span + span { > span + span {
position: static; position: static;
width: auto;
&:empty { &:empty {
display: none; display: none;
@ -1895,6 +1896,7 @@ a:focus-visible .card img {
.meta-container { .meta-container {
align-self: flex-start; align-self: flex-start;
flex-grow: 0; flex-grow: 0;
max-width: 100%;
} }
.card .title { .card .title {
line-height: 1.25; line-height: 1.25;

View file

@ -1061,7 +1061,14 @@ function Status({
)} )}
<MenuItem href={url} target="_blank"> <MenuItem href={url} target="_blank">
<Icon icon="external" /> <Icon icon="external" />
<small class="menu-double-lines">{nicePostURL(url)}</small> <small
class="menu-double-lines"
style={{
maxWidth: '16em',
}}
>
{nicePostURL(url)}
</small>
</MenuItem> </MenuItem>
<div class="menu-horizontal"> <div class="menu-horizontal">
<MenuItem <MenuItem
@ -2653,8 +2660,13 @@ function Card({ card, selfReferential, instance }) {
const imageData = ctx.createImageData(w, h); const imageData = ctx.createImageData(w, h);
imageData.data.set(blurhashPixels); imageData.data.set(blurhashPixels);
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
if (window.OffscreenCanvas) {
const blob = canvas.convertToBlob();
blurhashImage = URL.createObjectURL(blob);
} else {
blurhashImage = canvas.toDataURL(); blurhashImage = canvas.toDataURL();
} }
}
const isPost = isCardPost(domain); const isPost = isCardPost(domain);

View file

@ -15,6 +15,7 @@ import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context'; import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters'; import { filteredItems, isFiltered } from '../utils/filters';
import isRTL from '../utils/is-rtl'; import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states'; import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import { isMediaFirstInstance } from '../utils/store-utils'; import { isMediaFirstInstance } from '../utils/store-utils';
@ -121,6 +122,9 @@ function Timeline({
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setUIState('error'); setUIState('error');
if (firstLoad && !items.length && errorText) {
showToast(errorText);
}
} finally { } finally {
loadItems.cancel(); loadItems.cancel();
} }

View file

@ -9,8 +9,10 @@ import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import ComposeSuspense from './components/compose-suspense'; import ComposeSuspense from './components/compose-suspense';
import Loader from './components/loader';
import { initActivateLang } from './utils/lang'; import { initActivateLang } from './utils/lang';
import { initStates } from './utils/states'; import { initStates } from './utils/states';
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
initActivateLang(); initActivateLang();
@ -21,6 +23,7 @@ if (window.opener) {
function App() { function App() {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [isLoggedIn, setIsLoggedIn] = useState(null);
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {}; const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
@ -35,7 +38,11 @@ function App() {
); );
useEffect(() => { useEffect(() => {
const account = getCurrentAccount();
setIsLoggedIn(!!account);
if (account) {
initStates(); initStates();
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -69,6 +76,25 @@ function App() {
console.debug('OPEN COMPOSE'); 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 ( return (
<ComposeSuspense <ComposeSuspense
editStatus={editStatus} editStatus={editStatus}
@ -88,6 +114,13 @@ function App() {
}} }}
/> />
); );
}
return (
<div class="box">
<Loader />
</div>
);
} }
render( render(

View file

@ -1,16 +1,52 @@
[ [
{
"code": "ar-SA",
"nativeName": "العربية",
"name": "Arabic",
"completion": 26
},
{ {
"code": "ca-ES", "code": "ca-ES",
"nativeName": "català", "nativeName": "català",
"name": "Catalan", "name": "Catalan",
"completion": 100 "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", "code": "es-ES",
"nativeName": "español", "nativeName": "español",
"name": "Spanish", "name": "Spanish",
"completion": 100 "completion": 100
}, },
{
"code": "eu-ES",
"nativeName": "euskara",
"name": "Basque",
"completion": 100
},
{
"code": "fa-IR",
"nativeName": "فارسی",
"name": "Persian",
"completion": 73
},
{ {
"code": "fi-FI", "code": "fi-FI",
"nativeName": "suomi", "nativeName": "suomi",
@ -18,58 +54,52 @@
"completion": 100 "completion": 100
}, },
{ {
"code": "zh-CN", "code": "fr-FR",
"nativeName": "简体中文", "nativeName": "français",
"name": "Simplified Chinese", "name": "French",
"completion": 100 "completion": 99
},
{
"code": "de-DE",
"nativeName": "Deutsch",
"name": "German",
"completion": 98
},
{
"code": "eu-ES",
"nativeName": "euskara",
"name": "Basque",
"completion": 98
}, },
{ {
"code": "gl-ES", "code": "gl-ES",
"nativeName": "galego", "nativeName": "galego",
"name": "Galician", "name": "Galician",
"completion": 98 "completion": 100
}, },
{ {
"code": "fr-FR", "code": "he-IL",
"nativeName": "français", "nativeName": "עברית",
"name": "French", "name": "Hebrew",
"completion": 97 "completion": 12
}, },
{ {
"code": "ko-KR", "code": "it-IT",
"nativeName": "한국어", "nativeName": "italiano",
"name": "Korean", "name": "Italian",
"completion": 75 "completion": 34
}, },
{ {
"code": "cs-CZ", "code": "ja-JP",
"nativeName": "čeština", "nativeName": "日本語",
"name": "Czech", "name": "Japanese",
"completion": 72 "completion": 31
}, },
{ {
"code": "kab", "code": "kab",
"nativeName": "Taqbaylit", "nativeName": "Taqbaylit",
"name": "Kabyle", "name": "Kabyle",
"completion": 67 "completion": 100
}, },
{ {
"code": "fa-IR", "code": "ko-KR",
"nativeName": "فارسی", "nativeName": "한국어",
"name": "Persian", "name": "Korean",
"completion": 62 "completion": 86
},
{
"code": "lt-LT",
"nativeName": "lietuvių",
"name": "Lithuanian",
"completion": 43
}, },
{ {
"code": "nl-NL", "code": "nl-NL",
@ -78,46 +108,28 @@
"completion": 48 "completion": 48
}, },
{ {
"code": "ja-JP", "code": "pl-PL",
"nativeName": "日本語", "nativeName": "polski",
"name": "Japanese", "name": "Polish",
"completion": 32 "completion": 1
}, },
{ {
"code": "lt-LT", "code": "pt-BR",
"nativeName": "lietuvių", "nativeName": "português",
"name": "Lithuanian", "name": "Portuguese",
"completion": 28 "completion": 100
},
{
"code": "pt-PT",
"nativeName": "português",
"name": "Portuguese",
"completion": 100
}, },
{ {
"code": "ru-RU", "code": "ru-RU",
"nativeName": "русский", "nativeName": "русский",
"name": "Russian", "name": "Russian",
"completion": 23 "completion": 100
},
{
"code": "ar-SA",
"nativeName": "العربية",
"name": "Arabic",
"completion": 22
},
{
"code": "it-IT",
"nativeName": "italiano",
"name": "Italian",
"completion": 20
},
{
"code": "eo-UY",
"nativeName": "Esperanto",
"name": "Esperanto",
"completion": 14
},
{
"code": "he-IL",
"nativeName": "עברית",
"name": "Hebrew",
"completion": 11
}, },
{ {
"code": "th-TH", "code": "th-TH",
@ -125,10 +137,22 @@
"name": "Thai", "name": "Thai",
"completion": 3 "completion": 3
}, },
{
"code": "uk-UA",
"nativeName": "українська",
"name": "Ukrainian",
"completion": 26
},
{
"code": "zh-CN",
"nativeName": "简体中文",
"name": "Simplified Chinese",
"completion": 100
},
{ {
"code": "zh-TW", "code": "zh-TW",
"nativeName": "繁體中文", "nativeName": "繁體中文",
"name": "Traditional Chinese", "name": "Traditional Chinese",
"completion": 3 "completion": 14
} }
] ]

View file

@ -84,6 +84,7 @@
var(--text-color) 60% var(--text-color) 60%
); );
--outline-color: rgba(128, 128, 128, 0.2); --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); --outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1); --divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.1); --backdrop-color: rgba(0, 0, 0, 0.1);
@ -149,6 +150,11 @@
mediumslateblue 70%, mediumslateblue 70%,
var(--text-color) 30% var(--text-color) 30%
); );
--button-bg-color: color-mix(
in srgb,
var(--blue-color) 80%,
var(--bg-color) 20%
);
--reblog-faded-color: #b190f141; --reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color); --reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60017; --reply-to-faded-color: #ffa60017;

View file

@ -12,7 +12,15 @@ const locales = [
.filter(({ completion }) => completion >= PERCENTAGE_THRESHOLD) .filter(({ completion }) => completion >= PERCENTAGE_THRESHOLD)
.map(({ code }) => code), .map(({ code }) => code),
]; ];
if (import.meta.env.DEV) {
locales.push('pseudo-LOCALE');
}
export const LOCALES = locales; 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];

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

3732
src/locales/pl-PL.po Normal file

File diff suppressed because it is too large Load diff

3733
src/locales/pt-BR.po Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

3732
src/locales/uk-UA.po Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import MenuConfirm from '../components/menu-confirm'; import MenuConfirm from '../components/menu-confirm';
import MenuLink from '../components/menu-link';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import { api } from '../utils/api'; import { api } from '../utils/api';
@ -16,6 +17,8 @@ import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils'; import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
function Accounts({ onClose }) { function Accounts({ onClose }) {
const { masto } = api(); const { masto } = api();
// Accounts // Accounts
@ -107,6 +110,32 @@ function Accounts({ onClose }) {
</button> </button>
} }
> >
{moreThanOneAccount && (
<>
<MenuItem
disabled={isCurrent}
onClick={() => {
setCurrentAccountID(account.info.id);
location.reload();
}}
>
<Icon icon="transfer" />{' '}
<Trans>Switch to this account</Trans>
</MenuItem>
{!isStandalone && !isCurrent && (
<MenuLink
href={`./?account=${account.info.id}`}
target="_blank"
>
<Icon icon="external" />
<span>
<Trans>Switch in new tab/window</Trans>
</span>
</MenuLink>
)}
<MenuDivider />
</>
)}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showAccount = `${account.info.username}@${account.instanceURL}`; states.showAccount = `${account.info.username}@${account.instanceURL}`;

View file

@ -22,7 +22,7 @@ function Bookmarks() {
<Timeline <Timeline
title={t`Bookmarks`} title={t`Bookmarks`}
id="bookmarks" id="bookmarks"
emptyText={`No bookmarks yet. Go bookmark something!`} emptyText={t`No bookmarks yet. Go bookmark something!`}
errorText={t`Unable to load bookmarks.`} errorText={t`Unable to load bookmarks.`}
instance={instance} instance={instance}
fetchItems={fetchBookmarks} fetchItems={fetchBookmarks}

View file

@ -822,6 +822,22 @@ function Catchup() {
}, },
); );
const handleArrowKeys = useCallback((e) => {
const activeElement = document.activeElement;
const isRadio =
activeElement?.tagName === 'INPUT' && activeElement.type === 'radio';
const isArrowKeys =
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight';
if (isArrowKeys && isRadio) {
// Note: page scroll won't trigger on first arrow key press due to this. Subsequent presses will.
activeElement.blur();
return;
}
}, []);
return ( return (
<div <div
ref={(node) => { ref={(node) => {
@ -883,7 +899,7 @@ function Catchup() {
</div> </div>
</div> </div>
</header> </header>
<main> <main onKeyDown={handleArrowKeys}>
{uiState === 'start' && ( {uiState === 'start' && (
<div class="catchup-start"> <div class="catchup-start">
<h1> <h1>

View file

@ -22,7 +22,7 @@ function Favourites() {
<Timeline <Timeline
title={t`Likes`} title={t`Likes`}
id="favourites" id="favourites"
emptyText={`No likes yet. Go like something!`} emptyText={t`No likes yet. Go like something!`}
errorText={t`Unable to load likes.`} errorText={t`Unable to load likes.`}
instance={instance} instance={instance}
fetchItems={fetchFavourites} fetchItems={fetchFavourites}

View file

@ -205,7 +205,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
<MenuConfirm <MenuConfirm
subMenu subMenu
confirm={info.following} confirm={info.following}
confirmLabel={`Unfollow #${hashtag}?`} confirmLabel={t`Unfollow #${hashtag}?`}
disabled={followUIState === 'loading' || !authenticated} disabled={followUIState === 'loading' || !authenticated}
onClick={() => { onClick={() => {
setFollowUIState('loading'); setFollowUIState('loading');

View file

@ -11,7 +11,12 @@ import LangSelector from '../components/lang-selector';
import Link from '../components/link'; import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import instancesListURL from '../data/instances.json?url'; import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth'; import {
getAuthorizationURL,
getPKCEAuthorizationURL,
registerApplication,
} from '../utils/auth';
import { supportsPKCE } from '../utils/oauth-pkce';
import store from '../utils/store'; import store from '../utils/store';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -63,17 +68,36 @@ function Login() {
instanceURL, instanceURL,
}); });
const authPKCE = await supportsPKCE({ instanceURL });
console.log({ authPKCE });
if (authPKCE) {
if (client_id && client_secret) { if (client_id && client_secret) {
store.session.set('clientID', client_id); store.sessionCookie.set('clientID', client_id);
store.session.set('clientSecret', client_secret); store.sessionCookie.set('clientSecret', client_secret);
store.session.set('vapidKey', vapid_key); store.sessionCookie.set('vapidKey', vapid_key);
const [url, verifier] = await getPKCEAuthorizationURL({
instanceURL,
client_id,
});
store.sessionCookie.set('codeVerifier', verifier);
location.href = url;
} else {
alert(t`Failed to register application`);
}
} else {
if (client_id && client_secret) {
store.sessionCookie.set('clientID', client_id);
store.sessionCookie.set('clientSecret', client_secret);
store.sessionCookie.set('vapidKey', vapid_key);
location.href = await getAuthorizationURL({ location.href = await getAuthorizationURL({
instanceURL, instanceURL,
client_id, client_id,
}); });
} else { } else {
alert('Failed to register application'); alert(t`Failed to register application`);
}
} }
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
@ -158,7 +182,7 @@ function Login() {
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
spellCheck={false} spellCheck={false}
placeholder={`instance domain`} placeholder={t`instance domain`}
onInput={(e) => { onInput={(e) => {
setInstanceText(e.target.value); setInstanceText(e.target.value);
}} }}

View file

@ -232,9 +232,21 @@ function Settings({ onClose }) {
</div> </div>
</li> </li>
<li> <li>
<span>
<label> <label>
<Trans>Display language</Trans> <Trans>Display language</Trans>
</label> </label>
<br />
<small>
<a
href="https://crowdin.com/project/phanpy"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Volunteer translations</Trans>
</a>
</small>
</span>
<LangSelector /> <LangSelector />
</li> </li>
</ul> </ul>

View file

@ -1,12 +1,32 @@
const { PHANPY_CLIENT_NAME: CLIENT_NAME, PHANPY_WEBSITE: WEBSITE } = import.meta import { generateCodeChallenge, verifier } from './oauth-pkce';
.env;
const {
DEV,
PHANPY_CLIENT_NAME: CLIENT_NAME,
PHANPY_WEBSITE: WEBSITE,
} = import.meta.env;
const SCOPES = 'read write follow push'; const SCOPES = 'read write follow push';
/*
PHANPY_WEBSITE is set to the default official site.
It's used in pre-built releases, so there's no way to change it dynamically
without rebuilding.
Therefore, we can't use it as redirect_uri.
We only use PHANPY_WEBSITE if it's "same" as current location URL.
Very basic check based on location.hostname for now
*/
const sameSite = WEBSITE
? WEBSITE.toLowerCase().includes(location.hostname)
: false;
const currentLocation = location.origin + location.pathname;
const REDIRECT_URI = DEV || !sameSite ? currentLocation : WEBSITE;
export async function registerApplication({ instanceURL }) { export async function registerApplication({ instanceURL }) {
const registrationParams = new URLSearchParams({ const registrationParams = new URLSearchParams({
client_name: CLIENT_NAME, client_name: CLIENT_NAME,
redirect_uris: location.origin + location.pathname, redirect_uris: REDIRECT_URI,
scopes: SCOPES, scopes: SCOPES,
website: WEBSITE, website: WEBSITE,
}); });
@ -25,11 +45,26 @@ export async function registerApplication({ instanceURL }) {
return registrationJSON; return registrationJSON;
} }
export async function getPKCEAuthorizationURL({ instanceURL, client_id }) {
const codeVerifier = verifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
client_id,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: SCOPES,
});
const authorizationURL = `https://${instanceURL}/oauth/authorize?${params.toString()}`;
return [authorizationURL, codeVerifier];
}
export async function getAuthorizationURL({ instanceURL, client_id }) { export async function getAuthorizationURL({ instanceURL, client_id }) {
const authorizationParams = new URLSearchParams({ const authorizationParams = new URLSearchParams({
client_id, client_id,
scope: SCOPES, scope: SCOPES,
redirect_uri: location.origin + location.pathname, redirect_uri: REDIRECT_URI,
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', // redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
response_type: 'code', response_type: 'code',
}); });
@ -42,15 +77,23 @@ export async function getAccessToken({
client_id, client_id,
client_secret, client_secret,
code, code,
code_verifier,
}) { }) {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id, client_id,
client_secret, redirect_uri: REDIRECT_URI,
redirect_uri: location.origin + location.pathname,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
scope: SCOPES, scope: SCOPES,
// client_secret,
// code_verifier,
}); });
if (client_secret) {
params.append('client_secret', client_secret);
}
if (code_verifier) {
params.append('code_verifier', code_verifier);
}
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, { const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
method: 'POST', method: 'POST',
headers: { headers: {

View file

@ -5,6 +5,7 @@ const statusPostRegexes = [
/^\/@[^@\/]+\/(?:statuses|posts)\/([^\/]+)/i, // GoToSocial, Takahe /^\/@[^@\/]+\/(?:statuses|posts)\/([^\/]+)/i, // GoToSocial, Takahe
/\/notes\/([^\/]+)/i, // Misskey, Firefish /\/notes\/([^\/]+)/i, // Misskey, Firefish
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma /^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
/\/@[^@\/]+\/post\/([^\/]+)/i, // Threads
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon /\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
/^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed /^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed
]; ];

View file

@ -6,7 +6,7 @@ export default function isMastodonLinkMaybe(url) {
/^\/(@[^/]+|users\/[^/]+)\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe /^\/(@[^/]+|users\/[^/]+)\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Firefish /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Firefish
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) || // Pleroma /^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) || // Pleroma
/^\/@[^/]+\/post\/[a-z0-9]+$/i.test(pathname) || // Threads /^\/@[^/]+\/post\/[a-z0-9\-_]+$/i.test(pathname) || // Threads
/^\/@[^/]+\/[a-z0-9]+[a-z0-9\-]+[a-z0-9]+$/i.test(pathname) || // Hollo /^\/@[^/]+\/[a-z0-9]+[a-z0-9\-]+[a-z0-9]+$/i.test(pathname) || // Hollo
(hostname === 'fed.brid.gy' && pathname.startsWith('/r/http')) || // Bridgy Fed (hostname === 'fed.brid.gy' && pathname.startsWith('/r/http')) || // Bridgy Fed
/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(hash) // Phanpy 🫣 /#\/[^\/]+\.[^\/]+\/s\/.+/i.test(hash) // Phanpy 🫣

View file

@ -7,7 +7,7 @@ import {
} from '@lingui/detect-locale'; } from '@lingui/detect-locale';
import Locale from 'intl-locale-textinfo-polyfill'; import Locale from 'intl-locale-textinfo-polyfill';
import { DEFAULT_LANG, LOCALES } from '../locales'; import { ALL_LOCALES, DEFAULT_LANG } from '../locales';
import { messages } from '../locales/en.po'; import { messages } from '../locales/en.po';
import localeMatch from '../utils/locale-match'; import localeMatch from '../utils/locale-match';
@ -62,7 +62,7 @@ export function initActivateLang() {
DEFAULT_LANG, DEFAULT_LANG,
); );
const matchedLang = const matchedLang =
LOCALES.find((l) => l === lang) || localeMatch(lang, LOCALES); ALL_LOCALES.find((l) => l === lang) || localeMatch(lang, ALL_LOCALES);
activateLang(matchedLang); activateLang(matchedLang);
// const yes = confirm(t`Reload to apply language setting?`); // const yes = confirm(t`Reload to apply language setting?`);

View file

@ -1,12 +1,14 @@
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import localeMatch from './locale-match';
import mem from './mem'; import mem from './mem';
const defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale; const defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale;
const _DateTimeFormat = (opts) => { const _DateTimeFormat = (opts) => {
const { locale, dateYear, hideTime, formatOpts } = opts || {}; const { locale, dateYear, hideTime, formatOpts } = opts || {};
const loc = locale && !/pseudo/i.test(locale) ? locale : defaultLocale; const regionlessLocale = locale.replace(/-[a-z]+$/i, '');
const loc = localeMatch([regionlessLocale], [defaultLocale], locale);
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const options = { const options = {
// Show year if not current year // Show year if not current year
@ -20,9 +22,11 @@ const _DateTimeFormat = (opts) => {
}; };
try { try {
return Intl.DateTimeFormat(loc, options); return Intl.DateTimeFormat(loc, options);
} catch (e) { } catch (e) {}
try {
return Intl.DateTimeFormat(locale, options);
} catch (e) {}
return Intl.DateTimeFormat(undefined, options); return Intl.DateTimeFormat(undefined, options);
}
}; };
const DateTimeFormat = mem(_DateTimeFormat); const DateTimeFormat = mem(_DateTimeFormat);

46
src/utils/oauth-pkce.js Normal file
View file

@ -0,0 +1,46 @@
function dec2hex(dec) {
return ('0' + dec.toString(16)).slice(-2);
}
export function verifier() {
var array = new Uint32Array(56 / 2);
window.crypto.getRandomValues(array);
return Array.from(array, dec2hex).join('');
}
function sha256(plain) {
// returns promise ArrayBuffer
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest('SHA-256', data);
}
function base64urlencode(a) {
let str = '';
const bytes = new Uint8Array(a);
const len = bytes.byteLength;
for (var i = 0; i < len; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function generateCodeChallenge(v) {
const hashed = await sha256(v);
return base64urlencode(hashed);
}
// If /.well-known/oauth-authorization-server exists and code_challenge_methods_supported includes "S256", means support PKCE
export async function supportsPKCE({ instanceURL }) {
if (!instanceURL) return false;
try {
const res = await fetch(
`https://${instanceURL}/.well-known/oauth-authorization-server`,
);
if (!res.ok || res.status !== 200) return false;
const json = await res.json();
if (json.code_challenge_methods_supported?.includes('S256')) return true;
return false;
} catch (e) {
return false;
}
}
// For debugging
window.__generateCodeChallenge = generateCodeChallenge;

View file

@ -1,6 +1,6 @@
// Utils for push notifications // Utils for push notifications
import { api } from './api'; import { api } from './api';
import { getCurrentAccount } from './store-utils'; import { getVapidKey } from './store-utils';
// Subscription is an object with the following structure: // Subscription is an object with the following structure:
// { // {
@ -113,7 +113,7 @@ export async function initSubscription() {
// Check if the subscription changed // Check if the subscription changed
if (backendSubscription && subscription) { if (backendSubscription && subscription) {
const sameEndpoint = backendSubscription.endpoint === subscription.endpoint; const sameEndpoint = backendSubscription.endpoint === subscription.endpoint;
const { vapidKey } = getCurrentAccount(); const vapidKey = getVapidKey();
const sameKey = backendSubscription.serverKey === vapidKey; const sameKey = backendSubscription.serverKey === vapidKey;
if (!sameEndpoint) { if (!sameEndpoint) {
throw new Error('Backend subscription endpoint changed'); throw new Error('Backend subscription endpoint changed');
@ -146,7 +146,7 @@ export async function initSubscription() {
if (subscription && !backendSubscription) { if (subscription && !backendSubscription) {
// check if account's vapidKey is same as subscription's applicationServerKey // check if account's vapidKey is same as subscription's applicationServerKey
const { vapidKey } = getCurrentAccount(); const vapidKey = getVapidKey();
if (vapidKey) { if (vapidKey) {
const { applicationServerKey } = subscription.options; const { applicationServerKey } = subscription.options;
const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString(); const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString();
@ -210,7 +210,7 @@ export async function updateSubscription({ data, policy }) {
} }
} else { } else {
// User is not subscribed // User is not subscribed
const { vapidKey } = getCurrentAccount(); const vapidKey = getVapidKey();
if (!vapidKey) throw new Error('No server key found'); if (!vapidKey) throw new Error('No server key found');
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,

View file

@ -154,6 +154,13 @@ export function getCurrentInstanceConfiguration() {
return getInstanceConfiguration(instance); return getInstanceConfiguration(instance);
} }
export function getVapidKey() {
// Vapid key has moved from account to instance config
const config = getCurrentInstanceConfiguration();
const vapidKey = config?.vapid?.publicKey || config?.vapid?.public_key;
return vapidKey || getCurrentAccount()?.vapidKey;
}
export function isMediaFirstInstance() { export function isMediaFirstInstance() {
const instance = getCurrentInstance(); const instance = getCurrentInstance();
return /pixelfed/i.test(instance?.version); return /pixelfed/i.test(instance?.version);

View file

@ -1,5 +1,9 @@
import Cookies from 'js-cookie';
import { getCurrentAccountNS } from './store-utils'; import { getCurrentAccountNS } from './store-utils';
const cookies = Cookies.withAttributes({ sameSite: 'strict', secure: true });
const local = { const local = {
get: (key) => { get: (key) => {
try { try {
@ -86,6 +90,38 @@ const session = {
}, },
}; };
// Session secure cookie
const cookie = {
get: (key) => cookies.get(key),
set: (key, value) => cookies.set(key, value),
del: (key) => cookies.remove(key),
};
// Cookie with sessionStorage fallback
const sessionCookie = {
get: (key) => {
if (navigator.cookieEnabled) {
return cookie.get(key);
} else {
return session.get(key);
}
},
set: (key, value) => {
if (navigator.cookieEnabled) {
return cookie.set(key, value);
} else {
return session.set(key, value);
}
},
del: (key) => {
if (navigator.cookieEnabled) {
return cookie.del(key);
} else {
return session.del(key);
}
},
};
// Store with account namespace (id@domain.tld) <- uses id, not username // Store with account namespace (id@domain.tld) <- uses id, not username
const account = { const account = {
get: (key) => { get: (key) => {
@ -118,4 +154,4 @@ const account = {
}, },
}; };
export default { local, session, account }; export default { local, session, sessionCookie, cookie, account };

View file

@ -12,9 +12,12 @@ import { VitePWA } from 'vite-plugin-pwa';
import removeConsole from 'vite-plugin-remove-console'; import removeConsole from 'vite-plugin-remove-console';
import { run } from 'vite-plugin-run'; import { run } from 'vite-plugin-run';
import { ALL_LOCALES } from './src/locales';
const allowedEnvPrefixes = ['VITE_', 'PHANPY_']; const allowedEnvPrefixes = ['VITE_', 'PHANPY_'];
const { NODE_ENV } = process.env; const { NODE_ENV } = process.env;
const { const {
PHANPY_WEBSITE: WEBSITE,
PHANPY_CLIENT_NAME: CLIENT_NAME, PHANPY_CLIENT_NAME: CLIENT_NAME,
PHANPY_APP_ERROR_LOGGING: ERROR_LOGGING, PHANPY_APP_ERROR_LOGGING: ERROR_LOGGING,
} = loadEnv('production', process.cwd(), allowedEnvPrefixes); } = loadEnv('production', process.cwd(), allowedEnvPrefixes);
@ -70,6 +73,11 @@ export default defineConfig({
run: ['npm', 'run', 'messages:extract:clean'], run: ['npm', 'run', 'messages:extract:clean'],
pattern: 'src/**/*.{js,jsx,ts,tsx}', pattern: 'src/**/*.{js,jsx,ts,tsx}',
}, },
// {
// name: 'update-catalogs',
// run: ['node', 'scripts/catalogs.js'],
// pattern: 'src/locales/*.po',
// },
], ],
}), }),
splitVendorChunkPlugin(), splitVendorChunkPlugin(),
@ -78,6 +86,20 @@ export default defineConfig({
}), }),
htmlPlugin({ htmlPlugin({
headScripts: ERROR_LOGGING ? [rollbarCode] : [], headScripts: ERROR_LOGGING ? [rollbarCode] : [],
links: [
...ALL_LOCALES.map((lang) => ({
rel: 'alternate',
hreflang: lang,
// *Fully-qualified* URLs
href: `${WEBSITE}/?lang=${lang}`,
})),
// https://developers.google.com/search/docs/specialty/international/localized-versions#xdefault
{
rel: 'alternate',
hreflang: 'x-default',
href: `${WEBSITE}`,
},
],
}), }),
generateFile([ generateFile([
{ {