From 14ef3e3c373506c4c6fb69f8a4373c2510557da7 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Sat, 23 Aug 2025 08:52:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?MLS=20=E9=96=A2=E9=80=A3=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4=E3=81=97=E5=B9=B3=E6=96=87=E3=83=81?= =?UTF-8?q?=E3=83=A3=E3=83=83=E3=83=88=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/client/src/components/Application.tsx | 36 +- app/client/src/components/Chat.tsx | 3270 +---------------- app/client/src/components/Profile.tsx | 13 +- app/client/src/components/Setting/index.tsx | 31 - .../components/chat/ChatSettingsOverlay.tsx | 819 ----- .../src/components/chat/ChatTitleBar.tsx | 22 +- app/client/src/components/chat/api.ts | 82 + app/client/src/components/e2ee/api.ts | 1224 ------ app/client/src/components/e2ee/binding.ts | 40 - app/client/src/components/e2ee/mls_message.ts | 135 - app/client/src/components/e2ee/mls_test.ts | 167 - app/client/src/components/e2ee/mls_wrapper.ts | 619 ---- app/client/src/components/e2ee/storage.ts | 527 --- app/client/src/components/e2ee/useMLS.ts | 93 - app/client/src/components/microblog/api.ts | 37 +- 15 files changed, 200 insertions(+), 6915 deletions(-) delete mode 100644 app/client/src/components/chat/ChatSettingsOverlay.tsx create mode 100644 app/client/src/components/chat/api.ts delete mode 100644 app/client/src/components/e2ee/api.ts delete mode 100644 app/client/src/components/e2ee/binding.ts delete mode 100644 app/client/src/components/e2ee/mls_message.ts delete mode 100644 app/client/src/components/e2ee/mls_test.ts delete mode 100644 app/client/src/components/e2ee/mls_wrapper.ts delete mode 100644 app/client/src/components/e2ee/storage.ts delete mode 100644 app/client/src/components/e2ee/useMLS.ts diff --git a/app/client/src/components/Application.tsx b/app/client/src/components/Application.tsx index 12bbe0eb7..3d7299b8e 100644 --- a/app/client/src/components/Application.tsx +++ b/app/client/src/components/Application.tsx @@ -1,8 +1,8 @@ -import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; +import { createEffect, createSignal, onMount, Show } from "solid-js"; import { useAtom } from "solid-jotai"; import { selectedAppState } from "../states/app.ts"; import { selectedRoomState } from "../states/chat.ts"; -import { activeAccount, accounts as accountsAtom } from "../states/account.ts"; +import { activeAccount } from "../states/account.ts"; import { Home } from "./Home.tsx"; import Profile from "./Profile.tsx"; import { Microblog } from "./Microblog.tsx"; @@ -12,15 +12,12 @@ import UnifiedToolsContent from "./home/UnifiedToolsContent.tsx"; import Header from "./header/header.tsx"; import { connectWebSocket, registerUser } from "../utils/ws.ts"; import { getDomain } from "../utils/config.ts"; -import { topUpKeyPackagesBulk } from "./e2ee/api.ts"; export function Application() { const [selectedApp] = useAtom(selectedAppState); const [selectedRoom] = useAtom(selectedRoomState); const [account] = useAtom(activeAccount); - const [allAccounts] = useAtom(accountsAtom); const [isMobile, setIsMobile] = createSignal(false); - let topUpTimer: number | undefined; // モバイルかどうかを判定 onMount(() => { @@ -41,35 +38,6 @@ export function Application() { if (user) { registerUser(`${user.userName}@${getDomain()}`); } - - // Top up KeyPackages for all configured accounts (bulk) instead of only the active one. - const accs = allAccounts(); - if (accs && accs.length > 0) { - const payload = accs.map((a) => ({ userName: a.userName, accountId: a.id })); - // Run immediately and stop periodic top-up if uploads were performed. - void (async () => { - try { - const uploaded = await topUpKeyPackagesBulk(payload); - if (uploaded && topUpTimer) { - clearInterval(topUpTimer); - topUpTimer = undefined; - } - } catch (e) { - console.warn("topUpKeyPackagesBulk failed:", e); - } - })(); - if (topUpTimer) clearInterval(topUpTimer); - topUpTimer = setInterval(() => { - void topUpKeyPackagesBulk(payload); - }, 300_000); - } else if (topUpTimer) { - clearInterval(topUpTimer); - topUpTimer = undefined; - } - }); - - onCleanup(() => { - if (topUpTimer) clearInterval(topUpTimer); }); // チャットページかつスマホ版かつチャンネルが選択されている場合にヘッダーが非表示の場合のクラス名を生成 diff --git a/app/client/src/components/Chat.tsx b/app/client/src/components/Chat.tsx index eafd0c568..27b4b67bb 100644 --- a/app/client/src/components/Chat.tsx +++ b/app/client/src/components/Chat.tsx @@ -1,3212 +1,136 @@ -import { - createEffect, - createMemo, - createSignal, - on, - onCleanup, - onMount, - Show, -} from "solid-js"; +import { createEffect, createSignal, onMount, Show } from "solid-js"; import { useAtom } from "solid-jotai"; import { selectedRoomState } from "../states/chat.ts"; -import { type Account, activeAccount } from "../states/account.ts"; -import { - fetchFollowing, - fetchUserInfo, - fetchUserInfoBatch, -} from "./microblog/api.ts"; -import { - addKeyPackage, - addRoom, - fetchEncryptedMessages, - fetchHandshakes, - fetchKeepMessages, - fetchKeyPackages, - fetchPendingInvites, - importRosterEvidence, - searchRooms, - sendEncryptedMessage, - sendGroupMetadata, - sendHandshake, - sendKeepMessage, - uploadFile, -} from "./e2ee/api.ts"; -import { apiFetch, getDomain } from "../utils/config.ts"; -import { addMessageHandler, removeMessageHandler } from "../utils/ws.ts"; -import { - createCommitAndWelcomes, - createMLSGroup, - decryptMessage, - encryptMessage, - generateKeyPair, - joinWithWelcome, - processCommit, - processProposal, - removeMembers, - type RosterEvidence, - type StoredGroupState, - verifyWelcome, -} from "./e2ee/mls_wrapper.ts"; -import { - decodePublicMessage, - encodePublicMessage, -} from "./e2ee/mls_message.ts"; -import { decodeMlsMessage } from "ts-mls"; -import { - appendRosterEvidence, - getCacheItem, - loadAllMLSKeyPairs, - loadDecryptedMessages, - loadKeyPackageRecords, - loadMLSGroupStates, - loadMLSKeyPair, - saveDecryptedMessages, - saveMLSGroupStates, - saveMLSKeyPair, - setCacheItem, -} from "./e2ee/storage.ts"; -import { isAdsenseEnabled, loadAdsenseConfig } from "../utils/adsense.ts"; +import { activeAccount } from "../states/account.ts"; import { ChatRoomList } from "./chat/ChatRoomList.tsx"; import { ChatTitleBar } from "./chat/ChatTitleBar.tsx"; -import { ChatSettingsOverlay } from "./chat/ChatSettingsOverlay.tsx"; import { ChatMessageList } from "./chat/ChatMessageList.tsx"; import { ChatSendForm } from "./chat/ChatSendForm.tsx"; -import { GroupCreateDialog } from "./chat/GroupCreateDialog.tsx"; -import type { ActorID, ChatMessage, Room } from "./chat/types.ts"; -import { b64ToBuf, bufToB64 } from "../../../shared/buffer.ts"; -import type { GeneratedKeyPair } from "./e2ee/mls_wrapper.ts"; -import { useMLS } from "./e2ee/useMLS.ts"; - -function adjustHeight(el?: HTMLTextAreaElement) { - if (el) { - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - } -} - -function bufToUrl(buf: ArrayBuffer, type: string): string { - const blob = new Blob([buf], { type }); - return URL.createObjectURL(blob); -} - -// ActivityPub の Note 形式のテキストから content を取り出す -function _parseActivityPubContent(text: string): string { - try { - const obj = JSON.parse(text); - if (obj && typeof obj === "object" && typeof obj.content === "string") { - return obj.content; - } - } catch { - /* JSON ではない場合はそのまま返す */ - } - return text; -} - -interface ActivityPubPreview { - url: string; - mediaType: string; - width?: number; - height?: number; - key?: string; - iv?: string; -} - -interface ActivityPubAttachment { - url: string; - mediaType: string; - key?: string; - iv?: string; - preview?: ActivityPubPreview; -} - -interface ParsedActivityPubNote { - id?: string; - content: string; - attachments?: ActivityPubAttachment[]; -} - -function parseActivityPubNote(text: string): ParsedActivityPubNote { - try { - const obj = JSON.parse(text); - if (obj && typeof obj === "object" && typeof obj.content === "string") { - const rawAtt = (obj as { attachment?: unknown }).attachment; - const attachments = Array.isArray(rawAtt) - ? rawAtt - .map((a: unknown) => { - if ( - a && typeof a === "object" && - typeof (a as { url?: unknown }).url === "string" - ) { - const mediaType = - typeof (a as { mediaType?: unknown }).mediaType === "string" - ? (a as { mediaType: string }).mediaType - : "application/octet-stream"; - const key = typeof (a as { key?: unknown }).key === "string" - ? (a as { key: string }).key - : undefined; - const iv = typeof (a as { iv?: unknown }).iv === "string" - ? (a as { iv: string }).iv - : undefined; - const rawPrev = (a as { preview?: unknown }).preview; - let preview: ActivityPubPreview | undefined; - if ( - rawPrev && typeof rawPrev === "object" && - typeof (rawPrev as { url?: unknown }).url === "string" - ) { - preview = { - url: (rawPrev as { url: string }).url, - mediaType: - typeof (rawPrev as { mediaType?: unknown }).mediaType === - "string" - ? (rawPrev as { mediaType: string }).mediaType - : "image/jpeg", - width: typeof (rawPrev as { width?: unknown }).width === - "number" - ? (rawPrev as { width: number }).width - : undefined, - height: typeof (rawPrev as { height?: unknown }).height === - "number" - ? (rawPrev as { height: number }).height - : undefined, - key: typeof (rawPrev as { key?: unknown }).key === "string" - ? (rawPrev as { key: string }).key - : undefined, - iv: typeof (rawPrev as { iv?: unknown }).iv === "string" - ? (rawPrev as { iv: string }).iv - : undefined, - }; - } - return { - url: (a as { url: string }).url, - mediaType, - key, - iv, - preview, - } as ActivityPubAttachment; - } - return null; - }) - .filter(( - a: ActivityPubAttachment | null, - ): a is ActivityPubAttachment => !!a) - : undefined; - const id = typeof (obj as { id?: unknown }).id === "string" - ? (obj as { id: string }).id - : undefined; - return { id, content: obj.content, attachments }; - } - } catch { - /* ignore */ - } - return { content: text }; -} - -// joinAck シグナル (初回参加確認) を表示用メッセージから除外するための判定 -function isJoinAckText(text: string): boolean { - try { - const obj = JSON.parse(text); - return !!obj && typeof obj === "object" && - (obj as { type?: unknown }).type === "joinAck"; - } catch { - return false; - } -} - -async function encryptFile(file: File) { - const buf = await file.arrayBuffer(); - const key = await crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"], - ); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const enc = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, buf); - const rawKey = await crypto.subtle.exportKey("raw", key); - return { - data: enc, - key: bufToB64(rawKey), - iv: bufToB64(iv.buffer), - mediaType: file.type, - name: file.name, - }; -} - -async function decryptFile( - data: ArrayBuffer, - keyB64: string, - ivB64: string, -): Promise { - const key = await crypto.subtle.importKey( - "raw", - b64ToBuf(keyB64), - { name: "AES-GCM" }, - false, - ["decrypt"], - ); - const iv = b64ToBuf(ivB64); - const dec = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - data, - ); - return dec; -} -// 画像からプレビュー用の縮小画像を生成 -async function generateImagePreview( - file: File, -): Promise<{ file: File; width: number; height: number } | null> { - return await new Promise((resolve) => { - const img = new Image(); - const url = URL.createObjectURL(file); - img.onload = () => { - const max = 320; - const scale = Math.min(1, max / img.width); - const w = Math.round(img.width * scale); - const h = Math.round(img.height * scale); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(url); - resolve(null); - return; - } - ctx.drawImage(img, 0, 0, w, h); - canvas.toBlob( - (blob) => { - URL.revokeObjectURL(url); - if (blob) { - resolve({ - file: new File([blob], `preview-${file.name}.jpg`, { - type: "image/jpeg", - }), - width: w, - height: h, - }); - } else { - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - img.src = url; - }); -} -// 動画からプレビュー用の静止画を生成 -async function generateVideoPreview( - file: File, -): Promise<{ file: File; width: number; height: number } | null> { - return await new Promise((resolve) => { - const video = document.createElement("video"); - const url = URL.createObjectURL(file); - video.preload = "metadata"; - video.muted = true; - video.src = url; - video.onloadeddata = () => { - const max = 320; - const scale = Math.min(1, max / video.videoWidth); - const w = Math.round(video.videoWidth * scale); - const h = Math.round(video.videoHeight * scale); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(url); - resolve(null); - return; - } - ctx.drawImage(video, 0, 0, w, h); - canvas.toBlob( - (blob) => { - URL.revokeObjectURL(url); - if (blob) { - resolve({ - file: new File([blob], `preview-${file.name}.jpg`, { - type: "image/jpeg", - }), - width: w, - height: h, - }); - } else { - resolve(null); - } - }, - "image/jpeg", - 0.8, - ); - }; - video.onerror = () => { - URL.revokeObjectURL(url); - resolve(null); - }; - }); -} -// 添付ファイルをアップロードし、必要ならプレビューも付与 -async function buildAttachment(file: File) { - const enc = await encryptFile(file); - const url = await uploadFile({ - content: enc.data, - mediaType: enc.mediaType, - key: enc.key, - iv: enc.iv, - name: file.name, - }); - if (!url) return undefined; - let preview: ActivityPubPreview | undefined; - if (file.type.startsWith("image/")) { - const p = await generateImagePreview(file); - if (p) { - const pEnc = await encryptFile(p.file); - const pUrl = await uploadFile({ - content: pEnc.data, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - name: p.file.name, - }); - if (pUrl) { - preview = { - url: pUrl, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - width: p.width, - height: p.height, - }; - } - } - } else if (file.type.startsWith("video/")) { - const p = await generateVideoPreview(file); - if (p) { - const pEnc = await encryptFile(p.file); - const pUrl = await uploadFile({ - content: pEnc.data, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - name: p.file.name, - }); - if (pUrl) { - preview = { - url: pUrl, - mediaType: pEnc.mediaType, - key: pEnc.key, - iv: pEnc.iv, - width: p.width, - height: p.height, - }; - } - } - } - const attType = file.type.startsWith("image/") - ? "Image" - : file.type.startsWith("video/") - ? "Video" - : file.type.startsWith("audio/") - ? "Audio" - : "Document"; - const att: Record = { - type: attType, - url, - mediaType: enc.mediaType, - key: enc.key, - iv: enc.iv, - }; - if (preview) { - att.preview = { type: "Image", ...preview }; - } - return att; -} - -function getSelfRoomId(_user: Account | null): string | null { - // セルフルーム(TAKO Keep)のIDは固定で "memo" - return _user ? "memo" : null; -} +import type { ChatMessage, Room } from "./chat/types.ts"; +import { + fetchMessages, + searchRooms, + sendMessage as sendPlainMessage, +} from "./chat/api.ts"; +import { getDomain } from "../utils/config.ts"; export function Chat() { - const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); // グローバル状態を使用 const [account] = useAtom(activeAccount); - const { bindingStatus, bindingInfo, assessBinding, ktInfo } = useMLS( - account()?.userName ?? "", - ); - const [newMessage, setNewMessage] = createSignal(""); - const [mediaFile, setMediaFile] = createSignal(null); - const [mediaPreview, setMediaPreview] = createSignal(null); - const [showRoomList, setShowRoomList] = createSignal(true); // モバイル用: 部屋リスト表示制御 - const [isMobile, setIsMobile] = createSignal(false); // モバイル判定 - const [chatRooms, setChatRooms] = createSignal([]); - + const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); + const [rooms, setRooms] = createSignal([]); const [messages, setMessages] = createSignal([]); - // ルームごとの復号済みメッセージキャッシュ(再選択時の再復号を回避) - const [messagesByRoom, setMessagesByRoom] = createSignal< - Record - >({}); - const roomCacheKey = (roomId: string): string => { - const user = account(); - return user ? `${user.id}:${roomId}` : roomId; - }; - const [groups, setGroups] = createSignal>( - {}, - ); - const [keyPair, setKeyPair] = createSignal(null); - const [partnerHasKey, setPartnerHasKey] = createSignal(true); - const messageLimit = 30; - const [showAds, setShowAds] = createSignal(false); - onMount(async () => { - await loadAdsenseConfig(); - setShowAds(isAdsenseEnabled()); - }); - const [cursor, setCursor] = createSignal(null); - const [hasMore, setHasMore] = createSignal(true); - const [loadingOlder, setLoadingOlder] = createSignal(false); - const selectedRoomInfo = createMemo(() => - chatRooms().find((r) => r.id === selectedRoom()) ?? null - ); - const [showGroupDialog, setShowGroupDialog] = createSignal(false); - const [groupDialogMode, setGroupDialogMode] = createSignal< - "create" | "invite" - >("create"); - const [initialMembers, setInitialMembers] = createSignal([]); + const [newMessage, setNewMessage] = createSignal(""); const [segment, setSegment] = createSignal<"all" | "people" | "groups">( "all", ); - // 設定オーバーレイ表示状態 - const [showSettings, setShowSettings] = createSignal(false); - // 受信した Welcome を保留し、ユーザーに参加可否を尋ねる - const [pendingWelcomes, setPendingWelcomes] = createSignal< - Record - >({}); - - const actorUrl = createMemo(() => { - const user = account(); - return user - ? new URL(`/users/${user.userName}`, globalThis.location.origin).href - : null; - }); - - createEffect(() => { - const user = account(); - const roomId = selectedRoom(); - const actor = actorUrl(); - if (!user || !roomId || !actor) return; - void (async () => { - const records = await loadKeyPackageRecords(user.id, roomId); - const last = records[records.length - 1]; - if (last) { - await assessBinding( - user.id, - roomId, - actor, - last.credentialFingerprint, - last.ktIncluded, - ); - } - })(); - }); - - // ルーム重複防止ユーティリティ - function upsertRooms(next: Room[]) { - setChatRooms((prev) => { - const map = new Map(); - // 既存を入れてから next で上書き(最新情報を反映) - for (const r of prev) map.set(r.id, r); - for (const r of next) map.set(r.id, r); - return Array.from(map.values()); - }); - } - function upsertRoom(room: Room) { - upsertRooms([room]); - } - - // MLSの状態から参加者(自分以外)を抽出(actor URL / handle を正規化しつつ重複除去) - const participantsFromState = (roomId: string): string[] => { - const user = account(); - if (!user) return []; - const state = groups()[roomId]; - if (!state) return []; - const selfHandle = `${user.userName}@${getDomain()}` as ActorID; - try { - const raws = extractMembers(state); - const normed = raws - .map((m) => normalizeHandle(m as ActorID) ?? m) - .filter((m): m is string => !!m); - const withoutSelf = normed.filter((m) => { - const h = normalizeHandle(m as ActorID) ?? m; - return h !== selfHandle; - }); - return Array.from(new Set(withoutSelf)); - } catch { - return []; - } - }; - - // 受信メッセージの送信者ハンドルから、メンバーIDをフルハンドル形式に補正 - const updatePeerHandle = (roomId: string, fromHandle: string) => { - const user = account(); - if (!user) return; - const selfHandle = `${user.userName}@${getDomain()}`; - const fullFrom = normalizeHandle(fromHandle as ActorID) ?? fromHandle; - if (fromHandle === selfHandle) return; - const [fromUser] = splitActor(fromHandle as ActorID); - setChatRooms((prev) => - prev.map((r) => { - if (r.id !== roomId) return r; - const members = (r.members ?? []).map((m) => { - if (typeof m === "string" && !m.includes("@")) { - // ユーザー名だけ一致している場合はフルハンドルに置き換え - const [mu] = splitActor(m as ActorID); - if (mu === fromUser) return fullFrom as ActorID; - } - return m; - }); - // 1対1・未命名のとき、タイトルがローカル名等に上書きされていたらハンドルに補正 - const isDm = r.type !== "memo" && (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon); - let displayName = r.displayName; - if ( - isDm && - (!displayName || displayName === user.displayName || - displayName === user.userName || displayName === selfHandle) - ) { - displayName = fullFrom; - } - return { ...r, displayName, members }; - }) - ); - }; - const updateRoomLast = (roomId: string, msg?: ChatMessage) => { - setChatRooms((rooms) => { - let updated = false; - const newRooms = rooms.map((r) => { - if (r.id !== roomId) return r; - const lastMessage = msg?.attachments && msg.attachments.length > 0 - ? "[添付]" + (msg.content ? " " + msg.content : "") - : msg?.content ?? ""; - const lastMessageTime = msg?.timestamp; - if ( - r.lastMessage !== lastMessage || - r.lastMessageTime?.getTime() !== lastMessageTime?.getTime() - ) { - updated = true; - return { ...r, lastMessage, lastMessageTime }; - } - return r; - }); - return updated ? newRooms : rooms; - }); - }; - - // 1対1ルームで、選択時に相手の情報と members を補正する - const ensureDmPartnerInfo = async (room: Room) => { - const user = account(); - if (!user || room.type === "memo") return; - const selfHandle = `${user.userName}@${getDomain()}`; - // UUID のルームはグループとみなし、DM用の名称/アイコン補完は行わない - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const isUuidRoom = uuidRe.test(room.id); - // MLSの状態から相手を特定(自分以外) - const partner = participantsFromState(room.id)[0]; - if (!partner) return; - - // 画面表示用に client 側で members を補完(サーバーから返らない想定) - setChatRooms((prev) => - prev.map((r) => { - if (r.id !== room.id) return r; - const cur = r.members ?? []; - const norm = normalizeHandle(partner as string) as string | undefined; - if (!norm) return r; - if (cur.length === 1 && cur[0] === norm) return r; - return { ...r, members: [norm] }; - }) - ); - - // 名前が未設定/自分名に見える場合は相手の displayName を取得して補完 - if ( - !isUuidRoom && - !(room.hasName || room.hasIcon) && - ((room.displayName ?? room.name) === "" || - (room.displayName ?? room.name) === user.displayName || - (room.displayName ?? room.name) === user.userName || - (room.displayName ?? room.name) === selfHandle) - ) { - try { - const info = await fetchUserInfo(partner as ActorID); - if (info) { - setChatRooms((prev) => - prev.map((r) => - r.id === room.id - ? { - ...r, - displayName: info.displayName || info.userName, - avatar: info.authorAvatar || r.avatar, - } - : r - ) - ); - } - } catch (err) { - // ネットワークエラーや404は致命的ではないので無視 - console.warn("相手情報の取得に失敗しました", err); - } - } - }; - let textareaRef: HTMLTextAreaElement | undefined; - let wsCleanup: (() => void) | undefined; - let acceptCleanup: (() => void) | undefined; - - const loadGroupStates = async () => { - const user = account(); - if (!user) return; - try { - const stored = await loadMLSGroupStates(user.id); - setGroups(stored); - } catch (err) { - console.error("Failed to load group states", err); - } - }; - - const saveGroupStates = async () => { - const user = account(); - if (!user) return; - try { - await saveMLSGroupStates(user.id, groups()); - } catch (e) { - console.error("グループ状態の保存に失敗しました", e); - } - }; - - // グループ状態が存在しなければ初期化して保存 - const initGroupState = async (roomId: string) => { - try { - if (groups()[roomId]) return; - const user = account(); - if (!user) return; - // 保存済みの状態があればそれを復元 - try { - const stored = await loadMLSGroupStates(user.id); - if (stored[roomId]) { - setGroups((prev) => ({ ...prev, [roomId]: stored[roomId] })); - return; - } - } catch (err) { - console.error("グループ状態の読み込みに失敗しました", err); - } - const pair = await ensureKeyPair(); - if (!pair) return; - let initState: StoredGroupState | undefined; - try { - // アクターURLを identity に用いた正しい Credential で生成 - const actor = - new URL(`/users/${user.userName}`, globalThis.location.origin).href; - const created = await createMLSGroup(actor); - initState = created.state; - } catch (e) { - console.error( - "グループ初期化時にキーからの初期化に失敗しました", - e, - ); - } - if (initState) { - setGroups((prev) => ({ - ...prev, - [roomId]: initState, - })); - await saveGroupStates(); - } - } catch (e) { - console.error("ローカルグループ初期化に失敗しました", e); - } - }; - - const [isGeneratingKeyPair, setIsGeneratingKeyPair] = createSignal(false); - - const ensureKeyPair = async (): Promise => { - if (isGeneratingKeyPair()) return null; - - let pair: GeneratedKeyPair | null = keyPair(); - const user = account(); - if (!user) return null; - if (!pair) { - setIsGeneratingKeyPair(true); - try { - pair = await loadMLSKeyPair(user.id); - } catch (err) { - console.error("鍵ペアの読み込みに失敗しました", err); - pair = null; - } - if (!pair) { - // MLS の identity はアクターURLを用いる(外部連合との整合性維持) - const actor = - new URL(`/users/${user.userName}`, globalThis.location.origin).href; - const kp = await generateKeyPair(actor); - pair = { public: kp.public, private: kp.private, encoded: kp.encoded }; - try { - await saveMLSKeyPair(user.id, pair); - await addKeyPackage(user.userName, { content: kp.encoded }); - } catch (err) { - console.error("鍵ペアの保存に失敗しました", err); - setIsGeneratingKeyPair(false); - return null; - } - } - setKeyPair(pair); - setIsGeneratingKeyPair(false); - } - return pair; - }; - - // Handshake の再取得カーソルは ID ではなく時刻ベースで管理(APIが createdAt を after に使うため) - const lastHandshakeId = new Map(); - - async function syncHandshakes(room: Room) { - const user = account(); - if (!user) return; - let group = groups()[room.id]; - if (!group) { - await initGroupState(room.id); - group = groups()[room.id]; - if (!group) return; - } - const after = lastHandshakeId.get(room.id); - const hs = await fetchHandshakes( - room.id, - after ? { limit: 100, after } : { limit: 100 }, - ); - if (hs.length === 0) return; - const ordered = [...hs].sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - let updated = false; - for (const h of ordered) { - const body = decodePublicMessage(h.message); - if (!body) continue; - try { - try { - const dec = decodeMlsMessage(body, 0)?.[0]; - if (dec && dec.wireformat === "mls_public_message") { - group = await processCommit( - group, - dec.publicMessage as unknown as never, - ); - updated = true; - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a commit */ - } - try { - const dec = decodeMlsMessage(body, 0)?.[0]; - if (dec && dec.wireformat === "mls_public_message") { - group = await processProposal( - group, - dec.publicMessage as unknown as never, - ); - updated = true; - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a proposal */ - } - try { - const obj = JSON.parse(new TextDecoder().decode(body)); - if (obj?.type === "welcome" && Array.isArray(obj.data)) { - const wBytes = new Uint8Array(obj.data as number[]); - const ok = await verifyWelcome(wBytes); - if (!ok) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "warning", - title: "無視しました", - description: - "不正なWelcomeメッセージを受信したため無視しました", - }, - }), - ); - } else { - // 参加はユーザーの同意後に行うため保留に入れる - setPendingWelcomes((prev) => ({ ...prev, [room.id]: wBytes })); - } - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - if (obj?.type === "RosterEvidence") { - const ev = obj as RosterEvidence; - const okEv = await importRosterEvidence( - user.id, - room.id, - ev, - ); - if (okEv) { - await appendRosterEvidence(user.id, room.id, [ev]); - const actor = actorUrl(); - if (actor && ev.actor === actor) { - await assessBinding( - user.id, - room.id, - actor, - ev.leafSignatureKeyFpr, - ); - } - } - lastHandshakeId.set(room.id, String(h.createdAt)); - continue; - } - } catch { - /* not a JSON handshake */ - } - } catch (e) { - console.warn("handshake apply failed", e); - } - } - if (updated) { - setGroups({ ...groups(), [room.id]: group }); - await saveGroupStates(); - } - } - - const fetchMessagesForRoom = async ( - room: Room, - params?: { - limit?: number; - before?: string; - after?: string; - dryRun?: boolean; - }, - ): Promise => { - const user = account(); - if (!user) return []; - if (room.type === "memo") { - const list = await fetchKeepMessages( - `${user.userName}@${getDomain()}`, - params, - ); - const msgs = list.map((m) => ({ - id: m.id, - author: `${user.userName}@${getDomain()}`, - displayName: user.displayName || user.userName, - address: `${user.userName}@${getDomain()}`, - content: m.content, - timestamp: new Date(m.createdAt), - type: "text" as const, - isMe: true, - avatar: room.avatar, - })); - return msgs.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - } - const encryptedMsgs: ChatMessage[] = []; - const isDryRun = Boolean(params?.dryRun); - let group = groups()[room.id]; - if (!group) { - await initGroupState(room.id); - group = groups()[room.id]; - if (!group) return []; - } - await syncHandshakes(room); - group = groups()[room.id]; - const selfHandle = `${user.userName}@${getDomain()}`; - const participantsNow = extractMembers(group) - .map((x) => normalizeHandle(x) ?? x) - .filter((v): v is string => !!v); - const isJoined = participantsNow.includes(selfHandle); - if (!isJoined && room.type !== "memo") { - // 未参加(招待のみ)の場合は復号を試みず空で返す(UI側で招待状態を表示) - return []; - } - const list = await fetchEncryptedMessages( - room.id, - `${user.userName}@${getDomain()}`, - params, - ); - // 復号は古い順に処理しないとラチェットが進まず失敗するため昇順で処理 - const ordered = [...list].sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - for (const m of ordered) { - const data = b64ToBuf(m.content); - let res: { plaintext: Uint8Array; state: StoredGroupState } | null = null; - try { - console.debug("[decrypt] attempt", { - id: m.id, - room: room.id, - from: m.from, - mediaType: m.mediaType, - encoding: m.encoding, - contentLen: m.content ? m.content.length : 0, - }); - try { - const peek = decodeMlsMessage(data, 0)?.[0]; - console.debug("[decrypt] peekWireformat", { - id: m.id, - wireformat: peek?.wireformat, - }); - } catch (e) { - console.debug("[decrypt] peek failed", { id: m.id, err: e }); - } - res = await decryptMessage(group, data); - console.debug("[decrypt] result", { - id: m.id, - ok: !!res, - updatedState: !!res?.state, - }); - } catch (err) { - if (err instanceof DOMException && err.name === "OperationError") { - // DOMException(OperationError) は対象メッセージをスキップ - console.error("decryptMessage OperationError", err, { - id: m.id, - room: room.id, - message: err.message, - }); - continue; - } - console.error("decryptMessage failed", err, { - id: m.id, - room: room.id, - }); - } - if (!res) { - const isMe = m.from === `${user.userName}@${getDomain()}`; - // 自分発の暗号文で復号に失敗した場合はプレースホルダを表示せずスキップ - // (送信直後の世代ズレなど一時的要因で発生し得るが、後続の差分取得で解消される) - if (isMe) { - continue; - } - try { - const peek2 = decodeMlsMessage(b64ToBuf(m.content), 0)?.[0]; - console.warn("[decrypt] failed -> placeholder", { - id: m.id, - room: room.id, - from: m.from, - mediaType: m.mediaType, - encoding: m.encoding, - contentLen: m.content ? m.content.length : 0, - peekWireformat: peek2?.wireformat, - }); - } catch (e) { - console.warn("[decrypt] failed -> placeholder (peek failed)", { - id: m.id, - room: room.id, - err: e, - }); - } - if (!isMe) updatePeerHandle(room.id, m.from); - const selfH = `${user.userName}@${getDomain()}`; - const baseName = room.displayName ?? room.name; - const otherName = (!baseName || baseName === user.displayName || - baseName === user.userName || baseName === selfH) - ? m.from - : baseName; - const displayName = isMe - ? (user.displayName || user.userName) - : otherName; - // 復号できない暗号文はプレースホルダ表示 (後で再同期時に再取得対象) - encryptedMsgs.push({ - id: m.id, - author: m.from, - displayName, - address: m.from, - content: "[未復号]", // m.content そのまま出さない - timestamp: new Date(m.createdAt), - type: "text", - isMe, - avatar: room.avatar, - }); - continue; - } - group = res.state; - const plaintextStr = new TextDecoder().decode(res.plaintext); - // joinAck は UI に表示しない - if (isJoinAckText(plaintextStr)) { - continue; - } - const note = parseActivityPubNote(plaintextStr); - const text = note.content; - const listAtt = Array.isArray(m.attachments) - ? m.attachments - : note.attachments; - let attachments: - | { - data?: string; - url?: string; - mediaType: string; - preview?: { - url?: string; - data?: string; - mediaType?: string; - key?: string; - iv?: string; - }; - }[] - | undefined; - if (Array.isArray(listAtt)) { - attachments = []; - for (const at of listAtt) { - if (typeof at.url === "string") { - const attachmentItem = at as typeof at & { - preview?: ActivityPubPreview; - }; - const mt = typeof attachmentItem.mediaType === "string" - ? attachmentItem.mediaType - : "application/octet-stream"; - let preview; - if ( - attachmentItem.preview && - typeof attachmentItem.preview.url === "string" - ) { - const previewItem = attachmentItem.preview; - const pmt = typeof previewItem.mediaType === "string" - ? previewItem.mediaType - : "image/jpeg"; - try { - const pres = await fetch(previewItem.url); - let pbuf = await pres.arrayBuffer(); - if ( - typeof previewItem.key === "string" && - typeof previewItem.iv === "string" - ) { - pbuf = await decryptFile( - pbuf, - previewItem.key, - previewItem.iv, - ); - } - preview = { url: bufToUrl(pbuf, pmt), mediaType: pmt }; - } catch { - preview = { url: previewItem.url, mediaType: pmt }; - } - } - try { - const res = await fetch(attachmentItem.url); - let buf = await res.arrayBuffer(); - if ( - typeof attachmentItem.key === "string" && - typeof attachmentItem.iv === "string" - ) { - buf = await decryptFile( - buf, - attachmentItem.key, - attachmentItem.iv, - ); - } - if ( - mt.startsWith("video/") || - mt.startsWith("audio/") || - buf.byteLength > 1024 * 1024 - ) { - attachments.push({ - url: bufToUrl(buf, mt), - mediaType: mt, - preview, - }); - } else { - attachments.push({ - data: bufToB64(buf), - mediaType: mt, - preview, - }); - } - } catch { - attachments.push({ - url: attachmentItem.url, - mediaType: mt, - preview, - }); - } - } - } - } - const fullId = `${user.userName}@${getDomain()}`; - const isMe = m.from === fullId; - if (!isMe) updatePeerHandle(room.id, m.from); - const selfH2 = `${user.userName}@${getDomain()}`; - const baseName2 = room.displayName ?? room.name; - const otherName = (!baseName2 || baseName2 === user.displayName || - baseName2 === user.userName || baseName2 === selfH2) - ? m.from - : baseName2; - const displayName = isMe - ? (user.displayName || user.userName) - : otherName; - encryptedMsgs.push({ - id: m.id, - author: m.from, - displayName, - address: m.from, - content: text, - attachments, - timestamp: new Date(m.createdAt), - type: attachments && attachments.length > 0 - ? attachments[0].mediaType.startsWith("image/") ? "image" : "file" - : "text", - isMe, - avatar: room.avatar, - }); - } - const msgs = encryptedMsgs.sort((a, b) => - a.timestamp.getTime() - b.timestamp.getTime() - ); - if (!isDryRun) { - setGroups({ ...groups(), [room.id]: group }); - saveGroupStates(); - // 参加メンバーに合わせて招待中を整流化 - try { - const acc = account(); - if (acc) { - const participants = extractMembers(group).map((x) => - normalizeHandle(x) ?? x - ).filter((v): v is string => !!v); - await syncPendingWithParticipants(acc.id, room.id, participants); - } - } catch { - console.error("参加メンバーの同期に失敗しました"); - } - } - return msgs; - }; - - const loadMessages = async (room: Room, _isSelectedRoom: boolean) => { - const user = account(); - const cached = messagesByRoom()[roomCacheKey(room.id)] ?? ( - user - ? (await loadDecryptedMessages(user.id, room.id)) ?? undefined - : undefined - ); - if (cached && selectedRoom() === room.id) { - setMessages(cached); - if (cached.length > 0) { - setCursor(cached[0].timestamp.toISOString()); - updateRoomLast(room.id, cached[cached.length - 1]); - } else { - setCursor(null); - } - // 差分のみ取得(最新のタイムスタンプ以降) - const lastTs = cached.length > 0 - ? cached[cached.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: messageLimit }, - ); - if (fetched.length > 0) { - const ids = new Set(cached.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - if (add.length > 0) { - const next = [...cached, ...add]; - setMessages(next); - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - if (user) await saveDecryptedMessages(user.id, room.id, next); - updateRoomLast(room.id, next[next.length - 1]); - } - } - setHasMore(cached.length >= messageLimit); - return; - } - const msgs = await fetchMessagesForRoom(room, { limit: messageLimit }); - setMessagesByRoom({ ...messagesByRoom(), [roomCacheKey(room.id)]: msgs }); - if (user) await saveDecryptedMessages(user.id, room.id, msgs); - if (msgs.length > 0) { - setCursor(msgs[0].timestamp.toISOString()); - } else { - setCursor(null); - } - setHasMore(msgs.length === messageLimit); - if (selectedRoom() === room.id) { - setMessages(msgs); - } - const lastMessage = msgs.length > 0 ? msgs[msgs.length - 1] : undefined; - updateRoomLast(room.id, lastMessage); - // 招待のみで未参加なら送信を抑止(参加後に自動解除) - try { - const g = groups()[room.id]; - if (g && user) { - const selfHandle = `${user.userName}@${getDomain()}`; - const members = extractMembers(g).map((x) => normalizeHandle(x) ?? x) - .filter((v): v is string => !!v); - setPartnerHasKey(members.includes(selfHandle)); - } - } catch { /* ignore */ } - }; - - const loadOlderMessages = async (room: Room) => { - if (!hasMore() || loadingOlder()) return; - setLoadingOlder(true); - const msgs = await fetchMessagesForRoom(room, { - limit: messageLimit, - before: cursor() ?? undefined, - }); - if (msgs.length > 0 && selectedRoom() === room.id) { - setCursor(msgs[0].timestamp.toISOString()); - setMessages((prev) => { - const next = [...msgs, ...prev]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - } - setHasMore(msgs.length === messageLimit); - setLoadingOlder(false); - }; - - const extractMembers = (state: StoredGroupState): string[] => { - const list: string[] = []; - const tree = state.ratchetTree as unknown as { - nodeType: string; - leaf?: { credential?: { identity?: Uint8Array } }; - }[]; - for (const node of tree) { - if (node?.nodeType === "leaf") { - const id = node.leaf?.credential?.identity; - if (id) list.push(new TextDecoder().decode(id)); - } - } - return list; - }; const loadRooms = async () => { const user = account(); if (!user) return; - const rooms: Room[] = [ - { - id: "memo", - name: "TAKO Keep", - userName: user.userName, - domain: getDomain(), - avatar: "📝", - unreadCount: 0, - type: "memo", - members: [`${user.userName}@${getDomain()}`], - lastMessage: "...", - lastMessageTime: undefined, - }, - ]; - const handle = `${user.userName}@${getDomain()}` as ActorID; - // 暗黙のルーム(メッセージ由来)は除外して、明示的に作成されたもののみ取得 - const serverRooms = await searchRooms(user.id, { implicit: "include" }); - for (const item of serverRooms) { - const state = groups()[item.id]; - const name = ""; - const icon = ""; - // 参加者は MLS の leaf から導出。MLS が未同期の場合は pending 招待から暫定的に補完(UI表示用) - let members = state - ? extractMembers(state) - .map((m) => normalizeHandle(m as ActorID) ?? m) - .filter((m) => (normalizeHandle(m as ActorID) ?? m) !== handle) - : [] as string[]; - if (members.length === 0) { - try { - const pend = await readPending(user.id, item.id); - const others = (pend || []).filter((m) => m && m !== handle); - if (others.length > 0) members = others; - } catch { - /* ignore */ - } - } - rooms.push({ - id: item.id, - name, - userName: user.userName, - domain: getDomain(), - avatar: icon || (name ? name.charAt(0).toUpperCase() : "👥"), - unreadCount: 0, - type: "group", - members, - hasName: false, - hasIcon: false, - lastMessage: "...", - lastMessageTime: undefined, - }); - } - - await applyDisplayFallback(rooms); - - const unique = rooms.filter( - (room, idx, arr) => arr.findIndex((r) => r.id === room.id) === idx, - ); - setChatRooms(unique); - // 初期表示のため、各ルームの最新メッセージをバックグラウンドで取得し一覧のプレビューを更新 - // (選択中ルーム以外は本文状態には反映せず、lastMessage/lastMessageTime のみ更新) - void (async () => { - for (const r of unique) { - try { - const msgs = await fetchMessagesForRoom(r, { - limit: 1, - dryRun: true, - }); - if (msgs.length > 0) { - updateRoomLast(r.id, msgs[msgs.length - 1]); - } - } catch (e) { - // ネットワーク不通や復号不可などは致命的ではないため一覧更新のみ諦める - console.warn("最新メッセージの事前取得に失敗しました", r.id, e); - } - } - })(); - }; - - const applyDisplayFallback = async (rooms: Room[]) => { - const user = account(); - if (!user) return; - const selfHandle = `${user.userName}@${getDomain()}` as ActorID; - // 参加者は MLS の leaf から導出済みの room.members のみを信頼(APIやpendingは使わない) - const uniqueOthers = (r: Room): string[] => - (r.members ?? []).filter((m) => m && m !== selfHandle); - - // MLS 同期前の暫定表示: members が空のルームは pending 招待から1名だけでも補完 - for (const r of rooms) { - try { - if ((r.members?.length ?? 0) === 0 && r.type !== "memo") { - const pend = await readPending(user.id, r.id); - const cand = (pend || []).filter((m) => m && m !== selfHandle); - if (cand.length > 0) { - r.members = [cand[0]]; - } - } - } catch { - // ignore - } - } - const totalMembers = (r: Room) => 1 + uniqueOthers(r).length; // 自分+その他 - // 事前補正: 2人想定で名前が自分の表示名/ユーザー名のときは未命名として扱う - for (const r of rooms) { - if (r.type === "memo") continue; - const others = uniqueOthers(r); - // 自分の名前がタイトルに入ってしまう誤表示を防止(相手1人または未確定0人のとき) - if ( - others.length <= 1 && - (r.name === user.displayName || r.name === user.userName) - ) { - r.displayName = ""; - r.hasName = false; - // アバターが自分の頭文字(1文字)なら一旦消して再計算に委ねる - const selfInitial = (user.displayName || user.userName || "").charAt(0) - .toUpperCase(); - if ( - typeof r.avatar === "string" && r.avatar.length === 1 && - r.avatar.toUpperCase() === selfInitial - ) { - r.avatar = ""; - } - } - } - - const twoNoName = rooms.filter((r) => - r.type !== "memo" && totalMembers(r) === 2 && !(r.hasName || r.hasIcon) - ); - const ids = twoNoName - .map((r) => uniqueOthers(r)[0]) - .filter((v): v is string => !!v); - if (ids.length > 0) { - const infos = await fetchUserInfoBatch(ids, user.id); - for (let i = 0; i < twoNoName.length; i++) { - const info = infos[i]; - const r = twoNoName[i]; - if (info) { - r.displayName = info.displayName || info.userName; - r.avatar = info.authorAvatar || r.avatar; - // 参加者リストは MLS 由来を保持する(表示名のみ補完) - } - } - } - // 3人以上の自動生成(簡易) - const multi = rooms.filter((r) => - r.type !== "memo" && totalMembers(r) >= 3 && !(r.hasName) - ); - const needIds = Array.from(new Set(multi.flatMap((r) => uniqueOthers(r)))); - if (needIds.length > 0) { - const infos = await fetchUserInfoBatch(needIds, user.id); - const map = new Map(); - for (let i = 0; i < needIds.length; i++) map.set(needIds[i], infos[i]); - for (const r of multi) { - const names = uniqueOthers(r).map((m) => - map.get(m)?.displayName || map.get(m)?.userName - ).filter(Boolean) as string[]; - const top = names.slice(0, 2); - const rest = Math.max(0, names.length + 1 - top.length - 1); // +1 = 自分 - r.displayName = top.length > 0 - ? `${top.join("、")}${rest > 0 ? ` ほか${rest}名` : ""}` - : r.displayName ?? r.name; - r.avatar = r.avatar || "👥"; - } - } - }; - - const openRoomDialog = (friendId?: string) => { - setGroupDialogMode("create"); - setInitialMembers(friendId ? [friendId] : []); - setShowGroupDialog(true); - }; - - const createRoom = async ( - name: string, - membersInput: string, - autoOpen = true, - ) => { - const user = account(); - if (!user) return; - const members = membersInput - .split(",") - .map((m) => normalizeActor(m.trim() as ActorID)) - .filter((m): m is string => !!m); - if (members.length === 0) return; - const me = `${user.userName}@${getDomain()}`; - if (!members.includes(me)) members.push(me); - const others = members.filter((m) => m !== me); - // すべてのトークは同等。毎回新規作成してサーバ保存する - const finalName = (name ?? "").trim(); - - const newId = crypto.randomUUID(); - const room: Room = { - id: newId, - name: finalName || "", + const list = await searchRooms(user.userName); + const self = `${user.userName}@${getDomain()}`; + const roomList: Room[] = list.map((r) => ({ + id: r.id, + name: r.id, userName: user.userName, domain: getDomain(), - avatar: "", unreadCount: 0, type: "group", - // UI表示用に招待先を入れておく(MLS同期後は state 由来に上書きされる) - members: others, - hasName: Boolean(finalName), - hasIcon: false, - lastMessage: "...", - lastMessageTime: undefined, - }; - try { - await applyDisplayFallback([room]); - } catch (e) { - console.error("相手の表示情報取得に失敗しました", e); - } - upsertRoom(room); - await initGroupState(room.id); - try { - await addRoom( - user.id, - { id: room.id, name: room.name, members }, - { from: me, content: "hi", to: members }, - ); - } catch (e) { - console.error("ルーム作成に失敗しました", e); - } - // MLS 即時開始: 可能なら相手の KeyPackage を取得して Add→Commit→Welcome を送信 - try { - const group = groups()[room.id]; - if (group) { - const kpInputs: { - content: string; - actor?: string; - deviceId?: string; - }[] = []; - for (const h of others) { - const [uname, dom] = splitActor(h as ActorID); - const kps = await fetchKeyPackages(uname, dom); - if (kps && kps.length > 0) { - const kp = pickUsableKeyPackage( - kps as unknown as { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - }[], - ); - if (!kp) continue; - const actor = dom ? `https://${dom}/users/${uname}` : undefined; - kpInputs.push({ - content: kp.content, - actor, - deviceId: kp.deviceId, - }); - } - } - if (kpInputs.length > 0) { - const resAdd = await createCommitAndWelcomes(group, kpInputs); - const commitContent = encodePublicMessage(resAdd.commit); - const ok = await sendHandshake( - room.id, - `${user.userName}@${getDomain()}`, - commitContent, - // ルーム作成時は members が最新のロスター - members, - ); - if (ok) { - for (const w of resAdd.welcomes) { - const wContent = encodePublicMessage(w.data); - const wk = await sendHandshake( - room.id, - `${user.userName}@${getDomain()}`, - wContent, - members, - ); - if (!wk) break; - } - let gstate: StoredGroupState = resAdd.state; - const meta = await sendGroupMetadata( - room.id, - `${user.userName}@${getDomain()}`, - gstate, - members, - { name: room.name, icon: room.avatar }, - ); - if (meta) gstate = meta; - setGroups({ ...groups(), [room.id]: gstate }); - saveGroupStates(); - // 招待中として登録(Join後に設定画面で自動的にメンバー側へ移動) - await addPendingInvites(user.id, room.id, others); - } - } - // UI上は常に招待中として表示(Joinしたら自動的にメンバーへ移動) - await addPendingInvites(user.id, room.id, others); - } - } catch (e) { - console.warn("作成時のAdd/Welcome送信に失敗しました", e); - } - if (autoOpen) setSelectedRoom(room.id); - setShowGroupDialog(false); - }; - - const removeActorLeaves = async (actorId: string): Promise => { - const roomId = selectedRoom(); - const user = account(); - if (!roomId || !user) return false; - const group = groups()[roomId]; - if (!group) return false; - try { - const records = await loadKeyPackageRecords(user.id, roomId); - const indices = Array.from( - new Set( - records.filter((r) => r.actorId === actorId).map((r) => r.leafIndex), - ), - ); - if (indices.length === 0) return false; - const res = await removeMembers(group, indices); - const content = encodePublicMessage(res.commit); - const room = chatRooms().find((r) => r.id === roomId); - const toList = participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room?.members ?? []).filter((m) => !!m); - const ok = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - content, - toList, - ); - if (!ok) return false; - setGroups({ ...groups(), [roomId]: res.state }); - await saveGroupStates(); - await apiFetch(`/ap/rooms/${encodeURIComponent(roomId)}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: "Remove", object: actorId }), - }); - return true; - } catch (e) { - console.error("メンバー削除に失敗しました", e); - return false; - } - }; - - const sendMessage = async () => { - const text = newMessage().trim(); - const roomId = selectedRoom(); - const user = account(); - if (!text && !mediaFile() || !roomId || !user) return; - const room = chatRooms().find((r) => r.id === roomId); - if (!room) return; - if (room.type === "memo") { - const res = await sendKeepMessage( - `${user.userName}@${getDomain()}`, - text, - ); - if (!res) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "保存エラー", - description: "メモの保存に失敗しました", - }, - }), - ); - return; - } - const msg: ChatMessage = { - id: res.id, - author: `${user.userName}@${getDomain()}`, - displayName: user.displayName || user.userName, - address: `${user.userName}@${getDomain()}`, - content: res.content, - timestamp: new Date(res.createdAt), - type: "text", - isMe: true, - avatar: room.avatar, - }; - // まだメモが選択中かを確認してからUIに反映 - if (selectedRoom() === room.id) { - setMessages((prev) => [...prev, msg]); - } - // 部屋ごとのキャッシュと永続化を更新 - setMessagesByRoom((prev) => { - const key = roomCacheKey(room.id); - const list = (prev[key] ?? []).concat(msg); - const next = { ...prev, [key]: list }; - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, room.id, list); - return next; - }); - setNewMessage(""); - setMediaFile(null); - setMediaPreview(null); - return; - } - if (!partnerHasKey()) { - alert("このユーザーは暗号化された会話に対応していません。"); - return; - } - // クライアント側で仮のメッセージIDを生成しておく - const localId = crypto.randomUUID(); - const note: Record = { - "@context": "https://www.w3.org/ns/activitystreams", - type: "Note", - id: `urn:uuid:${localId}`, - content: text, - }; - if (mediaFile()) { - const file = mediaFile()!; - const att = await buildAttachment(file); - if (att) note.attachment = [att]; - } - let group = groups()[roomId]; - if (!group) { - await initGroupState(roomId); - group = groups()[roomId]; - if (!group) { - alert("グループ初期化に失敗したため送信できません"); - return; - } - } - // 必要であれば、相手の KeyPackage を使って Add→Commit→Welcome を先行送信 - try { - const self = `${user.userName}@${getDomain()}`; - const current = participantsFromState(roomId); - const targets = (room.members ?? []).filter((m) => m && m !== self); - const need = targets.filter((t) => !current.includes(t)); - if (need.length > 0) { - const kpInputs: { - content: string; - actor?: string; - deviceId?: string; - }[] = []; - for (const h of need) { - const [uname, dom] = splitActor(h as ActorID); - const kps = await fetchKeyPackages(uname, dom); - if (kps && kps.length > 0) { - const kp = pickUsableKeyPackage( - kps as unknown as { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - }[], - ); - if (!kp) continue; - const actor = dom ? `https://${dom}/users/${uname}` : undefined; - kpInputs.push({ - content: kp.content, - actor, - deviceId: kp.deviceId, - }); - } - } - if (kpInputs.length > 0) { - const resAdd = await createCommitAndWelcomes(group, kpInputs); - const commitContent = encodePublicMessage(resAdd.commit); - const toList = Array.from( - new Set([ - ...current, - ...need, - self, - ]), - ); - const ok = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - commitContent, - toList, - ); - if (!ok) throw new Error("Commit送信に失敗しました"); - for (const w of resAdd.welcomes) { - const wContent = encodePublicMessage(w.data); - const wk = await sendHandshake( - roomId, - `${user.userName}@${getDomain()}`, - wContent, - toList, - ); - if (!wk) throw new Error("Welcome送信に失敗しました"); - } - let gstate: StoredGroupState = resAdd.state; - const meta = await sendGroupMetadata( - roomId, - `${user.userName}@${getDomain()}`, - gstate, - toList, - { name: room.name, icon: room.avatar }, - ); - if (meta) gstate = meta; - group = gstate; - setGroups({ ...groups(), [roomId]: group }); - saveGroupStates(); - try { - const acc = account(); - if (acc) { - const participants = extractMembers(group).map((x) => - normalizeHandle(x) ?? x - ).filter((v): v is string => !!v); - await syncPendingWithParticipants(acc.id, roomId, participants); - } - } catch { - console.error("参加メンバーの同期に失敗しました"); - } - // 招待中に登録 - await addPendingInvites(user.id, roomId, need); - } - // UI上は常に招待中として表示 - await addPendingInvites(user.id, roomId, need); - } - } catch (e) { - console.warn("初回Add/Welcome処理に失敗しました", e); - } - // joinAck をルーム/端末ごとに一度だけ送る(永続化して再送を防止) - const ackCacheKey = `ackSent:${roomId}`; - try { - const sent = await getCacheItem(user.id, ackCacheKey); - if (!sent) { - const ackBody = JSON.stringify({ - type: "joinAck", - roomId, - deviceId: user.id, - }); - const ack = await encryptMessage(group, ackBody); - const ok = await sendEncryptedMessage( - roomId, - `${user.userName}@${getDomain()}`, - participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room.members ?? []).map((m) => m || "").filter((v) => !!v), - { - content: bufToB64(ack.message), - mediaType: "message/mls", - encoding: "base64", - }, - ); - if (ok) { - group = ack.state; - setGroups({ ...groups(), [roomId]: group }); - saveGroupStates(); - await setCacheItem(user.id, ackCacheKey, true); - } - } - } catch (e) { - console.warn("joinAck の送信または永続化に失敗しました", e); - } - const msgEnc = await encryptMessage(group, JSON.stringify(note)); - let success = true; - { - const ok = await sendEncryptedMessage( - roomId, - `${user.userName}@${getDomain()}`, - participantsFromState(roomId).length > 0 - ? participantsFromState(roomId) - : (room.members ?? []).map((m) => m || "").filter((v) => !!v), - { - content: bufToB64(msgEnc.message), - mediaType: "message/mls", - encoding: "base64", - }, - ); - if (!ok) success = false; - } - if (!success) { - alert("メッセージの送信に失敗しました"); - return; - } - setGroups({ ...groups(), [roomId]: msgEnc.state }); - saveGroupStates(); - - // 楽観的に自分の送信メッセージをUIへ即時反映(再取得は行わない) - try { - const meHandle = `${user.userName}@${getDomain()}`; - const dispName = user.displayName || user.userName; - let attachmentsUi: { - data?: string; - url?: string; - mediaType: string; - preview?: { url?: string; data?: string; mediaType?: string }; - }[] | undefined; - if (mediaFile()) { - const file = mediaFile()!; - const purl = mediaPreview(); - attachmentsUi = [{ - mediaType: file.type || "application/octet-stream", - ...(purl ? { url: purl } : {}), - }]; - } - const optimistic: ChatMessage = { - id: localId, - author: meHandle, - displayName: dispName, - address: meHandle, - content: text, - attachments: attachmentsUi, - timestamp: new Date(), - type: attachmentsUi && attachmentsUi.length > 0 - ? attachmentsUi[0].mediaType.startsWith("image/") ? "image" : "file" - : "text", - isMe: true, - avatar: room.avatar, - }; - setMessages((old) => { - const next = [...old, optimistic]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(roomId)]: next, - }); - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, roomId, next); - return next; - }); - updateRoomLast(roomId, optimistic); - } catch (e) { - console.warn("楽観表示の反映に失敗しました", e); - } - - // 入力欄と選択中のメディアをクリア - setNewMessage(""); - setMediaFile(null); - setMediaPreview(null); - }; - - // 画面サイズ検出 - const checkMobile = () => { - setIsMobile(globalThis.innerWidth < 768); + members: [self], + })); + setRooms(roomList); }; - // モバイルでの部屋選択時の動作 - const selectRoom = (roomId: string) => { - console.log("selected room:", roomId); // for debug - setSelectedRoom(roomId); - if (isMobile()) { - setShowRoomList(false); // モバイルではチャット画面に切り替え - } - // メッセージの取得は selectedRoom 監視の createEffect に任せる - }; - - // チャット一覧に戻る(モバイル用) - const backToRoomList = () => { - setShowRoomList(true); - setSelectedRoom(null); // チャンネル選択状態をリセット - }; - - // イベントリスナーの設定 - onMount(() => { - checkMobile(); - globalThis.addEventListener("resize", checkMobile); - // ルーム情報はアカウント取得後の createEffect で読み込む - loadGroupStates(); - ensureKeyPair(); - - // WebSocket からのメッセージを安全に型ガードして処理する - interface IncomingAttachment { - url: string; - mediaType: string; - key?: string; - iv?: string; - preview?: { url?: string; data?: string; mediaType?: string }; - } - // WS はトリガー用: 本文は含まれない想定 - interface IncomingPayload { - id: string; - roomId?: string; - from: string; - to: string[]; - createdAt: string; - // 旧仕様互換のため任意に残す(存在しても使わない) - content?: string; - mediaType?: string; - encoding?: string; - attachments?: IncomingAttachment[]; - } - interface HandshakePayload { - id: string; - roomId: string; - sender: string; - recipients: string[]; - createdAt: string; - } - type IncomingMessage = - | { type: "handshake"; payload: HandshakePayload } - | { - type: "encryptedMessage" | "publicMessage"; - payload: IncomingPayload; - }; - const isStringArray = (v: unknown): v is string[] => - Array.isArray(v) && v.every((x) => typeof x === "string"); - - const isAttachment = (v: unknown): v is IncomingAttachment => - typeof v === "object" && - v !== null && - typeof (v as { url?: unknown }).url === "string" && - typeof (v as { mediaType?: unknown }).mediaType === "string" && - (typeof (v as { key?: unknown }).key === "string" || - typeof (v as { key?: unknown }).key === "undefined") && - (typeof (v as { iv?: unknown }).iv === "string" || - typeof (v as { iv?: unknown }).iv === "undefined"); - - const isPayload = (v: unknown): v is IncomingPayload => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - const base = typeof o.id === "string" && - typeof o.from === "string" && - isStringArray(o.to) && - typeof o.createdAt === "string"; - if (!base) return false; - if (typeof o.attachments === "undefined") return true; - return Array.isArray(o.attachments) && o.attachments.every(isAttachment); - }; - - const isHandshakePayload = (v: unknown): v is HandshakePayload => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - return typeof o.id === "string" && - typeof o.roomId === "string" && - typeof o.sender === "string" && - isStringArray(o.recipients) && - typeof o.createdAt === "string"; - }; - - const isIncomingMessage = (v: unknown): v is IncomingMessage => { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - const t = o.type; - if (t === "handshake") return isHandshakePayload(o.payload); - if (t !== "encryptedMessage" && t !== "publicMessage") return false; - return isPayload(o.payload); - }; - - const handler = async (msg: unknown) => { - // WS 経由で送られる pendingInvite は isIncomingMessage に含まれないため - // 先に専用に処理する(チャット一覧へプレースホルダを作成して同期する) - try { - if (typeof msg === "object" && msg !== null) { - const m = msg as Record; - if (typeof m.type === "string" && m.type === "pendingInvite") { - const payload = m.payload as Record | undefined; - if (payload && typeof payload.roomId === "string") { - const user = account(); - if (!user) return; - const self = `${user.userName}@${getDomain()}`; - // 既に一覧にあれば同期処理だけ行う - let room = chatRooms().find((r) => r.id === payload.roomId); - if (!room) { - const maybeFrom = typeof payload.from === "string" - ? payload.from - : undefined; - const others = Array.from( - new Set( - [maybeFrom].filter((m): m is string => - typeof m === "string" && m !== self - ), - ), - ); - const newRoom = { - id: payload.roomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: others, - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(newRoom); - try { - await applyDisplayFallback([newRoom]); - } catch { /* ignore */ } - await initGroupState(newRoom.id); - room = newRoom; - } - if (room) await syncHandshakes(room); - } - return; - } - } - } catch (e) { - console.warn("failed to handle pendingInvite message", e); - } - - if (!isIncomingMessage(msg)) { - // 想定外のメッセージは無視 - return; - } - const user = account(); - if (!user) return; - const self = `${user.userName}@${getDomain()}`; - - if (msg.type === "handshake") { - const data = msg.payload; - if (!(data.recipients.includes(self) || data.sender === self)) { - return; - } - - // 招待元がフォロー中かどうかを先に判定 - let isFollowing = false; - try { - const me = account(); - if (me) { - const following = await fetchFollowing(me.userName); - isFollowing = Array.isArray(following) - ? following.some((u: string) => - u === data.sender || u === normalizeActor(data.sender) - ) - : false; - } - } catch { - // 判定失敗時はフォロー外として扱う - isFollowing = false; - } - // 自分が送信者(招待した側)の場合は通知しない - if (data.sender === self) { - isFollowing = true; - } - - if (!isFollowing) { - // フォロー外の招待はサーバー側で通知化(ここでは案内のみ) - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "info", - title: "会話招待", - description: - `${data.sender} から会話招待が届きました(フォロー外)。通知に表示します。`, - duration: 5000, - }, - }), - ); - // フォロー外の場合は自動参加・同期しない - return; - } - - // フォロー中ならチャット一覧にプレースホルダを作成して同期 - let room = chatRooms().find((r) => r.id === data.roomId); - if (!room) { - const others = Array.from( - new Set([ - ...data.recipients, - data.sender, - ].filter((m) => m && m !== self)), - ); - room = { - id: data.roomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: others, - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - try { - await applyDisplayFallback([room]); - } catch { /* ignore */ } - await initGroupState(room.id); - } - if (room) await syncHandshakes(room); - return; - } - - const data = msg.payload; - // フィルタ: 自分宛て/自分発でないメッセージは無視 - if (!(data.to.includes(self) || data.from === self)) { - return; - } - - // まず roomId が来ていればそれで特定する(UUIDグループ等に強い) - let room = data.roomId - ? chatRooms().find((r) => r.id === data.roomId) - : undefined; - - const partnerId = data.from === self - ? (data.to.find((v) => v !== self) ?? data.to[0]) - : data.from; - - const normalizedPartner = normalizeActor(partnerId); - const [partnerName] = splitActor(normalizedPartner); - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!room) room = chatRooms().find((r) => r.id === partnerName); - if (!room) { - for (const t of data.to) { - const normalized = normalizeActor(t); - const [toName] = splitActor(normalized); - const g = chatRooms().find((r) => r.id === toName); - if (g) { - room = g; - break; - } - } - } - // 名前付き1:1ルームなど、IDがパートナーと一致しない場合のフォールバック - if (!room) { - room = chatRooms().find((r) => - (r.members?.length ?? 0) === 1 && - r.members.includes(normalizedPartner) - ); - } - if (!room && uuidRe.test(partnerName)) { - // グループIDと推測されるがまだ一覧に存在しない場合はルームを作成しない - return; - } - if (!room) { - room = chatRooms().find((r) => r.id === normalizedPartner); - if (!room) { - if ( - confirm( - `${normalizedPartner} からメッセージが届きました。許可しますか?`, - ) - ) { - const info = await fetchUserInfo(normalizeActor(normalizedPartner)); - if (info) { - room = { - id: normalizedPartner, - name: "", - displayName: info.displayName || info.userName, - userName: info.userName, - domain: info.domain, - avatar: info.authorAvatar || - info.userName.charAt(0).toUpperCase(), - unreadCount: 0, - type: "group" as const, - members: [normalizedPartner], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room!); - } else { - return; - } - } else { - return; - } - } - } - - const isMe = data.from === self; - if (!isMe) updatePeerHandle(room.id, data.from); - const selfH3 = `${user.userName}@${getDomain()}`; - const baseName3 = room.displayName ?? room.name; - const otherName = (!baseName3 || baseName3 === user.displayName || - baseName3 === user.userName || baseName3 === selfH3) - ? data.from - : baseName3; - const _displayName = isMe - ? (user.displayName || user.userName) - : otherName; - const _text: string = ""; - const _attachments: - | { - data?: string; - url?: string; - mediaType: string; - preview?: { url?: string; data?: string; mediaType?: string }; - }[] - | undefined = undefined; - const _localId: string | undefined = undefined; - - // WSは通知のみ: RESTから取得して反映 - if (msg.type === "encryptedMessage") { - // 自分が送信した直後の通知は再取得せず無視(ラチェット巻き戻り防止) - if (msg.payload.from === self) { - return; - } - const _isSelected = selectedRoom() === room.id; - if (room.type === "memo") return; // メモはWS対象外 - if (selectedRoom() === room.id) { - const prev = messages(); - const lastTs = prev.length > 0 - ? prev[prev.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: 1 }, - ); - if (fetched.length > 0 && selectedRoom() === room.id) { - setMessages((old) => { - const ids = new Set(old.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - const next = [...old, ...add]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - const last = fetched[fetched.length - 1]; - updateRoomLast(room.id, last); - } - } else { - // 一覧のみ更新(最新1件を取得してプレビュー) - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - updateRoomLast(room.id, fetched[fetched.length - 1]); - } - } - return; - } - - // publicMessage 等の将来拡張が来た場合はRESTで取得する - if (room.type === "memo") return; // メモはWS対象外 - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - const last = fetched[fetched.length - 1]; - if (selectedRoom() === room.id) { - setMessages((prev) => { - if (prev.some((x) => x.id === last.id)) return prev; - const next = [...prev, last]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(room.id)]: next, - }); - const user = account(); - if (user) void saveDecryptedMessages(user.id, room.id, next); - return next; - }); - } - updateRoomLast(room.id, last); - } - }; - // 通知画面からの「参加する」操作を受信して処理 - const onAcceptInvite = async (ev: Event) => { - const e = ev as CustomEvent<{ roomId: string; sender?: string }>; - const targetRoomId = e.detail?.roomId; - if (!targetRoomId) return; - const user = account(); - if (!user) return; - // 一覧になければプレースホルダを作成 - let room = chatRooms().find((r) => r.id === targetRoomId); - if (!room) { - room = { - id: targetRoomId, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - await initGroupState(room.id); - } - try { - await syncHandshakes(room); - const w = pendingWelcomes()[room.id]; - if (w) { - const pairs = await loadAllMLSKeyPairs(user.id); - let joined: StoredGroupState | null = null; - const list = pairs.length > 0 - ? pairs - : (await ensureKeyPair() ? [await ensureKeyPair()!] : []); - for (const p of list) { - try { - if (!p) throw new Error("key pair not prepared"); - const st = await joinWithWelcome(w, p); - joined = st; - break; - } catch { /* try next */ } - } - if (joined) { - // 参加成功: 自分の chatrooms に登録 - try { - await addRoom(user.id, { id: room.id }); - } catch { /* ignore */ } - setGroups({ ...groups(), [room.id]: joined }); - await saveGroupStates(); - setPendingWelcomes((prev) => { - const n = { ...prev }; - delete n[room!.id]; - return n; - }); - await loadMessages(room, true); - setSelectedRoom(room.id); - // 招待のACK(任意) - try { - await apiFetch( - `/api/users/${ - encodeURIComponent(user.userName) - }/pendingInvites/ack`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ roomId: room.id, deviceId: "" }), - }, - ); - } catch { /* ignore */ } - // サーバー側の chatrooms 登録反映を一覧に再取得 - try { - await loadRooms(); - } catch { /* ignore */ } - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "success", - title: "参加しました", - description: "会話に参加しました", - }, - }), - ); - } else { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "参加に失敗", - description: "Welcomeの適用に失敗しました", - }, - }), - ); - } - } else { - // Welcome がまだ無い場合はルームを開いて手動参加に委ねる - setSelectedRoom(room.id); - } - } catch (err) { - globalThis.dispatchEvent( - new CustomEvent("app:toast", { - detail: { - type: "error", - title: "参加に失敗", - description: String(err), - }, - }), - ); - } - }; - - globalThis.addEventListener( - "app:accept-invite", - onAcceptInvite as EventListener, - ); - acceptCleanup = () => - globalThis.removeEventListener( - "app:accept-invite", - onAcceptInvite as EventListener, - ); - - addMessageHandler(handler); - wsCleanup = () => removeMessageHandler(handler); - // 初期表示時のメッセージ読み込みも - // selectedRoom 監視の createEffect に任せる - adjustHeight(textareaRef); - }); - - // 保留中招待の同期: 初期ロード時に取得し、その後は WS 通知に任せる - createEffect(() => { - const user = account(); - if (!user) return; - void (async () => { - try { - const list = await fetchPendingInvites(user.userName); - for (const it of list) { - const rid = it.roomId; - if (!rid) continue; - let room = chatRooms().find((r) => r.id === rid); - if (!room) { - room = { - id: rid, - name: "", - userName: user.userName, - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - await initGroupState(room.id); - } - await syncHandshakes(room); - } - } catch { /* ignore */ } - })(); - }); - - // 一覧のプレビュー更新を緩やかにポーリング(最大10件) - let previewPoller: number | undefined; - createEffect(() => { - const user = account(); - if (!user) return; - if (previewPoller) clearInterval(previewPoller); - previewPoller = setInterval(async () => { - try { - const rooms = chatRooms(); - const targets = rooms - .filter((r) => r.type !== "memo") - .slice(0, 10); - for (const r of targets) { - try { - const msgs = await fetchMessagesForRoom(r, { - limit: 1, - dryRun: true, - }); - if (msgs.length > 0) updateRoomLast(r.id, msgs[msgs.length - 1]); - } catch { /* ignore one */ } - } - } catch { /* ignore all */ } - }, 60_000) as unknown as number; - }); - - // ルーム一覧の読み込みはアカウント変更時と初期表示時のみ実行 onMount(() => { void loadRooms(); }); - createEffect( - on( - () => account(), - () => { - void loadRooms(); - }, - ), - ); - - // MLS グループ状態の更新に合わせてメンバー/表示名を補正 - createEffect( - on( - () => groups(), - async () => { - const user = account(); - if (!user) return; - const list = chatRooms(); - if (list.length === 0) return; - - // members を MLS 由来に同期(変更がある場合のみ更新) - let changed = false; - const nextA = list.map((r) => { - if (r.type === "memo") return r; - const parts = participantsFromState(r.id); - if (parts.length === 0) return r; - const cur = r.members ?? []; - const equals = cur.length === parts.length && - cur.every((v, i) => v === parts[i]); - if (!equals) { - changed = true; - return { ...r, members: parts }; - } - return r; - }); - if (changed) setChatRooms(nextA); - - // 1対1・未命名の表示名補完(変更がある場合のみ更新) - // ただし UUID などグループIDのルームは対象外(誤ってDM扱いしない) - const base = changed ? nextA : list; - const uuidRe = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const candidates = base.filter((r) => - r.type !== "memo" && (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon) && !uuidRe.test(r.id) - ); - const ids = candidates.map((r) => r.members[0]).filter(( - v, - ): v is string => !!v); - if (ids.length === 0) return; - try { - const infos = await fetchUserInfoBatch(ids, user.id); - const map = new Map(); - for (let i = 0; i < ids.length; i++) map.set(ids[i], infos[i]); - let nameChanged = false; - const nextB = base.map((r) => { - if ( - r.type === "memo" || !(r.members?.length === 1) || - (r.hasName || r.hasIcon) - ) return r; - const info = map.get(r.members[0]); - if (!info) return r; - const newName = info.displayName || info.userName; - const newAvatar = info.authorAvatar || r.avatar; - if ( - (r.displayName ?? r.name) !== newName || r.avatar !== newAvatar - ) { - nameChanged = true; - return { ...r, displayName: newName, avatar: newAvatar }; - } - return r; - }); - if (nameChanged) setChatRooms(nextB); - } catch { - // ignore - } - }, - ), - ); - createEffect( - on( - () => selectedRoom(), - async (roomId) => { - const selfRoomId = getSelfRoomId(account()); - if (!roomId) { - setMessages([]); - return; - } - - const normalizedRoomId = normalizeActor(roomId); - let room = chatRooms().find((r) => r.id === normalizedRoomId); - - // ルームが存在しない場合は作成を試行 - if (!room && normalizedRoomId !== selfRoomId) { - const info = await fetchUserInfo(normalizeActor(normalizedRoomId)); - const user = account(); - if (info && user) { - room = { - id: normalizedRoomId, - name: "", - displayName: info.displayName || info.userName, - userName: info.userName, - domain: info.domain, - avatar: info.authorAvatar || - info.userName.charAt(0).toUpperCase(), - unreadCount: 0, - type: "group", - members: [normalizedRoomId], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - } - } - - // ルームが見つかった場合は相手情報を補正した上でメッセージを読み込み - if (room) { - await ensureDmPartnerInfo(room); - await loadMessages(room, true); - } else if (roomId === selfRoomId) { - // セルフルーム(TAKO Keep)の場合は空のメッセージリストを設定 - setMessages([]); - } else { - setMessages([]); - } - }, - ), - ); - - // WS通知に反応して差分取得する方式へ移行(定期ポーリングは廃止) - - // 非選択ルームのプレビュー更新もWS通知時のみ(定期ポーリングは廃止) - - // 新規ルーム検出はWS handshake通知時と手動同期に限定(定期サーチは廃止) - - // URLから直接チャットを開いた場合、モバイルでは自動的にルーム表示を切り替える createEffect(() => { - if (!isMobile()) return; const roomId = selectedRoom(); - if (roomId && showRoomList()) { - setShowRoomList(false); - } else if (!roomId && !showRoomList()) { - setShowRoomList(true); + if (roomId) { + void loadMessages(roomId); } }); - createEffect(() => { - account(); - loadGroupStates(); - ensureKeyPair(); - }); - - createEffect(() => { - groups(); - saveGroupStates(); - }); - - createEffect(() => { - newMessage(); - adjustHeight(textareaRef); - }); + const loadMessages = async (roomId: string) => { + const list = await fetchMessages(roomId); + const me = account(); + const self = me ? `${me.userName}@${getDomain()}` : ""; + const msgs: ChatMessage[] = list.map((m) => ({ + id: m.id, + author: m.sender, + displayName: m.sender, + address: m.sender, + content: m.content, + timestamp: new Date(m.createdAt), + type: "text", + isMe: m.sender === self, + })); + setMessages(msgs); + }; - createEffect(() => { - if (!partnerHasKey()) { - alert("このユーザーは暗号化された会話に対応していません。"); + const handleSend = async () => { + const text = newMessage().trim(); + const roomId = selectedRoom(); + const user = account(); + if (!text || !roomId || !user) return; + const me = `${user.userName}@${getDomain()}`; + const ok = await sendPlainMessage(roomId, me, [me], text); + if (ok) { + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + author: me, + displayName: user.displayName || user.userName, + address: me, + content: text, + timestamp: new Date(), + type: "text", + isMe: true, + }, + ]); + setNewMessage(""); } - }); - - onCleanup(() => { - globalThis.removeEventListener("resize", checkMobile); - wsCleanup?.(); - acceptCleanup?.(); - if (previewPoller) clearInterval(previewPoller); - }); - - // APIベースのイベントで更新(WS不要運用向け) - onMount(async () => { - try { - const user = account(); - if (user) { - const cur = await getCacheItem(user.id, "eventsCursor"); - if (typeof cur === "string") setEventsCursor(cur); - } - } catch { /* ignore */ } - - const processEvents = async ( - evs: { - id: string; - type: string; - roomId?: string; - from?: string; - to?: string[]; - createdAt?: string; - }[], - ) => { - const user = account(); - if (!user) return; - let maxTs = eventsCursor(); - const byRoom = new Map< - string, - { handshake: boolean; message: boolean } - >(); - for (const ev of evs) { - const rid = ev.roomId; - if (!rid) continue; - const cur = byRoom.get(rid) || { handshake: false, message: false }; - if (ev.type === "handshake") cur.handshake = true; - if (ev.type === "encryptedMessage" || ev.type === "publicMessage") { - cur.message = true; - } - byRoom.set(rid, cur); - if (ev.createdAt && (!maxTs || ev.createdAt > maxTs)) { - maxTs = ev.createdAt; - } - } - for (const [rid, flg] of byRoom) { - let room = chatRooms().find((r) => r.id === rid); - if (!room) { - room = { - id: rid, - name: "", - userName: account()?.userName || "", - domain: getDomain(), - avatar: "", - unreadCount: 0, - type: "group", - members: [], - lastMessage: "...", - lastMessageTime: undefined, - }; - upsertRoom(room); - try { - await applyDisplayFallback([room]); - } catch { /* ignore */ } - await initGroupState(rid); - } - if (room && flg.handshake) await syncHandshakes(room); - if (room && flg.message) { - const isSel = selectedRoom() === rid; - if (isSel) { - const prev = messages(); - const lastTs = prev.length > 0 - ? prev[prev.length - 1].timestamp.toISOString() - : undefined; - const fetched = await fetchMessagesForRoom( - room, - lastTs ? { after: lastTs } : { limit: 1 }, - ); - if (fetched.length > 0) { - setMessages((old) => { - const ids = new Set(old.map((m) => m.id)); - const add = fetched.filter((m) => !ids.has(m.id)); - const next = [...old, ...add]; - setMessagesByRoom({ - ...messagesByRoom(), - [roomCacheKey(rid)]: next, - }); - const user2 = account(); - if (user2) void saveDecryptedMessages(user2.id, rid, next); - return next; - }); - updateRoomLast(rid, fetched[fetched.length - 1]); - } - } else { - const fetched = await fetchMessagesForRoom(room, { - limit: 1, - dryRun: true, - }); - if (fetched.length > 0) { - updateRoomLast(rid, fetched[fetched.length - 1]); - } - } - } - } - if (maxTs) { - setEventsCursor(maxTs); - try { - const user2 = account(); - if (user2) await setCacheItem(user2.id, "eventsCursor", maxTs); - } catch { /* ignore */ } - } - }; - - const syncOnce = async () => { - try { - const evs = await fetchEvents({ - since: eventsCursor() ?? undefined, - limit: 100, - }); - if (evs.length > 0) await processEvents(evs); - } catch { /* ignore */ } - }; - - await syncOnce(); - const onFocus = () => void syncOnce(); - globalThis.addEventListener("focus", onFocus); - globalThis.addEventListener("online", onFocus); - globalThis.addEventListener("visibilitychange", () => { - if (!document.hidden) void syncOnce(); - }); - onCleanup(() => { - globalThis.removeEventListener("focus", onFocus); - globalThis.removeEventListener("online", onFocus); - }); - }); + }; return ( - <> -
-
- {/* ルームリスト */} -
- openRoomDialog()} - segment={segment()} - onSegmentChange={setSegment} - onCreateFriendRoom={(friendId: string) => { - openRoomDialog(friendId); - }} - /> -
-
- -
-
- - - -
-

