kopia lustrzana https://github.com/nolanlawson/pinafore
Merge remote-tracking branch 'origin/master' into page-titles
commit
ef12dacd6c
|
@ -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/v14.21.1/node-v14.21.1-linux-x64.tar.xz" \
|
||||
| sudo tar --strip-components=2 -xJ -C /usr/local/bin/ node-v14.21.1-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 --frozen-lockfile
|
||||
- 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-18.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.3'
|
||||
- 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-18.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.3'
|
||||
- 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
|
|
@ -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`
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Pinafore [![Build status](https://circleci.com/gh/nolanlawson/pinafore.svg?style=svg)](https://app.circleci.com/pipelines/gh/nolanlawson/pinafore)
|
||||
# Pinafore
|
||||
|
||||
An alternative web client for [Mastodon](https://joinmastodon.org), focused on speed and simplicity.
|
||||
|
||||
|
|
|
@ -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
|
|
@ -18,6 +18,9 @@ DB_PASS=${DB_PASS}
|
|||
BIND=0.0.0.0
|
||||
`
|
||||
|
||||
export const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
|
||||
export const GIT_TAG = 'v3.5.3'
|
||||
|
||||
export const RUBY_VERSION = '3.0.3'
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pinafore",
|
||||
"description": "Alternative web client for Mastodon",
|
||||
"version": "2.2.3",
|
||||
"version": "2.3.2",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || ^16.0.0 || ^18.0.0"
|
||||
|
@ -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",
|
||||
|
|
|
@ -34,10 +34,18 @@
|
|||
|
||||
<style id="theBottomNavStyle" media="only x">
|
||||
:root {
|
||||
--nav-top: calc(100vh - var(--nav-total-height));
|
||||
--nav-bottom: 0px;
|
||||
--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>
|
||||
|
||||
|
|
|
@ -0,0 +1,692 @@
|
|||
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
|
||||
overLimit: '{count} {count, plural, =1 {символ} other {символов}} сверх лимита',
|
||||
underLimit: '{count} {count, plural, =1 {символ} other {символов}} осталось',
|
||||
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}'
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,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%);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
@ -75,7 +77,7 @@
|
|||
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'
|
||||
|
||||
|
@ -118,7 +120,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 +131,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')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<style>
|
||||
.toast-modal {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
bottom: calc(40px + var(--toast-gap-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -135,5 +135,5 @@
|
|||
--focus-bg: #{rgba(0, 0, 0, 0.1)};
|
||||
|
||||
accent-color: #{$main-theme-color};
|
||||
color-scheme: light dark;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
|
|
@ -53,5 +53,5 @@
|
|||
|
||||
--focus-bg: #{rgba(255, 255, 255, 0.1)};
|
||||
|
||||
color-scheme: dark light;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
|
@ -65,7 +65,8 @@
|
|||
// Used for moving the nav bar to the bottom
|
||||
--nav-top: 0px;
|
||||
--nav-bottom: initial;
|
||||
|
||||
--toast-gap-bottom: 0px; // used to position the Toast and Snackbar above the bottom nav
|
||||
--fab-gap-top: var(--nav-total-height); // used to position the FAB (floating action button) below the top nav
|
||||
|
||||
//
|
||||
// focus outline
|
||||
|
|
|
@ -41,8 +41,8 @@ test('shows account profile 3', async t => {
|
|||
.expect(accountProfileName.innerText).contains('foobar')
|
||||
.expect(accountProfileUsername.innerText).contains('@foobar')
|
||||
// can't follow or be followed by your own account
|
||||
.expect(accountProfileFollowedBy.innerText).eql('')
|
||||
.expect($('.account-profile .account-profile-follow').innerText).eql('')
|
||||
.expect(accountProfileFollowedBy.innerText).match(/\s*/)
|
||||
.expect($('.account-profile .account-profile-follow').innerText).match(/\s*/)
|
||||
})
|
||||
|
||||
test('shows account profile statuses', async t => {
|
||||
|
|
|
@ -7,9 +7,17 @@ import {
|
|||
getNthStatusMediaImg,
|
||||
getNthStatusSensitiveMediaButton,
|
||||
getNthStatusSpoiler,
|
||||
getUrl, modalDialog,
|
||||
getUrl,
|
||||
modalDialog,
|
||||
scrollToStatus,
|
||||
isNthStatusActive, getActiveElementRectTop, scrollToTop, isActiveStatusPinned, getFirstModalMedia
|
||||
isNthStatusActive,
|
||||
getActiveElementRectTop,
|
||||
scrollToTop,
|
||||
isActiveStatusPinned,
|
||||
getFirstModalMedia,
|
||||
getNthStatusAccountLink,
|
||||
getNthStatusAccountLinkSelector,
|
||||
focus
|
||||
} from '../utils'
|
||||
import { homeTimeline } from '../fixtures'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
|
@ -216,3 +224,13 @@ test('Shortcut j/k change the active status on pinned statuses', async t => {
|
|||
.expect(isNthStatusActive(1)()).ok()
|
||||
.expect(isActiveStatusPinned()).eql(true)
|
||||
})
|
||||
|
||||
test('Shortcut down makes next status active when focused inside of a status', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatusAccountLink(1).exists).ok()
|
||||
await focus(getNthStatusAccountLinkSelector(1))()
|
||||
await t
|
||||
.pressKey('down')
|
||||
.expect(isNthStatusActive(2)()).ok()
|
||||
})
|
||||
|
|
|
@ -522,8 +522,12 @@ export function getNthStatusOptionsButton (n) {
|
|||
return $(`${getNthStatusSelector(n)} .status-toolbar button:nth-child(4)`)
|
||||
}
|
||||
|
||||
export function getNthStatusAccountLinkSelector (n) {
|
||||
return `${getNthStatusSelector(n)} .status-author-name`
|
||||
}
|
||||
|
||||
export function getNthStatusAccountLink (n) {
|
||||
return $(`${getNthStatusSelector(n)} .status-author-name`)
|
||||
return $(getNthStatusAccountLinkSelector(n))
|
||||
}
|
||||
|
||||
export function getNthFavoritedLabel (n) {
|
||||
|
|
|
@ -127,8 +127,8 @@ export default {
|
|||
dev && new webpack.HotModuleReplacementPlugin({
|
||||
requestTimeout: 120000
|
||||
}),
|
||||
// generates report.html, somewhat expensive to compute, so avoid in CircleCI tests
|
||||
!dev && !process.env.CIRCLECI && new BundleAnalyzerPlugin({
|
||||
// generates report.html, somewhat expensive to compute, so avoid in CI tests
|
||||
!dev && !process.env.CI && new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
openAnalyzer: false,
|
||||
logLevel: 'silent'
|
||||
|
|
Ładowanie…
Reference in New Issue