- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment. - #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so they were redundant. Verified: the pin button rings from the global rule alone. - #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills. - #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix (has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured: no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a focus indicator); the overlapping ring is an accepted cosmetic edge-case. - #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation. Gate green (tsc/eslint/prettier/knip/vitest 79).
120 lines
4.5 KiB
TypeScript
120 lines
4.5 KiB
TypeScript
import { useEffect } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
|
import {
|
|
closeChat,
|
|
initDock,
|
|
notifyIncoming,
|
|
toggleMinimize,
|
|
useDockChats,
|
|
type DockChat,
|
|
} from "../lib/chatDock";
|
|
import { onMessage, onUnread } from "../lib/messagesSocket";
|
|
import * as e2ee from "../lib/e2ee";
|
|
import Avatar from "./Avatar";
|
|
import ChatThread from "./ChatThread";
|
|
import Overlay from "./Overlay";
|
|
|
|
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
|
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
|
|
// the app-wide live-message subscription and restores this device's unlock state on load.
|
|
export default function ChatDock({ meId }: { meId: number }) {
|
|
const qc = useQueryClient();
|
|
const chats = useDockChats();
|
|
|
|
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
|
|
useEffect(() => {
|
|
initDock(meId);
|
|
e2ee.loadFromDevice(meId);
|
|
}, [meId]);
|
|
|
|
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
|
|
// for an INCOMING message, open or flash that partner's dock window.
|
|
useEffect(
|
|
() =>
|
|
onMessage(({ message: m, from }) => {
|
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
|
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
|
|
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
|
|
// Auto-open/flash only for messages from someone else (not my own echo) and only for
|
|
// real user conversations.
|
|
if (m.kind === "user" && m.sender_id !== meId && from) {
|
|
notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url });
|
|
}
|
|
}),
|
|
[qc, meId],
|
|
);
|
|
|
|
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
|
|
useEffect(
|
|
() =>
|
|
onUnread(() => {
|
|
qc.invalidateQueries({ queryKey: qk.messageUnread() });
|
|
qc.invalidateQueries({ queryKey: qk.conversations() });
|
|
}),
|
|
[qc],
|
|
);
|
|
|
|
if (chats.length === 0) return null;
|
|
return (
|
|
<Overlay>
|
|
<div className="fixed bottom-0 right-0 z-rail flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
|
{chats.map((c) => (
|
|
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
|
|
))}
|
|
</div>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
|
|
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
|
{chat.flash > 0 && (
|
|
<div
|
|
key={chat.flash}
|
|
className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base"
|
|
/>
|
|
)}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
|
<button
|
|
onClick={() => toggleMinimize(chat.partnerId)}
|
|
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
|
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
|
>
|
|
<Avatar
|
|
src={chat.avatar}
|
|
fallback={chat.name}
|
|
className="w-7 h-7 rounded-full shrink-0 text-xs"
|
|
/>
|
|
<span className="font-semibold text-sm truncate">{chat.name}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => toggleMinimize(chat.partnerId)}
|
|
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
|
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
|
>
|
|
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => closeChat(chat.partnerId)}
|
|
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
|
aria-label={t("messages.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{!chat.minimized && (
|
|
<div className="flex flex-col h-96">
|
|
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|