diff --git a/README.md b/README.md index 58ec703a..0f35bbe4 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Follow the installation instructions on their home page. 1. **Clone the repository:** ```bash - git clone [https://github.com/meshtastic/meshtastic-web.git](https://github.com/meshtastic/meshtastic-web.git) + git clone https://github.com/meshtastic/meshtastic-web.git cd meshtastic-web ``` 2. **Install dependencies for all packages:** diff --git a/bun.lock b/bun.lock index 0732f678..29f49b77 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "meshtastic-web", "dependencies": { "@bufbuild/protobuf": "^2.6.1", + "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs", "ste-simple-events": "^3.0.11", "tslog": "^4.9.3", }, @@ -18,7 +19,6 @@ "name": "@meshtastic/core", "version": "2.6.5", "dependencies": { - "@meshtastic/protobufs": "npm:@jsr/meshtastic__protobufs", "crc": "npm:crc@^4.3.2", }, }, @@ -28,7 +28,7 @@ }, "packages/transport-http": { "name": "@meshtastic/transport-http", - "version": "0.2.1", + "version": "0.2.2", }, "packages/transport-node": { "name": "@meshtastic/transport-node", @@ -36,14 +36,14 @@ }, "packages/transport-web-bluetooth": { "name": "@meshtastic/transport-web-bluetooth", - "version": "0.1.2", + "version": "0.1.3", "devDependencies": { "@types/web-bluetooth": "npm:@types/web-bluetooth@^0.0.20", }, }, "packages/transport-web-serial": { "name": "@meshtastic/transport-web-serial", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@types/w3c-web-serial": "npm:@types/w3c-web-serial@^1.0.7", }, @@ -54,7 +54,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.6.0", "@hookform/resolvers": "^5.1.1", - "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4", + "@meshtastic/core": "workspace:*", "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", @@ -296,6 +296,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.10", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], @@ -1296,6 +1298,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "splaytree-ts": ["splaytree-ts@1.0.2", "", {}, "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA=="], "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], @@ -1336,6 +1340,8 @@ "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "terser": ["terser@5.43.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg=="], + "testing-library": ["testing-library@0.0.2", "", { "dependencies": { "tslib": "^1.9.0" }, "peerDependencies": { "@angular/common": "^6.0.0-rc.0 || ^6.0.0", "@angular/core": "^6.0.0-rc.0 || ^6.0.0" } }, "sha512-KCbqCCllbgiCXOgmh9MdsgdJ05pmimXGuggtC78pzpxpq/40A3bS+NJoqwCIqZbNnMr6KIZ2mlMZoZCkWVnaWw=="], "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], @@ -1696,8 +1702,6 @@ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "meshtastic-web/@meshtastic/core": ["@jsr/meshtastic__core@2.6.4", "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.4.tgz", { "dependencies": { "@bufbuild/protobuf": "^2.2.3", "@jsr/meshtastic__protobufs": "^2.6.2", "crc": "^4.3.2", "ste-simple-events": "^3.0.11", "tslog": "^4.9.3" } }, "sha512-1Kz5DK6peFxluHOJR38vFwfgeJzMXTz+3p6TvibjILVhSQC2U1nu8aJbn6w5zhRqS+j79OmtrRvdzL6VNsTkkQ=="], - "meshtastic-web/@meshtastic/transport-http": ["@jsr/meshtastic__transport-http@0.2.1", "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz", { "dependencies": { "@jsr/meshtastic__core": "^2.6.0" } }, "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg=="], "meshtastic-web/@meshtastic/transport-web-bluetooth": ["@jsr/meshtastic__transport-web-bluetooth@0.1.2", "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.2.tgz", { "dependencies": { "@jsr/meshtastic__core": "^2.6.4" } }, "sha512-Z+5pv9RXNgY0/crKExOH3pZ6LT0HIXFmnBL7NX5AO2knOFRn+4lmxQEhhmiTTlkUfqyEfAvbjuY5u4mq9TPTdQ=="], @@ -1724,6 +1728,8 @@ "recast/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1732,6 +1738,8 @@ "sweepline-intersections/tinyqueue": ["tinyqueue@2.0.3", "", {}, "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -1748,8 +1756,6 @@ "geojson-polygon-self-intersections/rbush/quickselect": ["quickselect@1.1.1", "", {}, "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ=="], - "meshtastic-web/@meshtastic/core/@bufbuild/protobuf": ["@bufbuild/protobuf@2.6.0", "", {}, "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg=="], - "meshtastic-web/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], "peek-stream/through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], diff --git a/packages/core/src/meshDevice.ts b/packages/core/src/meshDevice.ts index 7dbc5e07..b46bb51a 100755 --- a/packages/core/src/meshDevice.ts +++ b/packages/core/src/meshDevice.ts @@ -1,7 +1,6 @@ -import { Logger } from "tslog"; - import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import * as Protobuf from "@meshtastic/protobufs"; +import { Logger } from "tslog"; import { Constants } from "./constants.ts"; import type { Destination, PacketMetadata, Transport } from "./types.ts"; @@ -40,6 +39,8 @@ export class MeshDevice { public xModem: Xmodem; + private _heartbeatIntervalId: ReturnType | undefined; + constructor(transport: Transport, configId?: number) { this.log = new Logger({ name: "iMeshDevice", @@ -741,6 +742,18 @@ export class MeshDevice { return this.sendRaw(toBinary(Protobuf.Mesh.ToRadioSchema, toRadio)); } + /** + * Initializes the heartbeat interval, which sends a heartbeat ping every interval milliseconds. + */ + public setHeartbeatInterval(interval: number): void { + if (this._heartbeatIntervalId !== undefined) { + clearInterval(this._heartbeatIntervalId); + } + this._heartbeatIntervalId = setInterval(() => { + this.heartbeat(); + }, interval); + } + /** * Sends a trace route packet to the designated node */ @@ -798,6 +811,11 @@ export class MeshDevice { /** Disconnects from the device **/ public async disconnect(): Promise { this.log.debug(Emitter[Emitter.Disconnect], "🔌 Disconnecting from device"); + + if (this._heartbeatIntervalId !== undefined) { + clearInterval(this._heartbeatIntervalId); + } + this.complete(); await this.transport.toDevice.close(); } diff --git a/packages/core/src/utils/queue.ts b/packages/core/src/utils/queue.ts index bd14d229..55f23282 100644 --- a/packages/core/src/utils/queue.ts +++ b/packages/core/src/utils/queue.ts @@ -44,6 +44,16 @@ export class Queue { if (this.queue.findIndex((qi) => qi.id === item.id) !== -1) { this.remove(item.id); const decoded = fromBinary(Protobuf.Mesh.ToRadioSchema, item.data); + + if ( + decoded.payloadVariant.case === "heartbeat" || + decoded.payloadVariant.case === "wantConfigId" + ) { + // heartbeat and wantConfigId packets are not acknowledged by the device, assume success after timeout + resolve(item.id); + return; + } + console.warn( `Packet ${item.id} of type ${decoded.payloadVariant.case} timed out`, ); diff --git a/packages/transport-http/src/transport.ts b/packages/transport-http/src/transport.ts index 03f578aa..5616c1b4 100644 --- a/packages/transport-http/src/transport.ts +++ b/packages/transport-http/src/transport.ts @@ -12,7 +12,9 @@ export class TransportHTTP implements Types.Transport { tls?: boolean, ): Promise { const connectionUrl = `${tls ? "https" : "http"}://${address}`; - await fetch(`${connectionUrl}/json/report`); + await fetch(`${connectionUrl}/api/v1/toradio`, { + method: "OPTIONS", + }); await Promise.resolve(); return new TransportHTTP(connectionUrl); } diff --git a/packages/web/package.json b/packages/web/package.json index a13f27ea..149836a2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,14 +14,14 @@ "homepage": "https://meshtastic.org", "scripts": { - "build": "bunx --bun vite build", + "build": "vite build", "build:analyze": "BUNDLE_ANALYZE=true bun run build", "check": "biome check src/", "check:fix": "biome check --write src/", - "dev": "bunx --bun vite", - "test": "bunx --bun vitest", + "dev": "vite", + "test": "vitest", "ts:check": "bun run tsc --noEmit", - "preview": "bunx --bun vite preview", + "preview": "vite preview", "generate:routes": "bun @tanstack/router-cli generate --outDir src/ routes --rootRoutePath /", "package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." }, diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx index 6e89a4db..6480cb39 100644 --- a/packages/web/src/components/DeviceInfoPanel.tsx +++ b/packages/web/src/components/DeviceInfoPanel.tsx @@ -85,6 +85,12 @@ export const DeviceInfoPanel = ({ ]; const actionButtons: ActionButtonConfig[] = [ + { + id: "theme", + label: t("theme.changeTheme"), + icon: Palette, + render: () => , + }, { id: "changeName", label: t("sidebar.deviceInfo.deviceName.changeName"), @@ -97,12 +103,7 @@ export const DeviceInfoPanel = ({ icon: SearchIcon, onClick: setCommandPaletteOpen, }, - { - id: "theme", - label: t("theme.changeTheme"), - icon: Palette, - render: () => , - }, + { id: "language", label: t("language.changeLanguage"), diff --git a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx index bea409e5..decbac9b 100644 --- a/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx +++ b/packages/web/src/components/Dialog/TracerouteResponseDialog.tsx @@ -30,7 +30,7 @@ export const TracerouteResponseDialog = ({ const routeBack: number[] = traceroute?.data.routeBack ?? []; const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4); const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4); - const from = getNode(traceroute?.from ?? 0); + const from = getNode(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet const fromLongName = from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName")); @@ -40,7 +40,7 @@ export const TracerouteResponseDialog = ({ ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName")); - const toUser = getNode(traceroute?.to ?? 0); + const toUser = getNode(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet if (!toUser || !from) { return null; diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx index 20a27ee5..46b39b3e 100644 --- a/packages/web/src/components/PageComponents/Connect/BLE.tsx +++ b/packages/web/src/components/PageComponents/Connect/BLE.tsx @@ -36,6 +36,10 @@ export const BLE = ({ closeDialog }: TabElementProps) => { setSelectedDevice(id); device.addConnection(connection); subscribeAll(device, connection, messageStore); + + const HEARTBEAT_INTERVAL = 5*60*1000; + connection.setHeartbeatInterval(HEARTBEAT_INTERVAL); + closeDialog(); }; diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx index 9dad731a..71d43710 100644 --- a/packages/web/src/components/PageComponents/Connect/Serial.tsx +++ b/packages/web/src/components/PageComponents/Connect/Serial.tsx @@ -43,6 +43,9 @@ export const Serial = ({ closeDialog }: TabElementProps) => { device.addConnection(connection); subscribeAll(device, connection, messageStore); + const HEARTBEAT_INTERVAL = 5*60*1000; + connection.setHeartbeatInterval(HEARTBEAT_INTERVAL); + closeDialog(); }; @@ -52,7 +55,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { disabled={connectionInProgress} >
- {serialPorts.map((port) => { + {serialPorts.map((port, idx) => { const { usbProductId, usbVendorId } = port.getInfo(); const vendor = usbVendorId ?? t("unknown.shortName"); const product = usbProductId ?? t("unknown.shortName"); @@ -70,6 +73,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { {t("newDeviceDialog.serialConnection.deviceIdentifier", { vendorId: vendor, productId: product, + index: idx, })} ); diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx index 7f9d2ee1..caf176ee 100644 --- a/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.tsx @@ -41,7 +41,7 @@ const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {

{getNode(hop)?.user?.longName ?? - `${t("traceRoute.nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`} + `${t("unknown.longName")} (!${numberToHexUnpadded(hop)})`}

↓ {snr?.[i + 1] ?? t("unknown.num")} diff --git a/packages/web/src/components/ThemeSwitcher.tsx b/packages/web/src/components/ThemeSwitcher.tsx index f0900279..a4595079 100644 --- a/packages/web/src/components/ThemeSwitcher.tsx +++ b/packages/web/src/components/ThemeSwitcher.tsx @@ -1,7 +1,8 @@ -import { useTheme } from "@core/hooks/useTheme.ts"; -import { cn } from "@core/utils/cn.ts"; import { Monitor, Moon, Sun } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useTheme } from "../core/hooks/useTheme.ts"; +import { useToggleVisibility } from "../core/hooks/useToggleVisiblility.ts"; +import { cn } from "../core/utils/cn.ts"; import { Button } from "./UI/Button.tsx"; import { Subtle } from "./UI/Typography/Subtle.tsx"; @@ -12,10 +13,16 @@ interface ThemeSwitcherProps { disableHover?: boolean; } +const TOOLTIP_TIMEOUT = 2000; // 2 seconds + export default function ThemeSwitcher({ className: passedClassName = "", disableHover = false, }: ThemeSwitcherProps) { + const [showTooltip, toggleShowTooltip] = useToggleVisibility({ + timeout: TOOLTIP_TIMEOUT, + }); + const { preference, setPreference } = useTheme(); const { t } = useTranslation("ui"); @@ -35,8 +42,10 @@ export default function ThemeSwitcher({ const toggleTheme = () => { const preferences: ThemePreference[] = ["light", "dark", "system"]; const currentIndex = preferences.indexOf(preference); - const nextPreference = preferences[(currentIndex + 1) % preferences.length]; + const nextPreference = + preferences[(currentIndex + 1) % preferences.length] ?? "system"; setPreference(nextPreference); + toggleShowTooltip(); }; const preferenceDisplayMap: Record = { @@ -65,12 +74,12 @@ export default function ThemeSwitcher({ {currentDisplayPreference} diff --git a/packages/web/src/components/UI/Footer.tsx b/packages/web/src/components/UI/Footer.tsx index b98f940b..552e6c49 100644 --- a/packages/web/src/components/UI/Footer.tsx +++ b/packages/web/src/components/UI/Footer.tsx @@ -1,13 +1,11 @@ import { cn } from "@core/utils/cn.ts"; -import { Trans, useTranslation } from "react-i18next"; +import { Trans } from "react-i18next"; type FooterProps = { className?: string; }; const Footer = ({ className, ...props }: FooterProps) => { - const { t } = useTranslation(); - return (