From d14354cd2ac75f1ed8a284ce095464d0a70efa49 Mon Sep 17 00:00:00 2001 From: takoserver <96359093+tako0614@users.noreply.github.com> Date: Sat, 23 Aug 2025 10:29:53 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=8B=E3=82=89MLS=E9=96=A2=E9=80=A3=E8=A8=98?= =?UTF-8?q?=E8=BF=B0=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 +- app/client/src/components/Application.tsx | 36 +- app/client/src/components/Chat.tsx | 3274 +---------------- app/client/src/components/Profile.tsx | 10 +- app/client/src/components/Setting/index.tsx | 32 - .../src/components/chat/ChatRoomList.tsx | 550 +-- .../components/chat/ChatSettingsOverlay.tsx | 817 +--- .../src/components/chat/ChatTitleBar.tsx | 20 - app/client/src/components/chat/FriendList.tsx | 269 +- .../src/components/chat/FriendRoomList.tsx | 190 +- app/client/src/components/chat/api.ts | 53 + 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 | 69 +- app/client/src/components/utils/cache.ts | 42 + .../client/src/pages/WelcomePage.tsx | 16 +- docs/chat_ux.md | 334 -- docs/key-sharing.md | 18 - docs/multi-device-sync.md | 27 - docs/openapi.yaml | 180 +- docs/ui_ux.md | 1 - 26 files changed, 349 insertions(+), 8431 deletions(-) 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 create mode 100644 app/client/src/components/utils/cache.ts delete mode 100644 docs/chat_ux.md delete mode 100644 docs/key-sharing.md delete mode 100644 docs/multi-device-sync.md diff --git a/README.md b/README.md index 60fe4c967..e4d1efa0e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ takosはActivityPubでweb自主するためのソフトウェアです。 takosは、ActivityPubに追加で、以下の機能を提供します。 このソフトウェアは、1人のユーザが、他のユーザとコミュニケーションを取るためのものです。 -基本的に同一ドメインのユーザーは同一人物です。(サブアカウントなど) +基本的に同一ドメインのユーザーは同一人物です。(サブアカウントなど) ActivityPub +の投稿に加え、暗号化を行わないシンプルなダイレクトメッセージ機能を備え +ています。 ## 🔧 技術スタック @@ -165,29 +167,17 @@ ActivityPub 形式の一覧が必要な場合は、`/ap/users/:username/follower - `GET /api/accounts` – アカウント一覧取得 - `POST /api/accounts` – アカウント作成 -- `GET /api/accounts/:id` – アカウント取得(鍵情報を含む) +- `GET /api/accounts/:id` – アカウント取得 - `PUT /api/accounts/:id` – アカウント更新 - `DELETE /api/accounts/:id` – アカウント削除 ## チャット API -エンドツーエンド暗号化に対応したチャット機能の API です。 `/api/users/*` -プレフィックスには公開ユーザー情報取得用のエンドポイントも含まれますが、 -アカウント管理機能は `/api/accounts/*` で提供されます。 MLS -に関する暗号化・復号・状態管理はすべてクライアント側で行い、サーバーは -暗号化済みデータの保存と配送だけを担います。 - -- `GET /api/users/:user/keyPackages` – KeyPackage 一覧取得(`?summary=true` - で残数のみ取得) -- `POST /api/users/:user/keyPackages` – KeyPackage 登録(GroupInfo - や有効期限を付与可能) -- `POST /api/keyPackages/bulk` – 複数ユーザーの KeyPackage を一括登録 -- `GET /api/users/:user/keyPackages/:keyId` – KeyPackage 取得 -- `DELETE /api/users/:user/keyPackages/:keyId` – KeyPackage 削除 -- `GET /api/users/:user/encryptedKeyPair` – 暗号化鍵ペア取得 -- `POST /api/users/:user/encryptedKeyPair` – 暗号化鍵ペア保存 -- `DELETE /api/users/:user/encryptedKeyPair` – 暗号化鍵ペア削除 -- `POST /api/users/:user/resetKeys` – 鍵情報リセット +ActivityPub を利用したシンプルなダイレクトメッセージ機能の API +です。暗号化は行わ れません。 `/api/users/*` +プレフィックスには公開ユーザー情報取得用のエンドポイン +トも含まれますが、アカウント管理機能は `/api/accounts/*` で提供されます。 + - `GET /api/users/:user/messages` – メッセージ一覧取得 - `POST /api/users/:user/messages` – メッセージ送信 - `GET /api/users/:user/keep` – TAKO Keep に保存したメモ一覧を取得 @@ -214,15 +204,6 @@ ActivityPub 形式の一覧が必要な場合は、`/ap/users/:username/follower .env の例は `app/api/.env.example` を参照してください。 -## クライアントでのデータ保存 - -チャット機能で利用する MLS 関連データはブラウザの IndexedDB に保存されます。 -暗号化やグループ状態の更新もクライアント内で完結し、サーバーは暗号化済みデータを -そのまま中継します。データベースはアカウントごとに分割され、別アカウントの情報が混在しないようになっています。 - -鍵共有の仕組みについては [docs/key-sharing.md](docs/key-sharing.md) -を参照してください。 - ## OpenAPI仕様 APIの詳細仕様は [docs/openapi.yaml](docs/openapi.yaml) に記載されています。 diff --git a/app/client/src/components/Application.tsx b/app/client/src/components/Application.tsx index 12bbe0eb7..b694d7c69 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(() => { @@ -42,34 +39,7 @@ export function Application() { 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); + // KeyPackages 関連の定期処理は削除 }); // チャットページかつスマホ版かつチャンネルが選択されている場合にヘッダーが非表示の場合のクラス名を生成 diff --git a/app/client/src/components/Chat.tsx b/app/client/src/components/Chat.tsx index eafd0c568..6fe51793f 100644 --- a/app/client/src/components/Chat.tsx +++ b/app/client/src/components/Chat.tsx @@ -1,3212 +1,124 @@ -import { - createEffect, - createMemo, - createSignal, - on, - onCleanup, - onMount, - Show, -} from "solid-js"; +import { createEffect, createSignal } 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 { getDomain } from "../utils/config.ts"; +import { fetchDirectMessages, sendDirectMessage } from "./chat/api.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 { ChatSettingsOverlay } from "./chat/ChatSettingsOverlay.tsx"; export function Chat() { - const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); // グローバル状態を使用 + const [selectedRoom, setSelectedRoom] = useAtom(selectedRoomState); const [account] = useAtom(activeAccount); - const { bindingStatus, bindingInfo, assessBinding, ktInfo } = useMLS( - account()?.userName ?? "", - ); + const [rooms, setRooms] = createSignal([]); + const [messages, setMessages] = createSignal([]); 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 [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 [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; - }); + const [isSettingsOpen, setSettingsOpen] = createSignal(false); + // アカウントのフォロー/フォロワーから友だち候補ルームを生成 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 acc = account(); + if (!acc) return; + const friends = Array.from( + new Set([...(acc.following || []), ...(acc.followers || [])]), ); - // 復号は古い順に処理しないとラチェットが進まず失敗するため昇順で処理 - 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 || "", - userName: user.userName, - domain: getDomain(), - avatar: "", + const list: Room[] = friends.map((f) => ({ + id: f, + name: f, + userName: f.split("@")[0] ?? f, + domain: f.includes("@") ? f.split("@")[1] : "", 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); - }; + members: [f], + })); + setRooms(list); + }); - 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; + // 選択中ルームのメッセージを取得 + createEffect(async () => { + const acc = account(); + const peer = selectedRoom(); + if (!acc || !peer) { + setMessages([]); + return; } - }; + const selfHandle = `${acc.userName}@${getDomain()}`; + const objs = await fetchDirectMessages(selfHandle, peer); + const list: ChatMessage[] = objs.map((o) => ({ + id: o.id, + author: o.attributedTo, + displayName: o.attributedTo, + address: o.attributedTo, + content: o.content ?? "", + timestamp: new Date(o.published), + type: "text", + isMe: o.attributedTo === selfHandle, + })); + setMessages(list); + }); 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; - }); + const acc = account(); + const room = rooms().find((r) => r.id === selectedRoom()); + if (!acc || !room) return; + const selfHandle = `${acc.userName}@${getDomain()}`; + const ok = await sendDirectMessage(selfHandle, room.members, newMessage()); + if (ok) { 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); - }; - - // モバイルでの部屋選択時の動作 - const selectRoom = (roomId: string) => { - console.log("selected room:", roomId); // for debug - setSelectedRoom(roomId); - if (isMobile()) { - setShowRoomList(false); // モバイルではチャット画面に切り替え + const objs = await fetchDirectMessages(selfHandle, room.id); + const list: ChatMessage[] = objs.map((o) => ({ + id: o.id, + author: o.attributedTo, + displayName: o.attributedTo, + address: o.attributedTo, + content: o.content ?? "", + timestamp: new Date(o.published), + type: "text", + isMe: o.attributedTo === selfHandle, + })); + setMessages(list); } - // メッセージの取得は 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); - } - }); - - createEffect(() => { - account(); - loadGroupStates(); - ensureKeyPair(); - }); - - createEffect(() => { - groups(); - saveGroupStates(); - }); - - createEffect(() => { - newMessage(); - adjustHeight(textareaRef); - }); - - createEffect(() => { - if (!partnerHasKey()) { - alert("このユーザーは暗号化された会話に対応していません。"); - } - }); - - 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 受信時の参加確認バナー */} - -
-
- この会話に招待されています。参加しますか? -
-
- - -
-
-
- -
- -
-
- - { - 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={() => setSettingsOpen(true)} + /> + {}} /> + + r.id === selectedRoom()) ?? null} + onClose={() => setSettingsOpen(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..1578ef2ec 100644 --- a/app/client/src/components/Profile.tsx +++ b/app/client/src/components/Profile.tsx @@ -8,7 +8,6 @@ import { } from "./microblog/api.ts"; import { PostList } from "./microblog/Post.tsx"; import { UserAvatar } from "./microblog/UserAvatar.tsx"; -import { addRoom } from "./e2ee/api.ts"; import { accounts as accountsAtom, activeAccount, @@ -19,6 +18,7 @@ import { selectedAppState } from "../states/app.ts"; import { selectedRoomState } from "../states/chat.ts"; import { apiFetch, getDomain } from "../utils/config.ts"; import { isDataUrl } from "./home/types.ts"; +import { sendDirectMessage } from "./chat/api.ts"; export default function Profile() { const [username, setUsername] = useAtom(profileUserState); @@ -245,12 +245,8 @@ export default function Profile() { const user = account(); if (!name || !user) return; const handle = normalizeActor(name); - const me = `${user.userName}@${getDomain()}`; - await addRoom( - user.id, - { id: handle, name: handle, members: [handle, me] }, - { from: me, content: "hi", to: [handle, me] }, - ); + const selfHandle = `${user.userName}@${getDomain()}`; + await sendDirectMessage(selfHandle, [handle], ""); setRoom(handle); setApp("chat"); }; diff --git a/app/client/src/components/Setting/index.tsx b/app/client/src/components/Setting/index.tsx index 66bab38a9..37fb7c7de 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,11 +18,7 @@ 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 +53,6 @@ export function Setting() {

FASP 設定

-
-

MLS 鍵管理

- - -

{status()}

-
- -

{error()}

-
-
- ))} -
- - ); - }; - 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}

    -
    -
    -
    -
  • - )} -
    -
