fix: first stab at i18n, extract English strings, add French (#1904)

* first attempt

* progress

* working

* working

* test timeago

* rm

* get timeago working

* reduce size

* fix whitespace

* more intl stuff

* more effort

* more work

* more progress

* more work

* more intl

* set lang=LOCALE

* flatten

* more work

* add ltr/rtl

* more work

* add comments

* yet more work

* still more work

* more work

* fix tests

* more test and string fixes

* fix test

* fix test

* fix test

* fix some more strings, add test

* fix snackbar

* fix }

* fix typo

* fix english

* measure perf

* start on french

* more work on french

* more french

* more french

* finish french

* fix some missing translations

* update readme

* fix test
This commit is contained in:
Nolan Lawson 2020-11-29 14:13:27 -08:00 committed by GitHub
parent 583285a09c
commit 0022286b46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
180 changed files with 2320 additions and 846 deletions

View file

@ -1,5 +1,11 @@
# Contributing to Pinafore # Contributing to Pinafore
## Internationalization
To contribute or change translations for Pinafore, look in the [src/intl](https://github.com/nolanlawson/pinafore/tree/master/src/intl) directory. Create a new file or edit an existing file based on its [two-letter language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) and optionally, a region. For instance, `en-US.js` is American English, and `fr.js` is French.
The default is `en-US.js`, and any strings not defined in a language file will fall back to the strings from that file.
## Installing ## Installing
To install with dev dependencies, run: To install with dev dependencies, run:

View file

@ -31,12 +31,12 @@ Compatible versions of each (Opera, Brave, Samsung, etc.) should be fine.
- Progressive Web App features - Progressive Web App features
- Multi-instance support - Multi-instance support
- Support latest versions of Chrome, Edge, Firefox, and Safari - Support latest versions of Chrome, Edge, Firefox, and Safari
- Support non-Mastodon instances (e.g. Pleroma) as well as possible
- Internationalization
### Secondary / possible future goals ### Secondary / possible future goals
- Support for Pleroma or other non-Mastodon backends
- Serve as an alternative frontend tied to a particular instance - Serve as an alternative frontend tied to a particular instance
- Support for non-English languages (i18n)
- Offline search - Offline search
### Non-goals ### Non-goals

View file

@ -7,9 +7,12 @@ import { buildInlineScript } from './build-inline-script'
import { buildSvg } from './build-svg' import { buildSvg } from './build-svg'
import now from 'performance-now' import now from 'performance-now'
import debounce from 'lodash-es/debounce' import debounce from 'lodash-es/debounce'
import applyIntl from '../webpack/svelte-intl-loader'
import { LOCALE } from '../src/routes/_static/intl'
import { getLangDir } from 'rtl-detect'
const writeFile = promisify(fs.writeFile) const writeFile = promisify(fs.writeFile)
const LOCALE_DIRECTION = getLangDir(LOCALE)
const DEBOUNCE = 500 const DEBOUNCE = 500
const builders = [ const builders = [
@ -78,7 +81,7 @@ function doWatch () {
async function buildAll () { async function buildAll () {
const start = now() const start = now()
const html = (await Promise.all(partials.map(async partial => { let html = (await Promise.all(partials.map(async partial => {
if (typeof partial === 'string') { if (typeof partial === 'string') {
return partial return partial
} }
@ -88,6 +91,9 @@ async function buildAll () {
return partial.result return partial.result
}))).join('') }))).join('')
html = applyIntl(html)
.replace('{process.env.LOCALE}', LOCALE)
.replace('{process.env.LOCALE_DIRECTION}', LOCALE_DIRECTION)
await writeFile(path.resolve(__dirname, '../src/template.html'), html, 'utf8') await writeFile(path.resolve(__dirname, '../src/template.html'), html, 'utf8')
const end = now() const end = now()
console.log(`Built template.html in ${(end - start).toFixed(2)}ms`) console.log(`Built template.html in ${(end - start).toFixed(2)}ms`)

View file

@ -7,11 +7,11 @@
"lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'", "lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'",
"dev": "run-s build-template-html build-assets serve-dev", "dev": "run-s build-template-html build-assets serve-dev",
"serve-dev": "run-p --race build-template-html-watch sapper-dev", "serve-dev": "run-p --race build-template-html-watch sapper-dev",
"sapper-dev": "cross-env NODE_ENV=development PORT=4002 sapper dev", "sapper-dev": "cross-env NODE_ENV=development PORT=4002 node -r esm ./node_modules/.bin/sapper dev",
"before-build": "run-s build-template-html build-assets", "before-build": "run-s build-template-html build-assets",
"build": "cross-env NODE_ENV=production run-s build-steps", "build": "cross-env NODE_ENV=production run-s build-steps",
"build-steps": "run-s before-build sapper-export build-vercel-json", "build-steps": "run-s before-build sapper-export build-vercel-json",
"sapper-build": "sapper build", "sapper-build": "node -r esm ./node_modules/.bin/sapper build",
"start": "node server.js", "start": "node server.js",
"build-and-start": "run-s build start", "build-and-start": "run-s build start",
"build-template-html": "node -r esm ./bin/build-template-html.js", "build-template-html": "node -r esm ./bin/build-template-html.js",
@ -25,13 +25,13 @@
"testcafe": "run-s testcafe-suite0 testcafe-suite1", "testcafe": "run-s testcafe-suite0 testcafe-suite1",
"testcafe-suite0": "cross-env-shell testcafe -c 4 $BROWSER tests/spec/0*", "testcafe-suite0": "cross-env-shell testcafe -c 4 $BROWSER tests/spec/0*",
"testcafe-suite1": "cross-env-shell testcafe $BROWSER tests/spec/1*", "testcafe-suite1": "cross-env-shell testcafe $BROWSER tests/spec/1*",
"test-unit": "mocha -r esm -r bin/browser-shim.js tests/unit/", "test-unit": "NODE_ENV=test mocha -r esm -r bin/browser-shim.js tests/unit/",
"wait-for-mastodon-to-start": "node -r esm bin/wait-for-mastodon-to-start.js", "wait-for-mastodon-to-start": "node -r esm bin/wait-for-mastodon-to-start.js",
"wait-for-mastodon-data": "node -r esm bin/wait-for-mastodon-data.js", "wait-for-mastodon-data": "node -r esm bin/wait-for-mastodon-data.js",
"deploy-prod": "DEPLOY_TYPE=prod ./bin/deploy.sh", "deploy-prod": "DEPLOY_TYPE=prod ./bin/deploy.sh",
"deploy-dev": "DEPLOY_TYPE=dev ./bin/deploy.sh", "deploy-dev": "DEPLOY_TYPE=dev ./bin/deploy.sh",
"backup-mastodon-data": "./bin/backup-mastodon-data.sh", "backup-mastodon-data": "./bin/backup-mastodon-data.sh",
"sapper-export": "cross-env PORT=22939 sapper export", "sapper-export": "cross-env PORT=22939 node -r esm ./node_modules/.bin/sapper export",
"print-export-info": "node ./bin/print-export-info.js", "print-export-info": "node ./bin/print-export-info.js",
"export-steps": "run-s before-build sapper-export print-export-info", "export-steps": "run-s before-build sapper-export print-export-info",
"export": "cross-env NODE_ENV=production run-s export-steps", "export": "cross-env NODE_ENV=production run-s export-steps",
@ -62,6 +62,7 @@
"file-loader": "^6.1.0", "file-loader": "^6.1.0",
"focus-visible": "^5.1.0", "focus-visible": "^5.1.0",
"form-data": "^3.0.0", "form-data": "^3.0.0",
"format-message-interpret": "^6.2.3",
"glob": "^7.1.6", "glob": "^7.1.6",
"li": "^1.3.0", "li": "^1.3.0",
"localstorage-memory": "^1.0.3", "localstorage-memory": "^1.0.3",
@ -80,6 +81,7 @@
"rollup": "^2.26.10", "rollup": "^2.26.10",
"rollup-plugin-babel": "^4.4.0", "rollup-plugin-babel": "^4.4.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"rtl-detect": "^1.0.2",
"sapper": "nolanlawson/sapper#for-pinafore-21", "sapper": "nolanlawson/sapper#for-pinafore-21",
"sass": "^1.26.10", "sass": "^1.26.10",
"stringz": "^2.1.0", "stringz": "^2.1.0",
@ -101,6 +103,8 @@
"assert": "^2.0.0", "assert": "^2.0.0",
"eslint-plugin-html": "^6.1.0", "eslint-plugin-html": "^6.1.0",
"fake-indexeddb": "^3.1.2", "fake-indexeddb": "^3.1.2",
"format-message-parse": "^6.2.3",
"globby": "^11.0.1",
"husky": "^5.0.4", "husky": "^5.0.4",
"lint-staged": "^10.3.0", "lint-staged": "^10.3.0",
"mocha": "^8.1.3", "mocha": "^8.1.3",

View file

@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="{process.env.LOCALE}" dir="{process.env.LOCALE_DIRECTION}">
<head> <head>
<meta charset='utf-8' > <meta charset='utf-8' >
<meta name="viewport" content="width=device-width, viewport-fit=cover"> <meta name="viewport" content="width=device-width, viewport-fit=cover">
<meta id='theThemeColor' name='theme-color' content='#4169e1' > <meta id='theThemeColor' name='theme-color' content='#4169e1' >
<meta name="description" content="An alternative web client for Mastodon, focused on speed and simplicity." > <meta name="description" content="{intl.appDescription}" >
%sapper.base% %sapper.base%
@ -15,7 +15,7 @@
https://developers.google.com/web/fundamentals/native-hardware/fullscreen/ --> https://developers.google.com/web/fundamentals/native-hardware/fullscreen/ -->
<meta name="mobile-web-app-capable" content="yes" > <meta name="mobile-web-app-capable" content="yes" >
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Pinafore" > <meta name="apple-mobile-web-app-title" content="{intl.appName}" >
<meta name="apple-mobile-web-app-status-bar-style" content="white" > <meta name="apple-mobile-web-app-status-bar-style" content="white" >
<!-- inline CSS --> <!-- inline CSS -->

628
src/intl/en-US.js Normal file
View file

@ -0,0 +1,628 @@
export default {
// Home page, basic <title> and <description>
appName: 'Pinafore',
appDescription: 'An alternative web client for Mastodon, focused on speed and simplicity.',
homeDescription: `
<p>
Pinafore is a web client for
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
designed for speed and simplicity.
</p>
<p>
Read the
<a rel="noopener" target="_blank"
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">introductory blog post</a>,
or get started by logging in to an instance:
</p>`,
logIn: 'Log in',
footer: `
<p>
Pinafore is
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">open-source software</a>
created by
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
and distributed under the
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">AGPL License</a>.
Here is the <a href="/settings/about#privacy-policy" rel="prefetch">privacy policy</a>.
</p>
`,
// Generic UI
loading: 'Loading',
okay: 'OK',
cancel: 'Cancel',
alert: 'Alert',
close: 'Close',
error: 'Error: {error}',
errorShort: 'Error:',
// Relative timestamps
justNow: 'just now',
// Navigation, page titles
navItemLabel: `
{label} {selected, select,
true {(current page)}
other {}
} {name, select,
notifications {{count, plural,
=0 {}
one {(1 notification)}
other {({count} notifications)}
}}
community {{count, plural,
=0 {}
one {(1 follow request)}
other {({count} follow requests)}
}}
other {}
}
`,
blockedUsers: 'Blocked users',
bookmarks: 'Bookmarks',
directMessages: 'Direct messages',
favorites: 'Favorites',
federated: 'Federated',
home: 'Home',
local: 'Local',
notifications: 'Notifications',
mutedUsers: 'Muted users',
pinnedStatuses: 'Pinned toots',
followRequests: 'Follow requests',
followRequestsLabel: `Follow requests {hasFollowRequests, select,
true {({count})}
other {}
}`,
list: 'List',
search: 'Search',
pageHeader: 'Page header',
goBack: 'Go back',
back: 'Back',
profile: 'Profile',
federatedTimeline: 'Federated timeline',
localTimeline: 'Local timeline',
// community page
community: 'Community',
pinnableTimelines: 'Pinnable timelines',
timelines: 'Timelines',
lists: 'Lists',
instanceSettings: 'Instance settings',
notificationMentions: 'Notification mentions',
profileWithMedia: 'Profile with media',
profileWithReplies: 'Profile with replies',
hashtag: 'Hashtag',
// not logged in
profileNotLoggedIn: 'A user timeline will appear here when logged in.',
bookmarksNotLoggedIn: 'Your bookmarks will appear here when logged in.',
directMessagesNotLoggedIn: 'Your direct messages will appear here when logged in.',
favoritesNotLoggedIn: 'Your favorites will appear here when logged in.',
federatedTimelineNotLoggedIn: 'Your federated timeline will appear here when logged in.',
localTimelineNotLoggedIn: 'Your local timeline will appear here when logged in.',
searchNotLoggedIn: 'You can search once logged in to an instance.',
communityNotLoggedIn: 'Community options appear here when logged in.',
listNotLoggedIn: 'A list will appear here when logged in.',
notificationsNotLoggedIn: 'Your notifications will appear here when logged in.',
notificationMentionsNotLoggedIn: 'Your notification mentions will appear here when logged in.',
statusNotLoggedIn: 'A toot thread will appear here when logged in.',
tagNotLoggedIn: 'A hashtag timeline will appear here when logged in.',
// Notification subpages
filters: 'Filters',
all: 'All',
mentions: 'Mentions',
// Follow requests
approve: 'Approve',
reject: 'Reject',
// Hotkeys
hotkeys: 'Hotkeys',
global: 'Global',
timeline: 'Timeline',
media: 'Media',
globalHotkeys: `
{leftRightChangesFocus, select,
true {
<li><kbd></kbd> to go to the next focusable element</li>
<li><kbd></kbd> to go to the previous focusable element</li>
}
other {}
}
<li>
<kbd>1</kbd> - <kbd>6</kbd>
{leftRightChangesFocus, select,
true {}
other {or <kbd></kbd>/<kbd></kbd>}
}
to switch columns
</li>
<li><kbd>7</kbd> or <kbd>c</kbd> to compose a new toot</li>
<li><kbd>s</kbd> or <kbd>/</kbd> to search</li>
<li><kbd>g</kbd> + <kbd>h</kbd> to go home</li>
<li><kbd>g</kbd> + <kbd>n</kbd> to go to notifications</li>
<li><kbd>g</kbd> + <kbd>l</kbd> to go to the local timeline</li>
<li><kbd>g</kbd> + <kbd>t</kbd> to go to the federated timeline</li>
<li><kbd>g</kbd> + <kbd>c</kbd> to go to the community page</li>
<li><kbd>g</kbd> + <kbd>d</kbd> to go to the direct messages page</li>
<li><kbd>h</kbd> or <kbd>?</kbd> to toggle the help dialog</li>
<li><kbd>Backspace</kbd> to go back, close dialogs</li>
`,
timelineHotkeys: `
<li><kbd>j</kbd> or <kbd></kbd> to activate the next toot</li>
<li><kbd>k</kbd> or <kbd></kbd> to activate the previous toot</li>
<li><kbd>.</kbd> to show more and scroll to top</li>
<li><kbd>o</kbd> to open</li>
<li><kbd>f</kbd> to favorite</li>
<li><kbd>b</kbd> to boost</li>
<li><kbd>r</kbd> to reply</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>
<li><kbd>p</kbd> to open the author's profile</li>
<li><kbd>l</kbd> to open the card's link in a new tab</li>
<li><kbd>x</kbd> to show or hide text behind content warning</li>
`,
mediaHotkeys: `
<li><kbd></kbd> / <kbd></kbd> to go to next or previous</li>
`,
// Community page, tabs
tabLabel: `{label} {current, select,
true {(Current)}
other {}
}`,
pageTitle: `
{hasNotifications, select,
true {({count})}
other {}
}
{showInstanceName, select,
true {{instanceName}}
other {Pinafore}
}
·
{name}
`,
pinLabel: `{label} {pinnable, select,
true {
{pinned, select,
true {(Pinned page)}
other {(Unpinned page)}
}
}
other {}
}`,
pinPage: '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',
dropToUpload: 'Drop to upload',
invalidFileType: 'Invalid file type',
composeLabel: "What's on your mind?",
autocompleteDescription: 'When autocomplete results are available, press up or down arrows and enter to select.',
mediaUploads: 'Media uploads',
edit: 'Edit',
delete: 'Delete',
description: 'Description',
descriptionLabel: 'Describe for the visually impaired (image, video) or auditorily impaired (audio, video)',
markAsSensitive: 'Mark media as sensitive',
// Polls
createPoll: 'Create poll',
removePollChoice: 'Remove choice {index}',
pollChoiceLabel: 'Choice {index}',
multipleChoice: 'Multiple choice',
pollDuration: 'Poll duration',
fiveMinutes: '5 minutes',
thirtyMinutes: '30 minutes',
oneHour: '1 hour',
sixHours: '6 hours',
oneDay: '1 day',
threeDays: '3 days',
sevenDays: '7 days',
addEmoji: 'Insert emoji',
addMedia: 'Add media (images, video, audio)',
addPoll: 'Add poll',
removePoll: 'Remove poll',
postPrivacyLabel: 'Adjust privacy (currently {label})',
addContentWarning: 'Add content warning',
removeContentWarning: 'Remove content warning',
altLabel: 'Describe for the visually impaired',
extractText: 'Extract text from image',
extractingText: 'Extracting text…',
extractingTextCompletion: 'Extracting text ({percent}% complete)…',
unableToExtractText: 'Unable to extract text.',
// Account options
followAccount: 'Follow {account}',
unfollowAccount: 'Unfollow {account}',
blockAccount: 'Block {account}',
unblockAccount: 'Unblock {account}',
muteAccount: 'Mute {account}',
unmuteAccount: 'Unmute {account}',
showReblogsFromAccount: 'Show boosts from {account}',
hideReblogsFromAccount: 'Hide boosts from {account}',
showDomain: 'Unhide {domain}',
hideDomain: 'Hide {domain}',
reportAccount: 'Report {account}',
mentionAccount: 'Mention {account}',
copyLinkToAccount: 'Copy link to account',
copiedToClipboard: 'Copied to clipboard',
// Media dialog
navigateMedia: 'Navigate media items',
showPreviousMedia: 'Show previous media',
showNextMedia: 'Show next media',
enterPinchZoom: 'Pinch-zoom mode',
exitPinchZoom: 'Exit pinch-zoom mode',
showMedia: `Show {index, select,
1 {first}
2 {second}
3 {third}
other {fourth}
} media {current, select,
true {(current)}
other {}
}`,
previewFocalPoint: 'Preview (focal point)',
enterFocalPoint: 'Enter the focal point (X, Y) for this media',
muteNotifications: 'Mute notifications as well',
muteAccountConfirm: 'Mute {account}?',
mute: 'Mute',
unmute: 'Unmute',
zoomOut: 'Zoom out',
zoomIn: 'Zoom in',
// Reporting
reportingLabel: 'You are reporting {account} to the moderators of {instance}.',
additionalComments: 'Additional comments',
forwardDescription: 'Forward to the moderators of {instance} as well?',
forwardLabel: 'Forward to {instance}',
unableToLoadStatuses: 'Unable to load recent toots: {error}',
report: 'Report',
noContent: '(No content)',
noStatuses: 'No toots to report',
// Status options
unpinFromProfile: 'Unpin from profile',
pinToProfile: 'Pin to profile',
muteConversation: 'Mute conversation',
unmuteConversation: 'Unmute conversation',
bookmarkStatus: 'Bookmark toot',
unbookmarkStatus: 'Unbookmark toot',
deleteAndRedraft: 'Delete and redraft',
reportStatus: 'Report toot',
shareStatus: 'Share toot',
copyLinkToStatus: 'Copy link to toot',
// Account profile
profileForAccount: 'Profile for {account}',
statisticsAndMoreOptions: 'Stats and more options',
statuses: 'Toots',
follows: 'Follows',
followers: 'Followers',
moreOptions: 'More options',
followersLabel: 'Followed by {count}',
followingLabel: 'Follows {count}',
followLabel: `Follow {requested, select,
true {(follow requested)}
other {}
}`,
unfollowLabel: `Unfollow {requested, select,
true {(follow requested)}
other {}
}`,
unblock: 'Unblock',
nameAndFollowing: 'Name and following',
clickToSeeAvatar: 'Click to see avatar',
opensInNewWindow: '{label} (opens in new window)',
blocked: 'Blocked',
domainHidden: 'Domain hidden',
muted: 'Muted',
followsYou: 'Follows you',
avatarForAccount: 'Avatar for {account}',
fields: 'Fields',
accountHasMoved: '{account} has moved:',
profilePageForAccount: 'Profile page for {account}',
// About page
about: 'About',
aboutApp: 'About Pinafore',
aboutAppDescription: `
<p>
Pinafore is
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore">free and open-source software</a>
created by
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
and distributed under the
<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">Privacy Policy</h2>
<p>
Pinafore does not store any personal information on its servers,
including but not limited to names, email addresses,
IP addresses, posts, and photos.
</p>
<p>
Pinafore is a static site. All data is stored locally in your browser and shared with the fediverse
instance(s) you connect to.
</p>
<h2>Credits</h2>
<p>
Icons provided by <a rel="noopener" target="_blank" href="http://fontawesome.io/">Font Awesome</a>.
</p>
<p>
Logo thanks to "sailboat" by Gregor Cresnar from
<a rel="noopener" target="_blank" href="https://thenounproject.com/">the Noun Project</a>.
</p>`,
// Settings
settings: 'Settings',
general: 'General',
generalSettings: 'General settings',
showSensitive: 'Show sensitive media by default',
showPlain: 'Show a plain gray color for sensitive media',
allSensitive: 'Treat all media as sensitive',
largeMedia: 'Show large inline images and videos',
autoplayGifs: 'Autoplay animated GIFs',
hideCards: 'Hide link preview cards',
underlineLinks: 'Underline links in toots and profiles',
accessibility: 'Accessibility',
reduceMotion: 'Reduce motion in UI animations',
disableTappable: 'Disable tappable area on entire toot',
removeEmoji: 'Remove emoji from user display names',
shortAria: 'Use short article ARIA labels',
theme: 'Theme',
themeForInstance: 'Theme for {instance}',
disableCustomScrollbars: 'Disable custom scrollbars',
preferences: 'Preferences',
hotkeySettings: 'Hotkey settings',
disableHotkeys: 'Disable all hotkeys',
leftRightArrows: 'Left/right arrow keys change focus rather than columns/media',
guide: 'Guide',
reload: 'Reload',
// Wellness settings
wellness: 'Wellness',
wellnessSettings: 'Wellness settings',
wellnessDescription: `Wellness settings are designed to reduce the addictive or anxiety-inducing aspects of social media.
Choose any options that work well for you.`,
enableAll: 'Enable all',
metrics: 'Metrics',
hideFollowerCount: 'Hide follower counts (capped at 10)',
hideReblogCount: 'Hide boost counts',
hideFavoriteCount: 'Hide favorite counts',
hideUnread: 'Hide unread notifications count (i.e. the red dot)',
ui: 'UI',
grayscaleMode: 'Grayscale mode',
wellnessFooter: `These settings are partly based on guidelines from the
<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: 'You can filter or disable notifications in the',
filterNotificationsText: 'instance settings',
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: 'Disable',
disableInfiniteScrollText: 'infinite scroll',
disableInfiniteScrollDescription: `When infinite scroll is disabled, new toots will not automatically appear at
the bottom or top of the timeline. Instead, buttons will allow you to
load more content on demand.`,
disableInfiniteScrollPost: '',
// Instance settings
loggedInAs: 'Logged in as',
homeTimelineFilters: 'Home timeline filters',
notificationFilters: 'Notification filters',
pushNotifications: 'Push notifications',
// Add instance page
storageError: `It seems Pinafore cannot store data locally. Is your browser in private mode
or blocking cookies? Pinafore stores all data locally, and requires LocalStorage and
IndexedDB to work correctly.`,
javaScriptError: 'You must enable JavaScript to log in.',
enterInstanceName: 'Enter instance name',
instanceColon: 'Instance:',
// Custom tooltip, concatenated together
getAnInstancePre: "Don't have an",
getAnInstanceText: 'instance',
getAnInstanceDescription: 'An instance is your Mastodon home server, such as mastodon.social or cybre.space.',
getAnInstancePost: '?',
joinMastodon: 'Join Mastodon!',
instancesYouveLoggedInTo: "Instances you've logged in to:",
addAnotherInstance: 'Add another instance',
youreNotLoggedIn: "You're not logged in to any instances.",
currentInstanceLabel: `{instance} {current, select,
true {(current instance)}
other {}
}`,
// Link text
logInToAnInstancePre: '',
logInToAnInstanceText: 'Log in to an instance',
logInToAnInstancePost: 'to start using Pinafore.',
// Another custom tooltip
showRingPre: 'Always show',
showRingText: 'focus ring',
showRingDescription: `The focus ring is the outline showing the currently focused element. By default, it's only
shown when using the keyboard (not mouse or touch), but you may choose to always show it.`,
showRingPost: '',
instances: 'Instances',
addInstance: 'Add instance',
homeTimelineFilterSettings: 'Home timeline filter settings',
showReblogs: 'Show boosts',
showReplies: 'Show replies',
switchOrLogOut: 'Switch to or log out of this instance',
switchTo: 'Switch to this instance',
switchToInstance: 'Switch to instance',
switchToNameOfInstance: 'Switch to {instance}',
logOut: 'Log out',
logOutOfInstanceConfirm: 'Log out of {instance}?',
notificationFilterSettings: 'Notification filter settings',
// Push notifications
browserDoesNotSupportPush: "Your browser doesn't support push notifications.",
deniedPush: 'You have denied permission to show notifications.',
pushNotificationsNote: 'Note that you can only have push notifications for one instance at a time.',
pushSettings: 'Push notification settings',
newFollowers: 'New followers',
reblogs: 'Boosts',
pollResults: 'Poll results',
needToReauthenticate: 'You need to reauthenticate in order to enable push notification. Log out of {instance}?',
failedToUpdatePush: 'Failed to update push notification settings: {error}',
// Themes
chooseTheme: 'Choose a theme',
darkBackground: 'Dark background',
lightBackground: 'Light background',
themeLabel: `{label} {default, select,
true {(default)}
other {}
}`,
animatedImage: 'Animated image: {description}',
showImage: `Show {animated, select,
true {animated}
other {}
} image: {description}`,
playVideoOrAudio: `Play {audio, select,
true {audio}
other {video}
}: {description}`,
accountFollowedYou: '{name} followed you, {account}',
reblogCountsHidden: 'Boost counts hidden',
favoriteCountsHidden: 'Favorite counts hidden',
rebloggedTimes: `Boosted {count, plural,
one {1 time}
other {{count} times}
}`,
favoritedTimes: `Favorited {count, plural,
one {1 time}
other {{count} times}
}`,
pinnedStatus: 'Pinned toot',
rebloggedYou: 'boosted your toot',
favoritedYou: 'favorited your toot',
followedYou: 'followed you',
pollYouCreatedEnded: 'A poll you created has ended',
pollYouVotedEnded: 'A poll you voted on has ended',
reblogged: 'boosted',
showSensitiveMedia: 'Show sensitive media',
hideSensitiveMedia: 'Hide sensitive media',
clickToShowSensitive: 'Sensitive content. Click to show.',
longPost: 'Long post',
// Accessible status labels
accountRebloggedYou: '{account} boosted your toot',
accountFavoritedYou: '{account} favorited your toot',
rebloggedByAccount: 'Boosted by {account}',
contentWarningContent: 'Content warning: {spoiler}',
hasMedia: 'has media',
hasPoll: 'has poll',
shortStatusLabel: '{privacy} toot by {account}',
// Privacy types
public: 'Public',
unlisted: 'Unlisted',
followersOnly: 'Followers-only',
direct: 'Direct',
// Themes
themeRoyal: 'Royal',
themeScarlet: 'Scarlet',
themeSeafoam: 'Seafoam',
themeHotpants: 'Hotpants',
themeOaken: 'Oaken',
themeMajesty: 'Majesty',
themeGecko: 'Gecko',
themeGrayscale: 'Grayscale',
themeOzark: 'Ozark',
themeCobalt: 'Cobalt',
themeSorcery: 'Sorcery',
themePunk: 'Punk',
themeRiot: 'Riot',
themeHacker: 'Hacker',
themeMastodon: 'Mastodon',
themePitchBlack: 'Pitch Black',
themeDarkGrayscale: 'Dark Grayscale',
// Polls
voteOnPoll: 'Vote on poll',
pollChoices: 'Poll choices',
vote: 'Vote',
pollDetails: 'Poll details',
refresh: 'Refresh',
expires: 'Ends',
expired: 'Ended',
voteCount: `{count, plural,
one {1 vote}
other {{count} votes}
}`,
// Status interactions
clickToShowThread: '{time} - click to show thread',
showMore: 'Show more',
showLess: 'Show less',
closeReply: 'Close reply',
cannotReblogFollowersOnly: 'Cannot be boosted because this is followers-only',
cannotReblogDirectMessage: 'Cannot be boosted because this is a direct message',
reblog: 'Boost',
reply: 'Reply',
replyToThread: 'Reply to thread',
favorite: 'Favorite',
unfavorite: 'Unfavorite',
// timeline
loadingMore: 'Loading more…',
loadMore: 'Load more',
showCountMore: 'Show {count} more',
nothingToShow: 'Nothing to show.',
// status thread page
statusThreadPage: 'Toot thread page',
status: 'Toot',
// toast messages
blockedAccount: 'Blocked account',
unblockedAccount: 'Unblocked account',
unableToBlock: 'Unable to block account: {error}',
unableToUnblock: 'Unable to unblock account: {error}',
bookmarkedStatus: 'Bookmarked toot',
unbookmarkedStatus: 'Unbookmarked toot',
unableToBookmark: 'Unable to bookmark: {error}',
unableToUnbookmark: 'Unable to unbookmark: {error}',
cannotPostOffline: 'You cannot post while offline',
unableToPost: 'Unable to post toot: {error}',
statusDeleted: 'Toot deleted',
unableToDelete: 'Unable to delete toot: {error}',
cannotFavoriteOffline: 'You cannot favorite while offline',
cannotUnfavoriteOffline: 'You cannot unfavorite while offline',
unableToFavorite: 'Unable to favorite: {error}',
unableToUnfavorite: 'Unable to unfavorite: {error}',
followedAccount: 'Followed account',
unfollowedAccount: 'Unfollowed account',
unableToFollow: 'Unable to follow account: {error}',
unableToUnfollow: 'Unable to unfollow account: {error}',
accessTokenRevoked: 'The access token was revoked, logged out of {instance}',
loggedOutOfInstance: 'Logged out of {instance}',
failedToUploadMedia: 'Failed to upload media: {error}',
mutedAccount: 'Muted account',
unmutedAccount: 'Unmuted account',
unableToMute: 'Unable to mute account: {error}',
unableToUnmute: 'Unable to unmute account: {error}',
mutedConversation: 'Muted conversation',
unmutedConversation: 'Unmuted conversation',
unableToMuteConversation: 'Unable to mute conversation: {error}',
unableToUnmuteConversation: 'Unable to unmute conversation: {error}',
unpinnedStatus: 'Unpinned toot',
unableToPinStatus: 'Unable to pin toot: {error}',
unableToUnpinStatus: 'Unable to unpin toot: {error}',
unableToRefreshPoll: 'Unable to refresh poll: {error}',
unableToVoteInPoll: 'Unable to vote in poll: {error}',
cannotReblogOffline: 'You cannot boost while offline.',
cannotUnreblogOffline: 'You cannot unboost while offline.',
failedToReblog: 'Failed to boost: {error}',
failedToUnreblog: 'Failed to unboost: {error}',
submittedReport: 'Submitted report',
failedToReport: 'Failed to report: {error}',
approvedFollowRequest: 'Approved follow request',
rejectedFollowRequest: 'Rejected follow request',
unableToApproveFollowRequest: 'Unable to approve follow request: {error}',
unableToRejectFollowRequest: 'Unable to reject follow request: {error}',
searchError: 'Error during search: {error}',
hidDomain: 'Hid domain',
unhidDomain: 'Unhid domain',
unableToHideDomain: 'Unable to hide domain: {error}',
unableToUnhideDomain: 'Unable to unhide domain: {error}',
showingReblogs: 'Showing boosts',
hidingReblogs: 'Hiding boosts',
unableToShowReblogs: 'Unable to show boosts: {error}',
unableToHideReblogs: 'Unable to hide boosts: {error}',
unableToShare: 'Unable to share: {error}',
showingOfflineContent: 'Internet request failed. Showing offline content.',
youAreOffline: 'You seem to be offline. You can still read toots while offline.',
// Snackbar UI
updateAvailable: 'App update available.'
}

628
src/intl/fr.js Normal file
View file

@ -0,0 +1,628 @@
export default {
// Home page, basic <title> and <description>
appName: 'Pinafore',
appDescription: 'Un client alternatif pour Mastodon, concentré sur la vitesse et la simplicité',
homeDescription: `
<p>
Pinafore est un client web pour
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
dessiné pour la vitesse et la simplicité.
</p>
<p>
Lire
<a rel="noopener" target="_blank"
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">l'article introductoire (anglais)</a>,
ou se connecter à une instance:
</p>`,
logIn: 'Se connecter',
footer: `
<p>
Pinafore est
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">logiciel open-source</a>
créé par
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
et distribué sous la
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">License AGPL</a>.
Lire la <a href="/settings/about#privacy-policy" rel="prefetch">politique de confidentialité</a>.
</p>
`,
// Generic UI
loading: 'Chargement en cours',
okay: 'OK',
cancel: 'Annuler',
alert: 'Alerte',
close: 'Fermer',
error: 'Erreur: {error}',
errorShort: 'Erreur:',
// Relative timestamps
justNow: 'il y a un moment',
// Navigation, page titles
navItemLabel: `
{label} {selected, select,
true {(page actuelle)}
other {}
} {name, select,
notifications {{count, plural,
=0 {}
one {(1 notification)}
other {({count} notifications)}
}}
community {{count, plural,
=0 {}
one {(1 demande de suivre)}
other {({count} demandes de suivre)}
}}
other {}
}
`,
blockedUsers: 'Utilisateurs bloqués',
bookmarks: 'Signets',
directMessages: 'Messages directs',
favorites: 'Favoris',
federated: 'Fédéré',
home: 'Accueil',
local: 'Local',
notifications: 'Notifications',
mutedUsers: 'Utilisateurs mis en sourdine',
pinnedStatuses: 'Pouets épinglés',
followRequests: 'Demandes de suivre',
followRequestsLabel: `Demandes de suivre {hasFollowRequests, select,
true {({count})}
other {}
}`,
list: 'Liste',
search: 'Recherche',
pageHeader: 'Titre de page',
goBack: 'Rentrer',
back: 'Rentrer',
profile: 'Profil',
federatedTimeline: 'Historique fédéré',
localTimeline: 'Historique local',
// community page
community: 'Communauté',
pinnableTimelines: 'Historiques épinglables',
timelines: 'Historiques',
lists: 'Listes',
instanceSettings: "Paramètres d'instance",
notificationMentions: 'Notifications de mention',
profileWithMedia: 'Profil avec medias',
profileWithReplies: 'Profil avec réponses',
hashtag: 'Mot-dièse',
// not logged in
profileNotLoggedIn: "Un historique d'utilisateur s'apparêtra ici quand on est conncté.",
bookmarksNotLoggedIn: "Vos signets s'apparêtront ici quand on est conncté.",
directMessagesNotLoggedIn: "Vos messages directes s'apparêtront ici quand on est conncté.",
favoritesNotLoggedIn: "Vos favoris s'apparêtront ici quand on est conncté.",
federatedTimelineNotLoggedIn: "L'historique fédéré s'apparêtra ici quand on est conncté.",
localTimelineNotLoggedIn: "L'historique local s'apparêtra ici quand on est conncté.",
searchNotLoggedIn: "On peut rechercher dès qu'on est conncté.",
communityNotLoggedIn: "Les paramètres de commnautés s'apparêtront ici quand on est conncté.",
listNotLoggedIn: "Une liste s'apparêtra ici dès qu'on est conncté.",
notificationsNotLoggedIn: "Vos notifications s'apparêtront ici quand on est conncté.",
notificationMentionsNotLoggedIn: "Vos notifications de mention s'apparêtront ici quand on est conncté.",
statusNotLoggedIn: "Un historique de pouet s'apparêtra ici quand on est conncté.",
tagNotLoggedIn: "Un historique de mot-dièse s'apparêtra ici quand on est conncté.",
// Notification subpages
filters: 'Filtres',
all: 'Tous',
mentions: 'Mentions',
// Follow requests
approve: 'Accepter',
reject: 'Rejeter',
// Hotkeys
hotkeys: 'Raccourcis clavier',
global: 'Global',
timeline: 'Historique',
media: 'Medias',
globalHotkeys: `
{leftRightChangesFocus, select,
true {
<li><kbd></kbd> pour changer de focus à l'élément suivant</li>
<li><kbd></kbd> pour changer de focus à l'élément précédent</li>
}
other {}
}
<li>
<kbd>1</kbd> - <kbd>6</kbd>
{leftRightChangesFocus, select,
true {}
other {ou <kbd></kbd>/<kbd></kbd>}
}
pour changer de pages
</li>
<li><kbd>7</kbd> or <kbd>c</kbd> pour écrire un nouveau pouet</li>
<li><kbd>s</kbd> or <kbd>/</kbd> pour rechercher</li>
<li><kbd>g</kbd> + <kbd>h</kbd> pour renter à l'acceuil</li>
<li><kbd>g</kbd> + <kbd>n</kbd> pour voir les notifications</li>
<li><kbd>g</kbd> + <kbd>l</kbd> pour voir l'historique local</li>
<li><kbd>g</kbd> + <kbd>t</kbd> pour voir l'historique fédéré</li>
<li><kbd>g</kbd> + <kbd>c</kbd> pour voir les paramètres de communauté</li>
<li><kbd>g</kbd> + <kbd>d</kbd> pour voir les messages directs</li>
<li><kbd>h</kbd> ou <kbd>?</kbd> pour voir les raccourcis clavier</li>
<li><kbd>Retour arrière</kbd> pour rentrer à la page précédente, ou fermer une boite de dialogue</li>
`,
timelineHotkeys: `
<li><kbd>j</kbd> ou <kbd></kbd> pour activer le pouet suivant</li>
<li><kbd>k</kbd> ou <kbd></kbd> pour activer le pouet précedent</li>
<li><kbd>.</kbd> pour afficher les nouveaus messages et renter en haut</li>
<li><kbd>o</kbd> pour ouvrir</li>
<li><kbd>f</kbd> pour ajouter aux favoris</li>
<li><kbd>b</kbd> pour partager</li>
<li><kbd>r</kbd> pour répondre</li>
<li><kbd>i</kbd> pour voir une image, vidéo, ou audio</li>
<li><kbd>y</kbd> pour afficher ou cacher une image sensible</li>
<li><kbd>m</kbd> pour mentionner l'auteur</li>
<li><kbd>p</kbd> pour voir le profile de l'auteur</li>
<li><kbd>l</kbd> pour ouvrir un lien de carte dans un nouvel onglet</li>
<li><kbd>x</kbd> pour afficher ou cacher le texte caché derrière une avertissement</li>
`,
mediaHotkeys: `
<li><kbd></kbd> / <kbd></kbd> pour voir la prochaine ou dernière image</li>
`,
// Community page, tabs
tabLabel: `{label} {current, select,
true {(Actuel)}
other {}
}`,
pageTitle: `
{hasNotifications, select,
true {({count})}
other {}
}
{showInstanceName, select,
true {{instanceName}}
other {Pinafore}
}
·
{name}
`,
pinLabel: `{label} {pinnable, select,
true {
{pinned, select,
true {(Page épinglée)}
other {(Page non-épinglée)}
}
}
other {}
}`,
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',
dropToUpload: 'Déposer',
invalidFileType: "Impossible d'uploader ce type de fichier",
composeLabel: "Qu'avez vous en tête?",
autocompleteDescription: 'Quand les résultats sont dispibles, appuyez la fleche vers le haut ou vers le bas pour selectionner.',
mediaUploads: 'Medias uploadés',
edit: 'Rediger',
delete: 'Supprimer',
description: 'Déscription',
descriptionLabel: 'Décrire pour les aveugles (image, video) ou les sourds (audio, video)',
markAsSensitive: 'Désigner comme sensible',
// Polls
createPoll: 'Créer une enquête',
removePollChoice: 'Supprimer la choix {index}',
pollChoiceLabel: 'Choix {index}',
multipleChoice: 'Choix multiple',
pollDuration: "Duration de l'enquête",
fiveMinutes: '5 minutes',
thirtyMinutes: '30 minutes',
oneHour: '1 heure',
sixHours: '6 heures',
oneDay: '1 jour',
threeDays: '3 jours',
sevenDays: '7 jours',
addEmoji: 'Insérer un emoji',
addMedia: 'Ajouter un media (images, vidéos, audios)',
addPoll: 'Ajouter une enquête',
removePoll: "Enlever l'enquête",
postPrivacyLabel: 'Changer de confidentialité (actuellement {label})',
addContentWarning: 'Ajouter une avertissement',
removeContentWarning: "Enlever l'avertissement",
altLabel: 'Décrire pour les aveugles ou les sourds',
extractText: "Extraire le texte de l'image",
extractingText: 'Extraction de texte en cours…',
extractingTextCompletion: 'Extraction de texte en cours ({percent}% finit)…',
unableToExtractText: "Impossible d'extraire le texte.",
// Account options
followAccount: 'Suivre {account}',
unfollowAccount: 'Ne plus suivre {account}',
blockAccount: 'Bloquer {account}',
unblockAccount: 'Ne plus bloquer {account}',
muteAccount: 'Mettre {account} en sourdine',
unmuteAccount: 'Ne plus mettre {account} en sourdine',
showReblogsFromAccount: 'Afficher les partages de {account}',
hideReblogsFromAccount: 'Ne plus afficher les partages de {account}',
showDomain: 'Ne plus cacher {domain}',
hideDomain: 'Cacher {domain}',
reportAccount: 'Signaler {account}',
mentionAccount: 'Mentionner {account}',
copyLinkToAccount: 'Copier un lien vers ce compte',
copiedToClipboard: 'Copié vers le presse-papiers',
// Media dialog
navigateMedia: 'Changer de medias',
showPreviousMedia: 'Afficher le media précédent',
showNextMedia: 'Afficher le media suivant',
enterPinchZoom: 'Pincer pour zoomer',
exitPinchZoom: 'Ne plus pincer pour zoomer',
showMedia: `Afficher le {index, select,
1 {premier}
2 {deuxième}
3 {troisième}
other {quatrième}
} média {current, select,
true {(actuel)}
other {}
}`,
previewFocalPoint: 'Aperçu (point de mire)',
enterFocalPoint: 'Saisir le point de mire (X, Y) pour ce média',
muteNotifications: 'Mettre aussi bien les notifications en sourdine',
muteAccountConfirm: 'Mettre {account} en sourdine?',
mute: 'Mettre en sourdine',
unmute: 'Ne plus mettre en sourdine',
zoomOut: 'Dé-zoomer',
zoomIn: 'Zoomer',
// Reporting
reportingLabel: 'Vous signalez {account} aux modérateurs/modératrices de {instance}.',
additionalComments: 'Commentaires additionels',
forwardDescription: 'Faire parvenir aux modérateurs/modératrices de {instance} aussi?',
forwardLabel: 'Fair pervenir à {instance}',
unableToLoadStatuses: 'Impossible de charger les pouets récents: {error}',
report: 'Signaler',
noContent: '(Pas de contenu)',
noStatuses: 'Aucun pouet à signaler',
// Status options
unpinFromProfile: 'Ne plus épingler sur son profil',
pinToProfile: 'Epingler sur son profil',
muteConversation: 'Mettre en sourdine la conversation',
unmuteConversation: 'Ne plus mettre en sourdine la conversation',
bookmarkStatus: 'Ajouter aux signets',
unbookmarkStatus: 'Enlever des signets',
deleteAndRedraft: 'Supprimer et rediger',
reportStatus: 'Signaler ce pouet',
shareStatus: 'Partager ce pouet externellement',
copyLinkToStatus: 'Copier un lien vers ce pouet',
// Account profile
profileForAccount: 'Profil pour {account}',
statisticsAndMoreOptions: "Statistiques et plus d'options",
statuses: 'Pouets',
follows: 'Suis',
followers: 'Suivants',
moreOptions: "Plus d'options",
followersLabel: 'Suivi(e) par {count}',
followingLabel: 'Suis {count}',
followLabel: `Suivre {requested, select,
true {(suivre demandé)}
other {}
}`,
unfollowLabel: `Ne plus suivre {requested, select,
true {(suivre demandé)}
other {}
}`,
unblock: 'Ne plus bloquer',
nameAndFollowing: 'Nom et suivants',
clickToSeeAvatar: "Cliquer pour voir l'image de profile",
opensInNewWindow: '{label} (ouvrir dans un nouvel onglet)',
blocked: 'Bloquer',
domainHidden: 'Domaine bloqué',
muted: 'Mis en sourdine',
followsYou: 'Suivant',
avatarForAccount: 'Image de profil pour {account}',
fields: 'Champs',
accountHasMoved: '{account} a déménagé',
profilePageForAccount: 'Page de profil pour {account}',
// About page
about: 'Infos',
aboutApp: 'Infos sur Pinafore',
aboutAppDescription: `
<p>
Pinafore est un logiciel
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore">gratuit et open-source</a>
créé par
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
et distribué sous le
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">License GNU Affero General Public (AGPL)</a>.
</p>
<h2 id="privacy-policy">Politique de confidentialité</h2>
<p>
Pinafore ne garde pas d'informations personelles dans ses serveurs,
y compris les noms, addresses courriel, addresses IP, messages, et photos.
</p>
<p>
Pinafore est un site statique. Tous données sont gardées en locale dans le navigateur, et sont partagée qu'avec
les instances auxquelles vous vous connectez.
</p>
<h2>Crédits</h2>
<p>
Icônes par <a rel="noopener" target="_blank" href="http://fontawesome.io/">Font Awesome</a>.
</p>
<p>
Logo grâce à Gregor Cresnar du
<a rel="noopener" target="_blank" href="https://thenounproject.com/">Noun Project</a>.
</p>`,
// Settings
settings: 'Paramètres',
general: 'Général',
generalSettings: 'Paramètres générales',
showSensitive: 'Afficher les medias sensible par défaut',
showPlain: 'Afficher un simple gris pour les medias sensibles',
allSensitive: 'Considérer tous medias comme sensible',
largeMedia: 'Afficher de plus grands images et vidéos',
autoplayGifs: 'Repasser automatiquement les GIFs animés',
hideCards: 'Cacher les liens «cartes»',
underlineLinks: 'Souligner les liens dans les pouets et profils',
accessibility: 'Accessibilité',
reduceMotion: 'Reduire la motions dans les animations',
disableTappable: "Désactiver l'espace touchable sur un pouet entier",
removeEmoji: "Enlever les emojis des noms d'utilisateur",
shortAria: 'Utiliser des etiquettes courtes ARIA',
theme: 'Thème',
themeForInstance: 'Theème pour {instance}',
disableCustomScrollbars: 'Désactiver les scrollbars customisés',
preferences: 'Préférences',
hotkeySettings: 'Paramètres de raccourcis clavier',
disableHotkeys: 'Désactiver les raccourcis clavier',
leftRightArrows: 'Les flèches gauche/droit change de focus plutôt que les pages',
guide: 'Guide',
reload: 'Recharger',
// Wellness settings
wellness: 'Bien-être',
wellnessSettings: 'Paramètres de bien-être',
wellnessDescription: `Les paramètres de bien-être sont dessinées pour rédruire les effets accrochants ou d'anxiété des réseaux sociaux.
Veuillez choisir les options qui marchent pour vous.`,
enableAll: 'Activer tous',
metrics: 'Métrics',
hideFollowerCount: 'Cacher le nombre de suivants (10 maximum)',
hideReblogCount: 'Cacher le nombre de partages',
hideFavoriteCount: 'Cacher le nombre de favoris',
hideUnread: "Cacher le nombre de notifications (c'est-à-dire le point rouge)",
ui: 'Interface Utilisateur',
grayscaleMode: 'Mode echelle de gris',
wellnessFooter: `Ces paramètres sont basé sur les recommendations du
<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: 'Vous pouvez filtrer ou désactiver les notifications dans les',
filterNotificationsText: "paramètres d'instance",
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: 'Désactiver le',
disableInfiniteScrollText: 'défilage infini',
disableInfiniteScrollDescription: `Quand le défilage infini est désactivé, les pouets nouveau ne
s'apparêtront pas automatique au haut ou au bas de l'historique. Plutôt, il y aura des boutons pour
charger sur demande.`,
disableInfiniteScrollPost: '',
// Instance settings
loggedInAs: 'Connecté en tant que',
homeTimelineFilters: "Filtres d'historique de l'acceuil",
notificationFilters: 'Filtres de notifications',
pushNotifications: 'Filtres de notifications push',
// Add instance page
storageError: `Il semble que Pinafore ne peut pas stocker les données en locale. Est-ce que votre navigateur
est en mode privé, ou est-ce qu'il bloque les cookies? Pinafore garde tous ses données en locale et
ne peut pas fonctionner sans LocalStorage ou IndexedDB.`,
javaScriptError: 'Le JavaScript devrait être activé pour continuer.',
enterInstanceName: "Saisir le nom d'instance",
instanceColon: 'Instance:',
// Custom tooltip, concatenated together
getAnInstancePre: "N'avez-vous pas d'",
getAnInstanceText: 'instance',
getAnInstanceDescription: 'Une instance est votre serveur Mastodon, par exemple mastodon.social ou cybre.space.',
getAnInstancePost: '?',
joinMastodon: 'Joignez-vous à Mastodon!',
instancesYouveLoggedInTo: 'Instances conntectées:',
addAnotherInstance: 'Ajouter une nouvelle instance',
youreNotLoggedIn: 'Vous êtes connecté(e) à aucune instance.',
currentInstanceLabel: `{instance} {current, select,
true {(instance actuelle)}
other {}
}`,
// Link text
logInToAnInstancePre: '',
logInToAnInstanceText: 'Se connecter à une instance',
logInToAnInstancePost: 'pour utiliser Pinafore.',
// Another custom tooltip
showRingPre: 'Afficher toujours',
showRingText: "l'anneau de focus",
showRingDescription: `L'anneau de focus est le contour qui indique l'élément en focus actuel. Par défaut, ce n'est
affiché que quand on utilise le clavier (et ne pas la souris ou l'écran touche), mais vous pouvez choisr de
l'afficher toujours.`,
showRingPost: '',
instances: 'Les instances',
addInstance: 'Ajouter une instance',
homeTimelineFilterSettings: "Paramètres de filtre d'historique",
showReblogs: 'Afficher les partages',
showReplies: 'Afficher les réponses',
switchOrLogOut: 'Changer ou se déconnecter de cette instance',
switchTo: "Changer d'instance à celle-ci",
switchToInstance: "Changer d'instance",
switchToNameOfInstance: "Faire {instance} l'instance actuelle",
logOut: 'Se déconnecter',
logOutOfInstanceConfirm: 'Déconnectez-vous de {instance}?',
notificationFilterSettings: 'Paramètres de filtre de notifications',
// Push notifications
browserDoesNotSupportPush: 'Votre navigateur ne soutient pas les notifications push.',
deniedPush: 'Vous avez désactivé les notifications push.',
pushNotificationsNote: 'Veuillez noter que les notifications push ne peuvent être activées que pour une instance à la fois.',
pushSettings: 'Paramètres de notifications push',
newFollowers: 'Suivants nouveaux',
reblogs: 'Partages',
pollResults: "Résultats d'enquête",
needToReauthenticate: 'Vous devez ré-authentiquer pour activer les notifications push. Déconnectez-vous de {instance}?',
failedToUpdatePush: 'Impossible de mettre à jour les paramètres de notifications push: {error}',
// Themes
chooseTheme: 'Choisir une thème',
darkBackground: 'Sombre',
lightBackground: 'Clair',
themeLabel: `{label} {default, select,
true {(défaut)}
other {}
}`,
animatedImage: 'Image animée: {description}',
showImage: `Afficher l'image {animated, select,
true {animée}
other {}
}: {description}`,
playVideoOrAudio: `Repasser {audio, select,
true {l'audio}
other {la vidéo}
}: {description}`,
accountFollowedYou: '{name} vous a suivi(e), {account}',
reblogCountsHidden: 'Nombre de partages caché',
favoriteCountsHidden: 'nombre de mises en favori caché',
rebloggedTimes: `Partagé {count, plural,
one {une fois}
other {{count} fois}
}`,
favoritedTimes: `Mis en favori {count, plural,
one {une fois}
other {{count} fois}
}`,
pinnedStatus: 'Pouet épinglé',
rebloggedYou: 'a partagé votre pouet',
favoritedYou: 'a mis en favori votre pouet',
followedYou: 'followed you',
pollYouCreatedEnded: 'Une enquête vous avez créée a terminée',
pollYouVotedEnded: 'Une enquête dans laquelle vous avez voté a terminée',
reblogged: 'partagé',
showSensitiveMedia: 'Afficher la média sensible',
hideSensitiveMedia: 'Cacher la média sensible',
clickToShowSensitive: 'Image sensible. Cliquer pour afficher.',
longPost: 'Pouet long',
// Accessible status labels
accountRebloggedYou: '{account} a partagé votre pouet',
accountFavoritedYou: '{account} a mis votre pouet en favori',
rebloggedByAccount: 'Partagé par {account}',
contentWarningContent: 'Avertissement: {spoiler}',
hasMedia: 'média',
hasPoll: 'enquête',
shortStatusLabel: 'Pouet {privacy} par {account}',
// Privacy types
public: 'Publique',
unlisted: 'Non listé',
followersOnly: 'Abonnés/abonnées uniquement',
direct: 'Direct',
// Themes
themeRoyal: 'Royale',
themeScarlet: 'Ecarlate',
themeSeafoam: 'Ecume',
themeHotpants: 'Hotpants',
themeOaken: 'Chêne',
themeMajesty: 'Majesté',
themeGecko: 'Gecko',
themeGrayscale: 'Echelle gris',
themeOzark: 'Ozark',
themeCobalt: 'Cobalt',
themeSorcery: 'Sorcellerie',
themePunk: 'Punk',
themeRiot: 'Riot',
themeHacker: 'Hacker',
themeMastodon: 'Mastodon',
themePitchBlack: 'Noir complet',
themeDarkGrayscale: 'Echelle gris sombre',
// Polls
voteOnPoll: 'Voter dans cette enquête',
pollChoices: 'Choix',
vote: 'Voter',
pollDetails: 'Détails',
refresh: 'Recharger',
expires: 'Se termine',
expired: 'Terminée',
voteCount: `{count, plural,
one {1 vote}
other {{count} votes}
}`,
// Status interactions
clickToShowThread: '{time} - cliquer pour afficher le discussion',
showMore: 'Afficher plus',
showLess: 'Afficher moins',
closeReply: 'Fermer la réponse',
cannotReblogFollowersOnly: "Impossible de partager car ce pouet n'est que pour les abonné(e)s",
cannotReblogDirectMessage: 'Impossible de partager car ce pouet est direct',
reblog: 'Partager',
reply: 'Répondre',
replyToThread: 'Répondre au discussion',
favorite: 'Mettre en favori',
unfavorite: 'Ne plus mettre en favori',
// timeline
loadingMore: 'Chargement en cours…',
loadMore: 'Charger plus',
showCountMore: 'Afficher {count} de plus',
nothingToShow: 'Rien à afficher.',
// status thread page
statusThreadPage: 'Page de discussion',
status: 'Pouet',
// toast messages
blockedAccount: 'Compte bloqué',
unblockedAccount: 'Compte ne plus bloqué',
unableToBlock: 'Impossible de bloquer ce compte: {error}',
unableToUnblock: 'Impossible de ne plus bloquer ce compte: {error}',
bookmarkedStatus: 'Ajouté aux signets',
unbookmarkedStatus: 'Enlever des signets',
unableToBookmark: "Impossible d'ajouter aux signets: {error}",
unableToUnbookmark: "Impossible d'enlever des signets: {error}",
cannotPostOffline: 'Vous ne pouvez pas poueter car vous êtes hors connexion',
unableToPost: 'Impossible de poueter: {error}',
statusDeleted: 'Pouet supprimé',
unableToDelete: 'Impossible de supprimer: {error}',
cannotFavoriteOffline: 'Vous ne pouvez pas mettre en favori car vous êtes hors connexion',
cannotUnfavoriteOffline: 'Vous ne pouvez pas enlever des favoris car vous êtes hors connexion',
unableToFavorite: 'Impossible de mettre en favori: {error}',
unableToUnfavorite: "Impossible d'enlever des favoris: {error}",
followedAccount: 'Compte suivi',
unfollowedAccount: 'Compte ne plus suivi',
unableToFollow: 'Impossible de suivre: {error}',
unableToUnfollow: 'Impossible de ne plus suivre: {error}',
accessTokenRevoked: 'Authentication revoquée, déconnecté de {instance}',
loggedOutOfInstance: 'Déconnecté de {instance}',
failedToUploadMedia: "Impossible d'uploader: {error}",
mutedAccount: 'Compte mis en sourdine',
unmutedAccount: 'Compte ne plus mis en sourdine',
unableToMute: 'Impossible de mettre en sourdine: {error}',
unableToUnmute: 'Impossible de plus mettre en sourdine: {error}',
mutedConversation: 'Conversation mis en sourdine',
unmutedConversation: 'Conversation ne plus mis en sourdine',
unableToMuteConversation: 'Impossible de mettre en sourdine: {error}',
unableToUnmuteConversation: 'Impossible de ne plus mettre en sourdine: {error}',
unpinnedStatus: 'Pouet ne plus épinglé',
unableToPinStatus: "Impossible d'épingler: {error}",
unableToUnpinStatus: 'Impossible de ne plus épingler: {error}',
unableToRefreshPoll: 'Impossible de recharger: {error}',
unableToVoteInPoll: 'Impossible de voter: {error}',
cannotReblogOffline: 'Vous ne pouvez pas partager car vous êtes hors de connexion.',
cannotUnreblogOffline: 'Vous ne pouvez pas ne plus partager car vous êtes hors de connexion.',
failedToReblog: 'Impossible de partager: {error}',
failedToUnreblog: 'Impossible de ne plus partager: {error}',
submittedReport: 'Report signalé',
failedToReport: 'Impossible de signaler: {error}',
approvedFollowRequest: 'Demande de suivre approuvée',
rejectedFollowRequest: 'Demande de suivre rejetée',
unableToApproveFollowRequest: "Impossible d'appouver: {error}",
unableToRejectFollowRequest: 'Impossible de rejeter: {error}',
searchError: 'Erreur de recherche: {error}',
hidDomain: 'Domaine cachée',
unhidDomain: 'Domaine ne plus cachée',
unableToHideDomain: 'Impossible de cacher la domaine: {error}',
unableToUnhideDomain: 'Imipossible de ne plus cacher la domaine: {error}',
showingReblogs: 'Partages affichés',
hidingReblogs: 'Partages ne plus affichés',
unableToShowReblogs: "Impossible d'afficher les partages: {error}",
unableToHideReblogs: 'Impossible de ne plus afficher les partages: {error}',
unableToShare: 'Impossible de partager externellement: {error}',
showingOfflineContent: "Requête d'internet impossible. Contenu hors de connexion affiché.",
youAreOffline: 'Il semble que vous êtes hors de connextion. Vous pouvez toujours lire les pouets dans cet état.',
// Snackbar UI
updateAvailable: 'Mise à jour disponible.'
}

View file

@ -1,5 +1,6 @@
import { getAccountAccessibleName } from './getAccountAccessibleName' import { getAccountAccessibleName } from './getAccountAccessibleName'
import { POST_PRIVACY_OPTIONS } from '../_static/statuses' import { POST_PRIVACY_OPTIONS } from '../_static/statuses'
import { formatIntl } from '../_utils/formatIntl'
function getNotificationText (notification, omitEmojiInDisplayNames) { function getNotificationText (notification, omitEmojiInDisplayNames) {
if (!notification) { if (!notification) {
@ -7,9 +8,9 @@ function getNotificationText (notification, omitEmojiInDisplayNames) {
} }
const notificationAccountDisplayName = getAccountAccessibleName(notification.account, omitEmojiInDisplayNames) const notificationAccountDisplayName = getAccountAccessibleName(notification.account, omitEmojiInDisplayNames)
if (notification.type === 'reblog') { if (notification.type === 'reblog') {
return `${notificationAccountDisplayName} boosted your status` return formatIntl('intl.accountRebloggedYou', { account: notificationAccountDisplayName })
} else if (notification.type === 'favourite') { } else if (notification.type === 'favourite') {
return `${notificationAccountDisplayName} favorited your status` return formatIntl('intl.accountFavoritedYou', { account: notificationAccountDisplayName })
} }
} }
@ -26,7 +27,7 @@ function getReblogText (reblog, account, omitEmojiInDisplayNames) {
return return
} }
const accountDisplayName = getAccountAccessibleName(account, omitEmojiInDisplayNames) const accountDisplayName = getAccountAccessibleName(account, omitEmojiInDisplayNames)
return `Boosted by ${accountDisplayName}` return formatIntl('intl.rebloggedByAccount', { account: accountDisplayName })
} }
function cleanupText (text) { function cleanupText (text) {
@ -40,15 +41,15 @@ export function getAccessibleLabelForStatus (originalAccount, account, plainText
const originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames) const originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames)
const contentTextToShow = (showContent || !spoilerText) const contentTextToShow = (showContent || !spoilerText)
? cleanupText(plainTextContent) ? cleanupText(plainTextContent)
: `Content warning: ${cleanupText(spoilerText)}` : formatIntl('intl.contentWarningContent', { spoiler: cleanupText(spoilerText) })
const mediaTextToShow = showMedia && 'has media' const mediaTextToShow = showMedia && 'intl.hasMedia'
const pollTextToShow = showPoll && 'has poll' const pollTextToShow = showPoll && 'intl.hasPoll'
const privacyText = getPrivacyText(visibility) const privacyText = getPrivacyText(visibility)
if (disableLongAriaLabels) { if (disableLongAriaLabels) {
// Long text can crash NVDA; allow users to shorten it like we had it before. // Long text can crash NVDA; allow users to shorten it like we had it before.
// https://github.com/nolanlawson/pinafore/issues/694 // https://github.com/nolanlawson/pinafore/issues/694
return `${privacyText} status by ${originalAccountDisplayName}` return formatIntl('intl.shortStatusLabel', { privacy: privacyText, account: originalAccountDisplayName })
} }
const values = [ const values = [

View file

@ -3,6 +3,7 @@ import { blockAccount, unblockAccount } from '../_api/block'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts' import { updateLocalRelationship } from './accounts'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { formatIntl } from '../_utils/formatIntl'
export async function setAccountBlocked (accountId, block, toastOnSuccess) { export async function setAccountBlocked (accountId, block, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -16,14 +17,17 @@ export async function setAccountBlocked (accountId, block, toastOnSuccess) {
await updateLocalRelationship(currentInstance, accountId, relationship) await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) { if (toastOnSuccess) {
if (block) { if (block) {
toast.say('Blocked account') /* no await */ toast.say('intl.blockedAccount')
} else { } else {
toast.say('Unblocked account') /* no await */ toast.say('intl.unblockedAccount')
} }
} }
emit('refreshAccountsList') emit('refreshAccountsList')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${block ? 'block' : 'unblock'} account: ` + (e.message || '')) /* no await */ toast.say(block
? formatIntl('intl.unableToBlock', { block: !!block, error: (e.message || '') })
: formatIntl('intl.unableToUnblock', { error: (e.message || '') })
)
} }
} }

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { bookmarkStatus, unbookmarkStatus } from '../_api/bookmark' import { bookmarkStatus, unbookmarkStatus } from '../_api/bookmark'
import { database } from '../_database/database' import { database } from '../_database/database'
import { formatIntl } from '../_utils/formatIntl'
export async function setStatusBookmarkedOrUnbookmarked (statusId, bookmarked) { export async function setStatusBookmarkedOrUnbookmarked (statusId, bookmarked) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -12,14 +13,18 @@ export async function setStatusBookmarkedOrUnbookmarked (statusId, bookmarked) {
await unbookmarkStatus(currentInstance, accessToken, statusId) await unbookmarkStatus(currentInstance, accessToken, statusId)
} }
if (bookmarked) { if (bookmarked) {
toast.say('Bookmarked toot') /* no await */ toast.say('intl.bookmarkedStatus')
} else { } else {
toast.say('Unbookmarked toot') /* no await */ toast.say('intl.unbookmarkedStatus')
} }
store.setStatusBookmarked(currentInstance, statusId, bookmarked) store.setStatusBookmarked(currentInstance, statusId, bookmarked)
await database.setStatusBookmarked(currentInstance, statusId, bookmarked) await database.setStatusBookmarked(currentInstance, statusId, bookmarked)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${bookmarked ? 'bookmark' : 'unbookmark'} toot: ` + (e.message || '')) /* no await */toast.say(
bookmarked
? formatIntl('intl.unableToBookmark', { error: (e.message || '') })
: formatIntl('intl.unableToUnbookmark', { error: (e.message || '') })
)
} }
} }

View file

@ -8,6 +8,7 @@ import { putMediaMetadata } from '../_api/media'
import uniqBy from 'lodash-es/uniqBy' import uniqBy from 'lodash-es/uniqBy'
import { deleteCachedMediaFile } from '../_utils/mediaUploadFileCache' import { deleteCachedMediaFile } from '../_utils/mediaUploadFileCache'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { formatIntl } from '../_utils/formatIntl'
export async function insertHandleForReply (statusId) { export async function insertHandleForReply (statusId) {
const { currentInstance } = store.get() const { currentInstance } = store.get()
@ -31,7 +32,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
const { currentInstance, accessToken, online } = store.get() const { currentInstance, accessToken, online } = store.get()
if (!online) { if (!online) {
toast.say('You cannot post while offline') /* no await */ toast.say('intl.cannotPostOffline')
return return
} }
@ -63,7 +64,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
scheduleIdleTask(() => (mediaIds || []).forEach(mediaId => deleteCachedMediaFile(mediaId))) // clean up media cache scheduleIdleTask(() => (mediaIds || []).forEach(mediaId => deleteCachedMediaFile(mediaId))) // clean up media cache
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Unable to post status: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.unableToPost', { error: (e.message || '') }))
} finally { } finally {
store.set({ postingStatus: false }) store.set({ postingStatus: false })
} }

View file

@ -5,7 +5,7 @@ export async function copyText (text) {
if (navigator.clipboard) { // not supported in all browsers if (navigator.clipboard) { // not supported in all browsers
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.say('Copied to clipboard') /* no await */ toast.say('intl.copiedToClipboard')
return return
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View file

@ -2,17 +2,18 @@ import { store } from '../_store/store'
import { deleteStatus } from '../_api/delete' import { deleteStatus } from '../_api/delete'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { deleteStatus as deleteStatusLocally } from './deleteStatuses' import { deleteStatus as deleteStatusLocally } from './deleteStatuses'
import { formatIntl } from '../_utils/formatIntl'
export async function doDeleteStatus (statusId) { export async function doDeleteStatus (statusId) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
try { try {
const deletedStatus = await deleteStatus(currentInstance, accessToken, statusId) const deletedStatus = await deleteStatus(currentInstance, accessToken, statusId)
deleteStatusLocally(currentInstance, statusId) deleteStatusLocally(currentInstance, statusId)
toast.say('Status deleted.') /* no await */ toast.say('intl.statusDeleted')
return deletedStatus return deletedStatus
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Unable to delete status: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.unableToDelete', { error: (e.message || '') }))
throw e throw e
} }
} }

View file

@ -2,11 +2,12 @@ import { favoriteStatus, unfavoriteStatus } from '../_api/favorite'
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { database } from '../_database/database' import { database } from '../_database/database'
import { formatIntl } from '../_utils/formatIntl'
export async function setFavorited (statusId, favorited) { export async function setFavorited (statusId, favorited) {
const { online } = store.get() const { online } = store.get()
if (!online) { if (!online) {
toast.say(`You cannot ${favorited ? 'favorite' : 'unfavorite'} while offline.`) /* no await */ toast.say(favorited ? 'intl.cannotFavoriteOffline' : 'intl.cannotUnfavoriteOffline')
return return
} }
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -19,7 +20,10 @@ export async function setFavorited (statusId, favorited) {
await database.setStatusFavorited(currentInstance, statusId, favorited) await database.setStatusFavorited(currentInstance, statusId, favorited)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Failed to ${favorited ? 'favorite' : 'unfavorite'}. ` + (e.message || '')) /* no await */ toast.say(favorited
? formatIntl('intl.unableToFavorite', { error: (e.message || '') })
: formatIntl('intl.unableToUnfavorite', { error: (e.message || '') })
)
store.setStatusFavorited(currentInstance, statusId, !favorited) // undo optimistic update store.setStatusFavorited(currentInstance, statusId, !favorited) // undo optimistic update
} }
} }

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { followAccount, unfollowAccount } from '../_api/follow' import { followAccount, unfollowAccount } from '../_api/follow'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts' import { updateLocalRelationship } from './accounts'
import { formatIntl } from '../_utils/formatIntl'
export async function setAccountFollowed (accountId, follow, toastOnSuccess) { export async function setAccountFollowed (accountId, follow, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -14,14 +15,13 @@ export async function setAccountFollowed (accountId, follow, toastOnSuccess) {
} }
await updateLocalRelationship(currentInstance, accountId, relationship) await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) { if (toastOnSuccess) {
if (follow) { /* no await */ toast.say(follow ? 'intl.followedAccount' : 'intl.unfollowedAccount')
toast.say('Followed account')
} else {
toast.say('Unfollowed account')
}
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${follow ? 'follow' : 'unfollow'} account: ` + (e.message || '')) /* no await */ toast.say(follow
? formatIntl('intl.unableToFollow', { error: (e.message || '') })
: formatIntl('intl.unableToUnfollow', { error: (e.message || '') })
)
} }
} }

View file

@ -7,6 +7,7 @@ import { cacheFirstUpdateAfter } from '../_utils/sync'
import { getInstanceInfo } from '../_api/instance' import { getInstanceInfo } from '../_api/instance'
import { database } from '../_database/database' import { database } from '../_database/database'
import { importVirtualListStore } from '../_utils/asyncModules/importVirtualListStore.js' import { importVirtualListStore } from '../_utils/asyncModules/importVirtualListStore.js'
import { formatIntl } from '../_utils/formatIntl'
export function changeTheme (instanceName, newTheme) { export function changeTheme (instanceName, newTheme) {
const { instanceThemes } = store.get() const { instanceThemes } = store.get()
@ -32,7 +33,8 @@ export function switchToInstance (instanceName) {
switchToTheme(instanceThemes[instanceName], enableGrayscale) switchToTheme(instanceThemes[instanceName], enableGrayscale)
} }
export async function logOutOfInstance (instanceName, message = `Logged out of ${instanceName}`) { export async function logOutOfInstance (instanceName, message) {
message = message || formatIntl('intl.loggedOutOfInstance', { instance: instanceName })
const { const {
composeData, composeData,
currentInstance, currentInstance,
@ -123,7 +125,7 @@ export async function updateInstanceInfo (instanceName) {
export function logOutOnUnauthorized (instanceName) { export function logOutOnUnauthorized (instanceName) {
return async error => { return async error => {
if (error.message.startsWith('401:')) { if (error.message.startsWith('401:')) {
await logOutOfInstance(instanceName, `The access token was revoked, logged out of ${instanceName}`) await logOutOfInstance(instanceName, formatIntl('intl.accessTokenRevoked', { instance: instanceName }))
} }
throw error throw error

View file

@ -25,7 +25,7 @@ export async function doMediaUpload (realm, file) {
scheduleIdleTask(() => store.save()) scheduleIdleTask(() => store.save())
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Failed to upload media: ' + (e.message || '')) /* no await */ toast.say('intl.failedToUploadMedia', { error: (e.message || '') })
} finally { } finally {
store.set({ uploadingMedia: false }) store.set({ uploadingMedia: false })
} }

View file

@ -3,6 +3,7 @@ import { muteAccount, unmuteAccount } from '../_api/mute'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts' import { updateLocalRelationship } from './accounts'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { formatIntl } from '../_utils/formatIntl'
export async function setAccountMuted (accountId, mute, notifications, toastOnSuccess) { export async function setAccountMuted (accountId, mute, notifications, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -15,15 +16,14 @@ export async function setAccountMuted (accountId, mute, notifications, toastOnSu
} }
await updateLocalRelationship(currentInstance, accountId, relationship) await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) { if (toastOnSuccess) {
if (mute) { /* no await */ toast.say(mute ? 'intl.mutedAccount' : 'intl.unmutedAccount')
toast.say('Muted account')
} else {
toast.say('Unmuted account')
}
} }
emit('refreshAccountsList') emit('refreshAccountsList')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${mute ? 'mute' : 'unmute'} account: ` + (e.message || '')) /* no await */ toast.say(mute
? formatIntl('intl.unableToMute', { error: (e.message || '') })
: formatIntl('intl.unableToUnmute', { error: (e.message || '') })
)
} }
} }

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { muteConversation, unmuteConversation } from '../_api/muteConversation' import { muteConversation, unmuteConversation } from '../_api/muteConversation'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { database } from '../_database/database' import { database } from '../_database/database'
import { formatIntl } from '../_utils/formatIntl'
export async function setConversationMuted (statusId, mute, toastOnSuccess) { export async function setConversationMuted (statusId, mute, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -13,14 +14,13 @@ export async function setConversationMuted (statusId, mute, toastOnSuccess) {
} }
await database.setStatusMuted(currentInstance, statusId, mute) await database.setStatusMuted(currentInstance, statusId, mute)
if (toastOnSuccess) { if (toastOnSuccess) {
if (mute) { /* no await */ toast.say(mute ? 'intl.mutedConversation' : 'intl.unmutedConversation')
toast.say('Muted conversation')
} else {
toast.say('Unmuted conversation')
}
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${mute ? 'mute' : 'unmute'} conversation: ` + (e.message || '')) /* no await */ toast.say(mute
? formatIntl('intl.unableToMuteConversation', { error: (e.message || '') })
: formatIntl('intl.unableToUnmuteConversation', { error: (e.message || '') })
)
} }
} }

View file

@ -3,6 +3,7 @@ import { toast } from '../_components/toast/toast'
import { pinStatus, unpinStatus } from '../_api/pin' import { pinStatus, unpinStatus } from '../_api/pin'
import { database } from '../_database/database' import { database } from '../_database/database'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { formatIntl } from '../_utils/formatIntl'
export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSuccess) { export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -13,17 +14,16 @@ export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSucces
await unpinStatus(currentInstance, accessToken, statusId) await unpinStatus(currentInstance, accessToken, statusId)
} }
if (toastOnSuccess) { if (toastOnSuccess) {
if (pinned) { /* no await */ toast.say(pinned ? 'intl.pinnedStatus' : 'intl.unpinnedStatus')
toast.say('Pinned status')
} else {
toast.say('Unpinned status')
}
} }
store.setStatusPinned(currentInstance, statusId, pinned) store.setStatusPinned(currentInstance, statusId, pinned)
await database.setStatusPinned(currentInstance, statusId, pinned) await database.setStatusPinned(currentInstance, statusId, pinned)
emit('updatePinnedStatuses') emit('updatePinnedStatuses')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${pinned ? 'pin' : 'unpin'} status: ` + (e.message || '')) /* no await */ toast.say(pinned
? formatIntl('intl.unableToPinStatus', { error: (e.message || '') })
: formatIntl('intl.unableToUnpinStatus', { error: (e.message || '') })
)
} }
} }

View file

@ -1,6 +1,7 @@
import { getPoll as getPollApi, voteOnPoll as voteOnPollApi } from '../_api/polls' import { getPoll as getPollApi, voteOnPoll as voteOnPollApi } from '../_api/polls'
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { formatIntl } from '../_utils/formatIntl'
export async function getPoll (pollId) { export async function getPoll (pollId) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -9,7 +10,7 @@ export async function getPoll (pollId) {
return poll return poll
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Unable to refresh poll: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.unableToRefreshPoll', { error: (e.message || '') }))
} }
} }
@ -20,6 +21,6 @@ export async function voteOnPoll (pollId, choices) {
return poll return poll
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Unable to vote in poll: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.unableToVoteInPoll', { error: (e.message || '') }))
} }
} }

View file

@ -2,11 +2,12 @@ import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { reblogStatus, unreblogStatus } from '../_api/reblog' import { reblogStatus, unreblogStatus } from '../_api/reblog'
import { database } from '../_database/database' import { database } from '../_database/database'
import { formatIntl } from '../_utils/formatIntl'
export async function setReblogged (statusId, reblogged) { export async function setReblogged (statusId, reblogged) {
const online = store.get() const online = store.get()
if (!online) { if (!online) {
toast.say(`You cannot ${reblogged ? 'boost' : 'unboost'} while offline.`) /* no await */ toast.say(reblogged ? 'intl.cannotReblogOffline' : 'intl.cannotUnreblogOffline')
return return
} }
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -19,7 +20,10 @@ export async function setReblogged (statusId, reblogged) {
await database.setStatusReblogged(currentInstance, statusId, reblogged) await database.setStatusReblogged(currentInstance, statusId, reblogged)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Failed to ${reblogged ? 'boost' : 'unboost'}. ` + (e.message || '')) /* no await */ toast.say(reblogged
? formatIntl('intl.failedToReblog', { error: (e.message || '') })
: formatIntl('intl.failedToUnreblog', { error: (e.message || '') })
)
store.setStatusReblogged(currentInstance, statusId, !reblogged) // undo optimistic update store.setStatusReblogged(currentInstance, statusId, !reblogged) // undo optimistic update
} }
} }

View file

@ -1,13 +1,14 @@
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { report } from '../_api/report' import { report } from '../_api/report'
import { formatIntl } from '../_utils/formatIntl'
export async function reportStatuses (account, statusIds, comment, forward) { export async function reportStatuses (account, statusIds, comment, forward) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
try { try {
await report(currentInstance, accessToken, account.id, statusIds, comment, forward) await report(currentInstance, accessToken, account.id, statusIds, comment, forward)
toast.say('Submitted report') /* no await */ toast.say('intl.submittedReport')
} catch (e) { } catch (e) {
toast.say('Failed to report: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.failedToReport', { error: (e.message || '') }))
} }
} }

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { approveFollowRequest, rejectFollowRequest } from '../_api/requests' import { approveFollowRequest, rejectFollowRequest } from '../_api/requests'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { formatIntl } from '../_utils/formatIntl'
export async function setFollowRequestApprovedOrRejected (accountId, approved, toastOnSuccess) { export async function setFollowRequestApprovedOrRejected (accountId, approved, toastOnSuccess) {
const { const {
@ -15,15 +16,14 @@ export async function setFollowRequestApprovedOrRejected (accountId, approved, t
await rejectFollowRequest(currentInstance, accessToken, accountId) await rejectFollowRequest(currentInstance, accessToken, accountId)
} }
if (toastOnSuccess) { if (toastOnSuccess) {
if (approved) { /* no await */ toast.say(approved ? 'intl.approvedFollowRequest' : 'intl.rejectedFollowRequest')
toast.say('Approved follow request')
} else {
toast.say('Rejected follow request')
}
} }
emit('refreshAccountsList') emit('refreshAccountsList')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${approved ? 'approve' : 'reject'} account: ` + (e.message || '')) /* no await */ toast.say(approved
? formatIntl('intl.unableToApproveFollowRequest', { error: (e.message || '') })
: formatIntl('intl.unableToRejectFollowRequest', { error: (e.message || '') })
)
} }
} }

View file

@ -1,6 +1,7 @@
import { store } from '../_store/store' import { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { search } from '../_api/search' import { search } from '../_api/search'
import { formatIntl } from '../_utils/formatIntl'
export async function doSearch () { export async function doSearch () {
const { currentInstance, accessToken, queryInSearch } = store.get() const { currentInstance, accessToken, queryInSearch } = store.get()
@ -15,7 +16,7 @@ export async function doSearch () {
}) })
} }
} catch (e) { } catch (e) {
toast.say('Error during search: ' + (e.name || '') + ' ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.searchError', { error: (e.message || '') }))
console.error(e) console.error(e)
} finally { } finally {
store.set({ searchLoading: false }) store.set({ searchLoading: false })

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { blockDomain, unblockDomain } from '../_api/blockDomain' import { blockDomain, unblockDomain } from '../_api/blockDomain'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { updateRelationship } from './accounts' import { updateRelationship } from './accounts'
import { formatIntl } from '../_utils/formatIntl'
export async function setDomainBlocked (accountId, domain, block, toastOnSuccess) { export async function setDomainBlocked (accountId, domain, block, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -13,14 +14,13 @@ export async function setDomainBlocked (accountId, domain, block, toastOnSuccess
} }
await updateRelationship(accountId) await updateRelationship(accountId)
if (toastOnSuccess) { if (toastOnSuccess) {
if (block) { /* no await */ toast.say(block ? 'intl.hidDomain' : 'intl.unhidDomain')
toast.say(`Hiding ${domain}`)
} else {
toast.say(`Unhiding ${domain}`)
}
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${block ? 'hide' : 'unhide'} domain: ` + (e.message || '')) /* no await */ toast.say(block
? formatIntl('intl.unableToHideDomain', { error: (e.message || '') })
: formatIntl('intl.unableToUnhideDomain', { error: (e.message || '') })
)
} }
} }

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { setShowReblogs as setShowReblogsApi } from '../_api/showReblogs' import { setShowReblogs as setShowReblogsApi } from '../_api/showReblogs'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { updateLocalRelationship } from './accounts' import { updateLocalRelationship } from './accounts'
import { formatIntl } from '../_utils/formatIntl'
export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) { export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) {
const { currentInstance, accessToken } = store.get() const { currentInstance, accessToken } = store.get()
@ -9,14 +10,13 @@ export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) {
const relationship = await setShowReblogsApi(currentInstance, accessToken, accountId, showReblogs) const relationship = await setShowReblogsApi(currentInstance, accessToken, accountId, showReblogs)
await updateLocalRelationship(currentInstance, accountId, relationship) await updateLocalRelationship(currentInstance, accountId, relationship)
if (toastOnSuccess) { if (toastOnSuccess) {
if (showReblogs) { /* no await */ toast.say(showReblogs ? 'intl.showingReblogs' : 'intl.hidingReblogs')
toast.say('Showing boosts')
} else {
toast.say('Hiding boosts')
}
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${showReblogs ? 'show' : 'hide'} boosts: ` + (e.message || '')) /* no await */ toast.say(showReblogs
? formatIntl('intl.unableToShowReblogs', { error: (e.message || '') })
: formatIntl('intl.unableToHideReblogs', { error: (e.message || '') })
)
} }
} }

View file

@ -1,5 +1,6 @@
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText' import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText'
import { formatIntl } from '../_utils/formatIntl'
export async function shareStatus (status) { export async function shareStatus (status) {
try { try {
@ -9,6 +10,6 @@ export async function shareStatus (status) {
url: status.url url: status.url
}) })
} catch (e) { } catch (e) {
toast.say('Unable to share: ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.unableToShare', { error: (e.message || '') }))
} }
} }

View file

@ -142,7 +142,7 @@ async function fetchTimelineItems (instanceName, accessToken, timelineName, onli
await storeFreshTimelineItemsInDatabase(instanceName, timelineName, items) await storeFreshTimelineItemsInDatabase(instanceName, timelineName, items)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say('Internet request failed. Showing offline content.') /* no await */ toast.say('intl.showingOfflineContent')
items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, TIMELINE_BATCH_SIZE) items = await database.getTimeline(instanceName, timelineName, lastTimelineItemId, TIMELINE_BATCH_SIZE)
stale = true stale = true
} }

View file

@ -36,6 +36,7 @@
import AccountSearchResult from './search/AccountSearchResult.html' import AccountSearchResult from './search/AccountSearchResult.html'
import { toast } from './toast/toast' import { toast } from './toast/toast'
import { on } from '../_utils/eventBus' import { on } from '../_utils/eventBus'
import { formatIntl } from '../_utils/formatIntl'
// TODO: paginate // TODO: paginate
export default { export default {
@ -43,7 +44,7 @@
try { try {
await this.refreshAccounts() await this.refreshAccounts()
} catch (e) { } catch (e) {
toast.say('Error: ' + (e.name || '') + ' ' + (e.message || '')) /* no await */ toast.say(formatIntl('intl.error', { error: (e.message || '') }))
} finally { } finally {
this.set({ loading: false }) this.set({ loading: false })
} }

View file

@ -1,5 +1,5 @@
<div class="dynamic-page-banner {icon ? 'dynamic-page-with-icon' : ''}" <div class="dynamic-page-banner {icon ? 'dynamic-page-with-icon' : ''}"
role="navigation" aria-label="Page header" role="navigation" aria-label="{intl.pageHeader}"
> >
{#if icon} {#if icon}
<SvgIcon className="dynamic-page-banner-svg" href={icon} /> <SvgIcon className="dynamic-page-banner-svg" href={icon} />
@ -7,8 +7,8 @@
<h1 class="dynamic-page-title" aria-label={ariaTitle}>{title}</h1> <h1 class="dynamic-page-title" aria-label={ariaTitle}>{title}</h1>
<button type="button" <button type="button"
class="dynamic-page-go-back" class="dynamic-page-go-back"
aria-label="Go back" aria-label="{intl.goBack}"
on:click|preventDefault="onGoBack()">Back</button> on:click|preventDefault="onGoBack()">{intl.back}</button>
</div> </div>
<Shortcut key="Backspace" on:pressed="onGoBack()"/> <Shortcut key="Backspace" on:pressed="onGoBack()"/>
<style> <style>

View file

@ -1,18 +1,6 @@
<HiddenFromSSR> <HiddenFromSSR>
<footer> <footer>
<!-- Use raw HTML to make the output smaller --> {@html intl.footer}
{@html `
<p>
Pinafore is
<a rel="noopener" target="_blank" href="https://github.com/nolanlawson/pinafore">open-source software</a>
created by
<a rel="noopener" target="_blank" href="https://nolanlawson.com">Nolan Lawson</a>
and distributed under the
<a rel="noopener" target="_blank"
href="https://github.com/nolanlawson/pinafore/blob/master/LICENSE">AGPL License</a>.
Here is the <a href="/settings/about#privacy-policy" rel="prefetch">privacy policy</a>.
</p>
`}
</footer> </footer>
</HiddenFromSSR> </HiddenFromSSR>
<script> <script>

View file

@ -17,6 +17,7 @@
import { store } from '../_store/store' import { store } from '../_store/store'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { throttleTimer } from '../_utils/throttleTimer' import { throttleTimer } from '../_utils/throttleTimer'
import { formatIntl } from '../_utils/formatIntl'
const updateDisplayedLength = process.browser && throttleTimer(requestAnimationFrame) const updateDisplayedLength = process.browser && throttleTimer(requestAnimationFrame)
@ -43,9 +44,9 @@
lengthToDisplay: ({ length, max }) => max - length, lengthToDisplay: ({ length, max }) => max - length,
lengthLabel: ({ overLimit, lengthToDisplayDeferred }) => { lengthLabel: ({ overLimit, lengthToDisplayDeferred }) => {
if (overLimit) { if (overLimit) {
return `${-lengthToDisplayDeferred} characters over limit` return formatIntl('intl.overLimit', { count: -lengthToDisplayDeferred })
} else { } else {
return `${lengthToDisplayDeferred} characters remaining` return formatIntl('intl.underLimit', { count: lengthToDisplayDeferred })
} }
} }
}, },

View file

@ -1,7 +1,7 @@
<SvgIcon className="loading-spinner-icon spin {maskStyle ? 'mask-style' : ''}" <SvgIcon className="loading-spinner-icon spin {maskStyle ? 'mask-style' : ''}"
style="width: {size}px; height: {size}px;" style="width: {size}px; height: {size}px;"
href="#fa-spinner" href="#fa-spinner"
ariaLabel="Loading" ariaLabel="{intl.loading}"
/> />
<style> <style>
:global(.loading-spinner-icon) { :global(.loading-spinner-icon) {

View file

@ -128,6 +128,7 @@
import { doubleRAF } from '../_utils/doubleRAF' import { doubleRAF } from '../_utils/doubleRAF'
import { scrollToTop } from '../_utils/scrollToTop' import { scrollToTop } from '../_utils/scrollToTop'
import { normalizePageName } from '../_utils/normalizePageName' import { normalizePageName } from '../_utils/normalizePageName'
import { formatIntl } from '../_utils/formatIntl'
export default { export default {
oncreate () { oncreate () {
@ -150,16 +151,15 @@
computed: { computed: {
selected: ({ page, name }) => name === normalizePageName(page), selected: ({ page, name }) => name === normalizePageName(page),
ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => { ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => {
let res = label const count = name === 'notifications'
if (selected) { ? $numberOfNotifications
res += ' (current page)' : (name === 'community' ? $numberOfFollowRequests : 0)
} return formatIntl('intl.navItemLabel', {
if (name === 'notifications' && $numberOfNotifications) { label,
res += ` (${$numberOfNotifications} notification${$numberOfNotifications === 1 ? '' : 's'})` selected,
} else if (name === 'community' && $numberOfFollowRequests) { name,
res += ` (${$numberOfFollowRequests} follow request${$numberOfFollowRequests === 1 ? '' : 's'})` count
} })
return res
}, },
showBadge: ({ name, $hasNotifications, $hasFollowRequests }) => ( showBadge: ({ name, $hasNotifications, $hasFollowRequests }) => (
(name === 'notifications' && $hasNotifications) || (name === 'community' && $hasFollowRequests) (name === 'notifications' && $hasNotifications) || (name === 'community' && $hasFollowRequests)

View file

@ -3,30 +3,14 @@
<div class="not-logged-in-home"> <div class="not-logged-in-home">
<div class="banner"> <div class="banner">
<SvgIcon className="not-logged-in-home-svg" href="#pinafore-logo" /> <SvgIcon className="not-logged-in-home-svg" href="#pinafore-logo" />
<h1>Pinafore</h1> <h1>{intl.appName}</h1>
</div> </div>
<!-- Use raw HTML to make the output smaller -->
{@html `
<div> <div>
<p> {@html intl.homeDescription}
Pinafore is a web client for
<a rel="noopener" target="_blank" href="https://joinmastodon.org">Mastodon</a>,
designed for speed and simplicity.
</p>
<p>
Read the
<a rel="noopener" target="_blank"
href="https://nolanlawson.com/2018/04/09/introducing-pinafore-for-mastodon/">introductory blog post</a>,
or get started by logging in to an instance:
</p>
<p style="text-align: right;"> <p style="text-align: right;">
<a class="button primary" rel="prefetch" href="/settings/instances/add">Log in</a> <a class="button primary" rel="prefetch" href="/settings/instances/add">{intl.logIn}</a>
</p> </p>
</div> </div>
`}
</div>
</FreeTextLayout> </FreeTextLayout>
</HiddenFromSSR> </HiddenFromSSR>
<style> <style>

View file

@ -1,5 +1,5 @@
<TabSet <TabSet
label="Filters" label="{intl.filters}"
currentTabName={filter} currentTabName={filter}
{tabs} {tabs}
className="notification-filters" className="notification-filters"
@ -12,12 +12,12 @@
tabs: [ tabs: [
{ {
name: '', name: '',
label: 'All', label: 'intl.all',
href: '/notifications' href: '/notifications'
}, },
{ {
name: 'mentions', name: 'mentions',
label: 'Mentions', label: 'intl.mentions',
href: '/notifications/mentions' href: '/notifications/mentions'
} }
] ]

View file

@ -1,61 +1,26 @@
<div class="shortcut-help-info {inDialog ? 'in-dialog' : ''}" <div class="shortcut-help-info {inDialog ? 'in-dialog' : ''}"
tabindex="{inDialog ? '0' : '-1'}" tabindex="{inDialog ? '0' : '-1'}"
> >
<!-- Svelte makes this file kind of ridiculously large for a static page (~17kB), <h2>{intl.global}</h2>
so just use raw HTML here to make it smaller -->
{@html `
<h2>Global</h2>
<div class="hotkey-group">
${$leftRightChangesFocus ?
`
<ul>
<li><kbd></kbd> to go to the next focusable element</li>
<li><kbd></kbd> to go to the previous focusable element</li>
</ul>
` : ''}
<ul>
<li>
<kbd>1</kbd> - <kbd>6</kbd>
${$leftRightChangesFocus ? '' : `or <kbd></kbd>/<kbd></kbd>`}
to switch columns
</li>
<li><kbd>7</kbd> or <kbd>c</kbd> to compose a new toot</li>
<li><kbd>s</kbd> or <kbd>/</kbd> to search</li>
<li><kbd>g</kbd> + <kbd>h</kbd> to go home</li>
<li><kbd>g</kbd> + <kbd>n</kbd> to go to notifications</li>
<li><kbd>g</kbd> + <kbd>l</kbd> to go to the local timeline</li>
<li><kbd>g</kbd> + <kbd>t</kbd> to go to the federated timeline</li>
<li><kbd>g</kbd> + <kbd>c</kbd> to go to the community page</li>
<li><kbd>g</kbd> + <kbd>d</kbd> to go to the conversations page</li>
<li><kbd>h</kbd> or <kbd>?</kbd> to toggle the help dialog</li>
<li><kbd>Backspace</kbd> to go back, close dialogs</li>
</ul>
</div>
<h2>Timeline</h2>
<div class="hotkey-group"> <div class="hotkey-group">
<ul> <ul>
<li><kbd>j</kbd> or <kbd></kbd> to activate the next toot</li> {@html globalHotkeysText}
<li><kbd>k</kbd> or <kbd></kbd> to activate the previous toot</li>
<li><kbd>.</kbd> to show more and scroll to top</li>
<li><kbd>o</kbd> to open</li>
<li><kbd>f</kbd> to favorite</li>
<li><kbd>b</kbd> to boost</li>
<li><kbd>r</kbd> to reply</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>
<li><kbd>p</kbd> to open the author's profile</li>
<li><kbd>l</kbd> to open the card's link in a new tab</li>
<li><kbd>x</kbd> to show or hide text behind content warning</li>
</ul> </ul>
</div> </div>
<h2>Media</h2> <h2>{intl.timeline}</h2>
<div class="hotkey-group"> <div class="hotkey-group">
<ul> <ul>
<li><kbd></kbd> / <kbd></kbd> to go to next or previous</li> {@html intl.timelineHotkeys}
</ul> </ul>
</div> </div>
`} {#if !$leftRightChangesFocus}
<h2>{intl.media}</h2>
<div class="hotkey-group">
<ul>
{@html intl.mediaHotkeys}
</ul>
</div>
{/if}
</div> </div>
<style> <style>
.shortcut-help-info.in-dialog { .shortcut-help-info.in-dialog {
@ -96,11 +61,17 @@
</style> </style>
<script> <script>
import { store } from '../_store/store' import { store } from '../_store/store'
import { formatIntl } from '../_utils/formatIntl'
export default { export default {
store: () => store, store: () => store,
data: () => ({ data: () => ({
inDialog: false inDialog: false
}) }),
computed: {
globalHotkeysText: ({ $leftRightChangesFocus }) => (
formatIntl('intl.globalHotkeys', { leftRightChangesFocus: $leftRightChangesFocus })
)
}
} }
</script> </script>

View file

@ -1,36 +0,0 @@
<!-- old browsers can't handle <use> very well -->
<svg
class={className}
{style}
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
{viewBox}
ref:svg>
{@html html}
</svg>
<script>
import { animate } from '../_utils/animate'
import { store } from '../_store/store'
export default {
data: () => ({
className: '',
style: '',
ariaLabel: ''
}),
store: () => store,
computed: {
svgData: ({ href }) => process.env.ALL_SVGS[href],
html: ({ svgData }) => svgData.html,
viewBox: ({ svgData }) => svgData.viewBox
},
methods: {
animate (animation) {
const { reduceMotion } = this.store.get()
if (animation && !reduceMotion) {
animate(this.refs.svg, animation)
}
}
}
}
</script>

Before

Width:  |  Height:  |  Size: 797 B

View file

@ -2,7 +2,8 @@
<ul> <ul>
{#each tabs as tab (tab.name)} {#each tabs as tab (tab.name)}
<li class="{currentTabName === tab.name ? 'current' : 'not-current'}"> <li class="{currentTabName === tab.name ? 'current' : 'not-current'}">
<a aria-label="{tab.label} { currentTabName === tab.name ? '(Current)' : ''}" <a aria-label={createAriaLabel(tab.label, tab.name, currentTabName)}
aria-current={tab.name === currentTabName}
class="focus-fix" class="focus-fix"
href={tab.href} href={tab.href}
rel="prefetch"> rel="prefetch">
@ -83,9 +84,19 @@
} }
</style> </style>
<script> <script>
import { formatIntl } from '../_utils/formatIntl'
export default { export default {
data: () => ({ data: () => ({
className: '' className: ''
}),
helpers: {
createAriaLabel (tabLabel, tabName, currentTabName) {
return formatIntl('intl.tabLabel', {
label: tabLabel,
current: tabName === currentTabName
}) })
} }
}
}
</script> </script>

View file

@ -1,8 +1,9 @@
<svelte:head> <svelte:head>
<title>{notificationsIndicator}{instanceIndicator} · {name}</title> <title>{title}</title>
</svelte:head> </svelte:head>
<script> <script>
import { store } from '../_store/store' import { store } from '../_store/store'
import { formatIntl } from '../_utils/formatIntl'
export default { export default {
data: () => ({ data: () => ({
@ -10,15 +11,18 @@
}), }),
store: () => store, store: () => store,
computed: { computed: {
instanceIndicator: ({ $isUserLoggedIn, $currentInstance, settingsPage }) => ( showInstanceName: ({ $isUserLoggedIn, settingsPage, $currentInstance }) => (
// If the user is not logged in, or if they're on a settings page (which !!($isUserLoggedIn && !settingsPage && $currentInstance)
// is more general than instance-specific), of if this is server-rendered, then
// show "Pinafore". Otherwise show the instance name.
`${($isUserLoggedIn && !settingsPage && $currentInstance) ? $currentInstance : 'Pinafore'}`
), ),
notificationsIndicator: ({ $hasNotifications, $numberOfNotifications }) => ( title: ({ showInstanceName, $currentInstance, $hasNotifications, $numberOfNotifications, name }) => {
$hasNotifications ? `(${$numberOfNotifications}) ` : '' return formatIntl('intl.pageTitle', {
) showInstanceName,
instanceName: $currentInstance,
hasNotifications: $hasNotifications,
count: $numberOfNotifications,
name
})
}
} }
} }
</script> </script>

View file

@ -9,7 +9,7 @@
id="pinnables" id="pinnables"
className="pinnable-button" className="pinnable-button"
checked={$pinnedPage === href} checked={$pinnedPage === href}
label="Pin {label}" label={pinLabel}
index={pinIndex} index={pinIndex}
on:click="onPinClick(event)" on:click="onPinClick(event)"
> >
@ -119,6 +119,7 @@
import { store } from '../../_store/store' import { store } from '../../_store/store'
import SvgIcon from '../SvgIcon.html' import SvgIcon from '../SvgIcon.html'
import RadioGroupButton from '../../_components/radio/RadioGroupButton.html' import RadioGroupButton from '../../_components/radio/RadioGroupButton.html'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
store: () => store, store: () => store,
@ -128,12 +129,13 @@
}), }),
computed: { computed: {
ariaLabel: ({ label, pinnable, $pinnedPage, href }) => { ariaLabel: ({ label, pinnable, $pinnedPage, href }) => {
let res = label return formatIntl('intl.pinLabel', {
if (pinnable) { label,
res += ' (' + ($pinnedPage === href ? 'Pinned page' : 'Unpinned page') + ')' pinnable,
} pinned: $pinnedPage === href
return res })
} },
pinLabel: ({ label }) => formatIntl('intl.pinPage', { label })
}, },
components: { components: {
SvgIcon, SvgIcon,

View file

@ -1,5 +1,5 @@
{#if realm === 'home'} {#if realm === 'home'}
<h1 class="sr-only">Compose status</h1> <h1 class="sr-only">{intl.composeStatus}</h1>
{/if} {/if}
<ComposeFileDrop {realm} > <ComposeFileDrop {realm} >
<div class="{computedClassName} {hideAndFadeIn}"> <div class="{computedClassName} {hideAndFadeIn}">

View file

@ -1,10 +1,10 @@
<div class="compose-box-button-halo {sticky ? 'compose-box-button-halo-sticky' : ''}"> <div class="compose-box-button-halo {sticky ? 'compose-box-button-halo-sticky' : ''}">
<button class="primary compose-box-button" <button class="primary compose-box-button"
{disabled} {disabled}
aria-label={sticky ? 'Compose' : 'Toot!'} aria-label={sticky ? '{intl.composeStatus}' : '{intl.postStatus}'}
on:click> on:click>
<span class={$postingStatus || sticky ? 'hidden' : ''}> <span class={$postingStatus || sticky ? 'hidden' : ''}>
Toot! {intl.postStatus}
</span> </span>
<div class="compose-box-button-spinner" <div class="compose-box-button-spinner"
aria-hidden="true"> aria-hidden="true">

View file

@ -1,7 +1,7 @@
<input class="content-warning-input" <input class="content-warning-input"
type="text" type="text"
placeholder="Content warning" placeholder="{intl.contentWarning}"
aria-label="Content warning" aria-label="{intl.contentWarning}"
bind:value=rawText bind:value=rawText
/> />
<style> <style>

View file

@ -1,8 +1,8 @@
<file-drop class="file-drop file-drop-realm-{realm}" accept={mediaAccept} ref:fileDrop > <file-drop class="file-drop file-drop-realm-{realm}" accept={mediaAccept} ref:fileDrop >
<div class="file-drop-info"> <div class="file-drop-info">
<div class="file-drop-info-text"> <div class="file-drop-info-text">
<span class="file-drop-info-text-valid">Drop to upload</span> <span class="file-drop-info-text-valid">{intl.dropToUpload}</span>
<span class="file-drop-info-text-invalid">Invalid file type</span> <span class="file-drop-info-text-invalid">{intl.invalidFileType}</span>
</div> </div>
</div> </div>
<slot></slot> <slot></slot>

View file

@ -1,7 +1,7 @@
<textarea <textarea
id="the-compose-box-input-{realm}" id="the-compose-box-input-{realm}"
class="compose-box-input compose-box-input-realm-{realm}" class="compose-box-input compose-box-input-realm-{realm}"
placeholder="What's on your mind?" placeholder="{intl.composeLabel}"
aria-describedby="compose-box-input-description-{realm}" aria-describedby="compose-box-input-description-{realm}"
aria-owns="compose-autosuggest-list-{realm}" aria-owns="compose-autosuggest-list-{realm}"
aria-expanded={autosuggestShownForThisInput} aria-expanded={autosuggestShownForThisInput}
@ -15,10 +15,10 @@
on:keydown="onKeydown(event)" on:keydown="onKeydown(event)"
></textarea> ></textarea>
<label for="the-compose-box-input-{realm}" class="sr-only"> <label for="the-compose-box-input-{realm}" class="sr-only">
What's on your mind? {intl.composeLabel}
</label> </label>
<span id="compose-box-input-description-{realm}" class="sr-only"> <span id="compose-box-input-description-{realm}" class="sr-only">
When autocomplete results are available, press up or down arrows and enter to select. {intl.autocompleteDescription}
</span> </span>
<style> <style>
.compose-box-input { .compose-box-input {

View file

@ -1,6 +1,6 @@
{#if media.length} {#if media.length}
<ul class="compose-media-container" <ul class="compose-media-container"
aria-label="Media uploads" aria-label="{intl.mediaUploads}"
style="grid-template-columns: repeat({media.length}, 1fr);" style="grid-template-columns: repeat({media.length}, 1fr);"
> >
{#each media as mediaItem, index} {#each media as mediaItem, index}

View file

@ -8,14 +8,14 @@
/> />
<div class="compose-media-buttons"> <div class="compose-media-buttons">
<button class="compose-media-button compose-media-focal-button" <button class="compose-media-button compose-media-focal-button"
aria-label="Edit" aria-label="{intl.edit}"
title="Edit" title="{intl.edit}"
on:click="onEdit()" > on:click="onEdit()" >
<SvgIcon className="compose-media-button-svg" href="#fa-pencil" /> <SvgIcon className="compose-media-button-svg" href="#fa-pencil" />
</button> </button>
<button class="compose-media-button compose-media-delete-button" <button class="compose-media-button compose-media-delete-button"
aria-label="Delete" aria-label="{intl.delete}"
title="Delete" title="{intl.delete}"
on:click="onDeleteMedia()" > on:click="onDeleteMedia()" >
<SvgIcon className="compose-media-button-svg" href="#fa-times" /> <SvgIcon className="compose-media-button-svg" href="#fa-times" />
</button> </button>
@ -23,12 +23,12 @@
<div class="compose-media-alt"> <div class="compose-media-alt">
<textarea id="compose-media-input-{uuid}" <textarea id="compose-media-input-{uuid}"
class="compose-media-alt-input" class="compose-media-alt-input"
placeholder="Description" placeholder="{intl.description}"
ref:textarea ref:textarea
bind:value=rawText bind:value=rawText
></textarea> ></textarea>
<label for="compose-media-input-{uuid}" class="sr-only"> <label for="compose-media-input-{uuid}" class="sr-only">
Describe for the visually impaired (image, video) or auditorily impaired (audio, video) {intl.descriptionLabel}
</label> </label>
</div> </div>
</li> </li>

View file

@ -3,7 +3,7 @@
<label> <label>
<input type="checkbox" bind:checked="rawChecked" {disabled} /> <input type="checkbox" bind:checked="rawChecked" {disabled} />
<span class="{disabled ? 'compose-sensitive-span-disabled' : ''}"> <span class="{disabled ? 'compose-sensitive-span-disabled' : ''}">
Mark media as sensitive {intl.markAsSensitive}
</span> </span>
</label> </label>
</div> </div>

View file

@ -1,14 +1,14 @@
<section class="compose-poll" aria-label="Create poll"> <section class="compose-poll" aria-label="{intl.createPoll}">
{#each poll.options as option, i} {#each poll.options as option, i}
<input id="poll-option-{realm}-{i}" <input id="poll-option-{realm}-{i}"
type="text" type="text"
maxlength="25" maxlength="25"
on:change="onChange(i)" on:change="onChange(i)"
placeholder="Choice {i + 1}" placeholder="{createLabel(i)}"
> >
<IconButton <IconButton
label="Remove choice {i + 1}" label="{createRemoveLabel(i)}"
href="#fa-times" href="#fa-times"
muted={true} muted={true}
on:click="onDeleteClick(i)" on:click="onDeleteClick(i)"
@ -21,13 +21,13 @@
> >
<label class="multiple-choice-label" <label class="multiple-choice-label"
for="poll-option-multiple-{realm}"> for="poll-option-multiple-{realm}">
Multiple choice {intl.multipleChoice}
</label> </label>
<Select className="poll-expiry-select" <Select className="poll-expiry-select"
options={pollExpiryOptions} options={pollExpiryOptions}
defaultValue={pollExpiryDefaultValue} defaultValue={pollExpiryDefaultValue}
on:change="onExpiryChange(event)" on:change="onExpiryChange(event)"
label="Poll duration" label="{intl.pollDuration}"
/> />
</div> </div>
<IconButton <IconButton
@ -80,6 +80,7 @@
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { POLL_EXPIRY_DEFAULT, POLL_EXPIRY_OPTIONS } from '../../_static/polls' import { POLL_EXPIRY_DEFAULT, POLL_EXPIRY_OPTIONS } from '../../_static/polls'
import { formatIntl } from '../../_utils/formatIntl'
function flushPollOptionsToDom (poll, realm) { function flushPollOptionsToDom (poll, realm) {
for (let i = 0; i < poll.options.length; i++) { for (let i = 0; i < poll.options.length; i++) {
@ -101,6 +102,14 @@
pollExpiryDefaultValue: POLL_EXPIRY_DEFAULT pollExpiryDefaultValue: POLL_EXPIRY_DEFAULT
}), }),
store: () => store, store: () => store,
helpers: {
createLabel (i) {
return formatIntl('intl.pollChoiceLabel', { index: i + 1 })
},
createRemoveLabel (i) {
return formatIntl('intl.removePollChoice', { index: i + 1 })
}
},
methods: { methods: {
onChange (i) { onChange (i) {
scheduleIdleTask(() => { scheduleIdleTask(() => {

View file

@ -2,22 +2,22 @@
<div class="compose-box-toolbar-items"> <div class="compose-box-toolbar-items">
<IconButton <IconButton
className="compose-toolbar-button" className="compose-toolbar-button"
label="Insert emoji" label="{intl.addEmoji}"
href="#fa-smile" href="#fa-smile"
on:click="onEmojiClick()" on:click="onEmojiClick()"
/> />
<IconButton <IconButton
className="compose-toolbar-button" className="compose-toolbar-button"
svgClassName={$uploadingMedia ? 'spin' : ''} svgClassName={$uploadingMedia ? 'spin' : ''}
label="Add media (images, video, audio)" label="{intl.addMedia}"
href={$uploadingMedia ? '#fa-spinner' : '#fa-camera'} href={$uploadingMedia ? '#fa-spinner' : '#fa-camera'}
on:click="onMediaClick()" on:click="onMediaClick()"
disabled={$uploadingMedia || (media.length === 4)} disabled={$uploadingMedia || (media.length === 4)}
/> />
<IconButton <IconButton
className="compose-toolbar-button" className="compose-toolbar-button"
label="Add poll" label="{intl.addPoll}"
pressedLabel="Remove poll" pressedLabel="{intl.removePoll}"
href="#fa-bar-chart" href="#fa-bar-chart"
on:click="onPollClick()" on:click="onPollClick()"
pressable={true} pressable={true}
@ -25,14 +25,14 @@
/> />
<IconButton <IconButton
className="compose-toolbar-button" className="compose-toolbar-button"
label="Adjust privacy (currently {postPrivacy.label})" label={postPrivacyLabel}
href={postPrivacy.icon} href={postPrivacy.icon}
on:click="onPostPrivacyClick()" on:click="onPostPrivacyClick()"
/> />
<IconButton <IconButton
className="compose-toolbar-button" className="compose-toolbar-button"
label="Add content warning" label="{intl.addContentWarning}"
pressedLabel="Remove content warning" pressedLabel="{intl.removeContentWarning}"
href="#fa-exclamation-triangle" href="#fa-exclamation-triangle"
on:click="onContentWarningClick()" on:click="onContentWarningClick()"
pressable={true} pressable={true}
@ -79,6 +79,7 @@
import { mediaAccept } from '../../_static/media' import { mediaAccept } from '../../_static/media'
import { enablePoll, disablePoll } from '../../_actions/composePoll' import { enablePoll, disablePoll } from '../../_actions/composePoll'
import { updateCustomEmojiForInstance } from '../../_actions/emoji' import { updateCustomEmojiForInstance } from '../../_actions/emoji'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
components: { components: {
@ -87,6 +88,11 @@
data: () => ({ data: () => ({
mediaAccept mediaAccept
}), }),
computed: {
postPrivacyLabel: ({ postPrivacy }) => (
formatIntl('intl.postPrivacyLabel', { label: postPrivacy.label })
)
},
store: () => store, store: () => store,
methods: { methods: {
async onEmojiClick () { async onEmojiClick () {

View file

@ -22,6 +22,7 @@ import { copyText } from '../../../_actions/copyText'
import { composeNewStatusMentioning } from '../../../_actions/mention' import { composeNewStatusMentioning } from '../../../_actions/mention'
import { toggleMute } from '../../../_actions/toggleMute' import { toggleMute } from '../../../_actions/toggleMute'
import { reportStatusOrAccount } from '../../../_actions/report' import { reportStatusOrAccount } from '../../../_actions/report'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
oncreate, oncreate,
@ -43,18 +44,22 @@ export default {
return '' return ''
} }
return (following || followRequested) return (following || followRequested)
? `Unfollow @${username}` ? formatIntl('intl.unfollowAccount', { account: `@${username}` })
: `Follow @${username}` : formatIntl('intl.followAccount', { account: `@${username}` })
}, },
followIcon: ({ following, followRequested }) => ( followIcon: ({ following, followRequested }) => (
following ? '#fa-user-times' : followRequested ? '#fa-hourglass' : '#fa-user-plus' following ? '#fa-user-times' : followRequested ? '#fa-hourglass' : '#fa-user-plus'
), ),
blockLabel: ({ blocking, username }) => ( blockLabel: ({ blocking, username }) => (
blocking ? `Unblock @${username}` : `Block @${username}` blocking
? formatIntl('intl.unblockAccount', { account: `@${username}` })
: formatIntl('intl.blockAccount', { account: `@${username}` })
), ),
blockIcon: ({ blocking }) => blocking ? '#fa-unlock' : '#fa-ban', blockIcon: ({ blocking }) => blocking ? '#fa-unlock' : '#fa-ban',
muteLabel: ({ muting, username }) => ( muteLabel: ({ muting, username }) => (
muting ? `Unmute @${username}` : `Mute @${username}` muting
? formatIntl('intl.unmuteAccount', { account: `@${username}` })
: formatIntl('intl.muteAccount', { account: `@${username}` })
), ),
muteIcon: ({ muting }) => muting ? '#fa-volume-up' : '#fa-volume-off', muteIcon: ({ muting }) => muting ? '#fa-volume-up' : '#fa-volume-off',
isUser: ({ accountId, verifyCredentialsId }) => accountId === verifyCredentialsId, isUser: ({ accountId, verifyCredentialsId }) => accountId === verifyCredentialsId,
@ -64,17 +69,19 @@ export default {
showingReblogs: ({ relationship }) => relationship ? relationship.showing_reblogs : true, showingReblogs: ({ relationship }) => relationship ? relationship.showing_reblogs : true,
showReblogsLabel: ({ showingReblogs, username }) => ( showReblogsLabel: ({ showingReblogs, username }) => (
showingReblogs showingReblogs
? `Hide boosts from @${username}` ? formatIntl('intl.hideReblogsFromAccount', { account: `@${username}` })
: `Show boosts from @${username}` : formatIntl('intl.showReblogsFromAccount', { account: `@${username}` })
), ),
domain: ({ acct }) => acct.split('@')[1], domain: ({ acct }) => acct.split('@')[1],
blockingDomain: ({ relationship }) => relationship && relationship.domain_blocking, blockingDomain: ({ relationship }) => relationship && relationship.domain_blocking,
blockDomainLabel: ({ blockingDomain, domain }) => ( blockDomainLabel: ({ blockingDomain, domain }) => (
blockingDomain blockingDomain
? `Unhide ${domain}` ? formatIntl('intl.showDomain', { domain })
: `Hide ${domain}` : formatIntl('intl.hideDomain', { domain })
),
reportLabel: ({ username }) => (
formatIntl('intl.reportAccount', { account: `@${username}` })
), ),
reportLabel: ({ username }) => `Report @${username}`,
items: ({ items: ({
blockLabel, blocking, blockIcon, muteLabel, muteIcon, blockLabel, blocking, blockIcon, muteLabel, muteIcon,
followLabel, followIcon, following, followRequested, followLabel, followIcon, following, followRequested,
@ -83,7 +90,7 @@ export default {
}) => ([ }) => ([
!isUser && { !isUser && {
key: 'mention', key: 'mention',
label: `Mention @${username}`, label: formatIntl('intl.mentionAccount', { account: `@${username}` }),
icon: '#fa-comments' icon: '#fa-comments'
}, },
!isUser && !blocking && { !isUser && !blocking && {
@ -118,7 +125,7 @@ export default {
}, },
{ {
key: 'copy', key: 'copy',
label: 'Copy link to account', label: 'intl.copyLinkToAccount',
icon: '#fa-link' icon: '#fa-link'
} }
].filter(Boolean)) ].filter(Boolean))

View file

@ -53,8 +53,8 @@
onPositive: undefined, onPositive: undefined,
onNegative: undefined, onNegative: undefined,
title: '', title: '',
positiveText: 'OK', positiveText: 'intl.okay',
negativeText: 'Cancel' negativeText: 'intl.cancel'
}), }),
methods: { methods: {
show, show,

View file

@ -55,7 +55,7 @@
onClick () { onClick () {
const { input } = this.refs const { input } = this.refs
copyFromInput(input) copyFromInput(input)
toast.say('Copied to clipboard') toast.say('intl.copiedToClipboard')
this.close() this.close()
}, },
onShow () { onShow () {

View file

@ -2,12 +2,12 @@
<textarea <textarea
id="the-media-alt-input-{realm}-{index}" id="the-media-alt-input-{realm}-{index}"
class="media-alt-input" class="media-alt-input"
placeholder="Describe for the visually impaired" placeholder="{intl.altLabel}"
ref:textarea ref:textarea
bind:value=rawText bind:value=rawText
></textarea> ></textarea>
<label for="the-media-alt-input-{realm}-{index}" class="sr-only"> <label for="the-media-alt-input-{realm}-{index}" class="sr-only">
Describe for the visually impaired {intl.altLabel}
</label> </label>
<LengthGauge <LengthGauge
{length} {length}
@ -107,6 +107,7 @@
import SvgIcon from '../../SvgIcon.html' import SvgIcon from '../../SvgIcon.html'
import { toast } from '../../toast/toast' import { toast } from '../../toast/toast'
import { getCachedMediaFile } from '../../../_utils/mediaUploadFileCache' import { getCachedMediaFile } from '../../../_utils/mediaUploadFileCache'
import { formatIntl } from '../../../_utils/formatIntl'
const updateRawTextInStore = throttleTimer(requestPostAnimationFrame) const updateRawTextInStore = throttleTimer(requestPostAnimationFrame)
@ -132,10 +133,10 @@
overLimit: ({ mediaAltCharLimit, length }) => length > mediaAltCharLimit, overLimit: ({ mediaAltCharLimit, length }) => length > mediaAltCharLimit,
url: ({ media, index }) => get(media, [index, 'data', 'url']), url: ({ media, index }) => get(media, [index, 'data', 'url']),
mediaId: ({ media, index }) => get(media, [index, 'data', 'id']), mediaId: ({ media, index }) => get(media, [index, 'data', 'id']),
extractButtonText: ({ extracting }) => extracting ? 'Extracting text…' : 'Extract text from image', extractButtonText: ({ extracting }) => extracting ? 'intl.extractingText' : 'intl.extractText',
extractButtonLabel: ({ extractButtonText, extractionProgress, extracting }) => { extractButtonLabel: ({ extractButtonText, extractionProgress, extracting }) => {
if (extracting) { if (extracting) {
return `Extracting text (${Math.round(extractionProgress)}% complete)…` return formatIntl('intl.extractingTextCompletion', { percent: Math.round(extractionProgress) })
} }
return extractButtonText return extractButtonText
} }
@ -210,9 +211,7 @@
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
/* no await */ toast.say( /* no await */ toast.say('intl.unableToExtractText')
'Unable to extract text. Ensure your instance supports cross-origin resource sharing (CORS) for images.'
)
} finally { } finally {
this.set({ extracting: false }) this.set({ extracting: false })
setTimeout(() => { setTimeout(() => {

View file

@ -39,13 +39,13 @@
<!-- Roughly based on https://www.w3.org/WAI/tutorials/carousels/functionality/ <!-- Roughly based on https://www.w3.org/WAI/tutorials/carousels/functionality/
Since this toolbar contains a mix of left/right/first/second/third/fourth buttons, Since this toolbar contains a mix of left/right/first/second/third/fourth buttons,
just list them and explicitly label the current one as "current." --> just list them and explicitly label the current one as "current." -->
<ul class="media-controls" aria-label="Navigate media items"> <ul class="media-controls" aria-label="{intl.navigateMedia}">
<li class="media-control"> <li class="media-control">
<IconButton <IconButton
className="media-control-button" className="media-control-button"
svgClassName="media-control-button-svg" svgClassName="media-control-button-svg"
disabled={scrolledItem === 0} disabled={scrolledItem === 0}
label="Show previous media" label="{intl.showPreviousMedia}"
href="#fa-angle-left" href="#fa-angle-left"
on:click="prev()" on:click="prev()"
/> />
@ -56,8 +56,8 @@
className="media-control-button" className="media-control-button"
svgClassName="media-control-button-svg" svgClassName="media-control-button-svg"
pressable={true} pressable={true}
label="Show {nth(i)} media" label="{createLabel(i, false)}"
pressedLabel="Show {nth(i)} media (current)" pressedLabel="{createLabel(i, true)}"
pressed={i === scrolledItem} pressed={i === scrolledItem}
href={i === scrolledItem ? '#fa-circle' : '#fa-circle-o'} href={i === scrolledItem ? '#fa-circle' : '#fa-circle-o'}
sameColorWhenPressed={true} sameColorWhenPressed={true}
@ -70,7 +70,7 @@
className="media-control-button" className="media-control-button"
svgClassName="media-control-button-svg" svgClassName="media-control-button-svg"
disabled={scrolledItem === length - 1} disabled={scrolledItem === length - 1}
label="Show next media" label="{intl.showNextMedia}"
href="#fa-angle-right" href="#fa-angle-right"
on:click="next()" on:click="next()"
/> />
@ -83,8 +83,8 @@
svgClassName="media-control-button-svg" svgClassName="media-control-button-svg"
pressable={true} pressable={true}
pressed={pinchZoomMode} pressed={pinchZoomMode}
label="Pinch-zoom mode" label="{intl.enterPinchZoom}"
pressedLabel="Exit pinch-zoom mode" pressedLabel="{intl.exitPinchZoom}"
href="#fa-search" href="#fa-search"
on:click="togglePinchZoomMode()" on:click="togglePinchZoomMode()"
/> />
@ -244,6 +244,7 @@
import { store } from '../../../_store/store' import { store } from '../../../_store/store'
import { intrinsicScale } from '../../../_thirdparty/intrinsic-scale/intrinsicScale' import { intrinsicScale } from '../../../_thirdparty/intrinsic-scale/intrinsicScale'
import { get } from '../../../_utils/lodash-lite' import { get } from '../../../_utils/lodash-lite'
import { formatIntl } from '../../../_utils/formatIntl'
// padding for .media-scroll-item-image-area // padding for .media-scroll-item-image-area
const IMAGE_AREA_PADDING = { const IMAGE_AREA_PADDING = {
@ -281,17 +282,8 @@
PinchZoomable PinchZoomable
}, },
helpers: { helpers: {
nth (i) { createLabel (i, current) {
switch (i) { return formatIntl('intl.showMedia', { index: i + 1, current })
case 0:
return 'first'
case 1:
return 'second'
case 2:
return 'third'
case 3:
return 'fourth'
}
} }
}, },
methods: { methods: {

View file

@ -20,7 +20,7 @@
</div> </div>
{#if type === 'image' || type === 'gifv'} {#if type === 'image' || type === 'gifv'}
<div class="media-edit-header-and-item media-edit-header-and-item-focal"> <div class="media-edit-header-and-item media-edit-header-and-item-focal">
<h2>Preview (focal point)</h2> <h2>{intl.previewFocalPoint}</h2>
<MediaFocalPointEditor <MediaFocalPointEditor
className="media-edit-item" className="media-edit-item"
{realm} {realm}

View file

@ -1,5 +1,5 @@
<form class="media-focal-point-container {className}" <form class="media-focal-point-container {className}"
aria-label="Enter the focal point (X, Y) for this media" aria-label="{intl.enterFocalPoint}"
on:resize="measure()" on:resize="measure()"
> >
<div class="media-focal-point-image-container" ref:container> <div class="media-focal-point-image-container" ref:container>

View file

@ -7,14 +7,14 @@
> >
<div class="mute-dialog"> <div class="mute-dialog">
<p> <p>
Mute @{account.acct} ? {confirmMuteText}
</p> </p>
<div class="mute-dialog-form"> <div class="mute-dialog-form">
<input type="checkbox" <input type="checkbox"
id="mute-notifications" id="mute-notifications"
name="mute-notifications" name="mute-notifications"
bind:checked="muteNotifications"> bind:checked="muteNotifications">
<label for="mute-notifications">Mute notifications as well</label> <label for="mute-notifications">{intl.muteNotifications}</label>
</div> </div>
</div> </div>
</GenericConfirmationDialog> </GenericConfirmationDialog>
@ -32,14 +32,20 @@
import { close } from '../helpers/closeDialog' import { close } from '../helpers/closeDialog'
import { oncreate } from '../helpers/onCreateDialog' import { oncreate } from '../helpers/onCreateDialog'
import { setAccountMuted } from '../../../_actions/mute' import { setAccountMuted } from '../../../_actions/mute'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
oncreate, oncreate,
data: () => ({ data: () => ({
positiveText: 'Mute', positiveText: 'intl.mute',
title: '', title: '',
muteNotifications: true muteNotifications: true
}), }),
computed: {
confirmMuteText: ({ account }) => (
formatIntl('intl.muteAccountConfirm', { account: `@${account.acct}` })
)
},
methods: { methods: {
show, show,
close, close,

View file

@ -5,14 +5,14 @@
<IconButton <IconButton
className="pinch-zoom-button pinch-zoom-button-zoom-out" className="pinch-zoom-button pinch-zoom-button-zoom-out"
muted={true} muted={true}
label="Zoom out" label="{intl.zoomOut}"
href="#fa-search-minus" href="#fa-search-minus"
on:click="zoomOut()" on:click="zoomOut()"
/> />
<IconButton <IconButton
className="pinch-zoom-button pinch-zoom-button-zoom-in" className="pinch-zoom-button pinch-zoom-button-zoom-in"
muted={true} muted={true}
label="Zoom in" label="{intl.zoomIn}"
href="#fa-search-plus" href="#fa-search-plus"
on:click="zoomIn()" on:click="zoomIn()"
/> />

View file

@ -31,20 +31,20 @@
{/if} {/if}
</div> </div>
<div class="report-info"> <div class="report-info">
<p>You are reporting @{account.acct} to the moderators of {$currentInstance}.</p> <p>{reportingLabel}</p>
<label class="sr-only" id="comments-label">Additional comments</label> <label class="sr-only" id="comments-label">{intl.additionalComments}</label>
<textarea bind:value="comment" <textarea bind:value="comment"
placeholder="Additional comments" placeholder="{intl.additionalComments}"
aria-labelledby="comments-label" aria-labelledby="comments-label"
maxlength="1000"></textarea> maxlength="1000"></textarea>
{#if remoteInstance} {#if remoteInstance}
<p>Forward to the moderators of {remoteInstance} as well?</p> <p>{forwardDescription}</p>
<input type="checkbox" <input type="checkbox"
id="report-forward" id="report-forward"
name="report-forward" name="report-forward"
bind:checked="forward"> bind:checked="forward">
<label for="report-forward"> <label for="report-forward">
Forward to {remoteInstance} {forwardLabel}
</label> </label>
{/if} {/if}
</div> </div>
@ -164,6 +164,7 @@
import { toast } from '../../toast/toast' import { toast } from '../../toast/toast'
import { store } from '../../../_store/store' import { store } from '../../../_store/store'
import { reportStatuses } from '../../../_actions/reportStatuses' import { reportStatuses } from '../../../_actions/reportStatuses'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
async oncreate () { async oncreate () {
@ -178,7 +179,7 @@
this.set({ recentStatuses }) this.set({ recentStatuses })
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toast.say('Unable to load recent statuses: ' + (err.message || '')) /* no await */ toast.say(formatIntl('intl.unableToLoadStatuses', { error: (err.message || '') }))
} finally { } finally {
this.set({ loading: false }) this.set({ loading: false })
} }
@ -187,7 +188,7 @@
data: () => ({ data: () => ({
account: undefined, account: undefined,
status: undefined, status: undefined,
positiveText: 'Report', positiveText: 'intl.report',
reportMap: {}, reportMap: {},
recentStatuses: [], recentStatuses: [],
loading: true, loading: true,
@ -198,14 +199,30 @@
displayStatuses: ({ statuses, reportMap }) => ( displayStatuses: ({ statuses, reportMap }) => (
statuses.map(status => ({ statuses.map(status => ({
id: status.id, id: status.id,
text: statusHtmlToPlainText(status.content, status.mentions) || '(No content)', text: statusHtmlToPlainText(status.content, status.mentions) || 'intl.noContent',
report: reportMap[status.id] report: reportMap[status.id]
})) }))
), ),
statuses: ({ status, recentStatuses }) => ( statuses: ({ status, recentStatuses }) => (
[status].concat((recentStatuses || []).filter(({ id }) => (!status || id !== status.id))).filter(Boolean) [status].concat((recentStatuses || []).filter(({ id }) => (!status || id !== status.id))).filter(Boolean)
), ),
remoteInstance: ({ account }) => account.acct.split('@')[1] remoteInstance: ({ account }) => account.acct.split('@')[1],
reportingLabel: ({ account, $currentInstance }) => (
formatIntl('intl.reportingLabel', {
account: `@${account.acct}`,
instance: $currentInstance
})
),
forwardDescription: ({ remoteInstance }) => (
formatIntl('intl.forwardDescription', {
instance: remoteInstance
})
),
forwardLabel: ({ remoteInstance }) => (
formatIntl('intl.forwardLabel', {
instance: remoteInstance
})
)
}, },
methods: { methods: {
show, show,
@ -219,7 +236,7 @@
const { displayStatuses, account, comment, forward, reportMap } = this.get() const { displayStatuses, account, comment, forward, reportMap } = this.get()
const statusIds = displayStatuses.map(({ id }) => id).filter(id => reportMap[id]) const statusIds = displayStatuses.map(({ id }) => id).filter(id => reportMap[id])
if (!statusIds.length) { if (!statusIds.length) {
toast.say('No toots to report.') toast.say('intl.noStatuses')
} else { } else {
await reportStatuses(account, statusIds, comment, forward) await reportStatuses(account, statusIds, comment, forward)
} }

View file

@ -5,7 +5,7 @@
muted="true" muted="true"
className="shortcut-help-modal-dialog"> className="shortcut-help-modal-dialog">
<h1>Hotkeys</h1> <h1>{intl.hotkeys}</h1>
<ShortcutHelpInfo inDialog={true} /> <ShortcutHelpInfo inDialog={true} />

View file

@ -25,6 +25,7 @@ import { deleteAndRedraft } from '../../../_actions/deleteAndRedraft'
import { shareStatus } from '../../../_actions/share' import { shareStatus } from '../../../_actions/share'
import { toggleMute } from '../../../_actions/toggleMute' import { toggleMute } from '../../../_actions/toggleMute'
import { reportStatusOrAccount } from '../../../_actions/report' import { reportStatusOrAccount } from '../../../_actions/report'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
oncreate, oncreate,
@ -57,33 +58,41 @@ export default {
return '' return ''
} }
return (following || followRequested) return (following || followRequested)
? `Unfollow @${username}` ? formatIntl('intl.unfollowAccount', { account: `@${username}` })
: `Follow @${username}` : formatIntl('intl.followAccount', { account: `@${username}` })
}, },
followIcon: ({ following, followRequested }) => ( followIcon: ({ following, followRequested }) => (
following ? '#fa-user-times' : followRequested ? '#fa-hourglass' : '#fa-user-plus' following ? '#fa-user-times' : followRequested ? '#fa-hourglass' : '#fa-user-plus'
), ),
blockLabel: ({ blocking, username }) => ( blockLabel: ({ blocking, username }) => (
blocking ? `Unblock @${username}` : `Block @${username}` blocking
? formatIntl('intl.unblockAccount', { account: `@${username}` })
: formatIntl('intl.blockAccount', { account: `@${username}` })
), ),
blockIcon: ({ blocking }) => blocking ? '#fa-unlock' : '#fa-ban', blockIcon: ({ blocking }) => blocking ? '#fa-unlock' : '#fa-ban',
muteLabel: ({ muting, username }) => ( muteLabel: ({ muting, username }) => (
muting ? `Unmute @${username}` : `Mute @${username}` muting
? formatIntl('intl.unmuteAccount', { account: `@${username}` })
: formatIntl('intl.muteAccount', { account: `@${username}` })
), ),
muteIcon: ({ muting }) => muting ? '#fa-volume-up' : '#fa-volume-off', muteIcon: ({ muting }) => muting ? '#fa-volume-up' : '#fa-volume-off',
isUser: ({ accountId, verifyCredentialsId }) => accountId === verifyCredentialsId, isUser: ({ accountId, verifyCredentialsId }) => accountId === verifyCredentialsId,
// //
// end copypasta (StatusOptionsDialog.html / AccountProfileOptionsDialog.html) // end copypasta (StatusOptionsDialog.html / AccountProfileOptionsDialog.html)
// //
pinLabel: ({ pinned, isUser }) => isUser ? (pinned ? 'Unpin from profile' : 'Pin to profile') : '', pinLabel: ({ pinned, isUser }) => isUser ? (pinned ? 'intl.unpinFromProfile' : 'intl.pinToProfile') : '',
visibility: ({ status }) => status.visibility, visibility: ({ status }) => status.visibility,
mentions: ({ status }) => status.mentions || [], mentions: ({ status }) => status.mentions || [],
mentionsUser: ({ mentions, verifyCredentialsId }) => !!mentions.find(_ => _.id === verifyCredentialsId), mentionsUser: ({ mentions, verifyCredentialsId }) => !!mentions.find(_ => _.id === verifyCredentialsId),
mutingConversation: ({ status }) => !!status.muted, mutingConversation: ({ status }) => !!status.muted,
muteConversationLabel: ({ mutingConversation }) => mutingConversation ? 'Unmute conversation' : 'Mute conversation', muteConversationLabel: ({ mutingConversation }) => (
mutingConversation
? 'intl.unmuteConversation'
: 'intl.muteConversation'
),
muteConversationIcon: ({ mutingConversation }) => mutingConversation ? '#fa-volume-up' : '#fa-volume-off', muteConversationIcon: ({ mutingConversation }) => mutingConversation ? '#fa-volume-up' : '#fa-volume-off',
isPublicOrUnlisted: ({ visibility }) => visibility === 'public' || visibility === 'unlisted', isPublicOrUnlisted: ({ visibility }) => visibility === 'public' || visibility === 'unlisted',
bookmarkLabel: ({ status }) => status.bookmarked ? 'Unbookmark toot' : 'Bookmark toot', bookmarkLabel: ({ status }) => status.bookmarked ? 'intl.unbookmarkStatus' : 'intl.bookmarkStatus',
items: ({ items: ({
blockLabel, blocking, blockIcon, muteLabel, muteIcon, followLabel, followIcon, blockLabel, blocking, blockIcon, muteLabel, muteIcon, followLabel, followIcon,
following, followRequested, pinLabel, isUser, visibility, mentionsUser, mutingConversation, following, followRequested, pinLabel, isUser, visibility, mentionsUser, mutingConversation,
@ -91,7 +100,7 @@ export default {
}) => ([ }) => ([
isUser && { isUser && {
key: 'delete', key: 'delete',
label: 'Delete', label: 'intl.delete',
icon: '#fa-trash' icon: '#fa-trash'
}, },
isPublicOrUnlisted && isUser && { isPublicOrUnlisted && isUser && {
@ -121,12 +130,12 @@ export default {
}, },
isUser && { isUser && {
key: 'redraft', key: 'redraft',
label: 'Delete and redraft', label: 'intl.deleteAndRedraft',
icon: '#fa-pencil' icon: '#fa-pencil'
}, },
!isUser && { !isUser && {
key: 'report', key: 'report',
label: 'Report toot', label: 'intl.reportStatus',
icon: '#fa-flag' icon: '#fa-flag'
}, },
{ {
@ -136,12 +145,12 @@ export default {
}, },
isPublicOrUnlisted && supportsWebShare && { isPublicOrUnlisted && supportsWebShare && {
key: 'share', key: 'share',
label: 'Share toot', label: 'intl.shareStatus',
icon: '#fa-share-square-o' icon: '#fa-share-square-o'
}, },
isPublicOrUnlisted && { isPublicOrUnlisted && {
key: 'copy', key: 'copy',
label: 'Copy link to toot', label: 'intl.copyLinkToStatus',
icon: '#fa-link' icon: '#fa-link'
} }
].filter(Boolean)) ].filter(Boolean))

View file

@ -1,4 +1,4 @@
<h1 class="sr-only">Profile for {accountName}</h1> <h1 class="sr-only">{profileForAccount}</h1>
{#if moved} {#if moved}
<AccountProfileMovedBanner {account} /> <AccountProfileMovedBanner {account} />
{/if} {/if}
@ -118,6 +118,7 @@
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { addEmojiTooltips } from '../../_utils/addEmojiTooltips' import { addEmojiTooltips } from '../../_utils/addEmojiTooltips'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
oncreate () { oncreate () {
@ -134,7 +135,10 @@
moved && 'moved', moved && 'moved',
headerImageIsMissing && 'header-image-is-missing', headerImageIsMissing && 'header-image-is-missing',
$underlineLinks && 'underline-links' $underlineLinks && 'underline-links'
)) )),
profileForAccount: ({ accountName }) => (
formatIntl('intl.profileForAccount', { account: accountName })
)
}, },
components: { components: {
AccountProfileHeader, AccountProfileHeader,

View file

@ -1,8 +1,8 @@
<h2 class="sr-only">Stats and more options</h2> <h2 class="sr-only">{intl.statisticsAndMoreOptions}</h2>
<div class="account-profile-details"> <div class="account-profile-details">
<div class="account-profile-details-item"> <div class="account-profile-details-item">
<span class="account-profile-details-item-title"> <span class="account-profile-details-item-title">
Toots {intl.statuses}
</span> </span>
<span class="account-profile-details-item-datum"> <span class="account-profile-details-item-datum">
{numStatusesDisplay} {numStatusesDisplay}
@ -14,7 +14,7 @@
rel="prefetch" rel="prefetch"
> >
<span class="account-profile-details-item-title"> <span class="account-profile-details-item-title">
Follows {intl.follows}
</span> </span>
<span class="account-profile-details-item-datum"> <span class="account-profile-details-item-datum">
{numFollowingDisplay} {numFollowingDisplay}
@ -26,7 +26,7 @@
rel="prefetch" rel="prefetch"
> >
<span class="account-profile-details-item-title"> <span class="account-profile-details-item-title">
Followers {intl.followers}
</span> </span>
<span class="account-profile-details-item-datum"> <span class="account-profile-details-item-datum">
{numFollowersDisplay} {numFollowersDisplay}
@ -36,7 +36,7 @@
{#if account && verifyCredentials && account.id !== verifyCredentials.id} {#if account && verifyCredentials && account.id !== verifyCredentials.id}
<div class="account-profile-more-options"> <div class="account-profile-more-options">
<IconButton <IconButton
label="More options" label="{intl.moreOptions}"
href="#fa-bars" href="#fa-bars"
muted="true" muted="true"
on:click="onMoreOptionsClick()" on:click="onMoreOptionsClick()"
@ -124,8 +124,10 @@
<script> <script>
import IconButton from '../IconButton.html' import IconButton from '../IconButton.html'
import { importShowAccountProfileOptionsDialog } from '../dialog/asyncDialogs/importShowAccountProfileOptionsDialog.js' import { importShowAccountProfileOptionsDialog } from '../dialog/asyncDialogs/importShowAccountProfileOptionsDialog.js'
import { LOCALE } from '../../_static/intl'
import { formatIntl } from '../../_utils/formatIntl'
const numberFormat = new Intl.NumberFormat('en-US') const numberFormat = new Intl.NumberFormat(LOCALE)
export default { export default {
computed: { computed: {
@ -140,8 +142,12 @@
} }
return numberFormat.format(numFollowers) return numberFormat.format(numFollowers)
}, },
followersLabel: ({ numFollowers }) => `Followed by ${numFollowers}`, followersLabel: ({ numFollowers }) => (
followingLabel: ({ numFollowing }) => `Follows ${numFollowing}` formatIntl('intl.followersLabel', { count: numFollowers })
),
followingLabel: ({ numFollowing }) => (
formatIntl('intl.followingLabel', { count: numFollowing })
)
}, },
methods: { methods: {
async onMoreOptionsClick () { async onMoreOptionsClick () {

View file

@ -1,5 +1,5 @@
<TabSet <TabSet
label="Filters" label="{intl.filters}"
currentTabName={filter} currentTabName={filter}
{tabs} {tabs}
className="account-profile-filters" className="account-profile-filters"

View file

@ -40,6 +40,7 @@
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { setAccountFollowed } from '../../_actions/follow' import { setAccountFollowed } from '../../_actions/follow'
import { setAccountBlocked } from '../../_actions/block' import { setAccountBlocked } from '../../_actions/block'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
methods: { methods: {
@ -88,18 +89,15 @@
followRequested: ({ relationship }) => { followRequested: ({ relationship }) => {
return relationship && relationship.requested return relationship && relationship.requested
}, },
labelExtraText: ({ blocking, following, followRequested }) => { requested: ({ following, followRequested }) => !following && followRequested,
if (!blocking && !following && followRequested) { label: ({ blocking, requested }) => {
return ' (follow requested)' if (blocking) {
} else { return 'intl.unblock'
return ''
} }
return formatIntl('intl.followLabel', { requested })
}, },
label: ({ blocking, labelExtraText }) => { pressedLabel: ({ requested }) => {
return (blocking ? 'Unblock' : 'Follow') + labelExtraText return formatIntl('intl.unfollowLabel', { requested })
},
pressedLabel: ({ labelExtraText }) => {
return 'Unfollow' + labelExtraText
}, },
href: ({ blocking, following, followRequested }) => { href: ({ blocking, following, followRequested }) => {
if (blocking) { if (blocking) {

View file

@ -1,7 +1,7 @@
<h2 class="sr-only">Name and following</h2> <h2 class="sr-only">{intl.nameAndFollowing}</h2>
<div class="account-profile-avatar"> <div class="account-profile-avatar">
<button class="account-profile-avatar-button" <button class="account-profile-avatar-button"
aria-label="Click to see avatar" aria-label="{intl.clickToSeeAvatar}"
on:click="onAvatarClick()" > on:click="onAvatarClick()" >
<Avatar {account} size={avatarSize} /> <Avatar {account} size={avatarSize} />
</button> </button>
@ -12,7 +12,7 @@
href={account.url} href={account.url}
showIcon="true" showIcon="true"
normalIconColor="true" normalIconColor="true"
ariaLabel="{accessibleName} (opens in new window)"> ariaLabel={externalLinkLabel}>
<AccountDisplayName {account} /> <AccountDisplayName {account} />
</ExternalLink> </ExternalLink>
</div> </div>
@ -24,16 +24,16 @@
</div> </div>
<div class="account-profile-followed-by"> <div class="account-profile-followed-by">
{#if relationship && relationship.blocking} {#if relationship && relationship.blocking}
<span class="account-profile-followed-by-span">Blocked</span> <span class="account-profile-followed-by-span">{intl.blocked}</span>
{/if} {/if}
{#if relationship && relationship.domain_blocking} {#if relationship && relationship.domain_blocking}
<span class="account-profile-followed-by-span">Domain hidden</span> <span class="account-profile-followed-by-span">{intl.domainHidden}</span>
{/if} {/if}
{#if relationship && relationship.muting} {#if relationship && relationship.muting}
<span class="account-profile-followed-by-span">Muted</span> <span class="account-profile-followed-by-span">{intl.muted}</span>
{/if} {/if}
{#if relationship && relationship.followed_by} {#if relationship && relationship.followed_by}
<span class="account-profile-followed-by-span">Follows you</span> <span class="account-profile-followed-by-span">{intl.followsYou}</span>
{/if} {/if}
</div> </div>
<style> <style>
@ -126,6 +126,7 @@
import Label from '../Label.html' import Label from '../Label.html'
import { importShowMediaDialog } from '../dialog/asyncDialogs/importShowMediaDialog.js' import { importShowMediaDialog } from '../dialog/asyncDialogs/importShowMediaDialog.js'
import { getImageNativeDimensions } from '../../_utils/getImageNativeDimensions' import { getImageNativeDimensions } from '../../_utils/getImageNativeDimensions'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
store: () => store, store: () => store,
@ -141,6 +142,9 @@
label: ({ bot }) => bot ? 'bot' : '', label: ({ bot }) => bot ? 'bot' : '',
avatarSize: ({ $isVeryTinyMobileSize, $isTinyMobileSize }) => ( avatarSize: ({ $isVeryTinyMobileSize, $isTinyMobileSize }) => (
$isVeryTinyMobileSize ? 'small' : $isTinyMobileSize ? 'medium' : 'big' $isVeryTinyMobileSize ? 'small' : $isTinyMobileSize ? 'medium' : 'big'
),
externalLinkLabel: ({ accessibleName }) => (
formatIntl('intl.opensInNewWindow', { label: accessibleName })
) )
}, },
methods: { methods: {
@ -155,7 +159,7 @@
const { width, height } = nativeDimensions const { width, height } = nativeDimensions
const mediaAttachments = [ const mediaAttachments = [
{ {
description: `Avatar for ${displayName || username}`, description: formatIntl('intl.avatarForAccount', { account: displayName || username }),
type: 'image', type: 'image',
preview_url: avatarStatic, preview_url: avatarStatic,
url: avatar, url: avatar,

View file

@ -1,5 +1,5 @@
{#if massagedFields.length} {#if massagedFields.length}
<h2 class="sr-only">Fields</h2> <h2 class="sr-only">{intl.fields}</h2>
<div class="account-profile-meta"> <div class="account-profile-meta">
<div class="account-profile-meta-border"></div> <div class="account-profile-meta-border"></div>
{#each massagedFields as field, i} {#each massagedFields as field, i}

View file

@ -2,7 +2,7 @@
<Avatar className="from-avatar" size="extra-small" {account} /> <Avatar className="from-avatar" size="extra-small" {account} />
<div class="moved-label"> <div class="moved-label">
<SvgIcon className="moved-svg" href="#fa-suitcase" /> <SvgIcon className="moved-svg" href="#fa-suitcase" />
{accessibleName} has moved: {hasMovedLabel}
</div> </div>
<a class="moved-avatar" href="/accounts/{moved.id}"> <a class="moved-avatar" href="/accounts/{moved.id}">
<Avatar account={moved} /> <Avatar account={moved} />
@ -63,6 +63,7 @@
import { removeEmoji } from '../../_utils/removeEmoji' import { removeEmoji } from '../../_utils/removeEmoji'
import Avatar from '../Avatar.html' import Avatar from '../Avatar.html'
import SvgIcon from '../SvgIcon.html' import SvgIcon from '../SvgIcon.html'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
computed: { computed: {
@ -80,7 +81,10 @@
return $omitEmojiInDisplayNames return $omitEmojiInDisplayNames
? removeEmoji(movedDisplayName, movedEmojis) || movedDisplayName ? removeEmoji(movedDisplayName, movedEmojis) || movedDisplayName
: movedDisplayName : movedDisplayName
} },
hasMovedLabel: ({ accessibleName }) => (
formatIntl('intl.accountHasMoved', { account: accessibleName })
)
}, },
components: { components: {
Avatar, Avatar,

View file

@ -1,4 +1,4 @@
<h2 class="sr-only">Description</h2> <h2 class="sr-only">{intl.description}</h2>
<div class="account-profile-note"> <div class="account-profile-note">
{@html massagedNote} {@html massagedNote}
</div> </div>

View file

@ -1,6 +1,6 @@
{#if $isUserLoggedIn} {#if $isUserLoggedIn}
<TimelinePage {timeline} > <TimelinePage {timeline} >
<DynamicPageBanner title="" ariaTitle="Profile page for {accountName}"/> <DynamicPageBanner title="" {ariaTitle} />
{#if $currentAccountProfile && $currentVerifyCredentials} {#if $currentAccountProfile && $currentVerifyCredentials}
<AccountProfile account={$currentAccountProfile} <AccountProfile account={$currentAccountProfile}
relationship={$currentAccountRelationship} relationship={$currentAccountRelationship}
@ -15,9 +15,9 @@
{:else} {:else}
<HiddenFromSSR> <HiddenFromSSR>
<FreeTextLayout> <FreeTextLayout>
<h1>Profile</h1> <h1>{intl.profile}</h1>
<p>A user timeline will appear here when logged in.</p> <p>{intl.profileNotLoggedIn}</p>
</FreeTextLayout> </FreeTextLayout>
</HiddenFromSSR> </HiddenFromSSR>
{/if} {/if}
@ -30,6 +30,7 @@
import { updateProfileAndRelationship, clearProfileAndRelationship } from '../../_actions/accounts' import { updateProfileAndRelationship, clearProfileAndRelationship } from '../../_actions/accounts'
import AccountProfile from './AccountProfile.html' import AccountProfile from './AccountProfile.html'
import PinnedStatuses from '../timeline/PinnedStatuses.html' import PinnedStatuses from '../timeline/PinnedStatuses.html'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
oncreate () { oncreate () {
@ -50,6 +51,9 @@
}, },
timeline: ({ accountId, filter }) => ( timeline: ({ accountId, filter }) => (
`account/${accountId}` + (filter ? `/${filter}` : '') `account/${accountId}` + (filter ? `/${filter}` : '')
),
ariaTitle: ({ accountName }) => (
formatIntl('intl.profilePageForAccount', { account: accountName })
) )
}, },
components: { components: {

View file

@ -1,14 +1,14 @@
<form class="search-input-form" on:submit="onSubmit(event)"> <form class="search-input-form" on:submit="onSubmit(event)">
<label class="sr-only" for="the-search-input">{intl.search}</label>
<div class="search-input-wrapper"> <div class="search-input-wrapper">
<input id="the-search-input" <input id="the-search-input"
type="search" type="search"
class="search-input" class="search-input"
placeholder="Search" placeholder="{intl.search}"
aria-label="Search input"
required required
bind:value="$queryInSearch"> bind:value="$queryInSearch">
</div> </div>
<button type="submit" class="primary search-button" aria-label="Search" disabled={$searchLoading}> <button type="submit" class="primary search-button" aria-label="{intl.search}" disabled={$searchLoading}>
<SvgIcon className="search-button-svg" href="#fa-search" /> <SvgIcon className="search-button-svg" href="#fa-search" />
</button> </button>
</form> </form>

View file

@ -48,11 +48,11 @@
store: () => store, store: () => store,
computed: { computed: {
navItemLabels: ({ $isUserLoggedIn }) => ({ navItemLabels: ({ $isUserLoggedIn }) => ({
settings: 'Settings', settings: 'intl.settings',
'settings/about': 'About Pinafore', 'settings/about': 'intl.aboutApp',
'settings/general': 'General', 'settings/general': 'intl.general',
'settings/instances': 'Instances', 'settings/instances': 'intl.instances',
'settings/instances/add': $isUserLoggedIn ? 'Add instance' : 'Log in' 'settings/instances/add': $isUserLoggedIn ? 'intl.addInstance' : 'intl.logIn'
}), }),
navItems: ({ page, navItemLabels }) => { navItems: ({ page, navItemLabels }) => {
const res = [] const res = []

View file

@ -15,10 +15,18 @@
} }
</style> </style>
<script> <script>
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
computed: { computed: {
className: ({ page, name }) => page === name ? 'selected' : '', className: ({ page, name }) => page === name ? 'selected' : '',
ariaLabel: ({ page, name, label }) => page === name ? `${label} (current page)` : label ariaLabel: ({ page, name, label }) => (
formatIntl('intl.navItemLabel', {
label,
selected: page === name,
name
})
)
} }
} }
</script> </script>

View file

@ -1,7 +1,7 @@
<GenericInstanceSettings <GenericInstanceSettings
{instanceName} {instanceName}
{options} {options}
label="Home timeline filter settings" label="{intl.homeTimelineFilterSettings}"
/> />
<script> <script>
import GenericInstanceSettings from './GenericInstanceSettings.html' import GenericInstanceSettings from './GenericInstanceSettings.html'
@ -12,12 +12,12 @@
options: [ options: [
{ {
key: HOME_REBLOGS, key: HOME_REBLOGS,
label: 'Show boosts', label: 'intl.showReblogs',
defaultValue: true defaultValue: true
}, },
{ {
key: HOME_REPLIES, key: HOME_REPLIES,
label: 'Show replies', label: 'intl.showReplies',
defaultValue: true defaultValue: true
} }
] ]

View file

@ -1,11 +1,11 @@
<form class="instance-actions" aria-label="Switch to or log out of this instance"> <form class="instance-actions" aria-label="{intl.switchOrLogOut}">
{#if $loggedInInstancesInOrder.length > 1 && $currentInstance !== instanceName} {#if $loggedInInstancesInOrder.length > 1 && $currentInstance !== instanceName}
<button class="primary" <button class="primary"
on:click="onSwitchToThisInstance(event)"> on:click="onSwitchToThisInstance(event)">
Switch to this instance {intl.switchTo}
</button> </button>
{/if} {/if}
<button on:click="onLogOut(event)">Log out</button> <button on:click="onLogOut(event)">{intl.logOut}</button>
</form> </form>
<style> <style>
.instance-actions { .instance-actions {
@ -23,6 +23,7 @@
import { store } from '../../../_store/store' import { store } from '../../../_store/store'
import { importShowTextConfirmationDialog } from '../../dialog/asyncDialogs/importShowTextConfirmationDialog.js' import { importShowTextConfirmationDialog } from '../../dialog/asyncDialogs/importShowTextConfirmationDialog.js'
import { switchToInstance, logOutOfInstance } from '../../../_actions/instances' import { switchToInstance, logOutOfInstance } from '../../../_actions/instances'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
store: () => store, store: () => store,
@ -38,7 +39,7 @@
const showTextConfirmationDialog = await importShowTextConfirmationDialog() const showTextConfirmationDialog = await importShowTextConfirmationDialog()
showTextConfirmationDialog({ showTextConfirmationDialog({
text: `Log out of ${instanceName}?` text: formatIntl('intl.logOutOfInstanceConfirm', { instance: instanceName })
}).on('positive', () => { }).on('positive', () => {
// TODO: dumb timing hack because the modal navigates back while we're trying to navigate forward // TODO: dumb timing hack because the modal navigates back while we're trying to navigate forward
setTimeout(() => { setTimeout(() => {

View file

@ -1,7 +1,7 @@
<GenericInstanceSettings <GenericInstanceSettings
{instanceName} {instanceName}
{options} {options}
label="Notification filter settings" label="{intl.notificationFilterSettings}"
/> />
<script> <script>
import GenericInstanceSettings from './GenericInstanceSettings.html' import GenericInstanceSettings from './GenericInstanceSettings.html'
@ -18,27 +18,27 @@
options: [ options: [
{ {
key: NOTIFICATION_FOLLOWS, key: NOTIFICATION_FOLLOWS,
label: 'New followers', label: 'intl.newFollowers',
defaultValue: true defaultValue: true
}, },
{ {
key: NOTIFICATION_FAVORITES, key: NOTIFICATION_FAVORITES,
label: 'Favorites', label: 'intl.favorites',
defaultValue: true defaultValue: true
}, },
{ {
key: NOTIFICATION_REBLOGS, key: NOTIFICATION_REBLOGS,
label: 'Boosts', label: 'intl.reblogs',
defaultValue: true defaultValue: true
}, },
{ {
key: NOTIFICATION_MENTIONS, key: NOTIFICATION_MENTIONS,
label: 'Mentions', label: 'intl.mentions',
defaultValue: true defaultValue: true
}, },
{ {
key: NOTIFICATION_POLLS, key: NOTIFICATION_POLLS,
label: 'Poll results', label: 'intl.pollResults',
defaultValue: true defaultValue: true
} }
] ]

View file

@ -1,15 +1,15 @@
<div class="push-notifications"> <div class="push-notifications">
{#if pushNotificationsSupport === false} {#if pushNotificationsSupport === false}
<p>Your browser doesn't support push notifications.</p> <p>{intl.browserDoesNotSupportPush}</p>
{:elseif $notificationPermission === "denied"} {:elseif $notificationPermission === "denied"}
<p role="alert">You have denied permission to show notifications.</p> <p role="alert">{intl.deniedPush}</p>
{:elseif $loggedInInstancesInOrder.length > 1} {:elseif $loggedInInstancesInOrder.length > 1}
<p>Note that you can only have push notifications for one instance at a time.</p> <p>{intl.pushNotificationsNote}</p>
{/if} {/if}
<form id="push-notification-settings" <form id="push-notification-settings"
disabled="{!pushNotificationsSupport}" disabled="{!pushNotificationsSupport}"
ref:form ref:form
aria-label="Push notification settings"> aria-label="{intl.pushSettings}">
{#each options as option, i (option.key)} {#each options as option, i (option.key)}
{#if i > 0} {#if i > 0}
<br> <br>
@ -46,6 +46,7 @@
import { updatePushSubscriptionForInstance, updateAlerts } from '../../../_actions/pushSubscription' import { updatePushSubscriptionForInstance, updateAlerts } from '../../../_actions/pushSubscription'
import { toast } from '../../toast/toast' import { toast } from '../../toast/toast'
import { get } from '../../../_utils/lodash-lite' import { get } from '../../../_utils/lodash-lite'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
async oncreate () { async oncreate () {
@ -64,23 +65,23 @@
options: [ options: [
{ {
key: 'follow', key: 'follow',
label: 'New Followers' label: 'intl.newFollowers'
}, },
{ {
key: 'favourite', key: 'favourite',
label: 'Favorites' label: 'intl.favorites'
}, },
{ {
key: 'reblog', key: 'reblog',
label: 'Boosts' label: 'intl.reblogs'
}, },
{ {
key: 'mention', key: 'mention',
label: 'Mentions' label: 'intl.mentions'
}, },
{ {
key: 'poll', key: 'poll',
label: 'Poll results' label: 'intl.pollResults'
} }
] ]
}), }),
@ -106,12 +107,14 @@
if (err.message.startsWith('403:')) { if (err.message.startsWith('403:')) {
const showTextConfirmationDialog = await importShowTextConfirmationDialog() const showTextConfirmationDialog = await importShowTextConfirmationDialog()
showTextConfirmationDialog({ showTextConfirmationDialog({
text: `You need to reauthenticate in order to enable push notification. Log out of ${instanceName}?` text: formatIntl('intl.needToReauthenticate', { instance: instanceName })
}).on('positive', () => { }).on('positive', () => {
/* no await */ logOutOfInstance(instanceName) /* no await */ logOutOfInstance(instanceName)
}) })
} else { } else {
toast.say(`Failed to update push notification settings: ${err.message}`) toast.say(formatIntl('intl.failedToUpdatePush', {
error: err.message || ''
}))
} }
} }
} }

View file

@ -1,9 +1,9 @@
<form class="theme-chooser" aria-label="Choose a theme"> <form class="theme-chooser" aria-label="{intl.chooseTheme}">
<div class="theme-groups"> <div class="theme-groups">
{#each themeGroups as themeGroup} {#each themeGroups as themeGroup}
<div class="theme-group"> <div class="theme-group">
<h3> <h3>
{themeGroup.dark ? 'Dark background' : 'Light background' } {themeGroup.dark ? 'intl.darkBackground' : 'intl.lightBackground' }
</h3> </h3>
{#each themeGroup.themes as theme} {#each themeGroup.themes as theme}
<div class="theme-picker"> <div class="theme-picker">
@ -15,7 +15,7 @@
style="background-color: {theme.color};" > style="background-color: {theme.color};" >
</div> </div>
<span class="theme-picker-label-span"> <span class="theme-picker-label-span">
{theme.label} {theme.name === DEFAULT_THEME ? '(default)' : ''} {createThemeLabel(theme)}
</span> </span>
</label> </label>
</div> </div>
@ -93,6 +93,7 @@
import { store } from '../../../_store/store' import { store } from '../../../_store/store'
import { themes } from '../../../_static/themes' import { themes } from '../../../_static/themes'
import { DEFAULT_THEME } from '../../../_utils/themeEngine' import { DEFAULT_THEME } from '../../../_utils/themeEngine'
import { formatIntl } from '../../../_utils/formatIntl'
export default { export default {
async oncreate () { async oncreate () {
@ -120,6 +121,14 @@
} }
]) ])
}, },
helpers: {
createThemeLabel (theme) {
return formatIntl('intl.themeLabel', {
label: theme.label,
default: theme.name === DEFAULT_THEME
})
}
},
methods: { methods: {
onThemeChange () { onThemeChange () {
const { selectedTheme, instanceName } = this.get() const { selectedTheme, instanceName } = this.get()

View file

@ -2,7 +2,7 @@
aria-live="assertive" aria-live="assertive"
aria-atomic="true" aria-atomic="true"
aria-hidden={!shown} aria-hidden={!shown}
aria-label="Alert" aria-label="{intl.alert}}"
> >
<div class="snackbar-container"> <div class="snackbar-container">
<span class="text"> <span class="text">
@ -12,7 +12,7 @@
<button class="button" on:click="onClick(event)"> <button class="button" on:click="onClick(event)">
{buttonText} {buttonText}
</button> </button>
<button class="button" aria-label="Close" on:click="close(event)"> <button class="button" aria-label="{intl.close}" on:click="close(event)">
<SvgIcon className="close-snackbar-button" href="#fa-times" /> <SvgIcon className="close-snackbar-button" href="#fa-times" />
</button> </button>
</div> </div>

View file

@ -34,7 +34,7 @@
> >
{#if type === 'gifv' && $autoplayGifs && !blurhash} {#if type === 'gifv' && $autoplayGifs && !blurhash}
<AutoplayVideo <AutoplayVideo
ariaLabel="Animated image: {description}" ariaLabel={animatedLabel}
poster={previewUrl} poster={previewUrl}
src={url} src={url}
width={inlineWidth} width={inlineWidth}
@ -44,7 +44,7 @@
{:elseif type === 'gifv'} {:elseif type === 'gifv'}
<NonAutoplayGifv <NonAutoplayGifv
class={noNativeWidthHeight ? 'no-native-width-height' : ''} class={noNativeWidthHeight ? 'no-native-width-height' : ''}
label="Animated image: {description}" label={animatedLabel}
poster={previewUrl} poster={previewUrl}
{blurhash} {blurhash}
src={url} src={url}
@ -111,6 +111,7 @@
import AutoplayVideo from '../AutoplayVideo.html' import AutoplayVideo from '../AutoplayVideo.html'
import { registerClickDelegate } from '../../_utils/delegate' import { registerClickDelegate } from '../../_utils/delegate'
import { convertCssPropertyToDataUrl } from '../../_utils/convertCssPropertyToDataUrl' import { convertCssPropertyToDataUrl } from '../../_utils/convertCssPropertyToDataUrl'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
async oncreate () { async oncreate () {
@ -168,10 +169,13 @@ export default {
tabindex: ({ showAsSensitive }) => showAsSensitive ? '-1' : '0', tabindex: ({ showAsSensitive }) => showAsSensitive ? '-1' : '0',
ariaHidden: ({ showAsSensitive }) => showAsSensitive, ariaHidden: ({ showAsSensitive }) => showAsSensitive,
imageButtonAriaLabel: ({ type, description }) => ( imageButtonAriaLabel: ({ type, description }) => (
`Show ${type === 'gifv' ? 'animated image' : 'image'}: ${description}` formatIntl('intl.showImage', { animated: type === 'gifv', description })
), ),
videoOrAudioButtonLabel: ({ type, description }) => ( videoOrAudioButtonLabel: ({ type, description }) => (
`Play ${type === 'video' ? 'video' : 'audio'}: ${description}` formatIntl('intl.playVideoOrAudio', { audio: type === 'audio', description })
),
animatedLabel: ({ description }) => (
formatIntl('intl.animatedImage', { description })
) )
}, },
methods: { methods: {

View file

@ -42,6 +42,7 @@
import { composeNewStatusMentioning } from '../../_actions/mention' import { composeNewStatusMentioning } from '../../_actions/mention'
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid' import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
components: { components: {
@ -65,7 +66,10 @@
elementId: ({ uuid }) => uuid, elementId: ({ uuid }) => uuid,
shortcutScope: ({ elementId }) => elementId, shortcutScope: ({ elementId }) => elementId,
ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => ( ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => (
!status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}` !status && formatIntl('intl.accountFollowedYou', {
name: getAccountAccessibleName(account, $omitEmojiInDisplayNames),
account: `@${account.acct}`
})
), ),
className: ({ $underlineLinks }) => (classname( className: ({ $underlineLinks }) => (classname(
'notification-article', 'notification-article',

View file

@ -2,7 +2,7 @@
<ExternalLink className="status-absolute-date" <ExternalLink className="status-absolute-date"
href={originalStatus.url} href={originalStatus.url}
showIcon={true} showIcon={true}
ariaLabel="{displayAbsoluteFormattedDate} (opens in new window)" ariaLabel={externalLinkLabel}
> >
<time datetime={createdAtDate} title={absoluteFormattedDate}> <time datetime={createdAtDate} title={absoluteFormattedDate}>
{displayAbsoluteFormattedDate} {displayAbsoluteFormattedDate}
@ -13,7 +13,7 @@
<ExternalLink className="status-application" <ExternalLink className="status-application"
href={applicationWebsite} href={applicationWebsite}
showIcon={false} showIcon={false}
ariaLabel="{applicationName} (opens in new window)"> ariaLabel={applicationLinkLabel}>
<span class="status-application-span"> <span class="status-application-span">
{applicationName} {applicationName}
</span> </span>
@ -135,6 +135,7 @@
import { absoluteDateFormatter, shortAbsoluteDateFormatter } from '../../_utils/formatters' import { absoluteDateFormatter, shortAbsoluteDateFormatter } from '../../_utils/formatters'
import SvgIcon from '../SvgIcon.html' import SvgIcon from '../SvgIcon.html'
import { on } from '../../_utils/eventBus' import { on } from '../../_utils/eventBus'
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
oncreate () { oncreate () {
@ -181,22 +182,22 @@
), ),
reblogsLabel: ({ $disableReblogCounts, numReblogs }) => { reblogsLabel: ({ $disableReblogCounts, numReblogs }) => {
if ($disableReblogCounts) { if ($disableReblogCounts) {
return 'Boost counts hidden' return 'intl.reblogCountsHidden'
} }
// TODO: intl return formatIntl('intl.rebloggedTimes', { count: numReblogs })
return numReblogs === 1
? `Boosted ${numReblogs} time`
: `Boosted ${numReblogs} times`
}, },
favoritesLabel: ({ $disableFavCounts, numFavs }) => { favoritesLabel: ({ $disableFavCounts, numFavs }) => {
if ($disableFavCounts) { if ($disableFavCounts) {
return 'Favorite counts hidden' return 'intl.favoriteCountsHidden'
}
// TODO: intl
return numFavs === 1
? `Favorited ${numFavs} time`
: `Favorited ${numFavs} times`
} }
return formatIntl('intl.favoritedTimes', { count: numFavs })
},
externalLinkLabel: ({ displayAbsoluteFormattedDate }) => (
formatIntl('intl.opensInNewWindow', { label: displayAbsoluteFormattedDate })
),
applicationLinkLabel: ({ applicationName }) => (
formatIntl('intl.opensInNewWindow', { label: applicationName })
)
}, },
components: { components: {
ExternalLink, ExternalLink,

View file

@ -7,7 +7,7 @@
<div class="status-header-content"> <div class="status-header-content">
{#if timelineType === 'pinned'} {#if timelineType === 'pinned'}
<span class="status-header-author"> <span class="status-header-author">
Pinned toot {intl.pinnedStatus}
</span> </span>
{:elseif notificationType !== 'poll'} {:elseif notificationType !== 'poll'}
<a id={elementId} <a id={elementId}
@ -130,19 +130,19 @@
}, },
actionText: ({ notificationType, status, $currentVerifyCredentials }) => { actionText: ({ notificationType, status, $currentVerifyCredentials }) => {
if (notificationType === 'reblog') { if (notificationType === 'reblog') {
return 'boosted your status' return 'intl.rebloggedYou'
} else if (notificationType === 'favourite') { } else if (notificationType === 'favourite') {
return 'favorited your status' return 'intl.favoritedYou'
} else if (notificationType === 'follow') { } else if (notificationType === 'follow') {
return 'followed you' return 'intl.followedYou'
} else if (notificationType === 'poll') { } else if (notificationType === 'poll') {
if ($currentVerifyCredentials && status && $currentVerifyCredentials.id === status.account.id) { if ($currentVerifyCredentials && status && $currentVerifyCredentials.id === status.account.id) {
return 'A poll you created has ended' return 'intl.pollYouCreatedEnded'
} else { } else {
return 'A poll you voted on has ended' return 'intl.pollYouVotedEnded'
} }
} else if (status && status.reblog) { } else if (status && status.reblog) {
return 'boosted' return 'intl.reblogged'
} else { } else {
return '' return ''
} }

View file

@ -5,7 +5,7 @@
<button id={elementId} <button id={elementId}
type="button" type="button"
class="status-sensitive-media-button" class="status-sensitive-media-button"
aria-label="Hide sensitive media" aria-label="{intl.hideSensitiveMedia}"
ref:hideSensitiveMedia ref:hideSensitiveMedia
> >
<div class="svg-wrapper"> <div class="svg-wrapper">
@ -17,13 +17,13 @@
<button id={elementId} <button id={elementId}
type="button" type="button"
class="status-sensitive-media-button" class="status-sensitive-media-button"
aria-label="Show sensitive media" aria-label="{intl.showSensitiveMedia}"
ref:showSensitiveMedia ref:showSensitiveMedia
> >
<div class="status-sensitive-media-warning"> <div class="status-sensitive-media-warning">
<div class="status-sensitive-media-warning-text"> <div class="status-sensitive-media-warning-text">
Sensitive content. Click to show. {intl.clickToShowSensitive}
</div> </div>
</div> </div>
<div class="svg-wrapper"> <div class="svg-wrapper">

View file

@ -1,6 +1,6 @@
<div class={computedClass} aria-busy={loading} > <div class={computedClass} aria-busy={loading} >
{#if voted || expired } {#if voted || expired }
<ul class="poll-choices" aria-label="Poll results"> <ul class="poll-choices" aria-label="{intl.pollResults}">
{#each options as option} {#each options as option}
<li class="poll-choice option"> <li class="poll-choice option">
<div class="option-text"> <div class="option-text">
@ -13,8 +13,8 @@
{/each} {/each}
</ul> </ul>
{:else} {:else}
<form class="poll-form" aria-label="Vote on poll" on:submit="onSubmit(event)" ref:form> <form class="poll-form" aria-label="{intl.voteOnPoll}" on:submit="onSubmit(event)" ref:form>
<ul class="poll-choices" aria-label="Poll choices"> <ul class="poll-choices" aria-label="{intl.pollChoices}">
{#each options as option, i} {#each options as option, i}
<li class="poll-choice poll-form-option"> <li class="poll-choice poll-form-option">
<label> <label>
@ -28,10 +28,10 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<button disabled={formDisabled} type="submit">Vote</button> <button disabled={formDisabled} type="submit">{intl.vote}</button>
</form> </form>
{/if} {/if}
<ul class="poll-details" aria-label="Poll details"> <ul class="poll-details" aria-label="{intl.pollDetails}">
<li class="poll-stat {notification ? 'is-notification' : ''}"> <li class="poll-stat {notification ? 'is-notification' : ''}">
<SvgIcon className="poll-icon" href="#fa-bar-chart" /> <SvgIcon className="poll-icon" href="#fa-bar-chart" />
<span class="poll-stat-text">{votesText}</span> <span class="poll-stat-text">{votesText}</span>
@ -48,10 +48,10 @@
<li class="poll-stat {notification ? 'is-notification' : ''} {expired ? 'poll-expired' : ''}"> <li class="poll-stat {notification ? 'is-notification' : ''} {expired ? 'poll-expired' : ''}">
<button id={refreshElementId} <button id={refreshElementId}
class="focus-fix" class="focus-fix"
aria-label="Refresh"> aria-label="{intl.refresh}">
<SvgIcon className="poll-icon" href="#fa-refresh" /> <SvgIcon className="poll-icon" href="#fa-refresh" />
<span class="poll-stat-text poll-stat-text-refresh" aria-hidden="true"> <span class="poll-stat-text poll-stat-text-refresh" aria-hidden="true">
Refresh {intl.refresh}
</span> </span>
</button> </button>
</li> </li>
@ -252,6 +252,7 @@
import { getPoll, voteOnPoll } from '../../_actions/polls' import { getPoll, voteOnPoll } from '../../_actions/polls'
import escapeHtml from 'escape-html' import escapeHtml from 'escape-html'
import { emojifyText } from '../../_utils/emojifyText' import { emojifyText } from '../../_utils/emojifyText'
import { formatIntl } from '../../_utils/formatIntl'
const REFRESH_MIN_DELAY = 1000 const REFRESH_MIN_DELAY = 1000
@ -307,13 +308,15 @@
expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now) expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now)
), ),
expiresAtAbsoluteFormatted: ({ expiresAtTS }) => absoluteDateFormatter.format(expiresAtTS), expiresAtAbsoluteFormatted: ({ expiresAtTS }) => absoluteDateFormatter.format(expiresAtTS),
expiryText: ({ expired }) => expired ? 'Ended' : 'Ends', expiryText: ({ expired }) => expired ? 'intl.expired' : 'intl.expires',
refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`, refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`,
useNarrowSize: ({ $isMobileSize, expired, isStatusInOwnThread }) => ( useNarrowSize: ({ $isMobileSize, expired, isStatusInOwnThread }) => (
!isStatusInOwnThread && $isMobileSize && !expired !isStatusInOwnThread && $isMobileSize && !expired
), ),
formDisabled: ({ choices }) => !choices.length, formDisabled: ({ choices }) => !choices.length,
votesText: ({ votesCount }) => `${votesCount} ${votesCount === 1 ? 'vote' : 'votes'}`, votesText: ({ votesCount }) => (
formatIntl('intl.voteCount', { count: votesCount })
),
computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading, shown }) => ( computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading, shown }) => (
classname( classname(
'poll', 'poll',

View file

@ -5,7 +5,7 @@
{tabindex} {tabindex}
> >
<time datetime={createdAtDate} title={absoluteFormattedDate} <time datetime={createdAtDate} title={absoluteFormattedDate}
aria-label="{timeagoFormattedDate} click to show thread"> aria-label={createdAtLabel}>
{timeagoFormattedDate} {timeagoFormattedDate}
</time> </time>
</a> </a>
@ -31,6 +31,8 @@
</style> </style>
<script> <script>
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
computed: { computed: {
elementId: ({ uuid }) => `status-relative-date-${uuid}`, elementId: ({ uuid }) => `status-relative-date-${uuid}`,
@ -38,6 +40,9 @@
// If you can't tap on the entire status, then you need some way to click on it. Otherwise it's // If you can't tap on the entire status, then you need some way to click on it. Otherwise it's
// just a duplicate link in the focus order. // just a duplicate link in the focus order.
$disableTapOnStatus ? '0' : '-1' $disableTapOnStatus ? '0' : '-1'
),
createdAtLabel: ({ timeagoFormattedDate }) => (
formatIntl('intl.clickToShowThread', { time: timeagoFormattedDate })
) )
} }
} }

View file

@ -3,7 +3,7 @@
</div> </div>
<div class="status-spoiler-button {isStatusInOwnThread ? 'status-in-own-thread' : ''}"> <div class="status-spoiler-button {isStatusInOwnThread ? 'status-in-own-thread' : ''}">
<button id={elementId} type="button" > <button id={elementId} type="button" >
{spoilerShown ? 'Show less' : 'Show more'} {spoilerShown ? 'intl.showLess' : 'intl.showMore'}
</button> </button>
</div> </div>
{#if enableShortcuts} {#if enableShortcuts}

View file

@ -2,7 +2,7 @@
<IconButton <IconButton
className="status-toolbar-reply-button" className="status-toolbar-reply-button"
label={replyLabel} label={replyLabel}
pressedLabel="Close reply" pressedLabel="{intl.closeReply}"
pressable={true} pressable={true}
pressed={replyShown} pressed={replyShown}
href={replyIcon} href={replyIcon}
@ -21,8 +21,8 @@
ref:reblogIcon ref:reblogIcon
/> />
<IconButton <IconButton
label="Favorite" label="{intl.favorite}"
pressedLabel="Unfavorite" pressedLabel="{intl.unfavorite}"
pressable={true} pressable={true}
pressed={favorited} pressed={favorited}
href="#fa-star" href="#fa-star"
@ -31,7 +31,7 @@
ref:favoriteIcon ref:favoriteIcon
/> />
<IconButton <IconButton
label="Show more options" label="{intl.moreOptions}"
href="#fa-ellipsis-h" href="#fa-ellipsis-h"
clickListener={false} clickListener={false}
elementId={optionsKey} elementId={optionsKey}
@ -165,17 +165,17 @@
}), }),
computed: { computed: {
replyLabel: ({ inReplyToId }) => ( replyLabel: ({ inReplyToId }) => (
inReplyToId ? 'Reply to thread' : 'Reply' inReplyToId ? 'intl.replyToThread' : 'intl.reply'
), ),
replyIcon: ({ inReplyToId }) => inReplyToId ? '#fa-reply-all' : '#fa-reply', replyIcon: ({ inReplyToId }) => inReplyToId ? '#fa-reply-all' : '#fa-reply',
reblogLabel: ({ visibility }) => { reblogLabel: ({ visibility }) => {
switch (visibility) { switch (visibility) {
case 'private': case 'private':
return 'Cannot be boosted because this is followers-only' return 'intl.cannotReblogFollowersOnly'
case 'direct': case 'direct':
return 'Cannot be boosted because this is a direct message' return 'intl.cannotReblogDirectMessage'
default: default:
return 'Boost' return 'intl.reblog'
} }
}, },
reblogIcon: ({ visibility }) => { reblogIcon: ({ visibility }) => {

View file

@ -5,7 +5,7 @@
> >
<LoadingSpinner size={48} /> <LoadingSpinner size={48} />
<span class="loading-footer-info"> <span class="loading-footer-info">
Loading more... {intl.loadingMore}
</span> </span>
</div> </div>
<div class="button-wrapper {showLoadButton ? 'shown' : ''}" <div class="button-wrapper {showLoadButton ? 'shown' : ''}"
@ -14,7 +14,7 @@
<button type="button" <button type="button"
class="primary" class="primary"
on:click="onClickLoadMore(event)"> on:click="onClickLoadMore(event)">
Load more {intl.loadMore}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
<div class="more-items-header"> <div class="more-items-header">
<button class="primary" type="button" on:click="onClick(event)"> <button class="primary" type="button" on:click="onClick(event)">
Show {count} more {showMoreLabel}
</button> </button>
</div> </div>
<style> <style>
@ -12,6 +12,8 @@
} }
</style> </style>
<script> <script>
import { formatIntl } from '../../_utils/formatIntl'
export default { export default {
methods: { methods: {
onClick (event) { onClick (event) {
@ -20,6 +22,11 @@
onClick(event) onClick(event)
} }
} }
},
computed: {
showMoreLabel: ({ count }) => (
formatIntl('intl.showCountMore', { count })
)
} }
} }
</script> </script>

