Compare commits

...

42 Commits

Author SHA1 Message Date
Nolan Lawson 8f61ea75ce chore: cache png icons forever to lower vercel costs 2024-05-04 13:11:33 -07:00
Nolan Lawson 5889b404cb Revert "chore: remove small icons to reduce vercel costs"
This reverts commit 794d9ca74e.
2024-05-04 09:41:47 -07:00
Nolan Lawson 794d9ca74e chore: remove small icons to reduce vercel costs 2024-04-26 06:43:50 -07:00
Nolan Lawson 72a07ac40d
docs: mark as unmaintained (#2355) 2023-01-09 08:13:18 -08:00
Nolan Lawson ed9a9f6539 2.6.0 2023-01-09 07:56:22 -08:00
Arnaldo Gabriel 452b34b3b4
fix: grayscale mode support for header images (#2354) 2023-01-09 07:55:41 -08:00
Thomas Preece fd4bb4d864
feat: add option to always expand posts marked with content warnings (#2342)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2023-01-08 22:54:39 -08:00
vitalyster c426b7fe31
fix: OAuth2: use correct `Content-Type` as specified in RFC (#2343)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2023-01-08 22:31:00 -08:00
Noelia Ruiz Martínez c2851ce104
docs: explain how to use the buildCommand for internationalization (#2344) 2023-01-08 20:02:17 -08:00
Nolan Lawson 2578d0964d
chore: pin bundler/foreman versions (#2353) 2023-01-08 20:01:31 -08:00
Noelia Ruiz Martínez ff53fcab10
Replace builds with buildCommand in vercel.json (#2329)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-31 08:56:03 -08:00
Nolan Lawson 750235cd8f 2.5.1 2022-12-26 11:35:49 -08:00
Nolan Lawson b5cad87aaf
fix: lighten button colors on some themes (#2331) 2022-12-26 11:29:12 -08:00
Nick Colley a85ff62d48
fix: pitchback svgs not being visible (#2328) 2022-12-26 11:27:53 -08:00
Nick Colley e06f63684e
fix: improve dark theme icons (#2327) 2022-12-26 11:26:58 -08:00
Nick Colley f81778d37f
fix: improve icon readability in light theme (#2323)
Boost contrast of the default colour theme, to be closer to
the other theme's saturation then boost the unpressed state for action
buttons.

This brings the icons to 3:1 contrast while keeping colour in themes.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-18 12:07:53 -08:00
Nick Colley 746298a1f7
fix: pitchblack theme unpressed icons readability (#2324)
increase contrast so they're more readable.
2022-12-18 11:20:58 -08:00
Nolan Lawson 02f1dad098
fix: handle status edit events (#2325) 2022-12-18 11:20:17 -08:00
Nick Colley 3edfed971f
fix: notification page contrast (#2302)
Use lowest possible contrast gray that meets WCAG requirements for
very deemphasised text.

Makes the notification page more readable without compromising access.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-17 18:12:13 +00:00
Noelia Ruiz Martínez d71430f86d
feat: translation into Spanish (#2281)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
Co-authored-by: Noelia Ruiz Martínez <n4m1977@gmail.com>
2022-12-17 09:47:51 -08:00
Noelia Ruiz Martínez 6124c948de
fix: communicate expanded state of tooltips to screenreaders (#2322)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-17 09:47:02 -08:00
Nolan Lawson 774aa7a21c 2.5.0 2022-12-11 14:49:35 -08:00
Nolan Lawson 276c6e7bea
fix: show text for report notifications (#2318)
Fixes #2315
2022-12-11 13:09:12 -08:00
Nolan Lawson f61054a3d5
test: add test for #2263 (#2317) 2022-12-11 12:46:59 -08:00
Nolan Lawson b1dc43a9c9
fix: show proper notification text for follow request (#2314)
Fixes #1800
2022-12-11 12:01:01 -08:00
Nolan Lawson 040462f5b5
fix: fix pinned status aria-label/blurhash (#2313)
Fixes #2294
2022-12-11 11:00:45 -08:00
Thomas Broyer f5f3395a53
fix: fix rich push notifications for single-instance situations (#2296)
Partially addresses #1663.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-10 15:48:29 -08:00
Nick Colley 3fb152ac7c
fix: back button icon rendering inconsistently (#2306)
Depending on the operating system and therefore the system font
the back icon being a unicode arrow means it'll render inconsistently,
sometimes I've seen it looking really odd.

Instead make use of the font awesome arrow so that'll it render consistently
no matter ths system font.
2022-12-10 23:30:43 +00:00
Daniel Soohan Park 97e3b04f1f
fix: redesigned boost icon to fix alignment (#916) (#2297) 2022-12-10 14:50:46 -08:00
Scott Feeney 3c32b48e29
fix: improve toot edited notification (#2303) 2022-12-10 10:56:12 -08:00
Noelia Ruiz Martínez 4a6907bbdc
fix: report remaining chars to screenreaders (#2300)
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-10 10:40:37 -08:00
Thomas Broyer d31c800806
fix: add badge and tag to simple push notifications (#2299) 2022-12-09 08:22:36 -08:00
Nolan Lawson 380d2a0d45
fix: fix poll "ends at" time (#2292)
Fixes #2286
2022-12-03 18:53:20 -08:00
Nolan Lawson 7fdbd72f13
fix: fix nav animations (#2291)
Fixes #2290
2022-12-03 16:48:22 -08:00
dependabot[bot] 62b30f6d99
chore(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#2289)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-03 16:35:55 -08:00
Nolan Lawson 6d6eb59f41
test: run tests on Mastodon v4 (#2256) 2022-12-02 15:09:58 -08:00
James Teh 30b00667f2
feat: Add "a" keyboard shortcut to bookmark a toot. (#2268)
Fixes #2237.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-02 14:01:02 -08:00
Nick Colley da28e98cfb
fix: contrast for active navigation (#2274)
Increase the background contrast for the selected page.
Increase the prominance of the indictor bar so we dont need to rely on
the background to have a strong contrast difference.
2022-12-02 13:58:29 -08:00
Nick Colley 7417e89f78
fix: improve wording of disabled identity (#2275)
Generally phrasing that talks about "the disabled" or "the visually impaired"
feels othering, whereas it is more common these days to have identity focused
framing.
2022-12-02 12:58:57 -08:00
James Teh 815438172e
feat: Make it possible to close inline reply with the escape key. (#2273)
Fixes #915.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-02 12:54:54 -08:00
James Teh 8fc9d5c728
feat: Allow image descriptions to be read automatically by screen readers without needing to expand media. (#2269)
Fixes #2257.

Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
2022-12-02 12:54:03 -08:00
Scott Feeney a775bd9193
docs: update mastodon dev guide link (#2272) 2022-12-02 11:14:32 -08:00
76 changed files with 2051 additions and 275 deletions

View File

@ -4,7 +4,7 @@ on:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-18.04
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:12.2
@ -28,7 +28,7 @@ jobs:
node-version: '14'
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0.3'
ruby-version: '3.0.4'
- name: Cache Mastodon bundler
uses: actions/cache@v3
with:

View File

@ -4,7 +4,7 @@ on:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-18.04
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:12.2
@ -28,7 +28,7 @@ jobs:
node-version: '14'
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0.3'
ruby-version: '3.0.4'
- name: Cache Mastodon bundler
uses: actions/cache@v3
with:

View File

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

View File

@ -1,4 +1,6 @@
# 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.

View File

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

View File

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

View File

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

View File

@ -19,9 +19,9 @@ BIND=0.0.0.0
`
export const GIT_URL = 'https://github.com/tootsuite/mastodon.git'
export const GIT_TAG = 'v3.5.3'
export const GIT_TAG = 'v4.0.2'
export const RUBY_VERSION = '3.0.3'
export const RUBY_VERSION = '3.0.4'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
export const mastodonDir = path.join(__dirname, '../mastodon')

View File

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

View File

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

View File

@ -1,10 +1,10 @@
{
"name": "pinafore",
"description": "Alternative web client for Mastodon",
"version": "2.4.0",
"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'",

View File

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

View File

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

View File

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

696
src/intl/es.js Normal file
View File

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

View File

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

View File

@ -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: 'Предупреждение о содержимом',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -137,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'
},
@ -159,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 ''
}

View File

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

View File

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

View File

@ -42,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 {
@ -80,6 +82,7 @@
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 () {
@ -146,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)
@ -166,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: () => ({

View File

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

View File

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

View File

@ -35,6 +35,7 @@ const persistedState = {
loggedInInstances: {},
loggedInInstancesInOrder: [],
markMediaAsSensitive: false,
showAllSpoilers: false,
neverMarkMediaAsSensitive: false,
ignoreBlurhash: false,
omitEmojiInDisplayNames: undefined,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import {
getFirstModalMedia,
getNthStatusAccountLink,
getNthStatusAccountLinkSelector,
focus
focus, getNthComposeReplyInput, getActiveElementId, getActiveElementClassList
} from '../utils'
import { homeTimeline } from '../fixtures'
import { loginAsFoobar } from '../roles'
@ -234,3 +234,19 @@ test('Shortcut down makes next status active when focused inside of a status', a
.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)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,8 @@ const addFilter = async (t, phrase, tweak) => {
.expect(modalDialog.exists).notOk()
}
test('Can filter basic words', async t => {
// TODO: test broken by Mastodon v4 bug https://github.com/mastodon/mastodon/issues/21965
test.skip('Can filter basic words', async t => {
await postAs('admin', 'do not filter me!')
await postAs('admin', 'filterMeOut okay!')
await postAs('admin', 'filterMeOutTooEvenThoughItIsOneBigWord!')

28
tests/spec/140-editing.js Normal file
View File

@ -0,0 +1,28 @@
import {
getNthStatus, getUrl, goBack,
sleep
} from '../utils'
import { loginAsFoobar } from '../roles'
import { postAs, putAs } from '../serverActions'
fixture`140-editing.js`
.page`http://localhost:4002`
test('Edited toots are updated in the UI', async t => {
const { id: statusId } = await postAs('admin', 'yolo')
await sleep(500)
await loginAsFoobar(t)
await t.expect(getNthStatus(1).innerText).contains('yolo', { timeout: 20000 })
await putAs('admin', 'wait I mean YOLO', statusId)
await sleep(500)
await t.click(getNthStatus(1))
.expect(getUrl()).contains('/statuses')
.expect(getNthStatus(1).innerText).contains('wait I mean YOLO', { timeout: 20000 })
await goBack()
await t
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthStatus(1).innerText).contains('wait I mean YOLO', { timeout: 20000 })
})

View File

@ -394,6 +394,10 @@ export function getNthStatusPollVoteCount (n) {
return $(`${getNthStatusSelector(n)} .poll .poll-stat:nth-child(1) .poll-stat-text`)
}
export function getNthStatusPollExpiry (n) {
return $(`${getNthStatusSelector(n)} .poll .poll-stat-expiry`)
}
export function getComposePollNthInput (n) {
return $(`.compose-poll input[type="text"]:nth-of-type(${n})`)
}

View File

@ -6,15 +6,8 @@
"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$",
@ -36,7 +29,13 @@
}
},
{
"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"
}

View File

@ -12,8 +12,9 @@ import urlRegex from '../src/routes/_utils/urlRegexSource.js'
// TODO: make it so we don't have to list these out explicitly
import fr from 'emoji-picker-element/i18n/fr.js'
import de from 'emoji-picker-element/i18n/de.js'
import es from 'emoji-picker-element/i18n/es.js'
const emojiPickerLocales = { fr, de }
const emojiPickerLocales = { fr, de, es }
const emojiPickerI18n = LOCALE !== DEFAULT_LOCALE && emojiPickerLocales[LOCALE]

View File

@ -2441,9 +2441,9 @@ decamelize@^4.0.0:
integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==
decode-uri-component@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==
version "0.2.2"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
dedent@^0.4.0:
version "0.4.0"