web/intl: replace homegrown join functions with Intl

This commit is contained in:
Jade Ellis 2024-12-08 17:46:17 +00:00
parent 1056a319bd
commit 74a86d1342
No known key found for this signature in database
GPG key ID: 8705A2A3EBF77BD2
8 changed files with 166 additions and 82 deletions

139
web/package-lock.json generated
View file

@ -17,6 +17,7 @@
"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"
},
@ -837,6 +838,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",
@ -1754,6 +1826,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 +1871,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 +2594,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 +3695,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 +3776,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 +4822,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 +5306,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 +5403,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",

View file

@ -19,6 +19,7 @@
"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"
},

View file

@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 <VerificationScreen client={client} clientState={clientState}/>
} else {
return <ClientContext value={client}>
<IntlProvider locale="en">
<LightboxWrapper>
<MainScreen/>
</LightboxWrapper>
{errorOverlay}
</IntlProvider>
</ClientContext>
}
}

View file

@ -14,14 +14,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 => <code className="server-name">{item}</code>))
function joinServers(arr: string[]): JSX.Element {
return <FormattedList type="conjunction" value={(arr.map(item => <code className="server-name">{item}</code>))}/>
}
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 <FormattedList type="conjunction" value={parts}/>
}
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 = [<Fragment key="yay">
changeString = <Fragment key="yay">
🎉 All servers are banned from participating! This room can no longer be used.
</Fragment>]
</Fragment>
}
return <div className="acl-body">
{sender?.content.displayname ?? event.sender} changed the server ACLs: {changeString}

View file

@ -13,30 +13,35 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<string>) => intl.formatList(items, { type: "conjunction" })
const [added, removed] = listDiff(content.pinned ?? [], prevContent?.pinned ?? [])
if (added.length || removed.length) {
const items = []
if (added.length) {
if (removed.length) {
return `pinned ${humanJoin(added)} and unpinned ${humanJoin(removed)}`
items.push(`pinned ${list(added)}`)
}
return `pinned ${humanJoin(added)}`
} else if (removed.length) {
return `unpinned ${humanJoin(removed)}`
if (removed.length) {
items.push(`unpinned ${list(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 <div className="pinned-events-body">
{sender?.content.displayname ?? event.sender} {renderPinChanges(content, prevContent)}
{sender?.content.displayname ?? event.sender} {renderPinChanges(intl, content, prevContent)}
</div>
}

View file

@ -13,9 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 <div className="power-level-body">
{sender?.content.displayname ?? event.sender} {humanJoin(renderPowerLevels(content, prevContent))}
{sender?.content.displayname ?? event.sender}
<FormattedList type="conjunction" value={renderPowerLevels(content, prevContent)}/>
</div>
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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]
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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) =>
<Fragment key={idx}>
{elem}
{idx < arr.length - 1 ? (idx === arr.length - 2 ? lastSep : sep) : null}
</Fragment>)
}
export const joinReact = (arr: (string | JSX.Element)[]) => humanJoinReact(arr, " ", " ")