View file

@ -1,6 +1,6 @@
{#if pinnedStatuses.length } {#if pinnedStatuses.length }
<h1 class="sr-only">Pinned statuses</h1> <h1 class="sr-only">{intl.pinnedStatuses}</h1>
<div role="feed" aria-label="Pinned statuses" class="pinned-statuses"> <div role="feed" aria-label="{intl.pinnedStatuses}" class="pinned-statuses">
{#each pinnedStatuses as status, index (status.id)} {#each pinnedStatuses as status, index (status.id)}
<div class="pinned-status-wrapper"> <div class="pinned-status-wrapper">
<!-- empty div used because we assume the parent of the <article> gets the focus outline --> <!-- empty div used because we assume the parent of the <article> gets the focus outline -->

View file

@ -14,7 +14,7 @@
{/each} {/each}
{#if !$visibleItems.length} {#if !$visibleItems.length}
<div class="nothing-to-show"> <div class="nothing-to-show">
Nothing to show. {intl.nothingToShow}
</div> </div>
{/if} {/if}
{/if} {/if}

View file

@ -4,8 +4,8 @@ import { mark, stop } from '../_utils/marks'
// Format a date in the past // Format a date in the past
export function formatTimeagoDate (date, now) { export function formatTimeagoDate (date, now) {
mark('formatTimeagoDate') mark('formatTimeagoDate')
// use Math.max() to avoid things like "in 10 seconds" when the timestamps are slightly off // use Math.min() to avoid things like "in 10 seconds" when the timestamps are slightly off
const res = format(date, Math.max(now, date)) const res = format(Math.min(0, date - now))
stop('formatTimeagoDate') stop('formatTimeagoDate')
return res return res
} }
@ -13,8 +13,8 @@ export function formatTimeagoDate (date, now) {
// Format a date in the future // Format a date in the future
export function formatTimeagoFutureDate (date, now) { export function formatTimeagoFutureDate (date, now) {
mark('formatTimeagoFutureDate') mark('formatTimeagoFutureDate')
// use Math.min() for same reason as above // use Math.max() for same reason as above
const res = format(date, Math.min(now, date)) const res = format(Math.max(0, date - now))
stop('formatTimeagoFutureDate') stop('formatTimeagoFutureDate')
return res return res
} }

View file

@ -1,4 +1,4 @@
<DynamicPageBanner title="Followers" /> <DynamicPageBanner title="{intl.followers}" />
<AccountsListPage {accountsFetcher} /> <AccountsListPage {accountsFetcher} />
<script> <script>
import { getFollowers } from '../../../_api/followsAndFollowers' import { getFollowers } from '../../../_api/followsAndFollowers'

View file

@ -1,4 +1,4 @@
<DynamicPageBanner title="Follows" /> <DynamicPageBanner title="{intl.follows}" />
<AccountsListPage {accountsFetcher} /> <AccountsListPage {accountsFetcher} />
<script> <script>
import { getFollows } from '../../../_api/followsAndFollowers' import { getFollows } from '../../../_api/followsAndFollowers'

View file

@ -1,4 +1,4 @@
<DynamicPageBanner title="Blocked users" icon="#fa-ban" /> <DynamicPageBanner title="{intl.blockedUsers}" icon="#fa-ban" />
{#if $isUserLoggedIn } {#if $isUserLoggedIn }
<AccountsListPage {accountsFetcher} {accountActions} /> <AccountsListPage {accountsFetcher} {accountActions} />
{/if} {/if}
@ -14,7 +14,7 @@
accountActions: [ accountActions: [
{ {
icon: '#fa-unlock', icon: '#fa-unlock',
label: 'Unblock', label: 'intl.unblock',
onclick: (accountId) => setAccountBlocked(accountId, false, true) onclick: (accountId) => setAccountBlocked(accountId, false, true)
} }
] ]

View file

@ -1,15 +1,15 @@
{#if $isUserLoggedIn} {#if $isUserLoggedIn}
<TimelinePage timeline="bookmarks"> <TimelinePage timeline="bookmarks">
{#if $pinnedPage !== '/bookmarks'} {#if $pinnedPage !== '/bookmarks'}
<DynamicPageBanner title="Bookmarks" icon="#fa-bookmark"/> <DynamicPageBanner title="{intl.bookmarks}" icon="#fa-bookmark"/>
{/if} {/if}
</TimelinePage> </TimelinePage>
{:else} {:else}
<HiddenFromSSR> <HiddenFromSSR>
<FreeTextLayout> <FreeTextLayout>
<h1>Bookmarks</h1> <h1>{intl.bookmarks}</h1>
<p>Your bookmarks will appear here when logged in.</p> <p>{intl.bookmarksNotLoggedIn}</p>
</FreeTextLayout> </FreeTextLayout>
</HiddenFromSSR> </HiddenFromSSR>
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show more