diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86f1a980..6f32534d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,11 @@ # 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 To install with dev dependencies, run: diff --git a/README.md b/README.md index 7c641982..1d82d983 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,12 @@ Compatible versions of each (Opera, Brave, Samsung, etc.) should be fine. - Progressive Web App features - Multi-instance support - 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 -- Support for Pleroma or other non-Mastodon backends - Serve as an alternative frontend tied to a particular instance -- Support for non-English languages (i18n) - Offline search ### Non-goals diff --git a/bin/build-template-html.js b/bin/build-template-html.js index 22752a28..f10b16d7 100644 --- a/bin/build-template-html.js +++ b/bin/build-template-html.js @@ -7,9 +7,12 @@ import { buildInlineScript } from './build-inline-script' import { buildSvg } from './build-svg' import now from 'performance-now' 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 LOCALE_DIRECTION = getLangDir(LOCALE) const DEBOUNCE = 500 const builders = [ @@ -78,7 +81,7 @@ function doWatch () { async function buildAll () { 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') { return partial } @@ -88,6 +91,9 @@ async function buildAll () { return partial.result }))).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') const end = now() console.log(`Built template.html in ${(end - start).toFixed(2)}ms`) diff --git a/package.json b/package.json index ed2c692f..0b65ff9b 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,11 @@ "lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'", "dev": "run-s build-template-html build-assets serve-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", "build": "cross-env NODE_ENV=production run-s build-steps", "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", "build-and-start": "run-s build start", "build-template-html": "node -r esm ./bin/build-template-html.js", @@ -25,13 +25,13 @@ "testcafe": "run-s testcafe-suite0 testcafe-suite1", "testcafe-suite0": "cross-env-shell testcafe -c 4 $BROWSER tests/spec/0*", "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-data": "node -r esm bin/wait-for-mastodon-data.js", "deploy-prod": "DEPLOY_TYPE=prod ./bin/deploy.sh", "deploy-dev": "DEPLOY_TYPE=dev ./bin/deploy.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", "export-steps": "run-s before-build sapper-export print-export-info", "export": "cross-env NODE_ENV=production run-s export-steps", @@ -62,6 +62,7 @@ "file-loader": "^6.1.0", "focus-visible": "^5.1.0", "form-data": "^3.0.0", + "format-message-interpret": "^6.2.3", "glob": "^7.1.6", "li": "^1.3.0", "localstorage-memory": "^1.0.3", @@ -80,6 +81,7 @@ "rollup": "^2.26.10", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-terser": "^7.0.2", + "rtl-detect": "^1.0.2", "sapper": "nolanlawson/sapper#for-pinafore-21", "sass": "^1.26.10", "stringz": "^2.1.0", @@ -101,6 +103,8 @@ "assert": "^2.0.0", "eslint-plugin-html": "^6.1.0", "fake-indexeddb": "^3.1.2", + "format-message-parse": "^6.2.3", + "globby": "^11.0.1", "husky": "^5.0.4", "lint-staged": "^10.3.0", "mocha": "^8.1.3", diff --git a/src/build/template.html b/src/build/template.html index b390f763..382a215a 100644 --- a/src/build/template.html +++ b/src/build/template.html @@ -1,10 +1,10 @@ - +
- + %sapper.base% @@ -15,7 +15,7 @@ https://developers.google.com/web/fundamentals/native-hardware/fullscreen/ --> - + diff --git a/src/intl/en-US.js b/src/intl/en-US.js new file mode 100644 index 00000000..8489ea0a --- /dev/null +++ b/src/intl/en-US.js @@ -0,0 +1,628 @@ +export default { + // Home page, basic+ Pinafore is a web client for + Mastodon, + designed for speed and simplicity. +
++ Read the + introductory blog post, + or get started by logging in to an instance: +
`, + logIn: 'Log in', + footer: ` ++ Pinafore is + open-source software + created by + Nolan Lawson + and distributed under the + AGPL License. + Here is the privacy policy. +
+ `, + // 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 { ++ Pinafore is + free and open-source software + created by + Nolan Lawson + and distributed under the + GNU Affero General Public License. +
+ ++ Pinafore does not store any personal information on its servers, + including but not limited to names, email addresses, + IP addresses, posts, and photos. +
+ ++ Pinafore is a static site. All data is stored locally in your browser and shared with the fediverse + instance(s) you connect to. +
+ ++ Icons provided by Font Awesome. +
+ ++ Logo thanks to "sailboat" by Gregor Cresnar from + the Noun Project. +
`, + // 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 + Center for Humane Technology.`, + // 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.' +} diff --git a/src/intl/fr.js b/src/intl/fr.js new file mode 100644 index 00000000..0acde0ac --- /dev/null +++ b/src/intl/fr.js @@ -0,0 +1,628 @@ +export default { + // Home page, basic+ Pinafore est un client web pour + Mastodon, + dessiné pour la vitesse et la simplicité. +
++ Lire + l'article introductoire (anglais), + ou se connecter à une instance: +
`, + logIn: 'Se connecter', + footer: ` ++ Pinafore est + logiciel open-source + créé par + Nolan Lawson + et distribué sous la + License AGPL. + Lire la politique de confidentialité. +
+ `, + // 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 { ++ Pinafore est un logiciel + gratuit et open-source + créé par + Nolan Lawson + et distribué sous le + License GNU Affero General Public (AGPL). +
+ ++ Pinafore ne garde pas d'informations personelles dans ses serveurs, + y compris les noms, addresses courriel, addresses IP, messages, et photos. +
+ ++ 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. +
+ ++ Icônes par Font Awesome. +
+ ++ Logo grâce à Gregor Cresnar du + Noun Project. +
`, + // 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 + Center for Humane Technology.`, + // 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.' +} diff --git a/src/routes/_a11y/getAccessibleLabelForStatus.js b/src/routes/_a11y/getAccessibleLabelForStatus.js index 05f3ae8b..2fa78bb3 100644 --- a/src/routes/_a11y/getAccessibleLabelForStatus.js +++ b/src/routes/_a11y/getAccessibleLabelForStatus.js @@ -1,5 +1,6 @@ import { getAccountAccessibleName } from './getAccountAccessibleName' import { POST_PRIVACY_OPTIONS } from '../_static/statuses' +import { formatIntl } from '../_utils/formatIntl' function getNotificationText (notification, omitEmojiInDisplayNames) { if (!notification) { @@ -7,9 +8,9 @@ function getNotificationText (notification, omitEmojiInDisplayNames) { } const notificationAccountDisplayName = getAccountAccessibleName(notification.account, omitEmojiInDisplayNames) if (notification.type === 'reblog') { - return `${notificationAccountDisplayName} boosted your status` + return formatIntl('intl.accountRebloggedYou', { account: notificationAccountDisplayName }) } 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 } const accountDisplayName = getAccountAccessibleName(account, omitEmojiInDisplayNames) - return `Boosted by ${accountDisplayName}` + return formatIntl('intl.rebloggedByAccount', { account: accountDisplayName }) } function cleanupText (text) { @@ -40,15 +41,15 @@ export function getAccessibleLabelForStatus (originalAccount, account, plainText const originalAccountDisplayName = getAccountAccessibleName(originalAccount, omitEmojiInDisplayNames) const contentTextToShow = (showContent || !spoilerText) ? cleanupText(plainTextContent) - : `Content warning: ${cleanupText(spoilerText)}` - const mediaTextToShow = showMedia && 'has media' - const pollTextToShow = showPoll && 'has poll' + : formatIntl('intl.contentWarningContent', { spoiler: cleanupText(spoilerText) }) + const mediaTextToShow = showMedia && 'intl.hasMedia' + const pollTextToShow = showPoll && 'intl.hasPoll' const privacyText = getPrivacyText(visibility) if (disableLongAriaLabels) { // Long text can crash NVDA; allow users to shorten it like we had it before. // https://github.com/nolanlawson/pinafore/issues/694 - return `${privacyText} status by ${originalAccountDisplayName}` + return formatIntl('intl.shortStatusLabel', { privacy: privacyText, account: originalAccountDisplayName }) } const values = [ diff --git a/src/routes/_actions/block.js b/src/routes/_actions/block.js index a0efee1c..10409f61 100644 --- a/src/routes/_actions/block.js +++ b/src/routes/_actions/block.js @@ -3,6 +3,7 @@ import { blockAccount, unblockAccount } from '../_api/block' import { toast } from '../_components/toast/toast' import { updateLocalRelationship } from './accounts' import { emit } from '../_utils/eventBus' +import { formatIntl } from '../_utils/formatIntl' export async function setAccountBlocked (accountId, block, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -16,14 +17,17 @@ export async function setAccountBlocked (accountId, block, toastOnSuccess) { await updateLocalRelationship(currentInstance, accountId, relationship) if (toastOnSuccess) { if (block) { - toast.say('Blocked account') + /* no await */ toast.say('intl.blockedAccount') } else { - toast.say('Unblocked account') + /* no await */ toast.say('intl.unblockedAccount') } } emit('refreshAccountsList') } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/bookmark.js b/src/routes/_actions/bookmark.js index ac52f620..3759c03c 100644 --- a/src/routes/_actions/bookmark.js +++ b/src/routes/_actions/bookmark.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { toast } from '../_components/toast/toast' import { bookmarkStatus, unbookmarkStatus } from '../_api/bookmark' import { database } from '../_database/database' +import { formatIntl } from '../_utils/formatIntl' export async function setStatusBookmarkedOrUnbookmarked (statusId, bookmarked) { const { currentInstance, accessToken } = store.get() @@ -12,14 +13,18 @@ export async function setStatusBookmarkedOrUnbookmarked (statusId, bookmarked) { await unbookmarkStatus(currentInstance, accessToken, statusId) } if (bookmarked) { - toast.say('Bookmarked toot') + /* no await */ toast.say('intl.bookmarkedStatus') } else { - toast.say('Unbookmarked toot') + /* no await */ toast.say('intl.unbookmarkedStatus') } store.setStatusBookmarked(currentInstance, statusId, bookmarked) await database.setStatusBookmarked(currentInstance, statusId, bookmarked) } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/compose.js b/src/routes/_actions/compose.js index aa17480a..f7d74164 100644 --- a/src/routes/_actions/compose.js +++ b/src/routes/_actions/compose.js @@ -8,6 +8,7 @@ import { putMediaMetadata } from '../_api/media' import uniqBy from 'lodash-es/uniqBy' import { deleteCachedMediaFile } from '../_utils/mediaUploadFileCache' import { scheduleIdleTask } from '../_utils/scheduleIdleTask' +import { formatIntl } from '../_utils/formatIntl' export async function insertHandleForReply (statusId) { const { currentInstance } = store.get() @@ -31,7 +32,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds, const { currentInstance, accessToken, online } = store.get() if (!online) { - toast.say('You cannot post while offline') + /* no await */ toast.say('intl.cannotPostOffline') return } @@ -63,7 +64,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds, scheduleIdleTask(() => (mediaIds || []).forEach(mediaId => deleteCachedMediaFile(mediaId))) // clean up media cache } catch (e) { console.error(e) - toast.say('Unable to post status: ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.unableToPost', { error: (e.message || '') })) } finally { store.set({ postingStatus: false }) } diff --git a/src/routes/_actions/copyText.js b/src/routes/_actions/copyText.js index 96416e4c..4b3fcb0e 100644 --- a/src/routes/_actions/copyText.js +++ b/src/routes/_actions/copyText.js @@ -5,7 +5,7 @@ export async function copyText (text) { if (navigator.clipboard) { // not supported in all browsers try { await navigator.clipboard.writeText(text) - toast.say('Copied to clipboard') + /* no await */ toast.say('intl.copiedToClipboard') return } catch (e) { console.error(e) diff --git a/src/routes/_actions/delete.js b/src/routes/_actions/delete.js index 9c0c9b2e..8e4a260a 100644 --- a/src/routes/_actions/delete.js +++ b/src/routes/_actions/delete.js @@ -2,17 +2,18 @@ import { store } from '../_store/store' import { deleteStatus } from '../_api/delete' import { toast } from '../_components/toast/toast' import { deleteStatus as deleteStatusLocally } from './deleteStatuses' +import { formatIntl } from '../_utils/formatIntl' export async function doDeleteStatus (statusId) { const { currentInstance, accessToken } = store.get() try { const deletedStatus = await deleteStatus(currentInstance, accessToken, statusId) deleteStatusLocally(currentInstance, statusId) - toast.say('Status deleted.') + /* no await */ toast.say('intl.statusDeleted') return deletedStatus } catch (e) { console.error(e) - toast.say('Unable to delete status: ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.unableToDelete', { error: (e.message || '') })) throw e } } diff --git a/src/routes/_actions/favorite.js b/src/routes/_actions/favorite.js index b2953328..c8d43958 100644 --- a/src/routes/_actions/favorite.js +++ b/src/routes/_actions/favorite.js @@ -2,11 +2,12 @@ import { favoriteStatus, unfavoriteStatus } from '../_api/favorite' import { store } from '../_store/store' import { toast } from '../_components/toast/toast' import { database } from '../_database/database' +import { formatIntl } from '../_utils/formatIntl' export async function setFavorited (statusId, favorited) { const { online } = store.get() if (!online) { - toast.say(`You cannot ${favorited ? 'favorite' : 'unfavorite'} while offline.`) + /* no await */ toast.say(favorited ? 'intl.cannotFavoriteOffline' : 'intl.cannotUnfavoriteOffline') return } const { currentInstance, accessToken } = store.get() @@ -19,7 +20,10 @@ export async function setFavorited (statusId, favorited) { await database.setStatusFavorited(currentInstance, statusId, favorited) } catch (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 } } diff --git a/src/routes/_actions/follow.js b/src/routes/_actions/follow.js index 668a94c2..e1eed3c0 100644 --- a/src/routes/_actions/follow.js +++ b/src/routes/_actions/follow.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { followAccount, unfollowAccount } from '../_api/follow' import { toast } from '../_components/toast/toast' import { updateLocalRelationship } from './accounts' +import { formatIntl } from '../_utils/formatIntl' export async function setAccountFollowed (accountId, follow, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -14,14 +15,13 @@ export async function setAccountFollowed (accountId, follow, toastOnSuccess) { } await updateLocalRelationship(currentInstance, accountId, relationship) if (toastOnSuccess) { - if (follow) { - toast.say('Followed account') - } else { - toast.say('Unfollowed account') - } + /* no await */ toast.say(follow ? 'intl.followedAccount' : 'intl.unfollowedAccount') } } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/instances.js b/src/routes/_actions/instances.js index 5701ee11..a1812af8 100644 --- a/src/routes/_actions/instances.js +++ b/src/routes/_actions/instances.js @@ -7,6 +7,7 @@ import { cacheFirstUpdateAfter } from '../_utils/sync' import { getInstanceInfo } from '../_api/instance' import { database } from '../_database/database' import { importVirtualListStore } from '../_utils/asyncModules/importVirtualListStore.js' +import { formatIntl } from '../_utils/formatIntl' export function changeTheme (instanceName, newTheme) { const { instanceThemes } = store.get() @@ -32,7 +33,8 @@ export function switchToInstance (instanceName) { 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 { composeData, currentInstance, @@ -123,7 +125,7 @@ export async function updateInstanceInfo (instanceName) { export function logOutOnUnauthorized (instanceName) { return async error => { 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 diff --git a/src/routes/_actions/media.js b/src/routes/_actions/media.js index d3638829..5b4beddd 100644 --- a/src/routes/_actions/media.js +++ b/src/routes/_actions/media.js @@ -25,7 +25,7 @@ export async function doMediaUpload (realm, file) { scheduleIdleTask(() => store.save()) } catch (e) { console.error(e) - toast.say('Failed to upload media: ' + (e.message || '')) + /* no await */ toast.say('intl.failedToUploadMedia', { error: (e.message || '') }) } finally { store.set({ uploadingMedia: false }) } diff --git a/src/routes/_actions/mute.js b/src/routes/_actions/mute.js index 6d34cb69..d4bbe531 100644 --- a/src/routes/_actions/mute.js +++ b/src/routes/_actions/mute.js @@ -3,6 +3,7 @@ import { muteAccount, unmuteAccount } from '../_api/mute' import { toast } from '../_components/toast/toast' import { updateLocalRelationship } from './accounts' import { emit } from '../_utils/eventBus' +import { formatIntl } from '../_utils/formatIntl' export async function setAccountMuted (accountId, mute, notifications, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -15,15 +16,14 @@ export async function setAccountMuted (accountId, mute, notifications, toastOnSu } await updateLocalRelationship(currentInstance, accountId, relationship) if (toastOnSuccess) { - if (mute) { - toast.say('Muted account') - } else { - toast.say('Unmuted account') - } + /* no await */ toast.say(mute ? 'intl.mutedAccount' : 'intl.unmutedAccount') } emit('refreshAccountsList') } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/muteConversation.js b/src/routes/_actions/muteConversation.js index 620a2e34..2b3df5b2 100644 --- a/src/routes/_actions/muteConversation.js +++ b/src/routes/_actions/muteConversation.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { muteConversation, unmuteConversation } from '../_api/muteConversation' import { toast } from '../_components/toast/toast' import { database } from '../_database/database' +import { formatIntl } from '../_utils/formatIntl' export async function setConversationMuted (statusId, mute, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -13,14 +14,13 @@ export async function setConversationMuted (statusId, mute, toastOnSuccess) { } await database.setStatusMuted(currentInstance, statusId, mute) if (toastOnSuccess) { - if (mute) { - toast.say('Muted conversation') - } else { - toast.say('Unmuted conversation') - } + /* no await */ toast.say(mute ? 'intl.mutedConversation' : 'intl.unmutedConversation') } } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/pin.js b/src/routes/_actions/pin.js index 94137645..3d44afc0 100644 --- a/src/routes/_actions/pin.js +++ b/src/routes/_actions/pin.js @@ -3,6 +3,7 @@ import { toast } from '../_components/toast/toast' import { pinStatus, unpinStatus } from '../_api/pin' import { database } from '../_database/database' import { emit } from '../_utils/eventBus' +import { formatIntl } from '../_utils/formatIntl' export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -13,17 +14,16 @@ export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSucces await unpinStatus(currentInstance, accessToken, statusId) } if (toastOnSuccess) { - if (pinned) { - toast.say('Pinned status') - } else { - toast.say('Unpinned status') - } + /* no await */ toast.say(pinned ? 'intl.pinnedStatus' : 'intl.unpinnedStatus') } store.setStatusPinned(currentInstance, statusId, pinned) await database.setStatusPinned(currentInstance, statusId, pinned) emit('updatePinnedStatuses') } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/polls.js b/src/routes/_actions/polls.js index 5e532c44..aba6173a 100644 --- a/src/routes/_actions/polls.js +++ b/src/routes/_actions/polls.js @@ -1,6 +1,7 @@ import { getPoll as getPollApi, voteOnPoll as voteOnPollApi } from '../_api/polls' import { store } from '../_store/store' import { toast } from '../_components/toast/toast' +import { formatIntl } from '../_utils/formatIntl' export async function getPoll (pollId) { const { currentInstance, accessToken } = store.get() @@ -9,7 +10,7 @@ export async function getPoll (pollId) { return poll } catch (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 } catch (e) { console.error(e) - toast.say('Unable to vote in poll: ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.unableToVoteInPoll', { error: (e.message || '') })) } } diff --git a/src/routes/_actions/reblog.js b/src/routes/_actions/reblog.js index 87b9f007..ba9a24d9 100644 --- a/src/routes/_actions/reblog.js +++ b/src/routes/_actions/reblog.js @@ -2,11 +2,12 @@ import { store } from '../_store/store' import { toast } from '../_components/toast/toast' import { reblogStatus, unreblogStatus } from '../_api/reblog' import { database } from '../_database/database' +import { formatIntl } from '../_utils/formatIntl' export async function setReblogged (statusId, reblogged) { const online = store.get() if (!online) { - toast.say(`You cannot ${reblogged ? 'boost' : 'unboost'} while offline.`) + /* no await */ toast.say(reblogged ? 'intl.cannotReblogOffline' : 'intl.cannotUnreblogOffline') return } const { currentInstance, accessToken } = store.get() @@ -19,7 +20,10 @@ export async function setReblogged (statusId, reblogged) { await database.setStatusReblogged(currentInstance, statusId, reblogged) } catch (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 } } diff --git a/src/routes/_actions/reportStatuses.js b/src/routes/_actions/reportStatuses.js index 46806024..67e6c76f 100644 --- a/src/routes/_actions/reportStatuses.js +++ b/src/routes/_actions/reportStatuses.js @@ -1,13 +1,14 @@ import { store } from '../_store/store' import { toast } from '../_components/toast/toast' import { report } from '../_api/report' +import { formatIntl } from '../_utils/formatIntl' export async function reportStatuses (account, statusIds, comment, forward) { const { currentInstance, accessToken } = store.get() try { await report(currentInstance, accessToken, account.id, statusIds, comment, forward) - toast.say('Submitted report') + /* no await */ toast.say('intl.submittedReport') } catch (e) { - toast.say('Failed to report: ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.failedToReport', { error: (e.message || '') })) } } diff --git a/src/routes/_actions/requests.js b/src/routes/_actions/requests.js index 2d46668d..ad763ade 100644 --- a/src/routes/_actions/requests.js +++ b/src/routes/_actions/requests.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { approveFollowRequest, rejectFollowRequest } from '../_api/requests' import { emit } from '../_utils/eventBus' import { toast } from '../_components/toast/toast' +import { formatIntl } from '../_utils/formatIntl' export async function setFollowRequestApprovedOrRejected (accountId, approved, toastOnSuccess) { const { @@ -15,15 +16,14 @@ export async function setFollowRequestApprovedOrRejected (accountId, approved, t await rejectFollowRequest(currentInstance, accessToken, accountId) } if (toastOnSuccess) { - if (approved) { - toast.say('Approved follow request') - } else { - toast.say('Rejected follow request') - } + /* no await */ toast.say(approved ? 'intl.approvedFollowRequest' : 'intl.rejectedFollowRequest') } emit('refreshAccountsList') } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/search.js b/src/routes/_actions/search.js index 13a30e03..43b057ad 100644 --- a/src/routes/_actions/search.js +++ b/src/routes/_actions/search.js @@ -1,6 +1,7 @@ import { store } from '../_store/store' import { toast } from '../_components/toast/toast' import { search } from '../_api/search' +import { formatIntl } from '../_utils/formatIntl' export async function doSearch () { const { currentInstance, accessToken, queryInSearch } = store.get() @@ -15,7 +16,7 @@ export async function doSearch () { }) } } catch (e) { - toast.say('Error during search: ' + (e.name || '') + ' ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.searchError', { error: (e.message || '') })) console.error(e) } finally { store.set({ searchLoading: false }) diff --git a/src/routes/_actions/setDomainBlocked.js b/src/routes/_actions/setDomainBlocked.js index a9a635b0..ff5ac0ef 100644 --- a/src/routes/_actions/setDomainBlocked.js +++ b/src/routes/_actions/setDomainBlocked.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { blockDomain, unblockDomain } from '../_api/blockDomain' import { toast } from '../_components/toast/toast' import { updateRelationship } from './accounts' +import { formatIntl } from '../_utils/formatIntl' export async function setDomainBlocked (accountId, domain, block, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -13,14 +14,13 @@ export async function setDomainBlocked (accountId, domain, block, toastOnSuccess } await updateRelationship(accountId) if (toastOnSuccess) { - if (block) { - toast.say(`Hiding ${domain}`) - } else { - toast.say(`Unhiding ${domain}`) - } + /* no await */ toast.say(block ? 'intl.hidDomain' : 'intl.unhidDomain') } } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/setShowReblogs.js b/src/routes/_actions/setShowReblogs.js index c7cb0dc6..9f00afa1 100644 --- a/src/routes/_actions/setShowReblogs.js +++ b/src/routes/_actions/setShowReblogs.js @@ -2,6 +2,7 @@ import { store } from '../_store/store' import { setShowReblogs as setShowReblogsApi } from '../_api/showReblogs' import { toast } from '../_components/toast/toast' import { updateLocalRelationship } from './accounts' +import { formatIntl } from '../_utils/formatIntl' export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) { const { currentInstance, accessToken } = store.get() @@ -9,14 +10,13 @@ export async function setShowReblogs (accountId, showReblogs, toastOnSuccess) { const relationship = await setShowReblogsApi(currentInstance, accessToken, accountId, showReblogs) await updateLocalRelationship(currentInstance, accountId, relationship) if (toastOnSuccess) { - if (showReblogs) { - toast.say('Showing boosts') - } else { - toast.say('Hiding boosts') - } + /* no await */ toast.say(showReblogs ? 'intl.showingReblogs' : 'intl.hidingReblogs') } } catch (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 || '') }) + ) } } diff --git a/src/routes/_actions/share.js b/src/routes/_actions/share.js index e60f8ba1..38cbdbfe 100644 --- a/src/routes/_actions/share.js +++ b/src/routes/_actions/share.js @@ -1,5 +1,6 @@ import { toast } from '../_components/toast/toast' import { statusHtmlToPlainText } from '../_utils/statusHtmlToPlainText' +import { formatIntl } from '../_utils/formatIntl' export async function shareStatus (status) { try { @@ -9,6 +10,6 @@ export async function shareStatus (status) { url: status.url }) } catch (e) { - toast.say('Unable to share: ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.unableToShare', { error: (e.message || '') })) } } diff --git a/src/routes/_actions/timeline.js b/src/routes/_actions/timeline.js index 7b409921..4efb7b1a 100644 --- a/src/routes/_actions/timeline.js +++ b/src/routes/_actions/timeline.js @@ -142,7 +142,7 @@ async function fetchTimelineItems (instanceName, accessToken, timelineName, onli await storeFreshTimelineItemsInDatabase(instanceName, timelineName, items) } catch (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) stale = true } diff --git a/src/routes/_components/AccountsListPage.html b/src/routes/_components/AccountsListPage.html index e70d1dbb..9179befe 100644 --- a/src/routes/_components/AccountsListPage.html +++ b/src/routes/_components/AccountsListPage.html @@ -36,6 +36,7 @@ import AccountSearchResult from './search/AccountSearchResult.html' import { toast } from './toast/toast' import { on } from '../_utils/eventBus' + import { formatIntl } from '../_utils/formatIntl' // TODO: paginate export default { @@ -43,7 +44,7 @@ try { await this.refreshAccounts() } catch (e) { - toast.say('Error: ' + (e.name || '') + ' ' + (e.message || '')) + /* no await */ toast.say(formatIntl('intl.error', { error: (e.message || '') })) } finally { this.set({ loading: false }) } diff --git a/src/routes/_components/DynamicPageBanner.html b/src/routes/_components/DynamicPageBanner.html index df111f55..8c768678 100644 --- a/src/routes/_components/DynamicPageBanner.html +++ b/src/routes/_components/DynamicPageBanner.html @@ -1,5 +1,5 @@