Initial i18n dev

Expecting bugs!
This commit is contained in:
Lim Chee Aun 2024-08-13 15:26:23 +08:00
parent 3f23fe6eb6
commit c2e6d732c4
76 changed files with 13355 additions and 2042 deletions

3
.gitignore vendored
View file

@ -27,3 +27,6 @@ dist-ssr
.env.dev
phanpy-dist.zip
phanpy-dist.tar.gz
# Compiled locale files
src/locales/*.js

View file

@ -100,11 +100,13 @@ Everything is designed and engineered following my taste and vision. This is a p
Prerequisites: Node.js 18+
- `npm install` - Install dependencies
- `npm run dev` - Start development server
- `npm run dev` - Start development server and `messages:extract:watch` in parallel
- `npm run build` - Build for production
- `npm run preview` - Preview the production build
- `npm run fetch-instances` - Fetch instances list from [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json`
- `npm run sourcemap` - Run `source-map-explorer` on the production build
- `npm run messages:extract` - Extract messages from source files and update the locale message catalogs
- `npm run messages:extract:watch` - Same as `messages:extract` but in watch mode
## Tech stack
@ -115,10 +117,65 @@ Prerequisites: Node.js 18+
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library
- [MingCute icons](https://www.mingcute.com/)
- [Lingui](https://lingui.dev/) - Internationalization
- Vanilla CSS - _Yes, I'm old school._
Some of these may change in the future. The front-end world is ever-changing.
## Internationalization
All translations are available as [gettext](https://en.wikipedia.org/wiki/Gettext) `.po` files in the `src/locales` folder. The default language is English (`en`). [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) are used for pluralization. RTL (right-to-left) languages are also supported with proper text direction, icon rendering and layout.
On page load, default language is detected via these methods, in order (first match is used):
1. URL parameter `lang` e.g. `/?lang=zh-Hant`
2. `localStorage` key `lang`
3. Browser's `navigator.language`
Users can change the language in the settings, which sets the `localStorage` key `lang`.
### Guide for translators
*Inspired by [Translate WordPress Handbook](https://make.wordpress.org/polyglots/handbook/):
- [Dont translate literally, translate organically](https://make.wordpress.org/polyglots/handbook/translating/expectations/#dont-translate-literally-translate-organically).
- [Try to keep the same level of formality (or informality)](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
- [Dont use slang or audience-specific terms](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
- Be attentive to placeholders for variables. Many strings have placesholders e.g. `{account}` (variable), `<0>{name}</0>` (tag with variable) and `#` (number placeholder).
- [Ellipsis](https://en.wikipedia.org/wiki/Ellipsis) (…) is intentional. Don't remove it.
- Nielsen Norman Group: ["Include Ellipses in Command Text to Indicate When More Information Is Required"](https://www.nngroup.com/articles/ui-copy/)
- Apple Human Interface Guidelines: ["Append an ellipsis to a menu items label when the action requires more information before it can complete. The ellipsis character (…) signals that people need to input information or make additional choices, typically within another view."](https://developer.apple.com/design/human-interface-guidelines/menus)
- Windows App Development: ["Ellipses mean incompleteness."](https://learn.microsoft.com/en-us/windows/win32/uxguide/text-ui)
- Date timestamps, date ranges, numbers, language names and text segmentation are handled by the [ECMAScript Internationalization API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
- [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) - e.g. "8 Aug", "08/08/2024"
- [`Intl.RelativeTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat) - e.g. "2 days ago", "in 2 days"
- [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) - e.g. "1,000", "10K"
- [`Intl.DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames) - e.g. "English" (`en`) in Traditional Chinese (`zh-Hant`) is "英文"
- [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) (with polyfill for older browsers)
- [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) (with polyfill for older browsers)
### Technical notes
- IDs for strings are auto-generated instead of explicitly defined. Some of the [benefits](https://lingui.dev/tutorials/explicit-vs-generated-ids#benefits-of-generated-ids) are avoiding the "naming things" problem and avoiding duplicates.
- Explicit IDs might be introduced in the future when requirements and priorities change. The library (Lingui) allows both.
- Please report issues if certain strings are translated differently based on context, culture or region.
- There are no strings for push notifications. The language is set on the instance server.
- Native HTML date pickers, e.g. `<input type="month">` will always follow the system's locale and not the user's set locale.
- "ALT" in ALT badge is not translated. It serves as a a recognizable standard across languages.
- Custom emoji names are not localized, therefore searches don't work for non-English languages.
- GIPHY API supports [a list of languages for searches](https://developers.giphy.com/docs/optional-settings/#language-support).
- Unicode Right-to-left mark (RLM) (`U+200F`, `&rlm;`) may need to be used for mixed RTL/LTR text, especially for [`<title>` element](https://www.w3.org/International/questions/qa-html-dir.en.html#title_element) (`document.title`).
- On development, there's an additional `pseudo-LOCALE` locale, used for [pseudolocalization](https://en.wikipedia.org/wiki/Pseudolocalization). It's for testing and won't show up on production.
- When building for production, English (`en`) catalog messages are not bundled separatedly. Other locales are bundled as separate files and loaded on demand. This ensures that `en` is always available as fallback.
### Volunteer translations
[![Crowdin](https://badges.crowdin.net/phanpy/localized.svg)](https://crowdin.com/project/phanpy)
Translations are managed on [Crowdin](https://crowdin.com/project/phanpy). You can help by volunteering translations.
Read the [intro documentation](https://support.crowdin.com/for-volunteer-translators/) to get started.
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want.
@ -174,6 +231,9 @@ Available variables:
- `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy):
- URL of the privacy policy page
- May specify the instance's own privacy policy
- `PHANPY_DEFAULT_LANG` (optional):
- Default language is English (`en`) if not specified.
- Fallback language after multiple detection methods (`lang` query parameter, `lang` key in `localStorage` and `navigator.language`)
- `PHANPY_LINGVA_INSTANCES` (optional, space-separated list, default: `lingva.phanpy.social [...hard-coded list of fallback instances]`):
- Specify a space-separated list of instances. First will be used as default before falling back to the subsequent instances. If there's only 1 instance, means no fallback.
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)

17
lingui.config.js Normal file
View file

@ -0,0 +1,17 @@
const config = {
locales: ['en', 'pseudo-LOCALE'],
pseudoLocale: 'pseudo-LOCALE',
fallbackLocales: {
default: 'en',
},
catalogs: [
{
path: '<rootDir>/src/locales/{locale}',
include: ['src'],
},
],
compileNamespace: 'es',
orderBy: 'origin',
};
export default config;

2541
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,12 +3,17 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:vite": "vite",
"dev": "run-p dev:vite messages:extract:watch",
"build": "vite build",
"preview": "vite preview",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"sourcemap": "npx source-map-explorer dist/assets/*.js",
"bundle-visualizer": "npx vite-bundle-visualizer"
"bundle-visualizer": "npx vite-bundle-visualizer",
"messages:extract": "lingui extract",
"messages:extract:watch": "lingui extract --watch",
"messages:extract:clean": "lingui extract --clean",
"messages:compile": "lingui compile"
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.5.4",
@ -17,15 +22,18 @@
"@github/text-expander-element": "~2.7.1",
"@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0",
"@lingui/detect-locale": "~4.11.3",
"@lingui/macro": "~4.11.3",
"@lingui/react": "~4.11.3",
"@szhsin/react-menu": "~4.2.1",
"compare-versions": "~6.1.1",
"dayjs": "~1.11.12",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.4",
"fast-equals": "~5.0.1",
"fuse.js": "~7.0.0",
"html-prettify": "~1.0.7",
"idb-keyval": "~6.2.1",
"intl-locale-textinfo-polyfill": "~2.1.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.8.0",
@ -50,7 +58,11 @@
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
"@lingui/cli": "~4.11.3",
"@lingui/vite-plugin": "~4.11.3",
"@preact/preset-vite": "~2.9.0",
"babel-plugin-macros": "~3.1.0",
"npm-run-all2": "~6.2.2",
"postcss": "~8.4.40",
"postcss-dark-theme-class": "~1.3.0",
"postcss-preset-env": "~10.0.0",

View file

@ -1,5 +1,6 @@
import './app.css';
import { useLingui } from '@lingui/react';
import debounce from 'just-debounce-it';
import {
useEffect,
@ -299,6 +300,7 @@ subscribe(states, (changes) => {
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
useLingui();
useEffect(() => {
const instanceURL = store.local.get('instanceURL');

View file

@ -1,5 +1,7 @@
import './account-block.css';
import { Plural, t, Trans } from '@lingui/macro';
// import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time';
@ -128,20 +130,23 @@ function AccountBlock({
{locked && (
<>
{' '}
<Icon icon="lock" size="s" alt="Locked" />
<Icon icon="lock" size="s" alt={t`Locked`} />
</>
)}
</span>
{showActivity && (
<div class="account-block-stats">
Posts: {shortenNumber(statusesCount)}
<Trans>Posts: {shortenNumber(statusesCount)}</Trans>
{!!lastStatusAt && (
<>
{' '}
&middot; Last posted:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
&middot;{' '}
<Trans>
Last posted:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</Trans>
</>
)}
</div>
@ -151,14 +156,14 @@ function AccountBlock({
{bot && (
<>
<span class="tag collapsed">
<Icon icon="bot" /> Automated
<Icon icon="bot" /> <Trans>Automated</Trans>
</span>
</>
)}
{!!group && (
<>
<span class="tag collapsed">
<Icon icon="group" /> Group
<Icon icon="group" /> <Trans>Group</Trans>
</span>
</>
)}
@ -167,26 +172,37 @@ function AccountBlock({
<div class="shazam-container-inner">
{excludedRelationship.following &&
excludedRelationship.followedBy ? (
<span class="tag minimal">Mutual</span>
<span class="tag minimal">
<Trans>Mutual</Trans>
</span>
) : excludedRelationship.requested ? (
<span class="tag minimal">Requested</span>
<span class="tag minimal">
<Trans>Requested</Trans>
</span>
) : excludedRelationship.following ? (
<span class="tag minimal">Following</span>
<span class="tag minimal">
<Trans>Following</Trans>
</span>
) : excludedRelationship.followedBy ? (
<span class="tag minimal">Follows you</span>
<span class="tag minimal">
<Trans>Follows you</Trans>
</span>
) : null}
</div>
</div>
)}
{!!followersCount && (
<span class="ib">
{shortenNumber(followersCount)}{' '}
{followersCount === 1 ? 'follower' : 'followers'}
<Plural
value={followersCount}
one="# follower"
other="# followers"
/>
</span>
)}
{!!verifiedField && (
<span class="verified-field">
<Icon icon="check-circle" size="s" />{' '}
<Icon icon="check-circle" size="s" alt={t`Verified`} />{' '}
<span
dangerouslySetInnerHTML={{
__html: enhanceContent(verifiedField.value, { emojis }),
@ -201,12 +217,14 @@ function AccountBlock({
!verifiedField &&
!!createdAt && (
<span class="created-at">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
<Trans>
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</Trans>
</span>
)}
</div>

View file

@ -1,5 +1,7 @@
import './account-info.css';
import { msg, plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
import {
useCallback,
@ -15,6 +17,7 @@ import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import i18nDuration from '../utils/i18n-duration';
import { getLists } from '../utils/lists';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
@ -51,15 +54,16 @@ const MUTE_DURATIONS = [
0, // forever
];
const MUTE_DURATIONS_LABELS = {
0: 'Forever',
300: '5 minutes',
1_800: '30 minutes',
3_600: '1 hour',
21_600: '6 hours',
86_400: '1 day',
259_200: '3 days',
604_800: '1 week',
0: msg`Forever`,
300: i18nDuration(5, 'minute'),
1_800: i18nDuration(30, 'minute'),
3_600: i18nDuration(1, 'hour'),
21_600: i18nDuration(6, 'hour'),
86_400: i18nDuration(1, 'day'),
259_200: i18nDuration(3, 'day'),
604_800: i18nDuration(1, 'week'),
};
console.log({ MUTE_DURATIONS_LABELS });
const LIMIT = 80;
@ -130,6 +134,7 @@ function AccountInfo({
instance,
authenticated,
}) {
const { i18n } = useLingui();
const { masto } = api({
instance,
});
@ -369,14 +374,16 @@ function AccountInfo({
>
{uiState === 'error' && (
<div class="ui-state">
<p>Unable to load account.</p>
<p>
<Trans>Unable to load account.</Trans>
</p>
<p>
<a
href={isString ? account : url}
target="_blank"
rel="noopener noreferrer"
>
Go to account page <Icon icon="external" />
<Trans>Go to account page</Trans> <Icon icon="external" />
</a>
</p>
</div>
@ -404,21 +411,21 @@ function AccountInfo({
</div>
<div class="stats">
<div>
<span></span> Followers
<span></span> <Trans>Followers</Trans>
</div>
<div>
<span></span> Following
<span></span> <Trans>Following</Trans>
</div>
<div>
<span></span> Posts
<span></span> <Trans>Posts</Trans>
</div>
</div>
</div>
<div class="actions">
<span />
<span class="buttons">
<button type="button" title="More" class="plain" disabled>
<Icon icon="more" size="l" alt="More" />
<button type="button" class="plain" disabled>
<Icon icon="more" size="l" alt={t`More`} />
</button>
</span>
</div>
@ -430,8 +437,10 @@ function AccountInfo({
{!!moved && (
<div class="account-moved">
<p>
<b>{displayName}</b> has indicated that their new account is
now:
<Trans>
<b>{displayName}</b> has indicated that their new account is
now:
</Trans>
</p>
<AccountBlock
account={moved}
@ -573,28 +582,36 @@ function AccountInfo({
: `@${acct}@${instance}`;
try {
navigator.clipboard.writeText(handleWithInstance);
showToast('Handle copied');
showToast(t`Handle copied`);
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
showToast(t`Unable to copy handle`);
}
}}
>
<Icon icon="link" />
<span>Copy handle</span>
<span>
<Trans>Copy handle</Trans>
</span>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Go to original profile page</span>
<span>
<Trans>Go to original profile page</Trans>
</span>
</MenuItem>
<MenuDivider />
<MenuLink href={info.avatar} target="_blank">
<Icon icon="user" />
<span>View profile image</span>
<span>
<Trans>View profile image</Trans>
</span>
</MenuLink>
<MenuLink href={info.header} target="_blank">
<Icon icon="media" />
<span>View profile header</span>
<span>
<Trans>View profile header</Trans>
</span>
</MenuLink>
</Menu2>
) : (
@ -608,15 +625,19 @@ function AccountInfo({
</header>
<div class="faux-header-bg" aria-hidden="true" />
<main>
{!!memorial && <span class="tag">In Memoriam</span>}
{!!memorial && (
<span class="tag">
<Trans>In Memoriam</Trans>
</span>
)}
{!!bot && (
<span class="tag">
<Icon icon="bot" /> Automated
<Icon icon="bot" /> <Trans>Automated</Trans>
</span>
)}
{!!group && (
<span class="tag">
<Icon icon="group" /> Group
<Icon icon="group" /> <Trans>Group</Trans>
</span>
)}
{roles?.map((role) => (
@ -654,7 +675,11 @@ function AccountInfo({
<b>
<EmojiText text={name} emojis={emojis} />{' '}
{!!verifiedAt && (
<Icon icon="check-circle" size="s" />
<Icon
icon="check-circle"
size="s"
alt={t`Verified`}
/>
)}
</b>
<p
@ -675,14 +700,14 @@ function AccountInfo({
setTimeout(() => {
states.showGenericAccounts = {
id: 'followers',
heading: 'Followers',
heading: t`Followers`,
fetchAccounts: fetchFollowers,
instance,
excludeRelationshipAttrs: isSelf
? ['followedBy']
: [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
? t`This user has chosen to not make this information available.`
: undefined,
};
}, 0);
@ -705,7 +730,7 @@ function AccountInfo({
<span title={followersCount}>
{shortenNumber(followersCount)}
</span>{' '}
Followers
<Trans>Followers</Trans>
</LinkOrDiv>
<LinkOrDiv
class="insignificant"
@ -715,12 +740,12 @@ function AccountInfo({
// states.showAccount = false;
setTimeout(() => {
states.showGenericAccounts = {
heading: 'Following',
heading: t`Following`,
fetchAccounts: fetchFollowing,
instance,
excludeRelationshipAttrs: isSelf ? ['following'] : [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
? t`This user has chosen to not make this information available.`
: undefined,
};
}, 0);
@ -729,7 +754,7 @@ function AccountInfo({
<span title={followingCount}>
{shortenNumber(followingCount)}
</span>{' '}
Following
<Trans>Following</Trans>
<br />
</LinkOrDiv>
<LinkOrDiv
@ -746,16 +771,18 @@ function AccountInfo({
<span title={statusesCount}>
{shortenNumber(statusesCount)}
</span>{' '}
Posts
<Trans>Posts</Trans>
</LinkOrDiv>
{!!createdAt && (
<div class="insignificant">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
<Trans>
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</Trans>
</div>
)}
</div>
@ -773,25 +800,39 @@ function AccountInfo({
{hasPostingStats ? (
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
title={t`${(
postingStats.originals / postingStats.total
).toLocaleString(i18n.locale || undefined, {
style: 'percent',
})} original posts, ${(
postingStats.replies / postingStats.total
).toLocaleString(i18n.locale || undefined, {
style: 'percent',
})} replies, ${(
postingStats.boosts / postingStats.total
).toLocaleString(i18n.locale || undefined, {
style: 'percent',
})} boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} post${
postingStats.total > 1 ? 's' : ''
} in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
? plural(postingStats.total, {
one: plural(postingStats.daysSinceLastPost, {
one: `Last 1 post in the past 1 day`,
other: `Last 1 post in the past ${postingStats.daysSinceLastPost} days`,
}),
other: plural(
postingStats.daysSinceLastPost,
{
one: `Last ${postingStats.total} posts in the past 1 day`,
other: `Last ${postingStats.total} posts in the past ${postingStats.daysSinceLastPost} days`,
},
),
})
: plural(postingStats.total, {
one: 'Last 1 post in the past year(s)',
other: `Last ${postingStats.total} posts in the past year(s)`,
})}
</div>
<div
class="posting-stats-bar"
@ -812,20 +853,22 @@ function AccountInfo({
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
<Trans>Original</Trans>
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
<Trans>Replies</Trans>
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
<Trans>Boosts</Trans>
</span>
</div>
</div>
) : (
<div class="posting-stats">Post stats unavailable.</div>
<div class="posting-stats">
<Trans>Post stats unavailable.</Trans>
</div>
)}
</div>
</div>
@ -855,7 +898,7 @@ function AccountInfo({
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
<Trans>View post stats</Trans>{' '}
{/* <Loader
abrupt
hidden={postingStatsUIState !== 'loading'}
@ -894,6 +937,7 @@ function RelatedActions({
onProfileUpdate = () => {},
}) {
if (!info) return null;
const { _ } = useLingui();
const {
masto: currentMasto,
instance: currentInstance,
@ -1012,28 +1056,40 @@ function RelatedActions({
<div class="actions">
<span>
{followedBy ? (
<span class="tag">Follows you</span>
<span class="tag">
<Trans>Follows you</Trans>
</span>
) : !!lastStatusAt ? (
<small class="insignificant">
Last post:{' '}
<span class="ib">
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</span>
<Trans>
Last post:{' '}
<span class="ib">
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</span>
</Trans>
</small>
) : (
<span />
)}
{muting && <span class="tag danger">Muted</span>}
{blocking && <span class="tag danger">Blocked</span>}
{muting && (
<span class="tag danger">
<Trans>Muted</Trans>
</span>
)}
{blocking && (
<span class="tag danger">
<Trans>Blocked</Trans>
</span>
)}
</span>{' '}
<span class="buttons">
{!!privateNote && (
<button
type="button"
class="private-note-tag"
title="Private note"
title={t`Private note`}
onClick={() => {
setShowPrivateNoteModal(true);
}}
@ -1056,13 +1112,8 @@ function RelatedActions({
position="anchor"
overflow="auto"
menuButton={
<button
type="button"
title="More"
class="plain"
disabled={loading}
>
<Icon icon="more" size="l" alt="More" />
<button type="button" class="plain" disabled={loading}>
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
onMenuChange={(e) => {
@ -1094,7 +1145,9 @@ function RelatedActions({
}}
>
<Icon icon="at" />
<span>Mention @{username}</span>
<span>
<Trans>Mention @{username}</Trans>
</span>
</MenuItem>
<MenuItem
onClick={() => {
@ -1102,7 +1155,9 @@ function RelatedActions({
}}
>
<Icon icon="translate" />
<span>Translate bio</span>
<span>
<Trans>Translate bio</Trans>
</span>
</MenuItem>
{supports('@mastodon/profile-private-note') && (
<MenuItem
@ -1112,7 +1167,7 @@ function RelatedActions({
>
<Icon icon="pencil" />
<span>
{privateNote ? 'Edit private note' : 'Add private note'}
{privateNote ? t`Edit private note` : t`Add private note`}
</span>
</MenuItem>
)}
@ -1132,8 +1187,8 @@ function RelatedActions({
setRelationshipUIState('default');
showToast(
rel.notifying
? `Notifications enabled for @${username}'s posts.`
: ` Notifications disabled for @${username}'s posts.`,
? t`Notifications enabled for @${username}'s posts.`
: t` Notifications disabled for @${username}'s posts.`,
);
} catch (e) {
alert(e);
@ -1145,8 +1200,8 @@ function RelatedActions({
<Icon icon="notification" />
<span>
{notifying
? 'Disable notifications'
: 'Enable notifications'}
? t`Disable notifications`
: t`Enable notifications`}
</span>
</MenuItem>
<MenuItem
@ -1163,8 +1218,8 @@ function RelatedActions({
setRelationshipUIState('default');
showToast(
rel.showingReblogs
? `Boosts from @${username} enabled.`
: `Boosts from @${username} disabled.`,
? t`Boosts from @${username} enabled.`
: t`Boosts from @${username} disabled.`,
);
} catch (e) {
alert(e);
@ -1175,7 +1230,7 @@ function RelatedActions({
>
<Icon icon="rocket" />
<span>
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
{showingReblogs ? t`Disable boosts` : t`Enable boosts`}
</span>
</MenuItem>
</>
@ -1191,7 +1246,7 @@ function RelatedActions({
{lists.length ? (
<>
<small class="menu-grow">
Add/Remove from Lists
<Trans>Add/Remove from Lists</Trans>
<br />
<span class="more-insignificant">
{lists.map((list) => list.title).join(', ')}
@ -1200,7 +1255,9 @@ function RelatedActions({
<small class="more-insignificant">{lists.length}</small>
</>
) : (
<span>Add/Remove from Lists</span>
<span>
<Trans>Add/Remove from Lists</Trans>
</span>
)}
</MenuItem>
)}
@ -1212,16 +1269,16 @@ function RelatedActions({
const handle = `@${currentInfo?.acct || acctWithInstance}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
showToast(t`Handle copied`);
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
showToast(t`Unable to copy handle`);
}
}}
>
<Icon icon="copy" />
<small>
Copy handle
<Trans>Copy handle</Trans>
<br />
<span class="more-insignificant bidi-isolate">
@{currentInfo?.acct || acctWithInstance}
@ -1238,15 +1295,17 @@ function RelatedActions({
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
showToast(t`Link copied`);
} catch (e) {
console.error(e);
showToast('Unable to copy link');
showToast(t`Unable to copy link`);
}
}}
>
<Icon icon="link" />
<span>Copy</span>
<span>
<Trans>Copy</Trans>
</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
@ -1260,12 +1319,14 @@ function RelatedActions({
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
alert(t`Sharing doesn't seem to work.`);
}
}}
>
<Icon icon="share" />
<span>Share</span>
<span>
<Trans>Share</Trans>
</span>
</MenuItem>
)}
</div>
@ -1284,7 +1345,7 @@ function RelatedActions({
console.log('unmuting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unmuted @${username}`);
showToast(t`Unmuted @${username}`);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
@ -1295,7 +1356,9 @@ function RelatedActions({
}}
>
<Icon icon="unmute" />
<span>Unmute @{username}</span>
<span>
<Trans>Unmute @{username}</Trans>
</span>
</MenuItem>
) : (
<SubMenu2
@ -1307,7 +1370,9 @@ function RelatedActions({
label={
<>
<Icon icon="mute" />
<span class="menu-grow">Mute @{username}</span>
<span class="menu-grow">
<Trans>Mute @{username}</Trans>
</span>
<span
style={{
textOverflow: 'clip',
@ -1336,19 +1401,26 @@ function RelatedActions({
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
t`Muted @${username} for ${
typeof MUTE_DURATIONS_LABELS[duration] ===
'function'
? MUTE_DURATIONS_LABELS[duration]()
: _(MUTE_DURATIONS_LABELS[duration])
}`,
);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
showToast(`Unable to mute @${username}`);
showToast(t`Unable to mute @${username}`);
}
})();
}}
>
{MUTE_DURATIONS_LABELS[duration]}
{typeof MUTE_DURATIONS_LABELS[duration] === 'function'
? MUTE_DURATIONS_LABELS[duration]()
: _(MUTE_DURATIONS_LABELS[duration])}
</MenuItem>
))}
</div>
@ -1361,7 +1433,9 @@ function RelatedActions({
confirmLabel={
<>
<Icon icon="user-x" />
<span>Remove @{username} from followers?</span>
<span>
<Trans>Remove @{username} from followers?</Trans>
</span>
</>
}
onClick={() => {
@ -1377,7 +1451,7 @@ function RelatedActions({
);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`@${username} removed from followers`);
showToast(t`@${username} removed from followers`);
states.reloadGenericAccounts.id = 'followers';
states.reloadGenericAccounts.counter++;
} catch (e) {
@ -1388,7 +1462,9 @@ function RelatedActions({
}}
>
<Icon icon="user-x" />
<span>Remove follower</span>
<span>
<Trans>Remove follower</Trans>
</span>
</MenuConfirm>
)}
<MenuConfirm
@ -1397,7 +1473,9 @@ function RelatedActions({
confirmLabel={
<>
<Icon icon="block" />
<span>Block @{username}?</span>
<span>
<Trans>Block @{username}?</Trans>
</span>
</>
}
menuItemClassName="danger"
@ -1415,7 +1493,7 @@ function RelatedActions({
console.log('unblocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unblocked @${username}`);
showToast(t`Unblocked @${username}`);
} else {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
@ -1423,7 +1501,7 @@ function RelatedActions({
console.log('blocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Blocked @${username}`);
showToast(t`Blocked @${username}`);
}
states.reloadGenericAccounts.id = 'block';
states.reloadGenericAccounts.counter++;
@ -1431,9 +1509,9 @@ function RelatedActions({
console.error(e);
setRelationshipUIState('error');
if (blocking) {
showToast(`Unable to unblock @${username}`);
showToast(t`Unable to unblock @${username}`);
} else {
showToast(`Unable to block @${username}`);
showToast(t`Unable to block @${username}`);
}
}
})();
@ -1442,12 +1520,16 @@ function RelatedActions({
{blocking ? (
<>
<Icon icon="unblock" />
<span>Unblock @{username}</span>
<span>
<Trans>Unblock @{username}</Trans>
</span>
</>
) : (
<>
<Icon icon="block" />
<span>Block @{username}</span>
<span>
<Trans>Block @{username}</Trans>
</span>
</>
)}
</MenuConfirm>
@ -1460,7 +1542,9 @@ function RelatedActions({
}}
>
<Icon icon="flag" />
<span>Report @{username}</span>
<span>
<Trans>Report @{username}</Trans>
</span>
</MenuItem>
</>
)}
@ -1476,7 +1560,9 @@ function RelatedActions({
}}
>
<Icon icon="pencil" />
<span>Edit profile</span>
<span>
<Trans>Edit profile</Trans>
</span>
</MenuItem>
</>
)}
@ -1511,8 +1597,8 @@ function RelatedActions({
confirmLabel={
<span>
{requested
? 'Withdraw follow request?'
: `Unfollow @${info.acct || info.username}?`}
? t`Withdraw follow request?`
: t`Unfollow @${info.acct || info.username}?`}
</span>
}
menuItemClassName="danger"
@ -1559,20 +1645,31 @@ function RelatedActions({
>
{following ? (
<>
<span>Following</span>
<span>Unfollow</span>
<span>
<Trans>Following</Trans>
</span>
<span>
<Trans>Unfollow</Trans>
</span>
</>
) : requested ? (
<>
<span>Requested</span>
<span>Withdraw</span>
<span>
<Trans>Requested</Trans>
</span>
<span>
<Trans>Withdraw</Trans>
</span>
</>
) : locked ? (
<>
<Icon icon="lock" /> <span>Follow</span>
<Icon icon="lock" />{' '}
<span>
<Trans>Follow</Trans>
</span>
</>
) : (
'Follow'
t`Follow`
)}
</button>
</MenuConfirm>
@ -1683,11 +1780,13 @@ function TranslatedBioSheet({ note, fields, onClose }) {
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>Translated Bio</h2>
<h2>
<Trans>Translated Bio</Trans>
</h2>
</header>
<main>
<p
@ -1735,11 +1834,13 @@ function AddRemoveListsSheet({ accountID, onClose }) {
<div class="sheet" id="list-add-remove-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>Add/Remove from Lists</h2>
<h2>
<Trans>Add/Remove from Lists</Trans>
</h2>
</header>
<main>
{lists.length > 0 ? (
@ -1778,14 +1879,14 @@ function AddRemoveListsSheet({ accountID, onClose }) {
setUIState('error');
alert(
inList
? 'Unable to remove from list.'
: 'Unable to add to list.',
? t`Unable to remove from list.`
: t`Unable to add to list.`,
);
}
})();
}}
>
<Icon icon="check-circle" />
<Icon icon="check-circle" alt="☑️" />
<span>{list.title}</span>
</button>
</li>
@ -1797,9 +1898,13 @@ function AddRemoveListsSheet({ accountID, onClose }) {
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load lists.</p>
<p class="ui-state">
<Trans>Unable to load lists.</Trans>
</p>
) : (
<p class="ui-state">No lists.</p>
<p class="ui-state">
<Trans>No lists.</Trans>
</p>
)}
<button
type="button"
@ -1807,7 +1912,10 @@ function AddRemoveListsSheet({ accountID, onClose }) {
onClick={() => setShowListAddEditModal(true)}
disabled={uiState !== 'default'}
>
<Icon icon="plus" size="l" /> <span>New list</span>
<Icon icon="plus" size="l" />{' '}
<span>
<Trans>New list</Trans>
</span>
</button>
</main>
{showListAddEditModal && (
@ -1859,11 +1967,15 @@ function PrivateNoteSheet({
<div class="sheet" id="private-note-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<b>Private note about @{account?.username || account?.acct}</b>
<b>
<Trans>
Private note about @{account?.username || account?.acct}
</Trans>
</b>
</header>
<main>
<form
@ -1887,7 +1999,7 @@ function PrivateNoteSheet({
} catch (e) {
console.error(e);
setUIState('error');
alert(e?.message || 'Unable to update private note.');
alert(e?.message || t`Unable to update private note.`);
}
})();
}
@ -1910,12 +2022,12 @@ function PrivateNoteSheet({
onClose?.();
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
<span>
<Loader abrupt hidden={uiState !== 'loading'} />
<button disabled={uiState === 'loading'} type="submit">
Save &amp; close
<Trans>Save &amp; close</Trans>
</button>
</span>
</footer>
@ -1952,11 +2064,13 @@ function EditProfileSheet({ onClose = () => {} }) {
<div class="sheet" id="edit-profile-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<b>Edit profile</b>
<b>
<Trans>Edit profile</Trans>
</b>
</header>
<main>
{uiState === 'loading' ? (
@ -2006,7 +2120,7 @@ function EditProfileSheet({ onClose = () => {} }) {
});
} catch (e) {
console.error(e);
alert(e?.message || 'Unable to update profile.');
alert(e?.message || t`Unable to update profile.`);
}
})();
}}
@ -2026,7 +2140,7 @@ function EditProfileSheet({ onClose = () => {} }) {
</p>
<p>
<label>
Bio
<Trans>Bio</Trans>
<textarea
defaultValue={note}
name="note"
@ -2038,12 +2152,18 @@ function EditProfileSheet({ onClose = () => {} }) {
</label>
</p>
{/* Table for fields; name and values are in fields, min 4 rows */}
<p>Extra fields</p>
<p>
<Trans>Extra fields</Trans>
</p>
<table ref={fieldsAttributesRef}>
<thead>
<tr>
<th>Label</th>
<th>Content</th>
<th>
<Trans>Label</Trans>
</th>
<th>
<Trans>Content</Trans>
</th>
</tr>
</thead>
<tbody>
@ -2072,10 +2192,10 @@ function EditProfileSheet({ onClose = () => {} }) {
onClose?.();
}}
>
Cancel
<Trans>Cancel</Trans>
</button>
<button type="submit" disabled={uiState === 'loading'}>
Save
<Trans>Save</Trans>
</button>
</footer>
</form>
@ -2128,10 +2248,11 @@ function AccountHandleInfo({ acct, instance }) {
</span>
<div class="handle-legend">
<span class="ib">
<span class="handle-legend-icon username" /> username
<span class="handle-legend-icon username" /> <Trans>username</Trans>
</span>{' '}
<span class="ib">
<span class="handle-legend-icon server" /> server domain name
<span class="handle-legend-icon server" />{' '}
<Trans>server domain name</Trans>
</span>
</div>
</div>

View file

@ -1,3 +1,4 @@
import { t } from '@lingui/macro';
import { useEffect } from 'preact/hooks';
import { api } from '../utils/api';
@ -33,7 +34,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
>
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<AccountInfo

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
@ -134,7 +135,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
const currentCloakMode = states.settings.cloakMode;
states.settings.cloakMode = !currentCloakMode;
showToast({
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,
text: currentCloakMode ? t`Cloak mode disabled` : t`Cloak mode enabled`,
});
});

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
@ -15,7 +16,7 @@ import states from '../utils/states';
import useTitle from '../utils/useTitle';
function Columns() {
useTitle('Home', '/');
useTitle(t`Home`, '/');
const snapStates = useSnapshot(states);
const { shortcuts } = snapStates;

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
@ -45,7 +46,7 @@ export default function ComposeButton() {
snapStates.composerState.publishing ? 'loading' : ''
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
>
<Icon icon="quill" size="xl" alt="Compose" />
<Icon icon="quill" size="xl" alt={t`Compose`} />
</button>
);
}

