kopia lustrzana https://github.com/nolanlawson/pinafore
Porównaj commity
89 Commity
Autor | SHA1 | Data |
---|---|---|
Nolan Lawson | 8f61ea75ce | |
Nolan Lawson | 5889b404cb | |
Nolan Lawson | 794d9ca74e | |
Nolan Lawson | 72a07ac40d | |
Nolan Lawson | ed9a9f6539 | |
Arnaldo Gabriel | 452b34b3b4 | |
Thomas Preece | fd4bb4d864 | |
vitalyster | c426b7fe31 | |
Noelia Ruiz Martínez | c2851ce104 | |
Nolan Lawson | 2578d0964d | |
Noelia Ruiz Martínez | ff53fcab10 | |
Nolan Lawson | 750235cd8f | |
Nolan Lawson | b5cad87aaf | |
Nick Colley | a85ff62d48 | |
Nick Colley | e06f63684e | |
Nick Colley | f81778d37f | |
Nick Colley | 746298a1f7 | |
Nolan Lawson | 02f1dad098 | |
Nick Colley | 3edfed971f | |
Noelia Ruiz Martínez | d71430f86d | |
Noelia Ruiz Martínez | 6124c948de | |
Nolan Lawson | 774aa7a21c | |
Nolan Lawson | 276c6e7bea | |
Nolan Lawson | f61054a3d5 | |
Nolan Lawson | b1dc43a9c9 | |
Nolan Lawson | 040462f5b5 | |
Thomas Broyer | f5f3395a53 | |
Nick Colley | 3fb152ac7c | |
Daniel Soohan Park | 97e3b04f1f | |
Scott Feeney | 3c32b48e29 | |
Noelia Ruiz Martínez | 4a6907bbdc | |
Thomas Broyer | d31c800806 | |
Nolan Lawson | 380d2a0d45 | |
Nolan Lawson | 7fdbd72f13 | |
dependabot[bot] | 62b30f6d99 | |
Nolan Lawson | 6d6eb59f41 | |
James Teh | 30b00667f2 | |
Nick Colley | da28e98cfb | |
Nick Colley | 7417e89f78 | |
James Teh | 815438172e | |
James Teh | 8fc9d5c728 | |
Scott Feeney | a775bd9193 | |
Nolan Lawson | edb7e7b442 | |
Maxime Le Conte des Floris | 3c857d74b8 | |
Ringtail Software | 5eb7183048 | |
Nolan Lawson | a3f41917c7 | |
Nolan Lawson | 098da30f2a | |
Nolan Lawson | abc39ef982 | |
Nick Colley | b543399e0a | |
Nolan Lawson | fda00fc87c | |
Nick Colley | 0e4523a37d | |
Nolan Lawson | 4fb8f37db7 | |
Nolan Lawson | fac42a91a0 | |
Nolan Lawson | b50b9dc40b | |
Nick Colley | bc664e5ca1 | |
Marco Zehe | fa41fe7649 | |
Nolan Lawson | 53803db5be | |
James Teh | 8792d912bc | |
James Teh | a447b9535e | |
Nolan Lawson | 6b1533c947 | |
Nolan Lawson | 347dab4e29 | |
Nolan Lawson | fdec7b2b3d | |
Сергей Ворон | 7f86a94414 | |
Nolan Lawson | 302845866a | |
Nolan Lawson | f875e65c49 | |
Nolan Lawson | 85bc6ba372 | |
Nolan Lawson | 00b6d31f0c | |
Nolan Lawson | 035ab9cbff | |
Nolan Lawson | ad73918fa8 | |
Nolan Lawson | d57ab7238f | |
Nolan Lawson | fb5478cd06 | |
Nolan Lawson | 52880a4689 | |
Nolan Lawson | 2131ababf3 | |
Nolan Lawson | a318746961 | |
Nolan Lawson | 601c3e40c9 | |
Nolan Lawson | ff6e1dc6fc | |
Nolan Lawson | 4273666ce5 | |
Nolan Lawson | 6f4eb98397 | |
Nolan Lawson | 3c59069490 | |
dependabot[bot] | 55189e840b | |
Nolan Lawson | e2d5b5928d | |
Nolan Lawson | 6fde4e0b90 | |
Nolan Lawson | 6ebd6a6a01 | |
Gavin Mogan | 36ead0406d | |
Nolan Lawson | 1de26d4b06 | |
Nolan Lawson | f10e9dbcf3 | |
Nolan Lawson | 1c6387a0a4 | |
Nolan Lawson | 0fd7154ed4 | |
Nolan Lawson | 7ea387bc4c |
|
@ -1,212 +0,0 @@
|
|||
version: 2.1
|
||||
|
||||
orbs:
|
||||
browser-tools: circleci/browser-tools@1.1.3
|
||||
workflows:
|
||||
version: 2
|
||||
build_and_test:
|
||||
jobs:
|
||||
- build_and_unit_test
|
||||
- integration_test_readonly:
|
||||
requires:
|
||||
- build_and_unit_test
|
||||
- integration_test_readwrite:
|
||||
requires:
|
||||
- build_and_unit_test
|
||||
executors:
|
||||
node:
|
||||
working_directory: ~/pinafore
|
||||
docker:
|
||||
- image: cimg/ruby:3.0.3-browsers
|
||||
node_and_ruby:
|
||||
working_directory: ~/pinafore
|
||||
docker:
|
||||
- image: cimg/ruby:3.0.3-browsers
|
||||
- image: circleci/postgres:12.2
|
||||
environment:
|
||||
POSTGRES_USER: pinafore
|
||||
POSTGRES_PASSWORD: pinafore
|
||||
POSTGRES_DB: pinafore_development
|
||||
BROWSER: chrome:headless
|
||||
- image: circleci/redis:5-alpine
|
||||
commands:
|
||||
install_mastodon_system_dependencies:
|
||||
description: Install system dependencies that Mastodon requires
|
||||
steps:
|
||||
- run:
|
||||
name: Install system dependencies
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
fonts-noto-color-emoji \
|
||||
imagemagick \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler
|
||||
install_browsers:
|
||||
description: Install browsers and tools
|
||||
steps:
|
||||
- browser-tools/install-chrome:
|
||||
chrome-version: 91.0.4472.114
|
||||
- browser-tools/install-chromedriver
|
||||
- run:
|
||||
name: "Check browser version"
|
||||
command: |
|
||||
google-chrome --version
|
||||
install_node:
|
||||
description: Install Node.js
|
||||
steps:
|
||||
- run:
|
||||
name: "Install Node.js"
|
||||
# via https://circleci.com/docs/2.0/circleci-images/#notes-on-pinning-images
|
||||
command: |
|
||||
curl -sSL "https://nodejs.org/dist/v12.22.3/node-v12.22.3-linux-x64.tar.xz" \
|
||||
| sudo tar --strip-components=2 -xJ -C /usr/local/bin/ node-v12.22.3-linux-x64/bin/node
|
||||
- run:
|
||||
name: Check current version of node
|
||||
command: node -v
|
||||
|
||||
save_workspace:
|
||||
description: Persist workspace
|
||||
steps:
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- .
|
||||
load_workspace:
|
||||
description: Load workspace
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/pinafore
|
||||
restore_yarn_cache:
|
||||
description: Restore yarn cache
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore yarn cache
|
||||
key: yarn-v4-{{ checksum "yarn.lock" }}
|
||||
save_yarn_cache:
|
||||
description: Save yarn cache
|
||||
steps:
|
||||
- save_cache:
|
||||
name: Save yarn cache
|
||||
key: yarn-v4-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- ~/.cache/yarn
|
||||
restore_yarn_cache_mastodon:
|
||||
description: Restore yarn cache for Mastodon
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore yarn cache for Mastodon
|
||||
key: yarn-v4-{{ checksum "mastodon/yarn.lock" }}
|
||||
save_yarn_cache_mastodon:
|
||||
description: Save yarn cache for Mastodon
|
||||
steps:
|
||||
- save_cache:
|
||||
name: Save yarn cache for Mastodon
|
||||
key: yarn-v4-{{ checksum "mastodon/yarn.lock" }}
|
||||
paths:
|
||||
- ~/.cache/yarn
|
||||
restore_bundler_cache:
|
||||
description: Restore bundler cache
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore bundler cache
|
||||
key: bundler-v4-{{ checksum "mastodon/Gemfile.lock" }}
|
||||
save_bundler_cache:
|
||||
description: Save bundler cache
|
||||
steps:
|
||||
- save_cache:
|
||||
name: Save bundler cache
|
||||
key: bundler-v4-{{ checksum "mastodon/Gemfile.lock" }}
|
||||
paths:
|
||||
- mastodon/vendor/bundle
|
||||
install_mastodon:
|
||||
description: Install Mastodon and set up Postgres/Redis
|
||||
steps:
|
||||
- run:
|
||||
name: Clone mastodon
|
||||
command: yarn clone-mastodon
|
||||
- restore_yarn_cache_mastodon
|
||||
- restore_bundler_cache
|
||||
- run:
|
||||
name: Install mastodon
|
||||
command: yarn install-mastodon
|
||||
- save_yarn_cache_mastodon
|
||||
- save_bundler_cache
|
||||
- run:
|
||||
name: Wait for postgres to be ready
|
||||
command: |
|
||||
for i in `seq 1 10`;
|
||||
do
|
||||
nc -z localhost 5432 && echo Success && exit 0
|
||||
echo -n .
|
||||
sleep 1
|
||||
done
|
||||
echo Failed waiting for postgres && exit 1
|
||||
- run:
|
||||
name: Wait for redis to be ready
|
||||
command: |
|
||||
for i in `seq 1 10`;
|
||||
do
|
||||
nc -z localhost 6379 && echo Success && exit 0
|
||||
echo -n .
|
||||
sleep 1
|
||||
done
|
||||
echo Failed waiting for redis && exit 1
|
||||
jobs:
|
||||
build_and_unit_test:
|
||||
executor: node
|
||||
steps:
|
||||
- checkout
|
||||
- install_node
|
||||
- restore_yarn_cache
|
||||
- run:
|
||||
name: Yarn install
|
||||
command: yarn install --immutable
|
||||
- save_yarn_cache
|
||||
- run:
|
||||
name: Lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: Copy vercel.json
|
||||
command: cp vercel.json vercel-old.json
|
||||
- run:
|
||||
name: Build
|
||||
command: yarn build
|
||||
- run:
|
||||
name: Check vercel.json unchanged
|
||||
command: |
|
||||
if ! diff -q vercel-old.json vercel.json &>/dev/null; then
|
||||
diff vercel-old.json vercel.json
|
||||
echo "vercel.json changed, run yarn build and make sure everything looks okay"
|
||||
exit 1
|
||||
fi
|
||||
- run:
|
||||
name: Unit tests
|
||||
command: yarn test-unit
|
||||
- save_workspace
|
||||
integration_test_readonly:
|
||||
executor: node_and_ruby
|
||||
steps:
|
||||
- install_mastodon_system_dependencies
|
||||
- install_browsers
|
||||
- install_node
|
||||
- load_workspace
|
||||
- install_mastodon
|
||||
- run:
|
||||
name: Read-only integration tests
|
||||
command: yarn test-in-ci-suite0
|
||||
integration_test_readwrite:
|
||||
executor: node_and_ruby
|
||||
steps:
|
||||
- install_mastodon_system_dependencies
|
||||
- install_browsers
|
||||
- install_node
|
||||
- load_workspace
|
||||
- install_mastodon
|
||||
- run:
|
||||
name: Read-write integration tests
|
||||
command: yarn test-in-ci-suite1
|
|
@ -0,0 +1,65 @@
|
|||
name: Read-only e2e tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.2
|
||||
env:
|
||||
POSTGRES_USER: pinafore
|
||||
POSTGRES_PASSWORD: pinafore
|
||||
POSTGRES_DB: pinafore_development
|
||||
POSTGRES_HOST: 127.0.0.1
|
||||
POSTGRES_PORT: 5432
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0.4'
|
||||
- name: Cache Mastodon bundler
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bundle-vendor-cache
|
||||
# cache based on masto version implicitly defined in mastodon-config.js
|
||||
key: masto-bundler-v3-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Cache Mastodon's and our yarn
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
# cache based on our version and masto version implicitly defined in mastodon-config.js
|
||||
# because we share the yarn cache
|
||||
key: masto-yarn-v1-${{ hashFiles('yarn.lock') }}-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Install Mastodon system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
fonts-noto-color-emoji \
|
||||
imagemagick \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn clone-mastodon
|
||||
- name: Move bundler cache so Mastodon can find it
|
||||
run: if [ -d ~/.bundle-vendor-cache ]; then mkdir -p ./mastodon/vendor && mv ~/.bundle-vendor-cache ./mastodon/vendor/bundle; fi
|
||||
- name: Read-only e2e tests
|
||||
run: yarn test-in-ci-suite0
|
||||
- name: Move bundler cache so GitHub Actions can find it
|
||||
run: mv ./mastodon/vendor/bundle ~/.bundle-vendor-cache
|
|
@ -0,0 +1,65 @@
|
|||
name: Read-write e2e tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.2
|
||||
env:
|
||||
POSTGRES_USER: pinafore
|
||||
POSTGRES_PASSWORD: pinafore
|
||||
POSTGRES_DB: pinafore_development
|
||||
POSTGRES_HOST: 127.0.0.1
|
||||
POSTGRES_PORT: 5432
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0.4'
|
||||
- name: Cache Mastodon bundler
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bundle-vendor-cache
|
||||
# cache based on masto version implicitly defined in mastodon-config.js
|
||||
key: masto-bundler-v3-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Cache Mastodon's and our yarn
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
# cache based on our version and masto version implicitly defined in mastodon-config.js
|
||||
# because we share the yarn cache
|
||||
key: masto-yarn-v1-${{ hashFiles('yarn.lock') }}-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Install Mastodon system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
fonts-noto-color-emoji \
|
||||
imagemagick \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn clone-mastodon
|
||||
- name: Move bundler cache so Mastodon can find it
|
||||
run: if [ -d ~/.bundle-vendor-cache ]; then mkdir -p ./mastodon/vendor && mv ~/.bundle-vendor-cache ./mastodon/vendor/bundle; fi
|
||||
- name: Read-write e2e tests
|
||||
run: yarn test-in-ci-suite1
|
||||
- name: Move bundler cache so GitHub Actions can find it
|
||||
run: mv ./mastodon/vendor/bundle ~/.bundle-vendor-cache
|
|
@ -0,0 +1,17 @@
|
|||
name: Unit tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn lint
|
||||
- run: yarn test-vercel-json
|
||||
- run: yarn test-unit
|
|
@ -9,6 +9,7 @@
|
|||
/static/inline-script.js.map
|
||||
/static/emoji-*.json
|
||||
/static/manifest.json
|
||||
/static/TwemojiCountryFlags.woff2
|
||||
/src/inline-script/checksum.js
|
||||
yarn-error.log
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ running on `localhost:3000`.
|
|||
### Running integration tests
|
||||
|
||||
The integration tests require running Mastodon itself,
|
||||
meaning the [Mastodon development guide](https://docs.joinmastodon.org/development/overview/)
|
||||
meaning the [Mastodon development guide](https://docs.joinmastodon.org/dev/setup/)
|
||||
is relevant here. In particular, you'll need a recent
|
||||
version of Ruby, Redis, and Postgres running. For a full list of deps, see `bin/setup-mastodon-in-travis.sh`.
|
||||
|
||||
|
@ -120,8 +120,8 @@ or
|
|||
|
||||
1. Run `rm -fr mastodon` to clear out all Mastodon data
|
||||
1. Comment out `await restoreMastodonData()` in `run-mastodon.js` to avoid actually populating the database with statuses/favorites/etc.
|
||||
2. Update the `GIT_TAG_OR_BRANCH` in `clone-mastodon.js` to whatever you want
|
||||
3. If the Ruby version changed (check Mastodon's `.ruby-version`), install it and update `RUBY_VERSION` in `mastodon-config.js` as well as the Ruby version in `.circleci/config.yml`.
|
||||
2. Update the `GIT_TAG` in `mastodon-config.js` to whatever you want
|
||||
3. If the Ruby version changed (check Mastodon's `.ruby-version`), install it and update `RUBY_VERSION` in `mastodon-config.js` as well as the Ruby version in `.github/workflows`.
|
||||
4. Run `yarn run-mastodon`
|
||||
5. Run `yarn backup-mastodon-data` to overwrite the data in `fixtures/`
|
||||
6. Uncomment `await restoreMastodonData()` in `run-mastodon.js`
|
||||
|
|
17
README.md
17
README.md
|
@ -1,4 +1,6 @@
|
|||
# Pinafore [![Build status](https://circleci.com/gh/nolanlawson/pinafore.svg?style=svg)](https://app.circleci.com/pipelines/gh/nolanlawson/pinafore)
|
||||
# Pinafore [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/)
|
||||
|
||||
_**Note:** Pinafore is unmaintained. Read [this](https://nolanlawson.com/2023/01/09/retiring-pinafore/). Original documentation follows._
|
||||
|
||||
An alternative web client for [Mastodon](https://joinmastodon.org), focused on speed and simplicity.
|
||||
|
||||
|
@ -6,7 +8,7 @@ Pinafore is available at [pinafore.social](https://pinafore.social). Beta releas
|
|||
|
||||
See the [user guide](https://github.com/nolanlawson/pinafore/blob/master/docs/User-Guide.md) for basic usage. See the [admin guide](https://github.com/nolanlawson/pinafore/blob/master/docs/Admin-Guide.md) if Pinafore cannot connect to your instance.
|
||||
|
||||
For updates and support, follow [@pinafore@mastodon.technology](https://mastodon.technology/@pinafore).
|
||||
For updates and support, follow [@pinafore@fosstodon.org](https://fosstodon.org/@pinafore).
|
||||
|
||||
## Browser support
|
||||
|
||||
|
@ -94,12 +96,13 @@ To keep your version of Pinafore up to date, you can use `git` to check out the
|
|||
Pinafore is a static site. When you run `yarn build`, static files will be
|
||||
written to `__sapper__/export`.
|
||||
|
||||
In theory you could host these static files yourself (e.g. using nginx or Apache), but
|
||||
it's not recommended, because:
|
||||
It is _not_ recommended to directly expose these files when self-hosting. Instead, you should use `node server.js` (e.g. with an
|
||||
nginx or Apache proxy in front). This adds several things you don't get from the raw static files:
|
||||
|
||||
- You'd have to set the [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers yourself,
|
||||
which are an important security feature.
|
||||
- Some routes are dynamic and need to be routed to the correct static file.
|
||||
- [CSP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (important for security)
|
||||
- Certain dynamic routes (less important because of Service Worker managing routing, but certain things could break if Service Workers are disabled in the user's browser)
|
||||
|
||||
Having an [nginx config generator](https://github.com/nolanlawson/pinafore/issues/1878) is currently an open issue.
|
||||
|
||||
## Developing and testing
|
||||
|
||||
|
|
|
@ -21,15 +21,8 @@ const JSON_TEMPLATE = {
|
|||
github: {
|
||||
silent: true
|
||||
},
|
||||
builds: [
|
||||
{
|
||||
src: 'package.json',
|
||||
use: '@now/static-build',
|
||||
config: {
|
||||
distDir: '__sapper__/export'
|
||||
}
|
||||
}
|
||||
],
|
||||
buildCommand: 'yarn build',
|
||||
outputDirectory: '__sapper__/export',
|
||||
routes: [
|
||||
{
|
||||
src: '^/service-worker\\.js$',
|
||||
|
@ -51,7 +44,13 @@ const JSON_TEMPLATE = {
|
|||
}
|
||||
},
|
||||
{
|
||||
src: '^/.*\\.(png|css|json|svg|jpe?g|map|txt|gz|webapp|woff|woff2)$',
|
||||
src: '^/.*\\.(png|jpe?g)$',
|
||||
headers: {
|
||||
'cache-control': 'public,max-age=31536000,immutable'
|
||||
}
|
||||
},
|
||||
{
|
||||
src: '^/.*\\.(css|json|svg|map|txt|gz|webapp|woff|woff2)$',
|
||||
headers: {
|
||||
'cache-control': 'public,max-age=3600'
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { promisify } from 'util'
|
|||
import childProcessPromise from 'child-process-promise'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { envFile, RUBY_VERSION } from './mastodon-config.js'
|
||||
import { envFile, GIT_TAG, GIT_URL, RUBY_VERSION } from './mastodon-config.js'
|
||||
import esMain from 'es-main'
|
||||
|
||||
const exec = childProcessPromise.exec
|
||||
|
@ -11,9 +11,6 @@ const writeFile = promisify(fs.writeFile)
|
|||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
const dir = __dirname
|
||||
|
||||
const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
|
||||
const GIT_TAG = 'v3.5.3'
|
||||
|
||||
const mastodonDir = path.join(dir, '../mastodon')
|
||||
|
||||
export default async function cloneMastodon () {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Designed to be run before yarn build, and then tested with test-vercel-json-unchanged.sh
|
||||
|
||||
cp ./vercel.json /tmp/vercel-old.json
|
|
@ -4,12 +4,14 @@ import { DEFAULT_LOCALE, LOCALE } from '../src/routes/_static/intl.js'
|
|||
import enUS from '../src/intl/en-US.js'
|
||||
import fr from '../src/intl/fr.js'
|
||||
import de from '../src/intl/de.js'
|
||||
import es from '../src/intl/es.js'
|
||||
|
||||
// TODO: make it so we don't have to explicitly list these out
|
||||
const locales = {
|
||||
'en-US': enUS,
|
||||
fr,
|
||||
de
|
||||
de,
|
||||
es
|
||||
}
|
||||
|
||||
const intl = locales[LOCALE]
|
||||
|
|
|
@ -43,8 +43,8 @@ async function setupMastodonDatabase () {
|
|||
async function installMastodonDependencies () {
|
||||
const cwd = mastodonDir
|
||||
const installCommands = [
|
||||
'gem update --system',
|
||||
'gem install bundler foreman',
|
||||
'gem install bundler -v 2.3.26 --no-document',
|
||||
'gem install foreman -v 0.87.2 --no-document',
|
||||
'bundle config set --local frozen \'true\'',
|
||||
'bundle install',
|
||||
'yarn --pure-lockfile'
|
||||
|
|
|
@ -18,7 +18,10 @@ DB_PASS=${DB_PASS}
|
|||
BIND=0.0.0.0
|
||||
`
|
||||
|
||||
export const RUBY_VERSION = '3.0.3'
|
||||
export const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
|
||||
export const GIT_TAG = 'v4.0.2'
|
||||
|
||||
export const RUBY_VERSION = '3.0.4'
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
export const mastodonDir = path.join(__dirname, '../mastodon')
|
||||
|
|
|
@ -9,11 +9,11 @@ function unrollThread (user, prefix, privacy, thread) {
|
|||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
res.push({
|
||||
user: user,
|
||||
user,
|
||||
post: {
|
||||
internalId: prefix + key,
|
||||
text: key,
|
||||
privacy: privacy,
|
||||
privacy,
|
||||
inReplyTo: parentKey && (prefix + parentKey)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -17,10 +17,10 @@ export async function restoreMastodonData () {
|
|||
console.log('Restoring mastodon data...')
|
||||
const internalIdsToIds = {}
|
||||
for (const action of actions) {
|
||||
if (!action.post) {
|
||||
// If the action is a boost, favorite, etc., then it needs to
|
||||
if (!action.post || /@/.test(action.post.text)) {
|
||||
// If the action is a boost, favorite, mention, etc., then it needs to
|
||||
// be delayed, otherwise it may appear in an unpredictable order and break the tests.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
console.log(JSON.stringify(action))
|
||||
const accessToken = users[action.user].accessToken
|
||||
|
|
|
@ -15,7 +15,7 @@ async function runMastodon () {
|
|||
const cwd = mastodonDir
|
||||
const promise = spawn('foreman', ['start'], { cwd, env })
|
||||
// don't bother writing to mastodon.log in CI; we can't read the file anyway
|
||||
const logFile = process.env.CIRCLECI ? '/dev/null' : 'mastodon.log'
|
||||
const logFile = process.env.CI ? '/dev/null' : 'mastodon.log'
|
||||
const log = fs.createWriteStream(logFile, { flags: 'a' })
|
||||
childProc = promise.childProcess
|
||||
childProc.stdout.pipe(log)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export default [
|
||||
{ id: 'pinafore-logo', src: 'src/static/sailboat.svg', inline: true },
|
||||
{ id: 'fa-arrow-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/arrow-left.svg' },
|
||||
{ id: 'fa-bell', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell.svg', inline: true },
|
||||
{ id: 'fa-bell-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell-o.svg' },
|
||||
{ id: 'fa-users', src: 'src/thirdparty/font-awesome-svg-png/white/svg/users.svg', inline: true },
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# In CI, we need to make sure the vercel.json file is built correctly,
|
||||
# or else it will mess up the deployment to Vercel
|
||||
|
||||
if ! diff -q /tmp/vercel-old.json ./vercel.json &>/dev/null; then
|
||||
diff /tmp/vercel-old.json ./vercel.json
|
||||
echo "vercel.json changed, run yarn build and make sure everything looks okay"
|
||||
exit 1
|
||||
fi
|
|
@ -5,9 +5,17 @@ Basically think of it as a "lay of the land" as well as "weird unusual stuff tha
|
|||
|
||||
## Overview
|
||||
|
||||
Pinafore uses [SvelteJS](https://svelte.technology) and [SapperJS](https://sapper.svelte.technology). Most of it is a fairly typical Svelte/Sapper project, but there
|
||||
Pinafore uses [SvelteJS](https://svelte.technology) v2 and [SapperJS](https://sapper.svelte.technology). Most of it is a fairly typical Svelte/Sapper project, but there
|
||||
are some quirks, which are described below. This list of quirks is non-exhaustive.
|
||||
|
||||
## Why Svelte v2 / Sapper ?
|
||||
|
||||
There is [no upgrade path from Svelte v2 to v3](https://github.com/sveltejs/svelte/issues/2462). Doing so would require manually migrating every component over. And in the end, it would probably not change the UX (user experience) of Pinafore – only the DX (developer experience).
|
||||
|
||||
Similarly, [Sapper would need to be migrated to SvelteKit](https://kit.svelte.dev/docs/migrating). Since Pinafore generates static files, there is probably not much benefit in moving from Sapper to SvelteKit.
|
||||
|
||||
For this reason, Pinafore has been stuck on Svelte v2 and Sapper for a long time. Migrating it is not something I've considered. The [v2 Svelte docs](https://v2.svelte.dev/) are still online, and share many similarities with Svelte v3.
|
||||
|
||||
## Prebuild process
|
||||
|
||||
The `template.html` is itself templated. The "template template" has some inline scripts, CSS, and SVGs
|
||||
|
|
|
@ -16,5 +16,4 @@ or
|
|||
|
||||
LOCALE=fr yarn dev
|
||||
|
||||
There is also an experimental `LOCALE_DIRECTION` environment variable for the direction (LTR versus RTL) which is
|
||||
exposed to the source code while building.
|
||||
To host a localized version of Pinafore using Vercel, you can see this example: [buildCommand in vercel.json for Spanish](https://github.com/nvdaes/vercelPinafore/blob/45c70fb2088fe5f2380a729dab83e6f3ab4e6291/vercel.json#L9).
|
56
package.json
56
package.json
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "pinafore",
|
||||
"description": "Alternative web client for Mastodon",
|
||||
"version": "2.2.2",
|
||||
"version": "2.6.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
"node": "^12.20.0 || ^14.13.1 || ^16.0.0 || ^18.0.0 || ^20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "standard && standard --plugin html 'src/routes/**/*.html'",
|
||||
|
@ -31,11 +31,14 @@
|
|||
"test-mastodon-suite0": "run-s wait-for-mastodon-to-start wait-for-mastodon-data testcafe-suite0",
|
||||
"test-mastodon-suite1": "run-s wait-for-mastodon-to-start wait-for-mastodon-data testcafe-suite1",
|
||||
"testcafe": "run-s testcafe-suite0 testcafe-suite1",
|
||||
"testcafe-suite0": "cross-env-shell testcafe $BROWSER tests/spec/0*",
|
||||
"testcafe-suite0": "cross-env-shell testcafe -c 2 $BROWSER tests/spec/0*",
|
||||
"testcafe-suite1": "cross-env-shell testcafe $BROWSER tests/spec/1*",
|
||||
"test-unit": "NODE_ENV=test mocha -r bin/browser-shim.js tests/unit/",
|
||||
"test-in-ci-suite0": "cross-env BROWSER=chrome:headless run-p --race run-mastodon start test-mastodon-suite0",
|
||||
"test-in-ci-suite1": "cross-env BROWSER=chrome:headless run-p --race run-mastodon start test-mastodon-suite1",
|
||||
"test-vercel-json": "run-s test-vercel-json-copy build test-vercel-json-test",
|
||||
"test-vercel-json-copy": "./bin/copy-vercel-json.sh",
|
||||
"test-vercel-json-test": "./bin/test-vercel-json-unchanged.sh",
|
||||
"wait-for-mastodon-to-start": "node bin/wait-for-mastodon-to-start.js",
|
||||
"wait-for-mastodon-data": "node bin/wait-for-mastodon-data.js",
|
||||
"backup-mastodon-data": "./bin/backup-mastodon-data.sh",
|
||||
|
@ -47,13 +50,14 @@
|
|||
"build-vercel-json": "node bin/build-vercel-json.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-listformat": "^6.5.2",
|
||||
"@formatjs/intl-locale": "^2.4.45",
|
||||
"@formatjs/intl-pluralrules": "^4.3.2",
|
||||
"@formatjs/intl-relativetimeformat": "^9.1.7",
|
||||
"@formatjs/intl-listformat": "^7.1.3",
|
||||
"@formatjs/intl-locale": "^3.0.7",
|
||||
"@formatjs/intl-pluralrules": "^5.1.4",
|
||||
"@formatjs/intl-relativetimeformat": "^11.1.4",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^14.1.0",
|
||||
"@rollup/plugin-replace": "^2.4.2",
|
||||
"@stdlib/utils-noop": "^0.0.10",
|
||||
"@stdlib/utils-noop": "^0.0.13",
|
||||
"arrow-key-navigation": "^1.2.0",
|
||||
"blurhash": "^1.1.5",
|
||||
"cheerio": "1.0.0-rc.10",
|
||||
|
@ -61,18 +65,18 @@
|
|||
"chokidar": "^3.5.3",
|
||||
"circular-dependency-plugin": "^5.2.2",
|
||||
"compression": "^1.7.4",
|
||||
"country-flag-emoji-polyfill": "^0.1.1",
|
||||
"country-flag-emoji-polyfill": "^0.1.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-dedoupe": "^0.1.1",
|
||||
"emoji-picker-element": "^1.11.1",
|
||||
"emoji-picker-element": "^1.13.1",
|
||||
"emoji-picker-element-data": "^1.3.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"encoding": "^0.1.13",
|
||||
"es-main": "^1.0.2",
|
||||
"es-main": "^1.2.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"esm": "^3.2.25",
|
||||
"events-light": "^1.0.5",
|
||||
"express": "^4.17.3",
|
||||
"express": "^4.18.2",
|
||||
"file-api": "^0.10.4",
|
||||
"file-drop-element": "^1.0.1",
|
||||
"file-loader": "^6.2.0",
|
||||
|
@ -98,32 +102,32 @@
|
|||
"rtl-detect": "^1.0.4",
|
||||
"safari-14-idb-fix": "^1.0.4",
|
||||
"sapper": "nolanlawson/sapper#for-pinafore-26",
|
||||
"sass": "^1.49.8",
|
||||
"sass": "^1.56.1",
|
||||
"stringz": "^2.1.0",
|
||||
"svelte": "^2.16.1",
|
||||
"svelte-extras": "^2.0.2",
|
||||
"svelte-loader": "^2.13.6",
|
||||
"svelte-transitions": "^1.2.0",
|
||||
"svgo": "^2.8.0",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"tesseract.js": "^2.1.5",
|
||||
"tesseract.js-core": "^2.2.0",
|
||||
"text-encoding": "^0.7.0",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"webpack": "^5.69.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-bundle-analyzer": "^4.7.0",
|
||||
"worker-loader": "^3.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"assert": "^2.0.0",
|
||||
"eslint-plugin-html": "^6.2.0",
|
||||
"fake-indexeddb": "^3.1.7",
|
||||
"globby": "^11.0.4",
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^11.0.0",
|
||||
"mocha": "^9.2.0",
|
||||
"standard": "^16.0.4",
|
||||
"testcafe": "^1.18.6"
|
||||
"eslint-plugin-html": "^7.1.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"globby": "^13.1.2",
|
||||
"husky": "^8.0.2",
|
||||
"lint-staged": "^13.0.3",
|
||||
"mocha": "^10.1.0",
|
||||
"standard": "^17.0.0",
|
||||
"testcafe": "^1.20.1"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
|
@ -196,7 +200,7 @@
|
|||
"*.html": "standard --fix --plugin html 'src/routes/**/*.html'"
|
||||
},
|
||||
"volta": {
|
||||
"node": "12.22.12",
|
||||
"node": "14.21.1",
|
||||
"yarn": "1.22.19"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,13 +32,30 @@
|
|||
|
||||
<!-- inline CSS -->
|
||||
|
||||
<style id="theBottomNavStyle" media="only x">
|
||||
:root {
|
||||
--nav-top: calc(100dvh - var(--nav-total-height));
|
||||
--nav-bottom: initial;
|
||||
--main-content-pad-top: 0px;
|
||||
--main-content-pad-bottom: var(--main-content-pad-vertical);
|
||||
--toast-gap-bottom: var(--nav-total-height);
|
||||
--fab-gap-top: 0px;
|
||||
}
|
||||
@supports not (height: 1dvh) {
|
||||
/* In browsers that don't support dvh, use the large-small-dynamic-viewport-units-polyfill */
|
||||
:root {
|
||||
--nav-top: calc((100 * var(--1dvh)) - var(--nav-total-height));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style id="theGrayscaleStyle" media="only x">
|
||||
/* Firefox doesn't seem to like applying filter: grayscale() to
|
||||
* the entire body, so we apply individually.
|
||||
*/
|
||||
img, svg, video,
|
||||
input[type="checkbox"], input[type="radio"],
|
||||
.inline-emoji, .theme-preview {
|
||||
.inline-emoji, .theme-preview, .account-profile {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,6 +16,7 @@ const {
|
|||
currentInstance,
|
||||
instanceThemes,
|
||||
disableCustomScrollbars,
|
||||
bottomNav,
|
||||
enableGrayscale,
|
||||
pushSubscription,
|
||||
loggedInInstancesInOrder,
|
||||
|
@ -33,12 +34,13 @@ if (currentInstance) {
|
|||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
if (theme !== INLINE_THEME) {
|
||||
if (theme !== INLINE_THEME || enableGrayscale) {
|
||||
// switch theme ASAP to minimize flash of default theme
|
||||
switchToTheme(theme, enableGrayscale)
|
||||
}
|
||||
|
||||
if (enableGrayscale) {
|
||||
// set the grayscale style on every img, svg, etc.
|
||||
document.getElementById('theGrayscaleStyle')
|
||||
.setAttribute('media', 'all') // enables the style
|
||||
}
|
||||
|
@ -53,6 +55,11 @@ if (disableCustomScrollbars) {
|
|||
.setAttribute('media', 'only x') // disables the style
|
||||
}
|
||||
|
||||
if (bottomNav) {
|
||||
document.getElementById('theBottomNavStyle')
|
||||
.setAttribute('media', 'all') // enables the style
|
||||
}
|
||||
|
||||
if (centerNav) {
|
||||
document.getElementById('theCenterNavStyle')
|
||||
.setAttribute('media', 'all') // enables the style
|
||||
|
|
|
@ -170,12 +170,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -188,8 +188,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Hefte {label} an',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {Zeichen} other {Zeichen}} über der Beschränkung',
|
||||
underLimit: '{count} {count, plural, =1 {Zeichen} other {Zeichen}} übrig',
|
||||
composeStatus: 'Tröt erstellen',
|
||||
postStatus: 'Tröt!',
|
||||
contentWarning: 'Inhaltswarnung',
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategorien',
|
||||
emojiUnsupportedMessage: 'Dein Browser unterstützt keine farbigen Emojis.',
|
||||
favoritesLabel: 'Favoriten',
|
||||
loadingMessage: 'Wird geladen…',
|
||||
networkErrorMessage: 'Konnte Emoji nicht laden. Versuche, die Seite neu zu laden.',
|
||||
regionLabel: 'Emoji auswählen',
|
||||
searchDescription: 'Wenn Suchergebnisse verfügbar sind, wähle sie mit Pfeil rauf und runter, dann Eingabetaste, aus.',
|
||||
searchLabel: 'Suchen',
|
||||
searchResultsLabel: 'Suchergebnisse',
|
||||
skinToneDescription: 'Wenn angezeigt, nutze Pfeiltasten rauf und runter zum Auswählen, Eingabe zum Akzeptieren.',
|
||||
skinToneLabel: 'Wähle einen Hautton (aktuell {skinTone})',
|
||||
skinTonesLabel: 'Hauttöne',
|
||||
skinTones: [
|
||||
'Standard',
|
||||
'Hell',
|
||||
'Mittel-hell',
|
||||
'Mittel',
|
||||
'Mittel-dunkel',
|
||||
'Dunkel'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Benutzerdefiniert',
|
||||
'smileys-emotion': 'Smileys und Emoticons',
|
||||
'people-body': 'Menschen und Körper',
|
||||
'animals-nature': 'Tiere und Natur',
|
||||
'food-drink': 'Essen und Trinken',
|
||||
'travel-places': 'Reisen und Orte',
|
||||
activities: 'Aktivitäten',
|
||||
objects: 'Objekte',
|
||||
symbols: 'Symbole',
|
||||
flags: 'Flaggen'
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
export default {
|
||||
categoriesLabel: 'Catégories',
|
||||
emojiUnsupportedMessage: 'Votre navigateur ne soutient pas les emojis en couleur.',
|
||||
favoritesLabel: 'Favoris',
|
||||
loadingMessage: 'Chargement en cours…',
|
||||
networkErrorMessage: 'Impossible de charger les emojis. Veuillez essayer de recharger.',
|
||||
regionLabel: 'Choisir un emoji',
|
||||
searchDescription: 'Quand les résultats sont disponisbles, appuyez la fleche vers le haut ou le bas et la touche entrée pour choisir.',
|
||||
searchLabel: 'Rechercher',
|
||||
searchResultsLabel: 'Résultats',
|
||||
skinToneDescription: 'Quand disponible, appuyez la fleche vers le haut ou le bas et la touch entrée pour choisir.',
|
||||
skinToneLabel: 'Choisir une couleur de peau (actuellement {skinTone})',
|
||||
skinTonesLabel: 'Couleurs de peau',
|
||||
skinTones: [
|
||||
'Défaut',
|
||||
'Clair',
|
||||
'Moyennement clair',
|
||||
'Moyen',
|
||||
'Moyennement sombre',
|
||||
'Sombre'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Customisé',
|
||||
'smileys-emotion': 'Les smileyes et les émoticônes',
|
||||
'people-body': 'Les gens et le corps',
|
||||
'animals-nature': 'Les animaux et la nature',
|
||||
'food-drink': 'La nourriture et les boissons',
|
||||
'travel-places': 'Les voyages et les endroits',
|
||||
activities: 'Les activités',
|
||||
objects: 'Les objets',
|
||||
symbols: 'Les symbols',
|
||||
flags: 'Les drapeaux'
|
||||
}
|
||||
}
|
|
@ -153,6 +153,8 @@ export default {
|
|||
<li><kbd>f</kbd> to favorite</li>
|
||||
<li><kbd>b</kbd> to boost</li>
|
||||
<li><kbd>r</kbd> to reply</li>
|
||||
<li><kbd>Escape</kbd> to close reply</li>
|
||||
<li><kbd>a</kbd> to bookmark</li>
|
||||
<li><kbd>i</kbd> to open images, video, or audio</li>
|
||||
<li><kbd>y</kbd> to show or hide sensitive media</li>
|
||||
<li><kbd>m</kbd> to mention the author</li>
|
||||
|
@ -174,12 +176,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -192,8 +194,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Pin {label}',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {character} other {characters}} over limit',
|
||||
underLimit: '{count} {count, plural, =1 {character} other {characters}} remaining',
|
||||
composeStatus: 'Compose toot',
|
||||
postStatus: 'Toot!',
|
||||
contentWarning: 'Content warning',
|
||||
|
@ -205,7 +205,7 @@ export default {
|
|||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
description: 'Description',
|
||||
descriptionLabel: 'Describe for the visually impaired (image, video) or auditorily impaired (audio, video)',
|
||||
descriptionLabel: 'Describe for visually impaired (image, video) or auditorily impaired (audio, video) people',
|
||||
markAsSensitive: 'Mark media as sensitive',
|
||||
// Polls
|
||||
createPoll: 'Create poll',
|
||||
|
@ -229,7 +229,7 @@ export default {
|
|||
postPrivacyLabel: 'Adjust privacy (currently {label})',
|
||||
addContentWarning: 'Add content warning',
|
||||
removeContentWarning: 'Remove content warning',
|
||||
altLabel: 'Describe for the visually impaired',
|
||||
altLabel: 'Describe for visually impaired people',
|
||||
extractText: 'Extract text from image',
|
||||
extractingText: 'Extracting text…',
|
||||
extractingTextCompletion: 'Extracting text ({percent}% complete)…',
|
||||
|
@ -368,6 +368,7 @@ export default {
|
|||
general: 'General',
|
||||
generalSettings: 'General settings',
|
||||
showSensitive: 'Show sensitive media by default',
|
||||
showAllSpoilers: 'Expand content warnings by default',
|
||||
showPlain: 'Show a plain gray color for sensitive media',
|
||||
allSensitive: 'Treat all media as sensitive',
|
||||
largeMedia: 'Show large inline images and videos',
|
||||
|
@ -382,7 +383,8 @@ export default {
|
|||
theme: 'Theme',
|
||||
themeForInstance: 'Theme for {instance}',
|
||||
disableCustomScrollbars: 'Disable custom scrollbars',
|
||||
centerNav: 'Center the navigation header',
|
||||
bottomNav: 'Place the navigation bar at the bottom of the screen',
|
||||
centerNav: 'Center the navigation bar',
|
||||
preferences: 'Preferences',
|
||||
hotkeySettings: 'Hotkey settings',
|
||||
disableHotkeys: 'Disable all hotkeys',
|
||||
|
@ -495,6 +497,9 @@ export default {
|
|||
other {video}
|
||||
}: {description}`,
|
||||
accountFollowedYou: '{name} followed you, {account}',
|
||||
accountSignedUp: '{name} signed up, {account}',
|
||||
accountRequestedFollow: '{name} requested to follow you, {account}',
|
||||
accountReported: '{name} filed a report, {account}',
|
||||
reblogCountsHidden: 'Boost counts hidden',
|
||||
favoriteCountsHidden: 'Favorite counts hidden',
|
||||
rebloggedTimes: `Boosted {count, plural,
|
||||
|
@ -509,6 +514,10 @@ export default {
|
|||
rebloggedYou: 'boosted your toot',
|
||||
favoritedYou: 'favorited your toot',
|
||||
followedYou: 'followed you',
|
||||
edited: 'edited their toot',
|
||||
requestedFollow: 'requested to follow you',
|
||||
reported: 'filed a report',
|
||||
signedUp: 'signed up',
|
||||
posted: 'posted',
|
||||
pollYouCreatedEnded: 'A poll you created has ended',
|
||||
pollYouVotedEnded: 'A poll you voted on has ended',
|
||||
|
@ -523,6 +532,7 @@ export default {
|
|||
// Accessible status labels
|
||||
accountRebloggedYou: '{account} boosted your toot',
|
||||
accountFavoritedYou: '{account} favorited your toot',
|
||||
accountEdited: '{account} edited their toot',
|
||||
rebloggedByAccount: 'Boosted by {account}',
|
||||
contentWarningContent: 'Content warning: {spoiler}',
|
||||
hasMedia: 'has media',
|
||||
|
@ -678,5 +688,12 @@ export default {
|
|||
statusOptions: 'Status options',
|
||||
confirm: 'Confirm',
|
||||
closeDialog: 'Close dialog',
|
||||
postPrivacy: 'Post privacy'
|
||||
postPrivacy: 'Post privacy',
|
||||
homeOnInstance: 'Home on {instance}',
|
||||
statusesTimelineOnInstance: 'Statuses: {timeline} timeline on {instance}',
|
||||
statusesHashtag: 'Statuses: #{hashtag} hashtag',
|
||||
statusesThread: 'Statuses: thread',
|
||||
statusesAccountTimeline: 'Statuses: account timeline',
|
||||
statusesList: 'Statuses: list',
|
||||
notificationsOnInstance: 'Notifications on {instance}'
|
||||
}
|
||||
|
|
|
@ -0,0 +1,696 @@
|
|||
export default {
|
||||
// Home page, basic <title> and <description>
|
||||
appName: 'Pinafore',
|
||||
appDescription: 'Un cliente web alternativo para Mastodon, centrado en la velocidad y la sencillez.',
|
||||
homeDescription: `
|
||||
<p>
|
||||
Pinafore es un cliente web para
|
||||
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
|
||||
diseñado para ser rápido y sencillo.
|
||||
</p>
|
||||
<p>
|
||||
Lee el
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">artículo introductorio en el blog</a>,
|
||||
o comienza iniciando sesión en una instancia:
|
||||
</p>`,
|
||||
logIn: 'Iniciar sesión',
|
||||
footer: `
|
||||
<p>
|
||||
Pinafore es
|
||||
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">software de código abierto</a>
|
||||
creado por
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
|
||||
y distribuido bajo la
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">Licencia AGPL</a>.
|
||||
Aquí está la <a href="/settings/about#privacy-policy" rel="prefetch">política de privacidad</a>.
|
||||
</p>
|
||||
`,
|
||||
// Manifest
|
||||
longAppName: 'Pinafore para Mastodon',
|
||||
newStatus: 'Nuevo toot',
|
||||
// Generic UI
|
||||
loading: 'Cargando',
|
||||
okay: 'OK',
|
||||
cancel: 'Cancelar',
|
||||
alert: 'Alerta',
|
||||
close: 'Cerrar',
|
||||
error: 'Error: {error}',
|
||||
errorShort: 'Error:',
|
||||
// Relative timestamps
|
||||
justNow: 'ahora mismo',
|
||||
// Navigation, page titles
|
||||
navItemLabel: `
|
||||
{label} {selected, select,
|
||||
true {(página actual)}
|
||||
other {}
|
||||
} {name, select,
|
||||
notifications {{count, plural,
|
||||
=0 {}
|
||||
one {(1 notificación)}
|
||||
other {({count} notificaciones)}
|
||||
}}
|
||||
community {{count, plural,
|
||||
=0 {}
|
||||
one {(1 solicitud de seguimiento)}
|
||||
other {({count} solicitudes de seguimiento)}
|
||||
}}
|
||||
other {}
|
||||
}
|
||||
`,
|
||||
blockedUsers: 'Usuarios bloqueados',
|
||||
bookmarks: 'Marcadores',
|
||||
directMessages: 'Mensajes directos',
|
||||
favorites: 'Favoritos',
|
||||
federated: 'Federada',
|
||||
home: 'Inicio',
|
||||
local: 'Local',
|
||||
notifications: 'Notificaciones',
|
||||
mutedUsers: 'Usuarios silenciados',
|
||||
pinnedStatuses: 'Toots fijados',
|
||||
followRequests: 'Solicitudes de seguimiento',
|
||||
followRequestsLabel: `Solicitudes de seguimiento {hasFollowRequests, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}`,
|
||||
list: 'Lista',
|
||||
search: 'Buscar',
|
||||
pageHeader: 'Encabezado de página',
|
||||
goBack: 'Retroceder',
|
||||
back: 'Atrás',
|
||||
profile: 'Perfil',
|
||||
federatedTimeline: 'Cronología federada',
|
||||
localTimeline: 'Cronología local',
|
||||
// community page
|
||||
community: 'Comunidad',
|
||||
pinnableTimelines: 'Cronologías que puedes fijar',
|
||||
timelines: 'Cronologías',
|
||||
lists: 'Listas',
|
||||
instanceSettings: 'Opciones para instancia',
|
||||
notificationMentions: 'Notificación de menciones',
|
||||
profileWithMedia: 'Perfil con multimedia',
|
||||
profileWithReplies: 'Perfil con respuestas',
|
||||
hashtag: 'Hashtag',
|
||||
// not logged in
|
||||
profileNotLoggedIn: 'Aquí se mostrará una cronología de usuario cuando hayas iniciado sesión.',
|
||||
bookmarksNotLoggedIn: 'Tus marcadores se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
directMessagesNotLoggedIn: 'Tus mensajes directos se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
favoritesNotLoggedIn: 'Tus favoritos se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
federatedTimelineNotLoggedIn: 'Tu cronología federada se mostrará aquí cuando hayas iniciado sesión.',
|
||||
localTimelineNotLoggedIn: 'Tu cronología localse mostrará aquí cuando hayas iniciado sesión.',
|
||||
searchNotLoggedIn: 'Puedes buscar una vez que inicias sesión en una instancia.',
|
||||
communityNotLoggedIn: 'Las opciones para comunidad se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
listNotLoggedIn: 'Aquí se mostrará una lista cuando hayas iniciado sesión.',
|
||||
notificationsNotLoggedIn: 'Tus notificaciones se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
notificationMentionsNotLoggedIn: 'Las notificaciones de tus menciones se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
statusNotLoggedIn: 'Aquí se mostrará un hilo de toots cuando hayas iniciado sesión.',
|
||||
tagNotLoggedIn: 'Aquí se mostrará una cronología de hashtags cuando hayas iniciado sesión.',
|
||||
// Notification subpages
|
||||
filters: 'Filtros',
|
||||
all: 'Todo',
|
||||
mentions: 'Menciones',
|
||||
// Follow requests
|
||||
approve: 'Aceptar',
|
||||
reject: 'Rechazar',
|
||||
// Hotkeys
|
||||
hotkeys: 'Atajos de teclado',
|
||||
global: 'Globales',
|
||||
timeline: 'Cronología',
|
||||
media: 'Multimedia',
|
||||
globalHotkeys: `
|
||||
{leftRightChangesFocus, select,
|
||||
true {
|
||||
<li><kbd>→</kbd> para ir al elemento enfocable siguiente</li>
|
||||
<li><kbd>←</kbd> para ir al elemento enfocable anterior</li>
|
||||
}
|
||||
other {}
|
||||
}
|
||||
<li>
|
||||
<kbd>1</kbd> - <kbd>6</kbd>
|
||||
{leftRightChangesFocus, select,
|
||||
true {}
|
||||
other {o <kbd>←</kbd>/<kbd>→</kbd>}
|
||||
}
|
||||
para cambiar de columna
|
||||
</li>
|
||||
<li><kbd>7</kbd> o <kbd>c</kbd> para redactar un nuevo toot</li>
|
||||
<li><kbd>s</kbd> o <kbd>/</kbd> para buscar</li>
|
||||
<li><kbd>g</kbd> + <kbd>h</kbd> para ir a inicio</li>
|
||||
<li><kbd>g</kbd> + <kbd>n</kbd> para ir a notificaciones</li>
|
||||
<li><kbd>g</kbd> + <kbd>l</kbd> to para ir a la cronología local</li>
|
||||
<li><kbd>g</kbd> + <kbd>t</kbd> para ir a la cronología federada</li>
|
||||
<li><kbd>g</kbd> + <kbd>c</kbd> para ir a la página comunidad</li>
|
||||
<li><kbd>g</kbd> + <kbd>d</kbd> para ir a la página de mensajes directos</li>
|
||||
<li><kbd>h</kbd> o <kbd>?</kbd> para abrir o cerrar el diálogo de ayuda</li>
|
||||
<li><kbd>Backspace</kbd> para retroceder, cerrar diálogos</li>
|
||||
`,
|
||||
timelineHotkeys: `
|
||||
<li><kbd>j</kbd> o <kbd>↓</kbd> para activar el toot siguiente</li>
|
||||
<li><kbd>k</kbd> o <kbd>↑</kbd> para activar el toot anterior</li>
|
||||
<li><kbd>.</kbd> para mostrar más y desplazarse al principio</li>
|
||||
<li><kbd>o</kbd> para abrir</li>
|
||||
<li><kbd>f</kbd> para marcar como favorito</li>
|
||||
<li><kbd>b</kbd> para reenviar</li>
|
||||
<li><kbd>r</kbd> para responder</li>
|
||||
<li><kbd>Escape</kbd> para cerrar respuesta</li>
|
||||
<li><kbd>a</kbd> para marcador</li>
|
||||
<li><kbd>i</kbd> para abrir imágenes, vídeo o audio</li>
|
||||
<li><kbd>y</kbd> para mostrar u ocultar multimedia sensible</li>
|
||||
<li><kbd>m</kbd> para mencionar al autor</li>
|
||||
<li><kbd>p</kbd> para abrir el perfil del autor</li>
|
||||
<li><kbd>l</kbd> para abrir el enlace de la publicación en una nueva pestaña</li>
|
||||
<li><kbd>x</kbd> para mostrar u ocultar el texto tras una advertencia de contenido</li>
|
||||
<li><kbd>z</kbd> para mostrar u ocultar todas las advertencias de contenido en un hilo</li>
|
||||
`,
|
||||
mediaHotkeys: `
|
||||
<li><kbd>←</kbd> / <kbd>→</kbd> para ir a siguiente o anterior</li>
|
||||
`,
|
||||
// Community page, tabs
|
||||
tabLabel: `{label} {current, select,
|
||||
true {(Actual)}
|
||||
other {}
|
||||
}`,
|
||||
pageTitle: `
|
||||
{hasNotifications, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
{pinned, select,
|
||||
true {(página fijada)}
|
||||
other {(Página no fijada)}
|
||||
}
|
||||
}
|
||||
other {}
|
||||
}`,
|
||||
pinPage: 'Fijar {label}',
|
||||
// Status composition
|
||||
composeStatus: 'Redactar toot',
|
||||
postStatus: 'Toot!',
|
||||
contentWarning: 'Advertencia de contenido',
|
||||
dropToUpload: 'Soltar para subir',
|
||||
invalidFileType: 'Tipo de fichero no válido',
|
||||
composeLabel: '¿En qué estás pensando?',
|
||||
autocompleteDescription: 'Cuando haya disponibles resultados de autocompletado, pulsa las flechas arriba o abajo y enter para seleccionar.',
|
||||
mediaUploads: 'Subidas multimedia',
|
||||
edit: 'Editar',
|
||||
delete: 'Borrar',
|
||||
description: 'Descripción',
|
||||
descriptionLabel: 'Describir para las personas con discapacidad visual (imagen, vídeo) o con discapacidad auditiva (audio, vídeo)',
|
||||
markAsSensitive: 'Marcar multimedia como sensible',
|
||||
// Polls
|
||||
createPoll: 'Crear encuesta',
|
||||
removePollChoice: 'Eliminar opción {index}',
|
||||
pollChoiceLabel: 'Opción {index}',
|
||||
multipleChoice: 'Selección múltiple',
|
||||
pollDuration: 'Duración de la encuesta',
|
||||
fiveMinutes: '5 minutos',
|
||||
thirtyMinutes: '30 minutos',
|
||||
oneHour: '1 hora',
|
||||
sixHours: '6 horas',
|
||||
twelveHours: '12 horas',
|
||||
oneDay: '1 día',
|
||||
threeDays: '3 días',
|
||||
sevenDays: '7 días',
|
||||
never: 'Nunca',
|
||||
addEmoji: 'Insertar emoji',
|
||||
addMedia: 'Añadir multimedia (imágenes, vídeo, audio)',
|
||||
addPoll: 'Añadir encuesta',
|
||||
removePoll: 'Eliminar encuesta',
|
||||
postPrivacyLabel: 'Ajustar privacidad (actualmente {label})',
|
||||
addContentWarning: 'Añadir advertencia de contenido',
|
||||
removeContentWarning: 'Eliminar advertencia de contenido',
|
||||
altLabel: 'Describir para las personas con discapacidad visual',
|
||||
extractText: 'Extraer texto de imagen',
|
||||
extractingText: 'Extrayendo texto…',
|
||||
extractingTextCompletion: 'Extrayendo texto ({percent}% completado)…',
|
||||
unableToExtractText: 'No se puede extraer texto.',
|
||||
// Account options
|
||||
followAccount: 'Seguir a {account}',
|
||||
unfollowAccount: 'Dejar de seguir a {account}',
|
||||
blockAccount: 'Bloquear a {account}',
|
||||
unblockAccount: 'Desbloquear a {account}',
|
||||
muteAccount: 'Silenciar a {account}',
|
||||
unmuteAccount: 'Dejar de silenciar a Unmute {account}',
|
||||
showReblogsFromAccount: 'Mostrar toots reenviados por {account}',
|
||||
hideReblogsFromAccount: 'Ocultar toots reenviados por {account}',
|
||||
showDomain: 'Dejar de ocultar {domain}',
|
||||
hideDomain: 'Ocultar {domain}',
|
||||
reportAccount: 'Denunciar a {account}',
|
||||
mentionAccount: 'Mencionar a {account}',
|
||||
copyLinkToAccount: 'Copiar enlace a cuenta',
|
||||
copiedToClipboard: 'Copiado al portapapeles',
|
||||
// Media dialog
|
||||
navigateMedia: 'Navegar por elementos multimedia',
|
||||
showPreviousMedia: 'Mostrar multimedia anterior',
|
||||
showNextMedia: 'Mostrar multimedia siguiente',
|
||||
enterPinchZoom: 'Modo pinch-zoom',
|
||||
exitPinchZoom: 'Salir del modo pinch-zoom',
|
||||
showMedia: `Mostrar {index, select,
|
||||
1 {primer}
|
||||
2 {segundo}
|
||||
3 {tercero}
|
||||
other {cuarto}
|
||||
} multimedia {current, select,
|
||||
true {(actual)}
|
||||
other {}
|
||||
}`,
|
||||
previewFocalPoint: 'Previsualizar (punto focal)',
|
||||
enterFocalPoint: 'Introducir el punto focal (X, Y) para este multimedia',
|
||||
muteNotifications: 'Silenciar también las notificaciones',
|
||||
muteAccountConfirm: '¿Silenciar a {account}?',
|
||||
mute: 'Silenciar',
|
||||
unmute: 'Dejar de silenciar',
|
||||
zoomOut: 'Alejar',
|
||||
zoomIn: 'Acercar',
|
||||
// Reporting
|
||||
reportingLabel: 'Estás denunciando a {account} a los moderadores de {instance}.',
|
||||
additionalComments: 'Comentarios adicionales',
|
||||
forwardDescription: '?Reenviar también a los moderadores de {instance}?',
|
||||
forwardLabel: 'Reenviar a {instance}',
|
||||
unableToLoadStatuses: 'No se pueden cargar los toots recientes: {error}',
|
||||
report: 'Denunciar',
|
||||
noContent: '(Sin contenido)',
|
||||
noStatuses: 'No hay toots para denunciar',
|
||||
// Status options
|
||||
unpinFromProfile: 'Dejar de fijar en el perfil',
|
||||
pinToProfile: 'Fijar en el perfil',
|
||||
muteConversation: 'Silenciar conversación',
|
||||
unmuteConversation: 'Dejar de silenciar conversación',
|
||||
bookmarkStatus: 'Poner marcador al toot',
|
||||
unbookmarkStatus: 'Quitar marcador al toot',
|
||||
deleteAndRedraft: 'Borrar y volver a redactar',
|
||||
reportStatus: 'Denunciar toot',
|
||||
shareStatus: 'Compartir toot',
|
||||
copyLinkToStatus: 'Copiar enlace al toot',
|
||||
// Account profile
|
||||
profileForAccount: 'Perfil para {account}',
|
||||
statisticsAndMoreOptions: 'Estadísticas y más opciones',
|
||||
statuses: 'Toots',
|
||||
follows: 'Siguiendo',
|
||||
followers: 'Seguidores',
|
||||
moreOptions: 'Más opciones',
|
||||
followersLabel: 'Te han seguido {count}',
|
||||
followingLabel: 'Has seguido a {count}',
|
||||
followLabel: `Seguimiento {requested, select,
|
||||
true {(solicitud de seguimiento)}
|
||||
other {}
|
||||
}`,
|
||||
unfollowLabel: `Dejar de seguir {requested, select,
|
||||
true {(solicitud de seguimiento)}
|
||||
other {}
|
||||
}`,
|
||||
notify: 'Suscribirse a {account}',
|
||||
denotify: 'Cancelar suscripción a {account}',
|
||||
subscribedAccount: 'Te has suscrito a la cuenta',
|
||||
unsubscribedAccount: 'Has cancelado tu suscripción a la cuenta',
|
||||
unblock: 'Desbloquear',
|
||||
nameAndFollowing: 'Nombre y seguimientos',
|
||||
clickToSeeAvatar: 'Haz clic para ver el avatar',
|
||||
opensInNewWindow: '{label} (Se abre en nueva ventana)',
|
||||
blocked: 'Bloqueado',
|
||||
domainHidden: 'Dominio oculto',
|
||||
muted: 'Silenciado',
|
||||
followsYou: 'Te está siguiendo',
|
||||
avatarForAccount: 'Avatar para {account}',
|
||||
fields: 'Campos',
|
||||
accountHasMoved: '{account} se ha trasladado:',
|
||||
profilePageForAccount: 'Página de perfil para {account}',
|
||||
// About page
|
||||
about: 'Acerca de',
|
||||
aboutApp: 'Acerca de Pinafore',
|
||||
aboutAppDescription: `
|
||||
<p>
|
||||
Pinafore es
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore">software libre y de código abierto</a>
|
||||
creado por
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
|
||||
y distribuido bajo la
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">GNU Affero General Public License</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="privacy-policy">Política de privacidad</h2>
|
||||
|
||||
<p>
|
||||
Pinafore no almacena ninguna información personal en sus servidores,
|
||||
incluyendo, pero no limitándose a nombres, direcciones de correo electrónico,
|
||||
direcciones IP, posts y fotos.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Pinafore es un sitio estático. Todos los datos son almacenados en tu navegador y compartidos con las instancias del fediverso
|
||||
a las que te conectas.
|
||||
</p>
|
||||
|
||||
<h2>Créditos</h2>
|
||||
|
||||
<p>
|
||||
Iconos proporcionados por <a rel="noopener" target="_blank" href="http://fontawesome.io/">Font Awesome</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Logo gracias a "sailboat" por Gregor Cresnar, de
|
||||
<a rel="noopener" target="_blank" href="https://thenounproject.com/">the Noun Project</a>.
|
||||
</p>`,
|
||||
// Settings
|
||||
settings: 'Opciones de configuración',
|
||||
general: 'General',
|
||||
generalSettings: 'Opciones generales',
|
||||
showSensitive: 'Mostrar multimedia sensible por defecto',
|
||||
showPlain: 'Mostrar un color gris liso para multimedia sensible',
|
||||
allSensitive: 'Tratar todo multimedia como sensible',
|
||||
largeMedia: 'Mostrar imágenes y vídeos grandes incrustados',
|
||||
autoplayGifs: 'Reproducir automáticamente GIFs animados',
|
||||
hideCards: 'Ocultar paneles de previsualización de enlaces',
|
||||
underlineLinks: 'Subrayar enlaces en toots y perfiles',
|
||||
accessibility: 'Accesibilidad',
|
||||
reduceMotion: 'Reducir movimiento en animaciones de la interfaz',
|
||||
disableTappable: 'Deshabilitar área para tocar en todo el toot',
|
||||
removeEmoji: 'Eliminar emoji de nombres de usuario',
|
||||
shortAria: 'Usar etiquetas ARIA cortas para artículos',
|
||||
theme: 'Diseño visual',
|
||||
themeForInstance: 'Diseño visual para {instance}',
|
||||
disableCustomScrollbars: 'Deshabilitar barras deslizantes personalizadas',
|
||||
bottomNav: 'Situar la barra de navegación al final de la pantalla',
|
||||
centerNav: 'Centrar la barra de navegación',
|
||||
preferences: 'Preferencias',
|
||||
hotkeySettings: 'Opciones para atajos de teclado',
|
||||
disableHotkeys: 'Deshabilitar todos los atajos de teclado',
|
||||
leftRightArrows: 'Las flechas izquierda/derecha cambian el foco en vez de columnas/multimedia',
|
||||
guide: 'Guía',
|
||||
reload: 'Recargar',
|
||||
// Wellness settings
|
||||
wellness: 'Bienestar',
|
||||
wellnessSettings: 'Opciones para el bienestar',
|
||||
wellnessDescription: `Las opciones para el bienestar están diseñadas para reducir los aspectos que inducen adicción o ansiedad en las redes sociales.
|
||||
Elige cualquier opción que vaya bien para ti.`,
|
||||
enableAll: 'Habilitar todos',
|
||||
metrics: 'Métricas',
|
||||
hideFollowerCount: 'Ocultar recuento de seguidores (hasta 10)',
|
||||
hideReblogCount: 'Ocultar recuento de reenvíos',
|
||||
hideFavoriteCount: 'Ocultar recuento de favoritos',
|
||||
hideUnread: 'Ocultar recuento de notificaciones sin leer (es decir, el punto rojo)',
|
||||
// The quality that makes something seem important or interesting because it seems to be happening now
|
||||
immediacy: 'Inmediatez',
|
||||
showAbsoluteTimestamps: 'Mostrar marcas de tiempo absolutas (p.ej., "3 de marzo") en vez de marcas de tiempo relativas (p. ej., "hace 5 minutos")',
|
||||
ui: 'Interfaz',
|
||||
grayscaleMode: 'Modo escala de grises',
|
||||
wellnessFooter: `Estas opciones están parcialmente basadas en pautas del
|
||||
<a rel="noopener" target="_blank" href="https://humanetech.com">Center for Humane Technology</a>.`,
|
||||
// This is a link: "You can filter or disable notifications in the _instance settings_"
|
||||
filterNotificationsPre: 'Puedes filtrar o deshabilitar notificaciones en',
|
||||
filterNotificationsText: 'opciones para instancia',
|
||||
filterNotificationsPost: '',
|
||||
// Custom tooltips, like "Disable _infinite scroll_", where you can click _infinite scroll_
|
||||
// to see a description. It's hard to properly internationalize, so we just break up the strings.
|
||||
disableInfiniteScrollPre: 'Deshabilitar',
|
||||
disableInfiniteScrollText: 'desplazamiento infinito',
|
||||
disableInfiniteScrollDescription: `Cuando el desplazamiento infinito esté deshabilitado, los nuevos toots no se mostrarán automáticamente al final o al principio de la cronología. En vez de esto, habrá botones que te permitirán
|
||||
cargar más contenido a demanda.`,
|
||||
disableInfiniteScrollPost: '',
|
||||
// Instance settings
|
||||
loggedInAs: 'Iniciaste sesión como',
|
||||
homeTimelineFilters: 'Filtros para la cronología Inicio',
|
||||
notificationFilters: 'Filtros para notificaciones',
|
||||
pushNotifications: 'Notificaciones Push',
|
||||
// Add instance page
|
||||
storageError: `Parece que Pinafore no puede almacenar datos localmente. ¿Está tu navegador en modo privado
|
||||
o bloqueando las cookies? Pinafore almacena todos los datos localmente, y requiere LocalStorage e
|
||||
IndexedDB para funcionar correctamente.`,
|
||||
javaScriptError: 'Debes habilitar JavaScript para iniciar sesión.',
|
||||
enterInstanceName: 'Introducir nombre de instancia',
|
||||
instanceColon: 'Instancia:',
|
||||
// Custom tooltip, concatenated together
|
||||
getAnInstancePre: '¿No tienes una',
|
||||
getAnInstanceText: 'instancia',
|
||||
getAnInstanceDescription: 'Una instancia es tu servidor de inicio de Mastodon, por ejemplo, mastodon.social o cybre.space.',
|
||||
getAnInstancePost: '?',
|
||||
joinMastodon: '¡Unirse a Mastodon!',
|
||||
instancesYouveLoggedInTo: 'Instancias en las que has iniciado sesión:',
|
||||
addAnotherInstance: 'Añadir otra instancia',
|
||||
youreNotLoggedIn: 'No has iniciado sesión en ninguna instancia.',
|
||||
currentInstanceLabel: `{instance} {current, select,
|
||||
true {(instancia actual)}
|
||||
other {}
|
||||
}`,
|
||||
// Link text
|
||||
logInToAnInstancePre: '',
|
||||
logInToAnInstanceText: 'Inicia sesión en una instancia',
|
||||
logInToAnInstancePost: 'para empezar a usar Pinafore.',
|
||||
// Another custom tooltip
|
||||
showRingPre: 'Mostrar siempre',
|
||||
showRingText: 'anillo del foco',
|
||||
showRingDescription: 'El anillo del foco es el contorno que muestra el elemento que actualmente tiene el foco. Por defecto solo se muestra cuando se usa el teclado (no el ratón o un dispositivo táctil), pero puedes elegir mostrarlo siempre.',
|
||||
showRingPost: '',
|
||||
instances: 'Instancias',
|
||||
addInstance: 'Añadir instancia',
|
||||
homeTimelineFilterSettings: 'Opciones para filtros de la cronología Inicio',
|
||||
showReblogs: 'Mostrar reenvíos',
|
||||
showReplies: 'Mostrar respuestas',
|
||||
switchOrLogOut: 'Seleccionar o cerrar sesión en esta instancia',
|
||||
switchTo: 'Seleccionar esta instancia',
|
||||
switchToInstance: 'Seleccionar instancia',
|
||||
switchToNameOfInstance: 'Seleccionar {instance}',
|
||||
logOut: 'Cerrar sesión',
|
||||
logOutOfInstanceConfirm: '¿Cerrar sesión en {instance}?',
|
||||
notificationFilterSettings: 'Opciones para filtros de notificaciones',
|
||||
// Push notifications
|
||||
browserDoesNotSupportPush: 'Tu navegador no admite notificaciones Push.',
|
||||
deniedPush: 'Has denegado el permiso para mostrar notificaciones.',
|
||||
pushNotificationsNote: 'Observa que solo puedes recibir notificaciones Push para una instancia al mismo tiempo.',
|
||||
pushSettings: 'Opciones para notificaciones Push',
|
||||
newFollowers: 'Nuevos seguidores',
|
||||
reblogs: 'Reenvíos',
|
||||
pollResults: 'Resultados de encuesta',
|
||||
subscriptions: 'Suscripción a toots',
|
||||
needToReauthenticate: 'Tienes que volver a autenticarte para habilitar las notificaciones Push. ¿Cerrr sesión en {instance}?',
|
||||
failedToUpdatePush: 'Se ha producido un fallo al actualizar las opciones para notificaciones Push: {error}',
|
||||
// Themes
|
||||
chooseTheme: 'Elegir un diseño visual',
|
||||
darkBackground: 'Fondo oscuro',
|
||||
lightBackground: 'Fondo claro',
|
||||
themeLabel: `{label} {default, select,
|
||||
true {(por defecto)}
|
||||
other {}
|
||||
}`,
|
||||
animatedImage: 'Imagen animada: {description}',
|
||||
showImage: `Mostrar {animated, select,
|
||||
true {animated}
|
||||
other {}
|
||||
} imagen: {description}`,
|
||||
playVideoOrAudio: `Reproducir {audio, select,
|
||||
true {audio}
|
||||
other {vídeo}
|
||||
}: {description}`,
|
||||
accountFollowedYou: '{name} te siguió, {account}',
|
||||
accountSignedUp: '{name} inició sesión, {account}',
|
||||
accountRequestedFollow: '{name} solicitó seguirte, {account}',
|
||||
accountReported: '{name} creó una denuncia, {account}',
|
||||
reblogCountsHidden: 'Recuento de reenvíos oculto',
|
||||
favoriteCountsHidden: 'Recuento de favoritos oculto',
|
||||
rebloggedTimes: `Reenviado {count, plural,
|
||||
one {1 vez}
|
||||
other {{count} veces}
|
||||
}`,
|
||||
favoritedTimes: `Marcado como favorito {count, plural,
|
||||
one {1 vez}
|
||||
other {{count} veces}
|
||||
}`,
|
||||
pinnedStatus: 'Toot fijado',
|
||||
rebloggedYou: 'reenvió tu toot',
|
||||
favoritedYou: 'marcó como favorito tu toot',
|
||||
followedYou: 'te siguió',
|
||||
edited: 'editó su toot',
|
||||
requestedFollow: 'solicitó seguirte',
|
||||
reported: 'creó una denuncia',
|
||||
signedUp: 'sesión iniciada',
|
||||
posted: 'publicado',
|
||||
pollYouCreatedEnded: 'Una encuesta que creaste ha finalizado',
|
||||
pollYouVotedEnded: 'Una encuesta en la que votaste ha finalizado',
|
||||
reblogged: 'reenviado',
|
||||
favorited: 'marcado como favorito',
|
||||
unreblogged: 'no reenviado',
|
||||
unfavorited: 'no marcado como favorito',
|
||||
showSensitiveMedia: 'Mostrar multimedia sensible',
|
||||
hideSensitiveMedia: 'Ocultar multimedia sensible',
|
||||
clickToShowSensitive: 'Contenido sensible. Haz clic para mostrar.',
|
||||
longPost: 'Publicación larga',
|
||||
// Accessible status labels
|
||||
accountRebloggedYou: '{account} reenvió tu toot',
|
||||
accountFavoritedYou: '{account} marcó como favorito tu toot',
|
||||
accountEdited: '{account} editó su toot',
|
||||
rebloggedByAccount: 'reenviado por {account}',
|
||||
contentWarningContent: 'Advertencia de contenido: {spoiler}',
|
||||
hasMedia: 'tiene multimedia',
|
||||
hasPoll: 'tiene encuesta',
|
||||
shortStatusLabel: '{privacy} toot de {account}',
|
||||
// Privacy types
|
||||
public: 'Público',
|
||||
unlisted: 'No listado',
|
||||
followersOnly: 'Solo seguidores',
|
||||
direct: 'Directo',
|
||||
// Themes
|
||||
themeRoyal: 'Royal',
|
||||
themeScarlet: 'Escarlata',
|
||||
themeSeafoam: 'Espuma de mar',
|
||||
themeHotpants: 'Hotpants',
|
||||
themeOaken: 'Roble',
|
||||
themeMajesty: 'Majesty',
|
||||
themeGecko: 'Gecko',
|
||||
themeGrayscale: 'Escala de grises',
|
||||
themeOzark: 'Ozark',
|
||||
themeCobalt: 'Cobalto',
|
||||
themeSorcery: 'Sorcery',
|
||||
themePunk: 'Punk',
|
||||
themeRiot: 'Riot',
|
||||
themeHacker: 'Hacker',
|
||||
themeMastodon: 'Mastodon',
|
||||
themePitchBlack: 'Tono negro',
|
||||
themeDarkGrayscale: 'Escala de gris oscuro',
|
||||
// Polls
|
||||
voteOnPoll: 'Votar en encuesta',
|
||||
pollChoices: 'Opciones de la encuesta',
|
||||
vote: 'Votar',
|
||||
pollDetails: 'Detalles de la encuesta',
|
||||
refresh: 'Actualizar',
|
||||
expires: 'Finaliza',
|
||||
expired: 'Finalizada',
|
||||
voteCount: `{count, plural,
|
||||
one {1 voto}
|
||||
other {{count} votos}
|
||||
}`,
|
||||
// Status interactions
|
||||
clickToShowThread: '{time} - haz clic para mostrar el hilo',
|
||||
showMore: 'Mostrar más',
|
||||
showLess: 'Mostrar menos',
|
||||
closeReply: 'Cerrar respuesta',
|
||||
cannotReblogFollowersOnly: 'No se puede reenviar porque es solo para seguidores',
|
||||
cannotReblogDirectMessage: 'No se puede reenviar porque es un mensaje directo',
|
||||
reblog: 'Reenviar',
|
||||
reply: 'Responder',
|
||||
replyToThread: 'Responder al hilo',
|
||||
favorite: 'Favorito',
|
||||
unfavorite: 'No favorito',
|
||||
// timeline
|
||||
loadingMore: 'Cargando más…',
|
||||
loadMore: 'Cargar más',
|
||||
showCountMore: 'Mostrar {count} más',
|
||||
nothingToShow: 'Nada para mostrar.',
|
||||
// status thread page
|
||||
statusThreadPage: 'Página de hilo de toots',
|
||||
status: 'Toot',
|
||||
// toast messages
|
||||
blockedAccount: 'Cuenta bloqueada',
|
||||
unblockedAccount: 'Cuenta desbloqueada',
|
||||
unableToBlock: 'No se puede bloquear la cuenta: {error}',
|
||||
unableToUnblock: 'No se puede desbloquear la cuenta: {error}',
|
||||
bookmarkedStatus: 'Toot con marcador',
|
||||
unbookmarkedStatus: 'Toot sin marcador',
|
||||
unableToBookmark: 'No se puede poner marcador: {error}',
|
||||
unableToUnbookmark: 'No se puede quitar marcador: {error}',
|
||||
cannotPostOffline: 'No puedes publicar mientras estás sin conexión',
|
||||
unableToPost: 'No se puede publicar el toot: {error}',
|
||||
statusDeleted: 'Toot borrado',
|
||||
unableToDelete: 'No se puede borrar el toot: {error}',
|
||||
cannotFavoriteOffline: 'No puedes marcar como favorito mientras estás sin conexión',
|
||||
cannotUnfavoriteOffline: 'No puedes quitar marca de favorito mientras estás sin conexión',
|
||||
unableToFavorite: 'No se puede marcar como favorito: {error}',
|
||||
unableToUnfavorite: 'No se puede quitar marca de favorito: {error}',
|
||||
followedAccount: 'Cuenta seguida',
|
||||
unfollowedAccount: 'Cuenta no seguida',
|
||||
unableToFollow: 'No se puede seguir a la cuenta: {error}',
|
||||
unableToUnfollow: 'No se puede dejar de seguir a la cuenta: {error}',
|
||||
accessTokenRevoked: 'El token de acceso fue anulado, se cerró sesión en {instance}',
|
||||
loggedOutOfInstance: 'Se cerró sesión en {instance}',
|
||||
failedToUploadMedia: 'Falló la subida del multimedia: {error}',
|
||||
mutedAccount: 'Cuenta silenciada',
|
||||
unmutedAccount: 'Cuenta no silenciada',
|
||||
unableToMute: 'No se puede silenciar la cuenta: {error}',
|
||||
unableToUnmute: 'No se puede dejar de silenciar la cuenta: {error}',
|
||||
mutedConversation: 'Conversación silenciada',
|
||||
unmutedConversation: 'Conversación no silenciada',
|
||||
unableToMuteConversation: 'No se puede silenciar la conversación: {error}',
|
||||
unableToUnmuteConversation: 'No se puede dejar de silenciar la conversación: {error}',
|
||||
unpinnedStatus: 'Toot no fijado',
|
||||
unableToPinStatus: 'No se puede fijar el toot: {error}',
|
||||
unableToUnpinStatus: 'No se puede dejar de fijar el toot: {error}',
|
||||
unableToRefreshPoll: 'No se puede actualizar la encuesta: {error}',
|
||||
unableToVoteInPoll: 'No se puede votar en la encuesta: {error}',
|
||||
cannotReblogOffline: 'No puedes reenviar mientras estás sin conexión.',
|
||||
cannotUnreblogOffline: 'No puedes deshacer reenvíos mientras estás sin conexión.',
|
||||
failedToReblog: 'Fallo al reenviar: {error}',
|
||||
failedToUnreblog: 'Fallo al deshacer reenvío: {error}',
|
||||
submittedReport: 'Denuncia enviada',
|
||||
failedToReport: 'Fallo al enviar denuncia: {error}',
|
||||
approvedFollowRequest: 'Solicitud de seguimiento aceptada',
|
||||
rejectedFollowRequest: 'Solicitud de seguimiento rechazada',
|
||||
unableToApproveFollowRequest: 'No se puede aceptar la solicitud de seguimiento: {error}',
|
||||
unableToRejectFollowRequest: 'No se puede rechazar la solicitud de seguimiento: {error}',
|
||||
searchError: 'Error durante la búsqueda: {error}',
|
||||
hidDomain: 'Dominio oculto',
|
||||
unhidDomain: 'Dominio no oculto',
|
||||
unableToHideDomain: 'No se puede ocultar el dominio: {error}',
|
||||
unableToUnhideDomain: 'No se puede dejar de ocultar el dominio: {error}',
|
||||
showingReblogs: 'Mostrando reenvíos',
|
||||
hidingReblogs: 'Ocultando reenvíos',
|
||||
unableToShowReblogs: 'No se puede mostrar los reenvíos: {error}',
|
||||
unableToHideReblogs: 'No se puede ocultar los reenvíos: {error}',
|
||||
unableToShare: 'No se puede compartir: {error}',
|
||||
unableToSubscribe: 'Imposible suscribirse: {error}',
|
||||
unableToUnsubscribe: 'Imposible dejar de suscribirse: {error}',
|
||||
showingOfflineContent: 'La petición a internet falló. Mostrando contenido sin conexión.',
|
||||
youAreOffline: 'Parece que estás sin conexión. Puedes leer contenido incluso sin conexión.',
|
||||
// Snackbar UI
|
||||
updateAvailable: 'Actualización de la aplicación disponible.',
|
||||
// Word/phrase filters
|
||||
wordFilters: 'Filtros de palabras',
|
||||
noFilters: 'No tienes ningún filtro de palabras.',
|
||||
wordOrPhrase: 'Palabra o frase',
|
||||
contexts: 'Contextos',
|
||||
addFilter: 'Añadir filtro',
|
||||
editFilter: 'Editar filtro',
|
||||
filterHome: 'Inicio y listas',
|
||||
filterNotifications: 'Notificaciones',
|
||||
filterPublic: 'Cronologías públicas',
|
||||
filterThread: 'Conversaciones',
|
||||
filterAccount: 'Perfiles',
|
||||
filterUnknown: 'Desconocido',
|
||||
expireAfter: 'Expira al cabo de',
|
||||
whereToFilter: 'Dónde filtrar',
|
||||
irreversible: 'Irreversible',
|
||||
wholeWord: 'Palabra completa',
|
||||
save: 'Guardar',
|
||||
updatedFilter: 'Filtro actualizado',
|
||||
createdFilter: 'Filtro creado',
|
||||
failedToModifyFilter: 'Fallo al modificar el filtro: {error}',
|
||||
deletedFilter: 'Filtro borrado',
|
||||
required: 'Requerido',
|
||||
// Dialogs
|
||||
profileOptions: 'Opciones de perfil',
|
||||
copyLink: 'Copiar enlace',
|
||||
emoji: 'Emoji',
|
||||
editMedia: 'Editar multimedia',
|
||||
shortcutHelp: 'Ayuda sobre atajos de teclado',
|
||||
statusOptions: 'Opciones de estado',
|
||||
confirm: 'Confirmar',
|
||||
closeDialog: 'Cerrar diálogo',
|
||||
postPrivacy: 'Privacidad del post',
|
||||
homeOnInstance: 'Inicio en {instance}',
|
||||
statusesTimelineOnInstance: 'Estados: {timeline} cronología en {instance}',
|
||||
statusesHashtag: 'Estados: #{hashtag} hashtag',
|
||||
statusesThread: 'Estados: hilo',
|
||||
statusesAccountTimeline: 'Estado: cronología de cuenta',
|
||||
statusesList: 'Estado: lista',
|
||||
notificationsOnInstance: 'Notificaciones en {instance}'
|
||||
}
|
|
@ -171,12 +171,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -189,8 +189,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Epingler {label}',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {caractère} other {caractères}} en dessus de la limite',
|
||||
underLimit: '{count} {count, plural, =1 {caractère} other {caractères}} qui reste',
|
||||
composeStatus: 'Ecrire un pouet',
|
||||
postStatus: 'Pouet!',
|
||||
contentWarning: 'Avertissement',
|
||||
|
|
|
@ -0,0 +1,690 @@
|
|||
export default {
|
||||
// Home page, basic <title> and <description>
|
||||
appName: 'Pinafore',
|
||||
appDescription: 'Альтернативный веб-клиент для Mastodon, ориентированный на скорость и простоту.',
|
||||
homeDescription: `
|
||||
<p>
|
||||
Pinafore — веб-клиент для
|
||||
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
|
||||
разработан для скорости и простоты.
|
||||
</p>
|
||||
<p>
|
||||
Прочитайте
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">вводную запись в блоге</a>,
|
||||
или начните работу, войдя в инстанс:
|
||||
</p>`,
|
||||
logIn: 'Войти',
|
||||
footer: `
|
||||
<p>
|
||||
Pinafore — это
|
||||
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">программное обеспечение с открытым исходным кодом</a>
|
||||
созданное
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Ноланом Лоусоном</a>
|
||||
и распространяемое под лицензией
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">AGPL License</a>.
|
||||
Здесь <a href="/settings/about#privacy-policy" rel="prefetch">политика конфиденциальности</a>.
|
||||
</p>
|
||||
`,
|
||||
// Manifest
|
||||
longAppName: 'Pinafore для Mastodon',
|
||||
newStatus: 'Новая запись',
|
||||
// Generic UI
|
||||
loading: 'Загрузка',
|
||||
okay: 'OK',
|
||||
cancel: 'Отмена',
|
||||
alert: 'Оповещение',
|
||||
close: 'Закрыть',
|
||||
error: 'Ошибка: {error}',
|
||||
errorShort: 'Ошибка:',
|
||||
// Relative timestamps
|
||||
justNow: 'только что',
|
||||
// Navigation, page titles
|
||||
navItemLabel: `
|
||||
{label} {selected, select,
|
||||
true {(current page)}
|
||||
other {}
|
||||
} {name, select,
|
||||
notifications {{count, plural,
|
||||
=0 {}
|
||||
one {(1 notification)}
|
||||
other {({count} notifications)}
|
||||
}}
|
||||
community {{count, plural,
|
||||
=0 {}
|
||||
one {(1 follow request)}
|
||||
other {({count} follow requests)}
|
||||
}}
|
||||
other {}
|
||||
}
|
||||
`,
|
||||
blockedUsers: 'Заблокированные пользователи',
|
||||
bookmarks: 'Закладки',
|
||||
directMessages: 'Личные сообщения',
|
||||
favorites: 'Избранное',
|
||||
federated: 'Федеративное',
|
||||
home: 'Главная',
|
||||
local: 'Локальная',
|
||||
notifications: 'Уведомления',
|
||||
mutedUsers: 'Игнорируемые пользователи',
|
||||
pinnedStatuses: 'Закрепленные записи',
|
||||
followRequests: 'Запросы на подписку',
|
||||
followRequestsLabel: `Запросы на подписку {hasFollowRequests, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}`,
|
||||
list: 'Список',
|
||||
search: 'Поиск',
|
||||
pageHeader: 'Заголовок страницы',
|
||||
goBack: 'Вернуться назад',
|
||||
back: 'Назад',
|
||||
profile: 'Профиль',
|
||||
federatedTimeline: 'Глобальная лента',
|
||||
localTimeline: 'Локальная лента',
|
||||
// community page
|
||||
community: 'Сообщество',
|
||||
pinnableTimelines: 'Закрепляемые ленты',
|
||||
timelines: 'Ленты',
|
||||
lists: 'Списки',
|
||||
instanceSettings: 'Настройки инстанса',
|
||||
notificationMentions: 'Уведомление упоминаний',
|
||||
profileWithMedia: 'Профиль с медиа',
|
||||
profileWithReplies: 'Профиль с ответами',
|
||||
hashtag: 'Хэштег',
|
||||
// not logged in
|
||||
profileNotLoggedIn: 'При входе в систему здесь появится лента пользователя.',
|
||||
bookmarksNotLoggedIn: 'Ваши закладки появятся здесь после входа в систему.',
|
||||
directMessagesNotLoggedIn: 'Ваши личные сообщения будут отображаться здесь после входа в систему.',
|
||||
favoritesNotLoggedIn: 'Ваше избранное появится здесь после входа в систему.',
|
||||
federatedTimelineNotLoggedIn: 'Ваша глобальная лента появится здесь после входа в систему.',
|
||||
localTimelineNotLoggedIn: 'Ваша локальная лента появится здесь после входа в систему.',
|
||||
searchNotLoggedIn: 'Вы можете выполнять поиск после входа в инстанс.',
|
||||
communityNotLoggedIn: 'Параметры сообщества появится здесь при входе в систему.',
|
||||
listNotLoggedIn: 'Список появится здесь после входа в систему.',
|
||||
notificationsNotLoggedIn: 'Ваши уведомления будут отображаться здесь после входа в систему.',
|
||||
notificationMentionsNotLoggedIn: 'Ваши уведомления с упоминаниями будут отображаться здесь после входа в систему.',
|
||||
statusNotLoggedIn: 'При входе в систему здесь появится тред сообщений.',
|
||||
tagNotLoggedIn: 'При входе в систему здесь появится лента с хэштегом.',
|
||||
// Notification subpages
|
||||
filters: 'Фильтры',
|
||||
all: 'Все',
|
||||
mentions: 'Упоминания',
|
||||
// Follow requests
|
||||
approve: 'Одобрить',
|
||||
reject: 'Отклонить',
|
||||
// Hotkeys
|
||||
hotkeys: 'Горячие клавиши',
|
||||
global: 'Глобальная',
|
||||
timeline: 'Лента',
|
||||
media: 'Медиа',
|
||||
globalHotkeys: `
|
||||
{leftRightChangesFocus, select,
|
||||
true {
|
||||
<li><kbd>→</kbd> перейти к следующему элементу</li>
|
||||
<li><kbd>←</kbd> перейти к предыдущему элементу</li>
|
||||
}
|
||||
other {}
|
||||
}
|
||||
<li>
|
||||
<kbd>1</kbd> - <kbd>6</kbd>
|
||||
{leftRightChangesFocus, select,
|
||||
true {}
|
||||
other {или <kbd>←</kbd>/<kbd>→</kbd>}
|
||||
}
|
||||
переключение столбцов
|
||||
</li>
|
||||
<li><kbd>7</kbd> или <kbd>c</kbd> создать запись</li>
|
||||
<li><kbd>s</kbd> или <kbd>/</kbd> искать</li>
|
||||
<li><kbd>g</kbd> + <kbd>h</kbd> главная</li>
|
||||
<li><kbd>g</kbd> + <kbd>n</kbd> уведомления</li>
|
||||
<li><kbd>g</kbd> + <kbd>l</kbd> локальная лента</li>
|
||||
<li><kbd>g</kbd> + <kbd>t</kbd> глобальная лента</li>
|
||||
<li><kbd>g</kbd> + <kbd>c</kbd> сообщество</li>
|
||||
<li><kbd>g</kbd> + <kbd>d</kbd> личные сообщения</li>
|
||||
<li><kbd>h</kbd> или <kbd>?</kbd> диалог справки</li>
|
||||
<li><kbd>Backspace</kbd> закрыть диалог, чтобы вернуться назад</li>
|
||||
`,
|
||||
timelineHotkeys: `
|
||||
<li><kbd>j</kbd> или <kbd>↓</kbd> следующая запись</li>
|
||||
<li><kbd>k</kbd> или <kbd>↑</kbd> предыдущая запись</li>
|
||||
<li><kbd>.</kbd> показать больше и прокрутить вверх</li>
|
||||
<li><kbd>o</kbd> открыть</li>
|
||||
<li><kbd>f</kbd> в избранное</li>
|
||||
<li><kbd>b</kbd> продвинуть</li>
|
||||
<li><kbd>r</kbd> ответить</li>
|
||||
<li><kbd>i</kbd> открыть изображения, видео или аудио</li>
|
||||
<li><kbd>y</kbd> показать или скрыть деликатное медиа</li>
|
||||
<li><kbd>m</kbd> упомянуть автора</li>
|
||||
<li><kbd>p</kbd> открыть профиль автора</li>
|
||||
<li><kbd>l</kbd> открыть ссылку карточки в новой вкладке</li>
|
||||
<li><kbd>x</kbd> показать или скрыть текст за предупреждением о содержимом</li>
|
||||
<li><kbd>z</kbd> показать или скрыть все предупреждения о содержимом в треде</li>
|
||||
`,
|
||||
mediaHotkeys: `
|
||||
<li><kbd>←</kbd> / <kbd>→</kbd> перейти к следующему или предыдущему</li>
|
||||
`,
|
||||
// Community page, tabs
|
||||
tabLabel: `{label} {current, select,
|
||||
true {(Current)}
|
||||
other {}
|
||||
}`,
|
||||
pageTitle: `
|
||||
{hasNotifications, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
{pinned, select,
|
||||
true {(Pinned page)}
|
||||
other {(Unpinned page)}
|
||||
}
|
||||
}
|
||||
other {}
|
||||
}`,
|
||||
pinPage: 'Закрепить {label}',
|
||||
// Status composition
|
||||
composeStatus: 'Создать запись',
|
||||
postStatus: 'Опубликовать!',
|
||||
contentWarning: 'Предупреждение о содержимом',
|
||||
dropToUpload: 'Перетащите для загрузки',
|
||||
invalidFileType: 'Неверный тип файла',
|
||||
composeLabel: "О чем Вы думаете?",
|
||||
autocompleteDescription: 'Когда результаты автозаполнения доступны, нажмите стрелки вверх или вниз и нажмите Enter, чтобы выбрать.',
|
||||
mediaUploads: 'Загрузка медиа',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
description: 'Описание',
|
||||
descriptionLabel: 'Добавьте описание для слабовидящих (изображение, видео) или слабослышащих (аудио, видео)',
|
||||
markAsSensitive: 'Отметить медиа как деликатное',
|
||||
// Polls
|
||||
createPoll: 'Создать опрос',
|
||||
removePollChoice: 'Удалить вариант {index}',
|
||||
pollChoiceLabel: 'Вариант {index}',
|
||||
multipleChoice: 'Несколько вариантов',
|
||||
pollDuration: 'Продолжительность опроса',
|
||||
fiveMinutes: '5 минут',
|
||||
thirtyMinutes: '30 минут',
|
||||
oneHour: '1 час',
|
||||
sixHours: '6 часов',
|
||||
twelveHours: '12 часов',
|
||||
oneDay: '1 день',
|
||||
threeDays: '3 дня',
|
||||
sevenDays: '7 дней',
|
||||
never: 'Никогда',
|
||||
addEmoji: 'Вставить эмодзи',
|
||||
addMedia: 'Добавить медиа (изображения, видео, аудио)',
|
||||
addPoll: 'Добавить опрос',
|
||||
removePoll: 'Удалить опрос',
|
||||
postPrivacyLabel: 'Настройка конфиденциальности (на данный момент {label})',
|
||||
addContentWarning: 'Добавить предупреждение о содержимом',
|
||||
removeContentWarning: 'Удалить предупреждение о содержимом',
|
||||
altLabel: 'Описание для слабовидящих',
|
||||
extractText: 'Извлечь текст из изображения',
|
||||
extractingText: 'Извлечение текста…',
|
||||
extractingTextCompletion: 'Извлечение текста ({percent}% завершено)…',
|
||||
unableToExtractText: 'Не удалось извлечь текст.',
|
||||
// Account options
|
||||
followAccount: 'Подписаться на {account}',
|
||||
unfollowAccount: 'Отписаться от {account}',
|
||||
blockAccount: 'Заблокировать {account}',
|
||||
unblockAccount: 'Разблокировать {account}',
|
||||
muteAccount: 'Игнорировать {account}',
|
||||
unmuteAccount: 'Не игнорировать {account}',
|
||||
showReblogsFromAccount: 'Показывать продвижения от {account}',
|
||||
hideReblogsFromAccount: 'Скрыть продвижения от {account}',
|
||||
showDomain: 'Показать {domain}',
|
||||
hideDomain: 'Скрыть домен {domain}',
|
||||
reportAccount: 'Пожаловаться на {account}',
|
||||
mentionAccount: 'Упомянуть {account}',
|
||||
copyLinkToAccount: 'Копировать ссылку на аккаунт',
|
||||
copiedToClipboard: 'Скопировано в буфер обмена',
|
||||
// Media dialog
|
||||
navigateMedia: 'Навигация по элементам мультимедиа',
|
||||
showPreviousMedia: 'Показать предыдущие медиа',
|
||||
showNextMedia: 'Показать следующее медиа',
|
||||
enterPinchZoom: 'Режим масштабирования щипком',
|
||||
exitPinchZoom: 'Выйти из режима щипкового масштабирования.',
|
||||
showMedia: `Показать {index, select,
|
||||
1 {first}
|
||||
2 {second}
|
||||
3 {third}
|
||||
other {fourth}
|
||||
} медиа {current, select,
|
||||
true {(current)}
|
||||
other {}
|
||||
}`,
|
||||
previewFocalPoint: 'Предварительный просмотр (фокус)',
|
||||
enterFocalPoint: 'Введите точку фокусировки (X, Y) для этого медиа',
|
||||
muteNotifications: 'Отключить уведомления',
|
||||
muteAccountConfirm: 'Игнорировать {account}?',
|
||||
mute: 'Игнорировать',
|
||||
unmute: 'Не игнорировать',
|
||||
zoomOut: 'Уменьшить',
|
||||
zoomIn: 'Увеличить',
|
||||
// Reporting
|
||||
reportingLabel: 'Вы отправляете жалобу на {account} модератору {instance}.',
|
||||
additionalComments: 'Дополнительные комментарии',
|
||||
forwardDescription: 'Переслать также модераторам {instance}?',
|
||||
forwardLabel: 'Переслать {instance}',
|
||||
unableToLoadStatuses: 'Не удалось загрузить последние записи: {error}',
|
||||
report: 'Жалоба',
|
||||
noContent: '(Без содержания)',
|
||||
noStatuses: 'Нет записей для жалобы',
|
||||
// Status options
|
||||
unpinFromProfile: 'Открепить от профиля',
|
||||
pinToProfile: 'Закрепить в профиле',
|
||||
muteConversation: 'Игнорировать обсуждение',
|
||||
unmuteConversation: 'Не игнорировать обсуждение',
|
||||
bookmarkStatus: 'Добавить в закладки',
|
||||
unbookmarkStatus: 'Удалить закладку',
|
||||
deleteAndRedraft: 'Удалить и исправить',
|
||||
reportStatus: 'Пожаловаться на запись',
|
||||
shareStatus: 'Поделиться записью',
|
||||
copyLinkToStatus: 'Копировать ссылку на запись',
|
||||
// Account profile
|
||||
profileForAccount: 'Профиль для {account}',
|
||||
statisticsAndMoreOptions: 'Статистика и другие параметры',
|
||||
statuses: 'Записи',
|
||||
follows: 'Подписки',
|
||||
followers: 'Подписчики',
|
||||
moreOptions: 'Больше опций',
|
||||
followersLabel: 'Подписчиков {count}',
|
||||
followingLabel: 'Пописок {count}',
|
||||
followLabel: `Подписаться {requested, select,
|
||||
true {(follow requested)}
|
||||
other {}
|
||||
}`,
|
||||
unfollowLabel: `Отписаться {requested, select,
|
||||
true {(follow requested)}
|
||||
other {}
|
||||
}`,
|
||||
notify: 'Подписаться на {account}',
|
||||
denotify: 'Отписаться от {account}',
|
||||
subscribedAccount: 'Подписан на аккаунт',
|
||||
unsubscribedAccount: 'Отписаться от аккаунта',
|
||||
unblock: 'Разблокировать',
|
||||
nameAndFollowing: 'Имя и подписка',
|
||||
clickToSeeAvatar: 'Нажмите, чтобы увидеть аватар',
|
||||
opensInNewWindow: '{label} (открывается в новом окне)',
|
||||
blocked: 'Заблокирован',
|
||||
domainHidden: 'Домен скрыт',
|
||||
muted: 'Игнорирован',
|
||||
followsYou: 'Подписан на вас',
|
||||
avatarForAccount: 'Аватар для {account}',
|
||||
fields: 'Поля',
|
||||
accountHasMoved: '{account} переехал:',
|
||||
profilePageForAccount: 'Страница профиля для {account}',
|
||||
// About page
|
||||
about: 'О нас',
|
||||
aboutApp: 'О Pinafore',
|
||||
aboutAppDescription: `
|
||||
<p>
|
||||
Pinafore — это
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore">бесплатное программное обеспечение с открытым исходным кодом</a>
|
||||
создано
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Ноланом Лоусоном</a>
|
||||
и распространяется под
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">GNU Affero General Public License</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="privacy-policy">Политика конфиденциальности</h2>
|
||||
|
||||
<p>
|
||||
Pinafore не хранит никакой личной информации на своих серверах,
|
||||
включая, помимо прочего, имена, адреса электронной почты,
|
||||
IP-адреса, сообщения и фотографии.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Pinafore — это статический сайт. Все данные хранятся локально в вашем браузере и передаются через Федиверс
|
||||
инстансы, к которым вы подключаетесь.
|
||||
</p>
|
||||
|
||||
<h2>Кредиты</h2>
|
||||
|
||||
<p>
|
||||
Иконки предоставлены <a rel="noopener" target="_blank" href="http://fontawesome.io/">Font Awesome</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Благодарим за логотип «парусника» Грегора Креснара из
|
||||
<a rel="noopener" target="_blank" href="https://thenounproject.com/"> Noun Project</a>.
|
||||
</p>`,
|
||||
// Settings
|
||||
settings: 'Настройки',
|
||||
general: 'Общие',
|
||||
generalSettings: 'Общие настройки',
|
||||
showSensitive: 'Показывать деликатные медиа по умолчанию',
|
||||
showPlain: 'Показать простой серый цвет для деликатного медиа',
|
||||
allSensitive: 'Относиться ко всем медиа как к деликатным',
|
||||
largeMedia: 'Показывать большие изображения и видео',
|
||||
autoplayGifs: 'Автовоспроизведение анимированных GIF-файлов',
|
||||
hideCards: 'Скрыть предварительный просмотр ссылок',
|
||||
underlineLinks: 'Подчеркивание ссылок в записях и профилях',
|
||||
accessibility: 'Специальные возможности',
|
||||
reduceMotion: 'Уменьшить анимацию интерфейса',
|
||||
disableTappable: 'Отключить нажимаемую область на записи.',
|
||||
removeEmoji: 'Удалить эмодзи из имен пользователей',
|
||||
shortAria: 'Использовать метки ARIA для коротких статей',
|
||||
theme: 'Тема',
|
||||
themeForInstance: 'Тема для {instance}',
|
||||
disableCustomScrollbars: 'Отключить пользовательские полосы прокрутки',
|
||||
bottomNav: 'Поместите панель навигации в нижнюю часть экрана',
|
||||
centerNav: 'Центрировать панель навигации',
|
||||
preferences: 'Предпочтения',
|
||||
hotkeySettings: 'Настройки горячих клавиш',
|
||||
disableHotkeys: 'Отключить все горячие клавиши',
|
||||
leftRightArrows: 'Клавиши со стрелками влево/вправо изменяют фокус, а не столбцы/медиа',
|
||||
guide: 'Руководство',
|
||||
reload: 'Перезагрузить',
|
||||
// Wellness settings
|
||||
wellness: 'Здоровье',
|
||||
wellnessSettings: 'Настройки здоровья',
|
||||
wellnessDescription: `Настройки здоровья предназначены для уменьшения вызывающих привыкание или тревогу аспектов социальных сетей.
|
||||
Выберите любые варианты, которые вам подходят.`,
|
||||
enableAll: 'Включить все',
|
||||
metrics: 'Метрики',
|
||||
hideFollowerCount: 'Скрыть количество подписчиков (до 10)',
|
||||
hideReblogCount: 'Скрыть количество продижений',
|
||||
hideFavoriteCount: 'Скрыть количество избранных',
|
||||
hideUnread: 'Скрыть количество непрочитанных уведомлений (например, красную точку)',
|
||||
// The quality that makes something seem important or interesting because it seems to be happening now
|
||||
immediacy: 'Оперативность',
|
||||
showAbsoluteTimestamps: 'Показывать абсолютные метки времени (например, «3-е марта») вместо относительных меток времени (например, «5 минут назад»)',
|
||||
ui: 'Интерфейс',
|
||||
grayscaleMode: 'Режим оттенков серого',
|
||||
wellnessFooter: `Эти настройки частично основаны на рекомендациях
|
||||
<a rel="noopener" target="_blank" href="https://humanetech.com">Центра гуманитарных технологий</a>.`,
|
||||
// This is a link: "You can filter or disable notifications in the _instance settings_"
|
||||
filterNotificationsPre: 'Вы можете фильтровать или отключать уведомления в',
|
||||
filterNotificationsText: 'настройках инстанса',
|
||||
filterNotificationsPost: '',
|
||||
// Custom tooltips, like "Disable _infinite scroll_", where you can click _infinite scroll_
|
||||
// to see a description. It's hard to properly internationalize, so we just break up the strings.
|
||||
disableInfiniteScrollPre: 'Отключить',
|
||||
disableInfiniteScrollText: 'бесконечную прокрутку',
|
||||
disableInfiniteScrollDescription: `Когда бесконечная прокрутка отключена, новые записи не будут автоматически появляться в
|
||||
внизу или вверху ленты. Вместо этого кнопки позволят вам
|
||||
загружать больше контента по запросу.`,
|
||||
disableInfiniteScrollPost: '',
|
||||
// Instance settings
|
||||
loggedInAs: 'Вы вошли как',
|
||||
homeTimelineFilters: 'Фильтры главной ленты',
|
||||
notificationFilters: 'Фильтры уведомлений',
|
||||
pushNotifications: 'Всплывающее уведомление',
|
||||
// Add instance page
|
||||
storageError: `Похоже, Pinafore не может хранить данные локально. Ваш браузер находится в приватном режиме
|
||||
или блокирует файлов cookie? Pinafore хранит все данные локально, и для этого требуется LocalStorage и
|
||||
IndexedDB для корректной работы.`,
|
||||
javaScriptError: 'Вы должны включить JavaScript, чтобы войти в систему.',
|
||||
enterInstanceName: 'Введите имя инстанса',
|
||||
instanceColon: 'Инстанс:',
|
||||
// Custom tooltip, concatenated together
|
||||
getAnInstancePre: "У вас нет",
|
||||
getAnInstanceText: 'инстанса',
|
||||
getAnInstanceDescription: 'Инстанс — это ваш домашний сервер Mastodon, например, mastodon.social или cybre.space.',
|
||||
getAnInstancePost: '?',
|
||||
joinMastodon: 'Присоединяйтесь к Mastodon!',
|
||||
instancesYouveLoggedInTo: "Инстансы, в которые вы вошли:",
|
||||
addAnotherInstance: 'Добавить другой инстанс',
|
||||
youreNotLoggedIn: "Вы не вошли ни в один инстанс.",
|
||||
currentInstanceLabel: `{instance} {current, select,
|
||||
true {(current instance)}
|
||||
other {}
|
||||
}`,
|
||||
// Link text
|
||||
logInToAnInstancePre: '',
|
||||
logInToAnInstanceText: 'Войти в инстанс',
|
||||
logInToAnInstancePost: 'чтобы начать использовать Pinafore.',
|
||||
// Another custom tooltip
|
||||
showRingPre: 'Всегда показывать',
|
||||
showRingText: 'кольцо фокусировки',
|
||||
showRingDescription: `TКольцо фокусировки — это контур, показывающий элемент, на котором в данный момент установлен фокус. По умолчанию отображается
|
||||
только при использовании клавиатуры (не мыши или сенсорного экрана), но вы можете выбрать, чтобы он отображался всегда.`,
|
||||
showRingPost: '',
|
||||
instances: 'Инстансы',
|
||||
addInstance: 'Добавить инстанс',
|
||||
homeTimelineFilterSettings: 'Настройки фильтров главной ленты',
|
||||
showReblogs: 'Показать продвижения',
|
||||
showReplies: 'Показывать ответы',
|
||||
switchOrLogOut: 'Переключитесь или выйдите из этого инстанса',
|
||||
switchTo: 'Переключиться на этот инстанс',
|
||||
switchToInstance: 'Переключиться на инстанс',
|
||||
switchToNameOfInstance: 'Переключиться на {instance}',
|
||||
logOut: 'Выйти',
|
||||
logOutOfInstanceConfirm: 'Выйти из {instance}?',
|
||||
notificationFilterSettings: 'Настройки фильтра уведомлений',
|
||||
// Push notifications
|
||||
browserDoesNotSupportPush: "Ваш браузер не поддерживает push-уведомления.",
|
||||
deniedPush: 'Вы запретили показывать уведомления.',
|
||||
pushNotificationsNote: 'Обратите внимание, что вы можете получать push-уведомления только для одного инстанса за раз.',
|
||||
pushSettings: 'Настройки push-уведомлений',
|
||||
newFollowers: 'Новые подписчики',
|
||||
reblogs: 'Продвижения',
|
||||
pollResults: 'Результаты опроса',
|
||||
subscriptions: 'Подписка на записи',
|
||||
needToReauthenticate: 'Вам необходимо пройти повторную аутентификацию, чтобы включить push-уведомления. Выйти из {instance}?',
|
||||
failedToUpdatePush: 'Не удалось обновить настройки push-уведомлений: {error}',
|
||||
// Themes
|
||||
chooseTheme: 'Выберите тему',
|
||||
darkBackground: 'Темный фон',
|
||||
lightBackground: 'Светлый фон',
|
||||
themeLabel: `{label} {default, select,
|
||||
true {(default)}
|
||||
other {}
|
||||
}`,
|
||||
animatedImage: 'Анимированное изображение: {description}',
|
||||
showImage: `Показывать {animated, select,
|
||||
true {animated}
|
||||
other {}
|
||||
} image: {description}`,
|
||||
playVideoOrAudio: `Воспроизводить {audio, select,
|
||||
true {audio}
|
||||
other {video}
|
||||
}: {description}`,
|
||||
accountFollowedYou: '{name} подписался на вас, {account}',
|
||||
accountSignedUp: '{name} зарегистрировался, {account}',
|
||||
reblogCountsHidden: 'Количество продвижений скрыто',
|
||||
favoriteCountsHidden: 'Количество избранного скрыто',
|
||||
rebloggedTimes: `Продвинуто {count, plural,
|
||||
one {1 time}
|
||||
other {{count} times}
|
||||
}`,
|
||||
favoritedTimes: `Добавлено в избранное {count, plural,
|
||||
one {1 time}
|
||||
other {{count} times}
|
||||
}`,
|
||||
pinnedStatus: 'Закрепленная запись',
|
||||
rebloggedYou: 'продвинул вашу запись',
|
||||
favoritedYou: 'добавил(-а) в избранное вашу запись',
|
||||
followedYou: 'подписался на вас',
|
||||
signedUp: 'зарегистрировался',
|
||||
posted: 'опубликовал',
|
||||
pollYouCreatedEnded: 'Созданный вами опрос завершен',
|
||||
pollYouVotedEnded: 'Опрос, в котором вы голосовали, завершен',
|
||||
reblogged: 'продвинул(-а)',
|
||||
favorited: 'добавил(-а) в избранное',
|
||||
unreblogged: 'отменил(-а) продвижение',
|
||||
unfavorited: 'удалил(-а) из избранного',
|
||||
showSensitiveMedia: 'Показать деликатное медиа',
|
||||
hideSensitiveMedia: 'Скрыть деликатное медиа',
|
||||
clickToShowSensitive: 'Деликатное содержимое. Нажмите, чтобы показать.',
|
||||
longPost: 'Длинная запись',
|
||||
// Accessible status labels
|
||||
accountRebloggedYou: '{account} продвинул(-а) вашу запись',
|
||||
accountFavoritedYou: '{account} добавил(-а) в избранное вашу запись',
|
||||
rebloggedByAccount: 'Продвинул(-а) {account}',
|
||||
contentWarningContent: 'Предупреждение о содержимом: {spoiler}',
|
||||
hasMedia: 'имеет медия',
|
||||
hasPoll: 'имеет опрос',
|
||||
shortStatusLabel: '{privacy} запись от {account}',
|
||||
// Privacy types
|
||||
public: 'Публичный',
|
||||
unlisted: 'Открытый',
|
||||
followersOnly: 'Только для подписчиков',
|
||||
direct: 'Личное сообщение',
|
||||
// Themes
|
||||
themeRoyal: 'Royal',
|
||||
themeScarlet: 'Scarlet',
|
||||
themeSeafoam: 'Seafoam',
|
||||
themeHotpants: 'Hotpants',
|
||||
themeOaken: 'Oaken',
|
||||
themeMajesty: 'Majesty',
|
||||
themeGecko: 'Gecko',
|
||||
themeGrayscale: 'Grayscale',
|
||||
themeOzark: 'Ozark',
|
||||
themeCobalt: 'Cobalt',
|
||||
themeSorcery: 'Sorcery',
|
||||
themePunk: 'Punk',
|
||||
themeRiot: 'Riot',
|
||||
themeHacker: 'Hacker',
|
||||
themeMastodon: 'Mastodon',
|
||||
themePitchBlack: 'Pitch Black',
|
||||
themeDarkGrayscale: 'Dark Grayscale',
|
||||
// Polls
|
||||
voteOnPoll: 'Голосовать в опросе',
|
||||
pollChoices: 'Варианты опроса',
|
||||
vote: 'Голосовать',
|
||||
pollDetails: 'Детали опроса',
|
||||
refresh: 'Обновить',
|
||||
expires: 'Завершается',
|
||||
expired: 'Завершено',
|
||||
voteCount: `{count, plural,
|
||||
one {1 vote}
|
||||
other {{count} голосов}
|
||||
}`,
|
||||
// Status interactions
|
||||
clickToShowThread: '{time} - нажмите, чтобы показать тред',
|
||||
showMore: 'Показать больше',
|
||||
showLess: 'Показать меньше',
|
||||
closeReply: 'Закрыть ответ',
|
||||
cannotReblogFollowersOnly: 'Невозможно продвинуть, потому что это только для подписчиков',
|
||||
cannotReblogDirectMessage: 'Невозможно продвинуть, потому что это личное сообщение',
|
||||
reblog: 'Продвинуть',
|
||||
reply: 'Ответить',
|
||||
replyToThread: 'Ответить в треде',
|
||||
favorite: 'Добавить в избранное',
|
||||
unfavorite: 'Удалить из избранного',
|
||||
// timeline
|
||||
loadingMore: 'Загружается ещё…',
|
||||
loadMore: 'Загрузить ещё',
|
||||
showCountMore: 'Показать ещё {count}',
|
||||
nothingToShow: 'Нечего показывать.',
|
||||
// status thread page
|
||||
statusThreadPage: 'Страница треда записи',
|
||||
status: 'Запись',
|
||||
// toast messages
|
||||
blockedAccount: 'Аккаунт заблокирован',
|
||||
unblockedAccount: 'Аккаунт разблокирован',
|
||||
unableToBlock: 'Не удалось заблокировать аккаунт: {error}',
|
||||
unableToUnblock: 'Не удалось разблокировать аккаунт: {error}',
|
||||
bookmarkedStatus: 'Запись добавлена в закладки',
|
||||
unbookmarkedStatus: 'Запись удалена из закладок',
|
||||
unableToBookmark: 'Не удалось добавить в закладки: {error}',
|
||||
unableToUnbookmark: 'Не удалось удалить из закладок: {error}',
|
||||
cannotPostOffline: 'Вы не можете публиковать записи в офлайн-режиме',
|
||||
unableToPost: 'Не удалось опубликовать запись: {error}',
|
||||
statusDeleted: 'Запись удалена',
|
||||
unableToDelete: 'Не удалось удалить запись: {error}',
|
||||
cannotFavoriteOffline: 'Вы не можете добавлять в избранное в офлайн-режиме режиме',
|
||||
cannotUnfavoriteOffline: 'Вы не можете удалять из избранного в офлайн-режиме режиме',
|
||||
unableToFavorite: 'Не удалось добавить в избранное: {error}',
|
||||
unableToUnfavorite: 'Не удалось удалить из избранного: {error}',
|
||||
followedAccount: 'Подписан(-на) на аккаунт',
|
||||
unfollowedAccount: 'Отписан(-на) от аккаунта',
|
||||
unableToFollow: 'Не удалось подписаться на аккаунт: {error}',
|
||||
unableToUnfollow: 'Не удалось отписаться от аккаунта: {error}',
|
||||
accessTokenRevoked: 'Токен доступа был отозван, выполнен выход из {instance}',
|
||||
loggedOutOfInstance: 'Выполнен выход из {instance}',
|
||||
failedToUploadMedia: 'Не удалось загрузить мультимедиа: {error}',
|
||||
mutedAccount: 'Аккаунт игнорируется',
|
||||
unmutedAccount: 'Аккаунт не игнорируется',
|
||||
unableToMute: 'Не удалось добавить аккаунт в игнорируемые: {error}',
|
||||
unableToUnmute: 'Не удалось удалить аккаунт из игнорируемых: {error}',
|
||||
mutedConversation: 'Обсуждение добавлено в игнорируемые',
|
||||
unmutedConversation: 'Обсуждение удалено из игнорируемых',
|
||||
unableToMuteConversation: 'Не удалось добавить обсуждение в игнорируемые: {error}',
|
||||
unableToUnmuteConversation: 'Не удалось удалить обсуждение из игнорируемых: {error}',
|
||||
unpinnedStatus: 'Запись откреплена',
|
||||
unableToPinStatus: 'Не удалось закрепить запись: {error}',
|
||||
unableToUnpinStatus: 'Не удалось открепить запись: {error}',
|
||||
unableToRefreshPoll: 'Не удалось обновить опрос: {error}',
|
||||
unableToVoteInPoll: 'Не удалось проголосовать в опросе: {error}',
|
||||
cannotReblogOffline: 'Вы не можете продвигать в оффлайн-режиме.',
|
||||
cannotUnreblogOffline: 'Вы не можете отменить продвижение в оффлайн-режиме.',
|
||||
failedToReblog: 'Не удалось продвинуть: {error}',
|
||||
failedToUnreblog: 'Не удалось отменить продвижение: {error}',
|
||||
submittedReport: 'Жалоба отправлена',
|
||||
failedToReport: 'Не удалось отправить жалобу: {error}',
|
||||
approvedFollowRequest: 'Запрос на подписку одобрен',
|
||||
rejectedFollowRequest: 'Запрос на подписку отклонен',
|
||||
unableToApproveFollowRequest: 'Не удалось одобрить запрос на подписку: {error}',
|
||||
unableToRejectFollowRequest: 'Не удалось отклонить запрос на подписку: {error}',
|
||||
searchError: 'Ошибка во время поиска: {error}',
|
||||
hidDomain: 'Домен скрыт',
|
||||
unhidDomain: 'Домен удален из скрытых',
|
||||
unableToHideDomain: 'Не удалось скрыть домен: {error}',
|
||||
unableToUnhideDomain: 'Не удалось удалить домен из скрытых: {error}',
|
||||
showingReblogs: 'Показывать продвижения',
|
||||
hidingReblogs: 'Скрывать продвижения',
|
||||
unableToShowReblogs: 'Не удалось показать продвижения: {error}',
|
||||
unableToHideReblogs: 'Не удалось скрыть продвижения: {error}',
|
||||
unableToShare: 'Не удалось поделиться: {error}',
|
||||
unableToSubscribe: 'Не удалось подписаться: {error}',
|
||||
unableToUnsubscribe: 'Не удалось отписаться: {error}',
|
||||
showingOfflineContent: 'Интернет-запрос не выполнен. Отображается офлайн-содержимое.',
|
||||
youAreOffline: 'Похоже, вы не в сети. Вы по-прежнему можете читать записи в офлайн-режиме.',
|
||||
// Snackbar UI
|
||||
updateAvailable: 'Доступно обновление приложения.',
|
||||
// Word/phrase filters
|
||||
wordFilters: 'Фильтры слов',
|
||||
noFilters: 'У вас нет фильтров слов.',
|
||||
wordOrPhrase: 'Слово или фраза',
|
||||
contexts: 'Контексты',
|
||||
addFilter: 'Добавить фильтр',
|
||||
editFilter: 'Редактировать фильтр',
|
||||
filterHome: 'Главная и списки',
|
||||
filterNotifications: 'Уведомления',
|
||||
filterPublic: 'Публичные ленты',
|
||||
filterThread: 'Обсуждения',
|
||||
filterAccount: 'Профили',
|
||||
filterUnknown: 'Неизвестный',
|
||||
expireAfter: 'Истекает через',
|
||||
whereToFilter: 'Где фильтровать',
|
||||
irreversible: 'Необратимый',
|
||||
wholeWord: 'Целое слово',
|
||||
save: 'Сохранить',
|
||||
updatedFilter: 'Фильтр обновлён',
|
||||
createdFilter: 'Фильтр создан',
|
||||
failedToModifyFilter: 'Не удалось изменить фильтр: {error}',
|
||||
deletedFilter: 'Фильтр удалён',
|
||||
required: 'Требуется',
|
||||
// Dialogs
|
||||
profileOptions: 'Параметры профиля',
|
||||
copyLink: 'Копировать ссылку',
|
||||
emoji: 'Эмодзи',
|
||||
editMedia: 'Редактировать медиа',
|
||||
shortcutHelp: 'Быстрая помощь',
|
||||
statusOptions: 'Параметры статуса',
|
||||
confirm: 'Подтвердить',
|
||||
closeDialog: 'Закрыть диалог',
|
||||
postPrivacy: 'Конфиденциальность записи',
|
||||
homeOnInstance: 'Главная на {instance}',
|
||||
statusesTimelineOnInstance: 'Записи: {timeline} лента на {instance}',
|
||||
statusesHashtag: 'Записи: #{hashtag} хэштег',
|
||||
statusesThread: 'Записи: треды',
|
||||
statusesAccountTimeline: 'Записи: лента аккаунта',
|
||||
statusesList: 'Записи: список',
|
||||
notificationsOnInstance: 'Уведомления на {instance}'
|
||||
}
|
|
@ -11,6 +11,8 @@ function getNotificationText (notification, omitEmojiInDisplayNames) {
|
|||
return formatIntl('intl.accountRebloggedYou', { account: notificationAccountDisplayName })
|
||||
} else if (notification.type === 'favourite') {
|
||||
return formatIntl('intl.accountFavoritedYou', { account: notificationAccountDisplayName })
|
||||
} else if (notification.type === 'update') {
|
||||
return formatIntl('intl.accountEdited', { account: notificationAccountDisplayName })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,12 +39,15 @@ function cleanupText (text) {
|
|||
export function getAccessibleLabelForStatus (originalAccount, account, plainTextContent,
|
||||
shortInlineFormattedDate, spoilerText, showContent,
|
||||
reblog, notification, visibility, omitEmojiInDisplayNames,
|
||||
disableLongAriaLabels, showMedia, showPoll) {
|
||||
disableLongAriaLabels, showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll) {
|
||||
const originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames)
|
||||
const contentTextToShow = (showContent || !spoilerText)
|
||||
? cleanupText(plainTextContent)
|
||||
: formatIntl('intl.contentWarningContent', { spoiler: cleanupText(spoilerText) })
|
||||
const mediaTextToShow = showMedia && 'intl.hasMedia'
|
||||
const mediaDescText = (showMedia && (!sensitive || sensitiveShown))
|
||||
? mediaAttachments.map(media => media.description)
|
||||
: []
|
||||
const pollTextToShow = showPoll && 'intl.hasPoll'
|
||||
const privacyText = getPrivacyText(visibility)
|
||||
|
||||
|
@ -57,6 +62,7 @@ export function getAccessibleLabelForStatus (originalAccount, account, plainText
|
|||
originalAccountDisplayName,
|
||||
contentTextToShow,
|
||||
mediaTextToShow,
|
||||
...mediaDescText,
|
||||
pollTextToShow,
|
||||
shortInlineFormattedDate,
|
||||
`@${originalAccount.acct}`,
|
||||
|
|
|
@ -105,10 +105,10 @@ async function registerNewInstance (code) {
|
|||
instanceNameInSearch: '',
|
||||
currentRegisteredInstanceName: null,
|
||||
currentRegisteredInstance: null,
|
||||
loggedInInstances: loggedInInstances,
|
||||
loggedInInstances,
|
||||
currentInstance: currentRegisteredInstanceName,
|
||||
loggedInInstancesInOrder: loggedInInstancesInOrder,
|
||||
instanceThemes: instanceThemes
|
||||
loggedInInstancesInOrder,
|
||||
instanceThemes
|
||||
})
|
||||
store.save()
|
||||
const { enableGrayscale } = store.get()
|
||||
|
|
|
@ -118,6 +118,6 @@ export function addStatusesOrNotifications (instanceName, timelineName, newStatu
|
|||
let freshUpdates = store.getForTimeline(instanceName, timelineName, 'freshUpdates') || []
|
||||
freshUpdates = concat(freshUpdates, newStatusesOrNotifications)
|
||||
freshUpdates = uniqBy(freshUpdates, _ => _.id)
|
||||
store.setForTimeline(instanceName, timelineName, { freshUpdates: freshUpdates })
|
||||
store.setForTimeline(instanceName, timelineName, { freshUpdates })
|
||||
lazilyProcessFreshUpdates(instanceName, timelineName)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { database } from '../_database/database.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
async function getNotification (instanceName, timelineType, timelineValue, itemId) {
|
||||
return {
|
||||
|
@ -21,62 +18,10 @@ async function getStatus (instanceName, timelineType, timelineValue, itemId) {
|
|||
}
|
||||
}
|
||||
|
||||
function tryInitBlurhash () {
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createMakeProps (instanceName, timelineType, timelineValue) {
|
||||
let promiseChain = Promise.resolve()
|
||||
|
||||
tryInitBlurhash() // start the blurhash worker a bit early to save time
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
|
||||
async function fetchFromIndexedDB (itemId) {
|
||||
mark(`fetchFromIndexedDB-${itemId}`)
|
||||
|
@ -92,10 +37,7 @@ export function createMakeProps (instanceName, timelineType, timelineValue) {
|
|||
|
||||
async function getStatusOrNotification (itemId) {
|
||||
const statusOrNotification = await fetchFromIndexedDB(itemId)
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
await rehydrateStatusOrNotification(statusOrNotification)
|
||||
return statusOrNotification
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import { formatIntl } from '../_utils/formatIntl.js'
|
|||
export function changeTheme (instanceName, newTheme) {
|
||||
const { instanceThemes } = store.get()
|
||||
instanceThemes[instanceName] = newTheme
|
||||
store.set({ instanceThemes: instanceThemes })
|
||||
store.set({ instanceThemes })
|
||||
store.save()
|
||||
const { currentInstance } = store.get()
|
||||
if (instanceName === currentInstance) {
|
||||
|
@ -90,7 +90,7 @@ export async function logOutOfInstance (instanceName, message) {
|
|||
function setStoreVerifyCredentials (instanceName, thisVerifyCredentials) {
|
||||
const { verifyCredentials } = store.get()
|
||||
verifyCredentials[instanceName] = thisVerifyCredentials
|
||||
store.set({ verifyCredentials: verifyCredentials })
|
||||
store.set({ verifyCredentials })
|
||||
}
|
||||
|
||||
export async function updateVerifyCredentialsForInstance (instanceName) {
|
||||
|
@ -121,7 +121,7 @@ export async function updateInstanceInfo (instanceName) {
|
|||
info => {
|
||||
const { instanceInfos } = store.get()
|
||||
instanceInfos[instanceName] = info
|
||||
store.set({ instanceInfos: instanceInfos })
|
||||
store.set({ instanceInfos })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ async function syncLists (instanceName, syncMethod) {
|
|||
lists => {
|
||||
const { instanceLists } = store.get()
|
||||
instanceLists[instanceName] = lists
|
||||
store.set({ instanceLists: instanceLists })
|
||||
store.set({ instanceLists })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,25 +4,35 @@ import { database } from '../_database/database.js'
|
|||
import {
|
||||
getPinnedStatuses
|
||||
} from '../_api/pinnedStatuses.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
// Pinned statuses aren't a "normal" timeline, so their blurhashes/plaintext need to be calculated specially
|
||||
async function rehydratePinnedStatuses (statuses) {
|
||||
await Promise.all(statuses.map(status => rehydrateStatusOrNotification({ status })))
|
||||
return statuses
|
||||
}
|
||||
|
||||
export async function updatePinnedStatusesForAccount (accountId) {
|
||||
const { currentInstance, accessToken } = store.get()
|
||||
|
||||
await cacheFirstUpdateAfter(
|
||||
() => getPinnedStatuses(currentInstance, accessToken, accountId),
|
||||
async () => {
|
||||
return rehydratePinnedStatuses(await getPinnedStatuses(currentInstance, accessToken, accountId))
|
||||
},
|
||||
async () => {
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
const pinnedStatuses = await database.getPinnedStatuses(currentInstance, accountId)
|
||||
if (!pinnedStatuses || !pinnedStatuses.every(Boolean)) {
|
||||
throw new Error('missing pinned statuses in idb')
|
||||
}
|
||||
return pinnedStatuses
|
||||
return rehydratePinnedStatuses(pinnedStatuses)
|
||||
},
|
||||
statuses => database.insertPinnedStatuses(currentInstance, accountId, statuses),
|
||||
statuses => {
|
||||
const { pinnedStatuses } = store.get()
|
||||
pinnedStatuses[currentInstance] = pinnedStatuses[currentInstance] || {}
|
||||
pinnedStatuses[currentInstance][accountId] = statuses
|
||||
store.set({ pinnedStatuses: pinnedStatuses })
|
||||
store.set({ pinnedStatuses })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
export function prepareToRehydrate () {
|
||||
// start the blurhash worker a bit early to save time
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Do stuff that we need to do when the status or notification is fetched from the database,
|
||||
// like calculating the blurhash or calculating the plain text content
|
||||
export async function rehydrateStatusOrNotification (statusOrNotification) {
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
}
|
|
@ -2,8 +2,9 @@ import { mark, stop } from '../../_utils/marks.js'
|
|||
import { deleteStatus } from '../deleteStatuses.js'
|
||||
import { addStatusOrNotification } from '../addStatusOrNotification.js'
|
||||
import { emit } from '../../_utils/eventBus.js'
|
||||
import { updateStatus } from '../updateStatus.js'
|
||||
|
||||
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed']
|
||||
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed', 'status.update']
|
||||
|
||||
export function processMessage (instanceName, timelineName, message) {
|
||||
let { event, payload } = (message || {})
|
||||
|
@ -12,7 +13,7 @@ export function processMessage (instanceName, timelineName, message) {
|
|||
return
|
||||
}
|
||||
mark('processMessage')
|
||||
if (['update', 'notification', 'conversation'].includes(event)) {
|
||||
if (['update', 'notification', 'conversation', 'status.update'].includes(event)) {
|
||||
payload = JSON.parse(payload) // only these payloads are JSON-encoded for some reason
|
||||
}
|
||||
|
||||
|
@ -43,6 +44,9 @@ export function processMessage (instanceName, timelineName, message) {
|
|||
case 'filters_changed':
|
||||
emit('wordFiltersChanged', instanceName)
|
||||
break
|
||||
case 'status.update':
|
||||
updateStatus(instanceName, payload)
|
||||
break
|
||||
}
|
||||
stop('processMessage')
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { database } from '../_database/database.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
|
||||
async function doUpdateStatus (instanceName, newStatus) {
|
||||
console.log('updating status', newStatus)
|
||||
await database.updateStatus(instanceName, newStatus)
|
||||
}
|
||||
|
||||
export function updateStatus (instanceName, newStatus) {
|
||||
scheduleIdleTask(() => {
|
||||
/* no await */ doUpdateStatus(instanceName, newStatus)
|
||||
})
|
||||
}
|
|
@ -27,11 +27,13 @@ export function generateAuthLink (instanceName, clientId, redirectUri) {
|
|||
|
||||
export function getAccessTokenFromAuthCode (instanceName, clientId, clientSecret, code, redirectUri) {
|
||||
const url = `${basename(instanceName)}/oauth/token`
|
||||
return post(url, {
|
||||
// Using URLSearchParams here guarantees a content type of application/x-www-form-urlencoded
|
||||
// See https://fetch.spec.whatwg.org/#bodyinit-unions
|
||||
return post(url, new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code: code
|
||||
}, null, { timeout: WRITE_TIMEOUT })
|
||||
code
|
||||
}), null, { timeout: WRITE_TIMEOUT })
|
||||
}
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { auth, basename } from './utils.js'
|
||||
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax.js'
|
||||
import { DEFAULT_TIMEOUT, get, post, put, WRITE_TIMEOUT } from '../_utils/ajax.js'
|
||||
|
||||
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
|
||||
// post is create, put is edit
|
||||
async function postOrPutStatus (url, accessToken, method, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses`
|
||||
|
||||
const body = {
|
||||
status: text,
|
||||
in_reply_to_id: inReplyToId,
|
||||
media_ids: mediaIds,
|
||||
sensitive: sensitive,
|
||||
sensitive,
|
||||
spoiler_text: spoilerText,
|
||||
visibility: visibility,
|
||||
poll: poll
|
||||
poll,
|
||||
...(method === 'post' && {
|
||||
// you can't change these properties when editing
|
||||
in_reply_to_id: inReplyToId,
|
||||
visibility
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of Object.keys(body)) {
|
||||
|
@ -23,7 +25,23 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId,
|
|||
}
|
||||
}
|
||||
|
||||
return post(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||
const func = method === 'post' ? post : put
|
||||
|
||||
return func(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||
}
|
||||
|
||||
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses`
|
||||
return postOrPutStatus(url, accessToken, 'post', text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll)
|
||||
}
|
||||
|
||||
export async function putStatus (instanceName, accessToken, id, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses/${id}`
|
||||
return postOrPutStatus(url, accessToken, 'put', text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll)
|
||||
}
|
||||
|
||||
export async function getStatusContext (instanceName, accessToken, statusId) {
|
||||
|
|
|
@ -66,7 +66,7 @@ export async function getTimeline (instanceName, accessToken, timeline, maxId, s
|
|||
}
|
||||
|
||||
if (timeline === 'notifications/mentions') {
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll']
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll', 'admin.sign_up', 'update', 'follow_request', 'admin.report']
|
||||
}
|
||||
|
||||
url += '?' + paramsString(params)
|
||||
|
|
|
@ -68,7 +68,7 @@
|
|||
async refreshAccounts () {
|
||||
const { accountsFetcher } = this.get()
|
||||
const accounts = await accountsFetcher()
|
||||
this.set({ accounts: accounts })
|
||||
this.set({ accounts })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{#if level === 2}
|
||||
<h2 class={className || ''}><slot></slot></h2>
|
||||
{:else}
|
||||
<h1 class={className || ''}><slot></slot></h1>
|
||||
{/if}
|
|
@ -8,7 +8,10 @@
|
|||
<button type="button"
|
||||
class="dynamic-page-go-back"
|
||||
aria-label="{intl.goBack}"
|
||||
on:click|preventDefault="onGoBack()">{intl.back}</button>
|
||||
on:click|preventDefault="onGoBack()">
|
||||
<SvgIcon className="dynamic-page-go-back-icon" href="#fa-arrow-left" />
|
||||
{intl.back}
|
||||
</button>
|
||||
</div>
|
||||
<Shortcut key="Backspace" on:pressed="onGoBack()"/>
|
||||
<style>
|
||||
|
@ -34,19 +37,25 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
.dynamic-page-go-back {
|
||||
font-size: 1.3em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: flex-end;
|
||||
font-size: 1.2857142857142858em;
|
||||
color: var(--anchor-text);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
.dynamic-page-go-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dynamic-page-go-back::before {
|
||||
content: '←';
|
||||
margin-right: 5px;
|
||||
:global(.dynamic-page-go-back-icon) {
|
||||
position: relative;
|
||||
bottom: 0.06em;
|
||||
margin-right: 0.2em;
|
||||
height: 0.66666666em;
|
||||
width: 0.66666666em;
|
||||
fill: currentColor;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dynamic-page-banner {
|
||||
|
|
|
@ -12,9 +12,13 @@
|
|||
ref:node
|
||||
>
|
||||
<SvgIcon className="icon-button-svg {svgClassName || ''}" ref:svg {href} />
|
||||
{#if checked}
|
||||
<SvgIcon className="icon-button-svg icon-button-check" ref:check href="#fa-check" />
|
||||
{/if}
|
||||
</button>
|
||||
<style>
|
||||
.icon-button {
|
||||
position: relative;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
|
@ -31,6 +35,14 @@
|
|||
pointer-events: none; /* hack for Edge */
|
||||
}
|
||||
|
||||
:global(.icon-button-check) {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 2px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
:global(.icon-button.big-icon .icon-button-svg) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
@ -128,7 +140,8 @@
|
|||
className: undefined,
|
||||
sameColorWhenPressed: false,
|
||||
ariaHidden: false,
|
||||
clickListener: true
|
||||
clickListener: true,
|
||||
checked: false
|
||||
}),
|
||||
store: () => store,
|
||||
computed: {
|
||||
|
@ -144,8 +157,11 @@
|
|||
ariaLabel: ({ pressable, pressed, label, pressedLabel }) => ((pressable && pressed) ? pressedLabel : label)
|
||||
},
|
||||
methods: {
|
||||
animate (animation) {
|
||||
animate (animation, checkmarkAnimation) {
|
||||
this.refs.svg.animate(animation)
|
||||
if (checkmarkAnimation && this.get().checked) {
|
||||
this.refs.check.animate(checkmarkAnimation)
|
||||
}
|
||||
},
|
||||
onClick (e) {
|
||||
this.fire('click', e)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<span class="length-indicator {overLimit ? 'over-char-limit' : ''}"
|
||||
aria-label={lengthLabel}
|
||||
aria-live={lengthVerbosity}
|
||||
aria-atomic='true'
|
||||
{style}
|
||||
>{lengthToDisplayDeferred}</span>
|
||||
<style>
|
||||
|
@ -17,10 +18,11 @@
|
|||
import { store } from '../_store/store.js'
|
||||
import { observe } from 'svelte-extras'
|
||||
import { throttleTimer } from '../_utils/throttleTimer.js'
|
||||
import { formatIntl } from '../_utils/formatIntl.js'
|
||||
|
||||
const updateDisplayedLength = process.browser && throttleTimer(requestAnimationFrame)
|
||||
|
||||
// How many chars within the limit to start warning at
|
||||
const WARN_THRESHOLD = 10
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
const { lengthToDisplay } = this.get()
|
||||
|
@ -42,11 +44,12 @@
|
|||
store: () => store,
|
||||
computed: {
|
||||
lengthToDisplay: ({ length, max }) => max - length,
|
||||
lengthLabel: ({ overLimit, lengthToDisplayDeferred }) => {
|
||||
if (overLimit) {
|
||||
return formatIntl('intl.overLimit', { count: -lengthToDisplayDeferred })
|
||||
lengthVerbosity: ({ lengthToDisplayDeferred }) => {
|
||||
// When approaching the limit, notify screen reader users
|
||||
if (lengthToDisplayDeferred > WARN_THRESHOLD) {
|
||||
return 'off'
|
||||
} else {
|
||||
return formatIntl('intl.underLimit', { count: lengthToDisplayDeferred })
|
||||
return 'polite'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -25,7 +25,8 @@
|
|||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
top: var(--nav-top);
|
||||
bottom: var(--nav-bottom);
|
||||
z-index: 100;
|
||||
contain: content; /* see https://www.w3.org/TR/2018/CR-css-contain-1-20181108/#valdef-contain-content */
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
/>
|
||||
<span class="nav-link-label">{label}</span>
|
||||
</div>
|
||||
<div class="nav-indicator-wrapper">
|
||||
<div class="nav-indicator-wrapper {animationClasses}">
|
||||
<div class="nav-indicator" ref:indicator></div>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -45,35 +45,36 @@
|
|||
.nav-indicator-wrapper {
|
||||
width: 100%;
|
||||
height: var(--nav-indicator-height);
|
||||
background: var(--nav-a-border);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
flex: 1;
|
||||
background: var(--nav-a-border);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.nav-indicator.animate {
|
||||
.nav-indicator {
|
||||
background: var(--nav-indicator-bg);
|
||||
}
|
||||
|
||||
.nav-indicator-wrapper.animating > .nav-indicator {
|
||||
transition: transform 333ms ease-in-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.main-nav-link:hover .nav-indicator {
|
||||
background: var(--nav-a-border-hover);
|
||||
}
|
||||
|
||||
.main-nav-link.selected .nav-indicator-wrapper {
|
||||
background: var(--nav-a-border-hover);
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
}
|
||||
|
||||
.main-nav-link.selected .nav-indicator {
|
||||
background: var(--nav-indicator-bg);
|
||||
background: var(--nav-indicator-bg-active);
|
||||
}
|
||||
|
||||
.main-nav-link.selected:hover .nav-indicator {
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
/* Desktop/mouse only https://medium.com/@mezoistvan/finally-a-css-only-solution-to-hover-on-touchscreens-c498af39c31c */
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.main-nav-link:hover .nav-indicator-wrapper.pre-animating {
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav-link:hover {
|
||||
|
@ -129,6 +130,7 @@
|
|||
import { scrollToTop } from '../_utils/scrollToTop.js'
|
||||
import { normalizePageName } from '../_utils/normalizePageName.js'
|
||||
import { formatIntl } from '../_utils/formatIntl.js'
|
||||
import { classname } from '../_utils/classname.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -148,6 +150,10 @@
|
|||
})
|
||||
},
|
||||
store: () => store,
|
||||
data: () => ({
|
||||
preAnimating: false,
|
||||
animating: false
|
||||
}),
|
||||
computed: {
|
||||
selected: ({ page, name }) => name === normalizePageName(page),
|
||||
ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => {
|
||||
|
@ -166,6 +172,10 @@
|
|||
),
|
||||
badgeNumber: ({ name, $numberOfNotifications, $numberOfFollowRequests }) => (
|
||||
(name === 'notifications' && $numberOfNotifications) || (name === 'community' && $numberOfFollowRequests) || 0
|
||||
),
|
||||
animationClasses: ({ animating, preAnimating }) => classname(
|
||||
animating && 'animating',
|
||||
preAnimating && 'pre-animating'
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
|
@ -187,7 +197,7 @@
|
|||
emit('animateNavPart2', { fromRect, toPage })
|
||||
},
|
||||
animatePart2 ({ fromRect }) {
|
||||
const indicator = this.refs.indicator
|
||||
const { indicator } = this.refs
|
||||
mark('animateNavPart2 gBCR')
|
||||
const toRect = indicator.getBoundingClientRect()
|
||||
stop('animateNavPart2 gBCR')
|
||||
|
@ -196,11 +206,12 @@
|
|||
indicator.style.transform = `translateX(${translateX}px) scaleX(${scaleX})`
|
||||
const onTransitionEnd = () => {
|
||||
indicator.removeEventListener('transitionend', onTransitionEnd)
|
||||
indicator.classList.remove('animate')
|
||||
this.set({ animating: false, preAnimating: false })
|
||||
}
|
||||
indicator.addEventListener('transitionend', onTransitionEnd)
|
||||
this.set({ preAnimating: true }) // avoids a flicker before the doubleRAF
|
||||
doubleRAF(() => {
|
||||
indicator.classList.add('animate')
|
||||
this.set({ animating: true })
|
||||
indicator.style.transform = ''
|
||||
})
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
without a div wrapper due to sticky-positioned compose button.
|
||||
TODO: this is a bit hacky due to code duplication
|
||||
-->
|
||||
<h1 class="sr-only">{headingLabel}</h1>
|
||||
<div class="timeline-home-page" aria-busy={hideTimeline}>
|
||||
{#if hidePage}
|
||||
<LoadingPage />
|
||||
|
@ -30,6 +31,7 @@
|
|||
import { store } from '../_store/store.js'
|
||||
import LoadingPage from './LoadingPage.html'
|
||||
import LazyComposeBox from './compose/LazyComposeBox.html'
|
||||
import { formatIntl } from '../_utils/formatIntl.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -40,7 +42,8 @@
|
|||
},
|
||||
computed: {
|
||||
hidePage: ({ $timelineInitialized, $timelinePreinitialized }) => !$timelineInitialized && !$timelinePreinitialized,
|
||||
hideTimeline: ({ $timelineInitialized }) => !$timelineInitialized
|
||||
hideTimeline: ({ $timelineInitialized }) => !$timelineInitialized,
|
||||
headingLabel: ({ $currentInstance }) => formatIntl('intl.homeOnInstance', { instance: $currentInstance })
|
||||
},
|
||||
store: () => store,
|
||||
components: {
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
-->
|
||||
<span class="tooltip-button"
|
||||
aria-describedby="tooltip-{id}"
|
||||
aria-expanded={shown}
|
||||
aria-controls="tooltip-{id}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:mouseover="set({shown: true, mouseover: true})"
|
||||
|
|
|
@ -147,7 +147,7 @@
|
|||
const { currentInstance, pinnedPages } = this.store.get()
|
||||
const { href } = this.get()
|
||||
pinnedPages[currentInstance] = href
|
||||
this.store.set({ pinnedPages: pinnedPages })
|
||||
this.store.set({ pinnedPages })
|
||||
this.store.save()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{#if realm === 'home'}
|
||||
<h1 class="sr-only">{intl.composeStatus}</h1>
|
||||
<h2 class="sr-only">{intl.composeStatus}</h2>
|
||||
{/if}
|
||||
<ComposeFileDrop {realm} >
|
||||
<div class="{computedClassName} {hideAndFadeIn}">
|
||||
|
|
|
@ -117,7 +117,15 @@
|
|||
firstTime = false
|
||||
const { autoFocus } = this.get()
|
||||
if (autoFocus) {
|
||||
requestAnimationFrame(() => textarea.focus({ preventScroll: true }))
|
||||
const { realm } = this.get()
|
||||
if (realm === 'dialog') {
|
||||
// If we're in a dialog, the dialog will be hidden at this
|
||||
// point. Also, the dialog has its own initial focus behavior.
|
||||
// Tell the dialog to focus the textarea.
|
||||
textarea.setAttribute('autofocus', true)
|
||||
} else {
|
||||
requestAnimationFrame(() => textarea.focus({ preventScroll: true }))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
:global(.compose-box-button-sticky, .compose-box-button-fixed) {
|
||||
z-index: 5000;
|
||||
top: calc(var(--nav-total-height));
|
||||
top: calc(var(--fab-gap-top));
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
|
|
@ -119,7 +119,7 @@
|
|||
close,
|
||||
onEmojiSelected (event) {
|
||||
const { realm } = this.get()
|
||||
insertEmoji(realm, event.detail.emoji)
|
||||
insertEmoji(realm, event.detail)
|
||||
this.close()
|
||||
},
|
||||
onPickerKeydown (event) {
|
||||
|
|
|
@ -334,7 +334,7 @@
|
|||
}
|
||||
},
|
||||
scrollToItem (scrolledItem, smooth) {
|
||||
this.set({ scrolledItem: scrolledItem })
|
||||
this.set({ scrolledItem })
|
||||
const { length } = this.get()
|
||||
const { scroller } = this.refs
|
||||
const { scrollWidth } = scroller
|
||||
|
|
|
@ -5,8 +5,8 @@ export default function showAccountProfileOptionsDialog (account, relationship,
|
|||
return showDialog(AccountProfileOptionsDialog, {
|
||||
label: 'intl.profileOptions',
|
||||
title: '',
|
||||
account: account,
|
||||
relationship: relationship,
|
||||
verifyCredentials: verifyCredentials
|
||||
account,
|
||||
relationship,
|
||||
verifyCredentials
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,6 +5,6 @@ export default function showPostPrivacyDialog (realm) {
|
|||
return showDialog(PostPrivacyDialog, {
|
||||
label: 'intl.postPrivacy',
|
||||
title: 'intl.postPrivacy',
|
||||
realm: realm
|
||||
realm
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,6 +5,6 @@ export default function showStatusOptionsDialog (status) {
|
|||
return showDialog(StatusOptionsDialog, {
|
||||
label: 'intl.statusOptions',
|
||||
title: '',
|
||||
status: status
|
||||
status
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
if (makeProps) {
|
||||
const props = await makeProps(key)
|
||||
mark('ListLazyItem set props')
|
||||
this.set({ props: props })
|
||||
this.set({ props })
|
||||
this.fire('initialized')
|
||||
stop('ListLazyItem set props')
|
||||
}
|
||||
|
|
|
@ -119,7 +119,13 @@
|
|||
if (!activeElement) {
|
||||
return null
|
||||
}
|
||||
const activeItem = activeElement.getAttribute('id')
|
||||
// The user might be focused on an element inside a toot. We want to
|
||||
// move relative to that toot.
|
||||
const activeArticle = activeElement.closest('article')
|
||||
if (!activeArticle) {
|
||||
return null
|
||||
}
|
||||
const activeItem = activeArticle.getAttribute('id')
|
||||
if (!activeItem) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -21,14 +21,16 @@
|
|||
<style>
|
||||
.snackbar-modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
bottom: var(--toast-gap-bottom);
|
||||
left: 0;
|
||||
right: 0;
|
||||
transition: transform 333ms ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 99000;
|
||||
/* lower than the Nav.html .main-nav which is 100, but higher than .compose-autosuggest
|
||||
and .status-sensitive-media-shown which are 90 */
|
||||
z-index: 95;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
|
|
|
@ -58,6 +58,7 @@
|
|||
account: ({ notification }) => notification.account,
|
||||
accountId: ({ account }) => account.id,
|
||||
notificationId: ({ notification }) => notification.id,
|
||||
notificationType: ({ notification }) => notification.type,
|
||||
status: ({ notification }) => notification.status,
|
||||
statusId: ({ status }) => status && status.id,
|
||||
uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => (
|
||||
|
@ -65,12 +66,24 @@
|
|||
),
|
||||
elementId: ({ uuid }) => uuid,
|
||||
shortcutScope: ({ elementId }) => elementId,
|
||||
ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => (
|
||||
!status && formatIntl('intl.accountFollowedYou', {
|
||||
ariaLabel: ({ status, account, $omitEmojiInDisplayNames, notificationType }) => {
|
||||
if (status) {
|
||||
return undefined // aria-label not needed if there's a status
|
||||
}
|
||||
const params = {
|
||||
name: getAccountAccessibleName(account, $omitEmojiInDisplayNames),
|
||||
account: `@${account.acct}`
|
||||
})
|
||||
),
|
||||
}
|
||||
if (notificationType === 'admin.sign_up') {
|
||||
return formatIntl('intl.accountSignedUp', params)
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return formatIntl('intl.accountRequestedFollow', params)
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return formatIntl('intl.accountReported', params)
|
||||
} else { // 'follow'
|
||||
return formatIntl('intl.accountFollowedYou', params)
|
||||
}
|
||||
},
|
||||
className: ({ $underlineLinks }) => (classname(
|
||||
'notification-article',
|
||||
'shortcut-list-item',
|
||||
|
|
|
@ -34,12 +34,14 @@
|
|||
<StatusMediaAttachments {...params} on:recalculateHeight />
|
||||
{/if}
|
||||
{#if showPoll && (showContent || preloadHiddenContent)}
|
||||
<StatusPoll {...params} shown={showContent} />
|
||||
<StatusPoll {...params} shown={showContent} on:recalculateHeight />
|
||||
{/if}
|
||||
{#if isStatusInOwnThread}
|
||||
<StatusDetails {...params} {...timestampParams} />
|
||||
{/if}
|
||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight />
|
||||
{#if !isStatusInNotification}
|
||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight on:focusArticle="focusArticle()" />
|
||||
{/if}
|
||||
{#if replyShown}
|
||||
<StatusComposeBox {...params} on:recalculateHeight />
|
||||
{/if}
|
||||
|
@ -133,6 +135,7 @@
|
|||
import { composeNewStatusMentioning } from '../../_actions/mention.js'
|
||||
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid.js'
|
||||
import { addEmojiTooltips } from '../../_utils/addEmojiTooltips.js'
|
||||
import { tryToFocusElement } from '../../_utils/tryToFocusElement.js'
|
||||
|
||||
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
|
||||
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
|
||||
|
@ -213,6 +216,10 @@
|
|||
async mentionAuthor () {
|
||||
const { accountForShortcut } = this.get()
|
||||
await composeNewStatusMentioning(accountForShortcut)
|
||||
},
|
||||
focusArticle () {
|
||||
const { elementId } = this.get()
|
||||
tryToFocusElement(elementId, /* scroll */ true)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -253,7 +260,7 @@
|
|||
notification && notification.status &&
|
||||
notification.type !== 'mention' && notification.status.id === originalStatusId
|
||||
),
|
||||
spoilerShown: ({ $spoilersShown, uuid }) => !!$spoilersShown[uuid],
|
||||
spoilerShown: ({ $spoilersShown, uuid, $showAllSpoilers }) => (typeof $spoilersShown[uuid] === 'undefined' ? !!$showAllSpoilers : !!$spoilersShown[uuid]),
|
||||
replyShown: ({ $repliesShown, uuid }) => !!$repliesShown[uuid],
|
||||
showCard: ({ originalStatus, isStatusInNotification, showMedia, $hideCards }) => (
|
||||
!$hideCards &&
|
||||
|
@ -270,6 +277,13 @@
|
|||
originalStatus.media_attachments &&
|
||||
originalStatus.media_attachments.length
|
||||
),
|
||||
mediaAttachments: ({ originalStatus }) => (
|
||||
originalStatus.media_attachments
|
||||
),
|
||||
sensitiveShown: ({ $sensitivesShown, uuid }) => !!$sensitivesShown[uuid],
|
||||
sensitive: ({ originalStatus, $markMediaAsSensitive, $neverMarkMediaAsSensitive }) => (
|
||||
!$neverMarkMediaAsSensitive && ($markMediaAsSensitive || originalStatus.sensitive)
|
||||
),
|
||||
originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []),
|
||||
originalStatusEmojis: ({ originalStatus }) => (originalStatus.emojis || []),
|
||||
originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username),
|
||||
|
@ -288,16 +302,16 @@
|
|||
ariaLabel: ({
|
||||
originalAccount, account, plainTextContent, shortInlineFormattedDate, spoilerText,
|
||||
showContent, reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels,
|
||||
showMedia, showPoll
|
||||
showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll
|
||||
}) => (
|
||||
getAccessibleLabelForStatus(originalAccount, account, plainTextContent,
|
||||
shortInlineFormattedDate, spoilerText, showContent,
|
||||
reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels,
|
||||
showMedia, showPoll
|
||||
showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll
|
||||
)
|
||||
),
|
||||
showHeader: ({ notification, status, timelineType }) => (
|
||||
(notification && ['reblog', 'favourite', 'poll', 'status'].includes(notification.type)) ||
|
||||
(notification && ['reblog', 'favourite', 'poll', 'status', 'update'].includes(notification.type)) ||
|
||||
status.reblog ||
|
||||
timelineType === 'pinned'
|
||||
),
|
||||
|
|
|
@ -67,16 +67,6 @@
|
|||
color: var(--very-deemphasized-link-color);
|
||||
}
|
||||
|
||||
:global(.status-content .invisible) {
|
||||
/* copied from Mastodon */
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:global(.underline-links .status-content a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
<div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notificationType === 'follow' ? 'header-is-follow' : ''}">
|
||||
<div class="status-header-avatar {timelineType === 'pinned' || notificationType === 'poll' ? 'hidden' : ''}">
|
||||
<Avatar {account} size="extra-small"/>
|
||||
<a id={avatarElementId}
|
||||
href="/accounts/{accountId}"
|
||||
rel="prefetch"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<Avatar {account} size="extra-small"/>
|
||||
</a>
|
||||
</div>
|
||||
<SvgIcon className="status-header-svg" href={icon} />
|
||||
|
||||
|
@ -10,7 +17,7 @@
|
|||
{intl.pinnedStatus}
|
||||
</span>
|
||||
{:elseif notificationType !== 'poll'}
|
||||
<a id={elementId}
|
||||
<a id={authorElementId}
|
||||
href="/accounts/{accountId}"
|
||||
rel="prefetch"
|
||||
class="status-header-author"
|
||||
|
@ -114,7 +121,8 @@
|
|||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
elementId: ({ uuid }) => `status-header-${uuid}`,
|
||||
authorElementId: ({ uuid }) => `status-header-author-${uuid}`,
|
||||
avatarElementId: ({ uuid }) => `status-header-avatar-${uuid}`,
|
||||
notificationType: ({ notification }) => notification && notification.type,
|
||||
icon: ({ notificationType, status, timelineType }) => {
|
||||
if (timelineType === 'pinned') {
|
||||
|
@ -127,6 +135,14 @@
|
|||
return '#fa-bar-chart'
|
||||
} else if (notificationType === 'status') {
|
||||
return '#fa-comment'
|
||||
} else if (notificationType === 'admin.sign_up') {
|
||||
return '#fa-user-plus'
|
||||
} else if (notificationType === 'update') {
|
||||
return '#fa-pencil'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return '#fa-hourglass'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return '#fa-flag'
|
||||
}
|
||||
return '#fa-star'
|
||||
},
|
||||
|
@ -135,6 +151,8 @@
|
|||
return 'intl.rebloggedYou'
|
||||
} else if (notificationType === 'favourite') {
|
||||
return 'intl.favoritedYou'
|
||||
} else if (notificationType === 'admin.sign_up') {
|
||||
return 'intl.signedUp'
|
||||
} else if (notificationType === 'follow') {
|
||||
return 'intl.followedYou'
|
||||
} else if (notificationType === 'status') {
|
||||
|
@ -147,6 +165,12 @@
|
|||
}
|
||||
} else if (status && status.reblog) {
|
||||
return 'intl.reblogged'
|
||||
} else if (notificationType === 'update') {
|
||||
return 'intl.edited'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return 'intl.requestedFollow'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return 'intl.reported'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
}
|
||||
|
||||
.status-in-notification svg {
|
||||
opacity: 0.5;
|
||||
stroke: var(--very-deemphasized-text-color);
|
||||
}
|
||||
|
||||
.status-in-own-thread .option-text {
|
||||
|
@ -307,7 +307,10 @@
|
|||
expired: ({ poll }) => poll.expired,
|
||||
expiresAt: ({ poll }) => poll.expires_at,
|
||||
// Misskey can have polls that never end. These give expiresAt as null
|
||||
expiresAtTS: ({ expiresAt }) => typeof expiresAt === 'number' ? new Date(expiresAt).getTime() : null,
|
||||
// Also, Mastodon v4+ uses ISO strings, whereas Mastodon pre-v4 used numbers
|
||||
expiresAtTS: ({ expiresAt }) => (
|
||||
(typeof expiresAt === 'number' || typeof expiresAt === 'string') ? new Date(expiresAt).getTime() : null
|
||||
),
|
||||
expiresAtTimeagoFormatted: ({ expiresAtTS, expired, $now }) => (
|
||||
expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now)
|
||||
),
|
||||
|
@ -364,6 +367,8 @@
|
|||
const { polls } = this.store.get()
|
||||
polls[pollId] = poll
|
||||
this.store.set({ polls })
|
||||
// the height of the status changes after you vote on the poll
|
||||
requestAnimationFrame(() => this.fire('recalculateHeight'))
|
||||
}
|
||||
} finally {
|
||||
this.set({ loading: false })
|
||||
|
@ -372,7 +377,7 @@
|
|||
onChange () {
|
||||
const { options } = this.get()
|
||||
const choices = getChoices(this.refs.form, options)
|
||||
this.set({ choices: choices })
|
||||
this.set({ choices })
|
||||
}
|
||||
},
|
||||
helpers: {
|
||||
|
|
|
@ -76,8 +76,9 @@
|
|||
methods: {
|
||||
toggleSpoilers (shown) {
|
||||
const { uuid } = this.get()
|
||||
const { spoilersShown } = this.store.get()
|
||||
spoilersShown[uuid] = typeof shown === 'undefined' ? !spoilersShown[uuid] : !!shown
|
||||
const { spoilersShown, showAllSpoilers } = this.store.get()
|
||||
const currentValue = typeof spoilersShown[uuid] === 'undefined' ? !!showAllSpoilers : spoilersShown[uuid]
|
||||
spoilersShown[uuid] = typeof shown === 'undefined' ? !currentValue : !!shown
|
||||
this.store.set({ spoilersShown })
|
||||
requestAnimationFrame(() => {
|
||||
mark('clickSpoilerButton')
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
pressedLabel="Unboost"
|
||||
pressable={!reblogDisabled}
|
||||
pressed={reblogged}
|
||||
checked={reblogged}
|
||||
disabled={reblogDisabled}
|
||||
href={reblogIcon}
|
||||
clickListener={false}
|
||||
|
@ -25,6 +26,7 @@
|
|||
pressedLabel="{intl.unfavorite}"
|
||||
pressable={true}
|
||||
pressed={favorited}
|
||||
checked={favorited}
|
||||
href="#fa-star"
|
||||
clickListener={false}
|
||||
elementId={favoriteKey}
|
||||
|
@ -40,7 +42,9 @@
|
|||
{#if enableShortcuts}
|
||||
<Shortcut scope={shortcutScope} key="f" on:pressed="toggleFavorite(true)"/>
|
||||
<Shortcut scope={shortcutScope} key="r" on:pressed="reply()"/>
|
||||
<Shortcut scope={shortcutScope} key="escape" on:pressed="dismiss()"/>
|
||||
<Shortcut scope={shortcutScope} key="b" on:pressed="reblog(true)"/>
|
||||
<Shortcut scope={shortcutScope} key="a" on:pressed="bookmark()"/>
|
||||
{/if}
|
||||
<style>
|
||||
.status-toolbar {
|
||||
|
@ -75,9 +79,10 @@
|
|||
import { setReblogged } from '../../_actions/reblog.js'
|
||||
import { importShowStatusOptionsDialog } from '../dialog/asyncDialogs/importShowStatusOptionsDialog.js'
|
||||
import { updateProfileAndRelationship } from '../../_actions/accounts.js'
|
||||
import { FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations.js'
|
||||
import { CHECKMARK_ANIMATION, FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations.js'
|
||||
import { on } from '../../_utils/eventBus.js'
|
||||
import { announceAriaLivePolite } from '../../_utils/announceAriaLivePolite.js'
|
||||
import { setStatusBookmarkedOrUnbookmarked } from '../../_actions/bookmark.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -118,7 +123,7 @@
|
|||
const newFavoritedValue = !favorited
|
||||
/* no await */ setFavorited(originalStatusId, newFavoritedValue)
|
||||
if (newFavoritedValue) {
|
||||
this.refs.favoriteIcon.animate(FAVORITE_ANIMATION)
|
||||
this.refs.favoriteIcon.animate(FAVORITE_ANIMATION, CHECKMARK_ANIMATION)
|
||||
}
|
||||
if (announce) {
|
||||
announceAriaLivePolite(newFavoritedValue ? 'intl.favorited' : 'intl.unfavorited')
|
||||
|
@ -129,7 +134,7 @@
|
|||
const newRebloggedValue = !reblogged
|
||||
/* no await */ setReblogged(originalStatusId, newRebloggedValue)
|
||||
if (newRebloggedValue) {
|
||||
this.refs.reblogIcon.animate(REBLOG_ANIMATION)
|
||||
this.refs.reblogIcon.animate(REBLOG_ANIMATION, CHECKMARK_ANIMATION)
|
||||
}
|
||||
if (announce) {
|
||||
announceAriaLivePolite(newRebloggedValue ? 'intl.reblogged' : 'intl.unreblogged')
|
||||
|
@ -144,6 +149,13 @@
|
|||
this.fire('recalculateHeight')
|
||||
})
|
||||
},
|
||||
dismiss () {
|
||||
const { replyShown } = this.get()
|
||||
if (replyShown) {
|
||||
this.reply()
|
||||
this.fire('focusArticle')
|
||||
}
|
||||
},
|
||||
async onOptionsClick () {
|
||||
const { originalStatus, originalAccountId } = this.get()
|
||||
const updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
|
||||
|
@ -164,6 +176,10 @@
|
|||
// return status to the reply button after posting a reply
|
||||
this.refs.node.querySelector('.status-toolbar-reply-button').focus({ preventScroll: true })
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
bookmark () {
|
||||
const { originalStatus, originalStatusId } = this.get()
|
||||
/* no await */ setStatusBookmarkedOrUnbookmarked(originalStatusId, !originalStatus.bookmarked)
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<div class="loading-footer {shown ? '' : 'hidden'}">
|
||||
<div class="loading-wrapper {showLoading ? 'shown' : ''}"
|
||||
aria-hidden={!showLoading}
|
||||
role="alert"
|
||||
>
|
||||
<!-- Sapper's mousemove event listener schedules style recalculations for the loading spinner in
|
||||
Chrome because it's always animating, even when hidden. So disable animations when not visible
|
||||
|
@ -66,11 +65,30 @@
|
|||
}
|
||||
</style>
|
||||
<script>
|
||||
import { observe } from 'svelte-extras'
|
||||
import LoadingSpinner from '../LoadingSpinner.html'
|
||||
import { store } from '../../_store/store.js'
|
||||
import { fetchMoreItemsAtBottomOfTimeline } from '../../_actions/timeline.js'
|
||||
import { announceAriaLivePolite } from '../../_utils/announceAriaLivePolite.js'
|
||||
|
||||
const SCREEN_READER_ANNOUNCE_DELAY = 1000 // 1 second
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
// If the new statuses are delayed a significant amount of time, announce to screen readers that we're loading
|
||||
let delayedAriaAnnouncementHandle
|
||||
|
||||
this.observe('showLoading', showLoading => {
|
||||
if (showLoading) {
|
||||
delayedAriaAnnouncementHandle = setTimeout(() => {
|
||||
delayedAriaAnnouncementHandle = undefined
|
||||
announceAriaLivePolite('intl.loadingMore')
|
||||
}, SCREEN_READER_ANNOUNCE_DELAY)
|
||||
} else if (delayedAriaAnnouncementHandle) {
|
||||
clearTimeout(delayedAriaAnnouncementHandle)
|
||||
}
|
||||
})
|
||||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
shown: ({ $timelineInitialized, $runningUpdate, $disableInfiniteScroll }) => (
|
||||
|
@ -80,6 +98,7 @@
|
|||
showLoadButton: ({ $runningUpdate, $disableInfiniteScroll }) => !$runningUpdate && $disableInfiniteScroll
|
||||
},
|
||||
methods: {
|
||||
observe,
|
||||
onClickLoadMore (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<h1 class="sr-only">{label}</h1>
|
||||
<DynamicHeading className="sr-only" level={headingLevel}>{label}</DynamicHeading>
|
||||
<FocusRestoration realm={focusRealm}>
|
||||
<div class="timeline" role="feed">
|
||||
{#if components}
|
||||
|
@ -26,6 +26,7 @@
|
|||
<ScrollListShortcuts />
|
||||
<script>
|
||||
import { store } from '../../_store/store.js'
|
||||
import DynamicHeading from '../DynamicHeading.html'
|
||||
import Status from '../status/Status.html'
|
||||
import LoadingFooter from './LoadingFooter.html'
|
||||
import MoreHeaderVirtualWrapper from './MoreHeaderVirtualWrapper.html'
|
||||
|
@ -51,6 +52,7 @@
|
|||
import { createMakeProps } from '../../_actions/createMakeProps.js'
|
||||
import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop.js'
|
||||
import FocusRestoration from '../FocusRestoration.html'
|
||||
import { formatIntl } from '../../_utils/formatIntl.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -89,20 +91,23 @@
|
|||
),
|
||||
label: ({ timeline, $currentInstance, timelineType, timelineValue }) => {
|
||||
if (timelines[timeline]) {
|
||||
return `Statuses: ${timelines[timeline].label} timeline on ${$currentInstance}`
|
||||
return formatIntl('intl.statusesTimelineOnInstance', {
|
||||
timeline: timelines[timeline].label,
|
||||
instance: $currentInstance
|
||||
})
|
||||
}
|
||||
|
||||
switch (timelineType) {
|
||||
case 'tag':
|
||||
return `Statuses: #${timelineValue} hashtag`
|
||||
return formatIntl('intl.statusesHashtag', { hashtag: timelineValue })
|
||||
case 'status':
|
||||
return 'Statuses: thread'
|
||||
return 'intl.statusesThread'
|
||||
case 'account':
|
||||
return 'Statuses: account timeline'
|
||||
return 'intl.statusesAccountTimeline'
|
||||
case 'list':
|
||||
return 'Statuses: list'
|
||||
return 'intl.statusesList'
|
||||
case 'notifications':
|
||||
return `Notifications on ${$currentInstance}`
|
||||
return formatIntl('intl.notificationsOnInstance', { instance: $currentInstance })
|
||||
}
|
||||
},
|
||||
timelineType: ({ $currentTimelineType }) => $currentTimelineType,
|
||||
|
@ -127,7 +132,8 @@
|
|||
onClick: showMoreItemsForCurrentTimeline
|
||||
}
|
||||
},
|
||||
focusRealm: ({ $currentInstance, timeline }) => `${$currentInstance}-${timeline}`
|
||||
focusRealm: ({ $currentInstance, timeline }) => `${$currentInstance}-${timeline}`,
|
||||
headingLevel: ({ timeline, timelineType }) => timeline === 'home' || timelineType === 'status' ? 2 : 1
|
||||
},
|
||||
store: () => store,
|
||||
methods: {
|
||||
|
@ -146,7 +152,7 @@
|
|||
})
|
||||
},
|
||||
onScrollTopChanged (scrollTop) {
|
||||
this.set({ scrollTop: scrollTop })
|
||||
this.set({ scrollTop })
|
||||
},
|
||||
onScrollToBottom () {
|
||||
const { timelineType } = this.get()
|
||||
|
@ -232,7 +238,8 @@
|
|||
components: {
|
||||
ScrollListShortcuts,
|
||||
Shortcut,
|
||||
FocusRestoration
|
||||
FocusRestoration,
|
||||
DynamicHeading
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<style>
|
||||
.toast-modal {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
bottom: calc(40px + var(--toast-gap-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
|
@ -75,7 +75,7 @@
|
|||
onNewToast (text) {
|
||||
this._queue = this._queue.then(() => {
|
||||
this.set({
|
||||
text: text,
|
||||
text,
|
||||
shown: true
|
||||
})
|
||||
return new Promise(resolve => {
|
||||
|
|
|
@ -59,12 +59,12 @@
|
|||
}, SCROLL_EVENT_THROTTLE)
|
||||
this.observe('showFooter', showFooter => {
|
||||
mark('set showFooter')
|
||||
this.store.setForRealm({ showFooter: showFooter })
|
||||
this.store.setForRealm({ showFooter })
|
||||
mark('set showFooter')
|
||||
})
|
||||
this.observe('showHeader', showHeader => {
|
||||
mark('set showHeader')
|
||||
this.store.setForRealm({ showHeader: showHeader })
|
||||
this.store.setForRealm({ showHeader })
|
||||
stop('set showHeader')
|
||||
})
|
||||
this.observe('items', (newItems, oldItems) => {
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
const props = await makeProps(key)
|
||||
const setProps = () => {
|
||||
mark('VirtualListLazyItem set props')
|
||||
this.set({ props: props })
|
||||
this.set({ props })
|
||||
stop('VirtualListLazyItem set props')
|
||||
}
|
||||
// On desktop, if prefers-reduced-motion is enabled, avoid using scheduleIdleTask
|
||||
|
|
|
@ -71,7 +71,7 @@ virtualListStore.compute('rawVisibleItems',
|
|||
}
|
||||
visibleItems.push({
|
||||
offset: currentOffset,
|
||||
key: key,
|
||||
key,
|
||||
index: i
|
||||
})
|
||||
}
|
||||
|
|
|
@ -3,12 +3,13 @@ import { getInCache, hasInCache, statusesCache } from '../cache.js'
|
|||
import { STATUSES_STORE } from '../constants.js'
|
||||
import { cacheStatus } from './cacheStatus.js'
|
||||
import { putStatus } from './insertion.js'
|
||||
import { cloneForStorage } from '../helpers.js'
|
||||
|
||||
//
|
||||
// update statuses
|
||||
//
|
||||
|
||||
async function updateStatus (instanceName, statusId, updateFunc) {
|
||||
async function doUpdateStatus (instanceName, statusId, updateFunc) {
|
||||
const db = await getDatabase(instanceName)
|
||||
if (hasInCache(statusesCache, instanceName, statusId)) {
|
||||
const status = getInCache(statusesCache, instanceName, statusId)
|
||||
|
@ -25,7 +26,7 @@ async function updateStatus (instanceName, statusId, updateFunc) {
|
|||
}
|
||||
|
||||
export async function setStatusFavorited (instanceName, statusId, favorited) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
const delta = (favorited ? 1 : 0) - (status.favourited ? 1 : 0)
|
||||
status.favourited = favorited
|
||||
status.favourites_count = (status.favourites_count || 0) + delta
|
||||
|
@ -33,7 +34,7 @@ export async function setStatusFavorited (instanceName, statusId, favorited) {
|
|||
}
|
||||
|
||||
export async function setStatusReblogged (instanceName, statusId, reblogged) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
const delta = (reblogged ? 1 : 0) - (status.reblogged ? 1 : 0)
|
||||
status.reblogged = reblogged
|
||||
status.reblogs_count = (status.reblogs_count || 0) + delta
|
||||
|
@ -41,19 +42,36 @@ export async function setStatusReblogged (instanceName, statusId, reblogged) {
|
|||
}
|
||||
|
||||
export async function setStatusPinned (instanceName, statusId, pinned) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.pinned = pinned
|
||||
})
|
||||
}
|
||||
|
||||
export async function setStatusMuted (instanceName, statusId, muted) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.muted = muted
|
||||
})
|
||||
}
|
||||
|
||||
export async function setStatusBookmarked (instanceName, statusId, bookmarked) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.bookmarked = bookmarked
|
||||
})
|
||||
}
|
||||
|
||||
// For the full list, see https://docs.joinmastodon.org/methods/statuses/#edit
|
||||
const PROPS_THAT_CAN_BE_EDITED = ['content', 'spoiler_text', 'sensitive', 'language', 'media_ids', 'poll']
|
||||
|
||||
export async function updateStatus (instanceName, newStatus) {
|
||||
const clonedNewStatus = cloneForStorage(newStatus)
|
||||
return doUpdateStatus(instanceName, newStatus.id, status => {
|
||||
// We can't use a simple Object.assign() to merge because a prop might have been deleted
|
||||
for (const prop of PROPS_THAT_CAN_BE_EDITED) {
|
||||
if (!(prop in clonedNewStatus)) {
|
||||
delete status[prop]
|
||||
} else {
|
||||
status[prop] = clonedNewStatus[prop]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{#if $isUserLoggedIn}
|
||||
<h1 class="sr-only">{intl.community}</h1>
|
||||
<div class="community-page">
|
||||
|
||||
<FocusRestoration realm="community">
|
||||
<RadioGroup
|
||||
id="pinnables"
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
await updateVerifyCredentialsForInstance(currentInstance)
|
||||
const { accessToken, currentVerifyCredentials } = this.store.get()
|
||||
const statuses = await getPinnedStatuses(currentInstance, accessToken, currentVerifyCredentials.id)
|
||||
this.set({ statuses: statuses })
|
||||
this.set({ statuses })
|
||||
} catch (e) {
|
||||
/* no await */ toast.say(formatIntl('intl.error', { error: (e.name || '') + ' ' + (e.message || '') }))
|
||||
} finally {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{#if $isUserLoggedIn}
|
||||
<h1 class="sr-only">{intl.search}</h1>
|
||||
<div class="search-page">
|
||||
<Search></Search>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,11 @@
|
|||
bind:checked="$neverMarkMediaAsSensitive" on:change="onChange(event)">
|
||||
{intl.showSensitive}
|
||||
</label>
|
||||
<label class="setting-group">
|
||||
<input type="checkbox" id="choice-show-all-spoilers"
|
||||
bind:checked="$showAllSpoilers" on:change="onChange(event)">
|
||||
{intl.showAllSpoilers}
|
||||
</label>
|
||||
<label class="setting-group">
|
||||
<input type="checkbox" id="choice-use-blurhash"
|
||||
bind:checked="$ignoreBlurhash" on:change="onChange(event)">
|
||||
|
@ -62,6 +67,11 @@
|
|||
bind:checked="$centerNav" on:change="onChange(event)">
|
||||
{intl.centerNav}
|
||||
</label>
|
||||
<label class="setting-group">
|
||||
<input type="checkbox" id="choice-bottom-nav"
|
||||
bind:checked="$bottomNav" on:change="onChange(event)">
|
||||
{intl.bottomNav}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<h2>{intl.accessibility}</h2>
|
||||
|
|
|
@ -1,39 +1,37 @@
|
|||
export const FAVORITE_ANIMATION = [
|
||||
{
|
||||
properties: [
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(2)' },
|
||||
{ transform: 'scale(1)' }
|
||||
],
|
||||
options: {
|
||||
duration: 333,
|
||||
easing: 'ease-in-out'
|
||||
}
|
||||
},
|
||||
{
|
||||
properties: [
|
||||
{ fill: 'var(--action-button-fill-color)' },
|
||||
{ fill: 'var(--action-button-fill-color-pressed)' }
|
||||
],
|
||||
options: {
|
||||
duration: 333,
|
||||
easing: 'linear'
|
||||
}
|
||||
const growBigThenSmall = {
|
||||
properties: [
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(2)' },
|
||||
{ transform: 'scale(1)' }
|
||||
],
|
||||
options: {
|
||||
duration: 333,
|
||||
easing: 'ease-in-out'
|
||||
}
|
||||
}
|
||||
|
||||
const fadeColorToPressedState = {
|
||||
properties: [
|
||||
{ fill: 'var(--action-button-fill-color)' },
|
||||
{ fill: 'var(--action-button-fill-color-pressed)' }
|
||||
],
|
||||
options: {
|
||||
duration: 333,
|
||||
easing: 'linear'
|
||||
}
|
||||
}
|
||||
|
||||
export const FAVORITE_ANIMATION = [
|
||||
growBigThenSmall,
|
||||
fadeColorToPressedState
|
||||
]
|
||||
|
||||
export const REBLOG_ANIMATION = FAVORITE_ANIMATION
|
||||
|
||||
export const FOLLOW_BUTTON_ANIMATION = [
|
||||
{
|
||||
properties: [
|
||||
{ transform: 'scale(1)' },
|
||||
{ transform: 'scale(2)' },
|
||||
{ transform: 'scale(1)' }
|
||||
],
|
||||
options: {
|
||||
duration: 333,
|
||||
easing: 'ease-in-out'
|
||||
}
|
||||
}
|
||||
growBigThenSmall
|
||||
]
|
||||
|
||||
export const CHECKMARK_ANIMATION = [
|
||||
fadeColorToPressedState
|
||||
]
|
||||
|
|
|
@ -23,3 +23,5 @@ export const POST_PRIVACY_OPTIONS = [
|
|||
|
||||
export const LONG_POST_LENGTH = 1024
|
||||
export const LONG_POST_TEXT = 'intl.longPost'
|
||||
|
||||
export const MAX_STATUS_CHARS = 500
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { DEFAULT_THEME } from '../../_utils/themeEngine.js'
|
||||
import { mark, stop } from '../../_utils/marks.js'
|
||||
import { MAX_STATUS_CHARS } from '../../_static/statuses.js'
|
||||
|
||||
function computeForInstance (store, computedKey, key, defaultValue) {
|
||||
store.compute(computedKey,
|
||||
|
@ -57,10 +58,18 @@ export function instanceComputations (store) {
|
|||
store.compute(
|
||||
'maxStatusChars',
|
||||
['currentInstanceInfo'],
|
||||
(currentInstanceInfo) => (
|
||||
// unofficial api used in glitch-soc and pleroma
|
||||
(currentInstanceInfo && currentInstanceInfo.max_toot_chars) || 500
|
||||
)
|
||||
(currentInstanceInfo) => {
|
||||
if (currentInstanceInfo) {
|
||||
if (currentInstanceInfo.max_toot_chars) {
|
||||
// unofficial api used in glitch-soc and pleroma
|
||||
return currentInstanceInfo.max_toot_chars
|
||||
}
|
||||
if (currentInstanceInfo.configuration && currentInstanceInfo.configuration.statuses && currentInstanceInfo.configuration.statuses.max_characters) {
|
||||
return currentInstanceInfo.configuration.statuses.max_characters
|
||||
}
|
||||
}
|
||||
return MAX_STATUS_CHARS
|
||||
}
|
||||
)
|
||||
|
||||
stop('instanceComputations')
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
const style = process.browser && document.getElementById('theBottomNavStyle')
|
||||
|
||||
export function bottomNavObservers (store) {
|
||||
if (!process.browser) {
|
||||
return
|
||||
}
|
||||
|
||||
store.observe('bottomNav', bottomNav => {
|
||||
// disables or enables the style
|
||||
style.setAttribute('media', bottomNav ? 'all' : 'only x') // disable or enable the style
|
||||
}, { init: false }) // init: false because the inline script takes care of it
|
||||
}
|
|
@ -10,5 +10,5 @@ export function centerNavObservers () {
|
|||
|
||||
// disables or enables the style
|
||||
centerNavStyle.setAttribute('media', centerNav ? 'all' : 'only x')
|
||||
}, { init: false })
|
||||
}, { init: false }) // init:false because the inline script takes care of it
|
||||
}
|
||||
|
|
|
@ -10,5 +10,5 @@ export function customScrollbarObservers () {
|
|||
|
||||
// disables or enables the style
|
||||
theScrollbarStyle.setAttribute('media', disableCustomScrollbars ? 'only x' : 'all')
|
||||
}, { init: false })
|
||||
}, { init: false }) // init:false because the inline script takes care of it
|
||||
}
|
||||
|
|
|
@ -12,5 +12,5 @@ export function grayscaleObservers (store) {
|
|||
const theme = instanceThemes && instanceThemes[currentInstance]
|
||||
style.setAttribute('media', enableGrayscale ? 'all' : 'only x') // disable or enable the style
|
||||
switchToTheme(theme, enableGrayscale)
|
||||
})
|
||||
}, { init: false }) // init:false because the inline script takes care of it
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { touchObservers } from './touchObservers.js'
|
|||
import { grayscaleObservers } from './grayscaleObservers.js'
|
||||
import { focusRingObservers } from './focusRingObservers.js'
|
||||
import { leftRightFocusObservers } from './leftRightFocusObservers.js'
|
||||
import { bottomNavObservers } from './bottomNavObservers.js'
|
||||
|
||||
export function observers (store) {
|
||||
onlineObservers(store)
|
||||
|
@ -21,5 +22,6 @@ export function observers (store) {
|
|||
focusRingObservers(store)
|
||||
grayscaleObservers(store)
|
||||
leftRightFocusObservers(store)
|
||||
bottomNavObservers(store)
|
||||
setupLoggedInObservers(store)
|
||||
}
|
||||
|
|
|
@ -89,6 +89,7 @@ export function wordFilterObservers () {
|
|||
console.log('Word filters changed, forcing an update')
|
||||
// eslint-disable-next-line camelcase
|
||||
const { timelineData_timelineItemSummaries, timelineData_timelineItemSummariesToAdd } = store.get()
|
||||
// eslint-disable-next-line camelcase
|
||||
store.set({ timelineData_timelineItemSummaries, timelineData_timelineItemSummariesToAdd })
|
||||
}
|
||||
stop('update timeline item summary filter contexts')
|
||||
|
|
|
@ -14,6 +14,7 @@ const persistedState = {
|
|||
currentRegisteredInstance: undefined,
|
||||
// we disable scrollbars by default on iOS
|
||||
disableCustomScrollbars: process.browser && /iP(?:hone|ad|od)/.test(navigator.userAgent),
|
||||
bottomNav: false,
|
||||
centerNav: false,
|
||||
disableFavCounts: false,
|
||||
disableFollowerCounts: false,
|
||||
|
@ -34,6 +35,7 @@ const persistedState = {
|
|||
loggedInInstances: {},
|
||||
loggedInInstancesInOrder: [],
|
||||
markMediaAsSensitive: false,
|
||||
showAllSpoilers: false,
|
||||
neverMarkMediaAsSensitive: false,
|
||||
ignoreBlurhash: false,
|
||||
omitEmojiInDisplayNames: undefined,
|
||||
|
|
|
@ -108,7 +108,7 @@ A11yDialog.prototype.show = function (event) {
|
|||
// it later, then set the focus to the first focusable child of the dialog
|
||||
// element
|
||||
focusedBeforeDialog = document.activeElement
|
||||
setFocusToFirstItem(this.node)
|
||||
setInitialFocus(this.node)
|
||||
|
||||
// Bind a focus event listener to the body element to make sure the focus
|
||||
// stays trapped inside the dialog while open, and start listening for some
|
||||
|
@ -281,7 +281,7 @@ A11yDialog.prototype._maintainFocus = function (event) {
|
|||
// If the dialog is shown and the focus is not within the dialog element,
|
||||
// move it back to its first focusable child
|
||||
if (this.shown && !this.node.contains(event.target)) {
|
||||
setFocusToFirstItem(this.node)
|
||||
setInitialFocus(this.node)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -333,9 +333,17 @@ function collect (target) {
|
|||
*
|
||||
* @param {Element} node
|
||||
*/
|
||||
function setFocusToFirstItem (node) {
|
||||
function setInitialFocus (node) {
|
||||
const focusableChildren = getFocusableChildren(node)
|
||||
|
||||
// If there's an element with an autofocus attribute, focus that.
|
||||
for (const child of focusableChildren) {
|
||||
if (child.getAttribute('autofocus')) {
|
||||
child.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Otherwise, focus the first focusable element.
|
||||
if (focusableChildren.length) {
|
||||
focusableChildren[0].focus()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
Copyright <YEAR> <COPYRIGHT HOLDER>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,24 @@
|
|||
// via https://github.com/joppuyo/large-small-dynamic-viewport-units-polyfill/blob/93782ffff5d76f46b71591b859aac44f3cd591b2/src/index.js
|
||||
// with some stuff removed that we don't need
|
||||
import { throttleTimer } from '../../_utils/throttleTimer.js'
|
||||
|
||||
// Don't execute this resize listener more than the browser can paint
|
||||
const rafAlignedResize = process.browser && throttleTimer(requestAnimationFrame)
|
||||
|
||||
function setVh () {
|
||||
const dvh = window.innerHeight * 0.01
|
||||
document.documentElement.style.setProperty('--1dvh', (dvh + 'px'))
|
||||
}
|
||||
|
||||
if (process.browser) {
|
||||
// We run the calculation as soon as possible (eg. the script is in document head)
|
||||
setVh()
|
||||
|
||||
// We run the calculation again when DOM has loaded
|
||||
document.addEventListener('DOMContentLoaded', setVh)
|
||||
|
||||
// We run the calculation when window is resized
|
||||
window.addEventListener('resize', () => {
|
||||
rafAlignedResize(setVh)
|
||||
})
|
||||
}
|
|
@ -11,7 +11,7 @@ const { Store } = storePackage
|
|||
export class RealmStore extends Store {
|
||||
constructor (init, maxSize) {
|
||||
super(init)
|
||||
this.set({ realms: new QuickLRU({ maxSize: maxSize }) })
|
||||
this.set({ realms: new QuickLRU({ maxSize }) })
|
||||
this._batches = {}
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,7 @@ export class RealmStore extends Store {
|
|||
setForRealm (obj) {
|
||||
const { currentRealm, realms } = this.get()
|
||||
realms.set(currentRealm, Object.assign(realms.get(currentRealm) || {}, obj))
|
||||
this.set({ realms: realms })
|
||||
this.set({ realms })
|
||||
}
|
||||
|
||||
computeForRealm (key, defaultValue) {
|
||||
|
@ -67,7 +67,7 @@ export class RealmStore extends Store {
|
|||
delete this._batches[currentRealm][key]
|
||||
const { realms } = this.get()
|
||||
realms.set(currentRealm, Object.assign(realms.get(currentRealm) || {}, { [key]: obj }))
|
||||
this.set({ realms: realms })
|
||||
this.set({ realms })
|
||||
stop('batchUpdate')
|
||||
})
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ async function _fetch (url, fetchOptions, options) {
|
|||
async function _putOrPostOrPatch (method, url, body, headers, options) {
|
||||
const fetchOptions = makeFetchOptions(method, headers, options)
|
||||
if (body) {
|
||||
if (body instanceof FormData) {
|
||||
if (body instanceof FormData || body instanceof URLSearchParams) {
|
||||
fetchOptions.body = body
|
||||
} else {
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import emojiRegex from 'emoji-regex/es2015/text.js'
|
||||
import emojiRegex from 'emoji-regex'
|
||||
import { thunk } from './thunk.js'
|
||||
|
||||
export const getEmojiRegex = thunk(emojiRegex)
|
||||
|
|
|
@ -37,3 +37,7 @@ export const importIntlListFormat = async () => { // has to be imported serially
|
|||
/* webpackChunkName: '$polyfill$-internationalization' */ '@formatjs/intl-listformat/locale-data/en.js'
|
||||
)
|
||||
}
|
||||
|
||||
export const importDynamicViewportUnitsPolyfill = () => import(
|
||||
/* webpackChunkName: '$polyfill$-dynamic-viewport-units' */ '../../_thirdparty/large-small-dynamic-viewport-units-polyfill/dynamic-viewport-utils-polyfill.js'
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
importDynamicViewportUnitsPolyfill,
|
||||
importIntlListFormat,
|
||||
importIntlLocale, importIntlPluralRules, importIntlRelativeTimeFormat,
|
||||
importRequestIdleCallback
|
||||
|
@ -25,7 +26,8 @@ export async function loadPolyfills () {
|
|||
mark('loadPolyfills')
|
||||
await Promise.all([
|
||||
typeof requestIdleCallback !== 'function' && importRequestIdleCallback(),
|
||||
loadIntlPolyfillsIfNecessary()
|
||||
loadIntlPolyfillsIfNecessary(),
|
||||
!CSS.supports('height: 1dvh') && importDynamicViewportUnitsPolyfill()
|
||||
])
|
||||
stop('loadPolyfills')
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue