Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions engine/i18n-localization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { translateText } from "../src/lib/i18n";

describe("UI translations", () => {
test("keeps Thai text in Thai mode", () => {
expect(translateText("th", "เริ่มแชตใหม่")).toBe("เริ่มแชตใหม่");
});

test("translates common chat navigation labels", () => {
expect(translateText("en", "เพื่อน")).toBe("Friends");
expect(translateText("en", "รายการโปรด")).toBe("Favorites");
});

test("interpolates values after translation", () => {
expect(translateText("en", "แสดงกลุ่มเก่า ({{count}})", { count: 12 })).toBe("Show archived groups (12)");
});

test("preserves user-provided text without a translation entry", () => {
const userText = "ชื่อเพื่อนที่ผู้ใช้ตั้งเอง";
expect(translateText("en", userText)).toBe(userText);
});
});
453 changes: 238 additions & 215 deletions src/App.tsx

Large diffs are not rendered by default.

49 changes: 26 additions & 23 deletions src/components/FlexMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { useState } from "react";
import { useAppTranslation } from "../lib/i18n";
import type { FlexMessageData } from "../data/mock";
import { RemoteImage } from "./RemoteImage";

Expand All @@ -11,6 +12,7 @@ export type FlexActionHandler = (action: FlexAction) => void | Promise<void>;
type FlexLayout = "horizontal" | "vertical" | "baseline";
type FlexContext = {
onAction?: FlexActionHandler;
t?: (source: string) => string;
parentLayout?: FlexLayout;
inheritedTextDecoration?: CSSProperties["textDecoration"];
inheritedScaling?: boolean;
Expand Down Expand Up @@ -158,30 +160,30 @@ function actionUri(action: unknown): string | undefined {
return primary ?? desktop;
}

function actionLabel(action: unknown): string {
function actionLabel(action: unknown, t: (source: string) => string = (source) => source): string {
const value = asRecord(action);
const explicit = asString(value?.label).trim();
if (explicit) return explicit;
switch (actionType(action)) {
case "message": return asString(value?.text) || "ส่งข้อความ";
case "clipboard": return "คัดลอก";
case "datetimepicker": return "เลือกวันเวลา";
case "postback": return "ดำเนินการ";
default: return "เปิด";
case "message": return asString(value?.text) || t("ส่งข้อความ");
case "clipboard": return t("คัดลอก");
case "datetimepicker": return t("เลือกวันเวลา");
case "postback": return t("ดำเนินการ");
default: return t("เปิด");
}
}

function actionTitle(action: unknown): string {
function actionTitle(action: unknown, t: (source: string) => string = (source) => source): string {
switch (actionType(action)) {
case "message": return "ส่งข้อความนี้ในห้องแชต";
case "clipboard": return "คัดลอกข้อความไปยัง clipboard";
case "datetimepicker": return "เลือกวันเวลาสำหรับการดำเนินการนี้";
case "postback": return "ดำเนินการกับ LINE Official Account";
case "camera": return "เปิดกล้อง";
case "cameraroll": return "เลือกรูปภาพจากเครื่อง";
case "location": return "เปิดตำแหน่งที่ตั้ง";
case "richmenuswitch": return "สลับ Rich Menu";
default: return "ดำเนินการกับ Flex Message";
case "message": return t("ส่งข้อความนี้ในห้องแชต");
case "clipboard": return t("คัดลอกข้อความไปยัง clipboard");
case "datetimepicker": return t("เลือกวันเวลาสำหรับการดำเนินการนี้");
case "postback": return t("ดำเนินการกับ LINE Official Account");
case "camera": return t("เปิดกล้อง");
case "cameraroll": return t("เลือกรูปภาพจากเครื่อง");
case "location": return t("เปิดตำแหน่งที่ตั้ง");
case "richmenuswitch": return t("สลับ Rich Menu");
default: return t("ดำเนินการกับ Flex Message");
}
}

Expand Down Expand Up @@ -436,7 +438,7 @@ function FlexAction({
const value = asRecord(action);
if (!value) return <>{children}</>;
const uri = actionUri(value);
const label = actionLabel(value);
const label = actionLabel(value, context.t);
if (uri) {
return (
<a
Expand Down Expand Up @@ -472,7 +474,7 @@ function FlexAction({
tabIndex={interactive ? 0 : -1}
aria-label={label}
aria-disabled={interactive ? undefined : true}
title={actionTitle(value)}
title={actionTitle(value, context.t)}
onClick={(event) => {
event.stopPropagation();
activate();
Expand Down Expand Up @@ -560,7 +562,7 @@ function FlexVideo({ node, context }: { node: FlexNode; context: FlexContext })
if (!url || failed) {
return altContent
? <FlexNodeView node={altContent} context={context} />
: <div className="flex-unsupported" style={frameStyle}>วิดีโอ Flex โหลดไม่ได้</div>;
: <div className="flex-unsupported" style={frameStyle}>{context.t?.("วิดีโอ Flex โหลดไม่ได้") ?? "วิดีโอ Flex โหลดไม่ได้"}</div>;
}
const video = (
<div className="flex-video-frame" style={frameStyle}>
Expand Down Expand Up @@ -664,7 +666,7 @@ function FlexDatetimeButton({ action, context, outerStyle, labelStyle, label }:
const [value, setValue] = useState(() => pickerValue(action.initial, mode));
if (!inputType) return <FlexAction action={action} className="flex-button" style={outerStyle} context={context}><span style={labelStyle}>{label}</span></FlexAction>;
return (
<label className="flex-button flex-button-picker" style={outerStyle} title={actionTitle(action)}>
<label className="flex-button flex-button-picker" style={outerStyle} title={actionTitle(action, context.t)}>
<span style={labelStyle}>{label}</span>
<input
type={inputType}
Expand All @@ -684,7 +686,7 @@ function FlexDatetimeButton({ action, context, outerStyle, labelStyle, label }:

function FlexButton({ node, context }: { node: FlexNode; context: FlexContext }) {
const action = asRecord(node.action);
const label = actionLabel(action);
const label = actionLabel(action, context.t);
const variant = ["primary", "secondary", "link"].includes(asString(node.style).toLowerCase())
? asString(node.style).toLowerCase()
: "link";
Expand Down Expand Up @@ -753,7 +755,7 @@ function FlexNodeView({ node, context, inline = false }: { node: FlexNode; conte
return <div className={`flex-spacer ${horizontal ? "flex-spacer-horizontal" : ""}`} style={horizontal ? { width: dimension, height: "1px" } : { height: dimension, width: "1px" }} />;
}
if (type === "filler") return <span className="flex-filler" style={{ flex: safeFlex(node.flex) === 0 ? "0 0 auto" : `${safeFlex(node.flex) ?? 1} 1 auto` }} />;
if (type) return <div className="flex-unsupported">ไม่รองรับ Flex component: {type}</div>;
if (type) return <div className="flex-unsupported">{context.t?.("ไม่รองรับ Flex component") ?? "ไม่รองรับ Flex component"}: {type}</div>;
return null;
}

Expand Down Expand Up @@ -800,9 +802,10 @@ function FlexRoot({ contents, context }: { contents: FlexNode; context: FlexCont
}

export function FlexMessage({ data, onAction }: { data: FlexMessageData; onAction?: FlexActionHandler }) {
const { t } = useAppTranslation();
return (
<div className="flex-message" aria-label={data.altText || "Flex message"} data-flex-version={data.version} data-flex-effect-tag={data.effectTag}>
<FlexRoot contents={data.contents} context={{ onAction }} />
<FlexRoot contents={data.contents} context={{ onAction, t }} />
</div>
);
}
54 changes: 28 additions & 26 deletions src/components/FriendSyncDiagnostics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, CircleHelp, Clock3, LoaderCircle, RefreshCw, RotateCw, ShieldCheck } from "lucide-react";
import { lineyEngine } from "../lib/engine";
import { useAppTranslation } from "../lib/i18n";
import "./FriendSyncDiagnostics.css";

export type FriendSyncDiagnosticsState = {
Expand Down Expand Up @@ -29,10 +30,11 @@ const issueNames: Record<string, string> = {
PERMISSION: "สิทธิ์เข้าถึงหรือเซสชัน",
OTHER: "ข้อผิดพลาดอื่น",
};
const fmt = (n: number) => n.toLocaleString("th-TH");
const localeDate = (ms: number) => ms ? new Date(ms).toLocaleString("th-TH") : "—";

export function FriendSyncDiagnostics() {
const { t, english } = useAppTranslation();
const fmt = (n: number) => n.toLocaleString(english ? "en-US" : "th-TH");
const localeDate = (ms: number) => ms ? new Date(ms).toLocaleString(english ? "en-US" : "th-TH") : "—";
const [report, setReport] = useState<FriendSyncDiagnosticsState | null>(null);
const [busy, setBusy] = useState<"" | "scan" | "retry">("");
const [error, setError] = useState("");
Expand Down Expand Up @@ -66,11 +68,11 @@ export function FriendSyncDiagnostics() {
} catch (reason) {
const raw = reason instanceof Error ? reason.message : String(reason);
setError(raw.includes("FRIEND_SYNC_BUSY")
? "มีการซิงค์เพื่อนหรือ Full Sync กำลังทำงาน กรุณารอให้จบก่อน"
? t("มีการซิงค์เพื่อนหรือ Full Sync กำลังทำงาน กรุณารอให้จบก่อน")
: raw.includes("FRIEND_SYNC_COOLDOWN")
? "เพิ่ง Retry ไป กรุณารอให้ครบเวลาพักก่อน"
? t("เพิ่ง Retry ไป กรุณารอให้ครบเวลาพักก่อน")
: raw.includes("FRIEND_SYNC_SCAN_REQUIRED")
? "กรุณากดตรวจรายชื่อก่อน Retry"
? t("กรุณากดตรวจรายชื่อก่อน Retry")
: raw);
} finally {
setBusy("");
Expand All @@ -84,18 +86,18 @@ export function FriendSyncDiagnostics() {
const pending = Boolean(busy) || report?.phase === "running";
const cooldown = Math.max(0, Math.ceil(((report?.retryAfter ?? 0) - now) / 1000));
const retryDisabled = pending || !report?.unresolved || cooldown > 0;
const statusTitle = !report || report.phase === "idle" ? "ยังไม่มีผลตรวจจาก LINE"
: report.phase === "running" ? "กำลังซิงค์รายชื่อ…"
: report.phase === "complete" ? "ดึงโปรไฟล์ครบทุก MID แล้ว"
: report.phase === "error" ? "ตรวจสอบไม่สำเร็จ"
: "มีโปรไฟล์ที่ยังดึงไม่สำเร็จ";
const statusTitle = !report || report.phase === "idle" ? t("ยังไม่มีผลตรวจจาก LINE")
: report.phase === "running" ? t("กำลังซิงค์รายชื่อ…")
: report.phase === "complete" ? t("ดึงโปรไฟล์ครบทุก MID แล้ว")
: report.phase === "error" ? t("ตรวจสอบไม่สำเร็จ")
: t("มีโปรไฟล์ที่ยังดึงไม่สำเร็จ");

return <div className="liney-friend-diagnostics">
<div className="liney-friend-diag-head">
<div>
<span className="liney-friend-diag-eyebrow">LINEJS / CONTACT SYNC</span>
<h3>Sync Diagnostics</h3>
<p>แยกจำนวน MID ที่ LINE ส่งกลับมาจากโปรไฟล์ที่อ่านได้จริง</p>
<p>{t("แยกจำนวน MID ที่ LINE ส่งกลับมาจากโปรไฟล์ที่อ่านได้จริง")}</p>
</div>
<span className={`liney-friend-diag-state ${report?.phase === "complete" ? "success" : ""}`}>
{report?.phase === "complete" ? <CheckCircle2 size={15}/> : pending ? <LoaderCircle size={15} className="spin"/> : <CircleHelp size={15}/>}
Expand All @@ -104,54 +106,54 @@ export function FriendSyncDiagnostics() {
</div>

<div className="liney-friend-diag-stats">
<div><span>MID ที่ LINE ส่งมา</span><strong>{countKnown ? fmt(total) : "—"}</strong><small>ไม่ใช่จำนวนแชตใน SQLite</small></div>
<div><span>โปรไฟล์ที่ดึงสำเร็จ</span><strong>{countKnown ? fmt(done) : "—"}</strong><small>ชื่อที่ใช้แสดงผลได้จริง</small></div>
<div><span>ยังไม่สมบูรณ์</span><strong>{countKnown ? fmt(report?.unresolved ?? 0) : "—"}</strong><small>สามารถ Retry เฉพาะส่วนนี้</small></div>
<div><span>{t("MID ที่ LINE ส่งมา")}</span><strong>{countKnown ? fmt(total) : "—"}</strong><small>{t("ไม่ใช่จำนวนแชตใน SQLite")}</small></div>
<div><span>{t("โปรไฟล์ที่ดึงสำเร็จ")}</span><strong>{countKnown ? fmt(done) : "—"}</strong><small>{t("ชื่อที่ใช้แสดงผลได้จริง")}</small></div>
<div><span>{t("ยังไม่สมบูรณ์")}</span><strong>{countKnown ? fmt(report?.unresolved ?? 0) : "—"}</strong><small>{t("สามารถ Retry เฉพาะส่วนนี้")}</small></div>
</div>

<div className="liney-friend-diag-progress">
<div><span>ความสมบูรณ์ของโปรไฟล์</span><strong>{countKnown ? `${progress}%` : "ยังไม่ได้ตรวจ"}</strong></div>
<div className="liney-friend-diag-track" role="progressbar" aria-label="ความสมบูรณ์ของโปรไฟล์" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}>
<div><span>{t("ความสมบูรณ์ของโปรไฟล์")}</span><strong>{countKnown ? `${progress}%` : t("ยังไม่ได้ตรวจ")}</strong></div>
<div className="liney-friend-diag-track" role="progressbar" aria-label={t("ความสมบูรณ์ของโปรไฟล์")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}>
<span style={{ width: `${progress}%` }} />
</div>
<small><Clock3 size={13}/> ตรวจล่าสุด: {localeDate(report?.finishedAt ?? 0)}</small>
<small><Clock3 size={13}/> {t("ตรวจล่าสุด")}: {localeDate(report?.finishedAt ?? 0)}</small>
</div>

<div className="liney-friend-diag-breakdown">
<div><strong>{fmt(report?.viaV3 ?? 0)}</strong><span>Contacts V3</span></div>
<div><strong>{fmt(report?.viaV2 ?? 0)}</strong><span>Contacts V2</span></div>
<div><strong>{fmt(report?.viaSingle ?? 0)}</strong><span>getUser Fallback</span></div>
<div><strong>{fmt(report?.batches ?? 0)}</strong><span>API Batches</span></div>
<div><strong>{fmt(report?.splitBatches ?? 0)}</strong><span>Batch ถูกแบ่งย่อย</span></div>
<div><strong>{fmt(report?.splitBatches ?? 0)}</strong><span>{t("Batch ถูกแบ่งย่อย")}</span></div>
</div>

{Boolean(report?.unavailableEndpoints?.length) && <div className="liney-friend-diag-fallback" role="status">
<h4><CheckCircle2 size={16}/> ใช้ช่องทางสำรองอัตโนมัติ</h4>
<p>{report?.unavailableEndpoints?.join(" / ")} ไม่พร้อมใช้งานกับเซสชันนี้ จึงเปลี่ยนไปใช้ API สำรองโดยไม่ต้อง Retry หรือเข้าสู่ระบบใหม่</p>
<h4><CheckCircle2 size={16}/> {t("ใช้ช่องทางสำรองอัตโนมัติ")}</h4>
<p>{report?.unavailableEndpoints?.join(" / ")} {t("ไม่พร้อมใช้งานกับเซสชันนี้ จึงเปลี่ยนไปใช้ API สำรองโดยไม่ต้อง Retry หรือเข้าสู่ระบบใหม่")}</p>
</div>}
{Boolean(report?.errors?.length) && <div className="liney-friend-diag-errors">
<h4><AlertCircle size={16}/>{report?.unresolved === 0 && report?.phase === "complete" ? "คำขอที่ผิดพลาดแต่กู้ข้อมูลสำเร็จแล้ว" : "ข้อผิดพลาดจากรอบล่าสุด"}</h4>
<h4><AlertCircle size={16}/>{report?.unresolved === 0 && report?.phase === "complete" ? t("คำขอที่ผิดพลาดแต่กู้ข้อมูลสำเร็จแล้ว") : t("ข้อผิดพลาดจากรอบล่าสุด")}</h4>
{report?.errors.map((issue) => <div key={`${issue.category}-${issue.source || ""}`}>
<span>{issue.source ? `${issue.source} · ` : ""}{issueNames[issue.category] ?? issue.category}</span><strong>{fmt(issue.count)} ครั้ง</strong>
<span>{issue.source ? `${issue.source} · ` : ""}{issueNames[issue.category] ? t(issueNames[issue.category]) : issue.category}</span><strong>{t("{{count}} ครั้ง", { count: fmt(issue.count) })}</strong>
</div>)}
<small>ตัวเลขนี้นับจำนวนคำขอที่ผิดพลาด ไม่ใช่จำนวนเพื่อนที่ตกหล่น {report?.unresolved === 0 ? "โปรไฟล์เพื่อนครบแล้ว" : ""}</small>
<small>{t("ตัวเลขนี้นับจำนวนคำขอที่ผิดพลาด ไม่ใช่จำนวนเพื่อนที่ตกหล่น")} {report?.unresolved === 0 ? t("โปรไฟล์เพื่อนครบแล้ว") : ""}</small>
</div>}

{error && <div className="liney-friend-diag-error" role="alert"><AlertCircle size={16}/>{error}</div>}

<div className="liney-friend-diag-actions">
<button type="button" className="liney-friend-diag-button secondary" disabled={pending} onClick={() => void run("scan")}>
{busy === "scan" ? <LoaderCircle className="spin" size={16}/> : <RefreshCw size={16}/>}
ตรวจรายชื่อใหม่
{t("ตรวจรายชื่อใหม่")}
</button>
<button type="button" className="liney-friend-diag-button primary" disabled={retryDisabled} onClick={() => void run("retry")}>
{busy === "retry" ? <LoaderCircle className="spin" size={16}/> : <RotateCw size={16}/>}
{cooldown ? `Retry ได้ใน ${cooldown} วินาที` : `Retry ที่ตกหล่น ${fmt(report?.unresolved ?? 0)} คน`}
{cooldown ? t("Retry ได้ใน {{count}} วินาที", { count: cooldown }) : t("Retry ที่ตกหล่น {{count}} คน", { count: fmt(report?.unresolved ?? 0) })}
</button>
</div>
<p className="liney-friend-diag-footnote">
<ShieldCheck size={15}/>
ตรวจผ่าน LINEJS บนเครื่องของคุณ · Batch เริ่มต้น 50 คน · Retry สูงสุด 100 คนต่อครั้งและพัก 60 วินาที · ไม่แสดง MID หรือ Token
{t("ตรวจผ่าน LINEJS บนเครื่องของคุณ · Batch เริ่มต้น 50 คน · Retry สูงสุด 100 คนต่อครั้งและพัก 60 วินาที · ไม่แสดง MID หรือ Token")}
</p>
</div>;
}
Loading
Loading