- {isMobile() ? "チャンネルを選択" : "チャンネルを選択"} -

-

- {isMobile() - ? "チャンネルを選択してください" - : "左のサイドバーからチャンネルを選択して会話を開始しましょう"} -

-
-
- } - > -
- m !== selfHandle) ?? - r.members[0]; - const isDm = r.type !== "memo" && - (r.members?.length ?? 0) === 1 && - !(r.hasName || r.hasIcon); - const looksLikeSelf = me && - (r.name === me.displayName || r.name === me.userName); - if (isDm || looksLikeSelf) { - const other = rawOther && rawOther !== selfHandle - ? (normalizeHandle(rawOther) ?? null) - : null; - return { ...r, name: other ?? (r.name || "不明") }; - } - return r; - })()} - onBack={backToRoomList} - onOpenSettings={() => setShowSettings(true)} - showSettings={(function () { - const r = selectedRoomInfo(); - return r ? r.type !== "memo" : true; - })()} - bindingStatus={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? bindingStatus() : null; - })()} - bindingInfo={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? bindingInfo() : null; - })()} - ktInfo={(function () { - const r = selectedRoomInfo(); - return r && r.type !== "memo" ? ktInfo() : null; - })()} - /> - {/* 旧 group 操作UIは削除(イベントソース派生に移行) */} - { - const roomId = selectedRoom(); - if (roomId) { - const room = chatRooms().find((r) => r.id === roomId); - if (room) loadOlderMessages(room); - } - }} - /> - {/* Welcome 受信時の参加確認バナー */} - -
-
- この会話に招待されています。参加しますか? -
-
- - -
-
-
- -
- -
-
+
+
+ setSelectedRoom(id)} + showAds={false} + onCreateRoom={() => {}} + segment={segment()} + onSegmentChange={setSegment} + />
- { - setShowGroupDialog(false); - }} - onCreate={createRoom} - initialMembers={initialMembers()} - /> - setShowSettings(false)} - onRoomUpdated={(partial) => { - const id = selectedRoom(); - if (!id) return; - setChatRooms((prev) => - prev.map((r) => r.id === id ? { ...r, ...partial } : r) - ); - }} - bindingStatus={bindingStatus()} - bindingInfo={bindingInfo()} - ktInfo={ktInfo()} - onRemoveMember={removeActorLeaves} - /> - - ); -} - -function splitActor(actor: ActorID): [string, string | undefined] { - if (actor.startsWith("http")) { - const url = new URL(actor); - return [url.pathname.split("/").pop()!, url.hostname]; - } - if (actor.includes("@")) { - const [user, domain] = actor.split("@"); - return [user, domain]; - } - return [actor, undefined]; -} - -function normalizeActor(actor: ActorID): string { - if (actor.startsWith("http")) { - try { - const url = new URL(actor); - const name = url.pathname.split("/").pop()!; - return `${name}@${url.hostname}`; - } catch { - return actor; - } - } - return actor; -} - -// 招待中のローカル管理(設定オーバーレイが参照) -const cacheKeyPending = (roomId: string) => `pendingInvites:${roomId}`; -async function readPending( - accountId: string, - roomId: string, -): Promise { - const raw = await getCacheItem(accountId, cacheKeyPending(roomId)); - return Array.isArray(raw) - ? (raw as unknown[]).filter((v) => typeof v === "string") as string[] - : []; -} -async function writePending(accountId: string, roomId: string, ids: string[]) { - const uniq = Array.from(new Set(ids)); - await setCacheItem(accountId, cacheKeyPending(roomId), uniq); -} -async function addPendingInvites( - accountId: string, - roomId: string, - ids: string[], -) { - const cur = await readPending(accountId, roomId); - await writePending(accountId, roomId, [...cur, ...ids]); -} -async function _removePendingInvite( - accountId: string, - roomId: string, - id: string, -) { - const cur = (await readPending(accountId, roomId)).filter((v) => v !== id); - await writePending(accountId, roomId, cur); -} -async function syncPendingWithParticipants( - accountId: string, - roomId: string, - participants: string[], -) { - const present = new Set(participants); - const cur = await readPending(accountId, roomId); - const next = cur.filter((v) => !present.has(v)); - await writePending(accountId, roomId, next); -} - -function pickUsableKeyPackage( - list: { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - lastResort?: boolean; - }[], -): - | { - content: string; - expiresAt?: string; - used?: boolean; - deviceId?: string; - lastResort?: boolean; - } - | null { - const now = Date.now(); - const normal = list.filter((k) => !k.lastResort); - const lastResort = list.filter((k) => k.lastResort); - const usableNormal = normal.filter((k) => - !k.used && (!k.expiresAt || Date.parse(k.expiresAt) > now) - ); - if (usableNormal.length > 0) return usableNormal[0]; - // 通常キーが無い場合のみ lastResort を候補にする(unused/未期限切れ優先) - const usableLR = lastResort.filter((k) => - !k.used && (!k.expiresAt || Date.parse(k.expiresAt) > now) + +
+ r.id === selectedRoom()) ?? null} + onBack={() => setSelectedRoom(null)} + onOpenSettings={() => {}} + /> + {}} + /> + {}} + mediaPreview={null} + setMediaPreview={() => {}} + sendMessage={handleSend} + allowMedia={false} + /> +
+
+
); - if (usableLR.length > 0) return usableLR[0]; - // それでも無ければ全体から最初 - return list[0] ?? null; -} - -function normalizeHandle(actor: ActorID): string | null { - if (actor.startsWith("http")) { - try { - const url = new URL(actor); - const name = url.pathname.split("/").pop()!; - return `${name}@${url.hostname}`; - } catch { - return null; - } - } - if (actor.includes("@")) return actor; - // 裸の文字列(displayName/uuid等)はハンドルとみなさない - return null; } diff --git a/app/client/src/components/Profile.tsx b/app/client/src/components/Profile.tsx index 820a44b65..a2fb083db 100644 --- a/app/client/src/components/Profile.tsx +++ b/app/client/src/components/Profile.tsx @@ -8,7 +8,7 @@ import { } from "./microblog/api.ts"; import { PostList } from "./microblog/Post.tsx"; import { UserAvatar } from "./microblog/UserAvatar.tsx"; -import { addRoom } from "./e2ee/api.ts"; +import { addRoom } from "./chat/api.ts"; import { accounts as accountsAtom, activeAccount, @@ -248,8 +248,15 @@ export default function Profile() { const me = `${user.userName}@${getDomain()}`; await addRoom( user.id, - { id: handle, name: handle, members: [handle, me] }, - { from: me, content: "hi", to: [handle, me] }, + { + id: handle, + name: handle, + userName: user.userName, + domain: getDomain(), + unreadCount: 0, + type: "group", + members: [handle, me], + }, ); setRoom(handle); setApp("chat"); diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index 66bab38a9..8d88c94d3 100644 --- a/app/client/src/components/Setting/index.tsx +++ b/app/client/src/components/Setting/index.tsx @@ -5,24 +5,12 @@ import { } from "../../states/settings.ts"; import { loginState } from "../../states/session.ts"; import { apiFetch } from "../../utils/config.ts"; -import { - accounts as accountsAtom, - activeAccount, -} from "../../states/account.ts"; -import { deleteMLSDatabase } from "../e2ee/storage.ts"; import { FaspProviders } from "./FaspProviders.tsx"; -import { useMLS } from "../e2ee/useMLS.ts"; -import { Show } from "solid-js"; export function Setting() { const [language, setLanguage] = useAtom(languageState); const [postLimit, setPostLimit] = useAtom(microblogPostLimitState); const [, setIsLoggedIn] = useAtom(loginState); - const [accs] = useAtom(accountsAtom); - const [account] = useAtom(activeAccount); - const { generateKeys, status, error } = useMLS( - account()?.userName ?? "", - ); const handleLogout = async () => { try { @@ -30,9 +18,6 @@ export function Setting() { } catch (err) { console.error("logout failed", err); } finally { - for (const acc of accs()) { - await deleteMLSDatabase(acc.id); - } setIsLoggedIn(false); localStorage.removeItem("encryptionKey"); } @@ -69,22 +54,6 @@ export function Setting() {

