Merge remote-tracking branch 'origin/master' into page-titles

page-titles
Nolan Lawson 2022-11-25 11:20:32 -08:00
commit ef12dacd6c
36 zmienionych plików z 1063 dodań i 283 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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`

Wyświetl plik

@ -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.

Wyświetl plik

@ -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 () {

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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",

Wyświetl plik

@ -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>

692
src/intl/ru-RU.JS 100644
Wyświetl plik

@ -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}'
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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 }))
}
}
}
})

Wyświetl plik

@ -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>

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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%);
}

Wyświetl plik

@ -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')

Wyświetl plik

@ -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()

Wyświetl plik

@ -6,7 +6,7 @@
<style>
.toast-modal {
position: fixed;
bottom: 40px;
bottom: calc(40px + var(--toast-gap-bottom));
left: 0;
right: 0;
opacity: 0;

Wyświetl plik

@ -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
]

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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.

Wyświetl plik

@ -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)
})
}

Wyświetl plik

@ -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'
)

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -135,5 +135,5 @@
--focus-bg: #{rgba(0, 0, 0, 0.1)};
accent-color: #{$main-theme-color};
color-scheme: light dark;
color-scheme: light;
}

Wyświetl plik

@ -53,5 +53,5 @@
--focus-bg: #{rgba(255, 255, 255, 0.1)};
color-scheme: dark light;
color-scheme: dark;
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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()
})

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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'