-
-
+ ); } - -// チャットルーム一覧のスケルトン -function RoomListSkeleton() { - const items = Array.from({ length: 6 }); - return ( -
    - {items.map(() => ( -
  • -
    - - - - - - - - - - - - -
    -
  • - ))} -
- ); -} diff --git a/app/client/src/components/chat/ChatSettingsOverlay.tsx b/app/client/src/components/chat/ChatSettingsOverlay.tsx index 46fd3a65a..849aa2d6b 100644 --- a/app/client/src/components/chat/ChatSettingsOverlay.tsx +++ b/app/client/src/components/chat/ChatSettingsOverlay.tsx @@ -1,816 +1,29 @@ -import { createEffect, createSignal, For, Show } from "solid-js"; -import { useAtom } from "solid-jotai"; -import { activeAccount } from "../../states/account.ts"; -import { apiFetch, getDomain } from "../../utils/config.ts"; +import { Show } from "solid-js"; import type { Room } from "./types.ts"; -import type { BindingStatus } from "../e2ee/binding.ts"; -import { useMLS } from "../e2ee/useMLS.ts"; -import { - getCacheItem, - loadMLSGroupStates, - setCacheItem, -} from "../e2ee/storage.ts"; -import type { StoredGroupState } from "../e2ee/mls_wrapper.ts"; -import { fetchUserInfo } from "../microblog/api.ts"; -import { - fetchEncryptedMessages, - fetchKeyPackages, - sendGroupMetadata, - sendHandshake, -} from "../e2ee/api.ts"; -import { createCommitAndWelcomes } from "../e2ee/mls_wrapper.ts"; -import { encodePublicMessage } from "../e2ee/mls_message.ts"; interface ChatSettingsOverlayProps { isOpen: boolean; room: Room | null; onClose: () => void; - onRoomUpdated?: (partial: Partial) => void; - bindingStatus?: BindingStatus | null; - bindingInfo?: { label: string; caution?: string } | null; - ktInfo?: { included: boolean } | null; - onRemoveMember?: (id: string) => Promise; - // 親(Chat)から渡される現在のグループ状態(ブラウザでは保存されないため) - groupState?: StoredGroupState | null; -} - -interface MemberItem { - id: string; // actor id (@user@domain) または識別子 - display: string; - avatar?: string; - actor?: string; - leafSignatureKeyFpr: string; - bindingStatus: BindingStatus; - bindingInfo: { label: string; caution?: string }; - ktIncluded: boolean; } export function ChatSettingsOverlay(props: ChatSettingsOverlayProps) { - const [accountValue] = useAtom(activeAccount); - const { assessMemberBinding } = useMLS(accountValue()?.userName ?? ""); - const [tab, setTab] = createSignal<"general" | "members" | "appearance">( - "general", - ); - const [roomName, setRoomName] = createSignal(""); - const [roomIcon, setRoomIcon] = createSignal(null); - const [uploading, setUploading] = createSignal(false); - const [members, setMembers] = createSignal([]); - const [pending, setPending] = createSignal([]); - const [newMember, setNewMember] = createSignal(""); - const [saving, setSaving] = createSignal(false); - const [error, setError] = createSignal(null); - - createEffect(() => { - if (props.isOpen && props.room) { - setRoomName(props.room.name ?? ""); - setRoomIcon(props.room.avatar || null); - void loadMembers(); - } - }); - - const loadMembers = async () => { - const r = props.room; - const user = accountValue(); - if (!r || !user) return; - try { - // サーバーのメンバーAPIは使用しない。MLS 由来で取得 - await loadMembersFromMLS(r.id, props.groupState ?? undefined); - await loadPendingFromStorage(r.id); - } catch (e) { - console.warn("loadMembers failed", e); - await loadMembersFromMLS(r.id, props.groupState ?? undefined); - await loadPendingFromStorage(r.id); - } - }; - // 招待中リストの保存・読込(アカウント/ルーム単位でlocalStorage保持) - const cacheKey = (roomId: string) => `pendingInvites:${roomId}`; - const readPending = async (roomId: string): Promise => { - const user = accountValue(); - if (!user) return []; - const raw = await getCacheItem(user.id, cacheKey(roomId)); - return Array.isArray(raw) - ? (raw as unknown[]).filter((v) => typeof v === "string") as string[] - : []; - }; - const writePending = async (roomId: string, ids: string[]) => { - const user = accountValue(); - if (!user) return; - const uniq = Array.from(new Set(ids)); - await setCacheItem(user.id, cacheKey(roomId), uniq); - }; - const addPending = async (roomId: string, ids: string[]) => { - const cur = await readPending(roomId); - await writePending(roomId, [...cur, ...ids]); - }; - const _removePending = async (roomId: string, id: string) => { - const cur = (await readPending(roomId)).filter((v) => v !== id); - await writePending(roomId, cur); - }; - const loadPendingFromStorage = async ( - roomId: string, - presentIds?: string[], - ) => { - const user = accountValue(); - if (!user) return setPending([]); - const present = new Set(presentIds ?? members().map((m) => m.id)); - const rawIds = await readPending(roomId); - const list: MemberItem[] = []; - for (const raw of rawIds) { - const handle = normalizeHandle(raw); - if (handle && present.has(handle)) continue; // 既にメンバー - if (handle) { - try { - const info = await fetchUserInfo(handle); - const resEval = await assessMemberBinding( - user.id, - roomId, - handle, - "", - ); - list.push({ - id: handle, - display: info?.displayName || info?.userName || handle, - avatar: info?.authorAvatar, - actor: handle, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - }); - continue; - } catch { - console.error("ユーザー情報の取得に失敗しました"); - } - } - const resEval = await assessMemberBinding(user.id, roomId, undefined, ""); - list.push({ - id: raw, - display: "不明", - avatar: undefined, - actor: undefined, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - }); - } - setPending(list); - }; - - const loadMembersFromMLS = async ( - roomId: string, - stateFromParent?: StoredGroupState, - ) => { - const user = accountValue(); - if (!user) return setMembers([]); - try { - const state = stateFromParent ?? (await (async () => { - const stored = await loadMLSGroupStates(user.id); - return stored[roomId] as StoredGroupState | undefined; - })()); - if (!state) { - // 最後のフォールバック: props.room.members から推測 - const self = `${user.userName}@${getDomain()}`; - const fallback = (props.room?.members ?? []) - .map((id) => normalizeHandle(id)) - .filter((id): id is string => !!id) - .filter((id) => id !== self); - const derived = deriveIdsFromRoom(self); - const ids = [...new Set([...(fallback ?? []), ...derived])]; - if (ids.length > 0) { - const list = await Promise.all(ids.map(async (id) => { - const info = await fetchUserInfo(id); - const resEval = await assessMemberBinding(user.id, roomId, id, ""); - return { - id, - display: info?.displayName || info?.userName || id, - avatar: info?.authorAvatar, - actor: id, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - } as MemberItem; - })); - setMembers(list); - await loadPendingFromStorage(roomId, list.map((m) => m.id)); - return; - } - // さらに履歴メッセージから推測 - return await loadMembersFromMessages(roomId); - } - const self = `${user.userName}@${getDomain()}`; - const raws = extractIdentities(state); - const ids = raws - .map((id) => normalizeHandle(id)) - .filter((id): id is string => !!id); - const unknown = raws - .filter((raw) => !normalizeHandle(raw)) - .filter((raw) => !!raw); - if (ids.length === 0 && unknown.length === 0) { - const derived = deriveIdsFromRoom(self); - if (derived.length > 0) { - const list = await Promise.all(derived.map(async (id) => { - const info = await fetchUserInfo(id); - const resEval = await assessMemberBinding(user.id, roomId, id, ""); - return { - id, - display: info?.displayName || info?.userName || id, - avatar: info?.authorAvatar, - actor: id, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - } as MemberItem; - })); - setMembers(list); - return; - } - return await loadMembersFromMessages(roomId); - } - const list = await Promise.all(ids.map(async (id) => { - const info = await fetchUserInfo(id); - const resEval = await assessMemberBinding(user.id, roomId, id, ""); - return { - id, - display: info?.displayName || info?.userName || id, - avatar: info?.authorAvatar, - actor: id, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - } as MemberItem; - })); - const unknownList = await Promise.all(unknown.map(async (raw) => { - const resEval = await assessMemberBinding( - user.id, - roomId, - undefined, - "", - ); - return { - id: raw, - display: "不明", - avatar: undefined, - actor: undefined, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - } as MemberItem; - })); - setMembers([...list, ...unknownList]); - await loadPendingFromStorage(roomId, list.map((m) => m.id)); - await loadPendingFromStorage(roomId, list.map((m) => m.id)); - } catch (err) { - console.warn("loadMembersFromMLS failed", err); - await loadMembersFromMessages(roomId); - } - }; - - const loadMembersFromMessages = async (roomId: string) => { - const user = accountValue(); - if (!user) return setMembers([]); - try { - const self = `${user.userName}@${getDomain()}`; - const msgs = await fetchEncryptedMessages(roomId, self, { limit: 100 }); - const set = new Set(); - for (const m of msgs) { - if (typeof m.from === "string") { - const h = normalizeHandle(m.from); - if (h && h !== self) set.add(h); - } - if (Array.isArray(m.to)) { - for (const t of m.to) { - if (typeof t === "string") { - const h = normalizeHandle(t); - if (h && h !== self) set.add(h); - } - } - } - } - const ids = Array.from(set); - const list = await Promise.all(ids.map(async (id) => { - const info = await fetchUserInfo(id); - const resEval = await assessMemberBinding(user.id, roomId, id, ""); - return { - id, - display: info?.displayName || info?.userName || id, - avatar: info?.authorAvatar, - actor: id, - leafSignatureKeyFpr: "", - bindingStatus: resEval.status, - bindingInfo: resEval.info, - ktIncluded: resEval.kt.included, - } as MemberItem; - })); - setMembers(list); - } catch (err) { - console.warn("loadMembersFromMessages failed", err); - setMembers([]); - } - }; - - const extractIdentities = (state: StoredGroupState): string[] => { - const out: 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) out.push(new TextDecoder().decode(id)); - } - } - return out; - }; - - const deriveIdsFromRoom = (self: string): string[] => { - const out = new Set(); - const id = normalizeHandle(props.room?.id); - if (id && id !== self) out.add(id); - const name = normalizeHandle(props.room?.name || ""); - if (name && name !== self) out.add(name); - return Array.from(out); - }; - - 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; - // 裸の文字列はハンドルとみなさない - return undefined; - }; - - const normalizeActor = ( - input: string, - ): { user: string; domain?: string } | null => { - let v = input.trim(); - if (!v) return null; - if (v.startsWith("http")) { - try { - const url = new URL(v); - const name = url.pathname.split("/").pop() || ""; - if (!name) return null; - return { user: `${name}@${url.hostname}` } as { - user: string; - domain?: string; - }; - } catch { - return null; - } - } - if (v.startsWith("@")) v = v.slice(1); - if (v.includes("@")) { - return { user: v } as { user: string; domain?: string }; - } - // ドメイン省略時はローカル扱い - return { user: v, domain: getDomain() }; - }; - - const handleAddMember = async () => { - const value = newMember().trim(); - if (!value || !props.room) return; - const user = accountValue(); - if (!user) return; - try { - setSaving(true); - const ident = normalizeActor(value); - if (!ident) throw new Error("メンバーIDの形式が不正です"); - // 追加する相手の KeyPackage を取得 - const [name, dom] = ident.user.includes("@") - ? ((): [string, string | undefined] => { - const [u, d] = ident.user.split("@"); - return [u, d]; - })() - : [ident.user, ident.domain]; - const kps = await fetchKeyPackages(name, dom); - if (!kps || kps.length === 0) { - throw new Error("相手のKeyPackageが見つかりません"); - } - const kpInput = { - content: kps[0].content, - actor: dom ? `https://${dom}/users/${name}` : undefined, - deviceId: kps[0].deviceId, - }; - const state = props.groupState; - if (!state) throw new Error("ルームの暗号状態が未初期化です"); - // 追加用の Commit/Welcome を生成 - const res = await createCommitAndWelcomes(state, [kpInput]); - // Handshake として送信(commit と welcome) - const commitContent = encodePublicMessage(res.commit); - // 既知のメンバー(UIが持つ room.members)と自分を宛先に含める - const self = `${user.userName}@${getDomain()}`; - const toList = Array.from( - new Set([...(props.room?.members ?? []), self]), - ); - const ok = await sendHandshake( - props.room.id, - `${user.userName}@${getDomain()}`, - commitContent, - toList, - ); - if (!ok) throw new Error("Commitの送信に失敗しました"); - for (const w of res.welcomes) { - const wContent = encodePublicMessage(w.data); - const wk = await sendHandshake( - props.room.id, - `${user.userName}@${getDomain()}`, - wContent, - toList, - ); - if (!wk) throw new Error("Welcomeの送信に失敗しました"); - } - await sendGroupMetadata( - props.room.id, - `${user.userName}@${getDomain()}`, - res.state, - toList, - { name: props.room.name, icon: props.room.avatar ?? undefined }, - ); - // 招待中に登録(Join済みになれば自動でmembers側に移動) - const target = normalizeHandle(ident.user); - if (target) await addPending(props.room.id, [target]); - await loadMembers(); - setNewMember(""); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; - - const handleRemoveMember = async (id: string) => { - if (!confirm(`${id} を削除しますか?`)) return; - try { - setSaving(true); - if (props.onRemoveMember) { - const ok = await props.onRemoveMember(id); - if (!ok) throw new Error("remove failed"); - } - await loadMembers(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; - - const handleIconChange = async (file: File) => { - setUploading(true); - try { - const form = new FormData(); - form.append("file", file); - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(props.room!.id)}/icon`, - { method: "POST", body: form }, - ); - if (!res.ok) throw new Error("icon upload failed"); - const data = await res.json(); - setRoomIcon(data.url || null); - props.onRoomUpdated?.({ avatar: data.url }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setUploading(false); - } - }; - - const handleSaveGeneral = async () => { - if (!props.room) return; - if (!roomName().trim()) { - setError("名前を入力してください"); - return; - } - try { - setSaving(true); - const res = await apiFetch( - `/api/rooms/${encodeURIComponent(props.room.id)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: roomName() }), - }, - ); - if (!res.ok) throw new Error("update failed"); - props.onRoomUpdated?.({ name: roomName() }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; - - const close = () => { - setError(null); - setNewMember(""); - props.onClose(); - }; - return ( -
-
-
-
-
-
-

設定

- -
- -
- -
- {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 */} -
- )} -
-
-
-
- -
メンバー無し
-
-
-
-
-
- -
-

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