FASP 設定

-
-

MLS 鍵管理

- - -

{status()}

-
- -

{error()}

-
-
- - - -
- - - -
- {error()} -
-
-
- -
-
- - setRoomName(e.currentTarget.value)} - class="w-full bg-[#2b2b2b] border border-[#3a3a3a] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500" - placeholder="ルーム名" - /> -
-
- -
-
- {roomIcon() - ? ( - room icon - ) - : なし} -
- -
-
-
- -
-
-
- -
- -
- 状態: {props.bindingInfo!.label} - -

- {props.bindingInfo!.caution} -

-
- -

監査未検証

-
- - - -
-
-
-
- - setNewMember(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddMember(); - } - }} - placeholder="@alice@example.com" - class="w-full bg-[#2b2b2b] border border-[#3a3a3a] rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500 text-sm" - /> -
- -
-
-

- メンバー一覧 -

-
- 0}> -
-

メンバー

-
- - {(m) => ( -
-
- {m.avatar - ? ( - {m.display} - ) - : m.display[0]} -
-
-

- {m.display} -

-

- {m.actor ?? m.id} -

-

- {m.bindingInfo.label} - - - {m.bindingInfo.caution} - - - - - 監査未検証 - - -

-
-
- - - - - - -
-
- )} -
-
-
-
- 0}> -
-
- - {(m) => ( -
-
- {m.avatar - ? ( - {m.display} - ) - : m.display[0]} -
-
-

- {m.display} -

-

- {m.actor ?? m.id} -

-
- {/* pending badge intentionally hidden */} -
- )} -
-
-
-
- -
メンバー無し
-
-
-
-
-
- -
-

