Compare commits
59 Commits
Author | SHA1 | Date |
---|---|---|
Nolan Lawson | 8f61ea75ce | |
Nolan Lawson | 5889b404cb | |
Nolan Lawson | 794d9ca74e | |
Nolan Lawson | 72a07ac40d | |
Nolan Lawson | ed9a9f6539 | |
Arnaldo Gabriel | 452b34b3b4 | |
Thomas Preece | fd4bb4d864 | |
vitalyster | c426b7fe31 | |
Noelia Ruiz Martínez | c2851ce104 | |
Nolan Lawson | 2578d0964d | |
Noelia Ruiz Martínez | ff53fcab10 | |
Nolan Lawson | 750235cd8f | |
Nolan Lawson | b5cad87aaf | |
Nick Colley | a85ff62d48 | |
Nick Colley | e06f63684e | |
Nick Colley | f81778d37f | |
Nick Colley | 746298a1f7 | |
Nolan Lawson | 02f1dad098 | |
Nick Colley | 3edfed971f | |
Noelia Ruiz Martínez | d71430f86d | |
Noelia Ruiz Martínez | 6124c948de | |
Nolan Lawson | 774aa7a21c | |
Nolan Lawson | 276c6e7bea | |
Nolan Lawson | f61054a3d5 | |
Nolan Lawson | b1dc43a9c9 | |
Nolan Lawson | 040462f5b5 | |
Thomas Broyer | f5f3395a53 | |
Nick Colley | 3fb152ac7c | |
Daniel Soohan Park | 97e3b04f1f | |
Scott Feeney | 3c32b48e29 | |
Noelia Ruiz Martínez | 4a6907bbdc | |
Thomas Broyer | d31c800806 | |
Nolan Lawson | 380d2a0d45 | |
Nolan Lawson | 7fdbd72f13 | |
dependabot[bot] | 62b30f6d99 | |
Nolan Lawson | 6d6eb59f41 | |
James Teh | 30b00667f2 | |
Nick Colley | da28e98cfb | |
Nick Colley | 7417e89f78 | |
James Teh | 815438172e | |
James Teh | 8fc9d5c728 | |
Scott Feeney | a775bd9193 | |
Nolan Lawson | edb7e7b442 | |
Maxime Le Conte des Floris | 3c857d74b8 | |
Ringtail Software | 5eb7183048 | |
Nolan Lawson | a3f41917c7 | |
Nolan Lawson | 098da30f2a | |
Nolan Lawson | abc39ef982 | |
Nick Colley | b543399e0a | |
Nolan Lawson | fda00fc87c | |
Nick Colley | 0e4523a37d | |
Nolan Lawson | 4fb8f37db7 | |
Nolan Lawson | fac42a91a0 | |
Nolan Lawson | b50b9dc40b | |
Nick Colley | bc664e5ca1 | |
Marco Zehe | fa41fe7649 | |
Nolan Lawson | 53803db5be | |
James Teh | 8792d912bc | |
James Teh | a447b9535e |
|
@ -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-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.2
|
||||
env:
|
||||
POSTGRES_USER: pinafore
|
||||
POSTGRES_PASSWORD: pinafore
|
||||
POSTGRES_DB: pinafore_development
|
||||
POSTGRES_HOST: 127.0.0.1
|
||||
POSTGRES_PORT: 5432
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0.4'
|
||||
- name: Cache Mastodon bundler
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bundle-vendor-cache
|
||||
# cache based on masto version implicitly defined in mastodon-config.js
|
||||
key: masto-bundler-v3-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Cache Mastodon's and our yarn
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
# cache based on our version and masto version implicitly defined in mastodon-config.js
|
||||
# because we share the yarn cache
|
||||
key: masto-yarn-v1-${{ hashFiles('yarn.lock') }}-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Install Mastodon system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
fonts-noto-color-emoji \
|
||||
imagemagick \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn clone-mastodon
|
||||
- name: Move bundler cache so Mastodon can find it
|
||||
run: if [ -d ~/.bundle-vendor-cache ]; then mkdir -p ./mastodon/vendor && mv ~/.bundle-vendor-cache ./mastodon/vendor/bundle; fi
|
||||
- name: Read-only e2e tests
|
||||
run: yarn test-in-ci-suite0
|
||||
- name: Move bundler cache so GitHub Actions can find it
|
||||
run: mv ./mastodon/vendor/bundle ~/.bundle-vendor-cache
|
|
@ -0,0 +1,65 @@
|
|||
name: Read-write e2e tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.2
|
||||
env:
|
||||
POSTGRES_USER: pinafore
|
||||
POSTGRES_PASSWORD: pinafore
|
||||
POSTGRES_DB: pinafore_development
|
||||
POSTGRES_HOST: 127.0.0.1
|
||||
POSTGRES_PORT: 5432
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0.4'
|
||||
- name: Cache Mastodon bundler
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.bundle-vendor-cache
|
||||
# cache based on masto version implicitly defined in mastodon-config.js
|
||||
key: masto-bundler-v3-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Cache Mastodon's and our yarn
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
# cache based on our version and masto version implicitly defined in mastodon-config.js
|
||||
# because we share the yarn cache
|
||||
key: masto-yarn-v1-${{ hashFiles('yarn.lock') }}-${{ hashFiles('bin/mastodon-config.js') }}
|
||||
- name: Install Mastodon system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
fonts-noto-color-emoji \
|
||||
imagemagick \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libprotobuf-dev \
|
||||
postgresql-contrib \
|
||||
protobuf-compiler
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn build
|
||||
- run: yarn clone-mastodon
|
||||
- name: Move bundler cache so Mastodon can find it
|
||||
run: if [ -d ~/.bundle-vendor-cache ]; then mkdir -p ./mastodon/vendor && mv ~/.bundle-vendor-cache ./mastodon/vendor/bundle; fi
|
||||
- name: Read-write e2e tests
|
||||
run: yarn test-in-ci-suite1
|
||||
- name: Move bundler cache so GitHub Actions can find it
|
||||
run: mv ./mastodon/vendor/bundle ~/.bundle-vendor-cache
|
|
@ -0,0 +1,17 @@
|
|||
name: Unit tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn lint
|
||||
- run: yarn test-vercel-json
|
||||
- run: yarn test-unit
|
|
@ -38,7 +38,7 @@ running on `localhost:3000`.
|
|||
### Running integration tests
|
||||
|
||||
The integration tests require running Mastodon itself,
|
||||
meaning the [Mastodon development guide](https://docs.joinmastodon.org/development/overview/)
|
||||
meaning the [Mastodon development guide](https://docs.joinmastodon.org/dev/setup/)
|
||||
is relevant here. In particular, you'll need a recent
|
||||
version of Ruby, Redis, and Postgres running. For a full list of deps, see `bin/setup-mastodon-in-travis.sh`.
|
||||
|
||||
|
@ -120,8 +120,8 @@ or
|
|||
|
||||
1. Run `rm -fr mastodon` to clear out all Mastodon data
|
||||
1. Comment out `await restoreMastodonData()` in `run-mastodon.js` to avoid actually populating the database with statuses/favorites/etc.
|
||||
2. Update the `GIT_TAG_OR_BRANCH` in `clone-mastodon.js` to whatever you want
|
||||
3. If the Ruby version changed (check Mastodon's `.ruby-version`), install it and update `RUBY_VERSION` in `mastodon-config.js` as well as the Ruby version in `.circleci/config.yml`.
|
||||
2. Update the `GIT_TAG` in `mastodon-config.js` to whatever you want
|
||||
3. If the Ruby version changed (check Mastodon's `.ruby-version`), install it and update `RUBY_VERSION` in `mastodon-config.js` as well as the Ruby version in `.github/workflows`.
|
||||
4. Run `yarn run-mastodon`
|
||||
5. Run `yarn backup-mastodon-data` to overwrite the data in `fixtures/`
|
||||
6. Uncomment `await restoreMastodonData()` in `run-mastodon.js`
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# Pinafore [![Build status](https://circleci.com/gh/nolanlawson/pinafore.svg?style=svg)](https://app.circleci.com/pipelines/gh/nolanlawson/pinafore)
|
||||
# Pinafore [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/)
|
||||
|
||||
_**Note:** Pinafore is unmaintained. Read [this](https://nolanlawson.com/2023/01/09/retiring-pinafore/). Original documentation follows._
|
||||
|
||||
An alternative web client for [Mastodon](https://joinmastodon.org), focused on speed and simplicity.
|
||||
|
||||
|
|
|
@ -21,15 +21,8 @@ const JSON_TEMPLATE = {
|
|||
github: {
|
||||
silent: true
|
||||
},
|
||||
builds: [
|
||||
{
|
||||
src: 'package.json',
|
||||
use: '@now/static-build',
|
||||
config: {
|
||||
distDir: '__sapper__/export'
|
||||
}
|
||||
}
|
||||
],
|
||||
buildCommand: 'yarn build',
|
||||
outputDirectory: '__sapper__/export',
|
||||
routes: [
|
||||
{
|
||||
src: '^/service-worker\\.js$',
|
||||
|
@ -51,7 +44,13 @@ const JSON_TEMPLATE = {
|
|||
}
|
||||
},
|
||||
{
|
||||
src: '^/.*\\.(png|css|json|svg|jpe?g|map|txt|gz|webapp|woff|woff2)$',
|
||||
src: '^/.*\\.(png|jpe?g)$',
|
||||
headers: {
|
||||
'cache-control': 'public,max-age=31536000,immutable'
|
||||
}
|
||||
},
|
||||
{
|
||||
src: '^/.*\\.(css|json|svg|map|txt|gz|webapp|woff|woff2)$',
|
||||
headers: {
|
||||
'cache-control': 'public,max-age=3600'
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { promisify } from 'util'
|
|||
import childProcessPromise from 'child-process-promise'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { envFile, RUBY_VERSION } from './mastodon-config.js'
|
||||
import { envFile, GIT_TAG, GIT_URL, RUBY_VERSION } from './mastodon-config.js'
|
||||
import esMain from 'es-main'
|
||||
|
||||
const exec = childProcessPromise.exec
|
||||
|
@ -11,9 +11,6 @@ const writeFile = promisify(fs.writeFile)
|
|||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
const dir = __dirname
|
||||
|
||||
const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
|
||||
const GIT_TAG = 'v3.5.3'
|
||||
|
||||
const mastodonDir = path.join(dir, '../mastodon')
|
||||
|
||||
export default async function cloneMastodon () {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Designed to be run before yarn build, and then tested with test-vercel-json-unchanged.sh
|
||||
|
||||
cp ./vercel.json /tmp/vercel-old.json
|
|
@ -4,12 +4,14 @@ import { DEFAULT_LOCALE, LOCALE } from '../src/routes/_static/intl.js'
|
|||
import enUS from '../src/intl/en-US.js'
|
||||
import fr from '../src/intl/fr.js'
|
||||
import de from '../src/intl/de.js'
|
||||
import es from '../src/intl/es.js'
|
||||
|
||||
// TODO: make it so we don't have to explicitly list these out
|
||||
const locales = {
|
||||
'en-US': enUS,
|
||||
fr,
|
||||
de
|
||||
de,
|
||||
es
|
||||
}
|
||||
|
||||
const intl = locales[LOCALE]
|
||||
|
|
|
@ -43,8 +43,8 @@ async function setupMastodonDatabase () {
|
|||
async function installMastodonDependencies () {
|
||||
const cwd = mastodonDir
|
||||
const installCommands = [
|
||||
'gem update --system',
|
||||
'gem install bundler foreman',
|
||||
'gem install bundler -v 2.3.26 --no-document',
|
||||
'gem install foreman -v 0.87.2 --no-document',
|
||||
'bundle config set --local frozen \'true\'',
|
||||
'bundle install',
|
||||
'yarn --pure-lockfile'
|
||||
|
|
|
@ -18,7 +18,10 @@ DB_PASS=${DB_PASS}
|
|||
BIND=0.0.0.0
|
||||
`
|
||||
|
||||
export const RUBY_VERSION = '3.0.3'
|
||||
export const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
|
||||
export const GIT_TAG = 'v4.0.2'
|
||||
|
||||
export const RUBY_VERSION = '3.0.4'
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname)
|
||||
export const mastodonDir = path.join(__dirname, '../mastodon')
|
||||
|
|
|
@ -15,7 +15,7 @@ async function runMastodon () {
|
|||
const cwd = mastodonDir
|
||||
const promise = spawn('foreman', ['start'], { cwd, env })
|
||||
// don't bother writing to mastodon.log in CI; we can't read the file anyway
|
||||
const logFile = process.env.CIRCLECI ? '/dev/null' : 'mastodon.log'
|
||||
const logFile = process.env.CI ? '/dev/null' : 'mastodon.log'
|
||||
const log = fs.createWriteStream(logFile, { flags: 'a' })
|
||||
childProc = promise.childProcess
|
||||
childProc.stdout.pipe(log)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export default [
|
||||
{ id: 'pinafore-logo', src: 'src/static/sailboat.svg', inline: true },
|
||||
{ id: 'fa-arrow-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/arrow-left.svg' },
|
||||
{ id: 'fa-bell', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell.svg', inline: true },
|
||||
{ id: 'fa-bell-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bell-o.svg' },
|
||||
{ id: 'fa-users', src: 'src/thirdparty/font-awesome-svg-png/white/svg/users.svg', inline: true },
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# In CI, we need to make sure the vercel.json file is built correctly,
|
||||
# or else it will mess up the deployment to Vercel
|
||||
|
||||
if ! diff -q /tmp/vercel-old.json ./vercel.json &>/dev/null; then
|
||||
diff /tmp/vercel-old.json ./vercel.json
|
||||
echo "vercel.json changed, run yarn build and make sure everything looks okay"
|
||||
exit 1
|
||||
fi
|
|
@ -16,5 +16,4 @@ or
|
|||
|
||||
LOCALE=fr yarn dev
|
||||
|
||||
There is also an experimental `LOCALE_DIRECTION` environment variable for the direction (LTR versus RTL) which is
|
||||
exposed to the source code while building.
|
||||
To host a localized version of Pinafore using Vercel, you can see this example: [buildCommand in vercel.json for Spanish](https://github.com/nvdaes/vercelPinafore/blob/45c70fb2088fe5f2380a729dab83e6f3ab4e6291/vercel.json#L9).
|
10
package.json
10
package.json
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "pinafore",
|
||||
"description": "Alternative web client for Mastodon",
|
||||
"version": "2.3.2",
|
||||
"version": "2.6.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || ^16.0.0 || ^18.0.0"
|
||||
"node": "^12.20.0 || ^14.13.1 || ^16.0.0 || ^18.0.0 || ^20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "standard && standard --plugin html 'src/routes/**/*.html'",
|
||||
|
@ -31,11 +31,14 @@
|
|||
"test-mastodon-suite0": "run-s wait-for-mastodon-to-start wait-for-mastodon-data testcafe-suite0",
|
||||
"test-mastodon-suite1": "run-s wait-for-mastodon-to-start wait-for-mastodon-data testcafe-suite1",
|
||||
"testcafe": "run-s testcafe-suite0 testcafe-suite1",
|
||||
"testcafe-suite0": "cross-env-shell testcafe $BROWSER tests/spec/0*",
|
||||
"testcafe-suite0": "cross-env-shell testcafe -c 2 $BROWSER tests/spec/0*",
|
||||
"testcafe-suite1": "cross-env-shell testcafe $BROWSER tests/spec/1*",
|
||||
"test-unit": "NODE_ENV=test mocha -r bin/browser-shim.js tests/unit/",
|
||||
"test-in-ci-suite0": "cross-env BROWSER=chrome:headless run-p --race run-mastodon start test-mastodon-suite0",
|
||||
"test-in-ci-suite1": "cross-env BROWSER=chrome:headless run-p --race run-mastodon start test-mastodon-suite1",
|
||||
"test-vercel-json": "run-s test-vercel-json-copy build test-vercel-json-test",
|
||||
"test-vercel-json-copy": "./bin/copy-vercel-json.sh",
|
||||
"test-vercel-json-test": "./bin/test-vercel-json-unchanged.sh",
|
||||
"wait-for-mastodon-to-start": "node bin/wait-for-mastodon-to-start.js",
|
||||
"wait-for-mastodon-data": "node bin/wait-for-mastodon-data.js",
|
||||
"backup-mastodon-data": "./bin/backup-mastodon-data.sh",
|
||||
|
@ -52,6 +55,7 @@
|
|||
"@formatjs/intl-pluralrules": "^5.1.4",
|
||||
"@formatjs/intl-relativetimeformat": "^11.1.4",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^14.1.0",
|
||||
"@rollup/plugin-replace": "^2.4.2",
|
||||
"@stdlib/utils-noop": "^0.0.13",
|
||||
"arrow-key-navigation": "^1.2.0",
|
||||
|
|
|
@ -34,13 +34,19 @@
|
|||
|
||||
<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>
|
||||
|
||||
<style id="theGrayscaleStyle" media="only x">
|
||||
|
@ -49,7 +55,7 @@
|
|||
*/
|
||||
img, svg, video,
|
||||
input[type="checkbox"], input[type="radio"],
|
||||
.inline-emoji, .theme-preview {
|
||||
.inline-emoji, .theme-preview, .account-profile {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -170,12 +170,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -188,8 +188,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Hefte {label} an',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {Zeichen} other {Zeichen}} über der Beschränkung',
|
||||
underLimit: '{count} {count, plural, =1 {Zeichen} other {Zeichen}} übrig',
|
||||
composeStatus: 'Tröt erstellen',
|
||||
postStatus: 'Tröt!',
|
||||
contentWarning: 'Inhaltswarnung',
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
export default {
|
||||
categoriesLabel: 'Kategorien',
|
||||
emojiUnsupportedMessage: 'Dein Browser unterstützt keine farbigen Emojis.',
|
||||
favoritesLabel: 'Favoriten',
|
||||
loadingMessage: 'Wird geladen…',
|
||||
networkErrorMessage: 'Konnte Emoji nicht laden. Versuche, die Seite neu zu laden.',
|
||||
regionLabel: 'Emoji auswählen',
|
||||
searchDescription: 'Wenn Suchergebnisse verfügbar sind, wähle sie mit Pfeil rauf und runter, dann Eingabetaste, aus.',
|
||||
searchLabel: 'Suchen',
|
||||
searchResultsLabel: 'Suchergebnisse',
|
||||
skinToneDescription: 'Wenn angezeigt, nutze Pfeiltasten rauf und runter zum Auswählen, Eingabe zum Akzeptieren.',
|
||||
skinToneLabel: 'Wähle einen Hautton (aktuell {skinTone})',
|
||||
skinTonesLabel: 'Hauttöne',
|
||||
skinTones: [
|
||||
'Standard',
|
||||
'Hell',
|
||||
'Mittel-hell',
|
||||
'Mittel',
|
||||
'Mittel-dunkel',
|
||||
'Dunkel'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Benutzerdefiniert',
|
||||
'smileys-emotion': 'Smileys und Emoticons',
|
||||
'people-body': 'Menschen und Körper',
|
||||
'animals-nature': 'Tiere und Natur',
|
||||
'food-drink': 'Essen und Trinken',
|
||||
'travel-places': 'Reisen und Orte',
|
||||
activities: 'Aktivitäten',
|
||||
objects: 'Objekte',
|
||||
symbols: 'Symbole',
|
||||
flags: 'Flaggen'
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
export default {
|
||||
categoriesLabel: 'Catégories',
|
||||
emojiUnsupportedMessage: 'Votre navigateur ne soutient pas les emojis en couleur.',
|
||||
favoritesLabel: 'Favoris',
|
||||
loadingMessage: 'Chargement en cours…',
|
||||
networkErrorMessage: 'Impossible de charger les emojis. Veuillez essayer de recharger.',
|
||||
regionLabel: 'Choisir un emoji',
|
||||
searchDescription: 'Quand les résultats sont disponisbles, appuyez la fleche vers le haut ou le bas et la touche entrée pour choisir.',
|
||||
searchLabel: 'Rechercher',
|
||||
searchResultsLabel: 'Résultats',
|
||||
skinToneDescription: 'Quand disponible, appuyez la fleche vers le haut ou le bas et la touch entrée pour choisir.',
|
||||
skinToneLabel: 'Choisir une couleur de peau (actuellement {skinTone})',
|
||||
skinTonesLabel: 'Couleurs de peau',
|
||||
skinTones: [
|
||||
'Défaut',
|
||||
'Clair',
|
||||
'Moyennement clair',
|
||||
'Moyen',
|
||||
'Moyennement sombre',
|
||||
'Sombre'
|
||||
],
|
||||
categories: {
|
||||
custom: 'Customisé',
|
||||
'smileys-emotion': 'Les smileyes et les émoticônes',
|
||||
'people-body': 'Les gens et le corps',
|
||||
'animals-nature': 'Les animaux et la nature',
|
||||
'food-drink': 'La nourriture et les boissons',
|
||||
'travel-places': 'Les voyages et les endroits',
|
||||
activities: 'Les activités',
|
||||
objects: 'Les objets',
|
||||
symbols: 'Les symbols',
|
||||
flags: 'Les drapeaux'
|
||||
}
|
||||
}
|
|
@ -153,6 +153,8 @@ export default {
|
|||
<li><kbd>f</kbd> to favorite</li>
|
||||
<li><kbd>b</kbd> to boost</li>
|
||||
<li><kbd>r</kbd> to reply</li>
|
||||
<li><kbd>Escape</kbd> to close reply</li>
|
||||
<li><kbd>a</kbd> to bookmark</li>
|
||||
<li><kbd>i</kbd> to open images, video, or audio</li>
|
||||
<li><kbd>y</kbd> to show or hide sensitive media</li>
|
||||
<li><kbd>m</kbd> to mention the author</li>
|
||||
|
@ -174,12 +176,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -192,8 +194,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Pin {label}',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {character} other {characters}} over limit',
|
||||
underLimit: '{count} {count, plural, =1 {character} other {characters}} remaining',
|
||||
composeStatus: 'Compose toot',
|
||||
postStatus: 'Toot!',
|
||||
contentWarning: 'Content warning',
|
||||
|
@ -205,7 +205,7 @@ export default {
|
|||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
description: 'Description',
|
||||
descriptionLabel: 'Describe for the visually impaired (image, video) or auditorily impaired (audio, video)',
|
||||
descriptionLabel: 'Describe for visually impaired (image, video) or auditorily impaired (audio, video) people',
|
||||
markAsSensitive: 'Mark media as sensitive',
|
||||
// Polls
|
||||
createPoll: 'Create poll',
|
||||
|
@ -229,7 +229,7 @@ export default {
|
|||
postPrivacyLabel: 'Adjust privacy (currently {label})',
|
||||
addContentWarning: 'Add content warning',
|
||||
removeContentWarning: 'Remove content warning',
|
||||
altLabel: 'Describe for the visually impaired',
|
||||
altLabel: 'Describe for visually impaired people',
|
||||
extractText: 'Extract text from image',
|
||||
extractingText: 'Extracting text…',
|
||||
extractingTextCompletion: 'Extracting text ({percent}% complete)…',
|
||||
|
@ -368,6 +368,7 @@ export default {
|
|||
general: 'General',
|
||||
generalSettings: 'General settings',
|
||||
showSensitive: 'Show sensitive media by default',
|
||||
showAllSpoilers: 'Expand content warnings by default',
|
||||
showPlain: 'Show a plain gray color for sensitive media',
|
||||
allSensitive: 'Treat all media as sensitive',
|
||||
largeMedia: 'Show large inline images and videos',
|
||||
|
@ -497,6 +498,8 @@ export default {
|
|||
}: {description}`,
|
||||
accountFollowedYou: '{name} followed you, {account}',
|
||||
accountSignedUp: '{name} signed up, {account}',
|
||||
accountRequestedFollow: '{name} requested to follow you, {account}',
|
||||
accountReported: '{name} filed a report, {account}',
|
||||
reblogCountsHidden: 'Boost counts hidden',
|
||||
favoriteCountsHidden: 'Favorite counts hidden',
|
||||
rebloggedTimes: `Boosted {count, plural,
|
||||
|
@ -511,6 +514,9 @@ export default {
|
|||
rebloggedYou: 'boosted your toot',
|
||||
favoritedYou: 'favorited your toot',
|
||||
followedYou: 'followed you',
|
||||
edited: 'edited their toot',
|
||||
requestedFollow: 'requested to follow you',
|
||||
reported: 'filed a report',
|
||||
signedUp: 'signed up',
|
||||
posted: 'posted',
|
||||
pollYouCreatedEnded: 'A poll you created has ended',
|
||||
|
@ -526,6 +532,7 @@ export default {
|
|||
// Accessible status labels
|
||||
accountRebloggedYou: '{account} boosted your toot',
|
||||
accountFavoritedYou: '{account} favorited your toot',
|
||||
accountEdited: '{account} edited their toot',
|
||||
rebloggedByAccount: 'Boosted by {account}',
|
||||
contentWarningContent: 'Content warning: {spoiler}',
|
||||
hasMedia: 'has media',
|
||||
|
|
|
@ -0,0 +1,696 @@
|
|||
export default {
|
||||
// Home page, basic <title> and <description>
|
||||
appName: 'Pinafore',
|
||||
appDescription: 'Un cliente web alternativo para Mastodon, centrado en la velocidad y la sencillez.',
|
||||
homeDescription: `
|
||||
<p>
|
||||
Pinafore es un cliente web para
|
||||
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
|
||||
diseñado para ser rápido y sencillo.
|
||||
</p>
|
||||
<p>
|
||||
Lee el
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">artículo introductorio en el blog</a>,
|
||||
o comienza iniciando sesión en una instancia:
|
||||
</p>`,
|
||||
logIn: 'Iniciar sesión',
|
||||
footer: `
|
||||
<p>
|
||||
Pinafore es
|
||||
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">software de código abierto</a>
|
||||
creado por
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
|
||||
y distribuido bajo la
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">Licencia AGPL</a>.
|
||||
Aquí está la <a href="/settings/about#privacy-policy" rel="prefetch">política de privacidad</a>.
|
||||
</p>
|
||||
`,
|
||||
// Manifest
|
||||
longAppName: 'Pinafore para Mastodon',
|
||||
newStatus: 'Nuevo toot',
|
||||
// Generic UI
|
||||
loading: 'Cargando',
|
||||
okay: 'OK',
|
||||
cancel: 'Cancelar',
|
||||
alert: 'Alerta',
|
||||
close: 'Cerrar',
|
||||
error: 'Error: {error}',
|
||||
errorShort: 'Error:',
|
||||
// Relative timestamps
|
||||
justNow: 'ahora mismo',
|
||||
// Navigation, page titles
|
||||
navItemLabel: `
|
||||
{label} {selected, select,
|
||||
true {(página actual)}
|
||||
other {}
|
||||
} {name, select,
|
||||
notifications {{count, plural,
|
||||
=0 {}
|
||||
one {(1 notificación)}
|
||||
other {({count} notificaciones)}
|
||||
}}
|
||||
community {{count, plural,
|
||||
=0 {}
|
||||
one {(1 solicitud de seguimiento)}
|
||||
other {({count} solicitudes de seguimiento)}
|
||||
}}
|
||||
other {}
|
||||
}
|
||||
`,
|
||||
blockedUsers: 'Usuarios bloqueados',
|
||||
bookmarks: 'Marcadores',
|
||||
directMessages: 'Mensajes directos',
|
||||
favorites: 'Favoritos',
|
||||
federated: 'Federada',
|
||||
home: 'Inicio',
|
||||
local: 'Local',
|
||||
notifications: 'Notificaciones',
|
||||
mutedUsers: 'Usuarios silenciados',
|
||||
pinnedStatuses: 'Toots fijados',
|
||||
followRequests: 'Solicitudes de seguimiento',
|
||||
followRequestsLabel: `Solicitudes de seguimiento {hasFollowRequests, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}`,
|
||||
list: 'Lista',
|
||||
search: 'Buscar',
|
||||
pageHeader: 'Encabezado de página',
|
||||
goBack: 'Retroceder',
|
||||
back: 'Atrás',
|
||||
profile: 'Perfil',
|
||||
federatedTimeline: 'Cronología federada',
|
||||
localTimeline: 'Cronología local',
|
||||
// community page
|
||||
community: 'Comunidad',
|
||||
pinnableTimelines: 'Cronologías que puedes fijar',
|
||||
timelines: 'Cronologías',
|
||||
lists: 'Listas',
|
||||
instanceSettings: 'Opciones para instancia',
|
||||
notificationMentions: 'Notificación de menciones',
|
||||
profileWithMedia: 'Perfil con multimedia',
|
||||
profileWithReplies: 'Perfil con respuestas',
|
||||
hashtag: 'Hashtag',
|
||||
// not logged in
|
||||
profileNotLoggedIn: 'Aquí se mostrará una cronología de usuario cuando hayas iniciado sesión.',
|
||||
bookmarksNotLoggedIn: 'Tus marcadores se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
directMessagesNotLoggedIn: 'Tus mensajes directos se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
favoritesNotLoggedIn: 'Tus favoritos se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
federatedTimelineNotLoggedIn: 'Tu cronología federada se mostrará aquí cuando hayas iniciado sesión.',
|
||||
localTimelineNotLoggedIn: 'Tu cronología localse mostrará aquí cuando hayas iniciado sesión.',
|
||||
searchNotLoggedIn: 'Puedes buscar una vez que inicias sesión en una instancia.',
|
||||
communityNotLoggedIn: 'Las opciones para comunidad se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
listNotLoggedIn: 'Aquí se mostrará una lista cuando hayas iniciado sesión.',
|
||||
notificationsNotLoggedIn: 'Tus notificaciones se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
notificationMentionsNotLoggedIn: 'Las notificaciones de tus menciones se mostrarán aquí cuando hayas iniciado sesión.',
|
||||
statusNotLoggedIn: 'Aquí se mostrará un hilo de toots cuando hayas iniciado sesión.',
|
||||
tagNotLoggedIn: 'Aquí se mostrará una cronología de hashtags cuando hayas iniciado sesión.',
|
||||
// Notification subpages
|
||||
filters: 'Filtros',
|
||||
all: 'Todo',
|
||||
mentions: 'Menciones',
|
||||
// Follow requests
|
||||
approve: 'Aceptar',
|
||||
reject: 'Rechazar',
|
||||
// Hotkeys
|
||||
hotkeys: 'Atajos de teclado',
|
||||
global: 'Globales',
|
||||
timeline: 'Cronología',
|
||||
media: 'Multimedia',
|
||||
globalHotkeys: `
|
||||
{leftRightChangesFocus, select,
|
||||
true {
|
||||
<li><kbd>→</kbd> para ir al elemento enfocable siguiente</li>
|
||||
<li><kbd>←</kbd> para ir al elemento enfocable anterior</li>
|
||||
}
|
||||
other {}
|
||||
}
|
||||
<li>
|
||||
<kbd>1</kbd> - <kbd>6</kbd>
|
||||
{leftRightChangesFocus, select,
|
||||
true {}
|
||||
other {o <kbd>←</kbd>/<kbd>→</kbd>}
|
||||
}
|
||||
para cambiar de columna
|
||||
</li>
|
||||
<li><kbd>7</kbd> o <kbd>c</kbd> para redactar un nuevo toot</li>
|
||||
<li><kbd>s</kbd> o <kbd>/</kbd> para buscar</li>
|
||||
<li><kbd>g</kbd> + <kbd>h</kbd> para ir a inicio</li>
|
||||
<li><kbd>g</kbd> + <kbd>n</kbd> para ir a notificaciones</li>
|
||||
<li><kbd>g</kbd> + <kbd>l</kbd> to para ir a la cronología local</li>
|
||||
<li><kbd>g</kbd> + <kbd>t</kbd> para ir a la cronología federada</li>
|
||||
<li><kbd>g</kbd> + <kbd>c</kbd> para ir a la página comunidad</li>
|
||||
<li><kbd>g</kbd> + <kbd>d</kbd> para ir a la página de mensajes directos</li>
|
||||
<li><kbd>h</kbd> o <kbd>?</kbd> para abrir o cerrar el diálogo de ayuda</li>
|
||||
<li><kbd>Backspace</kbd> para retroceder, cerrar diálogos</li>
|
||||
`,
|
||||
timelineHotkeys: `
|
||||
<li><kbd>j</kbd> o <kbd>↓</kbd> para activar el toot siguiente</li>
|
||||
<li><kbd>k</kbd> o <kbd>↑</kbd> para activar el toot anterior</li>
|
||||
<li><kbd>.</kbd> para mostrar más y desplazarse al principio</li>
|
||||
<li><kbd>o</kbd> para abrir</li>
|
||||
<li><kbd>f</kbd> para marcar como favorito</li>
|
||||
<li><kbd>b</kbd> para reenviar</li>
|
||||
<li><kbd>r</kbd> para responder</li>
|
||||
<li><kbd>Escape</kbd> para cerrar respuesta</li>
|
||||
<li><kbd>a</kbd> para marcador</li>
|
||||
<li><kbd>i</kbd> para abrir imágenes, vídeo o audio</li>
|
||||
<li><kbd>y</kbd> para mostrar u ocultar multimedia sensible</li>
|
||||
<li><kbd>m</kbd> para mencionar al autor</li>
|
||||
<li><kbd>p</kbd> para abrir el perfil del autor</li>
|
||||
<li><kbd>l</kbd> para abrir el enlace de la publicación en una nueva pestaña</li>
|
||||
<li><kbd>x</kbd> para mostrar u ocultar el texto tras una advertencia de contenido</li>
|
||||
<li><kbd>z</kbd> para mostrar u ocultar todas las advertencias de contenido en un hilo</li>
|
||||
`,
|
||||
mediaHotkeys: `
|
||||
<li><kbd>←</kbd> / <kbd>→</kbd> para ir a siguiente o anterior</li>
|
||||
`,
|
||||
// Community page, tabs
|
||||
tabLabel: `{label} {current, select,
|
||||
true {(Actual)}
|
||||
other {}
|
||||
}`,
|
||||
pageTitle: `
|
||||
{hasNotifications, select,
|
||||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
{pinned, select,
|
||||
true {(página fijada)}
|
||||
other {(Página no fijada)}
|
||||
}
|
||||
}
|
||||
other {}
|
||||
}`,
|
||||
pinPage: 'Fijar {label}',
|
||||
// Status composition
|
||||
composeStatus: 'Redactar toot',
|
||||
postStatus: 'Toot!',
|
||||
contentWarning: 'Advertencia de contenido',
|
||||
dropToUpload: 'Soltar para subir',
|
||||
invalidFileType: 'Tipo de fichero no válido',
|
||||
composeLabel: '¿En qué estás pensando?',
|
||||
autocompleteDescription: 'Cuando haya disponibles resultados de autocompletado, pulsa las flechas arriba o abajo y enter para seleccionar.',
|
||||
mediaUploads: 'Subidas multimedia',
|
||||
edit: 'Editar',
|
||||
delete: 'Borrar',
|
||||
description: 'Descripción',
|
||||
descriptionLabel: 'Describir para las personas con discapacidad visual (imagen, vídeo) o con discapacidad auditiva (audio, vídeo)',
|
||||
markAsSensitive: 'Marcar multimedia como sensible',
|
||||
// Polls
|
||||
createPoll: 'Crear encuesta',
|
||||
removePollChoice: 'Eliminar opción {index}',
|
||||
pollChoiceLabel: 'Opción {index}',
|
||||
multipleChoice: 'Selección múltiple',
|
||||
pollDuration: 'Duración de la encuesta',
|
||||
fiveMinutes: '5 minutos',
|
||||
thirtyMinutes: '30 minutos',
|
||||
oneHour: '1 hora',
|
||||
sixHours: '6 horas',
|
||||
twelveHours: '12 horas',
|
||||
oneDay: '1 día',
|
||||
threeDays: '3 días',
|
||||
sevenDays: '7 días',
|
||||
never: 'Nunca',
|
||||
addEmoji: 'Insertar emoji',
|
||||
addMedia: 'Añadir multimedia (imágenes, vídeo, audio)',
|
||||
addPoll: 'Añadir encuesta',
|
||||
removePoll: 'Eliminar encuesta',
|
||||
postPrivacyLabel: 'Ajustar privacidad (actualmente {label})',
|
||||
addContentWarning: 'Añadir advertencia de contenido',
|
||||
removeContentWarning: 'Eliminar advertencia de contenido',
|
||||
altLabel: 'Describir para las personas con discapacidad visual',
|
||||
extractText: 'Extraer texto de imagen',
|
||||
extractingText: 'Extrayendo texto…',
|
||||
extractingTextCompletion: 'Extrayendo texto ({percent}% completado)…',
|
||||
unableToExtractText: 'No se puede extraer texto.',
|
||||
// Account options
|
||||
followAccount: 'Seguir a {account}',
|
||||
unfollowAccount: 'Dejar de seguir a {account}',
|
||||
blockAccount: 'Bloquear a {account}',
|
||||
unblockAccount: 'Desbloquear a {account}',
|
||||
muteAccount: 'Silenciar a {account}',
|
||||
unmuteAccount: 'Dejar de silenciar a Unmute {account}',
|
||||
showReblogsFromAccount: 'Mostrar toots reenviados por {account}',
|
||||
hideReblogsFromAccount: 'Ocultar toots reenviados por {account}',
|
||||
showDomain: 'Dejar de ocultar {domain}',
|
||||
hideDomain: 'Ocultar {domain}',
|
||||
reportAccount: 'Denunciar a {account}',
|
||||
mentionAccount: 'Mencionar a {account}',
|
||||
copyLinkToAccount: 'Copiar enlace a cuenta',
|
||||
copiedToClipboard: 'Copiado al portapapeles',
|
||||
// Media dialog
|
||||
navigateMedia: 'Navegar por elementos multimedia',
|
||||
showPreviousMedia: 'Mostrar multimedia anterior',
|
||||
showNextMedia: 'Mostrar multimedia siguiente',
|
||||
enterPinchZoom: 'Modo pinch-zoom',
|
||||
exitPinchZoom: 'Salir del modo pinch-zoom',
|
||||
showMedia: `Mostrar {index, select,
|
||||
1 {primer}
|
||||
2 {segundo}
|
||||
3 {tercero}
|
||||
other {cuarto}
|
||||
} multimedia {current, select,
|
||||
true {(actual)}
|
||||
other {}
|
||||
}`,
|
||||
previewFocalPoint: 'Previsualizar (punto focal)',
|
||||
enterFocalPoint: 'Introducir el punto focal (X, Y) para este multimedia',
|
||||
muteNotifications: 'Silenciar también las notificaciones',
|
||||
muteAccountConfirm: '¿Silenciar a {account}?',
|
||||
mute: 'Silenciar',
|
||||
unmute: 'Dejar de silenciar',
|
||||
zoomOut: 'Alejar',
|
||||
zoomIn: 'Acercar',
|
||||
// Reporting
|
||||
reportingLabel: 'Estás denunciando a {account} a los moderadores de {instance}.',
|
||||
additionalComments: 'Comentarios adicionales',
|
||||
forwardDescription: '?Reenviar también a los moderadores de {instance}?',
|
||||
forwardLabel: 'Reenviar a {instance}',
|
||||
unableToLoadStatuses: 'No se pueden cargar los toots recientes: {error}',
|
||||
report: 'Denunciar',
|
||||
noContent: '(Sin contenido)',
|
||||
noStatuses: 'No hay toots para denunciar',
|
||||
// Status options
|
||||
unpinFromProfile: 'Dejar de fijar en el perfil',
|
||||
pinToProfile: 'Fijar en el perfil',
|
||||
muteConversation: 'Silenciar conversación',
|
||||
unmuteConversation: 'Dejar de silenciar conversación',
|
||||
bookmarkStatus: 'Poner marcador al toot',
|
||||
unbookmarkStatus: 'Quitar marcador al toot',
|
||||
deleteAndRedraft: 'Borrar y volver a redactar',
|
||||
reportStatus: 'Denunciar toot',
|
||||
shareStatus: 'Compartir toot',
|
||||
copyLinkToStatus: 'Copiar enlace al toot',
|
||||
// Account profile
|
||||
profileForAccount: 'Perfil para {account}',
|
||||
statisticsAndMoreOptions: 'Estadísticas y más opciones',
|
||||
statuses: 'Toots',
|
||||
follows: 'Siguiendo',
|
||||
followers: 'Seguidores',
|
||||
moreOptions: 'Más opciones',
|
||||
followersLabel: 'Te han seguido {count}',
|
||||
followingLabel: 'Has seguido a {count}',
|
||||
followLabel: `Seguimiento {requested, select,
|
||||
true {(solicitud de seguimiento)}
|
||||
other {}
|
||||
}`,
|
||||
unfollowLabel: `Dejar de seguir {requested, select,
|
||||
true {(solicitud de seguimiento)}
|
||||
other {}
|
||||
}`,
|
||||
notify: 'Suscribirse a {account}',
|
||||
denotify: 'Cancelar suscripción a {account}',
|
||||
subscribedAccount: 'Te has suscrito a la cuenta',
|
||||
unsubscribedAccount: 'Has cancelado tu suscripción a la cuenta',
|
||||
unblock: 'Desbloquear',
|
||||
nameAndFollowing: 'Nombre y seguimientos',
|
||||
clickToSeeAvatar: 'Haz clic para ver el avatar',
|
||||
opensInNewWindow: '{label} (Se abre en nueva ventana)',
|
||||
blocked: 'Bloqueado',
|
||||
domainHidden: 'Dominio oculto',
|
||||
muted: 'Silenciado',
|
||||
followsYou: 'Te está siguiendo',
|
||||
avatarForAccount: 'Avatar para {account}',
|
||||
fields: 'Campos',
|
||||
accountHasMoved: '{account} se ha trasladado:',
|
||||
profilePageForAccount: 'Página de perfil para {account}',
|
||||
// About page
|
||||
about: 'Acerca de',
|
||||
aboutApp: 'Acerca de Pinafore',
|
||||
aboutAppDescription: `
|
||||
<p>
|
||||
Pinafore es
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore">software libre y de código abierto</a>
|
||||
creado por
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
|
||||
y distribuido bajo la
|
||||
<a rel="noopener" target="_blank"
|
||||
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">GNU Affero General Public License</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="privacy-policy">Política de privacidad</h2>
|
||||
|
||||
<p>
|
||||
Pinafore no almacena ninguna información personal en sus servidores,
|
||||
incluyendo, pero no limitándose a nombres, direcciones de correo electrónico,
|
||||
direcciones IP, posts y fotos.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Pinafore es un sitio estático. Todos los datos son almacenados en tu navegador y compartidos con las instancias del fediverso
|
||||
a las que te conectas.
|
||||
</p>
|
||||
|
||||
<h2>Créditos</h2>
|
||||
|
||||
<p>
|
||||
Iconos proporcionados por <a rel="noopener" target="_blank" href="http://fontawesome.io/">Font Awesome</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Logo gracias a "sailboat" por Gregor Cresnar, de
|
||||
<a rel="noopener" target="_blank" href="https://thenounproject.com/">the Noun Project</a>.
|
||||
</p>`,
|
||||
// Settings
|
||||
settings: 'Opciones de configuración',
|
||||
general: 'General',
|
||||
generalSettings: 'Opciones generales',
|
||||
showSensitive: 'Mostrar multimedia sensible por defecto',
|
||||
showPlain: 'Mostrar un color gris liso para multimedia sensible',
|
||||
allSensitive: 'Tratar todo multimedia como sensible',
|
||||
largeMedia: 'Mostrar imágenes y vídeos grandes incrustados',
|
||||
autoplayGifs: 'Reproducir automáticamente GIFs animados',
|
||||
hideCards: 'Ocultar paneles de previsualización de enlaces',
|
||||
underlineLinks: 'Subrayar enlaces en toots y perfiles',
|
||||
accessibility: 'Accesibilidad',
|
||||
reduceMotion: 'Reducir movimiento en animaciones de la interfaz',
|
||||
disableTappable: 'Deshabilitar área para tocar en todo el toot',
|
||||
removeEmoji: 'Eliminar emoji de nombres de usuario',
|
||||
shortAria: 'Usar etiquetas ARIA cortas para artículos',
|
||||
theme: 'Diseño visual',
|
||||
themeForInstance: 'Diseño visual para {instance}',
|
||||
disableCustomScrollbars: 'Deshabilitar barras deslizantes personalizadas',
|
||||
bottomNav: 'Situar la barra de navegación al final de la pantalla',
|
||||
centerNav: 'Centrar la barra de navegación',
|
||||
preferences: 'Preferencias',
|
||||
hotkeySettings: 'Opciones para atajos de teclado',
|
||||
disableHotkeys: 'Deshabilitar todos los atajos de teclado',
|
||||
leftRightArrows: 'Las flechas izquierda/derecha cambian el foco en vez de columnas/multimedia',
|
||||
guide: 'Guía',
|
||||
reload: 'Recargar',
|
||||
// Wellness settings
|
||||
wellness: 'Bienestar',
|
||||
wellnessSettings: 'Opciones para el bienestar',
|
||||
wellnessDescription: `Las opciones para el bienestar están diseñadas para reducir los aspectos que inducen adicción o ansiedad en las redes sociales.
|
||||
Elige cualquier opción que vaya bien para ti.`,
|
||||
enableAll: 'Habilitar todos',
|
||||
metrics: 'Métricas',
|
||||
hideFollowerCount: 'Ocultar recuento de seguidores (hasta 10)',
|
||||
hideReblogCount: 'Ocultar recuento de reenvíos',
|
||||
hideFavoriteCount: 'Ocultar recuento de favoritos',
|
||||
hideUnread: 'Ocultar recuento de notificaciones sin leer (es decir, el punto rojo)',
|
||||
// The quality that makes something seem important or interesting because it seems to be happening now
|
||||
immediacy: 'Inmediatez',
|
||||
showAbsoluteTimestamps: 'Mostrar marcas de tiempo absolutas (p.ej., "3 de marzo") en vez de marcas de tiempo relativas (p. ej., "hace 5 minutos")',
|
||||
ui: 'Interfaz',
|
||||
grayscaleMode: 'Modo escala de grises',
|
||||
wellnessFooter: `Estas opciones están parcialmente basadas en pautas del
|
||||
<a rel="noopener" target="_blank" href="https://humanetech.com">Center for Humane Technology</a>.`,
|
||||
// This is a link: "You can filter or disable notifications in the _instance settings_"
|
||||
filterNotificationsPre: 'Puedes filtrar o deshabilitar notificaciones en',
|
||||
filterNotificationsText: 'opciones para instancia',
|
||||
filterNotificationsPost: '',
|
||||
// Custom tooltips, like "Disable _infinite scroll_", where you can click _infinite scroll_
|
||||
// to see a description. It's hard to properly internationalize, so we just break up the strings.
|
||||
disableInfiniteScrollPre: 'Deshabilitar',
|
||||
disableInfiniteScrollText: 'desplazamiento infinito',
|
||||
disableInfiniteScrollDescription: `Cuando el desplazamiento infinito esté deshabilitado, los nuevos toots no se mostrarán automáticamente al final o al principio de la cronología. En vez de esto, habrá botones que te permitirán
|
||||
cargar más contenido a demanda.`,
|
||||
disableInfiniteScrollPost: '',
|
||||
// Instance settings
|
||||
loggedInAs: 'Iniciaste sesión como',
|
||||
homeTimelineFilters: 'Filtros para la cronología Inicio',
|
||||
notificationFilters: 'Filtros para notificaciones',
|
||||
pushNotifications: 'Notificaciones Push',
|
||||
// Add instance page
|
||||
storageError: `Parece que Pinafore no puede almacenar datos localmente. ¿Está tu navegador en modo privado
|
||||
o bloqueando las cookies? Pinafore almacena todos los datos localmente, y requiere LocalStorage e
|
||||
IndexedDB para funcionar correctamente.`,
|
||||
javaScriptError: 'Debes habilitar JavaScript para iniciar sesión.',
|
||||
enterInstanceName: 'Introducir nombre de instancia',
|
||||
instanceColon: 'Instancia:',
|
||||
// Custom tooltip, concatenated together
|
||||
getAnInstancePre: '¿No tienes una',
|
||||
getAnInstanceText: 'instancia',
|
||||
getAnInstanceDescription: 'Una instancia es tu servidor de inicio de Mastodon, por ejemplo, mastodon.social o cybre.space.',
|
||||
getAnInstancePost: '?',
|
||||
joinMastodon: '¡Unirse a Mastodon!',
|
||||
instancesYouveLoggedInTo: 'Instancias en las que has iniciado sesión:',
|
||||
addAnotherInstance: 'Añadir otra instancia',
|
||||
youreNotLoggedIn: 'No has iniciado sesión en ninguna instancia.',
|
||||
currentInstanceLabel: `{instance} {current, select,
|
||||
true {(instancia actual)}
|
||||
other {}
|
||||
}`,
|
||||
// Link text
|
||||
logInToAnInstancePre: '',
|
||||
logInToAnInstanceText: 'Inicia sesión en una instancia',
|
||||
logInToAnInstancePost: 'para empezar a usar Pinafore.',
|
||||
// Another custom tooltip
|
||||
showRingPre: 'Mostrar siempre',
|
||||
showRingText: 'anillo del foco',
|
||||
showRingDescription: 'El anillo del foco es el contorno que muestra el elemento que actualmente tiene el foco. Por defecto solo se muestra cuando se usa el teclado (no el ratón o un dispositivo táctil), pero puedes elegir mostrarlo siempre.',
|
||||
showRingPost: '',
|
||||
instances: 'Instancias',
|
||||
addInstance: 'Añadir instancia',
|
||||
homeTimelineFilterSettings: 'Opciones para filtros de la cronología Inicio',
|
||||
showReblogs: 'Mostrar reenvíos',
|
||||
showReplies: 'Mostrar respuestas',
|
||||
switchOrLogOut: 'Seleccionar o cerrar sesión en esta instancia',
|
||||
switchTo: 'Seleccionar esta instancia',
|
||||
switchToInstance: 'Seleccionar instancia',
|
||||
switchToNameOfInstance: 'Seleccionar {instance}',
|
||||
logOut: 'Cerrar sesión',
|
||||
logOutOfInstanceConfirm: '¿Cerrar sesión en {instance}?',
|
||||
notificationFilterSettings: 'Opciones para filtros de notificaciones',
|
||||
// Push notifications
|
||||
browserDoesNotSupportPush: 'Tu navegador no admite notificaciones Push.',
|
||||
deniedPush: 'Has denegado el permiso para mostrar notificaciones.',
|
||||
pushNotificationsNote: 'Observa que solo puedes recibir notificaciones Push para una instancia al mismo tiempo.',
|
||||
pushSettings: 'Opciones para notificaciones Push',
|
||||
newFollowers: 'Nuevos seguidores',
|
||||
reblogs: 'Reenvíos',
|
||||
pollResults: 'Resultados de encuesta',
|
||||
subscriptions: 'Suscripción a toots',
|
||||
needToReauthenticate: 'Tienes que volver a autenticarte para habilitar las notificaciones Push. ¿Cerrr sesión en {instance}?',
|
||||
failedToUpdatePush: 'Se ha producido un fallo al actualizar las opciones para notificaciones Push: {error}',
|
||||
// Themes
|
||||
chooseTheme: 'Elegir un diseño visual',
|
||||
darkBackground: 'Fondo oscuro',
|
||||
lightBackground: 'Fondo claro',
|
||||
themeLabel: `{label} {default, select,
|
||||
true {(por defecto)}
|
||||
other {}
|
||||
}`,
|
||||
animatedImage: 'Imagen animada: {description}',
|
||||
showImage: `Mostrar {animated, select,
|
||||
true {animated}
|
||||
other {}
|
||||
} imagen: {description}`,
|
||||
playVideoOrAudio: `Reproducir {audio, select,
|
||||
true {audio}
|
||||
other {vídeo}
|
||||
}: {description}`,
|
||||
accountFollowedYou: '{name} te siguió, {account}',
|
||||
accountSignedUp: '{name} inició sesión, {account}',
|
||||
accountRequestedFollow: '{name} solicitó seguirte, {account}',
|
||||
accountReported: '{name} creó una denuncia, {account}',
|
||||
reblogCountsHidden: 'Recuento de reenvíos oculto',
|
||||
favoriteCountsHidden: 'Recuento de favoritos oculto',
|
||||
rebloggedTimes: `Reenviado {count, plural,
|
||||
one {1 vez}
|
||||
other {{count} veces}
|
||||
}`,
|
||||
favoritedTimes: `Marcado como favorito {count, plural,
|
||||
one {1 vez}
|
||||
other {{count} veces}
|
||||
}`,
|
||||
pinnedStatus: 'Toot fijado',
|
||||
rebloggedYou: 'reenvió tu toot',
|
||||
favoritedYou: 'marcó como favorito tu toot',
|
||||
followedYou: 'te siguió',
|
||||
edited: 'editó su toot',
|
||||
requestedFollow: 'solicitó seguirte',
|
||||
reported: 'creó una denuncia',
|
||||
signedUp: 'sesión iniciada',
|
||||
posted: 'publicado',
|
||||
pollYouCreatedEnded: 'Una encuesta que creaste ha finalizado',
|
||||
pollYouVotedEnded: 'Una encuesta en la que votaste ha finalizado',
|
||||
reblogged: 'reenviado',
|
||||
favorited: 'marcado como favorito',
|
||||
unreblogged: 'no reenviado',
|
||||
unfavorited: 'no marcado como favorito',
|
||||
showSensitiveMedia: 'Mostrar multimedia sensible',
|
||||
hideSensitiveMedia: 'Ocultar multimedia sensible',
|
||||
clickToShowSensitive: 'Contenido sensible. Haz clic para mostrar.',
|
||||
longPost: 'Publicación larga',
|
||||
// Accessible status labels
|
||||
accountRebloggedYou: '{account} reenvió tu toot',
|
||||
accountFavoritedYou: '{account} marcó como favorito tu toot',
|
||||
accountEdited: '{account} editó su toot',
|
||||
rebloggedByAccount: 'reenviado por {account}',
|
||||
contentWarningContent: 'Advertencia de contenido: {spoiler}',
|
||||
hasMedia: 'tiene multimedia',
|
||||
hasPoll: 'tiene encuesta',
|
||||
shortStatusLabel: '{privacy} toot de {account}',
|
||||
// Privacy types
|
||||
public: 'Público',
|
||||
unlisted: 'No listado',
|
||||
followersOnly: 'Solo seguidores',
|
||||
direct: 'Directo',
|
||||
// Themes
|
||||
themeRoyal: 'Royal',
|
||||
themeScarlet: 'Escarlata',
|
||||
themeSeafoam: 'Espuma de mar',
|
||||
themeHotpants: 'Hotpants',
|
||||
themeOaken: 'Roble',
|
||||
themeMajesty: 'Majesty',
|
||||
themeGecko: 'Gecko',
|
||||
themeGrayscale: 'Escala de grises',
|
||||
themeOzark: 'Ozark',
|
||||
themeCobalt: 'Cobalto',
|
||||
themeSorcery: 'Sorcery',
|
||||
themePunk: 'Punk',
|
||||
themeRiot: 'Riot',
|
||||
themeHacker: 'Hacker',
|
||||
themeMastodon: 'Mastodon',
|
||||
themePitchBlack: 'Tono negro',
|
||||
themeDarkGrayscale: 'Escala de gris oscuro',
|
||||
// Polls
|
||||
voteOnPoll: 'Votar en encuesta',
|
||||
pollChoices: 'Opciones de la encuesta',
|
||||
vote: 'Votar',
|
||||
pollDetails: 'Detalles de la encuesta',
|
||||
refresh: 'Actualizar',
|
||||
expires: 'Finaliza',
|
||||
expired: 'Finalizada',
|
||||
voteCount: `{count, plural,
|
||||
one {1 voto}
|
||||
other {{count} votos}
|
||||
}`,
|
||||
// Status interactions
|
||||
clickToShowThread: '{time} - haz clic para mostrar el hilo',
|
||||
showMore: 'Mostrar más',
|
||||
showLess: 'Mostrar menos',
|
||||
closeReply: 'Cerrar respuesta',
|
||||
cannotReblogFollowersOnly: 'No se puede reenviar porque es solo para seguidores',
|
||||
cannotReblogDirectMessage: 'No se puede reenviar porque es un mensaje directo',
|
||||
reblog: 'Reenviar',
|
||||
reply: 'Responder',
|
||||
replyToThread: 'Responder al hilo',
|
||||
favorite: 'Favorito',
|
||||
unfavorite: 'No favorito',
|
||||
// timeline
|
||||
loadingMore: 'Cargando más…',
|
||||
loadMore: 'Cargar más',
|
||||
showCountMore: 'Mostrar {count} más',
|
||||
nothingToShow: 'Nada para mostrar.',
|
||||
// status thread page
|
||||
statusThreadPage: 'Página de hilo de toots',
|
||||
status: 'Toot',
|
||||
// toast messages
|
||||
blockedAccount: 'Cuenta bloqueada',
|
||||
unblockedAccount: 'Cuenta desbloqueada',
|
||||
unableToBlock: 'No se puede bloquear la cuenta: {error}',
|
||||
unableToUnblock: 'No se puede desbloquear la cuenta: {error}',
|
||||
bookmarkedStatus: 'Toot con marcador',
|
||||
unbookmarkedStatus: 'Toot sin marcador',
|
||||
unableToBookmark: 'No se puede poner marcador: {error}',
|
||||
unableToUnbookmark: 'No se puede quitar marcador: {error}',
|
||||
cannotPostOffline: 'No puedes publicar mientras estás sin conexión',
|
||||
unableToPost: 'No se puede publicar el toot: {error}',
|
||||
statusDeleted: 'Toot borrado',
|
||||
unableToDelete: 'No se puede borrar el toot: {error}',
|
||||
cannotFavoriteOffline: 'No puedes marcar como favorito mientras estás sin conexión',
|
||||
cannotUnfavoriteOffline: 'No puedes quitar marca de favorito mientras estás sin conexión',
|
||||
unableToFavorite: 'No se puede marcar como favorito: {error}',
|
||||
unableToUnfavorite: 'No se puede quitar marca de favorito: {error}',
|
||||
followedAccount: 'Cuenta seguida',
|
||||
unfollowedAccount: 'Cuenta no seguida',
|
||||
unableToFollow: 'No se puede seguir a la cuenta: {error}',
|
||||
unableToUnfollow: 'No se puede dejar de seguir a la cuenta: {error}',
|
||||
accessTokenRevoked: 'El token de acceso fue anulado, se cerró sesión en {instance}',
|
||||
loggedOutOfInstance: 'Se cerró sesión en {instance}',
|
||||
failedToUploadMedia: 'Falló la subida del multimedia: {error}',
|
||||
mutedAccount: 'Cuenta silenciada',
|
||||
unmutedAccount: 'Cuenta no silenciada',
|
||||
unableToMute: 'No se puede silenciar la cuenta: {error}',
|
||||
unableToUnmute: 'No se puede dejar de silenciar la cuenta: {error}',
|
||||
mutedConversation: 'Conversación silenciada',
|
||||
unmutedConversation: 'Conversación no silenciada',
|
||||
unableToMuteConversation: 'No se puede silenciar la conversación: {error}',
|
||||
unableToUnmuteConversation: 'No se puede dejar de silenciar la conversación: {error}',
|
||||
unpinnedStatus: 'Toot no fijado',
|
||||
unableToPinStatus: 'No se puede fijar el toot: {error}',
|
||||
unableToUnpinStatus: 'No se puede dejar de fijar el toot: {error}',
|
||||
unableToRefreshPoll: 'No se puede actualizar la encuesta: {error}',
|
||||
unableToVoteInPoll: 'No se puede votar en la encuesta: {error}',
|
||||
cannotReblogOffline: 'No puedes reenviar mientras estás sin conexión.',
|
||||
cannotUnreblogOffline: 'No puedes deshacer reenvíos mientras estás sin conexión.',
|
||||
failedToReblog: 'Fallo al reenviar: {error}',
|
||||
failedToUnreblog: 'Fallo al deshacer reenvío: {error}',
|
||||
submittedReport: 'Denuncia enviada',
|
||||
failedToReport: 'Fallo al enviar denuncia: {error}',
|
||||
approvedFollowRequest: 'Solicitud de seguimiento aceptada',
|
||||
rejectedFollowRequest: 'Solicitud de seguimiento rechazada',
|
||||
unableToApproveFollowRequest: 'No se puede aceptar la solicitud de seguimiento: {error}',
|
||||
unableToRejectFollowRequest: 'No se puede rechazar la solicitud de seguimiento: {error}',
|
||||
searchError: 'Error durante la búsqueda: {error}',
|
||||
hidDomain: 'Dominio oculto',
|
||||
unhidDomain: 'Dominio no oculto',
|
||||
unableToHideDomain: 'No se puede ocultar el dominio: {error}',
|
||||
unableToUnhideDomain: 'No se puede dejar de ocultar el dominio: {error}',
|
||||
showingReblogs: 'Mostrando reenvíos',
|
||||
hidingReblogs: 'Ocultando reenvíos',
|
||||
unableToShowReblogs: 'No se puede mostrar los reenvíos: {error}',
|
||||
unableToHideReblogs: 'No se puede ocultar los reenvíos: {error}',
|
||||
unableToShare: 'No se puede compartir: {error}',
|
||||
unableToSubscribe: 'Imposible suscribirse: {error}',
|
||||
unableToUnsubscribe: 'Imposible dejar de suscribirse: {error}',
|
||||
showingOfflineContent: 'La petición a internet falló. Mostrando contenido sin conexión.',
|
||||
youAreOffline: 'Parece que estás sin conexión. Puedes leer contenido incluso sin conexión.',
|
||||
// Snackbar UI
|
||||
updateAvailable: 'Actualización de la aplicación disponible.',
|
||||
// Word/phrase filters
|
||||
wordFilters: 'Filtros de palabras',
|
||||
noFilters: 'No tienes ningún filtro de palabras.',
|
||||
wordOrPhrase: 'Palabra o frase',
|
||||
contexts: 'Contextos',
|
||||
addFilter: 'Añadir filtro',
|
||||
editFilter: 'Editar filtro',
|
||||
filterHome: 'Inicio y listas',
|
||||
filterNotifications: 'Notificaciones',
|
||||
filterPublic: 'Cronologías públicas',
|
||||
filterThread: 'Conversaciones',
|
||||
filterAccount: 'Perfiles',
|
||||
filterUnknown: 'Desconocido',
|
||||
expireAfter: 'Expira al cabo de',
|
||||
whereToFilter: 'Dónde filtrar',
|
||||
irreversible: 'Irreversible',
|
||||
wholeWord: 'Palabra completa',
|
||||
save: 'Guardar',
|
||||
updatedFilter: 'Filtro actualizado',
|
||||
createdFilter: 'Filtro creado',
|
||||
failedToModifyFilter: 'Fallo al modificar el filtro: {error}',
|
||||
deletedFilter: 'Filtro borrado',
|
||||
required: 'Requerido',
|
||||
// Dialogs
|
||||
profileOptions: 'Opciones de perfil',
|
||||
copyLink: 'Copiar enlace',
|
||||
emoji: 'Emoji',
|
||||
editMedia: 'Editar multimedia',
|
||||
shortcutHelp: 'Ayuda sobre atajos de teclado',
|
||||
statusOptions: 'Opciones de estado',
|
||||
confirm: 'Confirmar',
|
||||
closeDialog: 'Cerrar diálogo',
|
||||
postPrivacy: 'Privacidad del post',
|
||||
homeOnInstance: 'Inicio en {instance}',
|
||||
statusesTimelineOnInstance: 'Estados: {timeline} cronología en {instance}',
|
||||
statusesHashtag: 'Estados: #{hashtag} hashtag',
|
||||
statusesThread: 'Estados: hilo',
|
||||
statusesAccountTimeline: 'Estado: cronología de cuenta',
|
||||
statusesList: 'Estado: lista',
|
||||
notificationsOnInstance: 'Notificaciones en {instance}'
|
||||
}
|
|
@ -171,12 +171,12 @@ export default {
|
|||
true {({count})}
|
||||
other {}
|
||||
}
|
||||
{name}
|
||||
·
|
||||
{showInstanceName, select,
|
||||
true {{instanceName}}
|
||||
other {Pinafore}
|
||||
}
|
||||
·
|
||||
{name}
|
||||
`,
|
||||
pinLabel: `{label} {pinnable, select,
|
||||
true {
|
||||
|
@ -189,8 +189,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Epingler {label}',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {caractère} other {caractères}} en dessus de la limite',
|
||||
underLimit: '{count} {count, plural, =1 {caractère} other {caractères}} qui reste',
|
||||
composeStatus: 'Ecrire un pouet',
|
||||
postStatus: 'Pouet!',
|
||||
contentWarning: 'Avertissement',
|
||||
|
|
|
@ -17,7 +17,7 @@ export default {
|
|||
logIn: 'Войти',
|
||||
footer: `
|
||||
<p>
|
||||
Pinafore — это
|
||||
Pinafore — это
|
||||
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">программное обеспечение с открытым исходным кодом</a>
|
||||
созданное
|
||||
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Ноланом Лоусоном</a>
|
||||
|
@ -192,8 +192,6 @@ export default {
|
|||
}`,
|
||||
pinPage: 'Закрепить {label}',
|
||||
// Status composition
|
||||
overLimit: '{count} {count, plural, =1 {символ} other {символов}} сверх лимита',
|
||||
underLimit: '{count} {count, plural, =1 {символ} other {символов}} осталось',
|
||||
composeStatus: 'Создать запись',
|
||||
postStatus: 'Опубликовать!',
|
||||
contentWarning: 'Предупреждение о содержимом',
|
||||
|
|
|
@ -11,6 +11,8 @@ function getNotificationText (notification, omitEmojiInDisplayNames) {
|
|||
return formatIntl('intl.accountRebloggedYou', { account: notificationAccountDisplayName })
|
||||
} else if (notification.type === 'favourite') {
|
||||
return formatIntl('intl.accountFavoritedYou', { account: notificationAccountDisplayName })
|
||||
} else if (notification.type === 'update') {
|
||||
return formatIntl('intl.accountEdited', { account: notificationAccountDisplayName })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,12 +39,15 @@ function cleanupText (text) {
|
|||
export function getAccessibleLabelForStatus (originalAccount, account, plainTextContent,
|
||||
shortInlineFormattedDate, spoilerText, showContent,
|
||||
reblog, notification, visibility, omitEmojiInDisplayNames,
|
||||
disableLongAriaLabels, showMedia, showPoll) {
|
||||
disableLongAriaLabels, showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll) {
|
||||
const originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames)
|
||||
const contentTextToShow = (showContent || !spoilerText)
|
||||
? cleanupText(plainTextContent)
|
||||
: formatIntl('intl.contentWarningContent', { spoiler: cleanupText(spoilerText) })
|
||||
const mediaTextToShow = showMedia && 'intl.hasMedia'
|
||||
const mediaDescText = (showMedia && (!sensitive || sensitiveShown))
|
||||
? mediaAttachments.map(media => media.description)
|
||||
: []
|
||||
const pollTextToShow = showPoll && 'intl.hasPoll'
|
||||
const privacyText = getPrivacyText(visibility)
|
||||
|
||||
|
@ -57,6 +62,7 @@ export function getAccessibleLabelForStatus (originalAccount, account, plainText
|
|||
originalAccountDisplayName,
|
||||
contentTextToShow,
|
||||
mediaTextToShow,
|
||||
...mediaDescText,
|
||||
pollTextToShow,
|
||||
shortInlineFormattedDate,
|
||||
`@${originalAccount.acct}`,
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { database } from '../_database/database.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
async function getNotification (instanceName, timelineType, timelineValue, itemId) {
|
||||
return {
|
||||
|
@ -21,62 +18,10 @@ async function getStatus (instanceName, timelineType, timelineValue, itemId) {
|
|||
}
|
||||
}
|
||||
|
||||
function tryInitBlurhash () {
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createMakeProps (instanceName, timelineType, timelineValue) {
|
||||
let promiseChain = Promise.resolve()
|
||||
|
||||
tryInitBlurhash() // start the blurhash worker a bit early to save time
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
|
||||
async function fetchFromIndexedDB (itemId) {
|
||||
mark(`fetchFromIndexedDB-${itemId}`)
|
||||
|
@ -92,10 +37,7 @@ export function createMakeProps (instanceName, timelineType, timelineValue) {
|
|||
|
||||
async function getStatusOrNotification (itemId) {
|
||||
const statusOrNotification = await fetchFromIndexedDB(itemId)
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
await rehydrateStatusOrNotification(statusOrNotification)
|
||||
return statusOrNotification
|
||||
}
|
||||
|
||||
|
|
|
@ -4,18 +4,28 @@ import { database } from '../_database/database.js'
|
|||
import {
|
||||
getPinnedStatuses
|
||||
} from '../_api/pinnedStatuses.js'
|
||||
import { prepareToRehydrate, rehydrateStatusOrNotification } from './rehydrateStatusOrNotification.js'
|
||||
|
||||
// Pinned statuses aren't a "normal" timeline, so their blurhashes/plaintext need to be calculated specially
|
||||
async function rehydratePinnedStatuses (statuses) {
|
||||
await Promise.all(statuses.map(status => rehydrateStatusOrNotification({ status })))
|
||||
return statuses
|
||||
}
|
||||
|
||||
export async function updatePinnedStatusesForAccount (accountId) {
|
||||
const { currentInstance, accessToken } = store.get()
|
||||
|
||||
await cacheFirstUpdateAfter(
|
||||
() => getPinnedStatuses(currentInstance, accessToken, accountId),
|
||||
async () => {
|
||||
return rehydratePinnedStatuses(await getPinnedStatuses(currentInstance, accessToken, accountId))
|
||||
},
|
||||
async () => {
|
||||
prepareToRehydrate() // start blurhash early to save time
|
||||
const pinnedStatuses = await database.getPinnedStatuses(currentInstance, accountId)
|
||||
if (!pinnedStatuses || !pinnedStatuses.every(Boolean)) {
|
||||
throw new Error('missing pinned statuses in idb')
|
||||
}
|
||||
return pinnedStatuses
|
||||
return rehydratePinnedStatuses(pinnedStatuses)
|
||||
},
|
||||
statuses => database.insertPinnedStatuses(currentInstance, accountId, statuses),
|
||||
statuses => {
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import { get } from '../_utils/lodash-lite.js'
|
||||
import { mark, stop } from '../_utils/marks.js'
|
||||
import { decode as decodeBlurhash, init as initBlurhash } from '../_utils/blurhash.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText.js'
|
||||
|
||||
function getActualStatus (statusOrNotification) {
|
||||
return get(statusOrNotification, ['status']) ||
|
||||
get(statusOrNotification, ['notification', 'status'])
|
||||
}
|
||||
|
||||
export function prepareToRehydrate () {
|
||||
// start the blurhash worker a bit early to save time
|
||||
try {
|
||||
initBlurhash()
|
||||
} catch (err) {
|
||||
console.error('could not start blurhash worker', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeAllBlurhashes (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const mediaWithBlurhashes = get(status, ['media_attachments'], [])
|
||||
.concat(get(status, ['reblog', 'media_attachments'], []))
|
||||
.filter(_ => _.blurhash)
|
||||
if (mediaWithBlurhashes.length) {
|
||||
mark(`decodeBlurhash-${status.id}`)
|
||||
await Promise.all(mediaWithBlurhashes.map(async media => {
|
||||
try {
|
||||
media.decodedBlurhash = await decodeBlurhash(media.blurhash)
|
||||
} catch (err) {
|
||||
console.warn('Could not decode blurhash, ignoring', err)
|
||||
}
|
||||
}))
|
||||
stop(`decodeBlurhash-${status.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePlainTextContent (statusOrNotification) {
|
||||
const status = getActualStatus(statusOrNotification)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
const originalStatus = status.reblog ? status.reblog : status
|
||||
const content = originalStatus.content || ''
|
||||
const mentions = originalStatus.mentions || []
|
||||
// Calculating the plaintext from the HTML is a non-trivial operation, so we might
|
||||
// as well do it in advance, while blurhash is being decoded on the worker thread.
|
||||
await new Promise(resolve => {
|
||||
scheduleIdleTask(() => {
|
||||
originalStatus.plainTextContent = statusHtmlToPlainText(content, mentions)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Do stuff that we need to do when the status or notification is fetched from the database,
|
||||
// like calculating the blurhash or calculating the plain text content
|
||||
export async function rehydrateStatusOrNotification (statusOrNotification) {
|
||||
await Promise.all([
|
||||
decodeAllBlurhashes(statusOrNotification),
|
||||
calculatePlainTextContent(statusOrNotification)
|
||||
])
|
||||
}
|
|
@ -2,8 +2,9 @@ import { mark, stop } from '../../_utils/marks.js'
|
|||
import { deleteStatus } from '../deleteStatuses.js'
|
||||
import { addStatusOrNotification } from '../addStatusOrNotification.js'
|
||||
import { emit } from '../../_utils/eventBus.js'
|
||||
import { updateStatus } from '../updateStatus.js'
|
||||
|
||||
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed']
|
||||
const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed', 'status.update']
|
||||
|
||||
export function processMessage (instanceName, timelineName, message) {
|
||||
let { event, payload } = (message || {})
|
||||
|
@ -12,7 +13,7 @@ export function processMessage (instanceName, timelineName, message) {
|
|||
return
|
||||
}
|
||||
mark('processMessage')
|
||||
if (['update', 'notification', 'conversation'].includes(event)) {
|
||||
if (['update', 'notification', 'conversation', 'status.update'].includes(event)) {
|
||||
payload = JSON.parse(payload) // only these payloads are JSON-encoded for some reason
|
||||
}
|
||||
|
||||
|
@ -43,6 +44,9 @@ export function processMessage (instanceName, timelineName, message) {
|
|||
case 'filters_changed':
|
||||
emit('wordFiltersChanged', instanceName)
|
||||
break
|
||||
case 'status.update':
|
||||
updateStatus(instanceName, payload)
|
||||
break
|
||||
}
|
||||
stop('processMessage')
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { database } from '../_database/database.js'
|
||||
import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js'
|
||||
|
||||
async function doUpdateStatus (instanceName, newStatus) {
|
||||
console.log('updating status', newStatus)
|
||||
await database.updateStatus(instanceName, newStatus)
|
||||
}
|
||||
|
||||
export function updateStatus (instanceName, newStatus) {
|
||||
scheduleIdleTask(() => {
|
||||
/* no await */ doUpdateStatus(instanceName, newStatus)
|
||||
})
|
||||
}
|
|
@ -27,11 +27,13 @@ export function generateAuthLink (instanceName, clientId, redirectUri) {
|
|||
|
||||
export function getAccessTokenFromAuthCode (instanceName, clientId, clientSecret, code, redirectUri) {
|
||||
const url = `${basename(instanceName)}/oauth/token`
|
||||
return post(url, {
|
||||
// Using URLSearchParams here guarantees a content type of application/x-www-form-urlencoded
|
||||
// See https://fetch.spec.whatwg.org/#bodyinit-unions
|
||||
return post(url, new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
code
|
||||
}, null, { timeout: WRITE_TIMEOUT })
|
||||
}), null, { timeout: WRITE_TIMEOUT })
|
||||
}
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { auth, basename } from './utils.js'
|
||||
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax.js'
|
||||
import { DEFAULT_TIMEOUT, get, post, put, WRITE_TIMEOUT } from '../_utils/ajax.js'
|
||||
|
||||
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
|
||||
// post is create, put is edit
|
||||
async function postOrPutStatus (url, accessToken, method, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses`
|
||||
|
||||
const body = {
|
||||
status: text,
|
||||
in_reply_to_id: inReplyToId,
|
||||
media_ids: mediaIds,
|
||||
sensitive,
|
||||
spoiler_text: spoilerText,
|
||||
visibility,
|
||||
poll
|
||||
poll,
|
||||
...(method === 'post' && {
|
||||
// you can't change these properties when editing
|
||||
in_reply_to_id: inReplyToId,
|
||||
visibility
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of Object.keys(body)) {
|
||||
|
@ -23,7 +25,23 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId,
|
|||
}
|
||||
}
|
||||
|
||||
return post(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||
const func = method === 'post' ? post : put
|
||||
|
||||
return func(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||
}
|
||||
|
||||
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses`
|
||||
return postOrPutStatus(url, accessToken, 'post', text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll)
|
||||
}
|
||||
|
||||
export async function putStatus (instanceName, accessToken, id, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
const url = `${basename(instanceName)}/api/v1/statuses/${id}`
|
||||
return postOrPutStatus(url, accessToken, 'put', text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility, poll)
|
||||
}
|
||||
|
||||
export async function getStatusContext (instanceName, accessToken, statusId) {
|
||||
|
|
|
@ -66,7 +66,7 @@ export async function getTimeline (instanceName, accessToken, timeline, maxId, s
|
|||
}
|
||||
|
||||
if (timeline === 'notifications/mentions') {
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll', 'admin.sign_up']
|
||||
params.exclude_types = ['follow', 'favourite', 'reblog', 'poll', 'admin.sign_up', 'update', 'follow_request', 'admin.report']
|
||||
}
|
||||
|
||||
url += '?' + paramsString(params)
|
||||
|
|
|
@ -8,7 +8,10 @@
|
|||
<button type="button"
|
||||
class="dynamic-page-go-back"
|
||||
aria-label="{intl.goBack}"
|
||||
on:click|preventDefault="onGoBack()">{intl.back}</button>
|
||||
on:click|preventDefault="onGoBack()">
|
||||
<SvgIcon className="dynamic-page-go-back-icon" href="#fa-arrow-left" />
|
||||
{intl.back}
|
||||
</button>
|
||||
</div>
|
||||
<Shortcut key="Backspace" on:pressed="onGoBack()"/>
|
||||
<style>
|
||||
|
@ -34,19 +37,25 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
.dynamic-page-go-back {
|
||||
font-size: 1.3em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: flex-end;
|
||||
font-size: 1.2857142857142858em;
|
||||
color: var(--anchor-text);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
.dynamic-page-go-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dynamic-page-go-back::before {
|
||||
content: '←';
|
||||
margin-right: 5px;
|
||||
:global(.dynamic-page-go-back-icon) {
|
||||
position: relative;
|
||||
bottom: 0.06em;
|
||||
margin-right: 0.2em;
|
||||
height: 0.66666666em;
|
||||
width: 0.66666666em;
|
||||
fill: currentColor;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dynamic-page-banner {
|
||||
|
|
|
@ -12,9 +12,13 @@
|
|||
ref:node
|
||||
>
|
||||
<SvgIcon className="icon-button-svg {svgClassName || ''}" ref:svg {href} />
|
||||
{#if checked}
|
||||
<SvgIcon className="icon-button-svg icon-button-check" ref:check href="#fa-check" />
|
||||
{/if}
|
||||
</button>
|
||||
<style>
|
||||
.icon-button {
|
||||
position: relative;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
|
@ -31,6 +35,14 @@
|
|||
pointer-events: none; /* hack for Edge */
|
||||
}
|
||||
|
||||
:global(.icon-button-check) {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 2px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
:global(.icon-button.big-icon .icon-button-svg) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
@ -128,7 +140,8 @@
|
|||
className: undefined,
|
||||
sameColorWhenPressed: false,
|
||||
ariaHidden: false,
|
||||
clickListener: true
|
||||
clickListener: true,
|
||||
checked: false
|
||||
}),
|
||||
store: () => store,
|
||||
computed: {
|
||||
|
@ -144,8 +157,11 @@
|
|||
ariaLabel: ({ pressable, pressed, label, pressedLabel }) => ((pressable && pressed) ? pressedLabel : label)
|
||||
},
|
||||
methods: {
|
||||
animate (animation) {
|
||||
animate (animation, checkmarkAnimation) {
|
||||
this.refs.svg.animate(animation)
|
||||
if (checkmarkAnimation && this.get().checked) {
|
||||
this.refs.check.animate(checkmarkAnimation)
|
||||
}
|
||||
},
|
||||
onClick (e) {
|
||||
this.fire('click', e)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<span class="length-indicator {overLimit ? 'over-char-limit' : ''}"
|
||||
aria-label={lengthLabel}
|
||||
aria-live={lengthVerbosity}
|
||||
aria-atomic='true'
|
||||
{style}
|
||||
>{lengthToDisplayDeferred}</span>
|
||||
<style>
|
||||
|
@ -17,10 +18,11 @@
|
|||
import { store } from '../_store/store.js'
|
||||
import { observe } from 'svelte-extras'
|
||||
import { throttleTimer } from '../_utils/throttleTimer.js'
|
||||
import { formatIntl } from '../_utils/formatIntl.js'
|
||||
|
||||
const updateDisplayedLength = process.browser && throttleTimer(requestAnimationFrame)
|
||||
|
||||
// How many chars within the limit to start warning at
|
||||
const WARN_THRESHOLD = 10
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
const { lengthToDisplay } = this.get()
|
||||
|
@ -42,11 +44,12 @@
|
|||
store: () => store,
|
||||
computed: {
|
||||
lengthToDisplay: ({ length, max }) => max - length,
|
||||
lengthLabel: ({ overLimit, lengthToDisplayDeferred }) => {
|
||||
if (overLimit) {
|
||||
return formatIntl('intl.overLimit', { count: -lengthToDisplayDeferred })
|
||||
lengthVerbosity: ({ lengthToDisplayDeferred }) => {
|
||||
// When approaching the limit, notify screen reader users
|
||||
if (lengthToDisplayDeferred > WARN_THRESHOLD) {
|
||||
return 'off'
|
||||
} else {
|
||||
return formatIntl('intl.underLimit', { count: lengthToDisplayDeferred })
|
||||
return 'polite'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
/>
|
||||
<span class="nav-link-label">{label}</span>
|
||||
</div>
|
||||
<div class="nav-indicator-wrapper">
|
||||
<div class="nav-indicator-wrapper {animationClasses}">
|
||||
<div class="nav-indicator" ref:indicator></div>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -45,35 +45,36 @@
|
|||
.nav-indicator-wrapper {
|
||||
width: 100%;
|
||||
height: var(--nav-indicator-height);
|
||||
background: var(--nav-a-border);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
flex: 1;
|
||||
background: var(--nav-a-border);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.nav-indicator.animate {
|
||||
.nav-indicator {
|
||||
background: var(--nav-indicator-bg);
|
||||
}
|
||||
|
||||
.nav-indicator-wrapper.animating > .nav-indicator {
|
||||
transition: transform 333ms ease-in-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.main-nav-link:hover .nav-indicator {
|
||||
background: var(--nav-a-border-hover);
|
||||
}
|
||||
|
||||
.main-nav-link.selected .nav-indicator-wrapper {
|
||||
background: var(--nav-a-border-hover);
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
}
|
||||
|
||||
.main-nav-link.selected .nav-indicator {
|
||||
background: var(--nav-indicator-bg);
|
||||
background: var(--nav-indicator-bg-active);
|
||||
}
|
||||
|
||||
.main-nav-link.selected:hover .nav-indicator {
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
/* Desktop/mouse only https://medium.com/@mezoistvan/finally-a-css-only-solution-to-hover-on-touchscreens-c498af39c31c */
|
||||
@media(hover: hover) and (pointer: fine) {
|
||||
.main-nav-link:hover .nav-indicator-wrapper.pre-animating {
|
||||
background: var(--nav-indicator-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav-link:hover {
|
||||
|
@ -129,6 +130,7 @@
|
|||
import { scrollToTop } from '../_utils/scrollToTop.js'
|
||||
import { normalizePageName } from '../_utils/normalizePageName.js'
|
||||
import { formatIntl } from '../_utils/formatIntl.js'
|
||||
import { classname } from '../_utils/classname.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -148,6 +150,10 @@
|
|||
})
|
||||
},
|
||||
store: () => store,
|
||||
data: () => ({
|
||||
preAnimating: false,
|
||||
animating: false
|
||||
}),
|
||||
computed: {
|
||||
selected: ({ page, name }) => name === normalizePageName(page),
|
||||
ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => {
|
||||
|
@ -166,6 +172,10 @@
|
|||
),
|
||||
badgeNumber: ({ name, $numberOfNotifications, $numberOfFollowRequests }) => (
|
||||
(name === 'notifications' && $numberOfNotifications) || (name === 'community' && $numberOfFollowRequests) || 0
|
||||
),
|
||||
animationClasses: ({ animating, preAnimating }) => classname(
|
||||
animating && 'animating',
|
||||
preAnimating && 'pre-animating'
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
|
@ -187,7 +197,7 @@
|
|||
emit('animateNavPart2', { fromRect, toPage })
|
||||
},
|
||||
animatePart2 ({ fromRect }) {
|
||||
const indicator = this.refs.indicator
|
||||
const { indicator } = this.refs
|
||||
mark('animateNavPart2 gBCR')
|
||||
const toRect = indicator.getBoundingClientRect()
|
||||
stop('animateNavPart2 gBCR')
|
||||
|
@ -196,11 +206,12 @@
|
|||
indicator.style.transform = `translateX(${translateX}px) scaleX(${scaleX})`
|
||||
const onTransitionEnd = () => {
|
||||
indicator.removeEventListener('transitionend', onTransitionEnd)
|
||||
indicator.classList.remove('animate')
|
||||
this.set({ animating: false, preAnimating: false })
|
||||
}
|
||||
indicator.addEventListener('transitionend', onTransitionEnd)
|
||||
this.set({ preAnimating: true }) // avoids a flicker before the doubleRAF
|
||||
doubleRAF(() => {
|
||||
indicator.classList.add('animate')
|
||||
this.set({ animating: true })
|
||||
indicator.style.transform = ''
|
||||
})
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
-->
|
||||
<span class="tooltip-button"
|
||||
aria-describedby="tooltip-{id}"
|
||||
aria-expanded={shown}
|
||||
aria-controls="tooltip-{id}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:mouseover="set({shown: true, mouseover: true})"
|
||||
|
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -76,6 +76,10 @@
|
|||
}
|
||||
if (notificationType === 'admin.sign_up') {
|
||||
return formatIntl('intl.accountSignedUp', params)
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return formatIntl('intl.accountRequestedFollow', params)
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return formatIntl('intl.accountReported', params)
|
||||
} else { // 'follow'
|
||||
return formatIntl('intl.accountFollowedYou', params)
|
||||
}
|
||||
|
|
|
@ -39,7 +39,9 @@
|
|||
{#if isStatusInOwnThread}
|
||||
<StatusDetails {...params} {...timestampParams} />
|
||||
{/if}
|
||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight />
|
||||
{#if !isStatusInNotification}
|
||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight on:focusArticle="focusArticle()" />
|
||||
{/if}
|
||||
{#if replyShown}
|
||||
<StatusComposeBox {...params} on:recalculateHeight />
|
||||
{/if}
|
||||
|
@ -133,6 +135,7 @@
|
|||
import { composeNewStatusMentioning } from '../../_actions/mention.js'
|
||||
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid.js'
|
||||
import { addEmojiTooltips } from '../../_utils/addEmojiTooltips.js'
|
||||
import { tryToFocusElement } from '../../_utils/tryToFocusElement.js'
|
||||
|
||||
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
|
||||
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
|
||||
|
@ -213,6 +216,10 @@
|
|||
async mentionAuthor () {
|
||||
const { accountForShortcut } = this.get()
|
||||
await composeNewStatusMentioning(accountForShortcut)
|
||||
},
|
||||
focusArticle () {
|
||||
const { elementId } = this.get()
|
||||
tryToFocusElement(elementId, /* scroll */ true)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -253,7 +260,7 @@
|
|||
notification && notification.status &&
|
||||
notification.type !== 'mention' && notification.status.id === originalStatusId
|
||||
),
|
||||
spoilerShown: ({ $spoilersShown, uuid }) => !!$spoilersShown[uuid],
|
||||
spoilerShown: ({ $spoilersShown, uuid, $showAllSpoilers }) => (typeof $spoilersShown[uuid] === 'undefined' ? !!$showAllSpoilers : !!$spoilersShown[uuid]),
|
||||
replyShown: ({ $repliesShown, uuid }) => !!$repliesShown[uuid],
|
||||
showCard: ({ originalStatus, isStatusInNotification, showMedia, $hideCards }) => (
|
||||
!$hideCards &&
|
||||
|
@ -270,6 +277,13 @@
|
|||
originalStatus.media_attachments &&
|
||||
originalStatus.media_attachments.length
|
||||
),
|
||||
mediaAttachments: ({ originalStatus }) => (
|
||||
originalStatus.media_attachments
|
||||
),
|
||||
sensitiveShown: ({ $sensitivesShown, uuid }) => !!$sensitivesShown[uuid],
|
||||
sensitive: ({ originalStatus, $markMediaAsSensitive, $neverMarkMediaAsSensitive }) => (
|
||||
!$neverMarkMediaAsSensitive && ($markMediaAsSensitive || originalStatus.sensitive)
|
||||
),
|
||||
originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []),
|
||||
originalStatusEmojis: ({ originalStatus }) => (originalStatus.emojis || []),
|
||||
originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username),
|
||||
|
@ -288,16 +302,16 @@
|
|||
ariaLabel: ({
|
||||
originalAccount, account, plainTextContent, shortInlineFormattedDate, spoilerText,
|
||||
showContent, reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels,
|
||||
showMedia, showPoll
|
||||
showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll
|
||||
}) => (
|
||||
getAccessibleLabelForStatus(originalAccount, account, plainTextContent,
|
||||
shortInlineFormattedDate, spoilerText, showContent,
|
||||
reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels,
|
||||
showMedia, showPoll
|
||||
showMedia, sensitive, sensitiveShown, mediaAttachments, showPoll
|
||||
)
|
||||
),
|
||||
showHeader: ({ notification, status, timelineType }) => (
|
||||
(notification && ['reblog', 'favourite', 'poll', 'status'].includes(notification.type)) ||
|
||||
(notification && ['reblog', 'favourite', 'poll', 'status', 'update'].includes(notification.type)) ||
|
||||
status.reblog ||
|
||||
timelineType === 'pinned'
|
||||
),
|
||||
|
|
|
@ -67,16 +67,6 @@
|
|||
color: var(--very-deemphasized-link-color);
|
||||
}
|
||||
|
||||
:global(.status-content .invisible) {
|
||||
/* copied from Mastodon */
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:global(.underline-links .status-content a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
<div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notificationType === 'follow' ? 'header-is-follow' : ''}">
|
||||
<div class="status-header-avatar {timelineType === 'pinned' || notificationType === 'poll' ? 'hidden' : ''}">
|
||||
<Avatar {account} size="extra-small"/>
|
||||
<a id={avatarElementId}
|
||||
href="/accounts/{accountId}"
|
||||
rel="prefetch"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<Avatar {account} size="extra-small"/>
|
||||
</a>
|
||||
</div>
|
||||
<SvgIcon className="status-header-svg" href={icon} />
|
||||
|
||||
|
@ -10,7 +17,7 @@
|
|||
{intl.pinnedStatus}
|
||||
</span>
|
||||
{:elseif notificationType !== 'poll'}
|
||||
<a id={elementId}
|
||||
<a id={authorElementId}
|
||||
href="/accounts/{accountId}"
|
||||
rel="prefetch"
|
||||
class="status-header-author"
|
||||
|
@ -114,7 +121,8 @@
|
|||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
elementId: ({ uuid }) => `status-header-${uuid}`,
|
||||
authorElementId: ({ uuid }) => `status-header-author-${uuid}`,
|
||||
avatarElementId: ({ uuid }) => `status-header-avatar-${uuid}`,
|
||||
notificationType: ({ notification }) => notification && notification.type,
|
||||
icon: ({ notificationType, status, timelineType }) => {
|
||||
if (timelineType === 'pinned') {
|
||||
|
@ -129,6 +137,12 @@
|
|||
return '#fa-comment'
|
||||
} else if (notificationType === 'admin.sign_up') {
|
||||
return '#fa-user-plus'
|
||||
} else if (notificationType === 'update') {
|
||||
return '#fa-pencil'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return '#fa-hourglass'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return '#fa-flag'
|
||||
}
|
||||
return '#fa-star'
|
||||
},
|
||||
|
@ -151,6 +165,12 @@
|
|||
}
|
||||
} else if (status && status.reblog) {
|
||||
return 'intl.reblogged'
|
||||
} else if (notificationType === 'update') {
|
||||
return 'intl.edited'
|
||||
} else if (notificationType === 'follow_request') {
|
||||
return 'intl.requestedFollow'
|
||||
} else if (notificationType === 'admin.report') {
|
||||
return 'intl.reported'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
}
|
||||
|
||||
.status-in-notification svg {
|
||||
opacity: 0.5;
|
||||
stroke: var(--very-deemphasized-text-color);
|
||||
}
|
||||
|
||||
.status-in-own-thread .option-text {
|
||||
|
@ -307,7 +307,10 @@
|
|||
expired: ({ poll }) => poll.expired,
|
||||
expiresAt: ({ poll }) => poll.expires_at,
|
||||
// Misskey can have polls that never end. These give expiresAt as null
|
||||
expiresAtTS: ({ expiresAt }) => typeof expiresAt === 'number' ? new Date(expiresAt).getTime() : null,
|
||||
// Also, Mastodon v4+ uses ISO strings, whereas Mastodon pre-v4 used numbers
|
||||
expiresAtTS: ({ expiresAt }) => (
|
||||
(typeof expiresAt === 'number' || typeof expiresAt === 'string') ? new Date(expiresAt).getTime() : null
|
||||
),
|
||||
expiresAtTimeagoFormatted: ({ expiresAtTS, expired, $now }) => (
|
||||
expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now)
|
||||
),
|
||||
|
|
|
@ -76,8 +76,9 @@
|
|||
methods: {
|
||||
toggleSpoilers (shown) {
|
||||
const { uuid } = this.get()
|
||||
const { spoilersShown } = this.store.get()
|
||||
spoilersShown[uuid] = typeof shown === 'undefined' ? !spoilersShown[uuid] : !!shown
|
||||
const { spoilersShown, showAllSpoilers } = this.store.get()
|
||||
const currentValue = typeof spoilersShown[uuid] === 'undefined' ? !!showAllSpoilers : spoilersShown[uuid]
|
||||
spoilersShown[uuid] = typeof shown === 'undefined' ? !currentValue : !!shown
|
||||
this.store.set({ spoilersShown })
|
||||
requestAnimationFrame(() => {
|
||||
mark('clickSpoilerButton')
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
pressedLabel="Unboost"
|
||||
pressable={!reblogDisabled}
|
||||
pressed={reblogged}
|
||||
checked={reblogged}
|
||||
disabled={reblogDisabled}
|
||||
href={reblogIcon}
|
||||
clickListener={false}
|
||||
|
@ -25,6 +26,7 @@
|
|||
pressedLabel="{intl.unfavorite}"
|
||||
pressable={true}
|
||||
pressed={favorited}
|
||||
checked={favorited}
|
||||
href="#fa-star"
|
||||
clickListener={false}
|
||||
elementId={favoriteKey}
|
||||
|
@ -40,7 +42,9 @@
|
|||
{#if enableShortcuts}
|
||||
<Shortcut scope={shortcutScope} key="f" on:pressed="toggleFavorite(true)"/>
|
||||
<Shortcut scope={shortcutScope} key="r" on:pressed="reply()"/>
|
||||
<Shortcut scope={shortcutScope} key="escape" on:pressed="dismiss()"/>
|
||||
<Shortcut scope={shortcutScope} key="b" on:pressed="reblog(true)"/>
|
||||
<Shortcut scope={shortcutScope} key="a" on:pressed="bookmark()"/>
|
||||
{/if}
|
||||
<style>
|
||||
.status-toolbar {
|
||||
|
@ -75,9 +79,10 @@
|
|||
import { setReblogged } from '../../_actions/reblog.js'
|
||||
import { importShowStatusOptionsDialog } from '../dialog/asyncDialogs/importShowStatusOptionsDialog.js'
|
||||
import { updateProfileAndRelationship } from '../../_actions/accounts.js'
|
||||
import { FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations.js'
|
||||
import { CHECKMARK_ANIMATION, FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations.js'
|
||||
import { on } from '../../_utils/eventBus.js'
|
||||
import { announceAriaLivePolite } from '../../_utils/announceAriaLivePolite.js'
|
||||
import { setStatusBookmarkedOrUnbookmarked } from '../../_actions/bookmark.js'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
|
@ -118,7 +123,7 @@
|
|||
const newFavoritedValue = !favorited
|
||||
/* no await */ setFavorited(originalStatusId, newFavoritedValue)
|
||||
if (newFavoritedValue) {
|
||||
this.refs.favoriteIcon.animate(FAVORITE_ANIMATION)
|
||||
this.refs.favoriteIcon.animate(FAVORITE_ANIMATION, CHECKMARK_ANIMATION)
|
||||
}
|
||||
if (announce) {
|
||||
announceAriaLivePolite(newFavoritedValue ? 'intl.favorited' : 'intl.unfavorited')
|
||||
|
@ -129,7 +134,7 @@
|
|||
const newRebloggedValue = !reblogged
|
||||
/* no await */ setReblogged(originalStatusId, newRebloggedValue)
|
||||
if (newRebloggedValue) {
|
||||
this.refs.reblogIcon.animate(REBLOG_ANIMATION)
|
||||
this.refs.reblogIcon.animate(REBLOG_ANIMATION, CHECKMARK_ANIMATION)
|
||||
}
|
||||
if (announce) {
|
||||
announceAriaLivePolite(newRebloggedValue ? 'intl.reblogged' : 'intl.unreblogged')
|
||||
|
@ -144,6 +149,13 @@
|
|||
this.fire('recalculateHeight')
|
||||
})
|
||||
},
|
||||
dismiss () {
|
||||
const { replyShown } = this.get()
|
||||
if (replyShown) {
|
||||
this.reply()
|
||||
this.fire('focusArticle')
|
||||
}
|
||||
},
|
||||
async onOptionsClick () {
|
||||
const { originalStatus, originalAccountId } = this.get()
|
||||
const updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
|
||||
|
@ -164,6 +176,10 @@
|
|||
// return status to the reply button after posting a reply
|
||||
this.refs.node.querySelector('.status-toolbar-reply-button').focus({ preventScroll: true })
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
bookmark () {
|
||||
const { originalStatus, originalStatusId } = this.get()
|
||||
/* no await */ setStatusBookmarkedOrUnbookmarked(originalStatusId, !originalStatus.bookmarked)
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<div class="loading-footer {shown ? '' : 'hidden'}">
|
||||
<div class="loading-wrapper {showLoading ? 'shown' : ''}"
|
||||
aria-hidden={!showLoading}
|
||||
role="alert"
|
||||
>
|
||||
<!-- Sapper's mousemove event listener schedules style recalculations for the loading spinner in
|
||||
Chrome because it's always animating, even when hidden. So disable animations when not visible
|
||||
|
@ -66,11 +65,30 @@
|
|||
}
|
||||
</style>
|
||||
<script>
|
||||
import { observe } from 'svelte-extras'
|
||||
import LoadingSpinner from '../LoadingSpinner.html'
|
||||
import { store } from '../../_store/store.js'
|
||||
import { fetchMoreItemsAtBottomOfTimeline } from '../../_actions/timeline.js'
|
||||
import { announceAriaLivePolite } from '../../_utils/announceAriaLivePolite.js'
|
||||
|
||||
const SCREEN_READER_ANNOUNCE_DELAY = 1000 // 1 second
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
// If the new statuses are delayed a significant amount of time, announce to screen readers that we're loading
|
||||
let delayedAriaAnnouncementHandle
|
||||
|
||||
this.observe('showLoading', showLoading => {
|
||||
if (showLoading) {
|
||||
delayedAriaAnnouncementHandle = setTimeout(() => {
|
||||
delayedAriaAnnouncementHandle = undefined
|
||||
announceAriaLivePolite('intl.loadingMore')
|
||||
}, SCREEN_READER_ANNOUNCE_DELAY)
|
||||
} else if (delayedAriaAnnouncementHandle) {
|
||||
clearTimeout(delayedAriaAnnouncementHandle)
|
||||
}
|
||||
})
|
||||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
shown: ({ $timelineInitialized, $runningUpdate, $disableInfiniteScroll }) => (
|
||||
|
@ -80,6 +98,7 @@
|
|||
showLoadButton: ({ $runningUpdate, $disableInfiniteScroll }) => !$runningUpdate && $disableInfiniteScroll
|
||||
},
|
||||
methods: {
|
||||
observe,
|
||||
onClickLoadMore (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
|
|
@ -3,12 +3,13 @@ import { getInCache, hasInCache, statusesCache } from '../cache.js'
|
|||
import { STATUSES_STORE } from '../constants.js'
|
||||
import { cacheStatus } from './cacheStatus.js'
|
||||
import { putStatus } from './insertion.js'
|
||||
import { cloneForStorage } from '../helpers.js'
|
||||
|
||||
//
|
||||
// update statuses
|
||||
//
|
||||
|
||||
async function updateStatus (instanceName, statusId, updateFunc) {
|
||||
async function doUpdateStatus (instanceName, statusId, updateFunc) {
|
||||
const db = await getDatabase(instanceName)
|
||||
if (hasInCache(statusesCache, instanceName, statusId)) {
|
||||
const status = getInCache(statusesCache, instanceName, statusId)
|
||||
|
@ -25,7 +26,7 @@ async function updateStatus (instanceName, statusId, updateFunc) {
|
|||
}
|
||||
|
||||
export async function setStatusFavorited (instanceName, statusId, favorited) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
const delta = (favorited ? 1 : 0) - (status.favourited ? 1 : 0)
|
||||
status.favourited = favorited
|
||||
status.favourites_count = (status.favourites_count || 0) + delta
|
||||
|
@ -33,7 +34,7 @@ export async function setStatusFavorited (instanceName, statusId, favorited) {
|
|||
}
|
||||
|
||||
export async function setStatusReblogged (instanceName, statusId, reblogged) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
const delta = (reblogged ? 1 : 0) - (status.reblogged ? 1 : 0)
|
||||
status.reblogged = reblogged
|
||||
status.reblogs_count = (status.reblogs_count || 0) + delta
|
||||
|
@ -41,19 +42,36 @@ export async function setStatusReblogged (instanceName, statusId, reblogged) {
|
|||
}
|
||||
|
||||
export async function setStatusPinned (instanceName, statusId, pinned) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.pinned = pinned
|
||||
})
|
||||
}
|
||||
|
||||
export async function setStatusMuted (instanceName, statusId, muted) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.muted = muted
|
||||
})
|
||||
}
|
||||
|
||||
export async function setStatusBookmarked (instanceName, statusId, bookmarked) {
|
||||
return updateStatus(instanceName, statusId, status => {
|
||||
return doUpdateStatus(instanceName, statusId, status => {
|
||||
status.bookmarked = bookmarked
|
||||
})
|
||||
}
|
||||
|
||||
// For the full list, see https://docs.joinmastodon.org/methods/statuses/#edit
|
||||
const PROPS_THAT_CAN_BE_EDITED = ['content', 'spoiler_text', 'sensitive', 'language', 'media_ids', 'poll']
|
||||
|
||||
export async function updateStatus (instanceName, newStatus) {
|
||||
const clonedNewStatus = cloneForStorage(newStatus)
|
||||
return doUpdateStatus(instanceName, newStatus.id, status => {
|
||||
// We can't use a simple Object.assign() to merge because a prop might have been deleted
|
||||
for (const prop of PROPS_THAT_CAN_BE_EDITED) {
|
||||
if (!(prop in clonedNewStatus)) {
|
||||
delete status[prop]
|
||||
} else {
|
||||
status[prop] = clonedNewStatus[prop]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,6 +8,11 @@
|
|||
bind:checked="$neverMarkMediaAsSensitive" on:change="onChange(event)">
|
||||
{intl.showSensitive}
|
||||
</label>
|
||||
<label class="setting-group">
|
||||
<input type="checkbox" id="choice-show-all-spoilers"
|
||||
bind:checked="$showAllSpoilers" on:change="onChange(event)">
|
||||
{intl.showAllSpoilers}
|
||||
</label>
|
||||
<label class="setting-group">
|
||||
<input type="checkbox" id="choice-use-blurhash"
|
||||
bind:checked="$ignoreBlurhash" on:change="onChange(event)">
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -35,6 +35,7 @@ const persistedState = {
|
|||
loggedInInstances: {},
|
||||
loggedInInstancesInOrder: [],
|
||||
markMediaAsSensitive: false,
|
||||
showAllSpoilers: false,
|
||||
neverMarkMediaAsSensitive: false,
|
||||
ignoreBlurhash: false,
|
||||
omitEmojiInDisplayNames: undefined,
|
||||
|
|
|
@ -108,7 +108,7 @@ A11yDialog.prototype.show = function (event) {
|
|||
// it later, then set the focus to the first focusable child of the dialog
|
||||
// element
|
||||
focusedBeforeDialog = document.activeElement
|
||||
setFocusToFirstItem(this.node)
|
||||
setInitialFocus(this.node)
|
||||
|
||||
// Bind a focus event listener to the body element to make sure the focus
|
||||
// stays trapped inside the dialog while open, and start listening for some
|
||||
|
@ -281,7 +281,7 @@ A11yDialog.prototype._maintainFocus = function (event) {
|
|||
// If the dialog is shown and the focus is not within the dialog element,
|
||||
// move it back to its first focusable child
|
||||
if (this.shown && !this.node.contains(event.target)) {
|
||||
setFocusToFirstItem(this.node)
|
||||
setInitialFocus(this.node)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -333,9 +333,17 @@ function collect (target) {
|
|||
*
|
||||
* @param {Element} node
|
||||
*/
|
||||
function setFocusToFirstItem (node) {
|
||||
function setInitialFocus (node) {
|
||||
const focusableChildren = getFocusableChildren(node)
|
||||
|
||||
// If there's an element with an autofocus attribute, focus that.
|
||||
for (const child of focusableChildren) {
|
||||
if (child.getAttribute('autofocus')) {
|
||||
child.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Otherwise, focus the first focusable element.
|
||||
if (focusableChildren.length) {
|
||||
focusableChildren[0].focus()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
Copyright <YEAR> <COPYRIGHT HOLDER>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,24 @@
|
|||
// via https://github.com/joppuyo/large-small-dynamic-viewport-units-polyfill/blob/93782ffff5d76f46b71591b859aac44f3cd591b2/src/index.js
|
||||
// with some stuff removed that we don't need
|
||||
import { throttleTimer } from '../../_utils/throttleTimer.js'
|
||||
|
||||
// Don't execute this resize listener more than the browser can paint
|
||||
const rafAlignedResize = process.browser && throttleTimer(requestAnimationFrame)
|
||||
|
||||
function setVh () {
|
||||
const dvh = window.innerHeight * 0.01
|
||||
document.documentElement.style.setProperty('--1dvh', (dvh + 'px'))
|
||||
}
|
||||
|
||||
if (process.browser) {
|
||||
// We run the calculation as soon as possible (eg. the script is in document head)
|
||||
setVh()
|
||||
|
||||
// We run the calculation again when DOM has loaded
|
||||
document.addEventListener('DOMContentLoaded', setVh)
|
||||
|
||||
// We run the calculation when window is resized
|
||||
window.addEventListener('resize', () => {
|
||||
rafAlignedResize(setVh)
|
||||
})
|
||||
}
|
|
@ -51,7 +51,7 @@ async function _fetch (url, fetchOptions, options) {
|
|||
async function _putOrPostOrPatch (method, url, body, headers, options) {
|
||||
const fetchOptions = makeFetchOptions(method, headers, options)
|
||||
if (body) {
|
||||
if (body instanceof FormData) {
|
||||
if (body instanceof FormData || body instanceof URLSearchParams) {
|
||||
fetchOptions.body = body
|
||||
} else {
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -172,15 +172,22 @@ function unmapKeys (keyMap, keys, component) {
|
|||
|
||||
function acceptShortcutEvent (event) {
|
||||
const { target } = event
|
||||
return !(
|
||||
if (
|
||||
event.altKey ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
(event.shiftKey && event.key !== '?') || // '?' is a special case - it is allowed
|
||||
(target && (
|
||||
target.isContentEditable ||
|
||||
(event.shiftKey && event.key !== '?') // '?' is a special case - it is allowed
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
// Allow escape everywhere.
|
||||
return true
|
||||
}
|
||||
// Don't allow other keys in text boxes.
|
||||
return !(target && (
|
||||
target.isContentEditable ||
|
||||
['TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
||||
(target.tagName === 'INPUT' && !['radio', 'checkbox'].includes(target.getAttribute('type')))
|
||||
))
|
||||
)
|
||||
))
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { scheduleIdleTask } from './scheduleIdleTask.js'
|
|||
const RETRIES = 5
|
||||
const TIMEOUT = 50
|
||||
|
||||
export async function tryToFocusElement (id) {
|
||||
export async function tryToFocusElement (id, scroll) {
|
||||
for (let i = 0; i < RETRIES; i++) {
|
||||
if (i > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, TIMEOUT))
|
||||
|
@ -13,7 +13,7 @@ export async function tryToFocusElement (id) {
|
|||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
try {
|
||||
element.focus({ preventScroll: true })
|
||||
element.focus({ preventScroll: !scroll })
|
||||
console.log('focused element', id)
|
||||
return
|
||||
} catch (e) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<Title name="{intl.followers}" />
|
||||
<!-- TODO: this should probably be formatted as intl rather than concatenated -->
|
||||
<Title name="{profileName}{intl.followers}" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
|
@ -15,6 +16,11 @@
|
|||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}),
|
||||
computed: {
|
||||
profileName: ({ $currentAccountProfile }) => {
|
||||
return ($currentAccountProfile && ('@' + $currentAccountProfile.acct + ' · ')) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<Title name="{intl.follows}" />
|
||||
<!-- TODO: this should probably be formatted as intl rather than concatenated -->
|
||||
<Title name="{profileName}{intl.follows}" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
|
@ -15,6 +16,11 @@
|
|||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}),
|
||||
computed: {
|
||||
profileName: ({ $currentAccountProfile }) => {
|
||||
return ($currentAccountProfile && ('@' + $currentAccountProfile.acct + ' · ')) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<Title name="{intl.profile}" />
|
||||
<!-- TODO: this should probably be formatted as intl rather than concatenated -->
|
||||
<Title name="{profileName}{intl.profile}" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
|
@ -15,6 +16,11 @@
|
|||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}),
|
||||
computed: {
|
||||
profileName: ({ $currentAccountProfile }) => {
|
||||
return ($currentAccountProfile && ('@' + $currentAccountProfile.acct + ' · ')) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<Title name="{intl.profileWithMedia}" />
|
||||
<!-- TODO: this should probably be formatted as intl rather than concatenated -->
|
||||
<Title name="{profileName}{intl.profileWithMedia}" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
|
@ -15,6 +16,11 @@
|
|||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}),
|
||||
computed: {
|
||||
profileName: ({ $currentAccountProfile }) => {
|
||||
return ($currentAccountProfile && ('@' + $currentAccountProfile.acct + ' · ')) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<Title name="{intl.profileWithReplies}" />
|
||||
<!-- TODO: this should probably be formatted as intl rather than concatenated -->
|
||||
<Title name="{profileName}{intl.profileWithReplies}" />
|
||||
|
||||
<LazyPage {pageComponent} {params} />
|
||||
|
||||
|
@ -15,6 +16,11 @@
|
|||
},
|
||||
data: () => ({
|
||||
pageComponent
|
||||
})
|
||||
}),
|
||||
computed: {
|
||||
profileName: ({ $currentAccountProfile }) => {
|
||||
return ($currentAccountProfile && ('@' + $currentAccountProfile.acct + ' · ')) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -207,3 +207,13 @@ textarea {
|
|||
.inline-emoji {
|
||||
font-family: CountryFlagEmojiPolyfill, PinaforeEmoji, sans-serif;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
/* copied from Mastodon */
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
|
|
@ -26,30 +26,28 @@
|
|||
--form-border: #{darken($border-color, 10%)};
|
||||
|
||||
--nav-bg: #{$main-theme-color};
|
||||
--nav-active-bg: #{lighten($main-theme-color, 9%)};
|
||||
--nav-active-bg: #{lighten($main-theme-color, 3%)};
|
||||
--nav-border: #{darken($main-theme-color, 10%)};
|
||||
--nav-a-border: #{$main-theme-color};
|
||||
--nav-a-selected-border: #{$secondary-text-color};
|
||||
--nav-a-selected-bg: #{lighten($main-theme-color, 10%)};
|
||||
--nav-a-selected-active-bg: #{lighten($main-theme-color, 17%)};
|
||||
--nav-a-selected-bg: #{lighten($main-theme-color, 3%)};
|
||||
--nav-a-selected-active-bg: var(--nav-a-selected-bg-hover);
|
||||
--nav-svg-fill: #{$secondary-text-color};
|
||||
--nav-text-color: #{$secondary-text-color};
|
||||
--nav-indicator-bg: #{rgba($secondary-text-color, 0.8)};
|
||||
--nav-indicator-bg-hover: #{rgba($secondary-text-color, 0.85)};
|
||||
--nav-indicator-bg: #{$main-theme-color};
|
||||
--nav-indicator-bg-active: #{mix($secondary-text-color, $main-theme-color, 90%)};
|
||||
--nav-indicator-bg-hover: #{mix($secondary-text-color, $main-theme-color, 60%)};
|
||||
|
||||
--nav-a-selected-border-hover: #{$secondary-text-color};
|
||||
--nav-a-selected-bg-hover: #{lighten($main-theme-color, 15%)};
|
||||
--nav-a-bg-hover: #{lighten($main-theme-color, 5%)};
|
||||
--nav-a-border-hover: #{$main-theme-color};
|
||||
--nav-a-selected-bg-hover: #{lighten($main-theme-color, 4.5%)};
|
||||
--nav-a-bg-hover: #{lighten($main-theme-color, 1.5%)};
|
||||
--nav-svg-fill-hover: #{$secondary-text-color};
|
||||
--nav-text-color-hover: #{$secondary-text-color};
|
||||
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 18%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 22%)};
|
||||
--action-button-fill-color-active: #{lighten($main-theme-color, 5%)};
|
||||
--action-button-fill-color-pressed: #{darken($main-theme-color, 7%)};
|
||||
--action-button-fill-color-pressed-hover: #{darken($main-theme-color, 2%)};
|
||||
--action-button-fill-color-pressed-active: #{darken($main-theme-color, 15%)};
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 11.5%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 6%)};
|
||||
--action-button-fill-color-active: #{$main-theme-color};
|
||||
--action-button-fill-color-pressed: #{darken(saturate($main-theme-color, 5%), 6%)};
|
||||
--action-button-fill-color-pressed-hover: #{darken(saturate($main-theme-color, 5%), 12%)};
|
||||
--action-button-fill-color-pressed-active: #{darken(saturate($main-theme-color, 5%), 15%)};
|
||||
|
||||
--action-button-deemphasized-fill-color: #{$deemphasized-color};
|
||||
--action-button-deemphasized-fill-color-hover: #{lighten($deemphasized-color, 22%)};
|
||||
|
@ -83,8 +81,8 @@
|
|||
--deemphasized-text-color: #{$deemphasized-color};
|
||||
--focus-outline: #{$focus-outline};
|
||||
|
||||
--very-deemphasized-link-color: #{rgba($anchor-color, 0.6)};
|
||||
--very-deemphasized-text-color: #{rgba(#666, 0.6)};
|
||||
--very-deemphasized-text-color: #757575;
|
||||
--very-deemphasized-link-color: var(--very-deemphasized-text-color);
|
||||
|
||||
--status-direct-background: #{darken($body-bg-color, 5%)};
|
||||
--main-theme-color: #{$main-theme-color};
|
||||
|
@ -135,5 +133,5 @@
|
|||
--focus-bg: #{rgba(0, 0, 0, 0.1)};
|
||||
|
||||
accent-color: #{$main-theme-color};
|
||||
color-scheme: light dark;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
:root {
|
||||
$deemphasized-color: lighten($main-bg-color, 45%);
|
||||
$deemphasized-color: lighten($main-bg-color, 54%);
|
||||
|
||||
--action-button-deemphasized-fill-color: #{$deemphasized-color};
|
||||
--action-button-deemphasized-fill-color-hover: #{lighten($deemphasized-color, 22%)};
|
||||
|
@ -12,8 +12,8 @@
|
|||
|
||||
--deemphasized-text-color: #{$deemphasized-color};
|
||||
|
||||
--very-deemphasized-link-color: #{rgba($anchor-color, 0.8)};
|
||||
--very-deemphasized-text-color: #{lighten($main-bg-color, 32%)};
|
||||
--very-deemphasized-text-color: #{lighten($main-bg-color, 44%)};
|
||||
--very-deemphasized-link-color: var(--very-deemphasized-text-color);
|
||||
|
||||
--status-direct-background: #{darken($body-bg-color, 5%)};
|
||||
--main-theme-color: #{$main-theme-color};
|
||||
|
@ -53,5 +53,5 @@
|
|||
|
||||
--focus-bg: #{rgba(255, 255, 255, 0.1)};
|
||||
|
||||
color-scheme: dark light;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ $compose-background: lighten($main-theme-color, 32%);
|
|||
--nav-text-color: #{$main-text-color};
|
||||
--nav-svg-fill-hover: #{$main-text-color};
|
||||
--nav-text-color-hover: #{$main-text-color};
|
||||
--nav-a-selected-border: #{$anchor-color};
|
||||
--nav-a-selected-border-hover: #{$anchor-color};
|
||||
|
||||
accent-color: #{lighten($main-theme-color, 15%)};
|
||||
|
|
|
@ -24,4 +24,6 @@ $compose-background: lighten($main-theme-color, 52%);
|
|||
--action-button-fill-color-pressed: #{darken($anchor-color, 7%)};
|
||||
--action-button-fill-color-pressed-hover: #{darken($anchor-color, 2%)};
|
||||
--action-button-fill-color-pressed-active: #{darken($anchor-color, 15%)};
|
||||
|
||||
--nav-indicator-bg: #{$main-theme-color}; // special override on the nav indicator color
|
||||
}
|
||||
|
|
|
@ -12,3 +12,10 @@ $compose-background: lighten($main-theme-color, 17%);
|
|||
|
||||
@import "_base.scss";
|
||||
@import "_light_scrollbars.scss";
|
||||
|
||||
:root {
|
||||
// make the action buttons a bit lighter
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 17%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 10%)};
|
||||
--action-button-fill-color-active: #{lighten($main-theme-color, 5%)};
|
||||
}
|
||||
|
|
|
@ -15,3 +15,7 @@ $compose-background: lighten($main-theme-color, 52%);
|
|||
@import "_dark.scss";
|
||||
@import "_dark_navbar.scss";
|
||||
@import "_dark_scrollbars.scss";
|
||||
|
||||
:root {
|
||||
--nav-indicator-bg: #{$main-theme-color}; // special override on the nav indicator color
|
||||
}
|
||||
|
|
|
@ -11,4 +11,11 @@ $focus-outline: lighten($main-theme-color, 30%);
|
|||
$compose-background: lighten($main-theme-color, 32%);
|
||||
|
||||
@import "_base.scss";
|
||||
@import "_light_scrollbars.scss";
|
||||
@import "_light_scrollbars.scss";
|
||||
|
||||
:root {
|
||||
// make the action buttons a bit lighter
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 17%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 10%)};
|
||||
--action-button-fill-color-active: #{lighten($main-theme-color, 5%)};
|
||||
}
|
||||
|
|
|
@ -23,6 +23,11 @@ $compose-background: darken($main-theme-color, 12%);
|
|||
--button-primary-bg-hover: #56a7e1;
|
||||
--button-primary-border: transparent;
|
||||
|
||||
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 30%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 36%)};
|
||||
--action-button-fill-color-active: #{lighten($main-theme-color, 42%)};
|
||||
--action-button-fill-color-pressed: #2b90d9;
|
||||
--action-button-fill-color-pressed-hover: #2b90d9;
|
||||
--action-button-fill-color-pressed-hover: #{darken(#2b90d9, 6%)};
|
||||
--action-button-fill-color-pressed-active: #{darken(#2b90d9, 12%)};
|
||||
}
|
||||
|
|
|
@ -12,4 +12,10 @@ $compose-background: darken($main-theme-color, 12%);
|
|||
|
||||
@import "_base.scss";
|
||||
@import "_dark.scss";
|
||||
@import "_dark_scrollbars.scss";
|
||||
@import "_dark_scrollbars.scss";
|
||||
|
||||
:root {
|
||||
--action-button-fill-color-pressed: #{lighten(saturate($main-theme-color, 25%), 8%)};
|
||||
--action-button-fill-color-pressed-hover: #{lighten(saturate($main-theme-color, 25%), 12%)};
|
||||
--action-button-fill-color-pressed-active: #{lighten(saturate($main-theme-color, 25%), 15%)};
|
||||
}
|
||||
|
|
|
@ -33,10 +33,12 @@ $compose-background: darken($main-theme-color, 12%);
|
|||
--form-bg: #{$body-bg-color};
|
||||
--form-border: #{darken($border-color, 10%)};
|
||||
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 20%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 30%)};
|
||||
--action-button-fill-color-active: #{darken($main-theme-color, 40%)};
|
||||
--action-button-fill-color: #{lighten($main-theme-color, 50%)};
|
||||
--action-button-fill-color-hover: #{lighten($main-theme-color, 60%)};
|
||||
--action-button-fill-color-active: #{darken($main-theme-color, 70%)};
|
||||
--action-button-fill-color-pressed: #{lighten($main-theme-color, 85%)};
|
||||
--action-button-fill-color-pressed-hover: #{lighten($main-theme-color, 100%)};
|
||||
--action-button-fill-color-pressed-active: #{lighten($main-theme-color, 80%)};
|
||||
|
||||
--svg-fill: #{lighten($main-theme-color, 50%)};
|
||||
}
|
||||
|
|
|
@ -15,3 +15,7 @@ $compose-background: lighten($main-theme-color, 52%);
|
|||
@import "_dark.scss";
|
||||
@import "_dark_navbar.scss";
|
||||
@import "_dark_scrollbars.scss";
|
||||
|
||||
:root {
|
||||
--nav-indicator-bg: #{$main-theme-color}; // special override on the nav indicator color
|
||||
}
|
||||
|
|
|
@ -18,4 +18,5 @@ $compose-background: lighten($main-theme-color, 52%);
|
|||
|
||||
:root {
|
||||
accent-color: #{darken($main-theme-color, 5%)};
|
||||
--nav-indicator-bg: #{$main-theme-color}; // special override on the nav indicator color
|
||||
}
|
||||
|
|
|
@ -23,4 +23,7 @@ $scrollbar-face-active: #{lighten($main-theme-color, 1%)};
|
|||
|
||||
:root {
|
||||
accent-color: #{darken($main-theme-color, 12%)};
|
||||
}
|
||||
|
||||
--button-primary-bg: #{$main-theme-color};
|
||||
--button-primary-text: #141414;
|
||||
}
|
|
@ -21,9 +21,9 @@
|
|||
//
|
||||
|
||||
--nav-font-size: 1rem;
|
||||
--nav-indicator-height: 2px;
|
||||
--nav-indicator-height: 3px;
|
||||
--nav-border-bottom: 0px;
|
||||
--nav-icon-pad-v: 15px;
|
||||
--nav-icon-pad-v: 14px;
|
||||
--nav-icon-pad-h: 20px;
|
||||
--nav-icon-size: 20px;
|
||||
|
||||
|
@ -46,10 +46,9 @@
|
|||
--main-border-size: 1px;
|
||||
|
||||
@media (max-width: 991px) {
|
||||
--nav-icon-pad-v: 20px;
|
||||
--nav-icon-pad-v: 18px;
|
||||
--nav-icon-pad-h: 10px;
|
||||
--nav-icon-size: 25px;
|
||||
--nav-indicator-height: 3px;
|
||||
--nav-border-bottom: 0px;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ import {
|
|||
} from '../__sapper__/service-worker.js'
|
||||
import { get, post } from './routes/_utils/ajax.js'
|
||||
import { setWebShareData, closeKeyValIDBConnection } from './routes/_database/webShare.js'
|
||||
import { getKnownInstances } from './routes/_database/knownInstances.js'
|
||||
import { basename } from './routes/_api/utils.js'
|
||||
|
||||
const timestamp = process.env.SAPPER_TIMESTAMP
|
||||
const ASSETS = `assets_${timestamp}`
|
||||
|
@ -169,8 +171,18 @@ self.addEventListener('fetch', event => {
|
|||
self.addEventListener('push', event => {
|
||||
event.waitUntil((async () => {
|
||||
const data = event.data.json()
|
||||
const { origin } = event.target
|
||||
// If there is only once instance, then we know for sure that the push notification came from it
|
||||
const knownInstances = await getKnownInstances()
|
||||
if (knownInstances.length !== 1) {
|
||||
// TODO: Mastodon currently does not tell us which instance the push notification came from.
|
||||
// So we have to guess and currently just choose the first one. We _could_ locally store the instance that
|
||||
// currently has push notifications enabled, but this would only work for one instance at a time.
|
||||
// See: https://github.com/mastodon/mastodon/issues/22183
|
||||
await showSimpleNotification(data)
|
||||
return
|
||||
}
|
||||
|
||||
const origin = basename(knownInstances[0])
|
||||
try {
|
||||
const notification = await get(`${origin}/api/v1/notifications/${data.notification_id}`, {
|
||||
Authorization: `Bearer ${data.access_token}`
|
||||
|
@ -185,8 +197,10 @@ self.addEventListener('push', event => {
|
|||
|
||||
async function showSimpleNotification (data) {
|
||||
await self.registration.showNotification(data.title, {
|
||||
badge: '/icon-push-badge.png',
|
||||
icon: data.icon,
|
||||
body: data.body,
|
||||
tag: data.notification_id,
|
||||
data: {
|
||||
url: `${self.origin}/notifications`
|
||||
}
|
||||
|
@ -201,6 +215,8 @@ async function showRichNotification (data, notification) {
|
|||
|
||||
switch (notification.type) {
|
||||
case 'follow':
|
||||
case 'follow_request':
|
||||
case 'admin.report':
|
||||
case 'admin.sign_up': {
|
||||
await self.registration.showNotification(data.title, {
|
||||
badge,
|
||||
|
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 1792"><path fill="#fff" d="M1344 1504q0 13-9 23t-23 9H352q-8 0-13-2t-9-7-6-8-3-11-1-12V896H128q-26 0-45-19t-19-45q0-24 15-41l320-384q19-22 49-22t49 22l320 384q15 17 15 41 0 26-19 45t-45 19H576v384h576q16 0 25 11l160 192q7 10 7 21zm640-416q0 24-15 41l-320 384q-20 23-49 23t-49-23l-320-384q-15-17-15-41 0-26 19-45t45-19h192V640H896q-16 0-25-12L711 436q-7-9-7-20 0-13 10-22t22-10h960q8 0 14 2t9 7 5 8 3 12 1 11v600h192q26 0 45 19t19 45z"/></svg>
|
||||
<svg viewBox="0 0 1792 1792" width="1792" height="1792" xmlns:svg="http://www.w3.org/2000/svg"><path fill="#fff" d="m 384.00001,344.0625 a 71.966714,71.966714 0 0 0 -56.18749,27 l -256.000008,320 a 71.966714,71.966714 0 0 0 56.187498,116.875 h 104.0625 V 1376 a 71.966714,71.966714 0 0 0 71.9375,71.9375 H 1024 a 71.966714,71.966714 0 0 0 56.1875,-116.875 l -128,-160 a 71.966714,71.966714 0 0 0 -56.18749,-27 h -360.0625 v -336.125 h 104.0625 a 71.966714,71.966714 0 0 0 56.18748,-116.875 l -256,-320 a 71.966714,71.966714 0 0 0 -56.18748,-27 z m 384,0 a 71.966714,71.966714 0 0 0 -56.18749,116.875 l 128,160 a 71.966714,71.966714 0 0 0 56.18749,27 h 360.06249 v 336.125 H 1152 a 71.966714,71.966714 0 0 0 -56.1875,116.875 l 256,320 a 71.966714,71.966714 0 0 0 112.375,0 l 256,-320 A 71.966714,71.966714 0 0 0 1664,984.0625 H 1559.9375 V 416 A 71.966714,71.966714 0 0 0 1488,344.0625 Z" /></svg>
|
||||
|
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 897 B |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
@ -2,7 +2,7 @@ import { favoriteStatus } from '../src/routes/_api/favorite.js'
|
|||
import fetch from 'node-fetch'
|
||||
import FileApi from 'file-api'
|
||||
import { users } from './users.js'
|
||||
import { postStatus } from '../src/routes/_api/statuses.js'
|
||||
import { postStatus, putStatus } from '../src/routes/_api/statuses.js'
|
||||
import { deleteStatus } from '../src/routes/_api/delete.js'
|
||||
import { authorizeFollowRequest, getFollowRequests } from '../src/routes/_api/followRequests.js'
|
||||
import { followAccount, unfollowAccount } from '../src/routes/_api/follow.js'
|
||||
|
@ -33,6 +33,11 @@ export async function postAs (username, text) {
|
|||
null, null, false, null, 'public')
|
||||
}
|
||||
|
||||
export async function putAs (username, text, statusId) {
|
||||
return putStatus(instanceName, users[username].accessToken, statusId, text,
|
||||
null, null, false, null, 'public')
|
||||
}
|
||||
|
||||
export async function postWithSpoilerAndPrivacyAs (username, text, spoiler, privacy) {
|
||||
return postStatus(instanceName, users[username].accessToken, text,
|
||||
null, null, true, spoiler, privacy)
|
||||
|
|
|
@ -35,3 +35,10 @@ test('shows direct vs followers-only vs regular in notifications', async t => {
|
|||
.eql('Cannot be boosted because this is a direct message')
|
||||
.expect($(`${getNthStatusSelector(5)} .status-toolbar button:nth-child(2)`).hasAttribute('disabled')).ok()
|
||||
})
|
||||
|
||||
test('hides status toolbar on notification page', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.navigateTo('/notifications')
|
||||
.expect($(`${getNthStatusSelector(1)} .status-toolbar`).exists).notOk()
|
||||
})
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
getActiveElementTagName,
|
||||
getActiveElementClassList,
|
||||
getNthStatusSensitiveMediaButton,
|
||||
getActiveElementAriaLabel, settingsNavButton, getActiveElementHref, communityNavButton
|
||||
getActiveElementAriaLabel, settingsNavButton, getActiveElementHref, communityNavButton, getActiveElementId
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { Selector as $ } from 'testcafe'
|
||||
|
@ -59,7 +59,7 @@ test('timeline link preserves focus', async t => {
|
|||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatus(1).exists).ok({ timeout: 20000 })
|
||||
.click($(`${getNthStatusSelector(1)} .status-header a`))
|
||||
.click($(`${getNthStatusSelector(1)} .status-header-author`))
|
||||
.expect(getUrl()).contains('/accounts/')
|
||||
.click(goBackButton)
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
|
@ -73,12 +73,28 @@ test('timeline link preserves focus', async t => {
|
|||
.expect(getActiveElementInsideNthStatus()).eql('1')
|
||||
})
|
||||
|
||||
test('timeline link preserves focus - reblogger avatar', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatus(1).exists).ok({ timeout: 20000 })
|
||||
|
||||
const avatar = `${getNthStatusSelector(1)} .status-header-avatar a`
|
||||
const id = await $(avatar).getAttribute('id')
|
||||
await t
|
||||
.click($(avatar))
|
||||
.expect(getUrl()).contains('/accounts/')
|
||||
.click(goBackButton)
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.expect(getNthStatus(1).exists).ok()
|
||||
.expect(getActiveElementId()).eql(id)
|
||||
})
|
||||
|
||||
test('notification timeline preserves focus', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.navigateTo('/notifications')
|
||||
await scrollToStatus(t, 6)
|
||||
await t.click($(`${getNthStatusSelector(6)} .status-header a`))
|
||||
await t.click($(`${getNthStatusSelector(6)} .status-header-author`))
|
||||
.expect(getUrl()).contains('/accounts/')
|
||||
.click(goBackButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
|
|
|
@ -2,7 +2,7 @@ import { loginAsFoobar } from '../roles'
|
|||
import {
|
||||
generalSettingsButton,
|
||||
getNthShowOrHideButton,
|
||||
getNthStatus, getNthStatusRelativeDateTime, homeNavButton,
|
||||
getNthStatus, getNthStatusAndSensitiveButton, getNthStatusRelativeDateTime, homeNavButton,
|
||||
notificationsNavButton,
|
||||
scrollToStatus,
|
||||
settingsNavButton
|
||||
|
@ -39,6 +39,7 @@ test('aria-labels for CWed statuses', async t => {
|
|||
.expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
|
||||
/foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
|
||||
)
|
||||
// toggle the CW button
|
||||
.click(getNthShowOrHideButton(1 + kittenIdx))
|
||||
.expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
|
||||
/foobar, here's a kitten with a CW, .* ago, @foobar, Public/i
|
||||
|
@ -47,6 +48,26 @@ test('aria-labels for CWed statuses', async t => {
|
|||
.expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
|
||||
/foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
|
||||
)
|
||||
// toggle the "show sensitive media" button
|
||||
.click(getNthStatusAndSensitiveButton(1 + kittenIdx, 1))
|
||||
.expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
|
||||
/foobar, Content warning: kitten CW, has media, kitten, .* ago, @foobar, Public/i
|
||||
)
|
||||
.click(getNthStatusAndSensitiveButton(1 + kittenIdx, 1))
|
||||
.expect(getNthStatus(1 + kittenIdx).getAttribute('aria-label')).match(
|
||||
/foobar, Content warning: kitten CW, .* ago, @foobar, Public/i
|
||||
)
|
||||
})
|
||||
|
||||
test('aria-labels for two media attachments', async t => {
|
||||
await loginAsFoobar(t)
|
||||
const twoKittensIdx = homeTimeline.findIndex(_ => _.content === 'here\'s 2 kitten photos')
|
||||
await scrollToStatus(t, 1 + twoKittensIdx)
|
||||
await t
|
||||
.hover(getNthStatus(1 + twoKittensIdx))
|
||||
.expect(getNthStatus(1 + twoKittensIdx).getAttribute('aria-label')).match(
|
||||
/foobar, here's 2 kitten photos, has media, kitten, kitten, .* ago, @foobar, Public/i
|
||||
)
|
||||
})
|
||||
|
||||
test('aria-labels for notifications', 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, getNthComposeReplyInput, getActiveElementId, getActiveElementClassList
|
||||
} from '../utils'
|
||||
import { homeTimeline } from '../fixtures'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
|
@ -216,3 +224,29 @@ 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()
|
||||
})
|
||||
|
||||
test('Press r to reply, press Esc to close reply', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatus(1).exists).ok()
|
||||
await activateStatus(t, 0)
|
||||
const id = await getActiveElementId()
|
||||
await t
|
||||
.expect(getNthComposeReplyInput(1).exists).notOk()
|
||||
.pressKey('r')
|
||||
.expect(getNthComposeReplyInput(1).exists).ok()
|
||||
.expect(getActiveElementClassList()).contains('compose-box-input')
|
||||
.pressKey('esc')
|
||||
.expect(getNthComposeReplyInput(1).exists).notOk()
|
||||
.expect(getActiveElementId()).eql(id)
|
||||
})
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
closeDialogButton,
|
||||
composeModalInput,
|
||||
getNthFavoritedLabel,
|
||||
getNthStatus,
|
||||
getUrl, modalDialog, notificationsNavButton,
|
||||
isNthStatusActive, goBack
|
||||
isNthStatusActive, goBack,
|
||||
getNthFavoritedLabel
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
|
||||
|
@ -12,16 +12,22 @@ fixture`026-shortcuts-notification.js`
|
|||
.page`http://localhost:4002`
|
||||
|
||||
test('Shortcut f toggles favorite status in notification', async t => {
|
||||
const idx = 0
|
||||
const idx = 6 // "hello foobar"
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
.expect(getNthStatus(1 + idx).exists).ok({ timeout: 30000 })
|
||||
.expect(getNthStatus(1).exists).ok({ timeout: 30000 })
|
||||
|
||||
for (let i = 0; i < idx + 1; i++) {
|
||||
await t.pressKey('j')
|
||||
.expect(getNthStatus(1 + i).exists).ok()
|
||||
.expect(isNthStatusActive(1 + i)()).ok()
|
||||
}
|
||||
|
||||
await t
|
||||
.expect(getNthFavoritedLabel(1 + idx)).eql('Favorite')
|
||||
.pressKey('j '.repeat(idx + 1))
|
||||
.expect(isNthStatusActive(1 + idx)()).ok()
|
||||
.pressKey('f')
|
||||
.expect(getNthFavoritedLabel(1 + idx)).eql('Unfavorite')
|
||||
.pressKey('f')
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
getActiveElementTagName,
|
||||
getUrl,
|
||||
searchButton, searchInput, searchNavButton
|
||||
searchButton, searchInput, searchNavButton, sleep
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { Selector as $ } from 'testcafe'
|
||||
|
@ -40,6 +40,8 @@ test('Pressing / without logging in just goes to the search page', async t => {
|
|||
await t
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.expect($('.main-content h1').innerText).eql('Pinafore')
|
||||
await sleep(500) // wait for keyboard shortcuts to be active
|
||||
await t
|
||||
.pressKey('/')
|
||||
.expect(getUrl()).contains('/search')
|
||||
.expect(getActiveElementTagName()).notMatch(/input/i)
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
getUrl,
|
||||
scrollToStatus,
|
||||
getNthStatusSpoiler,
|
||||
settingsNavButton,
|
||||
generalSettingsButton,
|
||||
homeNavButton,
|
||||
getNthStatus,
|
||||
getNthShowOrHideButton
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { homeTimeline } from '../fixtures.js'
|
||||
import { Selector as $ } from 'testcafe'
|
||||
|
||||
fixture`043-content-warnings.js`
|
||||
.page`http://localhost:4002`
|
||||
|
||||
test('Can set content warnings to auto-expand', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.click(settingsNavButton)
|
||||
.click(generalSettingsButton)
|
||||
.click($('#choice-show-all-spoilers'))
|
||||
.click(homeNavButton)
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.expect(getNthStatus(1).exists).ok()
|
||||
const idx = homeTimeline.findIndex(_ => _.spoiler === 'kitten CW')
|
||||
await scrollToStatus(t, idx + 1)
|
||||
await t
|
||||
.expect(getNthStatusSpoiler(1 + idx).innerText).contains('kitten CW')
|
||||
.expect(getNthStatus(1 + idx).innerText).contains('here\'s a kitten with a CW')
|
||||
.click(getNthShowOrHideButton(1 + idx))
|
||||
.expect(getNthStatus(1 + idx).innerText).notContains('here\'s a kitten with a CW')
|
||||
.click(getNthShowOrHideButton(1 + idx))
|
||||
.expect(getNthStatus(1 + idx).innerText).contains('here\'s a kitten with a CW')
|
||||
})
|
|
@ -12,7 +12,7 @@ test('shows unread notification', async t => {
|
|||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('Home · localhost:3000')
|
||||
.expect(getNthStatusContent(1).innerText).contains('somebody please favorite this to validate me', {
|
||||
timeout: 20000
|
||||
})
|
||||
|
@ -21,17 +21,17 @@ test('shows unread notification', async t => {
|
|||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (1 notification)', {
|
||||
timeout: 20000
|
||||
})
|
||||
.expect(getTitleText()).eql('(1) localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('(1) Home · localhost:3000')
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Notifications')
|
||||
.expect(getTitleText()).eql('Notifications · localhost:3000')
|
||||
.expect(getNthStatus(1).innerText).contains('somebody please favorite this to validate me')
|
||||
.expect(getNthStatus(1).innerText).match(/admin\s+favorited your toot/)
|
||||
await t
|
||||
.click(homeNavButton)
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('Home · localhost:3000')
|
||||
})
|
||||
|
||||
test('shows unread notifications, more than one', async t => {
|
||||
|
@ -39,7 +39,7 @@ test('shows unread notifications, more than one', async t => {
|
|||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('Home · localhost:3000')
|
||||
.expect(getNthStatusContent(1).innerText).contains('I need lots of favorites on this one', {
|
||||
timeout: 20000
|
||||
})
|
||||
|
@ -49,14 +49,14 @@ test('shows unread notifications, more than one', async t => {
|
|||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (2 notifications)', {
|
||||
timeout: 20000
|
||||
})
|
||||
.expect(getTitleText()).eql('(2) localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('(2) Home · localhost:3000')
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Notifications')
|
||||
.expect(getTitleText()).eql('Notifications · localhost:3000')
|
||||
.expect(getNthStatus(1).innerText).contains('I need lots of favorites on this one')
|
||||
await t
|
||||
.click(homeNavButton)
|
||||
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications')
|
||||
.expect(getTitleText()).eql('localhost:3000 · Home')
|
||||
.expect(getTitleText()).eql('Home · localhost:3000')
|
||||
})
|
||||
|
|
|
@ -37,10 +37,10 @@ test('External links, hashtags, and mentions have correct attributes', async t =
|
|||
.expect(nthAnchor(3).getAttribute('href')).eql('/tags/tag')
|
||||
.expect(nthAnchor(3).hasAttribute('rel')).notOk()
|
||||
.expect(nthAnchor(3).hasAttribute('target')).notOk()
|
||||
.expect(nthAnchor(4).getAttribute('href')).eql('/tags/anotherTag')
|
||||
.expect(nthAnchor(4).getAttribute('href')).eql('/tags/anothertag')
|
||||
.expect(nthAnchor(4).hasAttribute('rel')).notOk()
|
||||
.expect(nthAnchor(4).hasAttribute('target')).notOk()
|
||||
.expect(nthAnchor(5).getAttribute('href')).eql('/tags/yetAnotherTag')
|
||||
.expect(nthAnchor(5).getAttribute('href')).eql('/tags/yetanothertag')
|
||||
.expect(nthAnchor(5).hasAttribute('rel')).notOk()
|
||||
.expect(nthAnchor(5).hasAttribute('target')).notOk()
|
||||
.expect(nthAnchor(6).getAttribute('href')).eql('http://example.com')
|
||||
|
|
|
@ -2,8 +2,16 @@ import { loginAsLockedAccount } from '../roles'
|
|||
import { followAs, unfollowAs } from '../serverActions'
|
||||
import {
|
||||
avatarInComposeBox,
|
||||
communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack,
|
||||
homeNavButton, sleep
|
||||
communityNavButton,
|
||||
followersButton,
|
||||
getNthSearchResult,
|
||||
getNthStatus,
|
||||
getSearchResultByHref,
|
||||
getUrl,
|
||||
goBack,
|
||||
homeNavButton,
|
||||
notificationsNavButton,
|
||||
sleep
|
||||
} from '../utils'
|
||||
import { users } from '../users'
|
||||
import { Selector as $ } from 'testcafe'
|
||||
|
@ -93,6 +101,9 @@ test('Shows unresolved follow requests', async t => {
|
|||
|
||||
await t
|
||||
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (2 follow requests)')
|
||||
.click(notificationsNavButton)
|
||||
.expect(getUrl()).contains('/notifications')
|
||||
.expect(getNthStatus(1).innerText).contains('requested to follow you')
|
||||
.click(communityNavButton)
|
||||
.expect(requestsButton.innerText).contains('Follow requests (2)')
|
||||
.click(requestsButton)
|
||||
|
|
|
@ -4,10 +4,10 @@ import {
|
|||
getNthPinnedStatusFavoriteButton,
|
||||
getNthStatus, getNthStatusContent,
|
||||
getNthStatusOptionsButton, getUrl, homeNavButton, postStatusButton, scrollToTop, scrollToBottom,
|
||||
settingsNavButton, sleep
|
||||
settingsNavButton, sleep, getNthStatusAccountLink
|
||||
} from '../utils'
|
||||
import { users } from '../users'
|
||||
import { postAs } from '../serverActions'
|
||||
import { postAs, postStatusWithMediaAs } from '../serverActions'
|
||||
|
||||
fixture`117-pin-unpin.js`
|
||||
.page`http://localhost:4002`
|
||||
|
@ -84,3 +84,22 @@ test('Saved pinned/unpinned state of status', async t => {
|
|||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Unpin from profile', { timeout })
|
||||
})
|
||||
|
||||
test('pinned posts and aria-labels', async t => {
|
||||
const timeout = 20000
|
||||
await postStatusWithMediaAs('foobar', 'here is a sensitive kitty', 'kitten2.jpg', 'kitten', true)
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('here is a sensitive kitty', { timeout })
|
||||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Pin to profile')
|
||||
.click(getNthDialogOptionsOption(2))
|
||||
.click(getNthStatusAccountLink(1))
|
||||
.expect(getNthPinnedStatus(1).getAttribute('aria-label')).match(
|
||||
/foobar, here is a sensitive kitty, has media, (.+ ago|just now), @foobar, Public/i
|
||||
)
|
||||
.expect(getNthStatusContent(1).innerText).contains('here is a sensitive kitty')
|
||||
.click(getNthStatusOptionsButton(1))
|
||||
.expect(getNthDialogOptionsOption(2).innerText).contains('Unpin from profile')
|
||||
await sleep(2000)
|
||||
})
|
||||
|
|
|
@ -11,7 +11,7 @@ test('aria-labels for statuses with no content text', async t => {
|
|||
await t
|
||||
.hover(getNthStatus(1))
|
||||
.expect(getNthStatus(1).getAttribute('aria-label')).match(
|
||||
/foobar, has media, (.+ ago|just now), @foobar, Public/i
|
||||
/foobar, has media, kitteh, (.+ ago|just now), @foobar, Public/i
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
sleep,
|
||||
getNthStatusPollRefreshButton,
|
||||
getNthStatusPollVoteCount,
|
||||
getNthStatusRelativeDate, getUrl, goBack, getNthStatusSpoiler, getNthShowOrHideButton
|
||||
getNthStatusRelativeDate, getUrl, goBack, getNthStatusSpoiler, getNthShowOrHideButton, getNthStatusPollExpiry
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { createPollAs, voteOnPollAs } from '../serverActions'
|
||||
|
@ -22,6 +22,7 @@ test('Can vote on polls', async t => {
|
|||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('vote on my cool poll')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes')
|
||||
.expect(getNthStatusPollExpiry(1).innerText).match(/Ends in .*/)
|
||||
await sleep(1000)
|
||||
await t
|
||||
.click(getNthStatusPollOption(1, 2))
|
||||
|
@ -32,6 +33,7 @@ test('Can vote on polls', async t => {
|
|||
.expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('100% no')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
|
||||
.expect(getNthStatusPollExpiry(1).innerText).match(/Ends in .*/)
|
||||
})
|
||||
|
||||
test('Can vote on multiple-choice polls', async t => {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue