Browse Source

fix: add missing i18n strings (#671)

* fix: added required i18n labels to UI

* added node i18n

* updated tests

* updated path to match github action
pull/673/head
Dan Ditomaso 12 months ago
committed by GitHub
parent
commit
ec9b299b37
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      crowdin.yml
  2. 2
      src/components/Form/DynamicForm.tsx
  3. 9
      src/components/Map.tsx
  4. 24
      src/components/PageComponents/Messages/MessageInput.test.tsx
  5. 4
      src/components/PageComponents/Messages/MessageInput.tsx
  6. 16
      src/components/PageComponents/Messages/MessageItem.tsx
  7. 4
      src/components/UI/Avatar.tsx
  8. 34
      src/components/UI/Generator.tsx
  9. 134
      src/components/generic/TimeAgo.tsx
  10. 6
      src/core/hooks/useFavoriteNode.test.ts
  11. 14
      src/core/hooks/useFavoriteNode.ts
  12. 15
      src/core/hooks/useIgnoreNode.ts
  13. 6
      src/i18n/config.ts
  14. 10
      src/i18n/locales/en/common.json
  15. 39
      src/i18n/locales/en/messages.json
  16. 15
      src/i18n/locales/en/nodes.json
  17. 18
      src/i18n/locales/en/ui.json
  18. 17
      src/pages/Messages.tsx
  19. 9
      src/pages/Nodes/index.tsx

2
crowdin.yml

@ -6,5 +6,5 @@ base_url: "https://meshtastic.crowdin.com/api/v2"
preserve_hierarchy: true preserve_hierarchy: true
files: files:
- source: "/src/i18n/locales/en/**/*.json" - source: "/src/i18n/locales/en/*.json"
translation: "/src/i18n/locales/%locale%/%original_file_name%" translation: "/src/i18n/locales/%locale%/%original_file_name%"

2
src/components/Form/DynamicForm.tsx

@ -208,7 +208,7 @@ export function DynamicForm<T extends FieldValues>({
variant="outline" variant="outline"
disabled={!formState.isValid} disabled={!formState.isValid}
> >
Submit {t("button.submit")}
</Button> </Button>
)} )}
</form> </form>

9
src/components/Map.tsx

@ -1,6 +1,5 @@
import MapGl, { import MapGl, {
AttributionControl, AttributionControl,
GeolocateControl,
type MapRef, type MapRef,
NavigationControl, NavigationControl,
ScaleControl, ScaleControl,
@ -45,11 +44,15 @@ export const Map = ({ children, onLoad }: MapProps) => {
color: darkMode ? "black" : undefined, color: darkMode ? "black" : undefined,
}} }}
/> />
<GeolocateControl {/* { Disabled for now until we can use i18n for the geolocate control} */}
{
/* <GeolocateControl
position="top-right" position="top-right"
i18nIsDynamicList
positionOptions={{ enableHighAccuracy: true }} positionOptions={{ enableHighAccuracy: true }}
trackUserLocation trackUserLocation
/> /> */
}
<NavigationControl position="top-right" showCompass={false} /> <NavigationControl position="top-right" showCompass={false} />
<ScaleControl /> <ScaleControl />
{children} {children}

24
src/components/PageComponents/Messages/MessageInput.test.tsx