- 外観設定(テーマ / 色 / 通知音 など)は今後拡張予定です。 -

-
-
-
- - - - - ); -} diff --git a/app/client/src/components/chat/ChatTitleBar.tsx b/app/client/src/components/chat/ChatTitleBar.tsx index a889cf595..036329649 100644 --- a/app/client/src/components/chat/ChatTitleBar.tsx +++ b/app/client/src/components/chat/ChatTitleBar.tsx @@ -4,17 +4,12 @@ import { isFriendRoom } from "./types.ts"; import { useAtom } from "solid-jotai"; import { activeAccount } from "../../states/account.ts"; import { getDomain } from "../../utils/config.ts"; -import type { BindingStatus } from "../e2ee/binding.ts"; - interface ChatTitleBarProps { isMobile: boolean; selectedRoom: Room | null; onBack: () => void; onOpenSettings: () => void; // 右上設定メニュー表示 showSettings?: boolean; - bindingStatus?: BindingStatus | null; - bindingInfo?: { label: string; caution?: string } | null; - ktInfo?: { included: boolean } | null; } export function ChatTitleBar(props: ChatTitleBarProps) { @@ -102,22 +97,7 @@ export function ChatTitleBar(props: ChatTitleBarProps) {

{titleFor(props.selectedRoom)}

- - - {props.bindingInfo!.label} - - - - - {props.bindingInfo!.caution} - - - - 監査未検証 - + {/* 暗号化関連の表示は削除 */}
diff --git a/app/client/src/components/chat/api.ts b/app/client/src/components/chat/api.ts new file mode 100644 index 000000000..f4fbf9d50 --- /dev/null +++ b/app/client/src/components/chat/api.ts @@ -0,0 +1,82 @@ +import { apiFetch } from "../../utils/config.ts"; +import type { Room } from "./types.ts"; + +export interface PlainMessage { + id: string; + sender: string; + content: string; + createdAt: string; +} + +export const searchRooms = async ( + owner: string, +): Promise<{ id: string }[]> => { + try { + const params = new URLSearchParams(); + params.set("owner", owner); + const res = await apiFetch(`/api/rooms?${params.toString()}`); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.rooms) ? data.rooms : []; + } catch (err) { + console.error("Error searching rooms:", err); + return []; + } +}; + +export const addRoom = async ( + id: string, + room: Room, +): Promise => { + try { + const body = { owner: id, id: room.id }; + const res = await apiFetch(`/api/ap/rooms`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.ok; + } catch (err) { + console.error("Error adding room:", err); + return false; + } +}; + +export const fetchMessages = async ( + roomId: string, +): Promise => { + try { + const res = await apiFetch( + `/api/rooms/${encodeURIComponent(roomId)}/messages`, + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data as PlainMessage[] : []; + } catch (err) { + console.error("Error fetching messages:", err); + return []; + } +}; + +export const sendMessage = async ( + roomId: string, + from: string, + to: string[], + content: string, +): Promise => { + try { + const payload = { from, to, content, mediaType: "text/plain" }; + const res = await apiFetch( + `/api/rooms/${encodeURIComponent(roomId)}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + return res.ok; + } catch (err) { + console.error("Error sending message:", err); + return false; + } +}; diff --git a/app/client/src/components/e2ee/api.ts b/app/client/src/components/e2ee/api.ts deleted file mode 100644 index 40cac026d..000000000 --- a/app/client/src/components/e2ee/api.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import { apiFetch, getKpPoolSize, getDomain } from "../../utils/config.ts"; -import { decodeGroupInfo, encodePublicMessage } from "./mls_message.ts"; -import { - encryptMessage, - type GeneratedKeyPair, - generateKeyPair, - joinWithGroupInfo, - type RawKeyPackageInput, - type RosterEvidence, - type StoredGroupState, - updateKey, - verifyGroupInfo, - verifyKeyPackage, -} from "./mls_wrapper.ts"; -import { bufToB64 } from "../../../../shared/buffer.ts"; -import { - appendKeyPackageRecords, - appendRosterEvidence, - loadKeyPackageRecords, - saveMLSKeyPair, -} from "./storage.ts"; -import { decodeMlsMessage } from "ts-mls"; - -const bindingErrorMessages: Record = { - "ap_mls.binding.identity_mismatch": - "Credentialのidentityがアクターと一致しません", - "ap_mls.binding.policy_violation": "KeyPackageの形式が不正です", -}; - -export interface KeyPackage { - id: string; - type: "KeyPackage"; - content: string; - mediaType: string; - encoding: string; - groupInfo?: string; - expiresAt?: string; - used?: boolean; - createdAt: string; - attributedTo?: string; - deviceId?: string; - keyPackageRef?: string; - lastResort?: boolean; -} - -export interface EncryptedMessage { - id: string; - roomId: string; - from: string; - to: string[]; - content: string; - mediaType: string; - encoding: string; - createdAt: string; - attachments?: { - url: string; - mediaType: string; - key?: string; - iv?: string; - }[]; -} - -export const fetchKeyPackages = async ( - user: string, - domain?: string, -): Promise => { - try { - const identifier = domain ? `${user}@${domain}` : user; - const res = await apiFetch( - `/api/users/${encodeURIComponent(identifier)}/keyPackages`, - ); - if (!res.ok) { - throw new Error("Failed to fetch key packages"); - } - const data = await res.json(); - const items = Array.isArray(data.items) ? data.items : []; - const result: KeyPackage[] = []; - for (const item of items) { - if (typeof item.groupInfo === "string") { - const bytes = decodeGroupInfo(item.groupInfo); - if (!bytes || !(await verifyGroupInfo(bytes))) { - delete item.groupInfo; - } - } - const expected = typeof item.attributedTo === "string" - ? item.attributedTo - : domain - ? `https://${domain}/users/${user}` - : new URL(`/users/${user}`, globalThis.location.origin).href; - if (!await verifyKeyPackage(item.content, expected)) { - continue; - } - result.push(item as KeyPackage); - } - return result; - } catch (err) { - console.error("Error fetching key packages:", err); - return []; - } -}; - -export const addKeyPackage = async ( - user: string, - pkg: { - content: string; - mediaType?: string; - encoding?: string; - groupInfo?: string; - expiresAt?: string; - lastResort?: boolean; - }, -): Promise< - { keyId: string | null; groupInfo?: string; keyPackageRef?: string } -> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pkg), - }, - ); - if (!res.ok) { - let msg = "KeyPackageの登録に失敗しました"; - try { - const err = await res.json(); - if (typeof err.error === "string") { - msg = bindingErrorMessages[err.error] ?? err.error; - } - } catch (_) { - /* noop */ - } - throw new Error(msg); - } - const data = await res.json(); - let gi = typeof data.groupInfo === "string" ? data.groupInfo : undefined; - if (gi) { - const bytes = decodeGroupInfo(gi); - if (!bytes || !(await verifyGroupInfo(bytes))) { - gi = undefined; - } - } - return { - keyId: typeof data.keyId === "string" ? data.keyId : null, - groupInfo: gi, - keyPackageRef: typeof data.keyPackageRef === "string" - ? data.keyPackageRef - : undefined, - }; - } catch (err) { - console.error("Error adding key package:", err); - if (err instanceof Error) throw err; - throw new Error("KeyPackageの登録に失敗しました"); - } -}; - -export const addKeyPackagesBulk = async ( - items: { - user: string; - keyPackages: { - content: string; - mediaType?: string; - encoding?: string; - groupInfo?: string; - expiresAt?: string; - lastResort?: boolean; - deviceId?: string; - }[]; - }[], -): Promise => { - try { - // Normalize each keyPackage to include required fields expected by the server. - // Server requires mediaType === "message/mls" and encoding === "base64". - const normalized = items.map((it) => ({ - user: it.user, - keyPackages: it.keyPackages.map((kp) => ({ - content: kp.content, - mediaType: kp.mediaType ?? "message/mls", - encoding: kp.encoding ?? "base64", - groupInfo: kp.groupInfo, - expiresAt: kp.expiresAt, - lastResort: kp.lastResort, - deviceId: kp.deviceId, - })), - })); - const res = await apiFetch("/api/keyPackages/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(normalized), - }); - if (!res.ok) { - throw new Error("KeyPackageの登録に失敗しました"); - } - const data = await res.json(); - return Array.isArray(data.results) ? data.results : []; - } catch (err) { - console.error("Error adding key packages in bulk:", err); - if (err instanceof Error) throw err; - throw new Error("KeyPackageの登録に失敗しました"); - } -}; - -// KeyPackage 残数と lastResort 有無を取得 -export const fetchKeyPackageSummary = async ( - user: string, -): Promise<{ count: number; hasLastResort: boolean }> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages?summary=1`, - ); - if (!res.ok) throw new Error("failed"); - const data = await res.json(); - return { - count: typeof data.count === "number" ? data.count : 0, - hasLastResort: Boolean(data.hasLastResort), - }; - } catch (e) { - console.error("Error fetching key package summary:", e); - return { count: 0, hasLastResort: false }; - } -}; - -// 一括で複数ユーザーの KeyPackage summary を取得する -export const fetchKeyPackageSummaries = async ( - users: string[], -): Promise<{ user: string; count: number; hasLastResort: boolean }[]> => { - try { - const res = await apiFetch(`/api/keyPackages/summary`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ users }), - }); - if (!res.ok) throw new Error("failed"); - const data = await res.json(); - return Array.isArray(data.results) ? data.results : []; - } catch (e) { - console.error("Error fetching key package summaries:", e); - return users.map((u) => ({ user: u, count: 0, hasLastResort: false })); - } -}; - -export const topUpKeyPackages = async ( - userName: string, - accountId: string, -): Promise => { - return await topUpKeyPackagesBulk([{ userName, accountId }]); -}; - -// 複数アカウントの KeyPackage をまとめて補充 -export const topUpKeyPackagesBulk = async ( - accounts: { userName: string; accountId: string }[], -): Promise => { - try { - const target = getKpPoolSize(); - if (!target || target <= 0) return false; - // Fetch summaries in bulk to avoid per-account network calls - const users = accounts.map((a) => a.userName); - const summaries = await fetchKeyPackageSummaries(users); - const summaryMap = new Map(); - for (const s of summaries) summaryMap.set(s.user, s); - - const uploads: { - user: string; - keyPackages: { content: string; lastResort?: boolean }[]; - }[] = []; - - for (const acc of accounts) { - const sum = summaryMap.get(acc.userName) ?? { user: acc.userName, count: 0, hasLastResort: false }; - const actor = `https://${getDomain()}/users/${acc.userName}`; - const kpList: { content: string; lastResort?: boolean }[] = []; - if (!sum.hasLastResort) { - try { - const kp = await generateKeyPair(actor); - await saveMLSKeyPair(acc.accountId, { - public: kp.public, - private: kp.private, - encoded: kp.encoded, - }); - kpList.push({ content: kp.encoded, lastResort: true }); - } catch (e) { - console.warn("lastResort KeyPackage 生成に失敗", e); - } - } - const need = target - sum.count; - for (let i = 0; i < need; i++) { - try { - const kp = await generateKeyPair(actor); - await saveMLSKeyPair(acc.accountId, { - public: kp.public, - private: kp.private, - encoded: kp.encoded, - }); - kpList.push({ content: kp.encoded }); - } catch (e) { - console.warn("KeyPackage 補充に失敗しました", e); - break; - } - } - if (kpList.length > 0) { - uploads.push({ user: acc.userName, keyPackages: kpList }); - } - } - if (uploads.length > 0) { - await addKeyPackagesBulk(uploads); - return true; - } - return false; - } catch (e) { - console.warn("KeyPackage プール確認に失敗しました", e); - return false; - } -}; - -export const fetchKeyPackage = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - ); - if (!res.ok) return null; - const data = await res.json(); - if (typeof data.groupInfo === "string") { - const bytes = decodeGroupInfo(data.groupInfo); - if (!bytes || !(await verifyGroupInfo(bytes))) { - delete data.groupInfo; - } - } - const expected = typeof data.attributedTo === "string" - ? data.attributedTo - : new URL(`/users/${user}`, globalThis.location.origin).href; - if (!await verifyKeyPackage(data.content, expected)) { - return null; - } - return data as KeyPackage; - } catch (err) { - console.error("Error fetching key package:", err); - return null; - } -}; - -export const markKeyPackagesUsedByRef = async ( - user: string, - refs: string[], -): Promise => { - if (!refs.length) return; - try { - await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/markUsed`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ keyPackageRefs: refs }), - }, - ); - } catch (e) { - console.warn("markKeyPackagesUsedByRef failed", e); - } -}; - -// KeyPackage の URL を指定して Actor とのバインディングを検証しつつ取得する -export const fetchVerifiedKeyPackage = async ( - kpUrl: string, - candidateActor?: string, - record?: { accountId: string; roomId: string; leafIndex: number }, -): Promise => { - try { - const res = await fetch(kpUrl, { - headers: { Accept: "application/activity+json" }, - }); - if (!res.ok) return null; - const kp = await res.json(); - if ( - typeof kp.attributedTo !== "string" || - typeof kp.content !== "string" - ) { - return null; - } - const actorId = kp.attributedTo; - if (candidateActor && candidateActor !== actorId) return null; - const actorRes = await fetch(actorId, { - headers: { Accept: "application/activity+json" }, - }); - if (!actorRes.ok) return null; - const actor = await actorRes.json(); - const kpId = typeof kp.id === "string" ? kp.id : kpUrl; - let listed = false; - const col = actor.keyPackages; - if (Array.isArray(col)) { - listed = col.includes(kpId); - } else if (col && Array.isArray(col.items)) { - listed = col.items.includes(kpId); - } - if (!listed) return null; - if (!await verifyKeyPackage(kp.content, actorId)) return null; - const b64ToBytes = (b64: string) => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - }; - const bytes = b64ToBytes(kp.content); - const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); - const toHex = (arr: Uint8Array) => - Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(""); - const hashHex = toHex(new Uint8Array(hashBuffer)); - let ktIncluded = false; - try { - const origin = new URL(kpId).origin; - const proofRes = await fetch( - `${origin}/.well-known/key-transparency?hash=${hashHex}`, - ); - if (proofRes.ok) { - const proof = await proofRes.json(); - ktIncluded = Boolean(proof?.included); - } - } catch { - // KT 検証に失敗しても致命的ではない - } - let fpr: string | undefined; - const decoded = decodeMlsMessage(bytes, 0)?.[0] as unknown as { - keyPackage?: unknown; - }; - const key = (decoded?.keyPackage as { - leafNode?: { signaturePublicKey?: Uint8Array }; - })?.leafNode?.signaturePublicKey; - if (key) fpr = `p256:${toHex(key)}`; - const result: RawKeyPackageInput = { - content: kp.content, - actor: actorId, - deviceId: typeof kp.deviceId === "string" ? kp.deviceId : undefined, - url: kpId, - hash: hashHex, - leafSignatureKeyFpr: fpr, - fetchedAt: new Date().toISOString(), - etag: res.headers.get("ETag") ?? undefined, - kt: { included: ktIncluded }, - }; - if (record && fpr) { - await appendKeyPackageRecords(record.accountId, record.roomId, [{ - kpUrl: kpId, - actorId, - leafIndex: record.leafIndex, - credentialFingerprint: fpr, - time: result.fetchedAt!, - ktIncluded, - }]); - } - return result; - } catch (err) { - console.error("KeyPackage の検証に失敗しました:", err); - return null; - } -}; - -export const fetchGroupInfo = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - ); - if (!res.ok) return null; - const data = await res.json(); - if (typeof data.groupInfo === "string") { - const bytes = decodeGroupInfo(data.groupInfo); - if (bytes && (await verifyGroupInfo(bytes))) { - return data.groupInfo; - } - } - return null; - } catch (err) { - console.error("Error fetching group info:", err); - return null; - } -}; - -// RosterEvidence を検証する -export const importRosterEvidence = async ( - accountId: string, - roomId: string, - evidence: RosterEvidence, - leafIndex = -1, -): Promise => { - try { - const res = await fetch(evidence.keyPackageUrl, { - headers: { Accept: "application/activity+json" }, - }); - if (!res.ok) return false; - const kp = await res.json(); - if (typeof kp.content !== "string" || kp.attributedTo !== evidence.actor) { - return false; - } - const b64ToBytes = (b64: string) => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - }; - const bytes = b64ToBytes(kp.content); - const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); - const toHex = (arr: Uint8Array) => - Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(""); - const hashHex = toHex(new Uint8Array(hashBuffer)); - if (`sha256:${hashHex}` !== evidence.keyPackageHash) return false; - const decoded = decodeMlsMessage(bytes, 0)?.[0] as unknown as { - keyPackage?: unknown; - }; - const key = (decoded?.keyPackage as { - leafNode?: { signaturePublicKey?: Uint8Array }; - })?.leafNode?.signaturePublicKey; - if (!key || `p256:${toHex(key)}` !== evidence.leafSignatureKeyFpr) { - return false; - } - if (!await verifyKeyPackage(kp.content, evidence.actor)) return false; - await appendKeyPackageRecords(accountId, roomId, [{ - kpUrl: evidence.keyPackageUrl, - actorId: evidence.actor, - leafIndex, - credentialFingerprint: evidence.leafSignatureKeyFpr, - time: evidence.fetchedAt, - ktIncluded: false, - }]); - return true; - } catch (err) { - console.error("RosterEvidence の検証に失敗しました:", err); - return false; - } -}; - -export const deleteKeyPackage = async ( - user: string, - keyId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keyPackages/${ - encodeURIComponent(keyId) - }`, - { method: "DELETE" }, - ); - return res.ok; - } catch (err) { - console.error("Error deleting key package:", err); - return false; - } -}; - -export const sendEncryptedMessage = async ( - roomId: string, - from: string, - to: string[], - data: { - content: string; - mediaType?: string; - encoding?: string; - attachments?: unknown[]; - }, -): Promise => { - try { - const payload: Record = { - from, - to, - content: data.content, - mediaType: data.mediaType ?? "message/mls", - encoding: data.encoding ?? "base64", - }; - if (data.attachments) payload.attachments = data.attachments; - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/messages`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.ok; - } catch (err) { - console.error("Error sending message:", err); - return false; - } -}; - -// グループ設定(名前・アイコン)を application_data で送信 -export const sendGroupMetadata = async ( - roomId: string, - from: string, - state: StoredGroupState, - to: string[], - info: { name?: string; icon?: string }, -): Promise => { - try { - const obj: Record = { - "@context": "https://www.w3.org/ns/activitystreams", - type: "Object", - }; - if (info.name) obj.name = info.name; - if (info.icon?.startsWith("data:")) { - const m = info.icon.match(/^data:(.+);base64,(.+)$/); - if (m) { - obj.icon = { - type: "Image", - mediaType: m[1], - encoding: "base64", - content: m[2], - }; - } - } - const enc = await encryptMessage(state, JSON.stringify(obj)); - const ok = await sendEncryptedMessage(roomId, from, to, { - content: bufToB64(enc.message), - mediaType: "message/mls", - encoding: "base64", - }); - return ok ? enc.state : null; - } catch (err) { - console.error("Error sending group metadata:", err); - return null; - } -}; - -export const fetchEncryptedMessages = async ( - roomId: string, - member: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - search.set("member", member); - const query = search.toString(); - const url = `/api/rooms/${encodeURIComponent(roomId)}/messages${ - query ? `?${query}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) { - throw new Error("Failed to fetch messages"); - } - const data = await res.json(); - console.debug("[fetchEncryptedMessages]", { - roomId, - member, - params, - count: Array.isArray(data) ? data.length : undefined, - raw: data, - }); - if (!Array.isArray(data)) return []; - const result: EncryptedMessage[] = []; - for (const msg of data) { - if ( - msg && - typeof msg === "object" && - (msg as { mediaType?: unknown }).mediaType === "message/mls" && - (msg as { encoding?: unknown }).encoding === "base64" - ) { - result.push(msg as EncryptedMessage); - } else { - console.warn( - "[fetchEncryptedMessages] 不正なメッセージを破棄しました", - msg, - ); - } - } - return result; - } catch (err) { - console.error("Error fetching messages:", err); - return []; - } -}; - -export interface HandshakeMessage { - id: string; - roomId: string; - sender: string; - recipients: string[]; - message: string; - createdAt: string; -} - -export const sendHandshake = async ( - roomId: string, - from: string, - content: string, - to?: string[], -): Promise => { - try { - const payload: Record = { - from, - content, - mediaType: "message/mls", - encoding: "base64", - }; - if (Array.isArray(to)) { - // 送信先(MLS ロスター由来)を明示し、サーバー側の検証/配送に使用する - payload.to = to; - } - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/handshakes`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return res.ok; - } catch (err) { - console.error("Error sending handshake:", err); - return false; - } -}; - -export const removeRoomMembers = async ( - roomId: string, - from: string, - targets: string[], -): Promise => { - try { - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/remove`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from, targets }), - }, - ); - if (!res.ok) return null; - const data = await res.json(); - return typeof data.commit === "string" ? data.commit : null; - } catch (err) { - console.error("Error removing members:", err); - return null; - } -}; - -type UpdateResult = Awaited>; - -export const updateRoomKey = async ( - roomId: string, - from: string, - identity: string, - state: StoredGroupState, -): Promise => { - try { - const records = await loadKeyPackageRecords(from, roomId); - const rec = records.find((r) => r.actorId === identity); - if (!rec) { - throw new Error("保存済みの actorId と一致しません"); - } - const res = await updateKey(state, identity); - const content = encodePublicMessage(res.commit); - const ok = await sendHandshake(roomId, from, content); - if (!ok) return null; - return res; - } catch (err) { - console.error("Error updating room key:", err); - return null; - } -}; - -export const joinGroupWithInfo = async ( - roomId: string, - from: string, - groupInfo: string, - keyPair: GeneratedKeyPair, -): Promise<{ state: StoredGroupState } | null> => { - try { - const infoBytes = decodeGroupInfo(groupInfo); - if (!infoBytes) return null; - const res = await joinWithGroupInfo(infoBytes, keyPair); - const ok = await sendHandshake(roomId, from, res.commit); - if (!ok) return null; - return { state: res.state }; - } catch (err) { - console.error("Error joining with group info:", err); - return null; - } -}; - -export const fetchHandshakes = async ( - roomId: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - const query = search.toString(); - const url = `/api/rooms/${encodeURIComponent(roomId)}/handshakes${ - query ? `?${query}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) { - throw new Error("Failed to fetch handshakes"); - } - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch (err) { - console.error("Error fetching handshakes:", err); - return []; - } -}; - -export const fetchEncryptedKeyPair = async ( - user: string, - deviceId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - ); - if (!res.ok) return null; - const data = await res.json(); - return typeof data.content === "string" ? data.content : null; - } catch (err) { - console.error("Error fetching encrypted key pair:", err); - return null; - } -}; - -// ローカルユーザー向けの保留中招待一覧を取得 -export const fetchPendingInvites = async ( - user: string, -): Promise< - { roomId: string; deviceId?: string; expiresAt?: string; acked?: boolean }[] -> => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/pendingInvites`, - ); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) - ? data - .map((d: unknown) => { - const o = d as Record; - return { - roomId: typeof o.roomId === "string" ? o.roomId : "", - deviceId: typeof o.deviceId === "string" ? o.deviceId : undefined, - expiresAt: typeof o.expiresAt === "string" - ? o.expiresAt - : undefined, - acked: typeof o.acked === "boolean" ? o.acked : undefined, - }; - }) - .filter((x) => x.roomId !== "") - : []; - } catch (err) { - console.error("Error fetching pending invites:", err); - return []; - } -}; - -// 統合イベントAPI(ActivityPub前提のサーバ側集約を想定) -export interface UnifiedEvent { - id: string; - type: "handshake" | "encryptedMessage" | "publicMessage"; - roomId: string; - from: string; - to: string[]; - createdAt: string; -} - -export const fetchEvents = async ( - params?: { since?: string; limit?: number; types?: string[] }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.since) search.set("since", params.since); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.types?.length) search.set("types", params.types.join(",")); - const url = `/api/events${ - search.toString() ? `?${search.toString()}` : "" - }`; - const res = await apiFetch(url); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) ? data as UnifiedEvent[] : []; - } catch (err) { - console.error("Error fetching events:", err); - return []; - } -}; - -export const saveEncryptedKeyPair = async ( - user: string, - deviceId: string, - content: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }, - ); - return res.ok; - } catch (err) { - console.error("Error saving encrypted key pair:", err); - return false; - } -}; - -export const deleteEncryptedKeyPair = async ( - user: string, - deviceId: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/devices/${ - encodeURIComponent(deviceId) - }/encryptedKeyPair`, - { method: "DELETE" }, - ); - return res.ok; - } catch (err) { - console.error("Error deleting encrypted key pair:", err); - return false; - } -}; - -export const uploadFile = async ( - data: { - content: ArrayBuffer; - mediaType?: string; - key?: string; - iv?: string; - name?: string; - }, -): Promise => { - try { - const form = new FormData(); - form.append( - "file", - new Blob([data.content], { type: data.mediaType }), - data.name ?? "file", - ); - if (data.key) form.append("key", data.key); - if (data.iv) form.append("iv", data.iv); - const res = await apiFetch("/api/files", { - method: "POST", - body: form, - }); - if (!res.ok) return null; - const d = await res.json(); - return typeof d.url === "string" ? d.url : null; - } catch (err) { - console.error("Error uploading attachment:", err); - return null; - } -}; - -export const resetKeyData = async (user: string): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/resetKeys`, - { method: "POST" }, - ); - return res.ok; - } catch (err) { - console.error("Error resetting key data:", err); - return false; - } -}; - -export interface Room { - id: string; - // name や members はサーバ保存対象外(必要ならクライアント内のみで使用) - name?: string; - members?: string[]; -} - -export interface RoomsSearchItem { - id: string; - // 他フィールドはサーバーから提供されない -} - -export const searchRooms = async ( - owner: string, - params?: { - participants?: string[]; - match?: "all" | "any" | "none"; - hasName?: boolean; - hasIcon?: boolean; - members?: string; // e.g., "eq:2", "ge:3" - // 暗黙(メッセージ履歴から推定)のルームの扱い - implicit?: "include" | "exclude" | "only"; - }, -): Promise => { - try { - const search = new URLSearchParams(); - search.set("owner", owner); - if (params?.participants?.length) { - search.set("participants", params.participants.join(",")); - } - if (params?.match) search.set("match", params.match); - if (typeof params?.hasName === "boolean") { - search.set("hasName", String(params.hasName)); - } - if (typeof params?.hasIcon === "boolean") { - search.set("hasIcon", String(params.hasIcon)); - } - if (params?.members) search.set("members", params.members); - if (params?.implicit) search.set("implicit", params.implicit); - const res = await apiFetch(`/api/rooms?${search.toString()}`); - if (!res.ok) throw new Error("failed to search rooms"); - const data = await res.json(); - return Array.isArray(data.rooms) - ? data.rooms.map((r: unknown) => { - if (r && typeof r === "object" && "id" in r) { - // deno-lint-ignore no-explicit-any - return { id: String((r as any).id) }; - } - return { id: "" }; - }).filter((r: RoomsSearchItem) => r.id !== "") - : []; - } catch (err) { - console.error("Error searching rooms:", err); - return []; - } -}; - -export const addRoom = async ( - id: string, - room: Room, - handshake?: { - from: string; - content: string; - mediaType?: string; - encoding?: string; - to?: string[]; // recipients (ハンドシェイク時必須) - }, -): Promise => { - try { - const body: Record = { owner: id, id: room.id }; - if (handshake) { - body.handshake = { - from: handshake.from, - content: handshake.content, - mediaType: handshake.mediaType ?? "message/mls", - encoding: handshake.encoding ?? "base64", - to: Array.isArray(handshake.to) - ? handshake.to - : (room.members ?? []).filter((m): m is string => - typeof m === "string" - ), - }; - } - const res = await apiFetch(`/api/ap/rooms`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return res.ok; - } catch (err) { - console.error("Error adding room:", err); - return false; - } -}; - -export const updateRoomMember = async ( - roomId: string, - action: "Add" | "Remove", - member: string, -): Promise => { - try { - const res = await apiFetch(`/api/ap/rooms/${roomId}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ type: action, object: member }), - }); - return res.ok; - } catch (err) { - console.error("Error updating member:", err); - return false; - } -}; - -export const addRoomMember = async ( - roomId: string, - member: string, -): Promise => { - return await updateRoomMember(roomId, "Add", member); -}; - -export const removeRoomMember = async ( - roomId: string, - member: string, -): Promise => { - return await updateRoomMember(roomId, "Remove", member); -}; - -export interface KeepMessage { - id: string; - content: string; - createdAt: string; -} - -export const fetchKeepMessages = async ( - user: string, - params?: { limit?: number; before?: string; after?: string }, -): Promise => { - try { - const search = new URLSearchParams(); - if (params?.limit) search.set("limit", String(params.limit)); - if (params?.before) search.set("before", params.before); - if (params?.after) search.set("after", params.after); - const query = search.toString(); - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keep${query ? `?${query}` : ""}`, - ); - if (!res.ok) throw new Error("failed to fetch keep messages"); - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch (err) { - console.error("Error fetching keep messages:", err); - return []; - } -}; - -export const sendKeepMessage = async ( - user: string, - content: string, -): Promise => { - try { - const res = await apiFetch( - `/api/users/${encodeURIComponent(user)}/keep`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }, - ); - if (!res.ok) return null; - return await res.json(); - } catch (err) { - console.error("Error sending keep message:", err); - return null; - } -}; - -// MLS関連のサーバ通信 -export interface MLSProposalPayload { - type: "add" | "remove"; - member: string; - keyPackage?: string; -} - -export interface MLSWelcomePayload { - type: "welcome"; - member: string; - epoch: number; - tree: Record; - secret: string; -} - -export interface MLSCommitPayload { - type: "commit"; - epoch: number; - proposals: MLSProposalPayload[]; - welcomes?: MLSWelcomePayload[]; - evidences?: RosterEvidence[]; -} - -export const sendProposal = async ( - roomId: string, - from: string, - proposal: MLSProposalPayload, -): Promise => { - const content = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(proposal)), - ); - return await sendHandshake(roomId, from, content); -}; - -export const sendCommit = async ( - roomId: string, - from: string, - commit: MLSCommitPayload, -): Promise => { - const content = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(commit)), - ); - const ok = await sendHandshake(roomId, from, content); - if (!ok) return false; - if (commit.welcomes) { - for (const w of commit.welcomes) { - const wContent = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(w)), - ); - const success = await sendHandshake(roomId, from, wContent); - if (!success) return false; - } - } - if (commit.evidences) { - for (const ev of commit.evidences) { - const evContent = encodePublicMessage( - new TextEncoder().encode(JSON.stringify(ev)), - ); - const okEv = await sendHandshake(roomId, from, evContent); - if (!okEv) return false; - const verified = await importRosterEvidence(from, roomId, ev); - if (verified) { - await appendRosterEvidence(from, roomId, [ev]); - } - } - } - return true; -}; diff --git a/app/client/src/components/e2ee/binding.ts b/app/client/src/components/e2ee/binding.ts deleted file mode 100644 index eff10688c..000000000 --- a/app/client/src/components/e2ee/binding.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RosterEvidence } from "./mls_wrapper.ts"; - -export type BindingStatus = "Verified" | "BoundOnly" | "Unbound"; - -/** - * Evidence と Credential の一致状況からバインディング状態を判定する - */ -export function evaluateBinding( - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - evidences: RosterEvidence[], -): BindingStatus { - const ev = evidences.find((e) => - e.leafSignatureKeyFpr === leafSignatureKeyFpr - ); - if (!ev) return "Unbound"; - return credentialActor === ev.actor ? "Verified" : "BoundOnly"; -} - -/** - * 判定結果に応じて表示用ラベルと注意文言を返す - */ -export function bindingMessage( - status: BindingStatus, -): { label: string; caution?: string } { - switch (status) { - case "Verified": - return { label: "検証済み" }; - case "BoundOnly": - return { - label: "バインドのみ", - caution: "鍵の出所はサーバ経由。指紋確認で検証可", - }; - default: - return { - label: "未リンクの端末", - caution: "指紋の確認を推奨します", - }; - } -} diff --git a/app/client/src/components/e2ee/mls_message.ts b/app/client/src/components/e2ee/mls_message.ts deleted file mode 100644 index 75389be57..000000000 --- a/app/client/src/components/e2ee/mls_message.ts +++ /dev/null @@ -1,135 +0,0 @@ -// RFC 9420 に基づく MLS メッセージの TLV 形式シリアライズ - -import { b64ToBuf, bufToB64 } from "../../../../shared/buffer.ts"; - -export type MLSMessageType = - | "PublicMessage" - | "PrivateMessage" - | "Welcome" - | "KeyPackage" - | "Commit" - | "Proposal" - | "GroupInfo"; - -const typeToByte: Record = { - PublicMessage: 1, - PrivateMessage: 2, - Welcome: 3, - KeyPackage: 4, - Commit: 5, - Proposal: 6, - GroupInfo: 7, -}; - -const byteToType: Record = { - 1: "PublicMessage", - 2: "PrivateMessage", - 3: "Welcome", - 4: "KeyPackage", - 5: "Commit", - 6: "Proposal", - 7: "GroupInfo", -}; - -function toBytes(body: Uint8Array | string): Uint8Array { - return typeof body === "string" ? new TextEncoder().encode(body) : body; -} - -function serialize( - type: MLSMessageType, - body: Uint8Array | string, -): Uint8Array { - const bodyBuf = toBytes(body); - const len = bodyBuf.length; - const out = new Uint8Array(3 + len); - out[0] = typeToByte[type]; - out[1] = (len >>> 8) & 0xff; - out[2] = len & 0xff; - out.set(bodyBuf, 3); - return out; -} - -function deserialize( - data: Uint8Array, -): { type: MLSMessageType; body: Uint8Array } | null { - if (data.length < 3) return null; - const type = byteToType[data[0]]; - if (!type) return null; - const len = (data[1] << 8) | data[2]; - if (data.length < 3 + len) return null; - return { type, body: data.slice(3, 3 + len) }; -} - -export function encodePublicMessage(body: Uint8Array | string): string { - return bufToB64(serialize("PublicMessage", body)); -} - -export function encodePrivateMessage(body: Uint8Array | string): string { - return bufToB64(serialize("PrivateMessage", body)); -} - -export function encodeWelcome(body: Uint8Array | string): string { - return bufToB64(serialize("Welcome", body)); -} - -export function encodeKeyPackage(body: Uint8Array | string): string { - return bufToB64(serialize("KeyPackage", body)); -} - -export function encodeCommit(body: Uint8Array | string): string { - return bufToB64(serialize("Commit", body)); -} - -export function encodeProposal(body: Uint8Array | string): string { - return bufToB64(serialize("Proposal", body)); -} - -export function encodeGroupInfo(body: Uint8Array | string): string { - return bufToB64(serialize("GroupInfo", body)); -} - -export function decodePublicMessage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "PublicMessage" ? decoded.body : null; -} - -export function decodePrivateMessage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "PrivateMessage" ? decoded.body : null; -} - -export function decodeWelcome(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Welcome" ? decoded.body : null; -} - -export function decodeKeyPackage(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "KeyPackage" ? decoded.body : null; -} - -export function decodeCommit(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Commit" ? decoded.body : null; -} - -export function decodeProposal(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "Proposal" ? decoded.body : null; -} - -export function decodeGroupInfo(data: string): Uint8Array | null { - const decoded = parseMLSMessage(data); - return decoded && decoded.type === "GroupInfo" ? decoded.body : null; -} - -export function parseMLSMessage( - data: string, -): { type: MLSMessageType; body: Uint8Array } | null { - try { - const u8 = b64ToBuf(data); - return deserialize(u8); - } catch { - return null; - } -} diff --git a/app/client/src/components/e2ee/mls_test.ts b/app/client/src/components/e2ee/mls_test.ts deleted file mode 100644 index 9f21e3c6f..000000000 --- a/app/client/src/components/e2ee/mls_test.ts +++ /dev/null @@ -1,167 +0,0 @@ -function assert(condition: unknown, message?: string): asserts condition { - if (!condition) throw new Error(message ?? "assertion failed"); -} -function assertEquals(actual: T, expected: T, message?: string): void { - if (actual !== expected) { - throw new Error(message ?? `assertion failed: ${actual} !== ${expected}`); - } -} -import { - createCommitAndWelcomes, - decryptMessage, - encryptMessageWithAck, - generateKeyPair, - joinWithWelcome, - verifyKeyPackage, - verifyWelcome, -} from "./mls_wrapper.ts"; -import { fetchKeyPackages } from "./api.ts"; - -// サーバー側の KeyPackage 選択ロジックをテスト用に簡易実装 -interface KeyPackageDoc { - _id: unknown; - content: string; - mediaType: string; - encoding: string; - createdAt: string; - version: string; - cipherSuite: number; - generator: string; - used?: boolean; -} - -function selectKeyPackages( - list: KeyPackageDoc[], - suite: number, - M = 3, -): KeyPackageDoc[] { - return list - .filter((kp) => - kp.version === "1.0" && kp.cipherSuite === suite && kp.used !== true - ) - .sort((a, b) => { - const da = new Date(a.createdAt).getTime(); - const db = new Date(b.createdAt).getTime(); - return db - da; - }) - .reduce((acc: KeyPackageDoc[], kp) => { - if (kp.generator && acc.some((v) => v.generator === kp.generator)) { - return acc; - } - acc.push(kp); - return acc; - }, []) - .slice(0, M); -} - -Deno.test("ts-mlsでCommitとWelcomeを生成できる", async () => { - const bob = await generateKeyPair("bob"); - assert(await verifyKeyPackage(bob.encoded, "bob")); - const { commit, welcomes } = await createCommitAndWelcomes(1, ["alice"], [ - { content: bob.encoded, actor: "bob" }, - ]); - assert(commit instanceof Uint8Array); - assertEquals(welcomes.length, 1); -}); - -Deno.test("KeyPackage取得とCommit/Welcome交換ができる", async () => { - const kp1 = await generateKeyPair("alice1"); - const kp2 = await generateKeyPair("alice2"); - const list: KeyPackageDoc[] = [ - { - _id: 1, - content: kp1.encoded, - mediaType: "application/mls+json", - encoding: "base64", - createdAt: "2023-01-01T00:00:00.000Z", - version: "1.0", - cipherSuite: 1, - generator: "g1", - }, - { - _id: 2, - content: kp2.encoded, - mediaType: "application/mls+json", - encoding: "base64", - createdAt: "2023-01-02T00:00:00.000Z", - version: "1.0", - cipherSuite: 1, - generator: "g2", - }, - ]; - const selected = selectKeyPackages(list, 1, 1); - const originalFetch = globalThis.fetch; - globalThis.fetch = (_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve( - new Response( - JSON.stringify({ - items: selected.map((kp) => ({ - id: String(kp._id), - type: "KeyPackage", - content: kp.content, - mediaType: kp.mediaType, - encoding: kp.encoding, - createdAt: kp.createdAt, - })), - }), - { status: 200 }, - ), - ); - const fetched = await fetchKeyPackages("alice"); - assertEquals(fetched.length, 1); - globalThis.fetch = originalFetch; - - const { welcomes, state: serverState } = await createCommitAndWelcomes( - 1, - ["alice"], - [{ content: fetched[0].content, actor: "alice" }], - ); - assertEquals(welcomes.length, 1); - const welcome = welcomes[0].data; - assert(await verifyWelcome(welcome)); - const aliceState = await joinWithWelcome(welcome, kp2); - assert(aliceState); - assert(serverState); -}); - -Deno.test("Ackが一度だけ送信される", async () => { - const bob = await generateKeyPair("bob"); - const { welcomes, state: serverState0 } = await createCommitAndWelcomes( - 1, - ["bob"], - [{ content: bob.encoded, actor: "bob" }], - ); - const welcome = welcomes[0].data; - const clientState0 = await joinWithWelcome(welcome, bob); - let serverState = serverState0; - let clientState = clientState0; - - const { messages, state: clientState1 } = await encryptMessageWithAck( - clientState, - "こんにちは", - "room1", - "device1", - ); - clientState = clientState1; - assertEquals(messages.length, 2); - const resAck = await decryptMessage(serverState, messages[0]); - assert(resAck); - serverState = resAck.state; - const ackJson = JSON.parse(new TextDecoder().decode(resAck.plaintext)); - assertEquals(ackJson.type, "joinAck"); - const resMsg = await decryptMessage(serverState, messages[1]); - assert(resMsg); - serverState = resMsg.state; - assertEquals(new TextDecoder().decode(resMsg.plaintext), "こんにちは"); - - const { messages: msgs2 } = await encryptMessageWithAck( - clientState, - "2回目", - "room1", - "device1", - ); - assertEquals(msgs2.length, 1); - const res2 = await decryptMessage(serverState, msgs2[0]); - assert(res2); - assertEquals(new TextDecoder().decode(res2.plaintext), "2回目"); -}); diff --git a/app/client/src/components/e2ee/mls_wrapper.ts b/app/client/src/components/e2ee/mls_wrapper.ts deleted file mode 100644 index dea6b28d3..000000000 --- a/app/client/src/components/e2ee/mls_wrapper.ts +++ /dev/null @@ -1,619 +0,0 @@ -// ts-mls のラッパーモジュール - -import { - acceptAll, - bytesToBase64, - type CiphersuiteName, - type ClientState, - createApplicationMessage, - createCommit, - createGroup, - createGroupInfoWithExternalPubAndRatchetTree, - type Credential, - decodeMlsMessage, - defaultCapabilities, - defaultLifetime, - emptyPskIndex, - encodeMlsMessage, - generateKeyPackage as tsGenerateKeyPackage, - getCiphersuiteFromName, - getCiphersuiteImpl, - joinGroup, - joinGroupExternal, - type KeyPackage, - makePskIndex, - type PrivateKeyPackage, - processPrivateMessage, - processPublicMessage, - type Proposal, -} from "ts-mls"; -import "@noble/curves/p256"; -import { encodePublicMessage } from "./mls_message.ts"; - - -// ts-mls does not publish some internal helpers via package exports; use local fallbacks/types -type PublicMessage = { - content: { commit?: unknown; proposal?: unknown } & Record; - auth?: unknown; -} & Record; - -export type StoredGroupState = ClientState; - -export interface GeneratedKeyPair { - public: KeyPackage; - private: PrivateKeyPackage; - encoded: string; -} - -export interface RawKeyPackageInput { - content: string; - actor?: string; - deviceId?: string; - url?: string; - hash?: string; - leafSignatureKeyFpr?: string; - fetchedAt?: string; - etag?: string; - kt?: { included: boolean }; -} - -export interface RosterEvidence { - type: "RosterEvidence"; - actor: string; - keyPackageUrl: string; - keyPackageHash: string; - leafSignatureKeyFpr: string; - fetchedAt: string; - etag?: string; -} - -export interface WelcomeEntry { - actor?: string; - deviceId?: string; - data: Uint8Array; -} - -const DEFAULT_SUITE: CiphersuiteName = - "MLS_128_DHKEMP256_AES128GCM_SHA256_P256"; - -async function getSuite(name: CiphersuiteName) { - return await getCiphersuiteImpl(getCiphersuiteFromName(name)); -} - -function b64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function buildPskIndex( - state: StoredGroupState | undefined, - psks?: Record, -) { - if (!psks) return emptyPskIndex; - const map: Record = {}; - for (const [id, secret] of Object.entries(psks)) { - map[id] = b64ToBytes(secret); - } - return makePskIndex(state, map); -} - -export async function generateKeyPair( - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - const cs = await getSuite(suite); - const credential: Credential = { - credentialType: "basic", - identity: new TextEncoder().encode(identity), - }; - const { publicPackage, privatePackage } = await tsGenerateKeyPackage( - credential, - defaultCapabilities(), - defaultLifetime, - [], - cs, - ); - const encoded = bytesToBase64( - encodeMlsMessage({ - version: "mls10", - wireformat: "mls_key_package", - keyPackage: publicPackage, - }), - ); - return { public: publicPackage, private: privatePackage, encoded }; -} - -export async function verifyKeyPackage( - pkg: - | string - | { - credential: { publicKey: string; identity?: string }; - signature: string; - } - & Record, - expectedIdentity?: string, -): Promise { - if (typeof pkg === "string") { - const decoded = decodeMlsMessage(b64ToBytes(pkg), 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_key_package") { - return false; - } - if (expectedIdentity) { - try { - // KeyPackage stores credential inside the leafNode per ts-mls types - const kp = decoded.keyPackage as { - leafNode?: { - credential?: { credentialType?: string; identity?: Uint8Array }; - }; - } | undefined; - const leafCred = kp?.leafNode?.credential as { - credentialType?: string; - identity?: Uint8Array; - } | undefined; - if ( - !leafCred || leafCred.credentialType !== "basic" || !leafCred.identity - ) return false; - const id = new TextDecoder().decode(leafCred.identity); - if (id !== expectedIdentity) return false; - } catch { - return false; - } - } - return true; - } - try { - const { signature, ...body } = pkg; - const b = body as { credential?: { identity?: string } }; - if ( - expectedIdentity && - typeof b.credential?.identity === "string" && - b.credential.identity !== expectedIdentity - ) { - return false; - } - const data = new TextEncoder().encode(JSON.stringify(body)); - const pub = await crypto.subtle.importKey( - "raw", - b64ToBytes(pkg.credential.publicKey), - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["verify"], - ); - return await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - pub, - b64ToBytes(signature), - data, - ); - } catch { - return false; - } -} - -export async function verifyCommit( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.commit) return false; - try { - const cs = await getSuite(suite); - const cloned = structuredClone(state); - await processPublicMessage( - cloned, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return true; - } catch { - return false; - } -} - -export async function verifyWelcome( - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - try { - const decoded = decodeMlsMessage(data, 0)?.[0]; - if ( - !decoded || - decoded.wireformat !== "mls_welcome" || - !decoded.welcome?.ratchetTree - ) { - return false; - } - const cs = await getSuite(suite); - // joinGroup が失敗しないことを確認するためダミーの鍵ペアで参加を試みる - const kp = await generateKeyPair("verify", suite); - await joinGroup( - decoded.welcome, - kp.public, - kp.private, - buildPskIndex(undefined, psks), - cs, - ); - return true; - } catch { - return false; - } -} - -export function verifyGroupInfo( - data: Uint8Array, - _suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - try { - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_group_info") { - return Promise.resolve(false); - } - // deep verification requires internal helpers not exported from package entrypoint. - // As a conservative check, validate structure and signer index. - if (!decoded.groupInfo || typeof decoded.groupInfo.signer !== "number") { - return Promise.resolve(false); - } - return Promise.resolve(true); - } catch { - return Promise.resolve(false); - } -} - -export async function verifyPrivateMessage( - state: StoredGroupState, - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - try { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_private_message") { - return false; - } - const cloned = structuredClone(state); - await processPrivateMessage( - cloned, - decoded.privateMessage, - buildPskIndex(state, psks), - cs, - ); - return true; - } catch { - return false; - } -} - -export async function createMLSGroup( - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ - state: StoredGroupState; - keyPair: GeneratedKeyPair; - gid: Uint8Array; -}> { - const keyPair = await generateKeyPair(identity, suite); - const gid = new TextEncoder().encode(crypto.randomUUID()); - const cs = await getSuite(suite); - // 可能な限り拡張を使用しないため、拡張一覧は空とする - const state = await createGroup( - gid, - keyPair.public, - keyPair.private, - [], - cs, - ); - return { state, keyPair, gid }; -} - -export async function addMembers( - state: StoredGroupState, - addKeyPackages: RawKeyPackageInput[], - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ - commit: Uint8Array; - welcomes: WelcomeEntry[]; - state: StoredGroupState; - evidences: RosterEvidence[]; -}> { - const cs = await getSuite(suite); - const proposals: Proposal[] = []; - for (const kp of addKeyPackages) { - const decoded = decodeMlsMessage(b64ToBytes(kp.content), 0)?.[0]; - if (decoded && decoded.wireformat === "mls_key_package") { - proposals.push({ - proposalType: "add", - add: { keyPackage: decoded.keyPackage }, - }); - } - } - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - state = result.newState; - const commit = encodeMlsMessage(result.commit); - const welcomes: WelcomeEntry[] = []; - const evidences: RosterEvidence[] = []; - if (result.welcome) { - const info = await createGroupInfoWithExternalPubAndRatchetTree( - state, - cs, - ); - result.welcome.ratchetTree = info.ratchetTree; - const welcomeBytes = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_welcome", - welcome: result.welcome, - }); - for (const kp of addKeyPackages) { - welcomes.push({ - actor: kp.actor, - deviceId: kp.deviceId, - data: welcomeBytes, - }); - } - } - for (const kp of addKeyPackages) { - if ( - kp.actor && kp.url && kp.hash && kp.leafSignatureKeyFpr && kp.fetchedAt - ) { - evidences.push({ - type: "RosterEvidence", - actor: kp.actor, - keyPackageUrl: kp.url, - keyPackageHash: `sha256:${kp.hash}`, - leafSignatureKeyFpr: kp.leafSignatureKeyFpr, - fetchedAt: kp.fetchedAt, - etag: kp.etag, - }); - } - } - return { commit, welcomes, state, evidences }; -} - -export async function removeMembers( - state: StoredGroupState, - removeIndices: number[], - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ commit: Uint8Array; state: StoredGroupState }> { - const cs = await getSuite(suite); - const proposals: Proposal[] = []; - for (const index of removeIndices) { - proposals.push({ - proposalType: "remove", - remove: { removed: index }, - }); - } - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - return { commit: encodeMlsMessage(result.commit), state: result.newState }; -} - -export async function updateKey( - state: StoredGroupState, - identity: string, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ - commit: Uint8Array; - state: StoredGroupState; - keyPair: GeneratedKeyPair; -}> { - const cs = await getSuite(suite); - const keyPair = await generateKeyPair(identity, suite); - // ts-mls typings expect a specific `update` shape; cast to satisfy public API for now. - const proposals: Proposal[] = [{ - proposalType: "update", - update: ({ keyPackage: keyPair.public } as unknown), - } as unknown as Proposal]; - const result = await createCommit( - state, - buildPskIndex(state, psks), - false, - proposals, - cs, - ); - return { - commit: encodeMlsMessage(result.commit), - state: result.newState, - keyPair, - }; -} - -export async function joinWithWelcome( - welcome: Uint8Array, - keyPair: GeneratedKeyPair, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(welcome, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_welcome") { - throw new Error("不正なWelcomeメッセージです"); - } - return await joinGroup( - decoded.welcome, - keyPair.public, - keyPair.private, - buildPskIndex(undefined, psks), - cs, - ); -} - -export async function encryptMessage( - state: StoredGroupState, - plaintext: Uint8Array | string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ message: Uint8Array; state: StoredGroupState }> { - const cs = await getSuite(suite); - const input = typeof plaintext === "string" - ? new TextEncoder().encode(plaintext) - : plaintext; - const { newState, privateMessage } = await createApplicationMessage( - state, - input, - cs, - ); - const message = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_private_message", - privateMessage, - }); - return { message, state: newState }; -} - -// Join ACK を一度だけ付加してメッセージを暗号化 -const sentAck = new Set(); - -export async function encryptMessageWithAck( - state: StoredGroupState, - plaintext: Uint8Array | string, - roomId: string, - deviceId: string, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ messages: Uint8Array[]; state: StoredGroupState }> { - let current = state; - const out: Uint8Array[] = []; - const key = `${roomId}:${deviceId}`; - if (!sentAck.has(key)) { - const ackBody = JSON.stringify({ type: "joinAck", roomId, deviceId }); - const ack = await encryptMessage(current, ackBody, suite); - out.push(ack.message); - current = ack.state; - sentAck.add(key); - } - const msg = await encryptMessage(current, plaintext, suite); - out.push(msg.message); - return { messages: out, state: msg.state }; -} - -export async function decryptMessage( - state: StoredGroupState, - data: Uint8Array, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise<{ plaintext: Uint8Array; state: StoredGroupState } | null> { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(data, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_private_message") { - return null; - } - try { - const res = await processPrivateMessage( - state, - decoded.privateMessage, - buildPskIndex(state, psks), - cs, - ); - if (res.kind !== "applicationMessage") { - return { plaintext: new Uint8Array(), state: res.newState }; - } - return { plaintext: res.message, state: res.newState }; - } catch (e) { - if (e instanceof Error && e.message.includes("Desired gen in the past")) { - // 過去に処理済みのメッセージは無視する - return null; - } - throw e; - } -} - -export async function exportGroupInfo( - state: StoredGroupState, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise { - const cs = await getSuite(suite); - const info = await createGroupInfoWithExternalPubAndRatchetTree( - state, - cs, - ); - return encodeMlsMessage({ - version: "mls10", - wireformat: "mls_group_info", - groupInfo: info, - }); -} - -export async function joinWithGroupInfo( - groupInfo: Uint8Array, - keyPair: GeneratedKeyPair, - suite: CiphersuiteName = DEFAULT_SUITE, -): Promise<{ commit: string; state: StoredGroupState }> { - const cs = await getSuite(suite); - const decoded = decodeMlsMessage(groupInfo, 0)?.[0]; - if (!decoded || decoded.wireformat !== "mls_group_info") { - throw new Error("不正なGroupInfoです"); - } - const { publicMessage, newState } = await joinGroupExternal( - decoded.groupInfo, - keyPair.public, - keyPair.private, - false, - cs, - ); - const commitBytes = encodeMlsMessage({ - version: "mls10", - wireformat: "mls_public_message", - publicMessage, - }); - return { commit: encodePublicMessage(commitBytes), state: newState }; -} - -export async function processCommit( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.commit) { - throw new Error("不正なCommitメッセージです"); - } - const cs = await getSuite(suite); - const { newState } = await processPublicMessage( - state, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return newState; -} - -export async function processProposal( - state: StoredGroupState, - message: PublicMessage, - suite: CiphersuiteName = DEFAULT_SUITE, - psks?: Record, -): Promise { - if (!message.content.proposal) { - throw new Error("不正なProposalメッセージです"); - } - const cs = await getSuite(suite); - const { newState } = await processPublicMessage( - state, - message as unknown as never, - buildPskIndex(state, psks), - cs, - acceptAll, - ); - return newState; -} - -export { addMembers as createCommitAndWelcomes }; diff --git a/app/client/src/components/e2ee/storage.ts b/app/client/src/components/e2ee/storage.ts deleted file mode 100644 index fe126281e..000000000 --- a/app/client/src/components/e2ee/storage.ts +++ /dev/null @@ -1,527 +0,0 @@ -import type { - GeneratedKeyPair, - RosterEvidence, - StoredGroupState, -} from "./mls_wrapper.ts"; -import { load as loadStore, type Store } from "@tauri-apps/plugin-store"; -import { isTauri } from "../../utils/config.ts"; -import type { ChatMessage } from "../chat/types.ts"; -import { - type ClientConfig, - defaultAuthenticationService, - defaultKeyPackageEqualityConfig, - defaultKeyRetentionConfig, - defaultLifetimeConfig, - defaultPaddingConfig, -} from "ts-mls"; - -const defaultClientConfig: ClientConfig = { - keyRetentionConfig: defaultKeyRetentionConfig, - lifetimeConfig: defaultLifetimeConfig, - keyPackageEqualityConfig: defaultKeyPackageEqualityConfig, - paddingConfig: defaultPaddingConfig, - authService: defaultAuthenticationService, -}; - -// 新実装に伴い保存形式を変更 -const DB_VERSION = 10; -const STORE_NAME = "mlsGroups"; -const KEY_STORE = "mlsKeyPairs"; -const CACHE_STORE = "cache"; -const EVIDENCE_STORE = "evidence"; -const KP_RECORD_STORE = "kpRecords"; - -const stores: Record = {}; - -async function openStore(accountId: string): Promise { - if (stores[accountId]) return stores[accountId]; - const store = await loadStore(`takos_${accountId}.json`); - const version = await store.get("version"); - if (version !== DB_VERSION) { - await store.clear(); - await store.set("version", DB_VERSION); - await store.save(); - } - stores[accountId] = store; - return store; -} - -function openDB(accountId: string): Promise { - const name = `takos_${accountId}`; - return new Promise((resolve, reject) => { - const req = indexedDB.open(name, DB_VERSION); - req.onupgradeneeded = (ev) => { - const db = req.result; - const oldVersion = (ev as IDBVersionChangeEvent).oldVersion ?? 0; - if (oldVersion < DB_VERSION) { - if (db.objectStoreNames.contains(STORE_NAME)) { - db.deleteObjectStore(STORE_NAME); - } - if (db.objectStoreNames.contains(KEY_STORE)) { - db.deleteObjectStore(KEY_STORE); - } - if (db.objectStoreNames.contains(EVIDENCE_STORE)) { - db.deleteObjectStore(EVIDENCE_STORE); - } - if (db.objectStoreNames.contains(KP_RECORD_STORE)) { - db.deleteObjectStore(KP_RECORD_STORE); - } - } - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - if (!db.objectStoreNames.contains(KEY_STORE)) { - db.createObjectStore(KEY_STORE, { autoIncrement: true }); - } - if (!db.objectStoreNames.contains(CACHE_STORE)) { - db.createObjectStore(CACHE_STORE); - } - if (!db.objectStoreNames.contains(EVIDENCE_STORE)) { - db.createObjectStore(EVIDENCE_STORE); - } - if (!db.objectStoreNames.contains(KP_RECORD_STORE)) { - db.createObjectStore(KP_RECORD_STORE); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -// StoredGroupState をシリアライズ/デシリアライズするユーティリティ -function serializeGroupState(state: StoredGroupState): ArrayBuffer { - const replacer = (_key: string, value: unknown): unknown => { - if (typeof value === "bigint") { - return { $type: "bigint", data: value.toString() }; - } - if (value instanceof Map) { - return { $type: "Map", data: Array.from(value.entries()) }; - } - if (ArrayBuffer.isView(value)) { - return { - $type: value.constructor.name, - data: Array.from(value as Uint8Array), - }; - } - return value; - }; - const plain = { ...state, clientConfig: undefined } as Record< - string, - unknown - >; - const json = JSON.stringify(plain, replacer); - return new TextEncoder().encode(json).buffer; -} - -function deserializeGroupState(buf: ArrayBuffer): StoredGroupState { - const reviver = (_key: string, value: unknown): unknown => { - if (value && typeof value === "object" && "$type" in value) { - const v = value as { $type: string; data: unknown }; - switch (v.$type) { - case "bigint": - return BigInt(v.data as string); - case "Map": - return new Map(v.data as Iterable); - default: { - const ctor = (globalThis as Record)[v.$type] as - | { new (data: unknown): unknown } - | undefined; - if (ctor) { - return new ctor(v.data as unknown); - } - } - } - } - return value; - }; - const json = new TextDecoder().decode(new Uint8Array(buf)); - const obj = JSON.parse(json, reviver); - return { ...obj, clientConfig: defaultClientConfig } as StoredGroupState; -} - -export const loadMLSGroupStates = async ( - accountId: string, -): Promise> => { - if (isTauri()) { - const store = await openStore(accountId); - return await store.get>("groups") ?? {}; - } - const db = await openDB(accountId); - const tx = db.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - return await new Promise((resolve, reject) => { - const req = store.openCursor(); - const result: Record = {}; - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - const buf = cursor.value as ArrayBuffer; - result[cursor.key as string] = deserializeGroupState(buf); - cursor.continue(); - } else { - resolve(result); - } - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveMLSGroupStates = async ( - accountId: string, - states: Record, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.set("groups", states); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - store.clear(); - for (const [roomId, state] of Object.entries(states)) { - store.put(serializeGroupState(state), roomId); - } - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -export const loadMLSKeyPair = async ( - accountId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - return await store.get("keyPair") ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readonly"); - const store = tx.objectStore(KEY_STORE); - return await new Promise((resolve, reject) => { - const req = store.openCursor(null, "prev"); - req.onsuccess = () => { - const cursor = req.result; - resolve(cursor ? (cursor.value as GeneratedKeyPair) : null); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveMLSKeyPair = async ( - accountId: string, - pair: GeneratedKeyPair, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.set("keyPair", pair); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readwrite"); - const store = tx.objectStore(KEY_STORE); - store.add(pair); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -export const loadRosterEvidence = async ( - accountId: string, - roomId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const ev = await store.get>( - "evidence", - ) ?? {}; - return ev[roomId] ?? []; - } - const db = await openDB(accountId); - const tx = db.transaction(EVIDENCE_STORE, "readonly"); - const store = tx.objectStore(EVIDENCE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => { - resolve((req.result as RosterEvidence[]) ?? []); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const appendRosterEvidence = async ( - accountId: string, - roomId: string, - evidence: RosterEvidence[], -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const ev = await store.get>( - "evidence", - ) ?? {}; - const current = ev[roomId] ?? []; - ev[roomId] = current.concat(evidence); - await store.set("evidence", ev); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(EVIDENCE_STORE, "readwrite"); - const store = tx.objectStore(EVIDENCE_STORE); - const current: RosterEvidence[] = await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => resolve((req.result as RosterEvidence[]) ?? []); - req.onerror = () => reject(req.error); - }); - store.put(current.concat(evidence), roomId); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// すべての鍵ペアを取得(プール運用向け) -export const loadAllMLSKeyPairs = async ( - accountId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - // tauri-store では一覧を保持していないため直近のみ - const last = await store.get("keyPair"); - return last ? [last] : []; - } - const db = await openDB(accountId); - const tx = db.transaction(KEY_STORE, "readonly"); - const store = tx.objectStore(KEY_STORE); - return await new Promise((resolve, reject) => { - const req = store.openCursor(); - const out: GeneratedKeyPair[] = []; - req.onsuccess = () => { - const cur = req.result; - if (cur) { - out.push(cur.value as GeneratedKeyPair); - cur.continue(); - } else { - resolve(out); - } - }; - req.onerror = () => reject(req.error); - }); -}; - -// 汎用キャッシュ(IndexedDB の CACHE_STORE を利用) -export const getCacheItem = async ( - accountId: string, - key: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const all = await store.get>("cache"); - return (all ?? {})[key] ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readonly"); - const store = tx.objectStore(CACHE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(key); - req.onsuccess = () => resolve(req.result ?? null); - req.onerror = () => reject(req.error); - }); -}; - -export const setCacheItem = async ( - accountId: string, - key: string, - value: unknown, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const all = await store.get>("cache") ?? {}; - all[key] = value; - await store.set("cache", all); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readwrite"); - const store = tx.objectStore(CACHE_STORE); - store.put(value, key); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// KeyPackage 検証記録 -export interface KeyPackageRecord { - kpUrl: string; - actorId: string; - leafIndex: number; - credentialFingerprint: string; - time: string; - ktIncluded?: boolean; -} - -export const loadKeyPackageRecords = async ( - accountId: string, - roomId: string, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const rec = await store.get>( - "kpRecords", - ) ?? {}; - return rec[roomId] ?? []; - } - const db = await openDB(accountId); - const tx = db.transaction(KP_RECORD_STORE, "readonly"); - const store = tx.objectStore(KP_RECORD_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => { - resolve((req.result as KeyPackageRecord[]) ?? []); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const appendKeyPackageRecords = async ( - accountId: string, - roomId: string, - records: KeyPackageRecord[], -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const rec = await store.get>( - "kpRecords", - ) ?? {}; - const current = rec[roomId] ?? []; - rec[roomId] = current.concat(records); - await store.set("kpRecords", rec); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(KP_RECORD_STORE, "readwrite"); - const store = tx.objectStore(KP_RECORD_STORE); - const current: KeyPackageRecord[] = await new Promise((resolve, reject) => { - const req = store.get(roomId); - req.onsuccess = () => resolve((req.result as KeyPackageRecord[]) ?? []); - req.onerror = () => reject(req.error); - }); - store.put(current.concat(records), roomId); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// キャッシュの読み書き -export interface CacheEntry { - timestamp: number; - value: T; -} - -export const loadCacheEntry = async ( - accountId: string, - key: string, -): Promise | null> => { - if (isTauri()) { - const store = await openStore(accountId); - const cache = await store.get>>("cache") ?? {}; - return cache[key] ?? null; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readonly"); - const store = tx.objectStore(CACHE_STORE); - return await new Promise((resolve, reject) => { - const req = store.get(key); - req.onsuccess = () => { - resolve(req.result as CacheEntry | null); - }; - req.onerror = () => reject(req.error); - }); -}; - -export const saveCacheEntry = async ( - accountId: string, - key: string, - value: T, -): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - const cache = await store.get>>("cache") ?? {}; - cache[key] = { timestamp: Date.now(), value } as CacheEntry; - await store.set("cache", cache); - await store.save(); - return; - } - const db = await openDB(accountId); - const tx = db.transaction(CACHE_STORE, "readwrite"); - const store = tx.objectStore(CACHE_STORE); - store.put({ timestamp: Date.now(), value } as CacheEntry, key); - await new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(undefined); - tx.onerror = () => reject(tx.error); - }); -}; - -// 復号済みメッセージの永続化(ブラウザ/tauri 両対応) -type SerializableChatMessage = Omit & { - timestamp: string; -}; - -function serializeMessages(list: ChatMessage[]): SerializableChatMessage[] { - return list.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() })); -} - -function deserializeMessages(list: SerializableChatMessage[]): ChatMessage[] { - return list.map((m) => ({ ...m, timestamp: new Date(m.timestamp) })); -} - -export const loadDecryptedMessages = async ( - accountId: string, - roomId: string, -): Promise => { - const key = `roomMsgs:${roomId}`; - const entry = await loadCacheEntry(accountId, key); - if (!entry || !Array.isArray(entry.value)) return null; - try { - return deserializeMessages(entry.value); - } catch { - return null; - } -}; - -export const saveDecryptedMessages = async ( - accountId: string, - roomId: string, - messages: ChatMessage[], - opts?: { max?: number }, -): Promise => { - const key = `roomMsgs:${roomId}`; - const max = opts?.max ?? 500; - // 新しいものから最大 max 件を保存 - const trimmed = messages - .slice(-max) - .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - await saveCacheEntry(accountId, key, serializeMessages(trimmed)); -}; - -export const deleteMLSDatabase = async (accountId: string): Promise => { - if (isTauri()) { - const store = await openStore(accountId); - await store.clear(); - await store.save(); - return; - } - await new Promise((resolve, reject) => { - const name = `takos_${accountId}`; - const req = indexedDB.deleteDatabase(name); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -}; diff --git a/app/client/src/components/e2ee/useMLS.ts b/app/client/src/components/e2ee/useMLS.ts deleted file mode 100644 index 2c8f91af9..000000000 --- a/app/client/src/components/e2ee/useMLS.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createSignal } from "solid-js"; -import { addKeyPackage } from "./api.ts"; -import { generateKeyPair } from "./mls_wrapper.ts"; -import { - bindingMessage, - type BindingStatus, - evaluateBinding, -} from "./binding.ts"; -import { loadKeyPackageRecords, loadRosterEvidence } from "./storage.ts"; - -export function useMLS(userName: string) { - const [status, setStatus] = createSignal(null); - const [error, setError] = createSignal(null); - const [bindingStatus, setBindingStatus] = createSignal( - null, - ); - const [bindingInfo, setBindingInfo] = createSignal< - { label: string; caution?: string } | null - >(null); - const [ktInfo, setKtInfo] = createSignal<{ included: boolean } | null>(null); - - const generateKeys = async () => { - try { - setStatus("鍵を生成中..."); - setError(null); - // BasicCredential.identity に Actor の URL を設定する - const actorId = - new URL(`/users/${userName}`, globalThis.location.origin).href; - const kp = await generateKeyPair(actorId); - await addKeyPackage(userName, { content: kp.encoded }); - setStatus("鍵を生成しました"); - } catch (err) { - console.error("鍵生成に失敗しました", err); - setStatus(null); - setError( - err instanceof Error ? err.message : "鍵生成に失敗しました", - ); - } - }; - - const assessBinding = async ( - accountId: string, - roomId: string, - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - ktIncluded?: boolean, - ) => { - const evidences = await loadRosterEvidence(accountId, roomId); - const result = evaluateBinding( - credentialActor, - leafSignatureKeyFpr, - evidences, - ); - setBindingStatus(result); - setBindingInfo(bindingMessage(result)); - setKtInfo({ included: ktIncluded ?? false }); - }; - - const assessMemberBinding = async ( - accountId: string, - roomId: string, - credentialActor: string | undefined, - leafSignatureKeyFpr: string, - ) => { - const evidences = await loadRosterEvidence(accountId, roomId); - const result = evaluateBinding( - credentialActor, - leafSignatureKeyFpr, - evidences, - ); - const records = await loadKeyPackageRecords(accountId, roomId); - const rec = records.find((r) => - r.credentialFingerprint === leafSignatureKeyFpr && - (!credentialActor || r.actorId === credentialActor) - ); - return { - status: result, - info: bindingMessage(result), - kt: { included: rec?.ktIncluded ?? false }, - }; - }; - - return { - generateKeys, - status, - error, - bindingStatus, - bindingInfo, - ktInfo, - assessBinding, - assessMemberBinding, - }; -} diff --git a/app/client/src/components/microblog/api.ts b/app/client/src/components/microblog/api.ts index 882bb8a38..4c2096548 100644 --- a/app/client/src/components/microblog/api.ts +++ b/app/client/src/components/microblog/api.ts @@ -1,6 +1,5 @@ import type { ActivityPubObject, MicroblogPost } from "./types.ts"; import { apiFetch, getDomain } from "../../utils/config.ts"; -import { loadCacheEntry, saveCacheEntry } from "../e2ee/storage.ts"; /** * ActivityPub Object を取得 @@ -327,52 +326,33 @@ const userInfoCache = new Map => { +): UserInfo | null => { const mem = userInfoCache.get(identifier); if (mem && Date.now() - mem.timestamp < CACHE_DURATION) { return mem.userInfo; } - if (accountId) { - const entry = await loadCacheEntry( - accountId, - `userInfo:${identifier}`, - ); - if (entry && Date.now() - entry.timestamp < CACHE_DURATION) { - userInfoCache.set(identifier, { - userInfo: entry.value, - timestamp: entry.timestamp, - }); - return entry.value; - } - } return null; }; -export const setCachedUserInfo = async ( +export const setCachedUserInfo = ( identifier: string, userInfo: UserInfo, - accountId?: string, -) => { +): void => { userInfoCache.set(identifier, { userInfo, timestamp: Date.now(), }); - if (accountId) { - await saveCacheEntry(accountId, `userInfo:${identifier}`, userInfo); - } }; // 新しい共通ユーザー情報取得API export const fetchUserInfo = async ( identifier: string, - accountId?: string, ): Promise => { try { // まずキャッシュから確認 - const cached = await getCachedUserInfo(identifier, accountId); + const cached = getCachedUserInfo(identifier); if (cached) { return cached; } @@ -387,7 +367,7 @@ export const fetchUserInfo = async ( const userInfo = await response.json(); // キャッシュに保存 - await setCachedUserInfo(identifier, userInfo, accountId); + setCachedUserInfo(identifier, userInfo); return userInfo; } catch (error) { @@ -399,14 +379,13 @@ export const fetchUserInfo = async ( // バッチでユーザー情報を取得 export const fetchUserInfoBatch = async ( identifiers: string[], - accountId?: string, ): Promise => { try { const cachedMap: Record = {}; const uncached: string[] = []; for (const identifier of identifiers) { - const cachedInfo = await getCachedUserInfo(identifier, accountId); + const cachedInfo = getCachedUserInfo(identifier); if (cachedInfo) { cachedMap[identifier] = cachedInfo; } else { @@ -429,7 +408,7 @@ export const fetchUserInfoBatch = async ( await Promise.all( fetchedInfos.map((info, index) => - setCachedUserInfo(uncached[index], info, accountId) + setCachedUserInfo(uncached[index], info) ), ); From d64f60e675d93c0e3e6e624458f97c9eb8d555c8 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Sat, 23 Aug 2025 08:59:49 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E5=8F=8B=E3=81=A0=E3=81=A1ID=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=AEDM=E3=82=B9=E3=83=AC=E3=83=83?= =?UTF-8?q?=E3=83=89=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/client/src/components/Chat.tsx | 33 +- .../src/components/chat/ChatRoomList.tsx | 562 +----------------- app/client/src/components/chat/FriendList.tsx | 5 +- .../src/components/chat/FriendRoomList.tsx | 213 ------- app/client/src/components/chat/api.ts | 11 +- 5 files changed, 51 insertions(+), 773 deletions(-) delete mode 100644 app/client/src/components/chat/FriendRoomList.tsx diff --git a/app/client/src/components/Chat.tsx b/app/client/src/components/Chat.tsx index 27b4b67bb..d36c05345 100644 --- a/app/client/src/components/Chat.tsx +++ b/app/client/src/components/Chat.tsx @@ -16,7 +16,8 @@ import { getDomain } from "../utils/config.ts"; export function Chat() { const [account] = useAtom(activeAccount); - const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); + // selectedRoomState を友だち ID として扱う + const [selectedFriend, setSelectedFriend] = useAtom(selectedRoomState); const [rooms, setRooms] = createSignal([]); const [messages, setMessages] = createSignal([]); const [newMessage, setNewMessage] = createSignal(""); @@ -28,7 +29,6 @@ export function Chat() { const user = account(); if (!user) return; const list = await searchRooms(user.userName); - const self = `${user.userName}@${getDomain()}`; const roomList: Room[] = list.map((r) => ({ id: r.id, name: r.id, @@ -36,7 +36,7 @@ export function Chat() { domain: getDomain(), unreadCount: 0, type: "group", - members: [self], + members: [r.id], })); setRooms(roomList); }; @@ -46,14 +46,14 @@ export function Chat() { }); createEffect(() => { - const roomId = selectedRoom(); - if (roomId) { - void loadMessages(roomId); + const fid = selectedFriend(); + if (fid) { + void loadMessages(fid); } }); - const loadMessages = async (roomId: string) => { - const list = await fetchMessages(roomId); + const loadMessages = async (friendId: string) => { + const list = await fetchMessages(friendId); const me = account(); const self = me ? `${me.userName}@${getDomain()}` : ""; const msgs: ChatMessage[] = list.map((m) => ({ @@ -71,11 +71,11 @@ export function Chat() { const handleSend = async () => { const text = newMessage().trim(); - const roomId = selectedRoom(); + const fid = selectedFriend(); const user = account(); - if (!text || !roomId || !user) return; + if (!text || !fid || !user) return; const me = `${user.userName}@${getDomain()}`; - const ok = await sendPlainMessage(roomId, me, [me], text); + const ok = await sendPlainMessage(me, fid, text); if (ok) { setMessages((prev) => [ ...prev, @@ -99,20 +99,21 @@ export function Chat() {
setSelectedRoom(id)} + selectedFriend={selectedFriend()} + onSelect={(id) => setSelectedFriend(id)} showAds={false} onCreateRoom={() => {}} segment={segment()} onSegmentChange={setSegment} />
- +
r.id === selectedRoom()) ?? null} - onBack={() => setSelectedRoom(null)} + selectedRoom={rooms().find((r) => r.id === selectedFriend()) ?? + null} + onBack={() => setSelectedFriend(null)} onOpenSettings={() => {}} /> void; showAds: boolean; onCreateRoom: () => void; segment: "all" | "people" | "groups"; onSegmentChange: (seg: "all" | "people" | "groups") => void; - onCreateFriendRoom?: (friendId: string) => void; } export function ChatRoomList(props: ChatRoomListProps) { - const useDelayedVisibility = ( - visible: () => boolean, - delay = 250, - min = 300, - ) => { - const [shown, setShown] = createSignal(false); - let delayTimer: number | undefined; - let minTimer: number | undefined; - let shownAt = 0; - - const clearTimers = () => { - if (delayTimer) clearTimeout(delayTimer); - if (minTimer) clearTimeout(minTimer); - delayTimer = undefined; - minTimer = undefined; - }; - - createEffect(() => { - const v = visible(); - if (v) { - if (!shown()) { - clearTimers(); - delayTimer = setTimeout(() => { - setShown(true); - shownAt = Date.now(); - }, delay) as unknown as number; - } - } else { - if (shown()) { - const elapsed = Date.now() - shownAt; - const rest = Math.max(0, min - elapsed); - clearTimers(); - minTimer = setTimeout( - () => setShown(false), - rest, - ) as unknown as number; - } else { - clearTimers(); - setShown(false); - } - } - }); - - onMount(() => { - // マウント時にクリア状態を保証 - clearTimers(); - }); - - return shown; - }; const [query, setQuery] = createSignal(""); - const [selectedFriend, setSelectedFriend] = createSignal(null); - const [account] = useAtom(activeAccount); - // リストが空のときだけ、遅延してスケルトンを表示する(点滅防止) - const showAllSkeleton = useDelayedVisibility( - () => getFilteredRoomsFor("all").length === 0 && query().trim() === "", - 250, - 250, - ); - const showGroupSkeleton = useDelayedVisibility( - () => getFilteredRoomsFor("groups").length === 0 && query().trim() === "", - 250, - 250, - ); - - // ローカルストレージに最後のセグメントを保存/復元 - onMount(() => { - const saved = globalThis.localStorage.getItem("chat.seg"); - if (saved === "all" || saved === "people" || saved === "groups") { - if (saved !== props.segment) props.onSegmentChange(saved); - } - }); - createEffect(() => { - globalThis.localStorage.setItem("chat.seg", props.segment); - }); - - // 1対1(未命名)トークの表示名を補正(自分の名前で表示されないように) - // かつ、招待中で自分しか居ないグループはプレースホルダーを表示 - const displayNameFor = (room: Room): string => { - // 明示的な displayName があれば最優先 - if (room.displayName && room.displayName.trim() !== "") { - return room.displayName; - } - const me = account(); - if (!me) return room.name; - if (room.type === "memo") return room.name; - const selfHandle = `${me.userName}@${getDomain()}`; - const members = room.members ?? []; - // グループ(1:1以外)はそのまま(後段の補完で名前が入る想定) - if (!isFriendRoom(room)) return room.name; - if (isFriendRoom(room)) { - const rawOther = room.members.find((m) => m !== selfHandle) ?? - room.members[0]; - const other = normalizeHandle(rawOther); - if ( - room.name === "" || room.name === me.displayName || - room.name === me.userName || room.name === selfHandle - ) { - // 自分名や空のときは相手のハンドルを優先 - if (other && other !== selfHandle) return other; - // 相手未確定なら pendingInvites から推測(接尾辞は付けない) - const cand = (room.pendingInvites && room.pendingInvites[0]) || - undefined; - const guess = normalizeHandle(cand); - if (guess && guess !== selfHandle) { - const short = guess.includes("@") ? guess.split("@")[0] : guess; - return short; - } - // 何も推定できない場合は空文字(表示は空のまま) - return ""; - } - return room.name; - } - return room.name; - }; - - const normalizeHandle = (id?: string): string | undefined => { - if (!id) return undefined; - if (id.startsWith("http")) { - try { - const u = new URL(id); - const name = u.pathname.split("/").pop() || ""; - if (!name) return undefined; - return `${name}@${u.hostname}`; - } catch { - return undefined; - } - } - if (id.includes("@")) return id; - // 裸の文字列(displayName/uuid等)はハンドルとみなさない - return undefined; - }; - - const getFilteredRoomsFor = (seg: "all" | "people" | "groups") => { - const q = query().toLowerCase().trim(); - let base = props.rooms; - - if (seg === "people") { - base = base.filter((r) => isFriendRoom(r)); - } else if (seg === "groups") { - const memoRoom = base.find((r) => r.type === "memo"); - const rest = base.filter((r) => isGroupRoom(r)); - base = memoRoom ? [memoRoom, ...rest] : rest; - } - - let list = q - ? base.filter((r) => { - const dn = displayNameFor(r).toLowerCase(); - const nm = (r.name || "").toLowerCase(); - const lm = (r.lastMessage ?? "").toLowerCase(); - return dn.includes(q) || nm.includes(q) || lm.includes(q); - }) - : base; - - list = list.filter((r, i, arr) => - arr.findIndex((x) => x.id === r.id) === i - ); - - const time = (d?: Date) => (d ? d.getTime() : 0); - list.sort((a, b) => { - const ua = a.unreadCount || 0; - const ub = b.unreadCount || 0; - if (ua !== ub) return ub - ua; - const ta = time(a.lastMessageTime); - const tb = time(b.lastMessageTime); - if (ta !== tb) return tb - ta; - return displayNameFor(a).localeCompare(displayNameFor(b)); - }); - return list; - }; - - const segUnread = createMemo(() => { - const all = props.rooms.reduce((a, r) => a + (r.unreadCount || 0), 0); - const people = props.rooms.filter((r) => isFriendRoom(r)) - .reduce((a, r) => a + (r.unreadCount || 0), 0); - const groups = all - people; - return { all, people, groups }; - }); - - const getFriendName = (friendId: string) => { - const room = props.rooms.find((r) => - isFriendRoom(r) && r.members.includes(friendId) - ); - return room?.displayName || room?.name || friendId.split("@")[0] || - friendId; - }; - - const changeSeg = (seg: "all" | "people" | "groups") => { - if (seg === "people") { - // 友だちタブの場合は友達リストにリセット - setSelectedFriend(null); - } - if (seg !== props.segment) props.onSegmentChange(seg); - }; - - const onKeyDownTabs = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.preventDefault(); - const order: ("all" | "people" | "groups")[] = [ - "all", - "people", - "groups", - ]; - const idx = order.indexOf(props.segment); - const next = e.key === "ArrowLeft" - ? order[(idx + order.length - 1) % order.length] - : order[(idx + 1) % order.length]; - changeSeg(next); - } - }; - - // スライド式セグメントタブ(共通表示、配置のみ条件で入れ替え) - const SegTabs = () => { - const segs = ["all", "people", "groups"] as const; - const idx = () => segs.indexOf(props.segment); - return ( -
onKeyDownTabs(e as unknown as KeyboardEvent)} - > -
- -
- ); - }; + // 未実装のため現在は使用しない + void props.showAds; + void props.onCreateRoom; return ( -
-
-
- チャット -
-
- -
-
-
- setQuery(e.currentTarget.value)} - /> - -
- -
-
-
- {/* 検索の直下に常にセグメントを表示(順序が入れ替わらない) */} - - - {/* セグメントを横スワイプで切替(ドラッグ中は隣が見える) */} +
- changeSeg((["all", "people", "groups"] as const)[i] ?? "all")} - > - {/* すべて */} -
-
    - -
  • - -
  • -
    - -
  • - -
  • -
    - - {(room) => ( -
  • props.onSelect(room.id)} - > -
    - - {isUrl(room.avatar) || - (typeof room.avatar === "string" && - room.avatar.startsWith("data:image/")) - ? ( - avatar - ) - : ( - - {room.avatar || - displayNameFor(room).charAt(0).toUpperCase()} - - )} - - - - - {displayNameFor(room)} - - - {room.lastMessageTime - ? room.lastMessageTime.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : ""} - - - -

    {room.lastMessage}

    -
    -
    -
    -
  • - )} -
    -