-
-
-
+
+
+

チャット設定

+

+ {props.room?.name ?? "選択中のチャット"} +

+
+
diff --git a/app/client/src/components/chat/ChatTitleBar.tsx b/app/client/src/components/chat/ChatTitleBar.tsx index a889cf595..881dbfc1f 100644 --- a/app/client/src/components/chat/ChatTitleBar.tsx +++ b/app/client/src/components/chat/ChatTitleBar.tsx @@ -4,7 +4,6 @@ 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; @@ -12,9 +11,6 @@ interface ChatTitleBarProps { 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 +98,6 @@ export function ChatTitleBar(props: ChatTitleBarProps) {

{titleFor(props.selectedRoom)}

- - - {props.bindingInfo!.label} - - - - - {props.bindingInfo!.caution} - - - - 監査未検証 -
diff --git a/app/client/src/components/chat/FriendList.tsx b/app/client/src/components/chat/FriendList.tsx index 4520940d3..bbbe7a018 100644 --- a/app/client/src/components/chat/FriendList.tsx +++ b/app/client/src/components/chat/FriendList.tsx @@ -1,184 +1,50 @@ import { createMemo, createSignal, For, Show } from "solid-js"; -import { isUrl } from "../../utils/url.ts"; import type { Room } from "./types.ts"; -import { useAtom } from "solid-jotai"; -import { activeAccount } from "../../states/account.ts"; -import { getDomain } from "../../utils/config.ts"; interface Friend { - id: string; // actor ID + id: string; name: string; avatar?: string; - isOnline?: boolean; - domain?: string; } interface FriendListProps { rooms: Room[]; onSelectFriend: (friendId: string) => void; selectedFriend?: string | null; - query?: string; // 親から検索語を受け取る場合に使用 - onQueryChange?: (v: string) => void; // 親に検索語変更を通知する場合に使用 - showSearch?: boolean; // 内部の検索バーを表示するか + query?: string; + onQueryChange?: (v: string) => void; + showSearch?: boolean; } export function FriendList(props: FriendListProps) { - const [account] = useAtom(activeAccount); const [localQuery, setLocalQuery] = createSignal(""); const q = () => (props.query !== undefined ? props.query : localQuery()); const setQuery = (v: string) => props.onQueryChange ? props.onQueryChange(v) : setLocalQuery(v); - // ルームから友達リストを生成 const friends = createMemo(() => { - const me = account(); - const selfHandle = me ? `${me.userName}@${getDomain()}` : undefined; - const selfShort = me?.userName; - const isSelf = (id?: string) => { - if (!id) return false; - const h = normalizeHandle(id); - if (h && selfHandle && h === selfHandle) return true; - if (selfHandle && id === selfHandle) return true; - if (selfShort && id === selfShort) return true; - // 自分の actor URL 形式 - try { - if (id.startsWith("http")) { - const u = new URL(id); - const name = u.pathname.split("/").pop() || ""; - if (selfShort && name === selfShort && u.hostname === getDomain()) { - return true; - } - } - } catch { /* ignore */ } - return false; - }; - const isUuid = (v?: string) => - !!v && - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - .test(v); - const friendMap = new Map(); - // フレンド候補: 自分以外の候補がちょうど1名のルーム(名前の有無は問わない) - const candidateRooms = props.rooms.filter((r) => { - if (r.type === "memo") return false; - const base = [ - ...((r.members ?? []).filter((m: unknown): m is string => - typeof m === "string" && !!m - )), - ...((r.pendingInvites ?? []).filter((m: unknown): m is string => - typeof m === "string" && !!m - )), - ]; - let normalized = base.map((m) => normalizeHandle(m) || m); - normalized = normalized.filter((m) => !!m && !isSelf(m)); - // members/pending が空なら room.id を候補に(1:1の actor id ルーム想定) - if (normalized.length === 0) { - const rid = normalizeHandle(r.id) || r.id; - if (rid && !isSelf(rid) && !isUuid(rid)) normalized = [rid]; - } - const uniqueOthers = Array.from(new Set(normalized)); - return uniqueOthers.length === 1; - }); - for (const room of candidateRooms) { - const base = [ - ...((room.members ?? []).filter((m: unknown): m is string => - typeof m === "string" && !!m - )), - ...((room.pendingInvites ?? []).filter((m: unknown): m is string => - typeof m === "string" && !!m - )), - ]; - let normalized = base.map((m) => normalizeHandle(m) || m); - normalized = normalized.filter((m) => !!m && !isSelf(m)); - // 自分以外が見つからない場合は room.id を使用(1:1の actor id ルーム想定) - if (normalized.length === 0) { - const rid = normalizeHandle(room.id) || room.id; - if (rid && !isSelf(rid) && !isUuid(rid)) normalized = [rid]; - } - const raw = normalized[0]; - if (!raw) continue; - const friendId = raw; - if (selfHandle && friendId === selfHandle) continue; - if (!friendMap.has(friendId)) { - const isSelfLikeName = me && - (room.name === me.displayName || room.name === me.userName || - room.name === selfHandle); - const short = friendId.includes("@") - ? friendId.split("@")[0] - : friendId; - const fallbackName = short || friendId; - friendMap.set(friendId, { - id: friendId, - name: (room.displayName && !isSelfLikeName) - ? room.displayName - : (room.name && !isSelfLikeName) - ? room.name - : fallbackName, - avatar: room.avatar, - domain: friendId.includes("@") ? friendId.split("@")[1] : undefined, - }); - } + const map = new Map(); + for (const room of props.rooms) { + const id = room.members?.[0]; + if (!id || map.has(id)) continue; + map.set(id, { id, name: room.name || id, avatar: room.avatar }); } - // 並び順: 未読合計 → 最終アクティビティ → 名前 - const items = Array.from(friendMap.values()); - const unreadSum = (fid: string) => - props.rooms - .filter((r) => r.type !== "memo" && !(r.hasName || r.hasIcon)) - .filter((r) => - (r.members?.includes(fid)) || (r.pendingInvites?.includes(fid)) - ) - .reduce((a, r) => a + (r.unreadCount || 0), 0); - const lastTime = (fid: string) => { - let t = 0; - for (const r of props.rooms) { - if (r.type === "memo") continue; - const match = (r.members?.includes(fid)) || - (r.pendingInvites?.includes(fid)); - if (!match) continue; - const ts = r.lastMessageTime ? r.lastMessageTime.getTime() : 0; - if (ts > t) t = ts; - } - return t; - }; - items.sort((a, b) => { - const ua = unreadSum(a.id); - const ub = unreadSum(b.id); - if (ua !== ub) return ub - ua; - const ta = lastTime(a.id); - const tb = lastTime(b.id); - if (ta !== tb) return tb - ta; - return (a.name || a.id).localeCompare(b.name || b.id); - }); - return items; + return Array.from(map.values()).sort((a, b) => + (a.name || a.id).localeCompare(b.name || b.id) + ); }); - 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 filteredFriends = createMemo(() => { const qq = q().toLowerCase().trim(); if (!qq) return friends(); - return friends().filter((f) => - f.name.toLowerCase().includes(qq) || f.id.toLowerCase().includes(qq) + return friends().filter( + (f) => + f.name.toLowerCase().includes(qq) || f.id.toLowerCase().includes(qq), ); }); return (
- {/* 検索バー(必要に応じて非表示可) */}
- - {/* 友達リスト */}
-
-
- -
-

- 友だちがいません -

-

- 新しいトークを開始して友だちを増やしましょう -

-
+
友だちが見つかりません
- -
- - {(friend) => ( -
{ - console.log("Friend clicked:", friend.id); // デバッグ用 - props.onSelectFriend(friend.id); - }} - > -
- {isUrl(friend.avatar) || - (typeof friend.avatar === "string" && - friend.avatar.startsWith("data:image/")) - ? ( - avatar - ) - : ( -
- {friend.avatar || friend.name.charAt(0).toUpperCase()} -
- )} - - {/* オンライン状態の表示(将来的に実装) */} - -
-
-
-
- -
-

- {friend.name} -

-

- {friend.id} -

-
-
- )} -
-
+ + {(f) => ( +
props.onSelectFriend(f.id)} + > + {f.avatar + ? + : ( +
+ {f.name.charAt(0).toUpperCase()} +
+ )} + {f.name} +
+ )} +
); diff --git a/app/client/src/components/chat/FriendRoomList.tsx b/app/client/src/components/chat/FriendRoomList.tsx index bc81d90a4..04c6a783b 100644 --- a/app/client/src/components/chat/FriendRoomList.tsx +++ b/app/client/src/components/chat/FriendRoomList.tsx @@ -1,83 +1,44 @@ 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 friendRooms = createMemo(() => + props.rooms.filter( + (r) => r.members?.includes(props.friendId) || r.id === props.friendId, + ) + ); const filteredRooms = createMemo(() => { const q = query().toLowerCase().trim(); - const base = friendRooms(); - const byQuery = !q - ? base - : base.filter((r) => + const list = friendRooms(); + if (!q) return list; + return list.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); - }); + (r.lastMessage ?? "").toLowerCase().includes(q), + ); }); return (
- {/* ヘッダー */} -
+
-
-

{props.friendName}とのトーク

-
- +

{props.friendId}とのトーク

- - {/* 検索バー */}
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} - - -
-
+ + {(room) => ( +
props.onSelectRoom(room.id)} + > +
{room.name || room.id}
+
+ {room.lastMessage || "メッセージがありません"}
- )} - -
+
+ )} +
); diff --git a/app/client/src/components/chat/api.ts b/app/client/src/components/chat/api.ts new file mode 100644 index 000000000..e7f555842 --- /dev/null +++ b/app/client/src/components/chat/api.ts @@ -0,0 +1,53 @@ +import type { ActivityPubObject } from "../microblog/types.ts"; +import { apiFetch } from "../../utils/config.ts"; + +/** + * to が自分のみ、または指定相手とのダイレクトメッセージを取得 + */ +export async function fetchDirectMessages( + actor: string, + peer?: string, +): Promise { + try { + const params = new URLSearchParams({ actor }); + if (peer) params.set("peer", peer); + const res = await apiFetch(`/api/dm?${params.toString()}`); + if (!res.ok) throw new Error("DMの取得に失敗しました"); + return await res.json(); + } catch (error) { + console.error("Error fetching direct messages:", error); + return []; + } +} + +export interface DMAttachment { + url: string; + mediaType: string; +} + +/** + * 指定した相手にダイレクトメッセージを送信 + */ +export async function sendDirectMessage( + from: string, + to: string[], + content: string, + attachments?: DMAttachment[], +): Promise { + try { + const res = await apiFetch("/api/posts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + author: from, + content, + to, + attachments, + }), + }); + return res.ok; + } catch (error) { + console.error("Error sending direct message:", error); + 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..9f873c224 100644 --- a/app/client/src/components/microblog/api.ts +++ b/app/client/src/components/microblog/api.ts @@ -1,6 +1,6 @@ import type { ActivityPubObject, MicroblogPost } from "./types.ts"; import { apiFetch, getDomain } from "../../utils/config.ts"; -import { loadCacheEntry, saveCacheEntry } from "../e2ee/storage.ts"; +import { getCache, setCache } from "../utils/cache.ts"; /** * ActivityPub Object を取得 @@ -194,6 +194,38 @@ export const createPost = async ( } }; +export const createPostWithTo = async ( + content: string, + author: string, + to: string[], + attachments?: { url: string; type: "image" | "video" | "audio" }[], + parentId?: string, + quoteId?: string, + faspShare?: boolean, +): Promise => { + try { + const response = await apiFetch("/api/posts", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + author, + content, + to, + attachments, + parentId, + quoteId, + faspShare, + }), + }); + return response.ok; + } catch (error) { + console.error("Error creating directed post:", error); + return false; + } +}; + export const updatePost = async ( id: string, content: string, @@ -319,7 +351,7 @@ export interface UserInfo { isLocal: boolean; } -// ユーザー情報キャッシュ(メモリ) +// ユーザー情報キャッシュ const userInfoCache = new Map + accountId ? `${accountId}:${identifier}` : identifier; + export const getCachedUserInfo = async ( identifier: string, accountId?: string, ): Promise => { - const mem = userInfoCache.get(identifier); + const key = cacheKey(identifier, accountId); + const mem = userInfoCache.get(key); 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; - } + + const stored = await getCache(key); + if (stored) { + userInfoCache.set(key, { userInfo: stored, timestamp: Date.now() }); + return stored; } return null; }; @@ -356,13 +385,9 @@ export const setCachedUserInfo = async ( userInfo: UserInfo, accountId?: string, ) => { - userInfoCache.set(identifier, { - userInfo, - timestamp: Date.now(), - }); - if (accountId) { - await saveCacheEntry(accountId, `userInfo:${identifier}`, userInfo); - } + const key = cacheKey(identifier, accountId); + userInfoCache.set(key, { userInfo, timestamp: Date.now() }); + await setCache(key, userInfo); }; // 新しい共通ユーザー情報取得API diff --git a/app/client/src/components/utils/cache.ts b/app/client/src/components/utils/cache.ts new file mode 100644 index 000000000..99496a493 --- /dev/null +++ b/app/client/src/components/utils/cache.ts @@ -0,0 +1,42 @@ +const DB_NAME = "takos-cache"; +const STORE_NAME = "cache"; + +let dbPromise: Promise | null = null; + +const openDB = (): Promise => { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onerror = () => reject(request.error); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + }); + return dbPromise; +}; + +export const getCache = async (key: string): Promise => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const req = store.get(key); + req.onsuccess = () => resolve((req.result as T) ?? null); + req.onerror = () => reject(req.error); + }); +}; + +export const setCache = async (key: string, value: T): Promise => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +}; diff --git a/app/takos_host/client/src/pages/WelcomePage.tsx b/app/takos_host/client/src/pages/WelcomePage.tsx index 77561739e..107a304fb 100644 --- a/app/takos_host/client/src/pages/WelcomePage.tsx +++ b/app/takos_host/client/src/pages/WelcomePage.tsx @@ -195,10 +195,10 @@ const FEATURES = [ }, { icon: Lock, - title: "ac", + title: "ダイレクトメッセージ", desc: - "activitypub-e2eeを利用したエンドツーエンド暗号化。プライバシーを最優先に考えた設計。", - highlight: "セキュア", + "ActivityPub ベースのシンプルなDM機能を搭載。プライベートなやり取りを支援します。", + highlight: "プライベート", }, { icon: Smartphone, @@ -231,16 +231,6 @@ const COMPARISON = [ Facebook: "×", }, }, - { - label: "E2EE (エンドツーエンド暗号化)", - takos: "○", - others: { - Mastodon: "×", - Misskey: "×", - Twitter: "×", - Facebook: "×", - }, - }, { label: "サーバー所有権", takos: "○", diff --git a/docs/chat_ux.md b/docs/chat_ux.md deleted file mode 100644 index ca160c268..000000000 --- a/docs/chat_ux.md +++ /dev/null @@ -1,334 +0,0 @@ -# トークルーム仕様(単一モデル/表示フォールバック) - -## 目的 - -- **DM/グループという区別を設けず**、すべてを「トークルーム」として扱う。 -- 2人のルームで**ルーム名/アイコンが未設定**\*\*なら、各ユーザーにとっての**相手の名前・アイコンをそのまま表示**\*_\*する。_ -- ルームに**名前またはアイコンが設定されている場合は常にそれを表示**する(人数に関わらず)。 -- プロトコル拡張は使わず、**アプリ層メタデータと現在状態のみ**で成立させる。 -- さらに、ユーザーが**自分視点で“DM的な扱いを受けているトークルーム”をワンタップで表示**できる直観的なUI(クイックビュー)を提供する。 - -## スコープ - -- クライアント/サーバのアプリ層仕様(MLSはアプリメッセージでイベント配送するだけ)。 - ---- - -## 表示の基本ルール - -1. ルームにユーザー設定の**名前/アイコン**がある → **それを表示**。 -2. ルーム名/アイコンが**未設定**かつ**メンバーがちょうど2人** → - **各ユーザーに対して相手のプロフィール(名前・アイコン)を表示**。 -3. それ以外(例: 3人以上で未設定) → **自動生成表示**(例: - メンバーの頭文字/複合アイコン、タイトルはメンバー名の簡易列挙)。 - -> 注: -> 自動生成名/アイコンは“未設定扱い”。ユーザーが明示入力/変更した場合のみ「設定あり」。 - ---- - -## データモデル(アプリ層) - -**Room(永続)** - -```json -{ - "roomId": "r1", - "members": ["u1", "u2"], - "displayName": null, // ユーザー設定のルーム名。未設定なら null - "displayIcon": null, // ユーザー設定のルームアイコンURL等。未設定なら null - "createdAt": 1730000000 -} -``` - -**User(参照)** - -```json -{ "userId": "u1", "profileName": "山田 太郎", "avatarUrl": "https://..." } -``` - -**Event(任意:監査/同期)** - -```json -{ "type": "NameSet", "value": "プロジェクトA", "by": "u1", "ts": 1730000300 } -{ "type": "NameCleared", "by": "u1", "ts": 1730000400 } -{ "type": "IconSet", "value": "https://...", "by": "u1", "ts": 1730000500 } -{ "type": "IconCleared", "by": "u1", "ts": 1730000600 } -{ "type": "MemberAdded", "userId": "u3", "by": "u1", "ts": 1730000100 } -{ "type": "MemberRemoved","userId": "u3", "by": "u1", "ts": 1730000200 } -``` - ---- - -## UIフロー(作成/編集) - -- **「〇〇さんとのトークを開始」**: 相手を選ぶだけで作成(2人・未設定)。→ - 表示は相手のプロフィール。 -- **「グループを作成」**: 別メニューで**名前・アイコンを設定**(必須 or - 推奨)。→ 常にルーム名/アイコンを表示。 -- 途中から**名前/アイコンを設定/解除**できる(解除すると未設定扱いに戻り、2人なら相手表示にフォールバック)。 - ---- - -## 表示用ロジック(擬似コード) - -```ts -function roomTitleFor( - viewer: User, - room: Room, - users: Map, -): string { - if (room.displayName) return room.displayName; - if (room.members.length === 2) { - const otherId = room.members.find((id) => id !== viewer.userId)!; - return users.get(otherId)?.profileName ?? "(名称未設定)"; - } - return autoTitleFromMembers(room.members, users); // 例: 「太郎さん他3名」 -} - -function roomAvatarFor( - viewer: User, - room: Room, - users: Map, -): string | CompositeAvatar { - if (room.displayIcon) return room.displayIcon; - if (room.members.length === 2) { - const otherId = room.members.find((id) => id !== viewer.userId)!; - return users.get(otherId)?.avatarUrl ?? defaultAvatar(); - } - return autoCompositeAvatar(room.members, users); // 例: 上位2名の複合サムネ -} -``` - ---- - -## 3人以上の未設定時のデフォルト表示(推奨) - -- **タイトル**: 先頭2〜3名の名前+「ほかN名」。 -- **アイコン**: - 代表2名の複合(タイル/重ね)サムネ、またはメンバー頭文字グリッド。 - ---- - -## 同期とE2E - -- 名前/アイコン変更やメンバー追加/削除などのイベントは、**MLSのアプリメッセージ**として暗号化配送(原則)。 -- (任意)配信・検索・重複防止のために、**AAD - に最小メタ**を平文でミラーしてもよい(下記「AADミラー」を参照)。AAD - は**認証のみで暗号化されない**点に注意。 - -### AADミラー(任意):名前とアイコンURLをそのまま含める - -- **目的**:サーバ側での高速なルーティング/検索/同一ルーム検出のため、E2E復号なしに参照できる最小情報を持たせる。 -- **入れる内容**:**ルーム名(text)** と **アイコンURL(string)** - を**そのまま**格納。 -- **例**(`authenticated_data` に格納する JSON;1KB 未満推奨) - -```json -{ - "t": "room_meta_v1", - "name": "プロジェクトA", - "icon_url": "https://cdn.example.com/rooms/r1/icon.png" -} -``` - -- **注意** - - - AADは中継・配信サーバから**見える**ため、機微情報は載せない(個人情報やトークン付きURLは禁止)。 - - アイコンが秘匿対象なら、**暗号化オブジェクト(ストレージ)+復号鍵はアプリメッセージで配布**に切替。 - - クライアントは**アプリメッセージの状態をソース・オブ・トゥルース**とし、AADは整合用のヒント。差異があれば次回送信でAADを更新。 - - AADの利用は**設定でON/OFF**可能(法域・ポリシーに合わせて無効化できるように)。 - ---- - -## 移行 - -- 既存の「DM/グループ」フラグは廃止。 -- ルーム名/アイコンが**設定済み**ならそのまま表示。未設定なら、 - - - **2人**: 相手プロフィールへフォールバック - - **3人以上**: 自動生成表示 - ---- - -## オプション(実装チューニング) - -- **1:1の重複防止**: 同一ペアの未設定1:1ルームは再利用(サーバで `{me, other}` - 検索)。 -- **自動生成名の保存先**: 永続化しない(表示専用)か、別フィールドにキャッシュ。 -- **プロフィール変更連動**: - 相手のプロフィール名/アイコンが更新された場合、1:1未設定ルームの表示にも即反映。 - ---- - -## トップスライダー(セグメント):すべて / 友だち / グループ - -### ねらい - -- 一番上に**スライド式のセグメントUI**を置き、ワンタップ/スワイプで「すべて」「友だち」「グループ」を行き来できるようにする。 -- 単一モデルのまま、**見せ方=フィルタのプリセット**として提供(データ構造は分けない)。 - -### セグメント定義(フィルタ条件) - -- **すべて**: 閲覧者が現在メンバーの全ルーム。 -- **友だち**: 次のすべてを満たすルーム(= ユーザーのDM的扱い) - - - メンバー数が**ちょうど2人** - - ルームの**名前/アイコンが未設定**(自動生成は未設定扱い) -- **グループ**: 上記以外(= **3人以上**または**名前/アイコンが設定済み**) - -### レイアウト & 挙動 - -- **ヘッダー**にセグメント(3つのタブ + スライドアンダーライン)。 -- **スワイプ**で左右に切替、**タップ**でも切替。デスクトップは**左右矢印キー**操作も可。 -- 各タブに**未読バッジ**(合計未読。0なら非表示)。 -- **最後に選んだタブを記憶**(ユーザー×デバイス)。 -- **ディープリンク**: `/rooms?seg=all|people|groups` で直接そのタブを開く。 -- **スクロール位置をタブごとに保持**。切替時はスムーズに復帰。 - -### アクセシビリティ - -- `role="tablist"`/`tab`/`tabpanel` - を実装。フォーカスリング/コントラストを担保。 -- キーボード: `←/→` でタブ移動、`Enter/Space` で選択。 -- 読み上げ: タブに「未読N件」を含むARIAラベル。 - -### パフォーマンス - -- 各セグメントごとに**最近の上位N件を先読み**、下に向けて**仮想化リスト**で増分ロード。 -- バッジ用の未読合計は**非同期で集計**し、タブ描画後に反映(ジャンプ抑止)。 - -### 空状態 - -- **友だち**: - 「1:1のトークはまだありません。ユーザーを選んで開始しましょう」+開始ボタン。 -- **グループ**: - 「グループはまだありません。『グループを作成』から始めましょう」。 - -### クイックアクション - -- セグメント右端に **「+ 新しいトーク」**(2人開始)と **「+ グループ作成」** - のショートカット。 -- 検索バーを常時表示し、セグメントの条件に**追加で**ANDフィルタできる。 - ---- - -## 検索・フィルタ(ユーザー等での絞り込み) - -### 目的 - -- ルーム一覧を「参加ユーザー」「人数」「名前/アイコンの有無」などで柔軟に絞り込む。 -- DM/グループの概念は導入せず、**トークルーム単一モデル上のファセット検索**として提供。 - -### クイックビュー:『人とのトーク』(ユーザーのDM的扱い) - -- **定義**: 次の条件を満たすルームのみを一覧化 - - - 閲覧者が現在メンバーである - - メンバー数が**2人** - - ルームの**名前/アイコンが未設定**(自動生成は未設定扱い) -- **エントリポイント** - - - ホーム上部のチップ/タブ: 「人とのトーク」 - - サイドバーの固定セクション: 「人とのトーク」 - - 検索バーのクイックサジェスト(`@誰々 とのトーク`) - -#### UIレイアウト(見た目) - -> 目的: -> \*\*「これまでのトークをすぐ表示」+「新しいトークを開始」\*\*を同じ面で直観的に。 - -- **ヘッダー** - - - タイトル: 「人とのトーク」 - - サブコピー: 「過去の1:1を表示。相手を選べば新しいトークを開始できます」 -- **上部: 検索/開始インプット**(コンボボックス) - - - プレースホルダ: 「ユーザー名・メールで検索」 - - 入力中: - 候補リストに「◯◯さんとのトークを開く(既存あり)」または「◯◯さんとのトークを開始(新規)」を表示 - - 右側に主要アクション: **「新しいトークを開始」**(選択したユーザーで実行) -- **中段: これまでのトーク**(最近の順リスト) - - - 各行: 相手のアバター/相手の表示名/最終メッセージ抜粋/最終時刻/未読バッジ - - 行クリック: 既存の1:1未設定ルームを開く -- **下部: 新しいトークの開始** - - - 「よく連絡する人」チップ群(最近の相手トップN) - - **FAB**(モバイル): 右下に「+ 新しいトーク」 - -#### 状態遷移(動作ルール) - -1. **既存がある**: 選択ユーザーとの未設定1:1ルームが存在→そのルームを開く -2. **既存がない**: 未設定1:1ルームを作成して開く -3. **ブロックなどで不可**: トーストで理由提示+アクション(ブロック解除/申請) -4. **複数候補**: - 同名ユーザーが複数いる場合は候補カードで区別情報(部門・メール)を表示 - -#### 空状態 - -- 文言: - 「人とのトークはまだありません。検索欄からユーザーを選んでトークを開始しましょう。」 -- ガイド: サンプルアバターの並び+「ユーザーを検索」ボタン - -### UI(例) - -- **ユーザーで絞り込み**: ユーザーピッカー(複数選択可)。 - - - マッチ方式トグル: `すべて含む(AND)` / `いずれか含む(OR)` / `除外`。 - - 追加オプション: `現在のメンバーのみ` / - `過去に在籍したことがあるメンバーも含む`。 -- **人数**: `ちょうど1:1` / `2人以上` / `3人以上`。 -- **名前/アイコン**: `名前あり` / `アイコンあり` / `未設定`。 -- **その他(任意)**: - `未読のみ`、`@自分へのメンションあり`、`ピン留め`、`ミュート除外`、`期間(最終メッセージ時刻)`。 -- クイックフィルタ: ユーザープロフィールから「このユーザーとのトークを表示」。 - -### データモデル補助 - -- 正規化テーブル `room_members(room_id, user_id, is_current)` を用意。 -- ビュー/集計: - `members_count_current`、`last_message_at`、`has_user_set_name/icon`(派生)を保持。 -- ユーザー別状態 - `room_user_state(user_id, room_id, unread_count, pinned, muted, last_seen_at)`(任意)。 - -### API 例 - -``` -GET /rooms?participants=u1,u2&match=all&includeFormer=false&hasName=false&members=eq:2 -GET /rooms?participants=u3&match=any&unreadOnly=true&since=2025-01-01 -``` - -- `participants`: ユーザーIDのカンマ区切り -- `match`: `all`(AND) / `any`(OR) / `none`(除外) -- `includeFormer`: 退室済みも検索対象にするか -- `hasName` / `hasIcon`: `true|false` -- `members`: `eq:2` / `ge:3` など - -### 実装メモ(DB層) - -- 実装はORM/クエリビルダでOK。AND/OR/除外のロジックはAPI例に準拠。 -- インデックス設計や派生カラムの考え方は「パフォーマンス」節を参照。 - -### 検索式(任意・高度) - -- 文字列検索でのクエリ例: `in:@alice @bob mode:all members:eq:2 has:name:false` - - - `in:@user` - は参加者指定、`mode:all|any|none`、`members:eq:2|ge:3`、`has:name|icon:true|false`。 - -### パフォーマンス - -- `room_members(room_id, user_id, is_current)` に複合INDEX。 -- 頻出条件(1:1 / hasName / hasIcon / members\_count\_current / - last\_message\_at)を**派生カラム**として保持しINDEX。 -- モバイル向けは最近アクティブなルームのみをキャッシュ、フィルタ時は必要に応じてサーバ検索。 - ---- - -## エッジケース - -- 2人→3人に増やした直後に**名前/アイコン未設定**のままでもOK(自動生成表示に切替)。 -- 2人ルームで一度**ルーム名/アイコンを設定**してから**解除**した場合 → - 再び相手プロフィール表示に戻る。 -- メンバーが1人になったルームは**アーカイブ扱い**。再参加で復帰(表示は上記ルールに従う)。 diff --git a/docs/key-sharing.md b/docs/key-sharing.md deleted file mode 100644 index cf8e3f265..000000000 --- a/docs/key-sharing.md +++ /dev/null @@ -1,18 +0,0 @@ -# 鍵共有のしくみ - -このソフトウェアではチャット機能でMLSを利用するため、各アカウントは公開鍵/秘密鍵のペアを保持します。秘密鍵はユーザーが入力するパスワードで暗号化され、サーバーに保存されます。 -MLS -に関する暗号化・復号・グループ状態の更新はすべてクライアント側で完結し、サーバーは暗号化済みの鍵やメッセージを保存・配送するだけです。 - -## 基本的な流れ - -1. 初回利用時にパスワードを入力すると、鍵ペアが生成されます。 -2. 鍵ペアはパスワードで暗号化されてサーバーへ保存され、公開鍵は `KeyPackage` - としてフォロワーに共有されます。各 `KeyPackage` ドキュメントには `tenant_id` - フィールドが含まれ、インスタンスごとに区別されます。 -3. 次回以降はログイン後に一度だけパスワードを入力し、秘密鍵を復号します。復号した鍵はセッション中のみ保持されます。 -4. パスワードを忘れた場合は "鍵をリセット" - を実行すると、サーバー上の暗号化済み鍵と公開鍵を削除し、ローカルに保存された情報も消去されます。 -5. リセット後は再度パスワードを入力すると新しい鍵ペアが生成され、公開鍵が再共有されます。 - -この仕組みにより、ユーザーは一度のログインにつき一度だけパスワードを入力すればよく、安全に鍵を共有できます。 diff --git a/docs/multi-device-sync.md b/docs/multi-device-sync.md deleted file mode 100644 index eb2e8f32b..000000000 --- a/docs/multi-device-sync.md +++ /dev/null @@ -1,27 +0,0 @@ -# 複数端末環境でのMLSグループ管理とルーム同期 - -複数の端末を同じアカウントで利用しながら、チャットルームを安全に同期するための基本方針をまとめます。 - -## 端末ごとの鍵管理 - -- 各端末は独立した鍵ペアを保持し、MLS - グループではそれぞれが個別のメンバー(葉)として扱われます。 -- 同一ユーザーの端末であることを示すために、各端末の鍵には同じクレデンシャルを付与します。 -- 新しい端末を追加する際は既存メンバーから Welcome - メッセージを受け取り、不要になった端末は Remove 提案でグループから除外します。 - -## チャットルーム一覧の同期 - -- サーバーのルーム一覧エンドポイントから参加中のグループ ID - を取得し、どのルームが存在するかを同期します。 -- ルームの名前やアイコンなどのメタデータは MLS の `GroupContext` - 拡張に保存し、端末はこれを読み取って表示します。 - -## 実装上の注意点 - -- 端末がオフライン中に他端末でルーム更新があった場合、復帰時に未反映の更新を取得できるよう差分同期を行います。 -- 鍵のローテーションや端末の追加・削除が頻繁に行われる環境では、ACK - などを用いて状態の整合性を確認し、長期オフライン端末を整理します。 -- ルーム一覧のメタデータにはバージョン番号やハッシュを付与し、不整合を検知できるようにすると安全です。 - -このドキュメントは、複数端末でのルーム同期と鍵管理の基本方針をまとめたものです。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f046b40cd..fd40e8689 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -472,176 +472,6 @@ paths: responses: "200": description: OGPデータ - /api/users/{user}/keyPackages: - parameters: - - name: user - in: path - required: true - schema: - type: string - - name: summary - in: query - schema: - type: boolean - description: true を指定すると残数のみを返します - get: - summary: KeyPackage一覧取得 - description: チャット用MLSキーの公開情報を取得します。`summary=true` を指定した場合は残数と lastResort の有無のみを返します。 - responses: - "200": - description: KeyPackageのコレクションまたはサマリー - content: - application/json: - schema: - oneOf: - - type: object - properties: - type: - type: string - items: - type: array - items: - type: object - properties: - id: - type: string - type: - type: string - content: - type: string - mediaType: - type: string - encoding: - type: string - groupInfo: - type: string - expiresAt: - type: string - createdAt: - type: string - - type: object - properties: - count: - type: number - hasLastResort: - type: boolean - post: - summary: KeyPackage登録 - description: チャット用KeyPackageを登録します。 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - content: - type: string - mediaType: - type: string - encoding: - type: string - groupInfo: - type: string - expiresAt: - type: string - required: - - content - responses: - "200": - description: 登録結果 - /api/users/{user}/keyPackages/{keyId}: - parameters: - - name: user - in: path - required: true - schema: - type: string - - name: keyId - in: path - required: true - schema: - type: string - get: - summary: KeyPackage取得 - description: 指定IDのKeyPackageを取得します。 - responses: - "200": - description: KeyPackage - content: - application/json: - schema: - type: object - properties: - id: - type: string - type: - type: string - mediaType: - type: string - encoding: - type: string - content: - type: string - groupInfo: - type: string - expiresAt: - type: string - delete: - summary: KeyPackage削除 - description: KeyPackageを削除します。 - responses: - "200": - description: 成功 - /api/users/{user}/encryptedKeyPair: - parameters: - - name: user - in: path - required: true - schema: - type: string - get: - summary: 暗号化鍵ペア取得 - description: クライアント側で復号するための鍵ペアを取得します。 - responses: - "200": - description: 鍵ペア - post: - summary: 暗号化鍵ペア保存 - description: 鍵ペアを保存します。 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - content: - type: string - required: - - content - responses: - "200": - description: 保存結果 - delete: - summary: 暗号化鍵ペア削除 - description: 保存済みの鍵ペアを削除します。 - responses: - "200": - description: 成功 - /api/users/{user}/resetKeys: - parameters: - - name: user - in: path - required: true - schema: - type: string - post: - summary: 鍵情報リセット - description: 既存のKeyPackageと鍵ペアをすべて削除します。 - responses: - "200": - description: リセット結果 /api/users/{user}/messages: parameters: - name: user @@ -650,7 +480,7 @@ paths: schema: type: string get: - summary: メッセージ一覧取得(公開・非公開共通) + summary: ダイレクトメッセージ一覧取得 description: 2者間のメッセージ履歴を取得します。 parameters: - name: limit @@ -673,7 +503,7 @@ paths: "200": description: メッセージの配列 post: - summary: メッセージ送信 + summary: ダイレクトメッセージ送信 description: メッセージを送信します。 requestBody: required: true @@ -711,12 +541,6 @@ paths: file: type: string format: binary - key: - type: string - description: 暗号化鍵(Base64) - iv: - type: string - description: 暗号化 IV(Base64) required: - file responses: diff --git a/docs/ui_ux.md b/docs/ui_ux.md index 326ec7cdd..ff9fec49b 100644 --- a/docs/ui_ux.md +++ b/docs/ui_ux.md @@ -65,7 +65,6 @@ import { ## 既存画面の変更点(サマリ) - ログイン画面: `Card`, `Input`, `Button` を用いて視認性と一貫性を改善。 -- 暗号化キー入力: `Modal` を採用し、Esc キーまたは閉じるボタンで閉じられる 角丸なしの全画面ダイアログに変更。 - チャット: ルームが空のとき `EmptyState` を表示して、何をすれば良いかが伝わるように。