diff --git a/web/package-lock.json b/web/package-lock.json index 4644b470..0ca0a212 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,11 +17,13 @@ "react": "^19.0.0", "react-blurhash": "^0.3.0", "react-dom": "^19.0.0", + "react-intl": "^7.0.2", "react-spinners": "^0.15.0", "unhomoglyph": "^1.0.6" }, "devDependencies": { "@eslint/js": "^9.11.1", + "@swc/plugin-formatjs": "^2.0.1", "@types/katex": "^0.16.7", "@types/leaflet": "^1.9.14", "@types/react": "^19.0.0", @@ -837,6 +839,77 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.5.tgz", + "integrity": "sha512-ep/5vGkyZvMSi6s8nQG8k7vTcKjuXs402fgGIWixj0AWRgKbeaZeLuYc32NIPXexgBjWepMeZGgHLuZXkuD2Gg==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.4", + "@formatjs/intl-localematcher": "0.5.8", + "tslib": "2" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.4.tgz", + "integrity": "sha512-8SzI0cBADgbLOYsoQW/IqVHljCH964CrOdESFQ07wMkRLP90+MfV7k6gZPiGD88ubqET9igJV5c292rT28B7xQ==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.5.tgz", + "integrity": "sha512-mHauC9wuVXtnshAIoAYjlNrh6+OFOT6cC4fpK+AG+DHkVWwIPFVQE28hLQ/KptuvQ8VMfG/zYx6rRjtaeFPkSQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.2.5", + "@formatjs/icu-skeleton-parser": "1.8.9", + "tslib": "2" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.9.tgz", + "integrity": "sha512-1KSSlU7ywsU5E5v7xr6VTlBzLGszMi3GOu7EVINjkfA501GN5OkeNSbd5q6ie1wIknZJGBlqkvXPYYdp3YXjpw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.2.5", + "tslib": "2" + } + }, + "node_modules/@formatjs/intl": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-3.0.2.tgz", + "integrity": "sha512-yZZJDKwoyW0USqV6dnEbJohnNqPREuIFrew01Ht0IiXlfKAjuah2Q3VO6tPXEDUxDo0mhroNEk+nKV0AVLunVQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.2.5", + "@formatjs/fast-memoize": "2.2.4", + "@formatjs/icu-messageformat-parser": "2.9.5", + "intl-messageformat": "10.7.8", + "tslib": "2" + }, + "peerDependencies": { + "typescript": "5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.8.tgz", + "integrity": "sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1730,6 +1803,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/plugin-formatjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@swc/plugin-formatjs/-/plugin-formatjs-2.0.1.tgz", + "integrity": "sha512-rVGGiKm1u9WVhopC9275TDzuBheHUxQEO7cRbqcyo4yZ5FmO5ukvM5XYkBp24k8fUScH6g3vNTAASqLN0jBL3Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@swc/types": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", @@ -1754,6 +1837,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1789,7 +1882,6 @@ "version": "19.0.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.0.tgz", "integrity": "sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2513,7 +2605,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -3615,6 +3706,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -3687,6 +3787,18 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.7.8", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.8.tgz", + "integrity": "sha512-XnFFzJnTfdaDqeiF/ZAUjpkoKEM8UKwHijQXuqpLiM42kuJCawytP/rYAMDYNNaWww/PTaI0rIoG4oUjRrRlnA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.2.5", + "@formatjs/fast-memoize": "2.2.4", + "@formatjs/icu-messageformat-parser": "2.9.5", + "tslib": "2" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -4721,6 +4833,37 @@ "react": "^19.0.0" } }, + "node_modules/react-intl": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-7.0.2.tgz", + "integrity": "sha512-6WDHf6vHgCvoJLFRhAMbLfIMaAeHnjBuJrYbV/0BS9K6lxetlDUWlCy/yKAtlZyUgxUXxMwxm1zbTJN+vHfEfQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.2.5", + "@formatjs/icu-messageformat-parser": "2.9.5", + "@formatjs/intl": "3.0.2", + "@types/hoist-non-react-statics": "3", + "@types/react": "16 || 17 || 18 || 19", + "hoist-non-react-statics": "3", + "intl-messageformat": "10.7.8", + "tslib": "2" + }, + "peerDependencies": { + "react": "^16.6.0 || 17 || 18 || 19", + "typescript": "5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-spinners": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.15.0.tgz", @@ -5174,7 +5317,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -5272,7 +5414,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/web/package.json b/web/package.json index 0d135f15..55825d0b 100644 --- a/web/package.json +++ b/web/package.json @@ -19,11 +19,13 @@ "react": "^19.0.0", "react-blurhash": "^0.3.0", "react-dom": "^19.0.0", + "react-intl": "^7.0.2", "react-spinners": "^0.15.0", "unhomoglyph": "^1.0.6" }, "devDependencies": { "@eslint/js": "^9.11.1", + "@swc/plugin-formatjs": "^2.0.1", "@types/katex": "^0.16.7", "@types/leaflet": "^1.9.14", "@types/react": "^19.0.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 748115c6..18ae7723 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { useEffect, useLayoutEffect, useMemo } from "react" +import { IntlProvider } from "react-intl" import { ScaleLoader } from "react-spinners" import Client from "./api/client.ts" import RPCClient from "./api/rpc.ts" @@ -84,10 +85,12 @@ function App() { return } else { return - - - - {errorOverlay} + + + + + {errorOverlay} + } } diff --git a/web/src/ui/timeline/content/ACLBody.tsx b/web/src/ui/timeline/content/ACLBody.tsx index 1a1d8cbc..816fc84c 100644 --- a/web/src/ui/timeline/content/ACLBody.tsx +++ b/web/src/ui/timeline/content/ACLBody.tsx @@ -14,14 +14,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { Fragment, JSX } from "react" +import { FormattedList } from "react-intl" import { ACLEventContent } from "@/api/types" import { listDiff } from "@/util/diff.ts" -import { humanJoinReact, joinReact } from "@/util/reactjoin.tsx" import { ensureArray, ensureStringArray } from "@/util/validation.ts" import EventContentProps from "./props.ts" -function joinServers(arr: string[]): JSX.Element[] { - return humanJoinReact(arr.map(item => {item})) +function joinServers(arr: string[]): JSX.Element { + return {item}))}/> } function makeACLChangeString( @@ -47,7 +47,7 @@ function makeACLChangeString( <>Participating from a server using an IP literal hostname is now {newAllowIP ? "allowed" : "banned"}., ) } - return joinReact(parts) + return } const ACLBody = ({ event, sender }: EventContentProps) => { @@ -68,9 +68,9 @@ const ACLBody = ({ event, sender }: EventContentProps) => { } let changeString = makeACLChangeString(addedAllow, removedAllow, addedDeny, removedDeny, prevAllowIP, newAllowIP) if (ensureArray(content.allow).length === 0) { - changeString = [ + changeString = 🎉 All servers are banned from participating! This room can no longer be used. - ] + } return
{sender?.content.displayname ?? event.sender} changed the server ACLs: {changeString} diff --git a/web/src/ui/timeline/content/PinnedEventsBody.tsx b/web/src/ui/timeline/content/PinnedEventsBody.tsx index c46908d0..87167005 100644 --- a/web/src/ui/timeline/content/PinnedEventsBody.tsx +++ b/web/src/ui/timeline/content/PinnedEventsBody.tsx @@ -13,30 +13,35 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { IntlShape, useIntl } from "react-intl" import { PinnedEventsContent } from "@/api/types" import { listDiff } from "@/util/diff.ts" -import { humanJoin } from "@/util/join.ts" import EventContentProps from "./props.ts" -function renderPinChanges(content: PinnedEventsContent, prevContent?: PinnedEventsContent): string { +function renderPinChanges(intl: IntlShape, content: PinnedEventsContent, prevContent?: PinnedEventsContent): string { + const list = (items: ReadonlyArray) => intl.formatList(items, { type: "conjunction" }) const [added, removed] = listDiff(content.pinned ?? [], prevContent?.pinned ?? []) - if (added.length) { + if (added.length || removed.length) { + const items = [] + if (added.length) { + items.push(`pinned ${list(added)}`) + } if (removed.length) { - return `pinned ${humanJoin(added)} and unpinned ${humanJoin(removed)}` + items.push(`unpinned ${list(removed)}`) } - return `pinned ${humanJoin(added)}` - } else if (removed.length) { - return `unpinned ${humanJoin(removed)}` + return list(items) } else { return "sent a no-op pin event" } } const PinnedEventsBody = ({ event, sender }: EventContentProps) => { + + const intl = useIntl() const content = event.content as PinnedEventsContent const prevContent = event.unsigned.prev_content as PinnedEventsContent | undefined return
- {sender?.content.displayname ?? event.sender} {renderPinChanges(content, prevContent)} + {sender?.content.displayname ?? event.sender} {renderPinChanges(intl, content, prevContent)}
} diff --git a/web/src/ui/timeline/content/PowerLevelBody.tsx b/web/src/ui/timeline/content/PowerLevelBody.tsx index 35980759..f6adcc47 100644 --- a/web/src/ui/timeline/content/PowerLevelBody.tsx +++ b/web/src/ui/timeline/content/PowerLevelBody.tsx @@ -13,9 +13,9 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { FormattedList } from "react-intl" import { PowerLevelEventContent } from "@/api/types" import { objectDiff } from "@/util/diff.ts" -import { humanJoin } from "@/util/join.ts" import EventContentProps from "./props.ts" function intDiff(messageParts: TemplateStringsArray, oldVal: number, newVal: number): string | null { @@ -68,7 +68,8 @@ const PowerLevelBody = ({ event, sender }: EventContentProps) => { const content = event.content as PowerLevelEventContent const prevContent = event.unsigned.prev_content as PowerLevelEventContent | undefined return
- {sender?.content.displayname ?? event.sender} {humanJoin(renderPowerLevels(content, prevContent))} + {sender?.content.displayname ?? event.sender} +
} diff --git a/web/src/util/join.ts b/web/src/util/join.ts deleted file mode 100644 index 2252efb4..00000000 --- a/web/src/util/join.ts +++ /dev/null @@ -1,27 +0,0 @@ -// gomuks - A Matrix client written in Go. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -export function humanJoin(arr: string[], sep: string = ", ", lastSep: string = " and "): string { - if (arr.length === 0) { - return "" - } - if (arr.length === 1) { - return arr[0] - } - if (arr.length === 2) { - return arr.join(lastSep) - } - return arr.slice(0, -1).join(sep) + lastSep + arr[arr.length - 1] -} diff --git a/web/src/util/reactjoin.tsx b/web/src/util/reactjoin.tsx deleted file mode 100644 index 96f450d5..00000000 --- a/web/src/util/reactjoin.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// gomuks - A Matrix client written in Go. -// Copyright (C) 2024 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -import { Fragment, JSX } from "react" - -export function humanJoinReact( - arr: (string | JSX.Element)[], - sep: string | JSX.Element = ", ", - lastSep: string | JSX.Element = " and ", -): JSX.Element[] { - return arr.map((elem, idx) => - - {elem} - {idx < arr.length - 1 ? (idx === arr.length - 2 ? lastSep : sep) : null} - ) -} - -export const joinReact = (arr: (string | JSX.Element)[]) => humanJoinReact(arr, " ", " ") diff --git a/web/vite.config.ts b/web/vite.config.ts index 5d88b2d8..063daa92 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,6 +7,7 @@ const splitDeps = ["katex", "leaflet", "monaco-editor"] export default defineConfig({ base: "./", build: { + target: ["esnext", "firefox128", "chrome131", "safari18"], chunkSizeWarningLimit: 3500, rollupOptions: { @@ -36,6 +37,7 @@ export default defineConfig({ resolve: { alias: { "@": "/src", + "@formatjs/icu-messageformat-parser": "@formatjs/icu-messageformat-parser/no-parser", }, }, server: {