-
- - {/* 友だち */} -
- - setSelectedFriend(null)} - onCreateRoom={() => props.onCreateFriendRoom?.(selectedFriend()!)} - /> - - - setSelectedFriend(id)} - /> - -
- - {/* グループ */} -
-
    - -
  • - -
  • -
    - -
  • - -
  • -
    - - {(room) => ( -
  • props.onSelect(room.id)} - > -
    - - {isUrl(room.avatar) || - (typeof room.avatar === "string" && - room.avatar.startsWith("data:image/")) - ? ( - avatar - ) - : ( - - {room.avatar || - displayNameFor(room).charAt(0).toUpperCase()} - - )} - - - - - {displayNameFor(room)} - - - {room.lastMessageTime - ? room.lastMessageTime.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : ""} - - - -

    {room.lastMessage}

    -
    -
    -
    -
  • - )} -
    -
+ value={props.segment} + onChange={props.onSegmentChange} + tabs={[ + { value: "all", label: "すべて" }, + { value: "people", label: "友だち" }, + { value: "groups", label: "グループ" }, + ]} + /> + + + + +
+ グループ機能は未実装です
- +
); } - -// チャットルーム一覧のスケルトン -function RoomListSkeleton() { - const items = Array.from({ length: 6 }); - return ( -
    - {items.map(() => ( -
  • -
    - - - - - - - - - - - - -
    -
  • - ))} -
