diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index 3fe802d6..2012b443 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -208,7 +208,7 @@ export function DynamicForm({ variant="outline" disabled={!formState.isValid} > - Submit + {t("button.submit")} )} diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 9a3946f5..a121fb6e 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -1,6 +1,5 @@ import MapGl, { AttributionControl, - GeolocateControl, type MapRef, NavigationControl, ScaleControl, @@ -45,11 +44,15 @@ export const Map = ({ children, onLoad }: MapProps) => { color: darkMode ? "black" : undefined, }} /> - + /> */ + } {children} diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index 3488e0a0..01287f1c 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,6 +4,7 @@ import type { Types } from "@meshtastic/core"; import { SendIcon } from "lucide-react"; import { startTransition, useState } from "react"; import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import { useTranslation } from "react-i18next"; export interface MessageInputProps { onSend: (message: string) => void; @@ -17,6 +18,7 @@ export const MessageInput = ({ maxBytes, }: MessageInputProps) => { const { setDraft, getDraft, clearDraft } = useMessageStore(); + const { t } = useTranslation(); const calculateBytes = (text: string) => new Blob([text]).size; @@ -59,7 +61,7 @@ export const MessageInput = ({ autoFocus minLength={1} name="messageInput" - placeholder="Enter Message" + placeholder={t("messages.sendMessage.placeholder")} autoComplete="off" value={localDraft} onChange={handleInputChange} diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index ab509741..dbcfd533 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => { const MESSAGE_STATUS_MAP = useMemo( (): Record => ({ [MessageState.Ack]: { - displayText: t("deliveryStatus.deliveryStatus."), + displayText: t("deliveryStatus.delivered.displayText"), icon: CheckCircle2, - ariaLabel: t("deliveryStatus.delivered"), + ariaLabel: t("deliveryStatus.delivered.label"), iconClassName: "text-green-500", }, [MessageState.Waiting]: { - displayText: t("deliveryStatus.waiting"), + displayText: t("deliveryStatus.waiting.displayText"), icon: CircleEllipsis, - ariaLabel: t("deliveryStatus.waiting"), + ariaLabel: t("deliveryStatus.waiting.label"), iconClassName: "text-slate-400", }, [MessageState.Failed]: { - displayText: t("deliveryStatus.failed"), + displayText: t("deliveryStatus.failed.displayText"), icon: AlertCircle, - ariaLabel: t("deliveryStatus.failed"), + ariaLabel: t("deliveryStatus.failed.label"), iconClassName: "text-red-500 dark:text-red-400", }, }), @@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => { ); const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ - displayText: t("delveryStatus.unknown"), + displayText: t("delveryStatus.unknown.displayText"), icon: AlertCircle, - ariaLabel: t("deliveryStatus.unknown"), + ariaLabel: t("deliveryStatus.unknown.label"), iconClassName: "text-red-500 dark:text-red-400", }), [t]); diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index f23cf1e3..22fa2921 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -8,6 +8,7 @@ import { SelectTrigger, SelectValue, } from "@components/UI/Select.tsx"; +import { useTranslation } from "react-i18next"; export interface ActionButton { text: string; @@ -40,12 +41,7 @@ const Generator = ( variant, value, actionButtons, - bits = [ - { text: "256 bit", value: "32", key: "bit256" }, - { text: "128 bit", value: "16", key: "bit128" }, - { text: "8 bit", value: "1", key: "bit8" }, - { text: "Empty", value: "0", key: "empty" }, - ], + bits, selectChange, inputChange, disabled, @@ -55,6 +51,30 @@ const Generator = ( }: GeneratorProps, ) => { const inputRef = useRef(null); + const { t } = useTranslation(); + + const passwordRequiredBitSize = bits ? bits : [ + { + text: t("security.256bit"), + value: "32", + key: "bit256", + }, + { + text: t("security.128bit"), + value: "16", + key: "bit128", + }, + { + text: t("security.8bit"), + value: "1", + key: "bit8", + }, + { + text: t("security.empty"), + value: "0", + key: "bit0", + }, + ]; // Invokes onChange event on the input element when the value changes from the parent component useEffect(() => { @@ -91,7 +111,7 @@ const Generator = ( - {bits.map(({ text, value, key }) => ( + {passwordRequiredBitSize.map(({ text, value, key }) => ( {text} diff --git a/src/components/generic/TimeAgo.tsx b/src/components/generic/TimeAgo.tsx index 652cd364..f5fae897 100755 --- a/src/components/generic/TimeAgo.tsx +++ b/src/components/generic/TimeAgo.tsx @@ -5,49 +5,115 @@ import { TooltipProvider, TooltipTrigger, } from "@radix-ui/react-tooltip"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; export interface TimeAgoProps { - timestamp: number; + timestamp: number | Date; + locale?: string; + tooltipOptions?: Intl.DateTimeFormatOptions; + className?: string; } -const getTimeAgo = ( - unixTimestamp: number, - locale: Intl.LocalesArgument = "en", -): string => { - const timestamp = new Date(unixTimestamp); - const diff = (new Date().getTime() - timestamp.getTime()) / 1000; - - const minutes = Math.floor(diff / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const months = Math.floor(days / 30); - const years = Math.floor(months / 12); - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - - if (years > 0) { - return rtf.format(0 - years, "year"); - } - if (months > 0) { - return rtf.format(0 - months, "month"); - } - if (days > 0) { - return rtf.format(0 - days, "day"); - } - if (hours > 0) { - return rtf.format(0 - hours, "hour"); - } - if (minutes > 0) { - return rtf.format(0 - minutes, "minute"); +const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["year", 31536000], + ["month", 2592000], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ["second", 1], +]; + +const getRelativeTimeParts = ( + date: Date | number, +): { value: number; unit: Intl.RelativeTimeFormatUnit } => { + const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000; + + for (const [unit, secondsInUnit] of TIME_UNITS) { + if (Math.abs(diffInSeconds) >= secondsInUnit) { + const value = Math.round(diffInSeconds / secondsInUnit); + return { value, unit }; + } } - return rtf.format(Math.floor(0 - diff), "second"); + + return { value: Math.round(diffInSeconds), unit: "second" }; }; -export const TimeAgo = ({ timestamp }: TimeAgoProps) => { +const UPDATE_INTERVALS: Partial> = { + // For long-term units, an hourly update is more than sufficient. + year: 1000 * 60 * 60, + month: 1000 * 60 * 60, + + // When the unit is 'day', check hourly to catch the change to the next day. + day: 1000 * 60 * 60, + + // When the unit is 'hour', check every thiry seconds to catch the change to the next hour. + hour: 1000 * 30, + + // When the unit is 'minute', a 15-second check is a good balance. + minute: 1000 * 15, + + // For 'second', a 3-second check keeps it feeling "live" without being excessive. + second: 1000 * 3, +}; + +export const TimeAgo = ({ + timestamp, + locale: localeProp, + tooltipOptions, + className, +}: TimeAgoProps) => { + const { i18n } = useTranslation(); + const [timeAgo, setTimeAgo] = useState(""); + + const locale = useMemo( + () => + localeProp || + i18n.language || + (typeof navigator !== "undefined" ? navigator.language : "en-US"), + [localeProp, i18n.language], + ); + + const date = useMemo(() => new Date(timestamp), [timestamp]); + + const fullDate = useMemo(() => { + const defaultOptions: Intl.DateTimeFormatOptions = { + dateStyle: "full", + timeStyle: "medium", + }; + const formatter = new Intl.DateTimeFormat(locale, { + ...defaultOptions, + ...tooltipOptions, + }); + return formatter.format(date); + }, [date, locale, tooltipOptions]); + + useEffect(() => { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + let timerId: number; + + const update = () => { + const { value, unit } = getRelativeTimeParts(date); + setTimeAgo(rtf.format(value, unit)); + + const interval = UPDATE_INTERVALS[unit] || 60000; + timerId = globalThis.setTimeout(update, interval); + }; + + update(); + + return () => { + clearTimeout(timerId); + }; + }, [date, locale]); + return ( - - {getTimeAgo(timestamp)} + + { align="center" sideOffset={5} > - {new Date(timestamp).toLocaleString()} + {fullDate} diff --git a/src/core/hooks/useFavoriteNode.ts b/src/core/hooks/useFavoriteNode.ts index 6bf6c808..3eb303d3 100644 --- a/src/core/hooks/useFavoriteNode.ts +++ b/src/core/hooks/useFavoriteNode.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useToast } from "@core/hooks/useToast.ts"; +import { useTranslation } from "react-i18next"; interface FavoriteNodeOptions { nodeNum: number; @@ -9,6 +10,7 @@ interface FavoriteNodeOptions { export function useFavoriteNode() { const { updateFavorite, getNode } = useDevice(); + const { t } = useTranslation("ui"); const { toast } = useToast(); const updateFavoriteCB = useCallback( @@ -19,9 +21,11 @@ export function useFavoriteNode() { updateFavorite(nodeNum, isFavorite); toast({ - title: `${isFavorite ? "Added" : "Removed"} ${ - node?.user?.longName ?? "node" - } ${isFavorite ? "to" : "from"} favorites`, + title: t("toast.favoriteNode", { + action: isFavorite ? t("button.added") : t("button.removed"), + nodeName: node?.user?.longName ?? t("node"), + location: isFavorite ? t("to") : t("from"), + }), }); }, [updateFavorite, getNode], diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 097e3791..a17a9cd9 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -41,9 +41,9 @@ i18next fallbackLng: { default: [FALLBACK_LANGUAGE_CODE], "en-GB": [FALLBACK_LANGUAGE_CODE], - "fi": ["fi-FI"], - "sv": ["sv-SE"], - "de": ["de-DE"], + "fi": ["fi-FI", FALLBACK_LANGUAGE_CODE], + "sv": ["sv-SE", FALLBACK_LANGUAGE_CODE], + "de": ["de-DE", FALLBACK_LANGUAGE_CODE], }, fallbackNS: ["common", "ui", "dialog"], debug: import.meta.env.MODE === "development", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0fb0756b..b9ba239e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -24,7 +24,8 @@ "reset": "Reset", "save": "Save", "scanQr": "Scan QR Code", - "traceRoute": "Trace Route" + "traceRoute": "Trace Route", + "submit": "Submit" }, "app": { "title": "Meshtastic", @@ -48,6 +49,7 @@ "raw": "raw", "meter": { "one": "Meter", "plural": "Meters", "suffix": "m" }, "minute": { "one": "Minute", "plural": "Minutes" }, + "hour": { "one": "Hour", "plural": "Hours" }, "millisecond": { "one": "Millisecond", "plural": "Milliseconds", @@ -55,11 +57,16 @@ }, "second": { "one": "Second", "plural": "Seconds" }, "day": { "one": "Day", "plural": "Days" }, + "month": { "one": "Month", "plural": "Months" }, + "year": { "one": "Year", "plural": "Years" }, "snr": "SNR", "volt": { "one": "Volt", "plural": "Volts", "suffix": "V" }, "record": { "one": "Records", "plural": "Records" } }, "security": { + "0bit": "Empty", + "8bit": "8 bit", + "128bit": "128 bit", "256bit": "256 bit" }, "unknown": { @@ -71,6 +78,7 @@ "nodeUnknownPrefix": "!", "unset": "UNSET", "fallbackName": "Meshtastic {{last4}}", + "node": "Node", "formValidation": { "unsavedChanges": "Unsaved changes", "tooBig": { diff --git a/src/i18n/locales/en/messages.json b/src/i18n/locales/en/messages.json index 4df0ac92..07d60e57 100644 --- a/src/i18n/locales/en/messages.json +++ b/src/i18n/locales/en/messages.json @@ -1,6 +1,7 @@ { "page": { - "title": "Messages: {{chatName}}" + "title": "Messages: {{chatName}}", + "placeholder": "Enter Message" }, "emptyState": { "title": "Select a Chat", @@ -10,7 +11,7 @@ "text": "Select a channel or node to start messaging." }, "sendMessage": { - "placeholder": "Type your message here...", + "placeholder": "Enter your message here...", "sendButton": "Send" }, "actionsMenu": { @@ -18,24 +19,22 @@ "replyLabel": "Reply" }, - "item": { - "status": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" - }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" - }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" - }, - "waiting": { - "ariaLabel": "Sending message", - "displayText": "Waiting for delivery" - } + "deliveryStatus": { + "delivered": { + "label": "Message delivered", + "displayText": "Message delivered" + }, + "failed": { + "label": "Message delivery failed", + "displayText": "Delivery failed" + }, + "unknown": { + "label": "Message status unknown", + "displayText": "Unknown state" + }, + "waiting": { + "label": "Sending message", + "displayText": "Waiting for delivery" } } } diff --git a/src/i18n/locales/en/nodes.json b/src/i18n/locales/en/nodes.json index 5202f039..9bda5cfc 100644 --- a/src/i18n/locales/en/nodes.json +++ b/src/i18n/locales/en/nodes.json @@ -47,5 +47,12 @@ "lastHeardStatus": { "never": "Never" } + }, + "actions": { + "added": "Added", + "removed": "Removed", + "ignoreNode": "Ignore Node", + "unignoreNode": "Unignore Node", + "requestPosition": "Request Position" } } diff --git a/src/i18n/locales/en/ui.json b/src/i18n/locales/en/ui.json index 0f5aa51f..8f186ecd 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -66,6 +66,15 @@ "saveSuccess": { "title": "Saving Config", "description": "The configuration change {{case}} has been saved." + }, + "favoriteNode": { + "title": "Node {{nodeName}} {{action}} {{direction} favorites.", + "action": { + "added": "added", + "removed": "removed", + "to": "to", + "from": "from" + } } }, "notifications": { diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 6a13fb35..96bb7b0e 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -306,13 +306,16 @@ export const MessagesPage = () => { return ( { const { t } = useTranslation("nodes"); + const { currentLanguage } = useLang(); const { getNodes, hardware, connection, hasNodeError, setDialogOpen } = useDevice(); const { setNodeNumDetails } = useAppStore(); @@ -168,7 +170,12 @@ const NodesPage = (): JSX.Element => { {node.lastHeard === 0 ?

{t("nodesTable.lastHeardStatus.never")}

- : } + : ( + + )}
), sortValue: node.lastHeard,