@ -86,7 +86,7 @@ describe("MessageInput", () => {
it("should render the input field, byte counter, and send button", () => { it("should render the input field, byte counter, and send button", () => {
renderComponent(); renderComponent();
expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument(); expect(screen.getByTestId("message-input-field")).toBeInTheDocument();
expect(screen.getByTestId("byte-counter")).toBeInTheDocument(); expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument(); expect(screen.getByTestId("send-icon")).toBeInTheDocument();
@ -100,10 +100,6 @@ describe("MessageInput", () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText(
"Enter Message",
) as HTMLInputElement;
expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size; const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId("byte-counter")).toHaveTextContent( 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", () => { it("should update input value, byte counter, and call setDraft on change within limits", () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId("message-input-field");
const testMessage = "Hello there!"; const testMessage = "Hello there!";
const expectedBytes = new Blob([testMessage]).size; 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", () => { it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
const smallMaxBytes = 5; const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes }); renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId("message-input-field");
const initialValue = "12345"; const initialValue = "12345";
const excessiveValue = "123456"; 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 () => { it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form"); const formElement = screen.getByRole("form");
const testMessage = "Send this message"; const testMessage = "Send this message";
@ -171,7 +167,7 @@ describe("MessageInput", () => {
it("should trim whitespace before calling onSend", async () => { it("should trim whitespace before calling onSend", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form"); const formElement = screen.getByRole("form");
const testMessageWithWhitespace = " Trim me! "; const testMessageWithWhitespace = " Trim me! ";
const expectedTrimmedMessage = "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 () => { it("should not call onSend or clearDraft if input is empty on submit", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form"); const formElement = screen.getByRole("form");
expect((inputElement as HTMLInputElement).value).toBe(""); expect((inputElement as HTMLInputElement).value).toBe("");
@ -236,10 +232,14 @@ describe("MessageInput", () => {
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect( expect(
(screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value, (screen.getByTestId(
"message-input-field",
) as HTMLInputElement).value,
).toBe("Broadcast draft"); ).toBe("Broadcast draft");
const inputElement = screen.getByPlaceholderText("Enter Message"); const inputElement = screen.getByTestId(
"message-input-field",
) as HTMLInputElement;
const formElement = screen.getByRole("form"); const formElement = screen.getByRole("form");
const newMessage = "New broadcast msg"; const newMessage = "New broadcast msg";

4
src/components/PageComponents/Messages/MessageInput.tsx

@ -4,6 +4,7 @@ import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface MessageInputProps { export interface MessageInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
@ -17,6 +18,7 @@ export const MessageInput = ({
maxBytes, maxBytes,
}: MessageInputProps) => { }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore(); const { setDraft, getDraft, clearDraft } = useMessageStore();
const { t } = useTranslation("messages");
const calculateBytes = (text: string) => new Blob([text]).size; const calculateBytes = (text: string) => new Blob([text]).size;
@ -59,7 +61,7 @@ export const MessageInput = ({
autoFocus autoFocus
minLength={1} minLength={1}
name="messageInput" name="messageInput"
placeholder="Enter Message" placeholder={t("sendMessage.placeholder")}
autoComplete="off" autoComplete="off"
value={localDraft} value={localDraft}
onChange={handleInputChange} onChange={handleInputChange}

16
src/components/PageComponents/Messages/MessageItem.tsx

@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const MESSAGE_STATUS_MAP = useMemo( const MESSAGE_STATUS_MAP = useMemo(
(): Record<MessageState, MessageStatusInfo> => ({ (): Record<MessageState, MessageStatusInfo> => ({
[MessageState.Ack]: { [MessageState.Ack]: {
displayText: t("deliveryStatus.deliveryStatus."), displayText: t("deliveryStatus.delivered.displayText"),
icon: CheckCircle2, icon: CheckCircle2,
ariaLabel: t("deliveryStatus.delivered"), ariaLabel: t("deliveryStatus.delivered.label"),
iconClassName: "text-green-500", iconClassName: "text-green-500",
}, },
[MessageState.Waiting]: { [MessageState.Waiting]: {
displayText: t("deliveryStatus.waiting"), displayText: t("deliveryStatus.waiting.displayText"),
icon: CircleEllipsis, icon: CircleEllipsis,
ariaLabel: t("deliveryStatus.waiting"), ariaLabel: t("deliveryStatus.waiting.label"),
iconClassName: "text-slate-400", iconClassName: "text-slate-400",
}, },
[MessageState.Failed]: { [MessageState.Failed]: {
displayText: t("deliveryStatus.failed"), displayText: t("deliveryStatus.failed.displayText"),
icon: AlertCircle, icon: AlertCircle,
ariaLabel: t("deliveryStatus.failed"), ariaLabel: t("deliveryStatus.failed.label"),
iconClassName: "text-red-500 dark:text-red-400", iconClassName: "text-red-500 dark:text-red-400",
}, },
}), }),
@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
); );
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("delveryStatus.unknown"), displayText: t("delveryStatus.unknown.displayText"),
icon: AlertCircle, icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown"), ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400", iconClassName: "text-red-500 dark:text-red-400",
}), [t]); }), [t]);

4
src/components/UI/Avatar.tsx

@ -114,7 +114,7 @@ export const Avatar = ({
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Favorite {t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> <TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -132,7 +132,7 @@ export const Avatar = ({
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Node error {t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" /> <TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

34
src/components/UI/Generator.tsx

@ -8,6 +8,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.tsx"; } from "@components/UI/Select.tsx";
import { useTranslation } from "react-i18next";
export interface ActionButton { export interface ActionButton {
text: string; text: string;
@ -40,12 +41,7 @@ const Generator = (
variant, variant,
value, value,
actionButtons, actionButtons,
bits = [ 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" },
],
selectChange, selectChange,
inputChange, inputChange,
disabled, disabled,
@ -55,6 +51,30 @@ const Generator = (
}: GeneratorProps, }: GeneratorProps,
) => { ) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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 // Invokes onChange event on the input element when the value changes from the parent component
useEffect(() => { useEffect(() => {
@ -91,7 +111,7 @@ const Generator = (
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="w-36"> <SelectContent className="w-36">
{bits.map(({ text, value, key }) => ( {passwordRequiredBitSize.map(({ text, value, key }) => (
<SelectItem key={key} value={value} className="w-36"> <SelectItem key={key} value={value} className="w-36">
{text} {text}
</SelectItem> </SelectItem>

134
src/components/generic/TimeAgo.tsx

@ -5,49 +5,115 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@radix-ui/react-tooltip"; } from "@radix-ui/react-tooltip";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export interface TimeAgoProps { export interface TimeAgoProps {
timestamp: number; timestamp: number | Date;
locale?: string;
tooltipOptions?: Intl.DateTimeFormatOptions;
className?: string;
} }
const getTimeAgo = ( const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
unixTimestamp: number, ["year", 31536000],
locale: Intl.LocalesArgument = "en", ["month", 2592000],
): string => { ["day", 86400],
const timestamp = new Date(unixTimestamp); ["hour", 3600],
const diff = (new Date().getTime() - timestamp.getTime()) / 1000; ["minute", 60],
["second", 1],
const minutes = Math.floor(diff / 60); ];
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const getRelativeTimeParts = (
const months = Math.floor(days / 30); date: Date | number,
const years = Math.floor(months / 12); ): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
if (years > 0) { for (const [unit, secondsInUnit] of TIME_UNITS) {
return rtf.format(0 - years, "year"); if (Math.abs(diffInSeconds) >= secondsInUnit) {
} const value = Math.round(diffInSeconds / secondsInUnit);
if (months > 0) { return { value, unit };
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");
} }
return rtf.format(Math.floor(0 - diff), "second");
return { value: Math.round(diffInSeconds), unit: "second" };
}; };
export const TimeAgo = ({ timestamp }: TimeAgoProps) => { const UPDATE_INTERVALS: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
// 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<string>("");
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 ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<span>{getTimeAgo(timestamp)}</span> <time dateTime={date.toISOString()} className={className}>
{timeAgo}
</time>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent <TooltipContent
@ -56,7 +122,7 @@ export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
align="center" align="center"
sideOffset={5} sideOffset={5}
> >
{new Date(timestamp).toLocaleString()} {fullDate}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>

6
src/core/hooks/useFavoriteNode.test.ts

@ -43,7 +43,7 @@ describe("useFavoriteNode hook", () => {
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true); expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234); expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({ 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(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234); expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({ 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({ expect(mockToast).toHaveBeenCalledWith({
title: "Added node to favorites", title: "Added Node to favorites.",
}); });
}); });

14
src/core/hooks/useFavoriteNode.ts

@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface FavoriteNodeOptions { interface FavoriteNodeOptions {
nodeNum: number; nodeNum: number;
@ -9,6 +10,7 @@ interface FavoriteNodeOptions {
export function useFavoriteNode() { export function useFavoriteNode() {
const { updateFavorite, getNode } = useDevice(); const { updateFavorite, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const updateFavoriteCB = useCallback( const updateFavoriteCB = useCallback(
@ -19,9 +21,15 @@ export function useFavoriteNode() {
updateFavorite(nodeNum, isFavorite); updateFavorite(nodeNum, isFavorite);
toast({ toast({
title: `${isFavorite ? "Added" : "Removed"} ${ title: t("toast.favoriteNode.title", {
node?.user?.longName ?? "node" action: isFavorite
} ${isFavorite ? "to" : "from"} favorites`, ? 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], [updateFavorite, getNode],

15
src/core/hooks/useIgnoreNode.ts

@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface IgnoreNodeOptions { interface IgnoreNodeOptions {
nodeNum: number; nodeNum: number;
@ -9,6 +10,8 @@ interface IgnoreNodeOptions {
export function useIgnoreNode() { export function useIgnoreNode() {
const { updateIgnored, getNode } = useDevice(); const { updateIgnored, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const updateIgnoredCB = useCallback( const updateIgnoredCB = useCallback(
@ -19,9 +22,15 @@ export function useIgnoreNode() {
updateIgnored(nodeNum, isIgnored); updateIgnored(nodeNum, isIgnored);
toast({ toast({
title: `${isIgnored ? "Added" : "Removed"} ${ title: t("toast.ignoreNode.title", {
node?.user?.longName ?? "node" nodeName: node?.user?.longName ?? "node",
} ${isIgnored ? "to" : "from"} ignore list`, 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], [updateIgnored, getNode],

6
src/i18n/config.ts

@ -41,9 +41,9 @@ i18next
fallbackLng: { fallbackLng: {
default: [FALLBACK_LANGUAGE_CODE], default: [FALLBACK_LANGUAGE_CODE],
"en-GB": [FALLBACK_LANGUAGE_CODE], "en-GB": [FALLBACK_LANGUAGE_CODE],
"fi": ["fi-FI"], "fi": ["fi-FI", FALLBACK_LANGUAGE_CODE],
"sv": ["sv-SE"], "sv": ["sv-SE", FALLBACK_LANGUAGE_CODE],
"de": ["de-DE"], "de": ["de-DE", FALLBACK_LANGUAGE_CODE],
}, },
fallbackNS: ["common", "ui", "dialog"], fallbackNS: ["common", "ui", "dialog"],
debug: import.meta.env.MODE === "development", debug: import.meta.env.MODE === "development",

10
src/i18n/locales/en/common.json

@ -24,7 +24,8 @@
"reset": "Reset", "reset": "Reset",
"save": "Save", "save": "Save",
"scanQr": "Scan QR Code", "scanQr": "Scan QR Code",
"traceRoute": "Trace Route" "traceRoute": "Trace Route",
"submit": "Submit"
}, },
"app": { "app": {
"title": "Meshtastic", "title": "Meshtastic",
@ -48,6 +49,7 @@
"raw": "raw", "raw": "raw",
"meter": { "one": "Meter", "plural": "Meters", "suffix": "m" }, "meter": { "one": "Meter", "plural": "Meters", "suffix": "m" },
"minute": { "one": "Minute", "plural": "Minutes" }, "minute": { "one": "Minute", "plural": "Minutes" },
"hour": { "one": "Hour", "plural": "Hours" },
"millisecond": { "millisecond": {
"one": "Millisecond", "one": "Millisecond",
"plural": "Milliseconds", "plural": "Milliseconds",
@ -55,11 +57,16 @@
}, },
"second": { "one": "Second", "plural": "Seconds" }, "second": { "one": "Second", "plural": "Seconds" },
"day": { "one": "Day", "plural": "Days" }, "day": { "one": "Day", "plural": "Days" },
"month": { "one": "Month", "plural": "Months" },
"year": { "one": "Year", "plural": "Years" },
"snr": "SNR", "snr": "SNR",
"volt": { "one": "Volt", "plural": "Volts", "suffix": "V" }, "volt": { "one": "Volt", "plural": "Volts", "suffix": "V" },
"record": { "one": "Records", "plural": "Records" } "record": { "one": "Records", "plural": "Records" }
}, },
"security": { "security": {
"0bit": "Empty",
"8bit": "8 bit",
"128bit": "128 bit",
"256bit": "256 bit" "256bit": "256 bit"
}, },
"unknown": { "unknown": {
@ -71,6 +78,7 @@
"nodeUnknownPrefix": "!", "nodeUnknownPrefix": "!",
"unset": "UNSET", "unset": "UNSET",
"fallbackName": "Meshtastic {{last4}}", "fallbackName": "Meshtastic {{last4}}",
"node": "Node",
"formValidation": { "formValidation": {
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"tooBig": { "tooBig": {

39
src/i18n/locales/en/messages.json

@ -1,6 +1,7 @@
{ {
"page": { "page": {
"title": "Messages: {{chatName}}" "title": "Messages: {{chatName}}",
"placeholder": "Enter Message"
}, },
"emptyState": { "emptyState": {
"title": "Select a Chat", "title": "Select a Chat",
@ -10,7 +11,7 @@
"text": "Select a channel or node to start messaging." "text": "Select a channel or node to start messaging."
}, },
"sendMessage": { "sendMessage": {
"placeholder": "Type your message here...", "placeholder": "Enter your message here...",
"sendButton": "Send" "sendButton": "Send"
}, },
"actionsMenu": { "actionsMenu": {
@ -18,24 +19,22 @@
"replyLabel": "Reply" "replyLabel": "Reply"
}, },
"item": { "deliveryStatus": {
"status": { "delivered": {
"delivered": { "label": "Message delivered",
"label": "Message delivered", "displayText": "Message delivered"
"displayText": "Message delivered" },
}, "failed": {
"failed": { "label": "Message delivery failed",
"label": "Message delivery failed", "displayText": "Delivery failed"
"displayText": "Delivery failed" },
}, "unknown": {
"unknown": { "label": "Message status unknown",
"label": "Message status unknown", "displayText": "Unknown state"
"displayText": "Unknown state" },
}, "waiting": {
"waiting": { "label": "Sending message",
"ariaLabel": "Sending message", "displayText": "Waiting for delivery"
"displayText": "Waiting for delivery"
}
} }
} }
} }

15
src/i18n/locales/en/nodes.json

@ -10,11 +10,16 @@
"label": "Direct Message {{shortName}}" "label": "Direct Message {{shortName}}"
}, },
"favorite": { "favorite": {
"label": "Favorite" "label": "Favorite",
"tooltip": "Add or remove this node from your favorites"
}, },
"notFavorite": { "notFavorite": {
"label": "Not a Favorite" "label": "Not a Favorite"
}, },
"error": {
"label": "Error",
"text": "An error occurred while fetching node details. Please try again later."
},
"status": { "status": {
"heard": "Heard", "heard": "Heard",
"mqtt": "MQTT" "mqtt": "MQTT"
@ -47,5 +52,13 @@
"lastHeardStatus": { "lastHeardStatus": {
"never": "Never" "never": "Never"
} }
},
"actions": {
"added": "Added",
"removed": "Removed",
"ignoreNode": "Ignore Node",
"unignoreNode": "Unignore Node",
"requestPosition": "Request Position"
} }
} }

18
src/i18n/locales/en/ui.json

@ -66,6 +66,24 @@
"saveSuccess": { "saveSuccess": {
"title": "Saving Config", "title": "Saving Config",
"description": "The configuration change {{case}} has been saved." "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": { "notifications": {

17
src/pages/Messages.tsx

@ -306,13 +306,16 @@ export const MessagesPage = () => {
return ( return (
<PageLayout <PageLayout
label={`Messages: ${ label={`${
isBroadcast && currentChannel t("page.title", {
? getChannelName(currentChannel) chatName: isBroadcast && currentChannel
: isDirect && otherNode ? getChannelName(currentChannel)
? (otherNode.user?.longName ?? t("unknown.longName")) : isDirect && otherNode
: t("emptyState.title") ? (otherNode.user?.longName ?? t("unknown.longName"))
}`} : t("emptyState.title"),
})
}
`}
rightBar={rightSidebar} rightBar={rightSidebar}
leftBar={leftSidebar} leftBar={leftSidebar}
actions={isDirect && otherNode actions={isDirect && otherNode

9
src/pages/Nodes/index.tsx

@ -31,6 +31,7 @@ import {
} from "@components/generic/Filter/useFilterNode.ts"; } from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLang from "@core/hooks/useLang.ts";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
@ -39,6 +40,7 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { t } = useTranslation("nodes"); const { t } = useTranslation("nodes");
const { currentLanguage } = useLang();
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } = const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
useDevice(); useDevice();
const { setNodeNumDetails } = useAppStore(); const { setNodeNumDetails } = useAppStore();
@ -168,7 +170,12 @@ const NodesPage = (): JSX.Element => {
<Mono> <Mono>
{node.lastHeard === 0 {node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p> ? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: <TimeAgo timestamp={node.lastHeard * 1000} />} : (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
</Mono> </Mono>
), ),
sortValue: node.lastHeard, sortValue: node.lastHeard,

Loading…
Cancel
Save