- ); -} diff --git a/app/client/src/components/chat/FriendList.tsx b/app/client/src/components/chat/FriendList.tsx index 4520940d3..2787e848a 100644 --- a/app/client/src/components/chat/FriendList.tsx +++ b/app/client/src/components/chat/FriendList.tsx @@ -235,10 +235,7 @@ export function FriendList(props: FriendListProps) { ? "bg-[#4a4a4a]" : "hover:bg-[#3c3c3c]" }`} - onClick={() => { - console.log("Friend clicked:", friend.id); // デバッグ用 - props.onSelectFriend(friend.id); - }} + onClick={() => props.onSelectFriend(friend.id)} >
{isUrl(friend.avatar) || diff --git a/app/client/src/components/chat/FriendRoomList.tsx b/app/client/src/components/chat/FriendRoomList.tsx deleted file mode 100644 index bc81d90a4..000000000 --- a/app/client/src/components/chat/FriendRoomList.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { createMemo, createSignal, For, Show } from "solid-js"; -import { isUrl } from "../../utils/url.ts"; -import type { Room } from "./types.ts"; - -interface FriendRoomListProps { - rooms: Room[]; - friendId: string; - friendName: string; - selectedRoom: string | null; - onSelectRoom: (roomId: string) => void; - onBack: () => void; - onCreateRoom: () => void; -} - -export function FriendRoomList(props: FriendRoomListProps) { - const [query, setQuery] = createSignal(""); - - // 選択された友達とのトークルームを取得 - const friendRooms = createMemo(() => { - return props.rooms.filter((room) => { - const members = (room.members ?? []).map(normalizeHandle).filter(( - v, - ): v is string => !!v); - if (members.includes(props.friendId)) return true; - // members が未補完のときは、ID が friendId と一致する 1:1 とみなす - const rid = normalizeHandle(room.id); - if (members.length === 0 && rid && rid === props.friendId) return true; - return false; - }); - }); - - function normalizeHandle(id?: string): string | undefined { - if (!id) return undefined; - if (id.startsWith("http")) { - try { - const u = new URL(id); - const name = u.pathname.split("/").pop() || ""; - if (!name) return undefined; - return `${name}@${u.hostname}`; - } catch { - return undefined; - } - } - if (id.includes("@")) return id; - // 裸の文字列はハンドルとみなさない - return undefined; - } - - const filteredRooms = createMemo(() => { - const q = query().toLowerCase().trim(); - const base = friendRooms(); - const byQuery = !q - ? base - : base.filter((r) => - r.name.toLowerCase().includes(q) || - (r.lastMessage ?? "").toLowerCase().includes(q) - ); - const time = (d?: Date) => (d ? d.getTime() : 0); - return byQuery.sort((a, b) => { - const ua = a.unreadCount || 0; - const ub = b.unreadCount || 0; - if (ua !== ub) return ub - ua; - const ta = time(a.lastMessageTime); - const tb = time(b.lastMessageTime); - if (ta !== tb) return tb - ta; - return a.name.localeCompare(b.name); - }); - }); - - return ( -
- {/* ヘッダー */} -
- -
-

{props.friendName}とのトーク

-
- -
- - {/* 検索バー */} -
- setQuery(e.currentTarget.value)} - /> -
- - {/* トークルームリスト */} -
- -
-
- - - -
-

- トークがありません -

-

- {props.friendName}との新しいトークを始めましょう -

- -
-
- -
- - {(room) => ( -
props.onSelectRoom(room.id)} - > -
- {isUrl(room.avatar) || - (typeof room.avatar === "string" && - room.avatar.startsWith("data:image/")) - ? ( - avatar - ) - : ( -
- {room.avatar || room.name.charAt(0).toUpperCase() || - "👥"} -
- )} -
- -
-
-

- {room.name || "無題のトーク"} -

- - {room.lastMessageTime - ? room.lastMessageTime.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : ""} - -
-
-

- {room.lastMessage || "メッセージがありません"} -

- 0}> - - {room.unreadCount} - - -
-
-
- )} -
-
-
-
- ); -} diff --git a/app/client/src/components/chat/api.ts b/app/client/src/components/chat/api.ts index f4fbf9d50..1bd829ca5 100644 --- a/app/client/src/components/chat/api.ts +++ b/app/client/src/components/chat/api.ts @@ -43,11 +43,11 @@ export const addRoom = async ( }; export const fetchMessages = async ( - roomId: string, + friendId: string, ): Promise => { try { const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/messages`, + `/api/messages/${encodeURIComponent(friendId)}`, ); if (!res.ok) return []; const data = await res.json(); @@ -59,15 +59,14 @@ export const fetchMessages = async ( }; export const sendMessage = async ( - roomId: string, from: string, - to: string[], + to: string, content: string, ): Promise => { try { - const payload = { from, to, content, mediaType: "text/plain" }; + const payload = { from, to: [to], content, mediaType: "text/plain" }; const res = await apiFetch( - `/api/rooms/${encodeURIComponent(roomId)}/messages`, + `/api/messages`, { method: "POST", headers: { "Content-Type": "application/json" },