2026-06-25 22:34:24 +02:00
|
|
|
import { useEffect, useState } from "react";
|
2026-06-25 22:05:35 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-06-25 22:34:24 +02:00
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
|
|
|
|
import { api, type Conversation, type MessageUser } from "../lib/api";
|
2026-06-25 22:05:35 +02:00
|
|
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
|
|
|
|
import { relativeTime } from "../lib/format";
|
2026-06-25 22:58:29 +02:00
|
|
|
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
2026-06-26 01:37:43 +02:00
|
|
|
import { useHistorySubview } from "../lib/history";
|
2026-06-25 22:34:24 +02:00
|
|
|
import { openChat } from "../lib/chatDock";
|
2026-06-25 22:05:35 +02:00
|
|
|
import * as e2ee from "../lib/e2ee";
|
|
|
|
|
import Avatar from "./Avatar";
|
2026-06-25 22:34:24 +02:00
|
|
|
import KeyGate from "./KeyGate";
|
|
|
|
|
import ChatThread from "./ChatThread";
|
2026-06-25 22:05:35 +02:00
|
|
|
|
2026-06-25 22:34:24 +02:00
|
|
|
const POLL_MS = 20000;
|
2026-06-25 22:05:35 +02:00
|
|
|
|
|
|
|
|
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
|
|
|
|
|
|
|
|
|
export default function Messages({ meId }: { meId: number }) {
|
2026-06-26 01:37:43 +02:00
|
|
|
// Sub-views live in browser history, so Back returns to the conversation list before leaving
|
|
|
|
|
// the module (open() pushes an entry; back() = history.back()).
|
|
|
|
|
const { view, open, back } = useHistorySubview<View>("list");
|
2026-06-25 22:05:35 +02:00
|
|
|
|
2026-06-29 00:48:18 +02:00
|
|
|
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
|
|
|
|
|
// message yourself). This can be inherited across an account switch — fall back to the list.
|
|
|
|
|
if (typeof view === "object" && view.partnerId !== meId) {
|
2026-06-25 22:05:35 +02:00
|
|
|
return (
|
2026-06-25 22:34:24 +02:00
|
|
|
<PageThread
|
2026-06-25 22:05:35 +02:00
|
|
|
meId={meId}
|
|
|
|
|
partnerId={view.partnerId}
|
|
|
|
|
isSystem={!!view.system}
|
|
|
|
|
seedName={view.name}
|
|
|
|
|
seedAvatar={view.avatar}
|
2026-06-26 01:37:43 +02:00
|
|
|
onBack={back}
|
2026-06-25 22:05:35 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (view === "new") {
|
2026-06-26 01:37:43 +02:00
|
|
|
return <Directory onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={back} />;
|
2026-06-25 22:05:35 +02:00
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<ConversationList
|
2026-06-25 22:34:24 +02:00
|
|
|
meId={meId}
|
2026-06-26 01:37:43 +02:00
|
|
|
onOpen={(c) => open({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
|
|
|
|
|
onNew={() => open("new")}
|
2026-06-25 22:05:35 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ConversationList({
|
2026-06-25 22:34:24 +02:00
|
|
|
meId,
|
2026-06-25 22:05:35 +02:00
|
|
|
onOpen,
|
|
|
|
|
onNew,
|
|
|
|
|
}: {
|
2026-06-25 22:34:24 +02:00
|
|
|
meId: number;
|
2026-06-25 22:05:35 +02:00
|
|
|
onOpen: (c: Conversation) => void;
|
|
|
|
|
onNew: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-06-25 22:58:29 +02:00
|
|
|
const keyState = useKeyState();
|
|
|
|
|
const ready = keyState === "ready";
|
2026-06-25 22:05:35 +02:00
|
|
|
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
|
|
|
|
|
const items = q.data?.items ?? [];
|
|
|
|
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
|
|
|
|
|
2026-07-11 20:16:14 +02:00
|
|
|
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
|
|
|
|
|
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
|
|
|
|
|
// (and setPreviews) every render while the query is loading.
|
2026-06-25 22:05:35 +02:00
|
|
|
useEffect(() => {
|
2026-06-25 22:58:29 +02:00
|
|
|
if (!ready) return;
|
2026-06-25 22:05:35 +02:00
|
|
|
let cancelled = false;
|
|
|
|
|
(async () => {
|
|
|
|
|
const out: Record<number, string> = {};
|
2026-07-11 20:16:14 +02:00
|
|
|
for (const c of q.data?.items ?? []) {
|
2026-06-25 22:05:35 +02:00
|
|
|
const m = c.last_message;
|
|
|
|
|
if (m.kind === "user" && m.ciphertext && m.iv) {
|
|
|
|
|
try {
|
|
|
|
|
const pub = await partnerPub(c.partner.id);
|
|
|
|
|
out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv);
|
|
|
|
|
} catch {
|
|
|
|
|
out[c.partner.id] = "⚠";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!cancelled) setPreviews(out);
|
|
|
|
|
})();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-07-11 20:16:14 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [q.dataUpdatedAt, ready]);
|
2026-06-25 22:05:35 +02:00
|
|
|
|
|
|
|
|
function previewText(c: Conversation): string {
|
|
|
|
|
const m = c.last_message;
|
|
|
|
|
if (m.kind === "system") return m.body ?? "";
|
2026-06-25 22:58:29 +02:00
|
|
|
if (!ready) return "🔒 " + t("messages.encrypted");
|
2026-06-25 22:05:35 +02:00
|
|
|
return previews[c.partner.id] ?? "…";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
|
2026-06-25 22:58:29 +02:00
|
|
|
{ready && (
|
2026-06-25 22:05:35 +02:00
|
|
|
<button
|
|
|
|
|
onClick={onNew}
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-accent text-accent-fg hover:opacity-90 transition"
|
|
|
|
|
>
|
|
|
|
|
<SquarePen className="w-4 h-4" />
|
|
|
|
|
{t("messages.new")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-25 22:58:29 +02:00
|
|
|
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
2026-06-25 22:05:35 +02:00
|
|
|
{q.isLoading && !q.data ? (
|
|
|
|
|
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
|
|
|
|
) : items.length === 0 ? (
|
|
|
|
|
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
2026-06-25 22:34:24 +02:00
|
|
|
{items.map((c) => {
|
|
|
|
|
const isSystem = c.partner.id === SYSTEM_ID;
|
|
|
|
|
return (
|
|
|
|
|
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
|
|
|
|
|
<button onClick={() => onOpen(c)} className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1">
|
|
|
|
|
<Avatar src={c.partner.avatar_url} fallback={c.partner.name} className="w-10 h-10 rounded-full shrink-0 text-sm" />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name}</span>
|
|
|
|
|
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}>{previewText(c)}</span>
|
|
|
|
|
{c.unread > 0 && (
|
|
|
|
|
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-[11px] font-semibold">
|
|
|
|
|
{c.unread}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
2026-06-25 22:58:29 +02:00
|
|
|
{!isSystem && ready && (
|
2026-06-25 22:34:24 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => openChat(c.partner)}
|
|
|
|
|
title={t("messages.popOut")}
|
|
|
|
|
aria-label={t("messages.popOut")}
|
|
|
|
|
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-25 22:05:35 +02:00
|
|
|
</div>
|
2026-06-25 22:34:24 +02:00
|
|
|
);
|
|
|
|
|
})}
|
2026-06-25 22:05:35 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const [filter, setFilter] = useState("");
|
|
|
|
|
const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers });
|
|
|
|
|
const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase()));
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
value={filter}
|
|
|
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
|
|
|
placeholder={t("messages.searchPeople")}
|
|
|
|
|
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
|
|
|
|
/>
|
|
|
|
|
{q.isLoading ? (
|
|
|
|
|
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
|
|
|
|
) : users.length === 0 ? (
|
|
|
|
|
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.noPeople")}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
{users.map((u) => (
|
|
|
|
|
<button
|
|
|
|
|
key={u.id}
|
|
|
|
|
onClick={() => onPick(u)}
|
|
|
|
|
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
|
|
|
|
>
|
|
|
|
|
<Avatar src={u.avatar_url} fallback={u.name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
|
|
|
|
|
<span className="truncate font-medium">{u.name}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 22:34:24 +02:00
|
|
|
function PageThread({
|
2026-06-25 22:05:35 +02:00
|
|
|
meId,
|
|
|
|
|
partnerId,
|
|
|
|
|
isSystem,
|
|
|
|
|
seedName,
|
|
|
|
|
seedAvatar,
|
|
|
|
|
onBack,
|
|
|
|
|
}: {
|
|
|
|
|
meId: number;
|
|
|
|
|
partnerId: number;
|
|
|
|
|
isSystem: boolean;
|
|
|
|
|
seedName?: string;
|
|
|
|
|
seedAvatar?: string | null;
|
|
|
|
|
onBack: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-06-25 22:58:29 +02:00
|
|
|
const ready = useKeyState() === "ready";
|
2026-06-25 22:34:24 +02:00
|
|
|
const name = seedName ?? "";
|
2026-06-25 22:05:35 +02:00
|
|
|
return (
|
2026-06-25 22:34:24 +02:00
|
|
|
<div className="flex flex-col h-[65vh] min-h-80">
|
2026-06-25 22:05:35 +02:00
|
|
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
|
|
|
|
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
</button>
|
2026-06-25 22:34:24 +02:00
|
|
|
<Avatar src={seedAvatar} fallback={name} className="w-9 h-9 rounded-full shrink-0 text-sm" />
|
2026-06-25 22:05:35 +02:00
|
|
|
<span className="font-semibold truncate">{name}</span>
|
|
|
|
|
{!isSystem && <ShieldCheck className="w-4 h-4 text-muted shrink-0" aria-label={t("messages.encryptedHint")} />}
|
2026-06-25 22:34:24 +02:00
|
|
|
<div className="flex-1" />
|
2026-06-25 22:58:29 +02:00
|
|
|
{!isSystem && ready && (
|
2026-06-25 22:34:24 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
|
|
|
|
title={t("messages.popOut")}
|
|
|
|
|
aria-label={t("messages.popOut")}
|
|
|
|
|
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-25 22:05:35 +02:00
|
|
|
</div>
|
2026-06-25 22:58:29 +02:00
|
|
|
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
2026-06-25 22:05:35 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|