fix: add missing i18n strings (#671)
* fix: added required i18n labels to UI * added node i18n * updated tests * updated path to match github action
This commit is contained in:
+1
-1
@@ -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%"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 getRelativeTimeParts = (
|
||||||
const hours = Math.floor(minutes / 60);
|
date: Date | number,
|
||||||
const days = Math.floor(hours / 24);
|
): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
|
||||||
const months = Math.floor(days / 30);
|
const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
|
||||||
const years = Math.floor(months / 12);
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
||||||
|
|
||||||
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);
|
||||||
|
return { value, unit };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (months > 0) {
|
|
||||||
return rtf.format(0 - months, "month");
|
return { value: Math.round(diffInSeconds), unit: "second" };
|
||||||
}
|
|
||||||
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");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
+3
-3
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
+10
-7
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user