diff --git a/crowdin.yml b/crowdin.yml index 14d994ec..d32b8047 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -6,5 +6,5 @@ base_url: "https://meshtastic.crowdin.com/api/v2" preserve_hierarchy: true files: - - source: "/src/i18n/locales/en/**/*.json" + - source: "/src/i18n/locales/en/*.json" translation: "/src/i18n/locales/%locale%/%original_file_name%" 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.test.tsx b/src/components/PageComponents/Messages/MessageInput.test.tsx index 2c88c1cc..0ddb8531 100644 --- a/src/components/PageComponents/Messages/MessageInput.test.tsx +++ b/src/components/PageComponents/Messages/MessageInput.test.tsx @@ -86,7 +86,7 @@ describe("MessageInput", () => { it("should render the input field, byte counter, and send button", () => { renderComponent(); - expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument(); + expect(screen.getByTestId("message-input-field")).toBeInTheDocument(); expect(screen.getByTestId("byte-counter")).toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument(); expect(screen.getByTestId("send-icon")).toBeInTheDocument(); @@ -100,10 +100,6 @@ describe("MessageInput", () => { renderComponent(); - const inputElement = screen.getByPlaceholderText( - "Enter Message", - ) as HTMLInputElement; - expect(inputElement.value).toBe(initialDraft); expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); const expectedBytes = new Blob([initialDraft]).size; expect(screen.getByTestId("byte-counter")).toHaveTextContent( @@ -113,7 +109,7 @@ describe("MessageInput", () => { it("should update input value, byte counter, and call setDraft on change within limits", () => { renderComponent(); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId("message-input-field"); const testMessage = "Hello there!"; const expectedBytes = new Blob([testMessage]).size; @@ -130,7 +126,7 @@ describe("MessageInput", () => { it("should NOT update input value or call setDraft if maxBytes is exceeded", () => { const smallMaxBytes = 5; renderComponent({ maxBytes: smallMaxBytes }); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId("message-input-field"); const initialValue = "12345"; const excessiveValue = "123456"; @@ -150,7 +146,7 @@ describe("MessageInput", () => { it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => { renderComponent(); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId("message-input-field"); const formElement = screen.getByRole("form"); const testMessage = "Send this message"; @@ -171,7 +167,7 @@ describe("MessageInput", () => { it("should trim whitespace before calling onSend", async () => { renderComponent(); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId("message-input-field"); const formElement = screen.getByRole("form"); const testMessageWithWhitespace = " Trim me! "; const expectedTrimmedMessage = "Trim me!"; @@ -190,7 +186,7 @@ describe("MessageInput", () => { it("should not call onSend or clearDraft if input is empty on submit", async () => { renderComponent(); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId("message-input-field"); const formElement = screen.getByRole("form"); expect((inputElement as HTMLInputElement).value).toBe(""); @@ -236,10 +232,14 @@ describe("MessageInput", () => { expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); expect( - (screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value, + (screen.getByTestId( + "message-input-field", + ) as HTMLInputElement).value, ).toBe("Broadcast draft"); - const inputElement = screen.getByPlaceholderText("Enter Message"); + const inputElement = screen.getByTestId( + "message-input-field", + ) as HTMLInputElement; const formElement = screen.getByRole("form"); const newMessage = "New broadcast msg"; diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index 3488e0a0..fb58bc3e 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("messages"); 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("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/Avatar.tsx b/src/components/UI/Avatar.tsx index f9b0054d..f4192d64 100644 --- a/src/components/UI/Avatar.tsx +++ b/src/components/UI/Avatar.tsx @@ -114,7 +114,7 @@ export const Avatar = ({ /> - Favorite + {t("nodeDetail.favorite.label", { ns: "nodes" })} @@ -132,7 +132,7 @@ export const Avatar = ({ /> - Node error + {t("nodeDetail.error.label", { ns: "nodes" })} 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.test.ts b/src/core/hooks/useFavoriteNode.test.ts index 78732ace..19caf04c 100644 --- a/src/core/hooks/useFavoriteNode.test.ts +++ b/src/core/hooks/useFavoriteNode.test.ts @@ -43,7 +43,7 @@ describe("useFavoriteNode hook", () => { expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true); expect(mockGetNode).toHaveBeenCalledWith(1234); expect(mockToast).toHaveBeenCalledWith({ - title: "Added Test Node to favorites", + title: "Added Test Node to favorites.", }); }); @@ -57,7 +57,7 @@ describe("useFavoriteNode hook", () => { expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false); expect(mockGetNode).toHaveBeenCalledWith(1234); expect(mockToast).toHaveBeenCalledWith({ - title: "Removed Test Node from favorites", + title: "Removed Test Node from favorites.", }); }); @@ -74,7 +74,7 @@ describe("useFavoriteNode hook", () => { }); expect(mockToast).toHaveBeenCalledWith({ - title: "Added node to favorites", + title: "Added Node to favorites.", }); }); diff --git a/src/core/hooks/useFavoriteNode.ts b/src/core/hooks/useFavoriteNode.ts index 6bf6c808..f93779e4 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(); const { toast } = useToast(); const updateFavoriteCB = useCallback( @@ -19,9 +21,15 @@ export function useFavoriteNode() { updateFavorite(nodeNum, isFavorite); toast({ - title: `${isFavorite ? "Added" : "Removed"} ${ - node?.user?.longName ?? "node" - } ${isFavorite ? "to" : "from"} favorites`, + title: t("toast.favoriteNode.title", { + action: isFavorite + ? t("toast.favoriteNode.action.added") + : t("toast.favoriteNode.action.removed"), + nodeName: node?.user?.longName ?? t("node"), + direction: isFavorite + ? t("toast.favoriteNode.action.to") + : t("toast.favoriteNode.action.from"), + }), }); }, [updateFavorite, getNode], diff --git a/src/core/hooks/useIgnoreNode.ts b/src/core/hooks/useIgnoreNode.ts index 14b75974..c214bdc2 100644 --- a/src/core/hooks/useIgnoreNode.ts +++ b/src/core/hooks/useIgnoreNode.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 IgnoreNodeOptions { nodeNum: number; @@ -9,6 +10,8 @@ interface IgnoreNodeOptions { export function useIgnoreNode() { const { updateIgnored, getNode } = useDevice(); + const { t } = useTranslation(); + const { toast } = useToast(); const updateIgnoredCB = useCallback( @@ -19,9 +22,15 @@ export function useIgnoreNode() { updateIgnored(nodeNum, isIgnored); toast({ - title: `${isIgnored ? "Added" : "Removed"} ${ - node?.user?.longName ?? "node" - } ${isIgnored ? "to" : "from"} ignore list`, + title: t("toast.ignoreNode.title", { + nodeName: node?.user?.longName ?? "node", + action: isIgnored + ? t("toast.ignoreNode.action.added") + : t("toast.ignoreNode.action.removed"), + direction: isIgnored + ? t("toast.ignoreNode.action.to") + : t("toast.ignoreNode.action.from"), + }), }); }, [updateIgnored, 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..b63e90c4 100644 --- a/src/i18n/locales/en/nodes.json +++ b/src/i18n/locales/en/nodes.json @@ -10,11 +10,16 @@ "label": "Direct Message {{shortName}}" }, "favorite": { - "label": "Favorite" + "label": "Favorite", + "tooltip": "Add or remove this node from your favorites" }, "notFavorite": { "label": "Not a Favorite" }, + "error": { + "label": "Error", + "text": "An error occurred while fetching node details. Please try again later." + }, "status": { "heard": "Heard", "mqtt": "MQTT" @@ -47,5 +52,13 @@ "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..daab3891 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -66,6 +66,24 @@ "saveSuccess": { "title": "Saving Config", "description": "The configuration change {{case}} has been saved." + }, + "favoriteNode": { + "title": "{{action}} {{nodeName}} {{direction}} favorites.", + "action": { + "added": "Added", + "removed": "Removed", + "to": "to", + "from": "from" + } + }, + "ignoreNode": { + "title": "{{action}} {{nodeName}} {{direction}} ignore list", + "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,