View file

@ -1,6 +1,8 @@
import './compose.css';
import '@github/text-expander-element';
import { msg, plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { MenuItem } from '@szhsin/react-menu';
import { deepEqual } from 'fast-equals';
import Fuse from 'fuse.js';
@ -27,11 +29,14 @@ import urlRegex from '../data/url-regex';
import { api } from '../utils/api';
import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import i18nDuration from '../utils/i18n-duration';
import isRTL from '../utils/is-rtl';
import localeMatch from '../utils/locale-match';
import localeCode2Text from '../utils/localeCode2Text';
import mem from '../utils/mem';
import openCompose from '../utils/open-compose';
import pmem from '../utils/pmem';
import prettyBytes from '../utils/pretty-bytes';
import { fetchRelationships } from '../utils/relationships';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
@ -74,16 +79,15 @@ const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
*/
const expiryOptions = {
'5 minutes': 5 * 60,
'30 minutes': 30 * 60,
'1 hour': 60 * 60,
'6 hours': 6 * 60 * 60,
'12 hours': 12 * 60 * 60,
'1 day': 24 * 60 * 60,
'3 days': 3 * 24 * 60 * 60,
'7 days': 7 * 24 * 60 * 60,
300: i18nDuration(5, 'minute'),
1_800: i18nDuration(30, 'minute'),
3_600: i18nDuration(1, 'hour'),
21_600: i18nDuration(6, 'hour'),
86_400: i18nDuration(1, 'day'),
259_200: i18nDuration(3, 'day'),
604_800: i18nDuration(1, 'week'),
};
const expirySeconds = Object.values(expiryOptions);
const expirySeconds = Object.keys(expiryOptions);
const oneDay = 24 * 60 * 60;
const expiresInFromExpiresAt = (expiresAt) => {
@ -191,7 +195,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
); // Emoji shortcodes
}
const rtf = new Intl.RelativeTimeFormat();
// const rtf = new Intl.RelativeTimeFormat();
const RTF = mem((locale) => new Intl.RelativeTimeFormat(locale || undefined));
const CUSTOM_EMOJIS_COUNT = 100;
@ -203,6 +208,9 @@ function Compose({
standalone,
hasOpener,
}) {
const { i18n } = useLingui();
const rtf = RTF(i18n.locale);
console.warn('RENDER COMPOSER');
const { masto, instance } = api();
const [uiState, setUIState] = useState('default');
@ -381,7 +389,7 @@ function Compose({
const formRef = useRef();
const beforeUnloadCopy = 'You have unsaved changes. Discard this post?';
const beforeUnloadCopy = t`You have unsaved changes. Discard this post?`;
const canClose = () => {
const { value, dataset } = textareaRef.current;
@ -602,7 +610,12 @@ function Compose({
}
}
if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) {
alert(`You can only attach up to ${maxMediaAttachments} files.`);
alert(
plural(maxMediaAttachments, {
one: 'You can only attach up to 1 file.',
other: 'You can only attach up to # files.',
}),
);
return;
}
console.log({ files });
@ -613,7 +626,12 @@ function Compose({
const max = maxMediaAttachments - mediaAttachments.length;
const allowedFiles = files.slice(0, max);
if (allowedFiles.length <= 0) {
alert(`You can only attach up to ${maxMediaAttachments} files.`);
alert(
plural(maxMediaAttachments, {
one: 'You can only attach up to 1 file.',
other: 'You can only attach up to # files.',
}),
);
return;
}
const mediaFiles = allowedFiles.map((file) => ({
@ -757,14 +775,14 @@ function Compose({
onClose();
}}
>
<Icon icon="popout" alt="Pop out" />
<Icon icon="popout" alt={t`Pop out`} />
</button>
<button
type="button"
class="plain4 min-button"
onClick={onMinimize}
>
<Icon icon="minimize" alt="Minimize" />
<Icon icon="minimize" alt={t`Minimize`} />
</button>{' '}
<button
type="button"
@ -776,7 +794,7 @@ function Compose({
}
}}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
</span>
) : (
@ -800,20 +818,19 @@ function Compose({
// }
if (!window.opener) {
alert('Looks like you closed the parent window.');
alert(t`Looks like you closed the parent window.`);
return;
}
if (window.opener.__STATES__.showCompose) {
if (window.opener.__STATES__.composerState?.publishing) {
alert(
'Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.',
t`Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.`,
);
return;
}
let confirmText =
'Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?';
let confirmText = t`Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?`;
const yes = confirm(confirmText);
if (!yes) return;
}
@ -855,7 +872,7 @@ function Compose({
});
}}
>
<Icon icon="popin" alt="Pop in" />
<Icon icon="popin" alt={t`Pop in`} />
</button>
)
)}
@ -864,18 +881,22 @@ function Compose({
<div class="status-preview">
<Status status={replyToStatus} size="s" previewMode />
<div class="status-preview-legend reply-to">
Replying to @
{replyToStatus.account.acct || replyToStatus.account.username}
&rsquo;s post
{replyToStatusMonthsAgo >= 3 && (
<>
{' '}
(
{replyToStatusMonthsAgo > 0 ? (
<Trans>
Replying to @
{replyToStatus.account.acct || replyToStatus.account.username}
&rsquo;s post (
<strong>
{rtf.format(-replyToStatusMonthsAgo, 'month')}
</strong>
)
</>
</Trans>
) : (
<Trans>
Replying to @
{replyToStatus.account.acct || replyToStatus.account.username}
&rsquo;s post
</Trans>
)}
</div>
</div>
@ -883,7 +904,9 @@ function Compose({
{!!editStatus && (
<div class="status-preview">
<Status status={editStatus} size="s" previewMode />
<div class="status-preview-legend">Editing source post</div>
<div class="status-preview-legend">
<Trans>Editing source post</Trans>
</div>
</div>
)}
<form
@ -929,11 +952,11 @@ function Compose({
*/
if (poll) {
if (poll.options.length < 2) {
alert('Poll must have at least 2 options');
alert(t`Poll must have at least 2 options`);
return;
}
if (poll.options.some((option) => option === '')) {
alert('Some poll choices are empty');
alert(t`Some poll choices are empty`);
return;
}
}
@ -946,7 +969,7 @@ function Compose({
);
if (hasNoDescriptions) {
const yes = confirm(
'Some media have no descriptions. Continue?',
t`Some media have no descriptions. Continue?`,
);
if (!yes) return;
}
@ -998,7 +1021,7 @@ function Compose({
results.forEach((result) => {
if (result.status === 'rejected') {
console.error(result);
alert(result.reason || `Attachment #${i} failed`);
alert(result.reason || t`Attachment #${i} failed`);
}
});
return;
@ -1092,7 +1115,7 @@ function Compose({
ref={spoilerTextRef}
type="text"
name="spoilerText"
placeholder="Content warning"
placeholder={t`Content warning`}
disabled={uiState === 'loading'}
class="spoiler-text-field"
lang={language}
@ -1108,7 +1131,7 @@ function Compose({
/>
<label
class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
title="Content warning or sensitive media"
title={t`Content warning or sensitive media`}
>
<input
name="sensitive"
@ -1144,11 +1167,17 @@ function Compose({
dir="auto"
>
<option value="public">
Public <Icon icon="earth" />
<Trans>Public</Trans>
</option>
<option value="unlisted">
<Trans>Unlisted</Trans>
</option>
<option value="private">
<Trans>Followers only</Trans>
</option>
<option value="direct">
<Trans>Private mention</Trans>
</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers only</option>
<option value="direct">Private mention</option>
</select>
</label>{' '}
</div>
@ -1156,10 +1185,10 @@ function Compose({
ref={textareaRef}
placeholder={
replyToStatus
? 'Post your reply'
? t`Post your reply`
: editStatus
? 'Edit your post'
: 'What are you doing?'
? t`Edit your post`
: t`What are you doing?`
}
required={mediaAttachments?.length === 0}
disabled={uiState === 'loading'}
@ -1233,7 +1262,9 @@ function Compose({
setSensitive(sensitive);
}}
/>{' '}
<span>Mark media as sensitive</span>{' '}
<span>
<Trans>Mark media as sensitive</Trans>
</span>{' '}
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
</label>
</div>
@ -1294,7 +1325,10 @@ function Compose({
maxMediaAttachments
) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
plural(maxMediaAttachments, {
one: 'You can only attach up to 1 file.',
other: 'You can only attach up to # files.',
}),
);
} else {
setMediaAttachments((attachments) => {
@ -1327,7 +1361,7 @@ function Compose({
});
}}
>
<Icon icon="poll" alt="Add poll" />
<Icon icon="poll" alt={t`Add poll`} />
</button>
</>
))}
@ -1349,7 +1383,7 @@ function Compose({
setShowEmoji2Picker(true);
}}
>
<Icon icon="emoji2" />
<Icon icon="emoji2" alt={t`Add custom emoji`} />
</button>
{!!states.settings.composerGIFPicker && (
<button
@ -1400,17 +1434,31 @@ function Compose({
disabled={uiState === 'loading'}
dir="auto"
>
{topSupportedLanguages.map(([code, common, native]) => (
<option value={code} key={code}>
{common} ({native})
</option>
))}
{topSupportedLanguages.map(([code, common, native]) => {
const commonText = localeCode2Text({
code,
fallback: common,
});
const same = commonText === native;
return (
<option value={code} key={code}>
{same ? commonText : `${commonText} (${native})`}
</option>
);
})}
<hr />
{restSupportedLanguages.map(([code, common, native]) => (
<option value={code} key={code}>
{common} ({native})
</option>
))}
{restSupportedLanguages.map(([code, common, native]) => {
const commonText = localeCode2Text({
code,
fallback: common,
});
const same = commonText === native;
return (
<option value={code} key={code}>
{same ? commonText : `${commonText} (${native})`}
</option>
);
})}
</select>
</label>{' '}
<button
@ -1418,7 +1466,7 @@ function Compose({
class="large"
disabled={uiState === 'loading'}
>
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
{replyToStatus ? t`Reply` : editStatus ? t`Update` : t`Post`}
</button>
</div>
</form>
@ -1531,7 +1579,10 @@ function Compose({
console.log('GIF URL', url);
if (mediaAttachments.length >= maxMediaAttachments) {
alert(
`You can only attach up to ${maxMediaAttachments} files.`,
plural(maxMediaAttachments, {
one: 'You can only attach up to 1 file.',
other: 'You can only attach up to # files.',
}),
);
return;
}
@ -1540,7 +1591,7 @@ function Compose({
let theToast;
try {
theToast = showToast({
text: 'Downloading GIF…',
text: t`Downloading GIF…`,
duration: -1,
});
const blob = await fetch(url, {
@ -1568,7 +1619,7 @@ function Compose({
} catch (err) {
console.error(err);
theToast?.hideToast?.();
showToast('Failed to download GIF');
showToast(t`Failed to download GIF`);
}
})();
}}
@ -1679,7 +1730,7 @@ const Textarea = forwardRef((props, ref) => {
${encodeHTML(shortcode)}
</li>`;
});
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
// console.log({ emojis, html });
menu.innerHTML = html;
provide(
@ -1756,7 +1807,7 @@ const Textarea = forwardRef((props, ref) => {
}
});
if (type === 'accounts') {
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
html += `<li role="option" data-value="" data-more="${text}">${t`More…`}</li>`;
}
menu.innerHTML = html;
console.log('MENU', results, menu);
@ -2029,16 +2080,6 @@ function CharCountMeter({ maxCharacters = 500, hidden }) {
);
}
function prettyBytes(bytes) {
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let unitIndex = 0;
while (bytes >= 1024) {
bytes /= 1024;
unitIndex++;
}
return `${bytes.toFixed(0).toLocaleString()} ${units[unitIndex]}`;
}
function scaleDimension(matrix, matrixLimit, width, height) {
// matrix = number of pixels
// matrixLimit = max number of pixels
@ -2056,6 +2097,7 @@ function MediaAttachment({
onDescriptionChange = () => {},
onRemove = () => {},
}) {
const { i18n } = useLingui();
const [uiState, setUIState] = useState('default');
const supportsEdit = supports('@mastodon/edit-media-attributes');
const { type, id, file } = attachment;
@ -2167,7 +2209,9 @@ function MediaAttachment({
<>
{!!id && !supportsEdit ? (
<div class="media-desc">
<span class="tag">Uploaded</span>
<span class="tag">
<Trans>Uploaded</Trans>
</span>
<p title={description}>
{attachment.description || <i>No description</i>}
</p>
@ -2179,9 +2223,9 @@ function MediaAttachment({
lang={lang}
placeholder={
{
image: 'Image description',
video: 'Video description',
audio: 'Audio description',
image: t`Image description`,
video: t`Video description`,
audio: t`Audio description`,
}[suffixType]
}
autoCapitalize="sentences"
@ -2217,7 +2261,7 @@ function MediaAttachment({
switch (type) {
case 'imageSizeLimit': {
const { imageSize, imageSizeLimit } = details;
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
imageSize,
)} to ${prettyBytes(imageSizeLimit)} or lower.`;
}
@ -2229,11 +2273,15 @@ function MediaAttachment({
width,
height,
);
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number(
width,
)}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number(
newHeight,
)}px.`;
}
case 'videoSizeLimit': {
const { videoSize, videoSizeLimit } = details;
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
videoSize,
)} to ${prettyBytes(videoSizeLimit)} or lower.`;
}
@ -2245,11 +2293,15 @@ function MediaAttachment({
width,
height,
);
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number(
width,
)}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number(
newHeight,
)}px.`;
}
case 'videoFrameRateLimit': {
// Not possible to detect this on client-side for now
return 'Frame rate too high. Uploading might encounter issues.';
return t`Frame rate too high. Uploading might encounter issues.`;
}
}
};
@ -2309,7 +2361,7 @@ function MediaAttachment({
disabled={disabled}
onClick={onRemove}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Remove`} />
</button>
{!!maxError && (
<button
@ -2326,7 +2378,7 @@ function MediaAttachment({
});
}}
>
<Icon icon="alert" />
<Icon icon="alert" alt={t`Error`} />
</button>
)}
</div>
@ -2345,15 +2397,15 @@ function MediaAttachment({
setShowModal(false);
}}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<h2>
{
{
image: 'Edit image description',
video: 'Edit video description',
audio: 'Edit audio description',
image: t`Edit image description`,
video: t`Edit video description`,
audio: t`Edit audio description`,
}[suffixType]
}
</h2>
@ -2388,8 +2440,8 @@ function MediaAttachment({
position="anchor"
overflow="auto"
menuButton={
<button type="button" title="More" class="plain">
<Icon icon="more" size="l" alt="More" />
<button type="button" class="plain">
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
@ -2398,7 +2450,7 @@ function MediaAttachment({
onClick={() => {
setUIState('loading');
toastRef.current = showToast({
text: 'Generating description. Please wait...',
text: t`Generating description. Please wait...`,
duration: -1,
});
// POST with multipart
@ -2417,9 +2469,9 @@ function MediaAttachment({
} catch (e) {
console.error(e);
showToast(
`Failed to generate description${
e?.message ? `: ${e.message}` : ''
}`,
e.message
? t`Failed to generate description: ${e.message}`
: t`Failed to generate description`,
);
} finally {
setUIState('default');
@ -2431,12 +2483,14 @@ function MediaAttachment({
<Icon icon="sparkles2" />
{lang && lang !== 'en' ? (
<small>
Generate description
<Trans>Generate description</Trans>
<br />
(English)
</small>
) : (
<span>Generate description</span>
<span>
<Trans>Generate description</Trans>
</span>
)}
</MenuItem>
{!!lang && lang !== 'en' && (
@ -2445,7 +2499,7 @@ function MediaAttachment({
onClick={() => {
setUIState('loading');
toastRef.current = showToast({
text: 'Generating description. Please wait...',
text: t`Generating description. Please wait...`,
duration: -1,
});
// POST with multipart
@ -2468,7 +2522,7 @@ function MediaAttachment({
} catch (e) {
console.error(e);
showToast(
`Failed to generate description${
t`Failed to generate description${
e?.message ? `: ${e.message}` : ''
}`,
);
@ -2481,11 +2535,14 @@ function MediaAttachment({
>
<Icon icon="sparkles2" />
<small>
Generate description
<br />({localeCode2Text(lang)}){' '}
<span class="more-insignificant">
experimental
</span>
<Trans>Generate description</Trans>
<br />
<Trans>
({localeCode2Text(lang)}){' '}
<span class="more-insignificant">
experimental
</span>
</Trans>
</small>
</MenuItem>
)}
@ -2499,7 +2556,7 @@ function MediaAttachment({
}}
disabled={uiState === 'loading'}
>
Done
<Trans>Done</Trans>
</button>
</footer>
</div>
@ -2521,6 +2578,7 @@ function Poll({
minExpiration,
maxCharactersPerOption,
}) {
const { _ } = useLingui();
const { options, expiresIn, multiple } = poll;
return (
@ -2534,7 +2592,7 @@ function Poll({
value={option}
disabled={disabled}
maxlength={maxCharactersPerOption}
placeholder={`Choice ${i + 1}`}
placeholder={t`Choice ${i + 1}`}
lang={lang}
spellCheck="true"
dir="auto"
@ -2553,7 +2611,7 @@ function Poll({
onInput(poll);
}}
>
<Icon icon="x" size="s" />
<Icon icon="x" size="s" alt={t`Remove`} />
</button>
</div>
))}
@ -2581,10 +2639,10 @@ function Poll({
onInput(poll);
}}
/>{' '}
Multiple choices
<Trans>Multiple choices</Trans>
</label>
<label class="expires-in">
Duration{' '}
<Trans>Duration</Trans>{' '}
<select
value={expiresIn}
disabled={disabled}
@ -2595,12 +2653,12 @@ function Poll({
}}
>
{Object.entries(expiryOptions)
.filter(([label, value]) => {
.filter(([value]) => {
return value >= minExpiration && value <= maxExpiration;
})
.map(([label, value]) => (
.map(([value, label]) => (
<option value={value} key={value}>
{label}
{label()}
</option>
))}
</select>
@ -2615,7 +2673,7 @@ function Poll({
onInput(null);
}}
>
Remove poll
<Trans>Remove poll</Trans>
</button>
</div>
</div>
@ -2812,7 +2870,7 @@ function MentionModal({
<div id="mention-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
@ -2829,7 +2887,7 @@ function MentionModal({
required
type="search"
class="block"
placeholder="Search accounts"
placeholder={t`Search accounts`}
onInput={(e) => {
const { value } = e.target;
debouncedLoadAccounts(value);
@ -2870,7 +2928,7 @@ function MentionModal({
selectAccount(account);
}}
>
<Icon icon="plus" size="xl" />
<Icon icon="plus" size="xl" alt={t`Add`} />
</button>
</li>
);
@ -2882,7 +2940,9 @@ function MentionModal({
</div>
) : uiState === 'error' ? (
<div class="ui-state">
<p>Error loading accounts</p>
<p>
<Trans>Error loading accounts</Trans>
</p>
</div>
) : null}
</main>
@ -3018,12 +3078,14 @@ function CustomEmojisModal({
<div id="custom-emojis-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<div>
<b>Custom emojis</b>{' '}
<b>
<Trans>Custom emojis</Trans>
</b>{' '}
{uiState === 'loading' ? (
<Loader />
) : (
@ -3042,7 +3104,7 @@ function CustomEmojisModal({
<input
ref={inputRef}
type="search"
placeholder="Search emoji"
placeholder={t`Search emoji`}
onInput={onFind}
autocomplete="off"
autocorrect="off"
@ -3072,7 +3134,9 @@ function CustomEmojisModal({
<div class="custom-emojis-list">
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading custom emojis</p>
<p>
<Trans>Error loading custom emojis</Trans>
</p>
</div>
)}
{uiState === 'default' &&
@ -3082,8 +3146,8 @@ function CustomEmojisModal({
<>
<div class="section-header">
{{
'--recent--': 'Recently used',
'--others--': 'Others',
'--recent--': t`Recently used`,
'--others--': t`Others`,
}[category] || category}
</div>
<CustomEmojisList
@ -3101,6 +3165,7 @@ function CustomEmojisModal({
}
const CustomEmojisList = memo(({ emojis, onSelect }) => {
const { i18n } = useLingui();
const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT);
const showMore = emojis.length > max;
return (
@ -3120,7 +3185,7 @@ const CustomEmojisList = memo(({ emojis, onSelect }) => {
class="plain small"
onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)}
>
{(emojis.length - max).toLocaleString()} more
<Trans>{i18n.number(emojis.length - max)} more</Trans>
</button>
)}
</section>
@ -3187,6 +3252,7 @@ const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => {
const GIFS_PER_PAGE = 20;
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
const { i18n } = useLingui();
const [uiState, setUIState] = useState('default');
const [results, setResults] = useState([]);
const formRef = useRef(null);
@ -3212,6 +3278,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
limit: GIFS_PER_PAGE,
bundle: 'messaging_non_clips',
offset,
lang: i18n.locale || 'en',
};
const response = await fetch(
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
@ -3241,7 +3308,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
<div id="gif-picker-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
@ -3256,7 +3323,7 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
ref={qRef}
type="search"
name="q"
placeholder="Search GIFs"
placeholder={t`Search GIFs`}
required
autocomplete="off"
autocorrect="off"
@ -3271,13 +3338,16 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
src={poweredByGiphyURL}
width="86"
height="30"
alt={t`Powered by GIPHY`}
/>
</form>
</header>
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
{uiState === 'default' && (
<div class="ui-state">
<p class="insignificant">Type to search GIFs</p>
<p class="insignificant">
<Trans>Type to search GIFs</Trans>
</p>
</div>
)}
{uiState === 'loading' && !results?.data?.length && (
@ -3373,7 +3443,9 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
}}
>
<Icon icon="chevron-left" />
<span>Previous</span>
<span>
<Trans>Previous</Trans>
</span>
</button>
)}
<span />
@ -3389,7 +3461,10 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
});
}}
>
<span>Next</span> <Icon icon="chevron-right" />
<span>
<Trans>Next</Trans>
</span>{' '}
<Icon icon="chevron-right" />
</button>
)}
</p>
@ -3403,7 +3478,9 @@ function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
)}
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading GIFs</p>
<p>
<Trans>Error loading GIFs</Trans>
</p>
</div>
)}
</main>

View file

@ -1,5 +1,6 @@
import './drafts.css';
import { t, Trans } from '@lingui/macro';
import { useEffect, useMemo, useReducer, useState } from 'react';
import { api } from '../utils/api';
@ -54,17 +55,20 @@ function Drafts({ onClose }) {
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
<Trans>Unsent drafts</Trans>{' '}
<Loader abrupt hidden={uiState !== 'loading'} />
</h2>
{hasDrafts && (
<div class="insignificant">
Looks like you have unsent drafts. Let's continue where you left
off.
<Trans>
Looks like you have unsent drafts. Let's continue where you left
off.
</Trans>
</div>
)}
</header>
@ -91,7 +95,11 @@ function Drafts({ onClose }) {
</time>
</b>
<MenuConfirm
confirmLabel={<span>Delete this draft?</span>}
confirmLabel={
<span>
<Trans>Delete this draft?</Trans>
</span>
}
menuItemClassName="danger"
align="end"
disabled={uiState === 'loading'}
@ -104,7 +112,7 @@ function Drafts({ onClose }) {
reload();
// }
} catch (e) {
alert('Error deleting draft! Please try again.');
alert(t`Error deleting draft! Please try again.`);
}
})();
}}
@ -114,7 +122,7 @@ function Drafts({ onClose }) {
class="small light"
disabled={uiState === 'loading'}
>
Delete&hellip;
<Trans>Delete</Trans>
</button>
</MenuConfirm>
</div>
@ -133,7 +141,7 @@ function Drafts({ onClose }) {
.fetch();
} catch (e) {
console.error(e);
alert('Error fetching reply-to status!');
alert(t`Error fetching reply-to status!`);
setUIState('default');
return;
}
@ -156,7 +164,11 @@ function Drafts({ onClose }) {
{drafts.length > 1 && (
<p>
<MenuConfirm
confirmLabel={<span>Delete all drafts?</span>}
confirmLabel={
<span>
<Trans>Delete all drafts?</Trans>
</span>
}
menuItemClassName="danger"
disabled={uiState === 'loading'}
onClick={() => {
@ -172,7 +184,7 @@ function Drafts({ onClose }) {
reload();
} catch (e) {
console.error(e);
alert('Error deleting drafts! Please try again.');
alert(t`Error deleting drafts! Please try again.`);
setUIState('error');
}
// }
@ -184,14 +196,16 @@ function Drafts({ onClose }) {
class="light danger"
disabled={uiState === 'loading'}
>
Delete all&hellip;
<Trans>Delete all</Trans>
</button>
</MenuConfirm>
</p>
)}
</>
) : (
<p>No drafts found.</p>
<p>
<Trans>No drafts found.</Trans>
</p>
)}
</main>
</div>
@ -226,10 +240,10 @@ function MiniDraft({ draft }) {
: {}
}
>
{hasPoll && <Icon icon="poll" />}
{hasPoll && <Icon icon="poll" alt={t`Poll`} />}
{hasMedia && (
<span>
<Icon icon="attachment" />{' '}
<Icon icon="attachment" alt={t`Media`} />{' '}
<small>{mediaAttachments?.length}</small>
</span>
)}

View file

@ -1,5 +1,7 @@
import './embed-modal.css';
import { t, Trans } from '@lingui/macro';
import Icon from './icon';
function EmbedModal({ html, url, width, height, onClose = () => {} }) {
@ -7,7 +9,7 @@ function EmbedModal({ html, url, width, height, onClose = () => {} }) {
<div class="embed-modal-container">
<div class="top-controls">
<button type="button" class="light" onClick={() => onClose()}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
{url && (
<a
@ -16,7 +18,10 @@ function EmbedModal({ html, url, width, height, onClose = () => {} }) {
rel="noopener noreferrer"
class="button plain"
>
<span>Open link</span> <Icon icon="external" />
<span>
<Trans>Open in new window</Trans>
</span>{' '}
<Icon icon="external" />
</a>
)}
</div>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useState } from 'preact/hooks';
import { api } from '../utils/api';
@ -38,7 +39,7 @@ function FollowRequestButtons({ accountID, onChange }) {
})();
}}
>
Accept
<Trans>Accept</Trans>
</button>{' '}
<button
type="button"
@ -64,14 +65,18 @@ function FollowRequestButtons({ accountID, onChange }) {
})();
}}
>
Reject
<Trans>Reject</Trans>
</button>
<span class="follow-request-states">
{hasRelationship && requestState ? (
requestState === 'accept' ? (
<Icon icon="check-circle" alt="Accepted" class="follow-accepted" />
<Icon
icon="check-circle"
alt={t`Accepted`}
class="follow-accepted"
/>
) : (
<Icon icon="x-circle" alt="Rejected" class="follow-rejected" />
<Icon icon="x-circle" alt={t`Rejected`} class="follow-rejected" />
)
) : (
<Loader hidden={uiState !== 'loading'} />

View file

@ -1,5 +1,6 @@
import './generic-accounts.css';
import { t, Trans } from '@lingui/macro';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
@ -20,7 +21,7 @@ export default function GenericAccounts({
excludeRelationshipAttrs = [],
postID,
onClose = () => {},
blankCopy = 'Nothing to show',
blankCopy = t`Nothing to show`,
}) {
const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true;
@ -138,10 +139,10 @@ export default function GenericAccounts({
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<h2>{heading || 'Accounts'}</h2>
<h2>{heading || t`Accounts`}</h2>
</header>
<main>
{post && (
@ -201,11 +202,13 @@ export default function GenericAccounts({
class="plain block"
onClick={() => loadAccounts()}
>
Show more&hellip;
<Trans>Show more</Trans>
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
<p class="ui-state insignificant">
<Trans>The end.</Trans>
</p>
)
) : (
uiState === 'loading' && (
@ -220,7 +223,9 @@ export default function GenericAccounts({
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
<p class="ui-state">
<Trans>Error loading accounts</Trans>
</p>
) : (
<p class="ui-state insignificant">{blankCopy}</p>
)}

View file

@ -1,5 +1,6 @@
import './keyboard-shortcuts-help.css';
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
@ -35,153 +36,157 @@ export default memo(function KeyboardShortcutsHelp() {
<Modal onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<h2>Keyboard shortcuts</h2>
<h2>
<Trans>Keyboard shortcuts</Trans>
</h2>
</header>
<main>
<table>
{[
{
action: 'Keyboard shortcuts help',
keys: <kbd>?</kbd>,
},
{
action: 'Next post',
keys: <kbd>j</kbd>,
},
{
action: 'Previous post',
keys: <kbd>k</kbd>,
},
{
action: 'Skip carousel to next post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>j</kbd>
</>
),
},
{
action: 'Skip carousel to previous post',
keys: (
<>
<kbd>Shift</kbd> + <kbd>k</kbd>
</>
),
},
{
action: 'Load new posts',
keys: <kbd>.</kbd>,
},
{
action: 'Open post details',
keys: (
<>
<kbd>Enter</kbd> or <kbd>o</kbd>
</>
),
},
{
action: (
<>
Expand content warning or
<br />
toggle expanded/collapsed thread
</>
),
keys: <kbd>x</kbd>,
},
{
action: 'Close post or dialogs',
keys: (
<>
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
</>
),
},
{
action: 'Focus column in multi-column mode',
keys: (
<>
<kbd>1</kbd> to <kbd>9</kbd>
</>
),
},
{
action: 'Compose new post',
keys: <kbd>c</kbd>,
},
{
action: 'Compose new post (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>c</kbd>
</>
),
},
{
action: 'Send post',
keys: (
<>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd>
</>
),
},
{
action: 'Search',
keys: <kbd>/</kbd>,
},
{
action: 'Reply',
keys: <kbd>r</kbd>,
},
{
action: 'Reply (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>r</kbd>
</>
),
},
{
action: 'Like (favourite)',
keys: (
<>
<kbd>l</kbd> or <kbd>f</kbd>
</>
),
},
{
action: 'Boost',
keys: (
<>
<kbd>Shift</kbd> + <kbd>b</kbd>
</>
),
},
{
action: 'Bookmark',
keys: <kbd>d</kbd>,
},
{
action: 'Toggle Cloak mode',
keys: (
<>
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
</>
),
},
].map(({ action, className, keys }) => (
<tr key={action}>
<th class={className}>{action}</th>
<td>{keys}</td>
</tr>
))}
<tbody>
{[
{
action: t`Keyboard shortcuts help`,
keys: <kbd>?</kbd>,
},
{
action: t`Next post`,
keys: <kbd>j</kbd>,
},
{
action: t`Previous post`,
keys: <kbd>k</kbd>,
},
{
action: t`Skip carousel to next post`,
keys: (
<>
<kbd>Shift</kbd> + <kbd>j</kbd>
</>
),
},
{
action: t`Skip carousel to previous post`,
keys: (
<>
<kbd>Shift</kbd> + <kbd>k</kbd>
</>
),
},
{
action: t`Load new posts`,
keys: <kbd>.</kbd>,
},
{
action: t`Open post details`,
keys: (
<>
<kbd>Enter</kbd> or <kbd>o</kbd>
</>
),
},
{
action: (
<Trans>
Expand content warning or
<br />
toggle expanded/collapsed thread
</Trans>
),
keys: <kbd>x</kbd>,
},
{
action: t`Close post or dialogs`,
keys: (
<>
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
</>
),
},
{
action: t`Focus column in multi-column mode`,
keys: (
<>
<kbd>1</kbd> to <kbd>9</kbd>
</>
),
},
{
action: t`Compose new post`,
keys: <kbd>c</kbd>,
},
{
action: t`Compose new post (new window)`,
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>c</kbd>
</>
),
},
{
action: t`Send post`,
keys: (
<>
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd></kbd> +{' '}
<kbd>Enter</kbd>
</>
),
},
{
action: t`Search`,
keys: <kbd>/</kbd>,
},
{
action: t`Reply`,
keys: <kbd>r</kbd>,
},
{
action: t`Reply (new window)`,
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>r</kbd>
</>
),
},
{
action: t`Like (favourite)`,
keys: (
<>
<kbd>l</kbd> or <kbd>f</kbd>
</>
),
},
{
action: t`Boost`,
keys: (
<>
<kbd>Shift</kbd> + <kbd>b</kbd>
</>
),
},
{
action: t`Bookmark`,
keys: <kbd>d</kbd>,
},
{
action: t`Toggle Cloak mode`,
keys: (
<>
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
</>
),
},
].map(({ action, className, keys }) => (
<tr key={action}>
<th class={className}>{action}</th>
<td>{keys}</td>
</tr>
))}
</tbody>
</table>
</main>
</div>

View file

@ -0,0 +1,42 @@
import { useLingui } from '@lingui/react';
import { activateLang, DEFAULT_LANG, LOCALES } from '../utils/lang';
import localeCode2Text from '../utils/localeCode2Text';
export default function LangSelector() {
const { i18n } = useLingui();
return (
<label class="lang-selector">
🌐{' '}
<select
value={i18n.locale || DEFAULT_LANG}
onChange={(e) => {
localStorage.setItem('lang', e.target.value);
activateLang(e.target.value);
}}
>
{LOCALES.map((lang) => {
if (lang === 'pseudo-LOCALE') {
return (
<>
<hr />
<option value={lang} key={lang}>
Pseudolocalization (test)
</option>
</>
);
}
const common = localeCode2Text(lang);
const native = localeCode2Text({ code: lang, locale: lang });
const same = common === native;
return (
<option value={lang} key={lang}>
{same ? common : `${common} (${native})`}
</option>
);
})}
</select>
</label>
);
}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
@ -29,11 +30,11 @@ function ListAddEdit({ list, onClose }) {
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}{' '}
<header>
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
<h2>{editMode ? t`Edit list` : t`New list`}</h2>
</header>
<main>
<form
@ -88,7 +89,9 @@ function ListAddEdit({ list, onClose }) {
console.error(e);
setUIState('error');
alert(
editMode ? 'Unable to edit list.' : 'Unable to create list.',
editMode
? t`Unable to edit list.`
: t`Unable to create list.`,
);
}
})();
@ -96,7 +99,7 @@ function ListAddEdit({ list, onClose }) {
>
<div class="list-form-row">
<label for="list-title">
Name{' '}
<Trans>Name</Trans>{' '}
<input
ref={nameFieldRef}
type="text"
@ -115,9 +118,15 @@ function ListAddEdit({ list, onClose }) {
required
disabled={uiState === 'loading'}
>
<option value="list">Show replies to list members</option>
<option value="followed">Show replies to people I follow</option>
<option value="none">Don't show replies</option>
<option value="list">
<Trans>Show replies to list members</Trans>
</option>
<option value="followed">
<Trans>Show replies to people I follow</Trans>
</option>
<option value="none">
<Trans>Don't show replies</Trans>
</option>
</select>
</div>
{supportsExclusive && (
@ -129,20 +138,20 @@ function ListAddEdit({ list, onClose }) {
name="exclusive"
disabled={uiState === 'loading'}
/>{' '}
Hide posts on this list from Home/Following
<Trans>Hide posts on this list from Home/Following</Trans>
</label>
</div>
)}
<div class="list-form-footer">
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
{editMode ? t`Save` : t`Create`}
</button>
{editMode && (
<MenuConfirm
disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this list?"
confirmLabel={t`Delete this list?`}
onClick={() => {
// const yes = confirm('Delete this list?');
// if (!yes) return;
@ -161,7 +170,7 @@ function ListAddEdit({ list, onClose }) {
} catch (e) {
console.error(e);
setUIState('error');
alert('Unable to delete list.');
alert(t`Unable to delete list.`);
}
})();
}}
@ -171,7 +180,7 @@ function ListAddEdit({ list, onClose }) {
class="light danger"
disabled={uiState === 'loading'}
>
Delete
<Trans>Delete</Trans>
</button>
</MenuConfirm>
)}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
@ -29,17 +30,19 @@ export default function MediaAltModal({ alt, lang, onClose }) {
<div class="sheet" tabindex="-1">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<h2>
<Trans>Media description</Trans>
</h2>
<div class="header-side">
<Menu2
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
<Icon icon="more" alt={t`More`} size="xl" />
</button>
}
>
@ -50,7 +53,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
}}
>
<Icon icon="translate" />
<span>Translate</span>
<span>
<Trans>Translate</Trans>
</span>
</MenuItem>
{supportsTTS && (
<MenuItem
@ -59,7 +64,9 @@ export default function MediaAltModal({ alt, lang, onClose }) {
}}
>
<Icon icon="speak" />
<span>Speak</span>
<span>
<Trans>Speak</Trans>
</span>
</MenuItem>
)}
</Menu2>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import {
@ -243,7 +244,7 @@ function MediaModal({
class="carousel-button"
onClick={() => onClose()}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
</span>
{mediaAttachments?.length > 1 ? (
@ -257,15 +258,13 @@ function MediaModal({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.scrollTo({
left:
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
behavior: 'smooth',
});
const left =
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1);
carouselRef.current.scrollTo({ left, behavior: 'smooth' });
carouselRef.current.focus();
}}
>
<Icon icon="round" size="s" />
<Icon icon="round" size="s" alt="⸱" />
</button>
))}
</span>
@ -281,7 +280,7 @@ function MediaModal({
menuClassName="glass-menu"
menuButton={
<button type="button" class="carousel-button">
<Icon icon="more" alt="More" />
<Icon icon="more" alt={t`More`} />
</button>
}
>
@ -292,10 +291,12 @@ function MediaModal({
}
class="carousel-button"
target="_blank"
title="Open original media in new window"
title={t`Open original media in new window`}
>
<Icon icon="popout" />
<span>Open original media</span>
<span>
<Trans>Open original media</Trans>
</span>
</MenuLink>
{import.meta.env.DEV && // Only dev for now
!!states.settings.mediaAltGenerator &&
@ -310,7 +311,7 @@ function MediaModal({
onClick={() => {
setUIState('loading');
toastRef.current = showToast({
text: 'Attempting to describe image. Please wait...',
text: t`Attempting to describe image. Please wait...`,
duration: -1,
});
(async function () {
@ -325,7 +326,7 @@ function MediaModal({
};
} catch (e) {
console.error(e);
showToast('Failed to describe image');
showToast(t`Failed to describe image`);
} finally {
setUIState('default');
toastRef.current?.hideToast?.();
@ -334,7 +335,9 @@ function MediaModal({
}}
>
<Icon icon="sparkles2" />
<span>Describe image</span>
<span>
<Trans>Describe image</Trans>
</span>
</MenuItem>
</>
)}
@ -355,7 +358,10 @@ function MediaModal({
// }
// }}
>
<span class="button-label">View post </span>&raquo;
<span class="button-label">
<Trans>View post</Trans>{' '}
</span>
&raquo;
</Link>
</span>
</div>
@ -378,7 +384,7 @@ function MediaModal({
});
}}
>
<Icon icon="arrow-left" />
<Icon icon="arrow-left" alt={t`Previous`} />
</button>
<button
type="button"
@ -397,7 +403,7 @@ function MediaModal({
});
}}
>
<Icon icon="arrow-right" />
<Icon icon="arrow-right" alt={t`Next`} />
</button>
</div>
)}

View file

@ -1,5 +1,6 @@
import './media-post.css';
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat';
import { useContext, useMemo } from 'preact/hooks';
import { useSnapshot } from 'valtio';
@ -123,11 +124,13 @@ function MediaPost({
onMouseEnter={debugHover}
key={mediaKey}
data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined)
spoilerText || (sensitive ? t`Sensitive media` : undefined)
}
data-filtered-text={
filterInfo
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
? filterTitleStr
? t`Filtered: ${filterTitleStr}`
: t`Filtered`
: undefined
}
class={`

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
@ -46,7 +47,7 @@ const AltBadge = (props) => {
lang,
};
}}
title="Media description"
title={t`Media description`}
>
{dataAltLabel}
{!!index && <sup>{index}</sup>}
@ -615,7 +616,7 @@ function Media({
/>
)}
<div class="media-play">
<Icon icon="play" size="xl" />
<Icon icon="play" size="xl" alt="▶" />
</div>
</>
)}
@ -659,7 +660,7 @@ function Media({
{!showOriginal && (
<>
<div class="media-play">
<Icon icon="play" size="xl" />
<Icon icon="play" size="xl" alt="▶" />
</div>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
@ -68,9 +69,9 @@ export default function Modals() {
states.reloadStatusPage++;
showToast({
text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
post: t`Post published. Check it out.`,
reply: t`Reply posted. Check it out.`,
edit: t`Post updated. Check it out.`,
}[type || 'post'],
delay: 1000,
duration: 10_000, // 10 seconds

View file

@ -1,16 +1,21 @@
import './name-text.css';
import { useLingui } from '@lingui/react';
import { memo } from 'preact/compat';
import { api } from '../utils/api';
import mem from '../utils/mem';
import states from '../utils/states';
import Avatar from './avatar';
import EmojiText from './emoji-text';
const nameCollator = new Intl.Collator('en', {
sensitivity: 'base',
});
const nameCollator = mem(
(locale) =>
new Intl.Collator(locale || undefined, {
sensitivity: 'base',
}),
);
function NameText({
account,
@ -21,6 +26,7 @@ function NameText({
external,
onClick,
}) {
const { i18n } = useLingui();
const {
acct,
avatar,
@ -51,7 +57,10 @@ function NameText({
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) ||
nameCollator(i18n.locale).compare(
trimmedUsername,
shortenedDisplayName,
) === 0)) ||
shortenedAlphaNumericDisplayName === acct.toLowerCase();
return (

View file

@ -1,5 +1,6 @@
import './nav-menu.css';
import { t, Trans } from '@lingui/macro';
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
@ -122,7 +123,7 @@ function NavMenu(props) {
squircle={currentAccount?.info?.bot}
/>
)}
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} alt={t`Menu`} />
</button>
<ControlledMenu
menuClassName="nav-menu"
@ -158,7 +159,7 @@ function NavMenu(props) {
<div class="top-menu">
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
const yes = confirm(t`Reload page now to update?`);
if (yes) {
(async () => {
try {
@ -169,35 +170,51 @@ function NavMenu(props) {
}}
>
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
<span>New update available</span>
<span>
<Trans>New update available</Trans>
</span>
</MenuItem>
<MenuDivider />
</div>
)}
<section>
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
<Icon icon="home" size="l" />{' '}
<span>
<Trans>Home</Trans>
</span>
</MenuLink>
{authenticated ? (
<>
{showFollowing && (
<MenuLink to="/following">
<Icon icon="following" size="l" /> <span>Following</span>
<Icon icon="following" size="l" />{' '}
<span>
<Trans>Following</Trans>
</span>
</MenuLink>
)}
<MenuLink to="/catchup">
<Icon icon="history2" size="l" />
<span>Catch-up</span>
<span>
<Trans>Catch-up</Trans>
</span>
</MenuLink>
{supports('@mastodon/mentions') && (
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
<Icon icon="at" size="l" />{' '}
<span>
<Trans>Mentions</Trans>
</span>
</MenuLink>
)}
<MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span>
<Icon icon="notification" size="l" />{' '}
<span>
<Trans>Notifications</Trans>
</span>
{snapStates.notificationsShowNew && (
<sup title="New" style={{ opacity: 0.5 }}>
<sup title={t`New`} style={{ opacity: 0.5 }}>
{' '}
&bull;
</sup>
@ -206,7 +223,10 @@ function NavMenu(props) {
<MenuDivider />
{currentAccount?.info?.id && (
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
<Icon icon="user" size="l" /> <span>Profile</span>
<Icon icon="user" size="l" />{' '}
<span>
<Trans>Profile</Trans>
</span>
</MenuLink>
)}
{lists?.length > 0 ? (
@ -217,13 +237,17 @@ function NavMenu(props) {
label={
<>
<Icon icon="list" size="l" />
<span class="menu-grow">Lists</span>
<span class="menu-grow">
<Trans>Lists</Trans>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
<span>
<Trans>All Lists</Trans>
</span>
</MenuLink>
{lists?.length > 0 && (
<>
@ -240,12 +264,17 @@ function NavMenu(props) {
supportsLists && (
<MenuLink to="/l">
<Icon icon="list" size="l" />
<span>Lists</span>
<span>
<Trans>Lists</Trans>
</span>
</MenuLink>
)
)}
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
<Icon icon="bookmark" size="l" />{' '}
<span>
<Trans>Bookmarks</Trans>
</span>
</MenuLink>
<SubMenu2
menuClassName="nav-submenu"
@ -254,49 +283,56 @@ function NavMenu(props) {
label={
<>
<Icon icon="more" size="l" />
<span class="menu-grow">More</span>
<span class="menu-grow">
<Trans>More</Trans>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
<Icon icon="heart" size="l" />{' '}
<span>
<Trans>Likes</Trans>
</span>
</MenuLink>
<MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span>
<span>
<Trans>Followed Hashtags</Trans>
</span>
</MenuLink>
<MenuDivider />
{supports('@mastodon/filters') && (
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
<Trans>Filters</Trans>
</MenuLink>
)}
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
heading: t`Muted users`,
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
<Icon icon="mute" size="l" /> <Trans>Muted users</Trans>
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
heading: t`Blocked users`,
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
<Trans>Blocked users</Trans>
</MenuItem>{' '}
</SubMenu2>
<MenuDivider />
@ -305,14 +341,20 @@ function NavMenu(props) {
states.showAccounts = true;
}}
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
<Icon icon="group" size="l" />{' '}
<span>
<Trans>Accounts</Trans>
</span>
</MenuItem>
</>
) : (
<>
<MenuDivider />
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
<Icon icon="user" size="l" />{' '}
<span>
<Trans>Log in</Trans>
</span>
</MenuLink>
</>
)}
@ -320,16 +362,28 @@ function NavMenu(props) {
<section>
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
<Icon icon="search" size="l" />{' '}
<span>
<Trans>Search</Trans>
</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
<Icon icon="chart" size="l" />{' '}
<span>
<Trans>Trending</Trans>
</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
<Icon icon="building" size="l" />{' '}
<span>
<Trans>Local</Trans>
</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
<Icon icon="earth" size="l" />{' '}
<span>
<Trans>Federated</Trans>
</span>
</MenuLink>
{authenticated ? (
<>
@ -340,7 +394,9 @@ function NavMenu(props) {
}}
>
<Icon icon="keyboard" size="l" />{' '}
<span>Keyboard shortcuts</span>
<span>
<Trans>Keyboard shortcuts</Trans>
</span>
</MenuItem>
<MenuItem
onClick={() => {
@ -348,14 +404,19 @@ function NavMenu(props) {
}}
>
<Icon icon="shortcut" size="l" />{' '}
<span>Shortcuts / Columns&hellip;</span>
<span>
<Trans>Shortcuts / Columns</Trans>
</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
<Icon icon="gear" size="l" />{' '}
<span>
<Trans>Settings</Trans>
</span>
</MenuItem>
</>
) : (
@ -366,7 +427,10 @@ function NavMenu(props) {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
<Icon icon="gear" size="l" />{' '}
<span>
<Trans>Settings</Trans>
</span>
</MenuItem>
</>
)}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat';
import { useLayoutEffect, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
@ -152,14 +153,18 @@ export default memo(function NotificationService() {
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<b>Notification</b>
<b>
<Trans>Notification</Trans>
</b>
</header>
<main>
{!sameInstance && (
<p>This notification is from your other account.</p>
<p>
<Trans>This notification is from your other account.</Trans>
</p>
)}
<div
class="notification-peek"
@ -186,7 +191,10 @@ export default memo(function NotificationService() {
}}
>
<Link to="/notifications" class="button light" onClick={onClose}>
<span>View all notifications</span> <Icon icon="arrow-right" />
<span>
<Trans>View all notifications</Trans>
</span>{' '}
<Icon icon="arrow-right" />
</Link>
</div>
</main>

View file

@ -1,9 +1,10 @@
import { msg, Plural, Select, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import useTruncated from '../utils/useTruncated';
@ -13,7 +14,6 @@ import FollowRequestButtons from './follow-request-buttons';
import Icon from './icon';
import Link from './link';
import NameText from './name-text';
import RelativeTime from './relative-time';
import Status from './status';
const NOTIFICATION_ICONS = {
@ -50,7 +50,7 @@ severed_relationships = Severed relationships
moderation_warning = Moderation warning
*/
function emojiText(emoji, emoji_url) {
function emojiText({ account, emoji, emoji_url }) {
let url;
let staticUrl;
if (typeof emoji_url === 'string') {
@ -59,42 +59,204 @@ function emojiText(emoji, emoji_url) {
url = emoji_url?.url;
staticUrl = emoji_url?.staticUrl;
}
return url ? (
<>
reacted to your post with{' '}
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
</>
const emojiObject = url ? (
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
) : (
`reacted to your post with ${emoji}.`
emoji
);
return (
<Trans>
{account} reacted to your post with {emojiObject}
</Trans>
);
}
const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
reblog: 'boosted your post.',
'reblog+account': (count) => `boosted ${count} of your posts.`,
reblog_reply: 'boosted your reply.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'liked your post.',
'favourite+account': (count) => `liked ${count} of your posts.`,
favourite_reply: 'liked your reply.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & liked your post.',
'favourite+reblog+account': (count) =>
`boosted & liked ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
severed_relationships: (name) => (
<>
Lost connections with <i>{name}</i>.
</>
status: ({ account }) => <Trans>{account} published a post.</Trans>,
reblog: ({
count,
account,
postsCount,
postType,
components: { Subject },
}) => (
<Plural
value={count}
one={
<Plural
value={postsCount}
one={
<Select
value={postType}
_reply={<Trans>{account} boosted your reply.</Trans>}
other={<Trans>{account} boosted your post.</Trans>}
/>
}
other={
<Trans>
{account} boosted {postsCount} of your posts.
</Trans>
}
/>
}
other={
<Select
value={postType}
_reply={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
boosted your reply.
</Trans>
}
other={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
boosted your post.
</Trans>
}
/>
}
/>
),
follow: ({ account, count, components: { Subject } }) => (
<Plural
value={count}
one={<Trans>{account} followed you.</Trans>}
other={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
followed you.
</Trans>
}
/>
),
follow_request: ({ account }) => (
<Trans>{account} requested to follow you.</Trans>
),
favourite: ({
account,
count,
postsCount,
postType,
components: { Subject },
}) => (
<Plural
value={count}
one={
<Plural
value={postsCount}
one={
<Select
value={postType}
_reply={<Trans>{account} liked your reply.</Trans>}
other={<Trans>{account} liked your post.</Trans>}
/>
}
other={
<Trans>
{account} liked {postsCount} of your posts.
</Trans>
}
/>
}
other={
<Select
value={postType}
_reply={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
liked your reply.
</Trans>
}
other={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
liked your post.
</Trans>
}
/>
}
/>
),
poll: () => t`A poll you have voted in or created has ended.`,
'poll-self': () => t`A poll you have created has ended.`,
'poll-voted': () => t`A poll you have voted in has ended.`,
update: () => t`A post you interacted with has been edited.`,
'favourite+reblog': ({
count,
account,
postsCount,
postType,
components: { Subject },
}) => (
<Plural
value={count}
one={
<Plural
value={postsCount}
one={
<Select
value={postType}
_reply={<Trans>{account} boosted & liked your reply.</Trans>}
other={<Trans>{account} boosted & liked your post.</Trans>}
/>
}
other={
<Trans>
{account} boosted & liked {postsCount} of your posts.
</Trans>
}
/>
}
other={
<Select
value={postType}
_reply={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
boosted & liked your reply.
</Trans>
}
other={
<Trans>
<Subject clickable={count > 1}>
<span title={count}>{shortenNumber(count)}</span> people
</Subject>{' '}
boosted & liked your post.
</Trans>
}
/>
}
/>
),
'admin.sign_up': ({ account }) => <Trans>{account} signed up.</Trans>,
'admin.report': ({ account, targetAccount }) => (
<Trans>
{account} reported {targetAccount}
</Trans>
),
severed_relationships: ({ name }) => (
<Trans>
Lost connections with <i>{name}</i>.
</Trans>
),
moderation_warning: () => (
<b>
<Trans>Moderation warning</Trans>
</b>
),
moderation_warning: <b>Moderation warning</b>,
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
};
@ -102,34 +264,33 @@ const contentText = {
// account_suspension, domain_block, user_domain_block
const SEVERED_RELATIONSHIPS_TEXT = {
account_suspension: ({ from, targetName }) => (
<>
<Trans>
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
you can no longer receive updates from them or interact with them.
</>
</Trans>
),
domain_block: ({ from, targetName, followersCount, followingCount }) => (
<>
<Trans>
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
followers: {followersCount}, followings: {followingCount}.
</>
</Trans>
),
user_domain_block: ({ targetName, followersCount, followingCount }) => (
<>
<Trans>
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
followings: {followingCount}.
</>
</Trans>
),
};
const MODERATION_WARNING_TEXT = {
none: 'Your account has received a moderation warning.',
disable: 'Your account has been disabled.',
mark_statuses_as_sensitive:
'Some of your posts have been marked as sensitive.',
delete_statuses: 'Some of your posts have been deleted.',
sensitive: 'Your posts will be marked as sensitive from now on.',
silence: 'Your account has been limited.',
suspend: 'Your account has been suspended.',
none: msg`Your account has received a moderation warning.`,
disable: msg`Your account has been disabled.`,
mark_statuses_as_sensitive: msg`Some of your posts have been marked as sensitive.`,
delete_statuses: msg`Some of your posts have been deleted.`,
sensitive: msg`Your posts will be marked as sensitive from now on.`,
silence: msg`Your account has been limited.`,
suspend: msg`Your account has been suspended.`,
};
const AVATARS_LIMIT = 30;
@ -140,6 +301,7 @@ function Notification({
isStatic,
disableContextMenu,
}) {
const { _ } = useLingui();
const {
id,
status,
@ -157,6 +319,11 @@ function Notification({
} = notification;
let { type } = notification;
if (type === 'mention' && !status) {
// Could be deleted
return null;
}
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id;
@ -189,37 +356,37 @@ function Notification({
let text;
if (type === 'poll') {
text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll'];
} else if (
type === 'reblog' ||
type === 'favourite' ||
type === 'favourite+reblog'
) {
if (_statuses?.length > 1) {
text = contentText[`${type}+account`];
} else if (isReplyToOthers) {
text = contentText[`${type}_reply`];
} else {
text = contentText[type];
}
} else if (contentText[type]) {
text = contentText[type];
} else {
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
// This surfaces the error to the user, hoping that users will report it
text = `[Unknown notification type: ${type}]`;
text = t`[Unknown notification type: ${type}]`;
}
const Subject = ({ clickable, ...props }) =>
clickable ? (
<b tabIndex="0" onClick={handleOpenGenericAccounts} {...props} />
) : (
<b {...props} />
);
if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length;
const count =
_accounts?.length || sampleAccounts?.length || (account ? 1 : 0);
const postsCount = _statuses?.length || 0;
if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
text = text({
account: <NameText account={account} showAvatar />,
targetAccount: <NameText account={targetAccount} showAvatar />,
});
}
} else if (type === 'severed_relationships') {
const targetName = event?.targetName;
if (targetName) {
text = text(targetName);
text = text({ name: targetName });
}
} else if (
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
@ -232,27 +399,28 @@ function Notification({
emoji?.shortcode ===
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
); // Emoji object instead of string
text = text(notification.emoji, emojiURL);
} else if (count) {
text = text(count);
text = text({ emoji: notification.emoji, emojiURL });
} else {
text = text({
account: account && <NameText account={account} showAvatar />,
count,
postsCount,
postType: isReplyToOthers ? 'reply' : 'post',
components: { Subject },
});
}
}
if (type === 'mention' && !status) {
// Could be deleted
return null;
}
const formattedCreatedAt =
notification.createdAt && new Date(notification.createdAt).toLocaleString();
const genericAccountsHeading =
{
'favourite+reblog': 'Boosted/Liked by…',
favourite: 'Liked by…',
reblog: 'Boosted by…',
follow: 'Followed by…',
}[type] || 'Accounts';
'favourite+reblog': t`Boosted/Liked by…`,
favourite: t`Liked by…`,
reblog: t`Boosted by…`,
follow: t`Followed by…`,
}[type] || t`Accounts`;
const handleOpenGenericAccounts = () => {
states.showGenericAccounts = {
heading: genericAccountsHeading,
@ -291,48 +459,7 @@ function Notification({
<div class="notification-content">
{type !== 'mention' && (
<>
<p>
{!/poll|update|severed_relationships/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
<span title={_accounts.length}>
{shortenNumber(_accounts.length)}
</span>{' '}
people
</b>{' '}
</>
) : notificationsCount > 1 ? (
<>
<b>
<span title={notificationsCount}>
{shortenNumber(notificationsCount)}
</span>{' '}
people
</b>{' '}
</>
) : (
account && (
<>
<NameText account={account} showAvatar />{' '}
</>
)
)}
</>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
<p>{text}</p>
{type === 'follow_request' && (
<FollowRequestButtons accountID={account.id} />
)}
@ -348,23 +475,26 @@ function Notification({
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
<Trans>
Learn more <Icon icon="external" size="s" />
</Trans>
</a>
.
</div>
)}
{type === 'moderation_warning' && !!moderation_warning && (
<div>
{MODERATION_WARNING_TEXT[moderation_warning.action]}
{_(MODERATION_WARNING_TEXT[moderation_warning.action]())}
<br />
<a
href={`/disputes/strikes/${moderation_warning.id}`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
<Trans>
Learn more <Icon icon="external" size="s" />
</Trans>
</a>
.
</div>
)}
</>
@ -541,7 +671,7 @@ function Notification({
function TruncatedLink(props) {
const ref = useTruncated();
return <Link {...props} data-read-more="Read more →" ref={ref} />;
return <Link {...props} data-read-more={t`Read more →`} ref={ref} />;
}
export default memo(Notification, (oldProps, newProps) => {

View file

@ -1,3 +1,4 @@
import { Plural, t, Trans } from '@lingui/macro';
import { useState } from 'preact/hooks';
import shortenNumber from '../utils/shorten-number';
@ -75,11 +76,15 @@ export default function Poll({
<div class="poll-options">
{options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0; // check if current poll choice is the leading one
const ratio = pollVotesCount
? optionVotesCount / pollVotesCount
: 0;
const percentage = ratio
? ratio.toLocaleString(i18n.locale || undefined, {
style: 'percent',
maximumFractionDigits: roundPrecision,
})
: '0%';
const isLeading =
optionVotesCount > 0 &&
@ -92,7 +97,7 @@ export default function Poll({
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
'--percentage': `${ratio * 100}%`,
}}
>
<div class="poll-option-title">
@ -102,7 +107,7 @@ export default function Poll({
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
<Icon icon="check-circle" alt={t`Voted`} />
</>
)}
</div>
@ -112,7 +117,7 @@ export default function Poll({
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
{percentage}
</div>
</div>
);
@ -127,7 +132,7 @@ export default function Poll({
setShowResults(false);
}}
>
<Icon icon="arrow-left" size="s" /> Hide results
<Icon icon="arrow-left" size="s" /> <Trans>Hide results</Trans>
</button>
)}
</>
@ -176,7 +181,7 @@ export default function Poll({
type="submit"
disabled={uiState === 'loading'}
>
Vote
<Trans>Vote</Trans>
</button>
)}
</form>
@ -196,9 +201,9 @@ export default function Poll({
setUIState('default');
})();
}}
title="Refresh"
title={t`Refresh`}
>
<Icon icon="refresh" alt="Refresh" />
<Icon icon="refresh" alt={t`Refresh`} />
</button>
)}
{!voted && !expired && !readOnly && optionsHaveVoteCounts && (
@ -210,30 +215,66 @@ export default function Poll({
e.preventDefault();
setShowResults(!showResults);
}}
title={showResults ? 'Hide results' : 'Show results'}
title={showResults ? t`Hide results` : t`Show results`}
>
<Icon
icon={showResults ? 'eye-open' : 'eye-close'}
alt={showResults ? 'Hide results' : 'Show results'}
alt={showResults ? t`Hide results` : t`Show results`}
/>{' '}
</button>
)}
{!expired && !readOnly && ' '}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
<Plural
value={votesCount}
one={
<Trans>
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
</Trans>
}
other={
<Trans>
<span title={votesCount}>{shortenNumber(votesCount)}</span> votes
</Trans>
}
/>
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votersCount}>
{shortenNumber(votersCount)}
</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
&bull;{' '}
<Plural
value={votersCount}
one={
<Trans>
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
</Trans>
}
other={
<Trans>
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voters
</Trans>
}
/>
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>{' '}
&bull;{' '}
{expired ? (
!!expiresAtDate ? (
<Trans>
Ended <RelativeTime datetime={expiresAtDate} />
</Trans>
) : (
t`Ended`
)
) : !!expiresAtDate ? (
<Trans>
Ending <RelativeTime datetime={expiresAtDate} />
</Trans>
) : (
t`Ending`
)}
</p>
</div>
);
}

View file

@ -1,20 +1,64 @@
// Twitter-style relative time component
// Seconds = 1s
// Minutes = 1m
// Hours = 1h
// Days = 1d
// After 7 days, use DD/MM/YYYY or MM/DD/YYYY
import { i18n } from '@lingui/core';
import { t, Trans } from '@lingui/macro';
import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useEffect, useMemo, useReducer } from 'preact/hooks';
dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
import localeMatch from '../utils/locale-match';
import mem from '../utils/mem';
const dtf = new Intl.DateTimeFormat();
const resolvedLocale = new Intl.DateTimeFormat().resolvedOptions().locale;
const DTF = mem((locale, opts = {}) => {
const lang = localeMatch([locale], [resolvedLocale]);
try {
return new Intl.DateTimeFormat(lang, opts);
} catch (e) {}
try {
return new Intl.DateTimeFormat(locale, opts);
} catch (e) {}
return new Intl.DateTimeFormat(undefined, opts);
});
const RTF = mem((locale) => new Intl.RelativeTimeFormat(locale || undefined));
const minute = 60;
const hour = 60 * minute;
const day = 24 * hour;
const rtfFromNow = (date) => {
// date = Date object
const rtf = RTF(i18n.locale);
const seconds = (date.getTime() - Date.now()) / 1000;
const absSeconds = Math.abs(seconds);
if (absSeconds < minute) {
return rtf.format(seconds, 'second');
} else if (absSeconds < hour) {
return rtf.format(Math.floor(seconds / minute), 'minute');
} else if (absSeconds < day) {
return rtf.format(Math.floor(seconds / hour), 'hour');
} else {
return rtf.format(Math.floor(seconds / day), 'day');
}
};
const twitterFromNow = (date) => {
// date = Date object
const seconds = (Date.now() - date.getTime()) / 1000;
if (seconds < minute) {
return t({
comment: 'Relative time in seconds, as short as possible',
message: `${seconds < 1 ? 1 : Math.floor(seconds)}s`,
});
} else if (seconds < hour) {
return t({
comment: 'Relative time in minutes, as short as possible',
message: `${Math.floor(seconds / minute)}m`,
});
} else {
return t({
comment: 'Relative time in hours, as short as possible',
message: `${Math.floor(seconds / hour)}h`,
});
}
};
export default function RelativeTime({ datetime, format }) {
if (!datetime) return null;
@ -27,14 +71,26 @@ export default function RelativeTime({ datetime, format }) {
// If date <= 1 day ago or day is within this year
const now = dayjs();
const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) {
str = date.twitter();
if (dayDiff <= 1) {
str = twitterFromNow(date.toDate());
} else {
str = dtf.format(date.toDate());
const currentYear = now.year();
const dateYear = date.year();
if (dateYear === currentYear) {
str = DTF(i18n.locale, {
year: undefined,
month: 'short',
day: 'numeric',
}).format(date.toDate());
} else {
str = DTF(i18n.locale, {
dateStyle: 'short',
}).format(date.toDate());
}
}
}
if (!str) str = date.fromNow();
return [str, date.toISOString(), date.format('LLLL')];
if (!str) str = rtfFromNow(date.toDate());
return [str, date.toISOString(), date.toDate().toLocaleString()];
}, [date, format, renderCount]);
useEffect(() => {

View file

@ -1,5 +1,7 @@
import './report-modal.css';
import { msg, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Fragment } from 'preact';
import { useMemo, useRef, useState } from 'preact/hooks';
@ -24,26 +26,27 @@ const CATEGORIES_INFO = {
// description: 'Not something you want to see',
// },
spam: {
label: 'Spam',
description: 'Malicious links, fake engagement, or repetitive replies',
label: msg`Spam`,
description: msg`Malicious links, fake engagement, or repetitive replies`,
},
legal: {
label: 'Illegal',
description: "Violates the law of your or the server's country",
label: msg`Illegal`,
description: msg`Violates the law of your or the server's country`,
},
violation: {
label: 'Server rule violation',
description: 'Breaks specific server rules',
stampLabel: 'Violation',
label: msg`Server rule violation`,
description: msg`Breaks specific server rules`,
stampLabel: msg`Violation`,
},
other: {
label: 'Other',
description: "Issue doesn't fit other categories",
label: msg`Other`,
description: msg`Issue doesn't fit other categories`,
excludeStamp: true,
},
};
function ReportModal({ account, post, onClose }) {
const { _ } = useLingui();
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [username, domain] = account.acct.split('@');
@ -62,14 +65,14 @@ function ReportModal({ account, post, onClose }) {
return (
<div class="report-modal-container">
<div class="top-controls">
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
<h1>{post ? t`Report Post` : t`Report @${username}`}</h1>
<button
type="button"
class="plain4 small"
disabled={uiState === 'loading'}
onClick={() => onClose()}
>
<Icon icon="x" size="xl" />
<Icon icon="x" size="xl" alt={t`Close`} />
</button>
</div>
<main>
@ -93,9 +96,13 @@ function ReportModal({ account, post, onClose }) {
key={selectedCategory}
aria-hidden="true"
>
{CATEGORIES_INFO[selectedCategory].stampLabel ||
CATEGORIES_INFO[selectedCategory].label}
<small>Pending review</small>
{_(
CATEGORIES_INFO[selectedCategory].stampLabel ||
_(CATEGORIES_INFO[selectedCategory].label),
)}
<small>
<Trans>Pending review</Trans>
</small>
</span>
)}
<form
@ -136,7 +143,7 @@ function ReportModal({ account, post, onClose }) {
forward,
});
setUIState('success');
showToast(post ? 'Post reported' : 'Profile reported');
showToast(post ? t`Post reported` : t`Profile reported`);
onClose();
} catch (error) {
console.error(error);
@ -144,8 +151,8 @@ function ReportModal({ account, post, onClose }) {
showToast(
error?.message ||
(post
? 'Unable to report post'
: 'Unable to report profile'),
? t`Unable to report post`
: t`Unable to report profile`),
);
}
})();
@ -153,8 +160,8 @@ function ReportModal({ account, post, onClose }) {
>
<p>
{post
? `What's the issue with this post?`
: `What's the issue with this profile?`}
? t`What's the issue with this post?`
: t`What's the issue with this profile?`}
</p>
<section class="report-categories">
{CATEGORIES.map((category) =>
@ -173,9 +180,9 @@ function ReportModal({ account, post, onClose }) {
}}
/>
<span>
{CATEGORIES_INFO[category].label} &nbsp;
{_(CATEGORIES_INFO[category].label)} &nbsp;
<small class="ib insignificant">
{CATEGORIES_INFO[category].description}
{_(CATEGORIES_INFO[category].description)}
</small>
</span>
</label>
@ -222,7 +229,9 @@ function ReportModal({ account, post, onClose }) {
</section>
<section class="report-comment">
<p>
<label for="report-comment">Additional info</label>
<label for="report-comment">
<Trans>Additional info</Trans>
</label>
</p>
<textarea
maxlength="1000"
@ -243,7 +252,9 @@ function ReportModal({ account, post, onClose }) {
disabled={uiState === 'loading'}
/>{' '}
<span>
Forward to <i>{domain}</i>
<Trans>
Forward to <i>{domain}</i>
</Trans>
</span>
</label>
</p>
@ -251,7 +262,7 @@ function ReportModal({ account, post, onClose }) {
)}
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report
<Trans>Send Report</Trans>
</button>{' '}
<button
type="submit"
@ -260,15 +271,17 @@ function ReportModal({ account, post, onClose }) {
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
showToast(`Muted ${username}`);
showToast(t`Muted ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to mute ${username}`);
showToast(t`Unable to mute ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Mute profile</small>
<Trans>
Send Report <small class="ib">+ Mute profile</small>
</Trans>
</button>{' '}
<button
type="submit"
@ -277,15 +290,17 @@ function ReportModal({ account, post, onClose }) {
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).block();
showToast(`Blocked ${username}`);
showToast(t`Blocked ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to block ${username}`);
showToast(t`Unable to block ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Block profile</small>
<Trans>
Send Report <small class="ib">+ Block profile</small>
</Trans>
</button>
<Loader hidden={uiState !== 'loading'} />
</footer>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { forwardRef } from 'preact/compat';
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
@ -68,7 +69,7 @@ const SearchForm = forwardRef((props, ref) => {
name="q"
type="search"
// autofocus
placeholder="Search"
placeholder={t`Search`}
dir="auto"
autocomplete="off"
autocorrect="off"
@ -198,12 +199,12 @@ const SearchForm = forwardRef((props, ref) => {
[
{
label: (
<>
<Trans>
{query}{' '}
<small class="insignificant">
accounts, hashtags &amp; posts
</small>
</>
</Trans>
),
to: `/search?q=${encodeURIComponent(query)}`,
top: !type && !/\s/.test(query),
@ -211,9 +212,9 @@ const SearchForm = forwardRef((props, ref) => {
},
{
label: (
<>
<Trans>
Posts with <q>{query}</q>
</>
</Trans>
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
@ -223,9 +224,9 @@ const SearchForm = forwardRef((props, ref) => {
},
{
label: (
<>
<Trans>
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
</>
</Trans>
),
to: `/${instance}/t/${query.replace(/^#/, '')}`,
hidden:
@ -237,9 +238,9 @@ const SearchForm = forwardRef((props, ref) => {
},
{
label: (
<>
<Trans>
Look up <mark>{query}</mark>
</>
</Trans>
),
to: `/${query}`,
hidden: !/^https?:/.test(query),
@ -248,9 +249,9 @@ const SearchForm = forwardRef((props, ref) => {
},
{
label: (
<>
<Trans>
Accounts with <q>{query}</q>
</>
</Trans>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
icon: 'group',

View file

@ -64,6 +64,10 @@
}
#shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px;
&:dir(rtl) {
transform: scaleX(-1);
}
}
@media (prefers-color-scheme: dark) {
#shortcuts-settings-container .shortcuts-view-mode label img {
@ -82,9 +86,7 @@
}
#shortcuts-settings-container .shortcuts-view-mode label input ~ * {
opacity: 0.5;
transform-origin: bottom;
transform: scale(0.975);
transition: all 0.2s ease-out;
transition: opacity 0.2s ease-out;
}
#shortcuts-settings-container .shortcuts-view-mode label.checked {
box-shadow: inset 0 0 0 3px var(--link-color),
@ -95,7 +97,6 @@
label
input:is(:hover, :active, :checked)
~ * {
transform: scale(1);
opacity: 1;
}

View file

@ -1,6 +1,8 @@
import './shortcuts-settings.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { msg, Plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
@ -43,55 +45,55 @@ const TYPES = [
// 'account-statuses', // Need @acct search first
];
const TYPE_TEXT = {
following: 'Home / Following',
notifications: 'Notifications',
list: 'Lists',
public: 'Public (Local / Federated)',
search: 'Search',
'account-statuses': 'Account',
bookmarks: 'Bookmarks',
favourites: 'Likes',
hashtag: 'Hashtag',
trending: 'Trending',
mentions: 'Mentions',
following: msg`Home / Following`,
notifications: msg`Notifications`,
list: msg`Lists`,
public: msg`Public (Local / Federated)`,
search: msg`Search`,
'account-statuses': msg`Account`,
bookmarks: msg`Bookmarks`,
favourites: msg`Likes`,
hashtag: msg`Hashtag`,
trending: msg`Trending`,
mentions: msg`Mentions`,
};
const TYPE_PARAMS = {
list: [
{
text: 'List ID',
text: msg`List ID`,
name: 'id',
notRequired: true,
},
],
public: [
{
text: 'Local only',
text: msg`Local only`,
name: 'local',
type: 'checkbox',
},
{
text: 'Instance',
text: msg`Instance`,
name: 'instance',
type: 'text',
placeholder: 'Optional, e.g. mastodon.social',
placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true,
},
],
trending: [
{
text: 'Instance',
text: msg`Instance`,
name: 'instance',
type: 'text',
placeholder: 'Optional, e.g. mastodon.social',
placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true,
},
],
search: [
{
text: 'Search term',
text: msg`Search term`,
name: 'query',
type: 'text',
placeholder: 'Optional, unless for multi-column mode',
placeholder: msg`Optional, unless for multi-column mode`,
notRequired: true,
},
],
@ -108,19 +110,19 @@ const TYPE_PARAMS = {
text: '#',
name: 'hashtag',
type: 'text',
placeholder: 'e.g. PixelArt (Max 5, space-separated)',
placeholder: msg`e.g. PixelArt (Max 5, space-separated)`,
pattern: '[^#]+',
},
{
text: 'Media only',
text: msg`Media only`,
name: 'media',
type: 'checkbox',
},
{
text: 'Instance',
text: msg`Instance`,
name: 'instance',
type: 'text',
placeholder: 'Optional, e.g. mastodon.social',
placeholder: msg`Optional, e.g. mastodon.social`,
notRequired: true,
},
],
@ -132,46 +134,46 @@ const fetchAccountTitle = pmem(async ({ id }) => {
export const SHORTCUTS_META = {
following: {
id: 'home',
title: (_, index) => (index === 0 ? 'Home' : 'Following'),
title: (_, index) => (index === 0 ? t`Home` : t`Following`),
path: '/',
icon: 'home',
},
mentions: {
id: 'mentions',
title: 'Mentions',
title: msg`Mentions`,
path: '/mentions',
icon: 'at',
},
notifications: {
id: 'notifications',
title: 'Notifications',
title: msg`Notifications`,
path: '/notifications',
icon: 'notification',
},
list: {
id: ({ id }) => (id ? 'list' : 'lists'),
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
title: ({ id }) => (id ? getListTitle(id) : t`Lists`),
path: ({ id }) => (id ? `/l/${id}` : '/l'),
icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
},
public: {
id: 'public',
title: ({ local }) => (local ? 'Local' : 'Federated'),
title: ({ local }) => (local ? t`Local` : t`Federated`),
subtitle: ({ instance }) => instance || api().instance,
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
icon: ({ local }) => (local ? 'building' : 'earth'),
},
trending: {
id: 'trending',
title: 'Trending',
title: msg`Trending`,
subtitle: ({ instance }) => instance || api().instance,
path: ({ instance }) => `/${instance}/trending`,
icon: 'chart',
},
search: {
id: 'search',
title: ({ query }) => (query ? `${query}` : 'Search'),
title: ({ query }) => (query ? `${query}` : t`Search`),
path: ({ query }) =>
query
? `/search?q=${encodeURIComponent(query)}&type=statuses`
@ -187,13 +189,13 @@ export const SHORTCUTS_META = {
},
bookmarks: {
id: 'bookmarks',
title: 'Bookmarks',
title: msg`Bookmarks`,
path: '/b',
icon: 'bookmark',
},
favourites: {
id: 'favourites',
title: 'Likes',
title: msg`Likes`,
path: '/f',
icon: 'heart',
},
@ -210,6 +212,7 @@ export const SHORTCUTS_META = {
};
function ShortcutsSettings({ onClose }) {
const { _ } = useLingui();
const snapStates = useSnapshot(states);
const { shortcuts } = snapStates;
const [showForm, setShowForm] = useState(false);
@ -221,12 +224,12 @@ function ShortcutsSettings({ onClose }) {
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>
<Icon icon="shortcut" /> Shortcuts{' '}
<Icon icon="shortcut" /> <Trans>Shortcuts</Trans>{' '}
<sup
style={{
fontSize: 12,
@ -234,27 +237,29 @@ function ShortcutsSettings({ onClose }) {
textTransform: 'uppercase',
}}
>
beta
<Trans>beta</Trans>
</sup>
</h2>
</header>
<main>
<p>Specify a list of shortcuts that'll appear&nbsp;as:</p>
<p>
<Trans>Specify a list of shortcuts that'll appear&nbsp;as:</Trans>
</p>
<div class="shortcuts-view-mode">
{[
{
value: 'float-button',
label: 'Floating button',
label: t`Floating button`,
imgURL: floatingButtonUrl,
},
{
value: 'tab-menu-bar',
label: 'Tab/Menu bar',
label: t`Tab/Menu bar`,
imgURL: tabMenuBarUrl,
},
{
value: 'multi-column',
label: 'Multi-column',
label: t`Multi-column`,
imgURL: multiColumnUrl,
},
].map(({ value, label, imgURL }) => {
@ -291,9 +296,13 @@ function ShortcutsSettings({ onClose }) {
SHORTCUTS_META[type];
if (typeof title === 'function') {
title = title(shortcut, i);
} else {
title = _(title);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i);
} else {
subtitle = _(subtitle);
}
if (typeof icon === 'function') {
icon = icon(shortcut, i);
@ -317,7 +326,7 @@ function ShortcutsSettings({ onClose }) {
)}
{excludedViewMode && (
<span class="tag">
Not available in current view mode
<Trans>Not available in current view mode</Trans>
</span>
)}
</span>
@ -336,7 +345,7 @@ function ShortcutsSettings({ onClose }) {
}
}}
>
<Icon icon="arrow-up" alt="Move up" />
<Icon icon="arrow-up" alt={t`Move up`} />
</button>
<button
type="button"
@ -352,7 +361,7 @@ function ShortcutsSettings({ onClose }) {
}
}}
>
<Icon icon="arrow-down" alt="Move down" />
<Icon icon="arrow-down" alt={t`Move down`} />
</button>
<button
type="button"
@ -364,7 +373,7 @@ function ShortcutsSettings({ onClose }) {
});
}}
>
<Icon icon="pencil" alt="Edit" />
<Icon icon="pencil" alt={t`Edit`} />
</button>
{/* <button
type="button"
@ -385,7 +394,9 @@ function ShortcutsSettings({ onClose }) {
<div class="ui-state insignificant">
<Icon icon="info" />{' '}
<small>
Add more than one shortcut/column to make this work.
<Trans>
Add more than one shortcut/column to make this work.
</Trans>
</small>
</div>
)}
@ -394,38 +405,40 @@ function ShortcutsSettings({ onClose }) {
<div class="ui-state insignificant">
<p>
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'No columns yet. Tap on the Add column button.'
: 'No shortcuts yet. Tap on the Add shortcut button.'}
? t`No columns yet. Tap on the Add column button.`
: t`No shortcuts yet. Tap on the Add shortcut button.`}
</p>
<p>
Not sure what to add?
<br />
Try adding{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
states.shortcuts = [
{
type: 'following',
},
{
type: 'notifications',
},
];
}}
>
Home / Following and Notifications
</a>{' '}
first.
<Trans>
Not sure what to add?
<br />
Try adding{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
states.shortcuts = [
{
type: 'following',
},
{
type: 'notifications',
},
];
}}
>
Home / Following and Notifications
</a>{' '}
first.
</Trans>
</p>
</div>
)}
<p class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT &&
(snapStates.settings.shortcutsViewMode === 'multi-column'
? `Max ${SHORTCUTS_LIMIT} columns`
: `Max ${SHORTCUTS_LIMIT} shortcuts`)}
? t`Max ${SHORTCUTS_LIMIT} columns`
: t`Max ${SHORTCUTS_LIMIT} shortcuts`)}
</p>
<p
style={{
@ -439,7 +452,7 @@ function ShortcutsSettings({ onClose }) {
class="light"
onClick={() => setShowImportExport(true)}
>
Import/export
<Trans>Import/export</Trans>
</button>
<button
type="button"
@ -449,8 +462,8 @@ function ShortcutsSettings({ onClose }) {
<Icon icon="plus" />{' '}
<span>
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'Add column…'
: 'Add shortcut…'}
? t`Add column…`
: t`Add shortcut…`}
</span>
</button>
</p>
@ -497,9 +510,9 @@ function ShortcutsSettings({ onClose }) {
}
const FORM_NOTES = {
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
search: `For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.',
list: msg`Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
search: msg`For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: msg`Multiple hashtags are supported. Space-separated.`,
};
function ShortcutForm({
@ -509,10 +522,10 @@ function ShortcutForm({
shortcutIndex,
onClose,
}) {
const { _ } = useLingui();
console.log('shortcut', shortcut);
const editMode = !!shortcut;
const [currentType, setCurrentType] = useState(shortcut?.type || null);
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [lists, setLists] = useState([]);
@ -564,11 +577,11 @@ function ShortcutForm({
<div id="shortcut-settings-form" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>{editMode ? 'Edit' : 'Add'} shortcut</h2>
<h2>{editMode ? t`Edit shortcut` : t`Add shortcut`}</h2>
</header>
<main tabindex="-1">
<form
@ -603,7 +616,9 @@ function ShortcutForm({
>
<p>
<label>
<span>Timeline</span>
<span>
<Trans>Timeline</Trans>
</span>
<select
required
disabled={disabled}
@ -616,7 +631,7 @@ function ShortcutForm({
>
<option></option>
{TYPES.map((type) => (
<option value={type}>{TYPE_TEXT[type]}</option>
<option value={type}>{_(TYPE_TEXT[type])}</option>
))}
</select>
</label>
@ -627,7 +642,9 @@ function ShortcutForm({
return (
<p>
<label>
<span>List</span>
<span>
<Trans>List</Trans>
</span>
<select
name="id"
required={!notRequired}
@ -648,12 +665,12 @@ function ShortcutForm({
return (
<p>
<label>
<span>{text}</span>{' '}
<span>{_(text)}</span>{' '}
<input
type={type}
switch={type === 'checkbox' || undefined}
name={name}
placeholder={placeholder}
placeholder={_(placeholder)}
required={type === 'text' && !notRequired}
disabled={disabled}
list={
@ -683,7 +700,7 @@ function ShortcutForm({
{!!FORM_NOTES[currentType] && (
<p class="form-note insignificant">
<Icon icon="info" />
{FORM_NOTES[currentType]}
{_(FORM_NOTES[currentType])}
</p>
)}
<footer>
@ -692,7 +709,7 @@ function ShortcutForm({
class="block"
disabled={disabled || uiState === 'loading'}
>
{editMode ? 'Save' : 'Add'}
{editMode ? t`Save` : t`Add`}
</button>
{editMode && (
<button
@ -703,7 +720,7 @@ function ShortcutForm({
onClose?.();
}}
>
Remove
<Trans>Remove</Trans>
</button>
)}
</footer>
@ -714,6 +731,7 @@ function ShortcutForm({
}
function ImportExport({ shortcuts, onClose }) {
const { _ } = useLingui();
const { masto } = api();
const shortcutsStr = useMemo(() => {
if (!shortcuts) return '';
@ -759,26 +777,30 @@ function ImportExport({ shortcuts, onClose }) {
<div id="import-export-container" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>
Import/Export <small class="ib insignificant">Shortcuts</small>
<Trans>
Import/Export <small class="ib insignificant">Shortcuts</small>
</Trans>
</h2>
</header>
<main tabindex="-1">
<section>
<h3>
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
<span>Import</span>
<span>
<Trans>Import</Trans>
</span>
</h3>
<p class="field-button">
<input
ref={shortcutsImportFieldRef}
type="text"
name="import"
placeholder="Paste shortcuts here"
placeholder={t`Paste shortcuts here`}
class="block"
onInput={(e) => {
setImportShortcutStr(e.target.value);
@ -794,7 +816,7 @@ function ImportExport({ shortcuts, onClose }) {
setImportUIState('cloud-downloading');
const currentAccount = getCurrentAccountID();
showToast(
'Downloading saved shortcuts from instance server…',
t`Downloading saved shortcuts from instance server…`,
);
try {
const relationships =
@ -823,10 +845,10 @@ function ImportExport({ shortcuts, onClose }) {
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to download shortcuts');
showToast(t`Unable to download shortcuts`);
}
}}
title="Download shortcuts from instance server"
title={t`Download shortcuts from instance server`}
>
<Icon icon="cloud" />
<Icon icon="arrow-down" />
@ -861,7 +883,7 @@ function ImportExport({ shortcuts, onClose }) {
*
</span>
<span>
{TYPE_TEXT[shortcut.type]}
{_(TYPE_TEXT[shortcut.type])}
{shortcut.type === 'list' && ' ⚠️'}{' '}
{TYPE_PARAMS[shortcut.type]?.map?.(
({ text, name, type }) =>
@ -883,28 +905,37 @@ function ImportExport({ shortcuts, onClose }) {
))}
</ol>
<p>
<small>* Exists in current shortcuts</small>
<small>
<Trans>* Exists in current shortcuts</Trans>
</small>
<br />
<small>
List may not work if it's from a different account.
{' '}
<Trans>
List may not work if it's from a different account.
</Trans>
</small>
</p>
</>
)}
{importUIState === 'error' && (
<p class="error">
<small> Invalid settings format</small>
<small>
<Trans>Invalid settings format</Trans>
</small>
</p>
)}
<p>
{hasCurrentSettings && (
<>
<MenuConfirm
confirmLabel="Append to current shortcuts?"
confirmLabel={t`Append to current shortcuts?`}
menuFooter={
<div class="footer">
Only shortcuts that dont exist in current shortcuts will
be appended.
<Trans>
Only shortcuts that dont exist in current shortcuts
will be appended.
</Trans>
</div>
}
onClick={() => {
@ -923,7 +954,7 @@ function ImportExport({ shortcuts, onClose }) {
),
);
if (!nonUniqueShortcuts.length) {
showToast('No new shortcuts to import');
showToast(t`No new shortcuts to import`);
return;
}
let newShortcuts = [
@ -938,8 +969,8 @@ function ImportExport({ shortcuts, onClose }) {
states.shortcuts = newShortcuts;
showToast(
exceededLimit
? `Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
: 'Shortcuts imported',
? t`Shortcuts imported. Exceeded max ${SHORTCUTS_LIMIT}, so the rest are not imported.`
: t`Shortcuts imported`,
);
onClose?.();
}}
@ -949,7 +980,7 @@ function ImportExport({ shortcuts, onClose }) {
class="plain2"
disabled={!parsedImportShortcutStr}
>
Import & append
<Trans>Import & append</Trans>
</button>
</MenuConfirm>{' '}
</>
@ -957,13 +988,13 @@ function ImportExport({ shortcuts, onClose }) {
<MenuConfirm
confirmLabel={
hasCurrentSettings
? 'Override current shortcuts?'
: 'Import shortcuts?'
? t`Override current shortcuts?`
: t`Import shortcuts?`
}
menuItemClassName={hasCurrentSettings ? 'danger' : undefined}
onClick={() => {
states.shortcuts = parsedImportShortcutStr;
showToast('Shortcuts imported');
showToast(t`Shortcuts imported`);
onClose?.();
}}
>
@ -972,7 +1003,7 @@ function ImportExport({ shortcuts, onClose }) {
class="plain2"
disabled={!parsedImportShortcutStr}
>
{hasCurrentSettings ? 'or override…' : 'Import…'}
{hasCurrentSettings ? t`or override…` : t`Import…`}
</button>
</MenuConfirm>
</p>
@ -980,7 +1011,9 @@ function ImportExport({ shortcuts, onClose }) {
<section>
<h3>
<Icon icon="arrow-up-circle" size="l" class="insignificant" />{' '}
<span>Export</span>
<span>
<Trans>Export</Trans>
</span>
</h3>
<p>
<input
@ -994,10 +1027,10 @@ function ImportExport({ shortcuts, onClose }) {
// Copy url to clipboard
try {
navigator.clipboard.writeText(e.target.value);
showToast('Shortcuts copied');
showToast(t`Shortcuts copied`);
} catch (e) {
console.error(e);
showToast('Unable to copy shortcuts');
showToast(t`Unable to copy shortcuts`);
}
}}
dir="auto"
@ -1011,14 +1044,17 @@ function ImportExport({ shortcuts, onClose }) {
onClick={() => {
try {
navigator.clipboard.writeText(shortcutsStr);
showToast('Shortcut settings copied');
showToast(t`Shortcut settings copied`);
} catch (e) {
console.error(e);
showToast('Unable to copy shortcut settings');
showToast(t`Unable to copy shortcut settings`);
}
}}
>
<Icon icon="clipboard" /> <span>Copy</span>
<Icon icon="clipboard" />{' '}
<span>
<Trans>Copy</Trans>
</span>
</button>{' '}
{navigator?.share &&
navigator?.canShare?.({
@ -1035,11 +1071,14 @@ function ImportExport({ shortcuts, onClose }) {
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
alert(t`Sharing doesn't seem to work.`);
}
}}
>
<Icon icon="share" /> <span>Share</span>
<Icon icon="share" />{' '}
<span>
<Trans>Share</Trans>
</span>
</button>
)}{' '}
{states.settings.shortcutSettingsCloudImportExport && (
@ -1077,22 +1116,22 @@ function ImportExport({ shortcuts, onClose }) {
} else {
newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`;
}
showToast('Saving shortcuts to instance server…');
showToast(t`Saving shortcuts to instance server…`);
await masto.v1.accounts
.$select(currentAccount)
.note.create({
comment: newNote,
});
setImportUIState('default');
showToast('Shortcuts saved');
showToast(t`Shortcuts saved`);
}
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to save shortcuts');
showToast(t`Unable to save shortcuts`);
}
}}
title="Sync to instance server"
title={t`Sync to instance server`}
>
<Icon icon="cloud" />
<Icon icon="arrow-up" />
@ -1100,14 +1139,20 @@ function ImportExport({ shortcuts, onClose }) {
)}{' '}
{shortcutsStr.length > 0 && (
<small class="insignificant ib">
{shortcutsStr.length} characters
<Plural
value={shortcutsStr.length}
one="# character"
other="# characters"
/>
</small>
)}
</p>
{!!shortcutsStr && (
<details>
<summary class="insignificant">
<small>Raw Shortcuts JSON</small>
<small>
<Trans>Raw Shortcuts JSON</Trans>
</small>
</summary>
<textarea style={{ width: '100%' }} rows={10} readOnly>
{JSON.stringify(shortcuts.filter(Boolean), null, 2)}
@ -1118,8 +1163,11 @@ function ImportExport({ shortcuts, onClose }) {
{states.settings.shortcutSettingsCloudImportExport && (
<footer>
<p>
<Icon icon="cloud" /> Import/export settings from/to instance
server (Very experimental)
<Icon icon="cloud" />{' '}
<Trans>
Import/export settings from/to instance server (Very
experimental)
</Trans>
</p>
</footer>
)}

View file

@ -1,5 +1,7 @@
import './shortcuts.css';
import { t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { MenuDivider } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useRef, useState } from 'preact/hooks';
@ -20,6 +22,7 @@ import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function Shortcuts() {
const { _ } = useLingui();
const { instance } = api();
const snapStates = useSnapshot(states);
const { shortcuts, settings } = snapStates;
@ -57,9 +60,13 @@ function Shortcuts() {
}
if (typeof title === 'function') {
title = title(data, i);
} else {
title = _(title);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
} else {
subtitle = _(subtitle);
}
if (typeof icon === 'function') {
icon = icon(data, i);
@ -176,7 +183,7 @@ function Shortcuts() {
} catch (e) {}
}}
>
<Icon icon="shortcut" size="xl" alt="Shortcuts" />
<Icon icon="shortcut" size="xl" alt={t`Shortcuts`} />
</button>
}
>
@ -198,7 +205,9 @@ function Shortcuts() {
}
>
<MenuLink to="/l">
<span>All Lists</span>
<span>
<Trans>All Lists</Trans>
</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { memo } from 'preact/compat';
import {
useCallback,
@ -427,7 +428,7 @@ function Timeline({
headerStart
) : (
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
)}
</div>
@ -443,7 +444,7 @@ function Timeline({
type="button"
onClick={handleLoadNewPosts}
>
<Icon icon="arrow-up" /> New posts
<Icon icon="arrow-up" /> <Trans>New posts</Trans>
</button>
)}
</header>
@ -509,11 +510,13 @@ function Timeline({
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
<Trans>Show more</Trans>
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
<p class="ui-state insignificant">
<Trans>The end.</Trans>
</p>
))}
</>
) : uiState === 'loading' ? (
@ -542,7 +545,7 @@ function Timeline({
<br />
<br />
<button type="button" onClick={() => loadItems(!items.length)}>
Try again
<Trans>Try again</Trans>
</button>
</p>
)}
@ -874,7 +877,7 @@ function StatusCarousel({ title, class: className, children }) {
});
}}
>
<Icon icon="chevron-left" />
<Icon icon="chevron-left" alt={t`Previous`} />
</button>{' '}
<button
ref={endButtonRef}
@ -891,7 +894,7 @@ function StatusCarousel({ title, class: className, children }) {
});
}}
>
<Icon icon="chevron-right" />
<Icon icon="chevron-right" alt={t`Next`} />
</button>
</span>
</header>
@ -931,14 +934,14 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
>
{!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
<Icon icon="thread" size="s" alt={t`Thread`} />
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
</div>
) : (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
<Icon icon="thread" size="s" alt={t`Thread`} />
</div>
)}
<div
@ -952,7 +955,15 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
class="status-filtered-badge badge-meta horizontal"
title={filterInfo?.titlesStr || ''}
>
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
{filterInfo?.titlesStr ? (
<Trans>
<span>Filtered</span>: <span>{filterInfo.titlesStr}</span>
</Trans>
) : (
<span>
<Trans>Filtered</Trans>
</span>
)}
</b>
) : (
<>
@ -961,7 +972,7 @@ function TimelineStatusCompact({ status, instance, filterContext }) {
<>
{' '}
<span class="spoiler-badge">
<Icon icon="eye-close" size="s" />
<Icon icon="eye-close" size="s" alt={t`Content warning`} />
</span>
</>
)}

View file

@ -1,5 +1,6 @@
import './translation-block.css';
import { t, Trans } from '@lingui/macro';
import pRetry from 'p-retry';
import pThrottle from 'p-throttle';
import { useEffect, useRef, useState } from 'preact/hooks';
@ -148,7 +149,7 @@ function TranslationBlock({
<div class="status-translation-block-mini">
<Icon
icon="translate"
alt={`Auto-translated from ${sourceLangText}`}
alt={t`Auto-translated from ${sourceLangText}`}
/>
<output
lang={targetLang}
@ -186,12 +187,12 @@ function TranslationBlock({
<Icon icon="translate" />{' '}
<span>
{uiState === 'loading'
? 'Translating…'
? t`Translating…`
: sourceLanguage && sourceLangText && !detectedLang
? autoDetected
? `Translate from ${sourceLangText} (auto-detected)`
: `Translate from ${sourceLangText}`
: `Translate`}
? t`Translate from ${sourceLangText} (auto-detected)`
: t`Translate from ${sourceLangText}`
: t`Translate`}
</span>
</button>
</summary>
@ -207,7 +208,15 @@ function TranslationBlock({
>
{sourceLanguages.map((l) => (
<option value={l.code}>
{l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name}
{l.code === 'auto'
? t`Auto (${detectedLang ?? '…'})`
: `${localeCode2Text({
code: l.code,
fallback: l.name,
})} (${localeCode2Text({
code: l.code,
locale: l.code,
})})`}
</option>
))}
</select>{' '}
@ -215,7 +224,9 @@ function TranslationBlock({
<Loader abrupt hidden={uiState !== 'loading'} />
</div>
{uiState === 'error' ? (
<p class="ui-state">Failed to translate</p>
<p class="ui-state">
<Trans>Failed to translate</Trans>
</p>
) : (
!!translatedContent && (
<>

View file

@ -2,13 +2,19 @@ import './index.css';
import './app.css';
import './polyfills';
import { i18n } from '@lingui/core';
import { t, Trans } from '@lingui/macro';
import { I18nProvider } from '@lingui/react';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import ComposeSuspense from './components/compose-suspense';
import { initActivateLang } from './utils/lang';
import { initStates } from './utils/states';
import useTitle from './utils/useTitle';
initActivateLang();
if (window.opener) {
console = window.opener.console;
}
@ -20,12 +26,12 @@ function App() {
useTitle(
editStatus
? 'Editing source status'
? t`Editing source status`
: replyToStatus
? `Replying to @${
? t`Replying to @${
replyToStatus.account?.acct || replyToStatus.account?.username
}`
: 'Compose',
: t`Compose`,
);
useEffect(() => {
@ -45,14 +51,16 @@ function App() {
if (uiState === 'closed') {
return (
<div class="box">
<p>You may close this page now.</p>
<p>
<Trans>You may close this page now.</Trans>
</p>
<p>
<button
onClick={() => {
window.close();
}}
>
Close window
<Trans>Close window</Trans>
</button>
</p>
</div>
@ -82,4 +90,9 @@ function App() {
);
}
render(<App />, document.getElementById('app-standalone'));
render(
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>,
document.getElementById('app-standalone'),
);

3633
src/locales/en.po Normal file

File diff suppressed because it is too large Load diff

3633
src/locales/pseudo-LOCALE.po Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,8 @@ import './index.css';
import './cloak-mode.css';
import './polyfills';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
// Polyfill needed for Firefox < 122
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
// import '@formatjs/intl-segmenter/polyfill';
@ -9,15 +11,20 @@ import { render } from 'preact';
import { HashRouter } from 'react-router-dom';
import { App } from './app';
import { initActivateLang } from './utils/lang';
initActivateLang();
if (import.meta.env.DEV) {
import('preact/debug');
}
render(
<HashRouter>
<App />
</HashRouter>,
<I18nProvider i18n={i18n}>
<HashRouter>
<App />
</HashRouter>
</I18nProvider>,
document.getElementById('app'),
);

View file

@ -1,3 +1,5 @@
// NOTE: UNUSED
import Link from '../components/link';
export default function NotFound() {

View file

@ -1,3 +1,5 @@
import { t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { MenuItem } from '@szhsin/react-menu';
import {
useCallback,
@ -227,33 +229,32 @@ function AccountStatuses() {
}
const [featuredTags, setFeaturedTags] = useState([]);
useTitle(
account?.acct
? `${
account?.displayName
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
account.acct
})`
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
}${
!excludeReplies
? ' (+ Replies)'
: excludeBoosts
? ' (- Boosts)'
: tagged
? ` (#${tagged})`
: media
? ' (Media)'
: month
? ` (${new Date(month).toLocaleString('default', {
month: 'long',
year: 'numeric',
})})`
: ''
}`
: 'Account posts',
'/:instance?/a/:id',
);
const { i18n } = useLingui();
let title = t`Account posts`;
if (account?.acct) {
const acctDisplay = /@/.test(account.acct) ? '' : '@' + account.acct;
const accountDisplay = account?.displayName
? `${account.displayName} (${acctDisplay})`
: `${acctDisplay}`;
if (!excludeReplies) {
title = t`${accountDisplay} (+ Replies)`;
} else if (excludeBoosts) {
title = t`${accountDisplay} (- Boosts)`;
} else if (tagged) {
title = t`${accountDisplay} (#${tagged})`;
} else if (media) {
title = t`${accountDisplay} (Media)`;
} else if (month) {
const monthYear = new Date(month).toLocaleString(i18n.locale, {
month: 'long',
year: 'numeric',
});
title = t`${accountDisplay} (${monthYear})`;
} else {
title = accountDisplay;
}
}
useTitle(title, '/:instance?/a/:id');
const fetchAccountPromiseRef = useRef();
const fetchAccount = useCallback(() => {
@ -317,46 +318,51 @@ function AccountStatuses() {
<Link
to={`/${instance}/a/${id}`}
class="insignificant filter-clear"
title="Clear filters"
title={t`Clear filters`}
key="clear-filters"
>
<Icon icon="x" size="l" />
<Icon icon="x" size="l" alt={t`Clear`} />
</Link>
) : (
<Icon icon="filter" class="insignificant" size="l" />
<Icon
icon="filter"
class="insignificant"
size="l"
alt={t`Filters`}
/>
)}
<Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
onClick={() => {
if (excludeReplies) {
showToast('Showing post with replies');
showToast(t`Showing post with replies`);
}
}}
class={excludeReplies ? '' : 'is-active'}
>
+ Replies
<Trans>+ Replies</Trans>
</Link>
<Link
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
onClick={() => {
if (!excludeBoosts) {
showToast('Showing posts without boosts');
showToast(t`Showing posts without boosts`);
}
}}
class={!excludeBoosts ? '' : 'is-active'}
>
- Boosts
<Trans>- Boosts</Trans>
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
showToast(t`Showing posts with media`);
}
}}
class={media ? 'is-active' : ''}
>
Media
<Trans>Media</Trans>
</Link>
{featuredTags.map((tag) => (
<Link
@ -368,7 +374,7 @@ function AccountStatuses() {
}`}
onClick={() => {
if (tagged !== tag.name) {
showToast(`Showing posts tagged with #${tag.name}`);
showToast(t`Showing posts tagged with #${tag.name}`);
}
}}
class={tagged === tag.name ? 'is-active' : ''}
@ -407,7 +413,7 @@ function AccountStatuses() {
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast(
`Showing posts in ${date.toLocaleString('default', {
t`Showing posts in ${date.toLocaleString(i18n.locale, {
month: 'long',
year: 'numeric',
})}`,
@ -475,7 +481,7 @@ function AccountStatuses() {
return (
<Timeline
key={id}
title={`${account?.acct ? '@' + account.acct : 'Posts'}`}
title={`${account?.acct ? '@' + account.acct : t`Posts`}`}
titleComponent={
<h1
class="header-double-lines header-account"
@ -496,8 +502,8 @@ function AccountStatuses() {
}
id="account-statuses"
instance={instance}
emptyText="Nothing to see here yet."
errorText="Unable to load posts"
emptyText={t`Nothing to see here yet.`}
errorText={t`Unable to load posts`}
fetchItems={fetchAccountStatuses}
useItemID
view={media || mediaFirst ? 'media' : undefined}
@ -519,7 +525,7 @@ function AccountStatuses() {
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
@ -538,20 +544,22 @@ function AccountStatuses() {
location.hash = `/${accountInstance}/a/${id}`;
} catch (e) {
console.error(e);
alert('Unable to fetch account info');
alert(t`Unable to fetch account info`);
}
})();
}}
>
<Icon icon="transfer" />{' '}
<small class="menu-double-lines">
Switch to account's instance{' '}
{accountInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(accountInstance)}</b>)
</>
) : null}
<Trans>
Switch to account's instance{' '}
{accountInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(accountInstance)}</b>)
</>
) : null}
</Trans>
</small>
</MenuItem>
{!sameCurrentInstance && (
@ -566,14 +574,16 @@ function AccountStatuses() {
location.hash = `/${currentInstance}/a/${id}`;
} catch (e) {
console.error(e);
alert('Unable to fetch account info');
alert(t`Unable to fetch account info`);
}
})();
}}
>
<Icon icon="transfer" />{' '}
<small class="menu-double-lines">
Switch to my instance (<b>{currentInstance}</b>)
<Trans>
Switch to my instance (<b>{currentInstance}</b>)
</Trans>
</small>
</MenuItem>
)}
@ -584,6 +594,7 @@ function AccountStatuses() {
}
function MonthPicker(props) {
const { i18n } = useLingui();
const {
class: className,
disabled,
@ -631,7 +642,9 @@ function MonthPicker(props) {
});
}}
>
<option value="">Month</option>
<option value="">
<Trans>Month</Trans>
</option>
<option disabled>-----</option>
{Array.from({ length: 12 }, (_, i) => (
<option
@ -641,7 +654,7 @@ function MonthPicker(props) {
}
key={i}
>
{new Date(0, i).toLocaleString('default', {
{new Date(0, i).toLocaleString(i18n.locale, {
month: 'long',
})}
</option>

View file

@ -1,6 +1,7 @@
import './accounts.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { t, Trans } from '@lingui/macro';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useReducer } from 'preact/hooks';
@ -29,11 +30,13 @@ function Accounts({ onClose }) {
<div id="accounts-container" class="sheet" tabIndex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header class="header-grid">
<h2>Accounts</h2>
<h2>
<Trans>Accounts</Trans>
</h2>
</header>
<main>
<section>
@ -46,7 +49,7 @@ function Accounts({ onClose }) {
<div>
{moreThanOneAccount && (
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
<Icon icon="check-circle" alt="Current" />
<Icon icon="check-circle" alt={t`Current`} />
</span>
)}
<Avatar
@ -91,18 +94,16 @@ function Accounts({ onClose }) {
<div class="actions">
{isDefault && moreThanOneAccount && (
<>
<span class="tag">Default</span>{' '}
<span class="tag">
<Trans>Default</Trans>
</span>{' '}
</>
)}
<Menu2
align="end"
menuButton={
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
<button type="button" class="plain more-button">
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
@ -112,7 +113,9 @@ function Accounts({ onClose }) {
}}
>
<Icon icon="user" />
<span>View profile</span>
<span>
<Trans>View profile</Trans>
</span>
</MenuItem>
<MenuDivider />
{moreThanOneAccount && (
@ -127,7 +130,9 @@ function Accounts({ onClose }) {
}}
>
<Icon icon="check-circle" />
<span>Set as default</span>
<span>
<Trans>Set as default</Trans>
</span>
</MenuItem>
)}
<MenuConfirm
@ -135,7 +140,9 @@ function Accounts({ onClose }) {
confirmLabel={
<>
<Icon icon="exit" />
<span>Log out @{account.info.acct}?</span>
<span>
<Trans>Log out @{account.info.acct}?</Trans>
</span>
</>
}
disabled={!isCurrent}
@ -150,7 +157,9 @@ function Accounts({ onClose }) {
}}
>
<Icon icon="exit" />
<span>Log out</span>
<span>
<Trans>Log out</Trans>
</span>
</MenuConfirm>
</Menu2>
</div>
@ -160,14 +169,19 @@ function Accounts({ onClose }) {
</ul>
<p>
<Link to="/login" class="button plain2" onClick={onClose}>
<Icon icon="plus" /> <span>Add an existing account</span>
<Icon icon="plus" />{' '}
<span>
<Trans>Add an existing account</Trans>
</span>
</Link>
</p>
{moreThanOneAccount && (
<p>
<small>
Note: <i>Default</i> account will always be used for first load.
Switched accounts will persist during the session.
<Trans>
Note: <i>Default</i> account will always be used for first
load. Switched accounts will persist during the session.
</Trans>
</small>
</p>
)}

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useRef } from 'preact/hooks';
import Timeline from '../components/timeline';
@ -7,7 +8,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Bookmarks() {
useTitle('Bookmarks', '/b');
useTitle(t`Bookmarks`, '/bookmarks');
const { masto, instance } = api();
const bookmarksIterator = useRef();
async function fetchBookmarks(firstLoad) {
@ -19,10 +20,10 @@ function Bookmarks() {
return (
<Timeline
title="Bookmarks"
title={t`Bookmarks`}
id="bookmarks"
emptyText="No bookmarks yet. Go bookmark something!"
errorText="Unable to load bookmarks"
emptyText={`No bookmarks yet. Go bookmark something!`}
errorText={t`Unable to load bookmarks.`}
instance={instance}
fetchItems={fetchBookmarks}
/>

View file

@ -2,6 +2,8 @@ import '../components/links-bar.css';
import './catchup.css';
import autoAnimate from '@formkit/auto-animate';
import { msg, Plural, select, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
@ -34,6 +36,7 @@ import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import { isFiltered } from '../utils/filters';
import htmlContentLength from '../utils/html-content-length';
import mem from '../utils/mem';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
@ -48,29 +51,29 @@ import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = 'home';
const RANGES = [
{ label: 'last 1 hour', value: 1 },
{ label: 'last 2 hours', value: 2 },
{ label: 'last 3 hours', value: 3 },
{ label: 'last 4 hours', value: 4 },
{ label: 'last 5 hours', value: 5 },
{ label: 'last 6 hours', value: 6 },
{ label: 'last 7 hours', value: 7 },
{ label: 'last 8 hours', value: 8 },
{ label: 'last 9 hours', value: 9 },
{ label: 'last 10 hours', value: 10 },
{ label: 'last 11 hours', value: 11 },
{ label: 'last 12 hours', value: 12 },
{ label: 'beyond 12 hours', value: 13 },
{ label: msg`last 1 hour`, value: 1 },
{ label: msg`last 2 hours`, value: 2 },
{ label: msg`last 3 hours`, value: 3 },
{ label: msg`last 4 hours`, value: 4 },
{ label: msg`last 5 hours`, value: 5 },
{ label: msg`last 6 hours`, value: 6 },
{ label: msg`last 7 hours`, value: 7 },
{ label: msg`last 8 hours`, value: 8 },
{ label: msg`last 9 hours`, value: 9 },
{ label: msg`last 10 hours`, value: 10 },
{ label: msg`last 11 hours`, value: 11 },
{ label: msg`last 12 hours`, value: 12 },
{ label: msg`beyond 12 hours`, value: 13 },
];
const FILTER_LABELS = [
'Original',
'Replies',
'Boosts',
'Followed tags',
'Groups',
'Filtered',
];
const FILTER_KEYS = {
original: msg`Original`,
replies: msg`Replies`,
boosts: msg`Boosts`,
followedTags: msg`Followed tags`,
groups: msg`Groups`,
filtered: msg`Filtered`,
};
const FILTER_SORTS = [
'createdAt',
'repliesCount',
@ -79,33 +82,23 @@ const FILTER_SORTS = [
'density',
];
const FILTER_GROUPS = [null, 'account'];
const FILTER_VALUES = {
Filtered: 'filtered',
Groups: 'group',
Boosts: 'boost',
Replies: 'reply',
'Followed tags': 'followedTags',
Original: 'original',
};
const FILTER_CATEGORY_TEXT = {
Filtered: 'filtered posts',
Groups: 'group posts',
Boosts: 'boosts',
Replies: 'replies',
'Followed tags': 'followed-tag posts',
Original: 'original posts',
};
const SORT_BY_TEXT = {
// asc, desc
createdAt: ['oldest', 'latest'],
repliesCount: ['fewest replies', 'most replies'],
favouritesCount: ['fewest likes', 'most likes'],
reblogsCount: ['fewest boosts', 'most boosts'],
density: ['least dense', 'most dense'],
};
const DTF = mem(
(locale) =>
new Intl.DateTimeFormat(locale || undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}),
);
function Catchup() {
useTitle('Catch-up', '/catchup');
const { i18n, _ } = useLingui();
const dtf = DTF(i18n.locale);
useTitle(`Catch-up`, '/catchup');
const { masto, instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const id = searchParams.get('id');
@ -307,23 +300,23 @@ function Catchup() {
}, [uiState === 'start']);
const [filterCounts, links] = useMemo(() => {
let filtereds = 0,
let filtered = 0,
groups = 0,
boosts = 0,
replies = 0,
followedTags = 0,
originals = 0;
original = 0;
const links = {};
for (const post of posts) {
if (post._filtered) {
filtereds++;
filtered++;
post.__FILTER = 'filtered';
} else if (post.group) {
groups++;
post.__FILTER = 'group';
post.__FILTER = 'groups';
} else if (post.reblog) {
boosts++;
post.__FILTER = 'boost';
post.__FILTER = 'boosts';
} else if (post._followedTags?.length) {
followedTags++;
post.__FILTER = 'followedTags';
@ -332,9 +325,9 @@ function Catchup() {
post.inReplyToAccountId !== post.account?.id
) {
replies++;
post.__FILTER = 'reply';
post.__FILTER = 'replies';
} else {
originals++;
original++;
post.__FILTER = 'original';
}
@ -401,18 +394,18 @@ function Catchup() {
return [
{
Filtered: filtereds,
Groups: groups,
Boosts: boosts,
Replies: replies,
'Followed tags': followedTags,
Original: originals,
filtered,
groups,
boosts,
replies,
followedTags,
original,
},
topLinks,
];
}, [posts]);
const [selectedFilterCategory, setSelectedFilterCategory] = useState('All');
const [selectedFilterCategory, setSelectedFilterCategory] = useState('all');
const [selectedAuthor, setSelectedAuthor] = useState(null);
const [range, setRange] = useState(1);
@ -427,8 +420,8 @@ function Catchup() {
let filteredPosts = posts.filter((post) => {
const postFilterMatches =
selectedFilterCategory === 'All' ||
post.__FILTER === FILTER_VALUES[selectedFilterCategory];
selectedFilterCategory === 'all' ||
post.__FILTER === selectedFilterCategory;
if (postFilterMatches) {
authorsHash[post.account.id] = post.account;
@ -599,15 +592,37 @@ function Catchup() {
};
let toast = showToast({
duration: 5_000, // 5 seconds
text: `Showing ${
FILTER_CATEGORY_TEXT[selectedFilterCategory] || 'all posts'
}${authorUsername ? ` by @${authorUsername}` : ''}, ${
SORT_BY_TEXT[sortBy][sortOrderIndex]
} first${
!!groupBy
? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}`
: ''
}`,
// Note: I'm sorry, translators
text: t`Showing ${select(selectedFilterCategory, {
all: 'all posts',
original: 'original posts',
replies: 'replies',
boosts: 'boosts',
followedTags: 'followed tags',
groups: 'groups',
filtered: 'filtered posts',
})}, ${select(sortBy, {
createdAt: select(sortOrder, {
asc: 'oldest',
desc: 'latest',
}),
reblogsCount: select(sortOrder, {
asc: 'fewest boosts',
desc: 'most boosts',
}),
favouritesCount: select(sortOrder, {
asc: 'fewest likes',
desc: 'most likes',
}),
repliesCount: select(sortOrder, {
asc: 'fewest replies',
desc: 'most replies',
}),
density: select(sortOrder, { asc: 'least dense', desc: 'most dense' }),
})} first${select(groupBy, {
account: ', grouped by authors',
other: '',
})}`,
});
return () => {
toast?.hideToast?.();
@ -837,20 +852,20 @@ function Catchup() {
<NavMenu />
{uiState === 'results' && (
<Link to="/catchup" class="button plain">
<Icon icon="history2" size="l" />
<Icon icon="history2" size="l" alt={t`Catch-up`} />
</Link>
)}
{uiState === 'start' && (
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
)}
</div>
<h1>
{uiState !== 'start' && (
<>
<Trans>
Catch-up <sup>beta</sup>
</>
</Trans>
)}
</h1>
<div class="header-side">
@ -862,7 +877,7 @@ function Catchup() {
setShowHelp(true);
}}
>
Help
<Trans>Help</Trans>
</button>
)}
</div>
@ -872,20 +887,27 @@ function Catchup() {
{uiState === 'start' && (
<div class="catchup-start">
<h1>
Catch-up <sup>beta</sup>
<Trans>
Catch-up <sup>beta</sup>
</Trans>
</h1>
<details>
<summary>What is this?</summary>
<summary>
<Trans>What is this?</Trans>
</summary>
<p>
Catch-up is a separate timeline for your followings, offering
a high-level view at a glance, with a simple, email-inspired
interface to effortlessly sort and filter through posts.
<Trans>
Catch-up is a separate timeline for your followings,
offering a high-level view at a glance, with a simple,
email-inspired interface to effortlessly sort and filter
through posts.
</Trans>
</p>
<img
src={catchupUrl}
width="1200"
height="900"
alt="Preview of Catch-up UI"
alt={t`Preview of Catch-up UI`}
/>
<p>
<button
@ -894,13 +916,17 @@ function Catchup() {
e.target.closest('details').open = false;
}}
>
Let's catch up
<Trans>Let's catch up</Trans>
</button>
</p>
</details>
<p>Let's catch up on the posts from your followings.</p>
<p>
<b>Show me all posts from</b>
<Trans>Let's catch up on the posts from your followings.</Trans>
</p>
<p>
<b>
<Trans>Show me all posts from</Trans>
</b>
</p>
<div class="catchup-form">
<input
@ -918,11 +944,11 @@ function Catchup() {
width: '8em',
}}
>
{RANGES[range - 1].label}
{_(RANGES[range - 1].label)}
<br />
<small class="insignificant">
{range == RANGES[RANGES.length - 1].value
? 'until the max'
? t`until the max`
: niceDateTime(
new Date(Date.now() - range * 60 * 60 * 1000),
)}
@ -930,7 +956,7 @@ function Catchup() {
</span>
<datalist id="catchup-ranges">
{RANGES.map(({ label, value }) => (
<option value={value} label={label} />
<option value={value} label={_(label)} />
))}
</datalist>{' '}
<button
@ -952,12 +978,13 @@ function Catchup() {
}
}}
>
Catch up
<Trans>Catch up</Trans>
</button>
</div>
{lastCatchupRange && range > lastCatchupRange ? (
<p class="catchup-info">
<Icon icon="info" /> Overlaps with your last catch-up
<Icon icon="info" />{' '}
<Trans>Overlaps with your last catch-up</Trans>
</p>
) : range === RANGES[RANGES.length - 1].value &&
lastCatchupEndAt ? (
@ -969,21 +996,27 @@ function Catchup() {
checked
ref={catchupLastRef}
/>{' '}
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
<Trans>
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
</Trans>
</label>
</p>
) : null}
<p class="insignificant">
<small>
Note: your instance might only show a maximum of 800 posts in
the Home timeline regardless of the time range. Could be less
or more.
<Trans>
Note: your instance might only show a maximum of 800 posts
in the Home timeline regardless of the time range. Could be
less or more.
</Trans>
</small>
</p>
{!!prevCatchups?.length && (
<div class="catchup-prev">
<p>Previously</p>
<p>
<Trans>Previously</Trans>
</p>
<ul>
{prevCatchups.map((pc) => (
<li key={pc.id}>
@ -1000,23 +1033,29 @@ function Catchup() {
</Link>{' '}
<span>
<small class="ib insignificant">
{pc.count} posts
<Plural
value={pc.count}
one="# post"
other="# posts"
/>
</small>{' '}
<button
type="button"
class="light danger small"
onClick={async () => {
const yes = confirm('Remove this catch-up?');
const yes = confirm(t`Remove this catch-up?`);
if (yes) {
let t = showToast(`Removing Catch-up ${pc.id}`);
let t = showToast(
t`Removing Catch-up ${pc.id}`,
);
await db.catchup.del(pc.id);
t?.hideToast?.();
showToast(`Catch-up ${pc.id} removed`);
showToast(t`Catch-up ${pc.id} removed`);
reloadCatchups();
}
}}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Remove`} />
</button>
</span>
</li>
@ -1025,8 +1064,10 @@ function Catchup() {
{prevCatchups.length >= 3 && (
<p>
<small>
Note: Only max 3 will be stored. The rest will be
automatically removed.
<Trans>
Note: Only max 3 will be stored. The rest will be
automatically removed.
</Trans>
</small>
</p>
)}
@ -1037,8 +1078,12 @@ function Catchup() {
{uiState === 'loading' && (
<div class="ui-state catchup-start">
<Loader abrupt />
<p class="insignificant">Fetching posts</p>
<p class="insignificant">This might take a while.</p>
<p class="insignificant">
<Trans>Fetching posts</Trans>
</p>
<p class="insignificant">
<Trans>This might take a while.</Trans>
</p>
</div>
)}
{uiState === 'results' && (
@ -1057,7 +1102,7 @@ function Catchup() {
<aside>
<button
hidden={
selectedFilterCategory === 'All' &&
selectedFilterCategory === 'all' &&
!selectedAuthor &&
sortBy === 'createdAt' &&
sortOrder === 'asc'
@ -1065,14 +1110,14 @@ function Catchup() {
type="button"
class="plain4 small"
onClick={() => {
setSelectedFilterCategory('All');
setSelectedFilterCategory('all');
setSelectedAuthor(null);
setSortBy('createdAt');
setGroupBy(null);
setSortOrder('asc');
}}
>
Reset filters
<Trans>Reset filters</Trans>
</button>
{links?.length > 0 && (
<button
@ -1080,7 +1125,7 @@ function Catchup() {
class="plain small"
onClick={() => setShowTopLinks(!showTopLinks)}
>
Top links{' '}
<Trans>Top links</Trans>{' '}
<Icon
icon="chevron-down"
style={{
@ -1196,17 +1241,19 @@ function Catchup() {
whiteSpace: 'nowrap',
}}
>
Shared by{' '}
{sharers.map((s) => {
const { avatarStatic, displayName } = s;
return (
<Avatar
url={avatarStatic}
size="s"
alt={displayName}
/>
);
})}
<Trans>
Shared by{' '}
{sharers.map((s) => {
const { avatarStatic, displayName } = s;
return (
<Avatar
url={avatarStatic}
size="s"
alt={displayName}
/>
);
})}
</Trans>
</p>
</div>
</article>
@ -1230,22 +1277,21 @@ function Catchup() {
name="filter-cat"
checked={selectedFilterCategory.toLowerCase() === 'all'}
onChange={() => {
setSelectedFilterCategory('All');
setSelectedFilterCategory('all');
}}
/>
All <span class="count">{posts.length}</span>
<Trans>All</Trans> <span class="count">{posts.length}</span>
</label>
{FILTER_LABELS.map(
(label) =>
!!filterCounts[label] && (
{Object.entries(FILTER_KEYS).map(
([key, label]) =>
!!filterCounts[key] && (
<label
class="filter-cat"
key={label}
key={_(label)}
title={
(
(filterCounts[label] / posts.length) *
100
).toFixed(2) + '%'
((filterCounts[key] / posts.length) * 100).toFixed(
2,
) + '%'
}
>
<input
@ -1253,11 +1299,11 @@ function Catchup() {
name="filter-cat"
checked={
selectedFilterCategory.toLowerCase() ===
label.toLowerCase()
key.toLowerCase()
}
onChange={() => {
setSelectedFilterCategory(label);
if (label === 'Boosts') {
setSelectedFilterCategory(key);
if (key === 'boosts') {
setSortBy('reblogsCount');
setSortOrder('desc');
setGroupBy(null);
@ -1265,8 +1311,8 @@ function Catchup() {
// setSelectedAuthor(null);
}}
/>
{label}{' '}
<span class="count">{filterCounts[label]}</span>
{_(label)}{' '}
<span class="count">{filterCounts[key]}</span>
</label>
),
)}
@ -1319,14 +1365,20 @@ function Catchup() {
opacity: 0.33,
}}
>
{authorCountsList.length} authors
<Plural
value={authorCountsList.length}
one="# author"
other="# authors"
/>
</small>
)}
</div>
)}
{posts.length >= 2 && (
<div class="catchup-filters">
<span class="filter-label">Sort</span>{' '}
<span class="filter-label">
<Trans>Sort</Trans>
</span>{' '}
<fieldset class="radio-field-group">
{FILTER_SORTS.map((key) => (
<label
@ -1356,11 +1408,11 @@ function Catchup() {
/>
{
{
createdAt: 'Date',
repliesCount: 'Replies',
favouritesCount: 'Likes',
reblogsCount: 'Boosts',
density: 'Density',
createdAt: t`Date`,
repliesCount: t`Replies`,
favouritesCount: t`Likes`,
reblogsCount: t`Boosts`,
density: t`Density`,
}[key]
}
{sortBy === key && (sortOrder === 'asc' ? ' ↑' : ' ↓')}
@ -1382,7 +1434,9 @@ function Catchup() {
</label>
))}
</fieldset> */}
<span class="filter-label">Group</span>{' '}
<span class="filter-label">
<Trans>Group</Trans>
</span>{' '}
<fieldset class="radio-field-group">
{FILTER_GROUPS.map((key) => (
<label class="filter-group" key={key || 'none'}>
@ -1396,8 +1450,8 @@ function Catchup() {
disabled={key === 'account' && selectedAuthor}
/>
{{
account: 'Authors',
}[key] || 'None'}
account: t`Authors`,
}[key] || t`None`}
</label>
))}
</fieldset>
@ -1413,7 +1467,7 @@ function Catchup() {
whiteSpace: 'nowrap',
}}
>
Show all authors
<Trans>Show all authors</Trans>
</button>
) : null
// <button
@ -1428,7 +1482,7 @@ function Catchup() {
)}
<ul
class={`catchup-list catchup-filter-${
FILTER_VALUES[selectedFilterCategory] || ''
selectedFilterCategory || ''
} ${sortBy ? `catchup-sort-${sortBy}` : ''} ${
selectedAuthor && authors[selectedAuthor]
? `catchup-selected-author`
@ -1463,9 +1517,9 @@ function Catchup() {
<footer>
{filteredPosts.length > 5 && (
<p>
{selectedFilterCategory === 'Boosts'
? "You don't have to read everything."
: "That's all."}{' '}
{selectedFilterCategory === 'boosts'
? t`You don't have to read everything.`
: t`That's all.`}{' '}
<button
type="button"
class="textual"
@ -1473,7 +1527,7 @@ function Catchup() {
scrollableRef.current.scrollTop = 0;
}}
>
Back to top
<Trans>Back to top</Trans>
</button>
.
</p>
@ -1491,47 +1545,117 @@ function Catchup() {
class="sheet-close"
onClick={() => setShowHelp(false)}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<h2>Help</h2>
<h2>
<Trans>Help</Trans>
</h2>
</header>
<main>
<dl>
<dt>Top links</dt>
<dt>
<Trans>Top links</Trans>
</dt>
<dd>
Links shared by followings, sorted by shared counts, boosts
and likes.
<Trans>
Links shared by followings, sorted by shared counts, boosts
and likes.
</Trans>
</dd>
<dt>Sort: Density</dt>
<dt>
<Trans>Sort: Density</Trans>
</dt>
<dd>
Posts are sorted by information density or depth. Shorter
posts are "lighter" while longer posts are "heavier". Posts
with photos are "heavier" than posts without photos.
<Trans>
Posts are sorted by information density or depth. Shorter
posts are "lighter" while longer posts are "heavier". Posts
with photos are "heavier" than posts without photos.
</Trans>
</dd>
<dt>Group: Authors</dt>
<dt>
<Trans>Group: Authors</Trans>
</dt>
<dd>
Posts are grouped by authors, sorted by posts count per
author.
<Trans>
Posts are grouped by authors, sorted by posts count per
author.
</Trans>
</dd>
<dt>Keyboard shortcuts</dt>
<dd>
<kbd>j</kbd>: Next post
<dt>
<Trans>Keyboard shortcuts</Trans>
</dt>
{/* <dd>
<kbd>j</kbd>: <Trans>Next post</Trans>
</dd>
<dd>
<kbd>k</kbd>: Previous post
<kbd>k</kbd>: <Trans>Previous post</Trans>
</dd>
<dd>
<kbd>l</kbd>: Next author
<kbd>l</kbd>: <Trans>Next author</Trans>
</dd>
<dd>
<kbd>h</kbd>: Previous author
<kbd>h</kbd>: <Trans>Previous author</Trans>
</dd>
<dd>
<kbd>Enter</kbd>: Open post details
<kbd>Enter</kbd>: <Trans>Open post details</Trans>
</dd>
<dd>
<kbd>.</kbd>: Scroll to top
<kbd>.</kbd>: <Trans>Scroll to top</Trans>
</dd> */}
<dd>
<table>
<tbody>
<tr>
<td>
<Trans>Next post</Trans>
</td>
<td>
<kbd>j</kbd>
</td>
</tr>
<tr>
<td>
<Trans>Previous post</Trans>
</td>
<td>
<kbd>k</kbd>
</td>
</tr>
<tr>
<td>
<Trans>Next author</Trans>
</td>
<td>
<kbd>l</kbd>
</td>
</tr>
<tr>
<td>
<Trans>Previous author</Trans>
</td>
<td>
<kbd>h</kbd>
</td>
</tr>
<tr>
<td>
<Trans>Open post details</Trans>
</td>
<td>
<kbd>Enter</kbd>
</td>
</tr>
<tr>
<td>
<Trans>Scroll to top</Trans>
</td>
<td>
<kbd>.</kbd>
</td>
</tr>
</tbody>
</table>
</dd>
</dl>
</main>
@ -1713,7 +1837,10 @@ function PostPeek({ post, filterInfo }) {
)}
{!!filterInfo ? (
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
{/* Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''} */}
{filterInfo?.titlesStr
? t`Filtered: ${filterInfo.titlesStr}`
: t`Filtered`}
</span>
) : (
<>
@ -1729,7 +1856,9 @@ function PostPeek({ post, filterInfo }) {
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
<span class="post-peek-tag post-peek-thread">
<Trans>Thread</Trans>
</span>{' '}
</>
)}
{!!content && (
@ -1763,7 +1892,7 @@ function PostPeek({ post, filterInfo }) {
{!!poll && (
<span class="post-peek-tag post-peek-poll">
<Icon icon="poll" size="s" />
Poll
<Trans>Poll</Trans>
</span>
)}
{!!mediaAttachments?.length
@ -1891,32 +2020,26 @@ function PostStats({ post }) {
<span class="post-stats">
{repliesCount > 0 && (
<span class="post-stat-replies">
<Icon icon="comment2" size="s" /> {shortenNumber(repliesCount)}
<Icon icon="comment2" size="s" alt={t`Replies`} />{' '}
{shortenNumber(repliesCount)}
</span>
)}
{favouritesCount > 0 && (
<span class="post-stat-likes">
<Icon icon="heart" size="s" /> {shortenNumber(favouritesCount)}
<Icon icon="heart" size="s" alt={t`Likes`} />{' '}
{shortenNumber(favouritesCount)}
</span>
)}
{reblogsCount > 0 && (
<span class="post-stat-boosts">
<Icon icon="rocket" size="s" /> {shortenNumber(reblogsCount)}
<Icon icon="rocket" size="s" alt={t`Boosts`} />{' '}
{shortenNumber(reblogsCount)}
</span>
)}
</span>
);
}
const { locale } = new Intl.DateTimeFormat().resolvedOptions();
const dtf = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
});
function binByTime(data, key, numBins) {
// Extract dates from data objects
const dates = data.map((item) => new Date(item[key]));

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useRef } from 'preact/hooks';
import Timeline from '../components/timeline';
@ -7,7 +8,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Favourites() {
useTitle('Likes', '/f');
useTitle(t`Likes`, '/favourites');
const { masto, instance } = api();
const favouritesIterator = useRef();
async function fetchFavourites(firstLoad) {
@ -19,10 +20,10 @@ function Favourites() {
return (
<Timeline
title="Likes"
title={t`Likes`}
id="favourites"
emptyText="No likes yet. Go like something!"
errorText="Unable to load likes"
emptyText={`No likes yet. Go like something!`}
errorText={t`Unable to load likes.`}
instance={instance}
fetchItems={fetchFavourites}
/>

View file

@ -1,5 +1,8 @@
import './filters.css';
import { i18n } from '@lingui/core';
import { msg, Plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import Icon from '../components/icon';
@ -10,17 +13,18 @@ import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import i18nDuration from '../utils/i18n-duration';
import useInterval from '../utils/useInterval';
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
const FILTER_CONTEXT_LABELS = {
home: 'Home and lists',
notifications: 'Notifications',
public: 'Public timelines',
thread: 'Conversations',
account: 'Profiles',
home: msg`Home and lists`,
notifications: msg`Notifications`,
public: msg`Public timelines`,
thread: msg`Conversations`,
account: msg`Profiles`,
};
const EXPIRY_DURATIONS = [
@ -33,20 +37,21 @@ const EXPIRY_DURATIONS = [
60 * 60 * 24 * 7, // 7 days
60 * 60 * 24 * 30, // 30 days
];
const EXPIRY_DURATIONS_LABELS = {
0: 'Never',
1800: '30 minutes',
3600: '1 hour',
21600: '6 hours',
43200: '12 hours',
86_400: '24 hours',
604_800: '7 days',
2_592_000: '30 days',
0: msg`Never`,
1800: i18nDuration(30, 'minute'),
3600: i18nDuration(1, 'hour'),
21600: i18nDuration(6, 'hour'),
43200: i18nDuration(12, 'hour'),
86_400: i18nDuration(24, 'hour'),
604_800: i18nDuration(7, 'day'),
2_592_000: i18nDuration(30, 'day'),
};
function Filters() {
const { masto } = api();
useTitle(`Filters`, `/ft`);
useTitle(t`Filters`, `/ft`);
const [uiState, setUIState] = useState('default');
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
@ -81,10 +86,12 @@ function Filters() {
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
</div>
<h1>Filters</h1>
<h1>
<Trans>Filters</Trans>
</h1>
<div class="header-side">
<button
type="button"
@ -93,7 +100,7 @@ function Filters() {
setShowFiltersAddEditModal(true);
}}
>
<Icon icon="plus" size="l" alt="New filter" />
<Icon icon="plus" size="l" alt={t`New filter`} />
</button>
</div>
</div>
@ -141,8 +148,11 @@ function Filters() {
{filters.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{filters.length} filter
{filters.length === 1 ? '' : 's'}
<Plural
value={filters.length}
one="# filter"
other="# filters"
/>
</small>
</footer>
)}
@ -152,15 +162,19 @@ function Filters() {
<Loader />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load filters.</p>
<p class="ui-state">
<Trans>Unable to load filters.</Trans>
</p>
) : (
<p class="ui-state">No filters yet.</p>
<p class="ui-state">
<Trans>No filters yet.</Trans>
</p>
)}
</main>
</div>
{!!showFiltersAddEditModal && (
<Modal
title="Add filter"
title={t`Add filter`}
onClose={() => {
setShowFiltersAddEditModal(false);
}}
@ -183,6 +197,7 @@ function Filters() {
let _id = 1;
const incID = () => _id++;
function FiltersAddEdit({ filter, onClose }) {
const { _ } = useLingui();
const { masto } = api();
const [uiState, setUIState] = useState('default');
const editMode = !!filter;
@ -206,11 +221,11 @@ function FiltersAddEdit({ filter, onClose }) {
<div class="sheet" id="filters-add-edit-modal">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
<h2>{editMode ? t`Edit filter` : t`New filter`}</h2>
</header>
<main>
<form
@ -327,8 +342,8 @@ function FiltersAddEdit({ filter, onClose }) {
setUIState('error');
alert(
editMode
? 'Unable to edit filter'
: 'Unable to create filter',
? t`Unable to edit filter`
: t`Unable to create filter`,
);
}
})();
@ -336,7 +351,9 @@ function FiltersAddEdit({ filter, onClose }) {
>
<div class="filter-form-row">
<label>
<b>Title</b>
<b>
<Trans>Title</Trans>
</b>
<input
type="text"
name="title"
@ -376,7 +393,7 @@ function FiltersAddEdit({ filter, onClose }) {
defaultChecked={wholeWord}
disabled={uiState === 'loading'}
/>{' '}
Whole word
<Trans>Whole word</Trans>
</label>
<button
type="button"
@ -392,7 +409,7 @@ function FiltersAddEdit({ filter, onClose }) {
}
}}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Remove`} />
</button>
</div>
</li>
@ -401,7 +418,9 @@ function FiltersAddEdit({ filter, onClose }) {
</ul>
) : (
<div class="filter-keywords">
<div class="insignificant">No keywords. Add one.</div>
<div class="insignificant">
<Trans>No keywords. Add one.</Trans>
</div>
</div>
)}
<footer class="filter-keywords-footer">
@ -427,12 +446,15 @@ function FiltersAddEdit({ filter, onClose }) {
}, 10);
}}
>
Add keyword
<Trans>Add keyword</Trans>
</button>{' '}
{filteredEditKeywords?.length > 1 && (
<small class="insignificant">
{filteredEditKeywords.length} keyword
{filteredEditKeywords.length === 1 ? '' : 's'}
<Plural
value={filteredEditKeywords.length}
one="# keyword"
other="# keywords"
/>
</small>
)}
</footer>
@ -440,7 +462,9 @@ function FiltersAddEdit({ filter, onClose }) {
<div class="filter-form-cols">
<div class="filter-form-col">
<div>
<b>Filter from</b>
<b>
<Trans>Filter from</Trans>
</b>
</div>
{FILTER_CONTEXT.map((ctx) => (
<div>
@ -458,27 +482,29 @@ function FiltersAddEdit({ filter, onClose }) {
defaultChecked={!!context ? context.includes(ctx) : true}
disabled={uiState === 'loading'}
/>{' '}
{FILTER_CONTEXT_LABELS[ctx]}
{_(FILTER_CONTEXT_LABELS[ctx])}
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
</label>{' '}
</div>
))}
<p>
<small class="insignificant">* Not implemented yet</small>
<small class="insignificant">
<Trans>* Not implemented yet</Trans>
</small>
</p>
</div>
<div class="filter-form-col">
{editMode && (
<>
<Trans>
Status:{' '}
<b>
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
</b>
</>
</Trans>
)}
<div>
<label for="filters-expires_in">
{editMode ? 'Change expiry' : 'Expiry'}
{editMode ? t`Change expiry` : t`Expiry`}
</label>
<select
id="filters-expires_in"
@ -488,12 +514,16 @@ function FiltersAddEdit({ filter, onClose }) {
>
{editMode && <option></option>}
{EXPIRY_DURATIONS.map((v) => (
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
<option value={v}>
{typeof EXPIRY_DURATIONS_LABELS[v] === 'function'
? EXPIRY_DURATIONS_LABELS[v]()
: _(EXPIRY_DURATIONS_LABELS[v])}
</option>
))}
</select>
</div>
<p>
Filtered post will be
<Trans>Filtered post will be</Trans>
<br />
<label class="ib">
<input
@ -503,7 +533,7 @@ function FiltersAddEdit({ filter, onClose }) {
defaultChecked={filterAction === 'warn' || !editMode}
disabled={uiState === 'loading'}
/>{' '}
minimized
<Trans>minimized</Trans>
</label>{' '}
<label class="ib">
<input
@ -513,7 +543,7 @@ function FiltersAddEdit({ filter, onClose }) {
defaultChecked={filterAction === 'hide'}
disabled={uiState === 'loading'}
/>{' '}
hidden
<Trans>hidden</Trans>
</label>
</p>
</div>
@ -521,7 +551,7 @@ function FiltersAddEdit({ filter, onClose }) {
<footer class="filter-form-footer">
<span>
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
{editMode ? t`Save` : t`Create`}
</button>{' '}
<Loader abrupt hidden={uiState !== 'loading'} />
</span>
@ -530,7 +560,7 @@ function FiltersAddEdit({ filter, onClose }) {
disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this filter?"
confirmLabel={t`Delete this filter?`}
onClick={() => {
setUIState('loading');
(async () => {
@ -543,7 +573,7 @@ function FiltersAddEdit({ filter, onClose }) {
} catch (e) {
console.error(e);
setUIState('error');
alert('Unable to delete filter.');
alert(t`Unable to delete filter.`);
}
})();
}}
@ -554,7 +584,7 @@ function FiltersAddEdit({ filter, onClose }) {
onClick={() => {}}
disabled={uiState === 'loading'}
>
Delete
<Trans>Delete</Trans>
</button>
</MenuConfirm>
)}
@ -575,13 +605,13 @@ function ExpiryStatus({ expiresAt, showNeverExpires }) {
useInterval(rerender, expired || 30_000);
return expired ? (
'Expired'
t`Expired`
) : hasExpiry ? (
<>
<Trans>
Expiring <RelativeTime datetime={expiresAtDate} />
</>
</Trans>
) : (
showNeverExpires && 'Never expires'
showNeverExpires && t`Never expires`
);
}

View file

@ -1,3 +1,4 @@
import { Plural, t, Trans } from '@lingui/macro';
import { useEffect, useState } from 'preact/hooks';
import Icon from '../components/icon';
@ -10,7 +11,7 @@ import useTitle from '../utils/useTitle';
function FollowedHashtags() {
const { masto, instance } = api();
useTitle(`Followed Hashtags`, `/fh`);
useTitle(t`Followed Hashtags`, `/fh`);
const [uiState, setUIState] = useState('default');
const [followedHashtags, setFollowedHashtags] = useState([]);
@ -36,10 +37,12 @@ function FollowedHashtags() {
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
</div>
<h1>Followed Hashtags</h1>
<h1>
<Trans>Followed Hashtags</Trans>
</h1>
<div class="header-side" />
</div>
</header>
@ -56,7 +59,7 @@ function FollowedHashtags() {
: `/t/${tag.name}`
}
>
<Icon icon="hashtag" /> <span>{tag.name}</span>
<Icon icon="hashtag" alt="#" /> <span>{tag.name}</span>
</Link>
</li>
))}
@ -64,8 +67,11 @@ function FollowedHashtags() {
{followedHashtags.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{followedHashtags.length} hashtag
{followedHashtags.length === 1 ? '' : 's'}
<Plural
value={followedHashtags.length}
one="# hashtag"
other="# hashtags"
/>
</small>
</footer>
)}
@ -75,9 +81,13 @@ function FollowedHashtags() {
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load followed hashtags.</p>
<p class="ui-state">
<Trans>Unable to load followed hashtags.</Trans>
</p>
) : (
<p class="ui-state">No hashtags followed yet.</p>
<p class="ui-state">
<Trans>No hashtags followed yet.</Trans>
</p>
)}
</main>
</div>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useEffect, useRef } from 'preact/hooks';
import { useSnapshot } from 'valtio';
@ -16,7 +17,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Following({ title, path, id, ...props }) {
useTitle(title || 'Following', path || '/following');
useTitle(title || t`Following`, path || '/following');
const { masto, streaming, instance } = api();
const snapStates = useSnapshot(states);
const homeIterator = useRef();
@ -127,10 +128,10 @@ function Following({ title, path, id, ...props }) {
return (
<Timeline
title={title || 'Following'}
title={title || t`Following`}
id={id || 'following'}
emptyText="Nothing to see here."
errorText="Unable to load posts."
emptyText={t`Nothing to see here.`}
errorText={t`Unable to load posts.`}
instance={instance}
fetchItems={fetchHome}
checkForUpdates={checkForUpdates}

View file

@ -1,3 +1,5 @@
import { plural, t, Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
FocusableItem,
MenuDivider,
@ -48,10 +50,13 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
authenticated: currentAuthenticated,
} = api();
const hashtagTitle = hashtags.map((t) => `#${t}`).join(' ');
const hashtagPostTitle = media ? ` (Media only)` : '';
const title = instance
? `${hashtagTitle}${hashtagPostTitle} on ${instance}`
: `${hashtagTitle}${hashtagPostTitle}`;
? media
? t`${hashtagTitle} (Media only) on ${instance}`
: t`${hashtagTitle} on ${instance}`
: media
? t`${hashtagTitle} (Media only)`
: t`${hashtagTitle}`;
useTitle(title, `/:instance?/t/:hashtag`);
const latestItem = useRef();
@ -173,8 +178,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}
id="hashtag"
instance={instance}
emptyText="No one has posted anything with this tag yet."
errorText="Unable to load posts with this tag"
emptyText={t`No one has posted anything with this tag yet.`}
errorText={t`Unable to load posts with this tag`}
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
@ -191,7 +196,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
@ -215,7 +220,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
.unfollow()
.then(() => {
setInfo({ ...info, following: false });
showToast(`Unfollowed #${hashtag}`);
showToast(t`Unfollowed #${hashtag}`);
})
.catch((e) => {
alert(e);
@ -230,7 +235,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
.follow()
.then(() => {
setInfo({ ...info, following: true });
showToast(`Followed #${hashtag}`);
showToast(t`Followed #${hashtag}`);
})
.catch((e) => {
alert(e);
@ -244,11 +249,17 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
>
{info.following ? (
<>
<Icon icon="check-circle" /> <span>Following</span>
<Icon icon="check-circle" />{' '}
<span>
<Trans>Following</Trans>
</span>
</>
) : (
<>
<Icon icon="plus" /> <span>Follow</span>
<Icon icon="plus" />{' '}
<span>
<Trans>Follow</Trans>
</span>
</>
)}
</MenuConfirm>
@ -268,7 +279,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
.remove()
.then(() => {
setIsFeaturedTag(false);
showToast('Unfeatured on profile');
showToast(t`Unfeatured on profile`);
setFeaturedTags(
featuredTags.filter(
(tag) => tag.id !== featuredTagID,
@ -282,7 +293,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
setFeaturedUIState('default');
});
} else {
showToast('Unable to unfeature on profile');
showToast(t`Unable to unfeature on profile`);
}
} else {
masto.v1.featuredTags
@ -291,7 +302,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
})
.then((value) => {
setIsFeaturedTag(true);
showToast('Featured on profile');
showToast(t`Featured on profile`);
setFeaturedTags(featuredTags.concat(value));
})
.catch((e) => {
@ -306,12 +317,16 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
{isFeaturedTag ? (
<>
<Icon icon="check-circle" />
<span>Featured on profile</span>
<span>
<Trans>Featured on profile</Trans>
</span>
</>
) : (
<>
<Icon icon="check-circle" />
<span>Feature on profile</span>
<span>
<Trans>Feature on profile</Trans>
</span>
</>
)}
</MenuItem>
@ -320,7 +335,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
)}
{!mediaFirst && (
<>
<MenuHeader className="plain">Filters</MenuHeader>
<MenuHeader className="plain">
<Trans>Filters</Trans>
</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
@ -333,8 +350,10 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
<Icon icon="check-circle" alt="☑️" />{' '}
<span class="menu-grow">
<Trans>Media only</Trans>
</span>
</MenuItem>
<MenuDivider />
</>
@ -370,7 +389,11 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
ref={ref}
type="text"
placeholder={
reachLimit ? `Max ${TOTAL_TAGS_LIMIT} tags` : 'Add hashtag'
reachLimit
? plural(TOTAL_TAGS_LIMIT, {
other: 'Max # tags',
})
: t`Add hashtag`
}
required
autocorrect="off"
@ -385,9 +408,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
)}
</FocusableItem>
<MenuGroup takeOverflow>
{hashtags.map((t, i) => (
{hashtags.map((tag, i) => (
<MenuItem
key={t}
key={tag}
disabled={hashtags.length === 1}
onClick={(e) => {
hashtags.splice(i, 1);
@ -402,10 +425,10 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
: `/t/${hashtags.join('+')}${linkParams}`;
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
<Icon icon="x" alt={t`Remove hashtag`} class="danger-icon" />
<span class="bidi-isolate">
<span class="more-insignificant">#</span>
{t}
{tag}
</span>
</MenuItem>
))}
@ -416,7 +439,10 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
onClick={() => {
if (states.shortcuts.length >= SHORTCUTS_LIMIT) {
alert(
`Max ${SHORTCUTS_LIMIT} shortcuts reached. Unable to add shortcut.`,
plural(SHORTCUTS_LIMIT, {
one: 'Max # shortcut reached. Unable to add shortcut.',
other: 'Max # shortcuts reached. Unable to add shortcut.',
}),
);
return;
}
@ -442,22 +468,25 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
(s.media ? !!s.media === !!shortcut.media : true),
);
if (exists) {
alert('This shortcut already exists');
alert(t`This shortcut already exists`);
} else {
states.shortcuts.push(shortcut);
showToast(`Hashtag shortcut added`);
showToast(t`Hashtag shortcut added`);
}
}}
>
<Icon icon="shortcut" /> <span>Add to Shortcuts</span>
<Icon icon="shortcut" />{' '}
<span>
<Trans>Add to Shortcuts</Trans>
</span>
</MenuItem>
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
t`Enter a new instance e.g. "mastodon.social"`,
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
if (newInstance) alert(t`Invalid instance`);
return;
}
if (newInstance) {
@ -469,7 +498,10 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
<Icon icon="bus" />{' '}
<span>
<Trans>Go to another instance</Trans>
</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
@ -481,7 +513,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
<Trans>
Go to my instance (<b>{currentInstance}</b>)
</Trans>
</small>
</MenuItem>
)}

View file

@ -1,5 +1,6 @@
import './notifications-menu.css';
import { t, Trans } from '@lingui/macro';
import { ControlledMenu } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
@ -46,7 +47,7 @@ function Home() {
<Columns />
) : (
<Following
title="Home"
title={t`Home`}
path="/"
id="home"
headerStart={false}
@ -77,7 +78,7 @@ function NotificationsLink() {
}
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
<Icon icon="notification" size="l" alt={t`Notifications`} />
</Link>
<NotificationsMenu
state={menuState}
@ -176,7 +177,9 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
boundingBoxPadding="8 8 8 8"
>
<header>
<h2>Notifications</h2>
<h2>
<Trans>Notifications</Trans>
</h2>
</header>
<main>
{snapStates.notifications.length ? (
@ -199,10 +202,12 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
) : (
uiState === 'error' && (
<div class="ui-state">
<p>Unable to fetch notifications.</p>
<p>
<Trans>Unable to fetch notifications.</Trans>
</p>
<p>
<button type="button" onClick={loadNotifications}>
Try again
<Trans>Try again</Trans>
</button>
</p>
</div>
@ -211,16 +216,21 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
</main>
<footer>
<Link to="/mentions" class="button plain">
<Icon icon="at" /> <span>Mentions</span>
<Icon icon="at" />{' '}
<span>
<Trans>Mentions</Trans>
</span>
</Link>
<Link to="/notifications" class="button plain2">
{hasFollowRequests ? (
<>
<Trans>
<span class="tag collapsed">New</span>{' '}
<span>Follow Requests</span>
</>
</Trans>
) : (
<b>See all</b>
<b>
<Trans>See all</Trans>
</b>
)}{' '}
<Icon icon="arrow-right" />
</Link>

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useLayoutEffect, useState } from 'preact/hooks';
import { useLocation } from 'react-router-dom';
@ -63,7 +64,9 @@ export default function HttpRoute() {
{uiState === 'loading' ? (
<>
<Loader abrupt />
<h2>Resolving</h2>
<h2>
<Trans>Resolving</Trans>
</h2>
<p>
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
@ -72,7 +75,9 @@ export default function HttpRoute() {
</>
) : (
<>
<h2>Unable to resolve URL</h2>
<h2>
<Trans>Unable to resolve URL</Trans>
</h2>
<p>
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
@ -82,7 +87,9 @@ export default function HttpRoute() {
)}
<hr />
<p>
<Link to="/">Go home</Link>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</p>
</div>
);

View file

@ -1,5 +1,6 @@
import './lists.css';
import { t, Trans } from '@lingui/macro';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
@ -103,8 +104,8 @@ function List(props) {
key={id}
title={list.title}
id="list"
emptyText="Nothing yet."
errorText="Unable to load posts."
emptyText={t`Nothing yet.`}
errorText={t`Unable to load posts.`}
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
@ -122,13 +123,15 @@ function List(props) {
overflow="auto"
menuButton={
<button type="button" class="plain">
<Icon icon="list" size="l" alt="Lists" />
<Icon icon="list" size="l" alt={t`Lists`} />
<Icon icon="chevron-down" size="s" />
</button>
}
>
<MenuLink to="/l">
<span>All Lists</span>
<span>
<Trans>All Lists</Trans>
</span>
</MenuLink>
{lists?.length > 0 && (
<>
@ -151,7 +154,7 @@ function List(props) {
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
@ -163,11 +166,15 @@ function List(props) {
}
>
<Icon icon="pencil" size="l" />
<span>Edit</span>
<span>
<Trans>Edit</Trans>
</span>
</MenuItem>
<MenuItem onClick={() => setShowManageMembersModal(true)}>
<Icon icon="group" size="l" />
<span>Manage members</span>
<span>
<Trans>Manage members</Trans>
</span>
</MenuItem>
</Menu2>
}
@ -264,11 +271,13 @@ function ListManageMembers({ listID, onClose }) {
<div class="sheet" id="list-manage-members-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>Manage members</h2>
<h2>
<Trans>Manage members</Trans>
</h2>
</header>
<main>
<ul>
@ -281,7 +290,7 @@ function ListManageMembers({ listID, onClose }) {
{showMore && uiState === 'default' && (
<InView as="li" onChange={(inView) => inView && fetchMembers()}>
<button type="button" class="light block" onClick={fetchMembers}>
Show more&hellip;
<Trans>Show more</Trans>
</button>
</InView>
)}
@ -299,7 +308,11 @@ function RemoveAddButton({ account, listID }) {
return (
<MenuConfirm
confirm={!removed}
confirmLabel={<span>Remove @{account.username} from list?</span>}
confirmLabel={
<span>
<Trans>Remove @{account.username} from list?</Trans>
</span>
}
align="end"
menuItemClassName="danger"
onClick={() => {
@ -340,7 +353,7 @@ function RemoveAddButton({ account, listID }) {
class={`light ${removed ? '' : 'danger'}`}
disabled={uiState === 'loading'}
>
{removed ? 'Add' : 'Remove…'}
{removed ? t`Add` : t`Remove…`}
</button>
</MenuConfirm>
);

View file

@ -1,6 +1,7 @@
import './lists.css';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import { Plural, t, Trans } from '@lingui/macro';
import { useEffect, useReducer, useState } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
@ -12,7 +13,7 @@ import { fetchLists } from '../utils/lists';
import useTitle from '../utils/useTitle';
function Lists() {
useTitle(`Lists`, `/l`);
useTitle(t`Lists`, `/l`);
const [uiState, setUIState] = useState('default');
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
@ -45,14 +46,16 @@ function Lists() {
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Lists</h1>
<h1>
<Trans>Lists</Trans>
</h1>
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => setShowListAddEditModal(true)}
>
<Icon icon="plus" size="l" alt="New list" />
<Icon icon="plus" size="l" alt={t`New list`} />
</button>
</div>
</div>
@ -87,8 +90,7 @@ function Lists() {
{lists.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{lists.length} list
{lists.length === 1 ? '' : 's'}
<Plural value={lists.length} one="# list" other="# lists" />
</small>
</footer>
)}
@ -98,9 +100,13 @@ function Lists() {
<Loader />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load lists.</p>
<p class="ui-state">
<Trans>Unable to load lists.</Trans>
</p>
) : (
<p class="ui-state">No lists yet.</p>
<p class="ui-state">
<Trans>No lists yet.</Trans>
</p>
)}
</main>
</div>

View file

@ -1,11 +1,13 @@
import './login.css';
import { t, Trans } from '@lingui/macro';
import Fuse from 'fuse.js';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import logo from '../assets/logo.svg';
import LangSelector from '../components/lang-selector';
import Link from '../components/link';
import Loader from '../components/loader';
import instancesListURL from '../data/instances.json?url';
@ -137,10 +139,12 @@ function Login() {
<h1>
<img src={logo} alt="" width="80" height="80" />
<br />
Log in
<Trans>Log in</Trans>
</h1>
<label>
<p>Instance</p>
<p>
<Trans>Instance</Trans>
</p>
<input
value={instanceText}
required
@ -154,7 +158,7 @@ function Login() {
autocapitalize="off"
autocomplete="off"
spellCheck={false}
placeholder="instance domain"
placeholder={`instance domain`}
onInput={(e) => {
setInstanceText(e.target.value);
}}
@ -177,7 +181,9 @@ function Login() {
))}
</ul>
) : (
<div id="instances-eg">e.g. &ldquo;mastodon.social&rdquo;</div>
<div id="instances-eg">
<Trans>e.g. &ldquo;mastodon.social&rdquo;</Trans>
</div>
)}
{/* <datalist id="instances-list">
{instancesList.map((instance) => (
@ -187,7 +193,9 @@ function Login() {
</label>
{uiState === 'error' && (
<p class="error">
Failed to log in. Please try again or another instance.
<Trans>
Failed to log in. Please try again or another instance.
</Trans>
</p>
)}
<div>
@ -197,8 +205,8 @@ function Login() {
}
>
{selectedInstanceText
? `Continue with ${selectedInstanceText}`
: 'Continue'}
? t`Continue with ${selectedInstanceText}`
: t`Continue`}
</button>{' '}
</div>
<Loader hidden={uiState !== 'loading'} />
@ -206,13 +214,16 @@ function Login() {
{!DEFAULT_INSTANCE && (
<p>
<a href="https://joinmastodon.org/servers" target="_blank">
Don't have an account? Create one!
<Trans>Don't have an account? Create one!</Trans>
</a>
</p>
)}
<p>
<Link to="/">Go home</Link>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</p>
<LangSelector />
</form>
</main>
);

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { useMemo, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
@ -16,7 +17,7 @@ function Mentions({ columnMode, ...props }) {
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const [stateType, setStateType] = useState(null);
const type = props?.type || searchParams.get('type') || stateType;
useTitle(`Mentions${type === 'private' ? ' (Private)' : ''}`, '/mentions');
useTitle(type === 'private' ? t`Private mentions` : t`Mentions`, '/mentions');
const mentionsIterator = useRef();
const latestItem = useRef();
@ -143,7 +144,7 @@ function Mentions({ columnMode, ...props }) {
}
}}
>
All
<Trans>All</Trans>
</Link>
<Link
to="/mentions?type=private"
@ -155,7 +156,7 @@ function Mentions({ columnMode, ...props }) {
}
}}
>
Private
<Trans>Private</Trans>
</Link>
</div>
);
@ -163,10 +164,10 @@ function Mentions({ columnMode, ...props }) {
return (
<Timeline
title="Mentions"
title={t`Mentions`}
id="mentions"
emptyText="No one mentioned you :("
errorText="Unable to load mentions."
emptyText={t`No one mentioned you :(`}
errorText={t`Unable to load mentions.`}
instance={instance}
fetchItems={fetchItems}
checkForUpdates={checkForUpdates}

View file

@ -1,5 +1,6 @@
import './notifications.css';
import { Plural, t, Trans } from '@lingui/macro';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
@ -85,7 +86,7 @@ export function getGroupedNotifications(notifications) {
}
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
useTitle(t`Notifications`, '/notifications');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
@ -484,10 +485,12 @@ function Notifications({ columnMode }) {
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" alt="Home" />
<Icon icon="home" size="l" alt={t`Home`} />
</Link>
</div>
<h1>Notifications</h1>
<h1>
<Trans>Notifications</Trans>
</h1>
<div class="header-side">
{supportsFilteredNotifications && (
<button
@ -497,7 +500,11 @@ function Notifications({ columnMode }) {
setShowNotificationsSettings(true);
}}
>
<Icon icon="settings" size="l" alt="Notifications settings" />
<Icon
icon="settings"
size="l"
alt={t`Notifications settings`}
/>
</button>
)}
</div>
@ -514,7 +521,7 @@ function Notifications({ columnMode }) {
});
}}
>
<Icon icon="arrow-up" /> New notifications
<Icon icon="arrow-up" /> <Trans>New notifications</Trans>
</button>
)}
</header>
@ -525,7 +532,11 @@ function Notifications({ columnMode }) {
<summary>
<span>
<Icon icon="announce" class="announcement-icon" size="l" />{' '}
<b>Announcement{announcements.length > 1 ? 's' : ''}</b>{' '}
<Plural
value={announcements.length}
one="Announcement"
other="Announcements"
/>{' '}
<small class="insignificant">{instance}</small>
</span>
{announcements.length > 1 && (
@ -567,10 +578,18 @@ function Notifications({ columnMode }) {
)}
{followRequests.length > 0 && (
<div class="follow-requests">
<h2 class="timeline-header">Follow requests</h2>
<h2 class="timeline-header">
<Trans>Follow requests</Trans>
</h2>
{followRequests.length > 5 ? (
<details>
<summary>{followRequests.length} follow requests</summary>
<summary>
<Plural
value={followRequests.length}
one="# follow request"
other="# follow requests"
/>
</summary>
<ul>
{followRequests.map((account) => (
<li key={account.id}>
@ -620,8 +639,11 @@ function Notifications({ columnMode }) {
}}
>
<summary>
Filtered notifications from{' '}
{notificationsPolicy.summary.pendingRequestsCount} people
<Plural
value={notificationsPolicy.summary.pendingRequestsCount}
one="Filtered notifications from # person"
other="Filtered notifications from # people"
/>
</summary>
{!notificationsRequests ? (
<p class="ui-state">
@ -683,13 +705,15 @@ function Notifications({ columnMode }) {
setOnlyMentions(e.target.checked);
}}
/>{' '}
Only mentions
<Trans>Only mentions</Trans>
</label>
</div>
<h2 class="timeline-header">Today</h2>
<h2 class="timeline-header">
<Trans>Today</Trans>
</h2>
{showTodayEmpty && (
<p class="ui-state insignificant">
{uiState === 'default' ? "You're all caught up." : <>&hellip;</>}
{uiState === 'default' ? t`You're all caught up.` : <>&hellip;</>}
</p>
)}
{snapStates.notifications.length ? (
@ -712,7 +736,7 @@ function Notifications({ columnMode }) {
const heading =
notificationDay.toDateString() ===
yesterdayDate.toDateString()
? 'Yesterday'
? t`Yesterday`
: niceDateTime(currentDay, {
hideTime: true,
});
@ -748,11 +772,11 @@ function Notifications({ columnMode }) {
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load notifications
<Trans>Unable to load notifications</Trans>
<br />
<br />
<button type="button" onClick={() => loadNotifications(true)}>
Try again
<Trans>Try again</Trans>
</button>
</p>
)}
@ -776,7 +800,7 @@ function Notifications({ columnMode }) {
{uiState === 'loading' ? (
<Loader abrupt />
) : (
<>Show more&hellip;</>
<Trans>Show more</Trans>
)}
</button>
</InView>
@ -796,10 +820,12 @@ function Notifications({ columnMode }) {
class="sheet-close"
onClick={() => setShowNotificationsSettings(false)}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<h2>Notifications settings</h2>
<h2>
<Trans>Notifications settings</Trans>
</h2>
</header>
<main>
<form
@ -825,14 +851,16 @@ function Notifications({ columnMode }) {
(async () => {
try {
await masto.v1.notifications.policy.update(allFilters);
showToast('Notifications settings updated');
showToast(t`Notifications settings updated`);
} catch (e) {
console.error(e);
}
})();
}}
>
<p>Filter out notifications from people:</p>
<p>
<Trans>Filter out notifications from people:</Trans>
</p>
<p>
<label>
<input
@ -841,7 +869,7 @@ function Notifications({ columnMode }) {
defaultChecked={notificationsPolicy.filterNotFollowing}
name="filterNotFollowing"
/>{' '}
You don't follow
<Trans>You don't follow</Trans>
</label>
</p>
<p>
@ -852,7 +880,7 @@ function Notifications({ columnMode }) {
defaultChecked={notificationsPolicy.filterNotFollowers}
name="filterNotFollowers"
/>{' '}
Who don't follow you
<Trans>Who don't follow you</Trans>
</label>
</p>
<p>
@ -863,7 +891,7 @@ function Notifications({ columnMode }) {
defaultChecked={notificationsPolicy.filterNewAccounts}
name="filterNewAccounts"
/>{' '}
With a new account
<Trans>With a new account</Trans>
</label>
</p>
<p>
@ -874,11 +902,13 @@ function Notifications({ columnMode }) {
defaultChecked={notificationsPolicy.filterPrivateMentions}
name="filterPrivateMentions"
/>{' '}
Who unsolicitedly private mention you
<Trans>Who unsolicitedly private mention you</Trans>
</label>
</p>
<p>
<button type="submit">Save</button>
<button type="submit">
<Trans>Save</Trans>
</button>
</p>
</form>
</main>
@ -940,10 +970,12 @@ function AnnouncementBlock({ announcement }) {
{' '}
&bull;{' '}
<span class="ib">
Updated{' '}
<time datetime={updatedAtDate.toISOString()}>
{niceDateTime(updatedAtDate)}
</time>
<Trans>
Updated{' '}
<time datetime={updatedAtDate.toISOString()}>
{niceDateTime(updatedAtDate)}
</time>
</Trans>
</span>
</>
)}
@ -1005,7 +1037,9 @@ function NotificationRequestModalButton({ request }) {
}}
>
<Icon icon="notification" class="more-insignificant" />{' '}
<small>View notifications from @{account.username}</small>{' '}
<small>
<Trans>View notifications from @{account.username}</Trans>
</small>{' '}
<Icon icon="chevron-down" />
</button>
{showModal && (
@ -1018,10 +1052,12 @@ function NotificationRequestModalButton({ request }) {
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
<header>
<b>Notifications from @{account.username}</b>
<b>
<Trans>Notifications from @{account.username}</Trans>
</b>
</header>
<main>
{uiState === 'loading' ? (
@ -1084,17 +1120,17 @@ function NotificationRequestButtons({ request, onChange }) {
state: 'accept',
});
showToast(
`Notifications from @${request.account.username} will not be filtered from now on.`,
t`Notifications from @${request.account.username} will not be filtered from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to accept notification request`);
showToast(t`Unable to accept notification request`);
}
})();
}}
>
Allow
<Trans>Allow</Trans>
</button>{' '}
<button
type="button"
@ -1114,17 +1150,17 @@ function NotificationRequestButtons({ request, onChange }) {
state: 'dismiss',
});
showToast(
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
t`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to dismiss notification request`);
showToast(t`Unable to dismiss notification request`);
}
})();
}}
>
Dismiss
<Trans>Dismiss</Trans>
</button>
<span class="notification-request-states">
{uiState === 'loading' ? (
@ -1132,14 +1168,14 @@ function NotificationRequestButtons({ request, onChange }) {
) : requestState === 'accept' ? (
<Icon
icon="check-circle"
alt="Accepted"
alt={t`Accepted`}
class="notification-accepted"
/>
) : (
requestState === 'dismiss' && (
<Icon
icon="x-circle"
alt="Dismissed"
alt={t`Dismissed`}
class="notification-dismissed"
/>
)

View file

@ -1,3 +1,4 @@
import { t, Trans } from '@lingui/macro';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useRef } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
@ -22,7 +23,9 @@ function Public({ local, columnMode, ...props }) {
instance: props?.instance || params.instance,
});
const { masto: currentMasto, instance: currentInstance } = api();
const title = `${isLocal ? 'Local' : 'Federated'} timeline (${instance})`;
const title = isLocal
? t`Local timeline (${instance})`
: t`Federated timeline (${instance})`;
useTitle(title, isLocal ? `/:instance?/p/l` : `/:instance?/p`);
// const navigate = useNavigate();
const latestItem = useRef();
@ -84,14 +87,14 @@ function Public({ local, columnMode, ...props }) {
title={title}
titleComponent={
<h1 class="header-double-lines">
<b>{isLocal ? 'Local timeline' : 'Federated timeline'}</b>
<b>{isLocal ? t`Local timeline` : t`Federated timeline`}</b>
<div>{instance}</div>
</h1>
}
id="public"
instance={instance}
emptyText="No one has posted anything yet."
errorText="Unable to load posts"
emptyText={t`No one has posted anything yet.`}
errorText={t`Unable to load posts`}
fetchItems={fetchPublic}
checkForUpdates={checkForUpdates}
useItemID
@ -108,18 +111,24 @@ function Public({ local, columnMode, ...props }) {
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
<MenuItem href={isLocal ? `/#/${instance}/p` : `/#/${instance}/p/l`}>
{isLocal ? (
<>
<Icon icon="transfer" /> <span>Switch to Federated</span>
<Icon icon="transfer" />{' '}
<span>
<Trans>Switch to Federated</Trans>
</span>
</>
) : (
<>
<Icon icon="transfer" /> <span>Switch to Local</span>
<Icon icon="transfer" />{' '}
<span>
<Trans>Switch to Local</Trans>
</span>
</>
)}
</MenuItem>
@ -127,10 +136,10 @@ function Public({ local, columnMode, ...props }) {
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
t`Enter a new instance e.g. "mastodon.social"`,
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
if (newInstance) alert(t`Invalid instance`);
return;
}
if (newInstance) {
@ -142,7 +151,10 @@ function Public({ local, columnMode, ...props }) {
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
<Icon icon="bus" />{' '}
<span>
<Trans>Go to another instance</Trans>
</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
@ -154,7 +166,9 @@ function Public({ local, columnMode, ...props }) {
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
<Trans>
Go to my instance (<b>{currentInstance}</b>)
</Trans>
</small>
</MenuItem>
)}

View file

@ -1,6 +1,7 @@
import './search.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { t, Trans } from '@lingui/macro';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
@ -35,22 +36,23 @@ function Search({ columnMode, ...props }) {
const type = columnMode
? 'statuses'
: props?.type || searchParams.get('type');
useTitle(
q
? `Search: ${q}${
type
? ` (${
{
statuses: 'Posts',
accounts: 'Accounts',
hashtags: 'Hashtags',
}[type]
})`
: ''
}`
: 'Search',
`/search`,
);
let title = t`Search`;
if (q) {
switch (type) {
case 'statuses':
title = t`Search: ${q} (Posts)`;
break;
case 'accounts':
title = t`Search: ${q} (Accounts)`;
break;
case 'hashtags':
title = t`Search: ${q} (Hashtags)`;
break;
default:
title = t`Search: ${q}`;
}
}
useTitle(title, `/search`);
const [showMore, setShowMore] = useState(false);
const offsetRef = useRef(0);
@ -204,7 +206,7 @@ function Search({ columnMode, ...props }) {
}}
disabled={uiState === 'loading'}
>
<Icon icon="search" size="l" />
<Icon icon="search" size="l" alt={t`Search`} />
</button>
</div>
</div>
@ -217,22 +219,22 @@ function Search({ columnMode, ...props }) {
>
{!!type && (
<Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}>
All
<Icon icon="chevron-left" /> <Trans>All</Trans>
</Link>
)}
{[
{
label: 'Accounts',
label: t`Accounts`,
type: 'accounts',
to: `/search?q=${encodeURIComponent(q)}&type=accounts`,
},
{
label: 'Hashtags',
label: t`Hashtags`,
type: 'hashtags',
to: `/search?q=${encodeURIComponent(q)}&type=hashtags`,
},
{
label: 'Posts',
label: t`Posts`,
type: 'statuses',
to: `/search?q=${encodeURIComponent(q)}&type=statuses`,
},
@ -255,11 +257,11 @@ function Search({ columnMode, ...props }) {
<>
{type !== 'accounts' && (
<h2 class="timeline-header">
Accounts{' '}
<Trans>Accounts</Trans>{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=accounts`}
>
<Icon icon="arrow-right" size="l" />
<Icon icon="arrow-right" size="l" alt={t`See more`} />
</Link>
</h2>
)}
@ -285,7 +287,8 @@ function Search({ columnMode, ...props }) {
q,
)}&type=accounts`}
>
See more accounts <Icon icon="arrow-right" />
<Trans>See more accounts</Trans>{' '}
<Icon icon="arrow-right" />
</Link>
</div>
)}
@ -297,7 +300,9 @@ function Search({ columnMode, ...props }) {
<Loader abrupt />
</p>
) : (
<p class="ui-state">No accounts found.</p>
<p class="ui-state">
<Trans>No accounts found.</Trans>
</p>
))
)}
</>
@ -306,11 +311,11 @@ function Search({ columnMode, ...props }) {
<>
{type !== 'hashtags' && (
<h2 class="timeline-header">
Hashtags{' '}
<Trans>Hashtags</Trans>{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=hashtags`}
>
<Icon icon="arrow-right" size="l" />
<Icon icon="arrow-right" size="l" alt={t`See more`} />
</Link>
</h2>
)}
@ -332,7 +337,7 @@ function Search({ columnMode, ...props }) {
: `/t/${name}`
}
>
<Icon icon="hashtag" />
<Icon icon="hashtag" alt="#" />
<span>{name}</span>
{!!total && (
<span class="count">
@ -352,7 +357,8 @@ function Search({ columnMode, ...props }) {
q,
)}&type=hashtags`}
>
See more hashtags <Icon icon="arrow-right" />
<Trans>See more hashtags</Trans>{' '}
<Icon icon="arrow-right" />
</Link>
</div>
)}
@ -364,7 +370,9 @@ function Search({ columnMode, ...props }) {
<Loader abrupt />
</p>
) : (
<p class="ui-state">No hashtags found.</p>
<p class="ui-state">
<Trans>No hashtags found.</Trans>
</p>
))
)}
</>
@ -373,11 +381,11 @@ function Search({ columnMode, ...props }) {
<>
{type !== 'statuses' && (
<h2 class="timeline-header">
Posts{' '}
<Trans>Posts</Trans>{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=statuses`}
>
<Icon icon="arrow-right" size="l" />
<Icon icon="arrow-right" size="l" alt={t`See more`} />
</Link>
</h2>
)}
@ -407,7 +415,8 @@ function Search({ columnMode, ...props }) {
q,
)}&type=statuses`}
>
See more posts <Icon icon="arrow-right" />
<Trans>See more posts</Trans>{' '}
<Icon icon="arrow-right" />
</Link>
</div>
)}
@ -419,7 +428,9 @@ function Search({ columnMode, ...props }) {
<Loader abrupt />
</p>
) : (
<p class="ui-state">No posts found.</p>
<p class="ui-state">
<Trans>No posts found.</Trans>
</p>
))
)}
</>
@ -440,11 +451,13 @@ function Search({ columnMode, ...props }) {
onClick={() => loadResults()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
<Trans>Show more</Trans>
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
<p class="ui-state insignificant">
<Trans>The end.</Trans>
</p>
)
) : (
uiState === 'loading' && (
@ -460,7 +473,9 @@ function Search({ columnMode, ...props }) {
</p>
) : (
<p class="ui-state">
Enter your search term or paste a URL above to get started.
<Trans>
Enter your search term or paste a URL above to get started.
</Trans>
</p>
)}
</main>

View file

@ -143,14 +143,14 @@
background-color: var(--bg-faded-color);
border-radius: 8px;
margin: 8px 0;
max-height: 6.5em;
max-height: 10em;
overflow: auto;
display: flex;
flex-wrap: wrap;
font-size: 90%;
}
#settings-container .checkbox-fieldset label {
flex: 1 0 10em;
flex: 1 0 12em;
padding: 4px;
display: flex;
gap: 4px;

View file

@ -1,11 +1,13 @@
import './settings.css';
import { Plural, t, Trans } from '@lingui/macro';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg';
import Icon from '../components/icon';
import LangSelector from '../components/lang-selector';
import Link from '../components/link';
import RelativeTime from '../components/relative-time';
import targetLanguages from '../data/lingva-target-languages';
@ -64,18 +66,22 @@ function Settings({ onClose }) {
<div id="settings-container" class="sheet" tabIndex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
<Icon icon="x" alt={t`Close`} />
</button>
)}
<header>
<h2>Settings</h2>
<h2>
<Trans>Settings</Trans>
</h2>
</header>
<main>
<section>
<ul>
<li>
<div>
<label>Appearance</label>
<label>
<Trans>Appearance</Trans>
</label>
</div>
<div>
<form
@ -149,7 +155,9 @@ function Settings({ onClose }) {
value="light"
defaultChecked={currentTheme === 'light'}
/>
<span>Light</span>
<span>
<Trans>Light</Trans>
</span>
</label>
<label>
<input
@ -158,7 +166,9 @@ function Settings({ onClose }) {
value="dark"
defaultChecked={currentTheme === 'dark'}
/>
<span>Dark</span>
<span>
<Trans>Dark</Trans>
</span>
</label>
<label>
<input
@ -169,7 +179,9 @@ function Settings({ onClose }) {
currentTheme !== 'light' && currentTheme !== 'dark'
}
/>
<span>Auto</span>
<span>
<Trans>Auto</Trans>
</span>
</label>
</div>
</form>
@ -177,10 +189,16 @@ function Settings({ onClose }) {
</li>
<li>
<div>
<label>Text size</label>
<label>
<Trans>Text size</Trans>
</label>
</div>
<div class="range-group">
<span style={{ fontSize: TEXT_SIZES[0] }}>A</span>{' '}
<span style={{ fontSize: TEXT_SIZES[0] }}>
<Trans comment="Preview of one character, in smallest size">
A
</Trans>
</span>{' '}
<input
type="range"
min={TEXT_SIZES[0]}
@ -202,7 +220,9 @@ function Settings({ onClose }) {
}}
/>{' '}
<span style={{ fontSize: TEXT_SIZES[TEXT_SIZES.length - 1] }}>
A
<Trans comment="Preview of one character, in largest size">
A
</Trans>
</span>
<datalist id="sizes">
{TEXT_SIZES.map((size) => (
@ -211,18 +231,26 @@ function Settings({ onClose }) {
</datalist>
</div>
</li>
<li>
<label>
<Trans>Display language</Trans>
</label>
<LangSelector />
</li>
</ul>
</section>
{authenticated && (
<>
<h3>Posting</h3>
<h3>
<Trans>Posting</Trans>
</h3>
<section>
<ul>
<li>
<div>
<label for="posting-privacy-field">
Default visibility{' '}
<Icon icon="cloud" alt="Synced" class="synced-icon" />
<Trans>Default visibility</Trans>{' '}
<Icon icon="cloud" alt={t`Synced`} class="synced-icon" />
</label>
</div>
<div>
@ -247,36 +275,46 @@ function Settings({ onClose }) {
'posting:default:visibility': value,
});
} catch (e) {
alert('Failed to update posting privacy');
alert(t`Failed to update posting privacy`);
console.error(e);
}
})();
}}
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers only</option>
<option value="public">
<Trans>Public</Trans>
</option>
<option value="unlisted">
<Trans>Unlisted</Trans>
</option>
<option value="private">
<Trans>Followers only</Trans>
</option>
</select>
</div>
</li>
</ul>
</section>
<p class="section-postnote">
<Icon icon="cloud" alt="Synced" class="synced-icon" />{' '}
<Icon icon="cloud" alt={t`Synced`} class="synced-icon" />{' '}
<small>
Synced to your instance server's settings.{' '}
<a
href={`https://${instance}/`}
target="_blank"
rel="noopener noreferrer"
>
Go to your instance ({instance}) for more settings.
</a>
<Trans>
Synced to your instance server's settings.{' '}
<a
href={`https://${instance}/`}
target="_blank"
rel="noopener noreferrer"
>
Go to your instance ({instance}) for more settings.
</a>
</Trans>
</small>
</p>
</>
)}
<h3>Experiments</h3>
<h3>
<Trans>Experiments</Trans>
</h3>
<section>
<ul>
<li>
@ -288,7 +326,7 @@ function Settings({ onClose }) {
states.settings.autoRefresh = e.target.checked;
}}
/>{' '}
Auto refresh timeline posts
<Trans>Auto refresh timeline posts</Trans>
</label>
</li>
<li>
@ -300,7 +338,7 @@ function Settings({ onClose }) {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel
<Trans>Boosts carousel</Trans>
</label>
</li>
<li>
@ -316,7 +354,7 @@ function Settings({ onClose }) {
}
}}
/>{' '}
Post translation
<Trans>Post translation</Trans>
</label>
<div
class={`sub-section ${
@ -327,7 +365,7 @@ function Settings({ onClose }) {
>
<div>
<label>
Translate to{' '}
<Trans>Translate to </Trans>
<select
value={targetLanguage || ''}
disabled={!snapStates.settings.contentTranslation}
@ -337,78 +375,99 @@ function Settings({ onClose }) {
}}
>
<option value="">
System language ({systemTargetLanguageText})
<Trans>
System language ({systemTargetLanguageText})
</Trans>
</option>
<option disabled></option>
{targetLanguages.map((lang) => (
<option value={lang.code}>{lang.name}</option>
))}
{targetLanguages.map((lang) => {
const common = localeCode2Text({
code: lang.code,
fallback: lang.name,
});
const native = localeCode2Text({
code: lang.code,
locale: lang.code,
});
const same = !native || common === native;
return (
<option value={lang.code}>
{same ? common : `${common} (${native})`}
</option>
);
})}
</select>
</label>
</div>
<hr />
<p class="checkbox-fieldset">
Hide "Translate" button for
{snapStates.settings.contentTranslationHideLanguages.length >
0 && (
<>
{' '}
(
{
snapStates.settings.contentTranslationHideLanguages
.length
}
)
</>
)}
:
<div class="checkbox-fieldset">
<Plural
value={
snapStates.settings.contentTranslationHideLanguages.length
}
_0={`Hide "Translate" button for:`}
other={`Hide "Translate" button for (#):`}
/>
<div class="checkbox-fields">
{targetLanguages.map((lang) => (
<label>
<input
type="checkbox"
checked={snapStates.settings.contentTranslationHideLanguages.includes(
lang.code,
)}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
states.settings.contentTranslationHideLanguages.push(
lang.code,
);
} else {
states.settings.contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages.filter(
(code) => code !== lang.code,
{targetLanguages.map((lang) => {
const common = localeCode2Text({
code: lang.code,
fallback: lang.name,
});
const native = localeCode2Text({
code: lang.code,
locale: lang.code,
});
const same = !native || common === native;
return (
<label>
<input
type="checkbox"
checked={snapStates.settings.contentTranslationHideLanguages.includes(
lang.code,
)}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
states.settings.contentTranslationHideLanguages.push(
lang.code,
);
}
}}
/>{' '}
{lang.name}
</label>
))}
} else {
states.settings.contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages.filter(
(code) => code !== lang.code,
);
}
}}
/>{' '}
{same ? common : `${common} (${native})`}
</label>
);
})}
</div>
</p>
</div>
<p class="insignificant">
<small>
Note: This feature uses external translation services,
powered by{' '}
<a
href="https://github.com/cheeaun/lingva-api"
target="_blank"
rel="noopener noreferrer"
>
Lingva API
</a>{' '}
&amp;{' '}
<a
href="https://github.com/thedaviddelta/lingva-translate"
target="_blank"
rel="noopener noreferrer"
>
Lingva Translate
</a>
.
<Trans>
Note: This feature uses external translation services,
powered by{' '}
<a
href="https://github.com/cheeaun/lingva-api"
target="_blank"
rel="noopener noreferrer"
>
Lingva API
</a>{' '}
&amp;{' '}
<a
href="https://github.com/thedaviddelta/lingva-translate"
target="_blank"
rel="noopener noreferrer"
>
Lingva Translate
</a>
.
</Trans>
</small>
</p>
<hr />
@ -423,13 +482,15 @@ function Settings({ onClose }) {
e.target.checked;
}}
/>{' '}
Auto inline translation
<Trans>Auto inline translation</Trans>
</label>
<p class="insignificant">
<small>
Automatically show translation for posts in timeline. Only
works for <b>short</b> posts without content warning,
media and poll.
<Trans>
Automatically show translation for posts in timeline.
Only works for <b>short</b> posts without content
warning, media and poll.
</Trans>
</small>
</p>
</div>
@ -445,23 +506,25 @@ function Settings({ onClose }) {
states.settings.composerGIFPicker = e.target.checked;
}}
/>{' '}
GIF Picker for composer
<Trans>GIF Picker for composer</Trans>
</label>
<div class="sub-section insignificant">
<small>
Note: This feature uses external GIF search service, powered
by{' '}
<a
href="https://developers.giphy.com/"
target="_blank"
rel="noopener noreferrer"
>
GIPHY
</a>
. G-rated (suitable for viewing by all ages), tracking
parameters are stripped, referrer information is omitted
from requests, but search queries and IP address information
will still reach their servers.
<Trans>
Note: This feature uses external GIF search service,
powered by{' '}
<a
href="https://developers.giphy.com/"
target="_blank"
rel="noopener noreferrer"
>
GIPHY
</a>
. G-rated (suitable for viewing by all ages), tracking
parameters are stripped, referrer information is omitted
from requests, but search queries and IP address
information will still reach their servers.
</Trans>
</small>
</div>
</li>
@ -476,23 +539,29 @@ function Settings({ onClose }) {
states.settings.mediaAltGenerator = e.target.checked;
}}
/>{' '}
Image description generator{' '}
<Trans>Image description generator</Trans>{' '}
<Icon icon="sparkles2" class="more-insignificant" />
</label>
<div class="sub-section insignificant">
<small>Only for new images while composing new posts.</small>
<small>
<Trans>
Only for new images while composing new posts.
</Trans>
</small>
</div>
<div class="sub-section insignificant">
<small>
Note: This feature uses external AI service, powered by{' '}
<a
href="https://github.com/cheeaun/img-alt-api"
target="_blank"
rel="noopener noreferrer"
>
img-alt-api
</a>
. May not work well. Only for images and in English.
<Trans>
Note: This feature uses external AI service, powered by{' '}
<a
href="https://github.com/cheeaun/img-alt-api"
target="_blank"
rel="noopener noreferrer"
>
img-alt-api
</a>
. May not work well. Only for images and in English.
</Trans>
</small>
</div>
</li>
@ -508,12 +577,14 @@ function Settings({ onClose }) {
e.target.checked;
}}
/>{' '}
Server-side grouped notifications
<Trans>Server-side grouped notifications</Trans>
</label>
<div class="sub-section insignificant">
<small>
Alpha-stage feature. Potentially improved grouping window
but basic grouping logic.
<Trans>
Alpha-stage feature. Potentially improved grouping window
but basic grouping logic.
</Trans>
</small>
</div>
</li>
@ -531,22 +602,26 @@ function Settings({ onClose }) {
e.target.checked;
}}
/>{' '}
"Cloud" import/export for shortcuts settings{' '}
<Trans>"Cloud" import/export for shortcuts settings</Trans>{' '}
<Icon icon="cloud" class="more-insignificant" />
</label>
<div class="sub-section insignificant">
<small>
Very experimental.
<br />
Stored in your own profiles notes. Profile (private) notes
are mainly used for other profiles, and hidden for own
profile.
<Trans>
Very experimental.
<br />
Stored in your own profiles notes. Profile (private)
notes are mainly used for other profiles, and hidden for
own profile.
</Trans>
</small>
</div>
<div class="sub-section insignificant">
<small>
Note: This feature uses currently-logged-in instance server
API.
<Trans>
Note: This feature uses currently-logged-in instance
server API.
</Trans>
</small>
</div>
</li>
@ -560,15 +635,19 @@ function Settings({ onClose }) {
states.settings.cloakMode = e.target.checked;
}}
/>{' '}
Cloak mode{' '}
<span class="insignificant">
(<samp>Text</samp> <samp></samp>)
</span>
<Trans>
Cloak mode{' '}
<span class="insignificant">
(<samp>Text</samp> <samp></samp>)
</span>
</Trans>
</label>
<div class="sub-section insignificant">
<small>
Replace text as blocks, useful when taking screenshots, for
privacy reasons.
<Trans>
Replace text as blocks, useful when taking screenshots, for
privacy reasons.
</Trans>
</small>
</div>
</li>
@ -582,14 +661,16 @@ function Settings({ onClose }) {
states.showSettings = false;
}}
>
Unsent drafts
<Trans>Unsent drafts</Trans>
</button>
</li>
)}
</ul>
</section>
{authenticated && <PushNotificationsSection onClose={onClose} />}
<h3>About</h3>
<h3>
<Trans>About</Trans>
</h3>
<section>
<div
style={{
@ -627,25 +708,27 @@ function Settings({ onClose }) {
@phanpy
</a>
<br />
<a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
<Trans>
<a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
</Trans>
</div>
</div>
<p>
@ -654,7 +737,7 @@ function Settings({ onClose }) {
target="_blank"
rel="noopener noreferrer"
>
Sponsor
<Trans>Sponsor</Trans>
</a>{' '}
&middot;{' '}
<a
@ -662,7 +745,7 @@ function Settings({ onClose }) {
target="_blank"
rel="noopener noreferrer"
>
Donate
<Trans>Donate</Trans>
</a>{' '}
&middot;{' '}
<a
@ -670,52 +753,56 @@ function Settings({ onClose }) {
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
<Trans>Privacy Policy</Trans>
</a>
</p>
{__BUILD_TIME__ && (
<p>
{WEBSITE && (
<>
<span class="insignificant">Site:</span>{' '}
{WEBSITE.replace(/https?:\/\//g, '').replace(/\/$/, '')}
<Trans>
<span class="insignificant">Site:</span>{' '}
{WEBSITE.replace(/https?:\/\//g, '').replace(/\/$/, '')}
</Trans>
<br />
</>
)}
<span class="insignificant">Version:</span>{' '}
<input
type="text"
class="version-string"
readOnly
size="18" // Manually calculated here
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
}`}
onClick={(e) => {
e.target.select();
// Copy to clipboard
try {
navigator.clipboard.writeText(e.target.value);
showToast('Version string copied');
} catch (e) {
console.warn(e);
showToast('Unable to copy version string');
}
}}
/>{' '}
{!__FAKE_COMMIT_HASH__ && (
<span class="ib insignificant">
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
<RelativeTime datetime={new Date(__BUILD_TIME__)} />
</a>
)
</span>
)}
<Trans>
<span class="insignificant">Version:</span>{' '}
<input
type="text"
class="version-string"
readOnly
size="18" // Manually calculated here
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
}`}
onClick={(e) => {
e.target.select();
// Copy to clipboard
try {
navigator.clipboard.writeText(e.target.value);
showToast(t`Version string copied`);
} catch (e) {
console.warn(e);
showToast(t`Unable to copy version string`);
}
}}
/>{' '}
{!__FAKE_COMMIT_HASH__ && (
<span class="ib insignificant">
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
<RelativeTime datetime={new Date(__BUILD_TIME__)} />
</a>
)
</span>
)}
</Trans>
</p>
)}
</section>
@ -823,24 +910,26 @@ function PushNotificationsSection({ onClose }) {
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
alert(t`Failed to update subscription. Please try again.`);
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
alert(t`Failed to update subscription. Please try again.`);
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
alert(t`Failed to remove subscription. Please try again.`);
});
}
}, 100);
}}
>
<h3>Push Notifications (beta)</h3>
<h3>
<Trans>Push Notifications (beta)</Trans>
</h3>
<section>
<ul>
<li>
@ -861,7 +950,7 @@ function PushNotificationsSection({ onClose }) {
setAllowNotifications(false);
if (permission === 'denied') {
alert(
'Push notifications are blocked. Please enable them in your browser settings.',
t`Push notifications are blocked. Please enable them in your browser settings.`,
);
}
}
@ -870,28 +959,30 @@ function PushNotificationsSection({ onClose }) {
}
}}
/>{' '}
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNotifications}
>
{[
{
value: 'all',
label: 'anyone',
},
{
value: 'followed',
label: 'people I follow',
},
{
value: 'follower',
label: 'followers',
},
].map((type) => (
<option value={type.value}>{type.label}</option>
))}
</select>
<Trans>
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNotifications}
>
{[
{
value: 'all',
label: t`anyone`,
},
{
value: 'followed',
label: t`people I follow`,
},
{
value: 'follower',
label: t`followers`,
},
].map((type) => (
<option value={type.value}>{type.label}</option>
))}
</select>
</Trans>
</label>
<div
class="shazam-container no-animation"
@ -906,35 +997,35 @@ function PushNotificationsSection({ onClose }) {
{[
{
value: 'mention',
label: 'Mentions',
label: t`Mentions`,
},
{
value: 'favourite',
label: 'Likes',
label: t`Likes`,
},
{
value: 'reblog',
label: 'Boosts',
label: t`Boosts`,
},
{
value: 'follow',
label: 'Follows',
label: t`Follows`,
},
{
value: 'followRequest',
label: 'Follow requests',
label: t`Follow requests`,
},
{
value: 'poll',
label: 'Polls',
label: t`Polls`,
},
{
value: 'update',
label: 'Post edits',
label: t`Post edits`,
},
{
value: 'status',
label: 'New posts',
label: t`New posts`,
},
].map((alert) => (
<li>
@ -951,12 +1042,14 @@ function PushNotificationsSection({ onClose }) {
{needRelogin && (
<div class="sub-section">
<p>
Push permission was not granted since your last login. You'll
need to{' '}
<Link to={`/login?instance=${instance}`} onClick={onClose}>
<b>log in</b> again to grant push permission
</Link>
.
<Trans>
Push permission was not granted since your last login.
You'll need to{' '}
<Link to={`/login?instance=${instance}`} onClick={onClose}>
<b>log in</b> again to grant push permission
</Link>
.
</Trans>
</p>
</div>
)}
@ -965,7 +1058,9 @@ function PushNotificationsSection({ onClose }) {
</section>
<p class="section-postnote">
<small>
NOTE: Push notifications only work for <b>one account</b>.
<Trans>
NOTE: Push notifications only work for <b>one account</b>.
</Trans>
</small>
</p>
</form>

View file

@ -1,5 +1,6 @@
import './status.css';
import { Plural, t, Trans } from '@lingui/macro';
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
import debounce from 'just-debounce-it';
import pRetry from 'p-retry';
@ -561,7 +562,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
useTitle(
heroDisplayName && heroContentText
? `${heroDisplayName}: "${heroContentText}"`
: 'Status',
: t`Post`,
'/:instance?/s/:id',
);
@ -782,19 +783,23 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{uiState !== 'loading' && !authenticated ? (
<div class="post-status-banner">
<p>
You're not logged in. Interactions (reply, boost, etc) are
not possible.
<Trans>
You're not logged in. Interactions (reply, boost, etc) are
not possible.
</Trans>
</p>
<Link to="/login" class="button">
Log in
<Trans>Log in</Trans>
</Link>
</div>
) : (
!sameInstance && (
<div class="post-status-banner">
<p>
This post is from another instance (<b>{instance}</b>).
Interactions (reply, boost, etc) are not possible.
<Trans>
This post is from another instance (<b>{instance}</b>).
Interactions (reply, boost, etc) are not possible.
</Trans>
</p>
<button
type="button"
@ -819,14 +824,16 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}
} catch (e) {
setUIState('default');
alert('Error: ' + e);
alert(t`Error: ${e}`);
console.error(e);
}
})();
}}
>
<Icon icon="transfer" /> Switch to my instance to enable
interactions
<Icon icon="transfer" />{' '}
<Trans>
Switch to my instance to enable interactions
</Trans>
</button>
</div>
)
@ -882,7 +889,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
)}
{ancestor && repliesCount > 1 && (
<div class="replies-link">
<Icon icon="comment2" />{' '}
<Icon icon="comment2" alt={t`Replies`} />{' '}
<span title={repliesCount}>
{shortenNumber(repliesCount)}
</span>
@ -926,7 +933,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-error">
Unable to load replies.
<Trans>Unable to load replies.</Trans>
<br />
<button
type="button"
@ -935,7 +942,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
states.reloadStatusPage++;
}}
>
Try again
<Trans>Try again</Trans>
</button>
</div>
)}
@ -1038,7 +1045,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
history.back();
}}
>
<Icon icon="chevron-left" size="xl" />
<Icon icon="chevron-left" size="xl" alt={t`Back`} />
</button>
)}
{!heroInView && heroStatus && uiState !== 'loading' ? (
@ -1069,7 +1076,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
block: 'start',
});
}}
title="Go to main post"
title={t`Go to main post`}
>
<Icon
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
@ -1092,7 +1099,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
});
}}
hidden={!ancestors.length || reachTopPost}
title={`${ancestors.length} posts above Go to top`}
title={t`${ancestors.length} posts above Go to top`}
>
<Icon icon="arrow-up" />
{ancestors
@ -1135,7 +1142,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
searchParams.delete('view');
setSearchParams(searchParams);
}}
title="Switch to Side Peek view"
title={t`Switch to Side Peek view`}
>
<Icon icon="layout4" size="l" />
</button>
@ -1148,7 +1155,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
setShowRefresh(false);
}}
>
<Icon icon="refresh" size="l" />
<Icon icon="refresh" size="l" alt={t`Refresh`} />
</button>
)}
<Menu2
@ -1159,7 +1166,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}}
menuButton={
<button type="button" class="button plain4">
<Icon icon="more" alt="Actions" size="xl" />
<Icon icon="more" alt={t`More`} size="xl" />
</button>
}
>
@ -1170,7 +1177,9 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}}
>
<Icon icon="refresh" />
<span>Refresh</span>
<span>
<Trans>Refresh</Trans>
</span>
</MenuItem>
<MenuItem
className="menu-switch-view"
@ -1195,7 +1204,9 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}
/>
<span>
Switch to {viewMode === 'full' ? 'Side Peek' : 'Full'} view
{viewMode === 'full'
? t`Switch to Side Peek view`
: t`Switch to Full view`}
</span>
</MenuItem>
<MenuItem
@ -1211,10 +1222,15 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
});
}}
>
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
<Icon icon="eye-open" />{' '}
<span>
<Trans>Show all sensitive content</Trans>
</span>
</MenuItem>
<MenuDivider />
<MenuHeader className="plain">Experimental</MenuHeader>
<MenuHeader className="plain">
<Trans>Experimental</Trans>
</MenuHeader>
<MenuItem
disabled={!postInstance || postSameInstance}
onClick={() => {
@ -1222,26 +1238,22 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
if (statusURL) {
location.hash = statusURL;
} else {
alert('Unable to switch');
alert(t`Unable to switch`);
}
}}
>
<Icon icon="transfer" />
<small class="menu-double-lines">
Switch to post's instance
{postInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(postInstance)}</b>)
</>
) : (
''
)}
{postInstance
? t`Switch to post's instance (${punycode.toUnicode(
postInstance,
)})`
: t`Switch to post's instance`}
</small>
</MenuItem>
</Menu2>
<Link class="button plain deck-close" to={closeLink}>
<Icon icon="x" size="xl" />
<Icon icon="x" size="xl" alt={t`Close`} />
</Link>
</div>
</div>
@ -1274,7 +1286,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
))}
</div>{' '}
<div class="ib">
Show more&hellip;{' '}
<Trans>Show more</Trans>{' '}
<span class="tag">
{showMore > LIMIT ? `${LIMIT}+` : showMore}
</span>
@ -1294,7 +1306,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load post
<Trans>Unable to load post</Trans>
<br />
<br />
<button
@ -1303,7 +1315,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
states.reloadStatusPage++;
}}
>
Try again
<Trans>Try again</Trans>
</button>
</p>
)}
@ -1411,20 +1423,36 @@ function SubComments({
</span>
<span class="replies-counts">
<b>
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
repl
{replies.length === 1 ? 'y' : 'ies'}
<Plural
value={replies.length}
one="# reply"
other={
<Trans>
<span title={replies.length}>
{shortenNumber(replies.length)}
</span>{' '}
replies
</Trans>
}
/>
</b>
{!sameCount && totalComments > 1 && (
<>
{' '}
&middot;{' '}
<span>
<span title={totalComments}>
{shortenNumber(totalComments)}
</span>{' '}
comment
{totalComments === 1 ? '' : 's'}
<Plural
value={totalComments}
one="# comment"
other={
<Trans>
<span title={totalComments}>
{shortenNumber(totalComments)}
</span>{' '}
comments
</Trans>
}
/>
</span>
</>
)}
@ -1435,7 +1463,7 @@ function SubComments({
class="replies-parent-link"
to={parentLink.to}
onClick={parentLink.onClick}
title="View post with its replies"
title={t`View post with its replies`}
>
&raquo;
</Link>
@ -1463,7 +1491,7 @@ function SubComments({
/>
{!r.replies?.length && r.repliesCount > 0 && (
<div class="replies-link">
<Icon icon="comment2" />{' '}
<Icon icon="comment2" alt={t`Replies`} />{' '}
<span title={r.repliesCount}>
{shortenNumber(r.repliesCount)}
</span>

View file

@ -1,6 +1,7 @@
import '../components/links-bar.css';
import './trending.css';
import { t, Trans } from '@lingui/macro';
import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
@ -66,7 +67,7 @@ function Trending({ columnMode, ...props }) {
instance: props?.instance || params.instance,
});
const { masto: currentMasto, instance: currentInstance } = api();
const title = `Trending (${instance})`;
const title = t`Trending (${instance})`;
useTitle(title, `/:instance?/trending`);
// const navigate = useNavigate();
const latestItem = useRef();
@ -222,7 +223,9 @@ function Trending({ columnMode, ...props }) {
{!!links.length && (
<div class="links-bar">
<header>
<h3>Trending News</h3>
<h3>
<Trans>Trending News</Trans>
</h3>
</header>
{links.map((link) => {
const {
@ -339,7 +342,10 @@ function Trending({ columnMode, ...props }) {
}}
disabled={url === currentLink}
>
<Icon icon="comment2" /> <span>Mentions</span>{' '}
<Icon icon="comment2" />{' '}
<span>
<Trans>Mentions</Trans>
</span>{' '}
<Icon icon="chevron-down" />
</button>
)}
@ -365,21 +371,25 @@ function Trending({ columnMode, ...props }) {
setCurrentLink(null);
}}
>
<Icon icon="x" />
<Icon icon="x" alt={t`Back to showing trending posts`} />
</button>
)}
</div>
<p>
Showing posts mentioning{' '}
<span class="link-text">
{currentLink
.replace(/^https?:\/\/(www\.)?/i, '')
.replace(/\/$/, '')}
</span>
<Trans>
Showing posts mentioning{' '}
<span class="link-text">
{currentLink
.replace(/^https?:\/\/(www\.)?/i, '')
.replace(/\/$/, '')}
</span>
</Trans>
</p>
</>
) : (
<p class="insignificant">Trending posts</p>
<p class="insignificant">
<Trans>Trending posts</Trans>
</p>
)}
</div>
)}
@ -393,14 +403,16 @@ function Trending({ columnMode, ...props }) {
title={title}
titleComponent={
<h1 class="header-double-lines">
<b>Trending</b>
<b>
<Trans>Trending</Trans>
</b>
<div>{instance}</div>
</h1>
}
id="trending"
instance={instance}
emptyText="No trending posts."
errorText="Unable to load posts"
emptyText={t`No trending posts.`}
errorText={t`Unable to load posts`}
fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends}
checkForUpdates={hasCurrentLink ? undefined : checkForUpdates}
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
@ -422,17 +434,17 @@ function Trending({ columnMode, ...props }) {
position="anchor"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
<Icon icon="more" size="l" alt={t`More`} />
</button>
}
>
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
t`Enter a new instance e.g. "mastodon.social"`,
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
if (newInstance) alert(t`Invalid instance`);
return;
}
if (newInstance) {
@ -442,7 +454,10 @@ function Trending({ columnMode, ...props }) {
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
<Icon icon="bus" />{' '}
<span>
<Trans>Go to another instance</Trans>
</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
@ -452,7 +467,9 @@ function Trending({ columnMode, ...props }) {
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
<Trans>
Go to my instance (<b>{currentInstance}</b>)
</Trans>
</small>
</MenuItem>
)}

View file

@ -1,5 +1,7 @@
import './welcome.css';
import { t, Trans } from '@lingui/macro';
import boostsCarouselUrl from '../assets/features/boosts-carousel.jpg';
import groupedNotificationsUrl from '../assets/features/grouped-notifications.jpg';
import multiColumnUrl from '../assets/features/multi-column.jpg';
@ -8,6 +10,7 @@ import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.j
import logoText from '../assets/logo-text.svg';
import logo from '../assets/logo.svg';
import LangSelector from '../components/lang-selector';
import Link from '../components/link';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
@ -46,7 +49,9 @@ function Welcome() {
/>
<img src={logoText} alt="Phanpy" width="200" />
</h1>
<p class="desc">A minimalistic opinionated Mastodon web client.</p>
<p class="desc">
<Trans>A minimalistic opinionated Mastodon web client.</Trans>
</p>
<p>
<Link
to={
@ -56,22 +61,24 @@ function Welcome() {
}
class="button"
>
{DEFAULT_INSTANCE ? 'Log in' : 'Log in with Mastodon'}
{DEFAULT_INSTANCE ? t`Log in` : t`Log in with Mastodon`}
</Link>
</p>
{DEFAULT_INSTANCE && DEFAULT_INSTANCE_REGISTRATION_URL && (
<p>
<a href={DEFAULT_INSTANCE_REGISTRATION_URL} class="button plain5">
Sign up
<Trans>Sign up</Trans>
</a>
</p>
)}
{!DEFAULT_INSTANCE && (
<p class="insignificant">
<small>
Connect your existing Mastodon/Fediverse account.
<br />
Your credentials are not stored on this server.
<Trans>
Connect your existing Mastodon/Fediverse account.
<br />
Your credentials are not stored on this server.
</Trans>
</small>
</p>
)}
@ -84,81 +91,107 @@ function Welcome() {
</p>
)}
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<a href={PRIVACY_POLICY_URL} target="_blank">
Privacy Policy
</a>
.
<Trans>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<a href={PRIVACY_POLICY_URL} target="_blank">
Privacy Policy
</a>
.
</Trans>
</p>
<LangSelector />
</div>
<div id="why-container">
<div class="sections">
<section>
<img
src={boostsCarouselUrl}
alt="Screenshot of Boosts Carousel"
alt={t`Screenshot of Boosts Carousel`}
loading="lazy"
/>
<h4>Boosts Carousel</h4>
<h4>
<Trans>Boosts Carousel</Trans>
</h4>
<p>
Visually separate original posts and re-shared posts (boosted
posts).
<Trans>
Visually separate original posts and re-shared posts (boosted
posts).
</Trans>
</p>
</section>
<section>
<img
src={nestedCommentsThreadUrl}
alt="Screenshot of nested comments thread"
alt={t`Screenshot of nested comments thread`}
loading="lazy"
/>
<h4>Nested comments thread</h4>
<p>Effortlessly follow conversations. Semi-collapsible replies.</p>
<h4>
<Trans>Nested comments thread</Trans>
</h4>
<p>
<Trans>
Effortlessly follow conversations. Semi-collapsible replies.
</Trans>
</p>
</section>
<section>
<img
src={groupedNotificationsUrl}
alt="Screenshot of grouped notifications"
alt={t`Screenshot of grouped notifications`}
loading="lazy"
/>
<h4>Grouped notifications</h4>
<h4>
<Trans>Grouped notifications</Trans>
</h4>
<p>
Similar notifications are grouped and collapsed to reduce clutter.
<Trans>
Similar notifications are grouped and collapsed to reduce
clutter.
</Trans>
</p>
</section>
<section>
<img
src={multiColumnUrl}
alt="Screenshot of multi-column UI"
alt={t`Screenshot of multi-column UI`}
loading="lazy"
/>
<h4>Single or multi-column</h4>
<h4>
<Trans>Single or multi-column</Trans>
</h4>
<p>
By default, single column for zen-mode seekers. Configurable
multi-column for power users.
<Trans>
By default, single column for zen-mode seekers. Configurable
multi-column for power users.
</Trans>
</p>
</section>
<section>
<img
src={multiHashtagTimelineUrl}
alt="Screenshot of multi-hashtag timeline with a form to add more hashtags"
alt={t`Screenshot of multi-hashtag timeline with a form to add more hashtags`}
loading="lazy"
/>
<h4>Multi-hashtag timeline</h4>
<p>Up to 5 hashtags combined into a single timeline.</p>
<h4>
<Trans>Multi-hashtag timeline</Trans>
</h4>
<p>
<Trans>Up to 5 hashtags combined into a single timeline.</Trans>
</p>
</section>
</div>
</div>

View file

@ -0,0 +1,10 @@
import { i18n } from '@lingui/core';
export default function i18nDuration(duration, unit) {
return () =>
i18n.number(duration, {
style: 'unit',
unit,
unitDisplay: 'long',
});
}

56
src/utils/lang.js Normal file
View file

@ -0,0 +1,56 @@
import { i18n } from '@lingui/core';
import {
detect,
fromNavigator,
fromStorage,
fromUrl,
} from '@lingui/detect-locale';
import Locale from 'intl-locale-textinfo-polyfill';
import { messages } from '../locales/en.po';
import localeMatch from '../utils/locale-match';
const { PHANPY_DEFAULT_LANG } = import.meta.env;
export const DEFAULT_LANG = 'en';
export const LOCALES = [DEFAULT_LANG];
if (import.meta.env.DEV) {
LOCALES.push('pseudo-LOCALE');
}
export async function activateLang(lang) {
if (!lang || lang === DEFAULT_LANG) {
i18n.loadAndActivate({ locale: DEFAULT_LANG, messages });
console.log('💬 ACTIVATE LANG', lang);
} else {
const { messages } = await import(`../locales/${lang}.po`);
i18n.loadAndActivate({ locale: lang, messages });
console.log('💬 ACTIVATE LANG', lang);
}
}
i18n.on('change', () => {
const lang = i18n.locale;
if (lang) {
// LTR or RTL
const { direction } = new Locale(lang).textInfo;
document.documentElement.dir = direction;
}
});
export function initActivateLang() {
const lang = detect(
fromUrl('lang'),
fromStorage('lang'),
fromNavigator(),
PHANPY_DEFAULT_LANG,
DEFAULT_LANG,
);
const matchedLang = localeMatch(lang, LOCALES);
activateLang(matchedLang);
// const yes = confirm(t`Reload to apply language setting?`);
// if (yes) {
// window.location.reload();
// }
}

View file

@ -1,15 +1,41 @@
import { i18n } from '@lingui/core';
import mem from './mem';
const IntlDN = new Intl.DisplayNames(undefined, {
type: 'language',
});
// Some codes are not supported by Intl.DisplayNames
// These are mapped to other codes as fallback
const codeMappings = {
'zh-YUE': 'YUE',
zh_HANT: 'zh-Hant',
};
const IntlDN = mem(
(locale) =>
new Intl.DisplayNames(locale || undefined, {
type: 'language',
}),
);
function _localeCode2Text(code) {
let locale;
let fallback;
if (typeof code === 'object') {
({ code, locale, fallback } = code);
}
try {
return IntlDN.of(code);
const text = IntlDN(locale || i18n.locale).of(code);
if (text !== code) return text;
return fallback || '';
} catch (e) {
console.error(e);
return null;
if (codeMappings[code]) {
try {
const text = IntlDN(locale || i18n.locale).of(codeMappings[code]);
if (text !== codeMappings[code]) return text;
return fallback || '';
} catch (e) {}
}
console.warn(code, e);
return fallback || '';
}
}

View file

@ -1,11 +1,14 @@
import { i18n } from '@lingui/core';
import mem from './mem';
const { locale } = new Intl.DateTimeFormat().resolvedOptions();
const defaultLocale = new Intl.DateTimeFormat().resolvedOptions().locale;
const _DateTimeFormat = (opts) => {
const { dateYear, hideTime, formatOpts } = opts || {};
const { locale, dateYear, hideTime, formatOpts } = opts || {};
const loc = locale && !/pseudo/i.test(locale) ? locale : defaultLocale;
const currentYear = new Date().getFullYear();
return Intl.DateTimeFormat(locale, {
return Intl.DateTimeFormat(loc, {
// Show year if not current year
year: dateYear === currentYear ? undefined : 'numeric',
month: 'short',
@ -24,6 +27,7 @@ function niceDateTime(date, dtfOpts) {
}
const DTF = DateTimeFormat({
dateYear: date.getFullYear(),
locale: i18n.locale,
...dtfOpts,
});
const dateText = DTF.format(date);

View file

@ -1,3 +1,5 @@
import { t, Trans } from '@lingui/macro';
export default function openCompose(opts) {
const url = URL.parse('/compose/', window.location);
const { width: screenWidth, height: screenHeight } = window.screen;
@ -19,7 +21,7 @@ export default function openCompose(opts) {
newWin.__COMPOSE__ = opts;
} else {
alert('Looks like your browser is blocking popups.');
alert(t`Looks like your browser is blocking popups.`);
}
return newWin;

24
src/utils/pretty-bytes.js Normal file
View file

@ -0,0 +1,24 @@
import { i18n } from '@lingui/core';
// https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers
const BYTES_UNITS = [
'byte',
'kilobyte',
'megabyte',
'gigabyte',
'terabyte',
'petabyte',
];
export default function prettyBytes(bytes) {
const unitIndex = Math.min(
Math.floor(Math.log2(bytes) / 10),
BYTES_UNITS.length - 1,
);
const value = bytes / 1024 ** unitIndex;
return i18n.number(value, {
style: 'unit',
unit: BYTES_UNITS[unitIndex],
unitDisplay: 'narrow',
maximumFractionDigits: 0,
});
}

View file

@ -1,6 +1,8 @@
const { locale } = Intl.NumberFormat().resolvedOptions();
const shortenNumber = Intl.NumberFormat(locale, {
notation: 'compact',
roundingMode: 'floor',
}).format;
export default shortenNumber;
import { i18n } from '@lingui/core';
export default function shortenNumber(num) {
return i18n.number(num, {
notation: 'compact',
roundingMode: 'floor',
});
}

View file

@ -1,3 +1,5 @@
import { t, Trans } from '@lingui/macro';
import openOSK from './open-osk';
import showToast from './show-toast';
import states from './states';
@ -11,12 +13,12 @@ export default function showCompose(opts) {
if (states.composerState.minimized) {
showToast({
duration: TOAST_DURATION,
text: `A draft post is currently minimized. Post or discard it before creating a new one.`,
text: t`A draft post is currently minimized. Post or discard it before creating a new one.`,
});
} else {
showToast({
duration: TOAST_DURATION,
text: `A post is currently open. Post or discard it before creating a new one.`,
text: t`A post is currently open. Post or discard it before creating a new one.`,
});
}
return;

View file

@ -2,6 +2,7 @@ import { execSync } from 'child_process';
import fs from 'fs';
import { resolve } from 'path';
import { lingui } from '@lingui/vite-plugin';
import preact from '@preact/preset-vite';
import { uid } from 'uid/single';
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
@ -55,8 +56,11 @@ export default defineConfig({
preact({
// Force use Babel instead of ESBuild due to this change: https://github.com/preactjs/preset-vite/pull/114
// Else, a bug will happen with importing variables from import.meta.env
babel: {},
babel: {
plugins: ['macros'],
},
}),
lingui(),
splitVendorChunkPlugin(),
removeConsole({
includes: ['log', 'debug', 'info', 'warn', 'error'],