Skip to content

MLS機能を削除してチャット周辺を整理#617

Open
tako0614 wants to merge 1 commit intomasterfrom
codex/remove-mls-and-e2ee-components
Open

MLS機能を削除してチャット周辺を整理#617
tako0614 wants to merge 1 commit intomasterfrom
codex/remove-mls-and-e2ee-components

Conversation

@tako0614
Copy link
Copy Markdown
Owner

@tako0614 tako0614 commented Aug 23, 2025

概要

  • READMEとdocsからMLS/E2EEに関する説明を撤去し、暗号化なしのDM機能を記述
  • Welcomeページの比較表からE2EE項目を削除し、DM機能を紹介
  • OpenAPI仕様をダイレクトメッセージAPIに合わせて更新

テスト

  • deno fmt README.md docs/openapi.yaml docs/ui_ux.md app/takos_host/client/src/pages/WelcomePage.tsx
  • deno lint app/takos_host/client/src/pages/WelcomePage.tsx

https://chatgpt.com/codex/tasks/task_e_68a90fd2e984832895201ab30c63fe47

Summary by CodeRabbit

  • 新機能
    • ダイレクトメッセージ(ActivityPubベース)を提供し、DMの取得・送信に対応
    • ファイルアップロードをサポートし、サイズ・MIME・拡張子の環境設定を追加
  • リファクタ
    • チャット左パネルと設定ダイアログを簡素化し、フレンド中心の表示に変更
    • プロフィールからのDM開始フローをシンプルに
  • ドキュメント
    • ドキュメントをDM中心に更新し、E2EE/鍵共有/同期に関する記述を整理・削除
    • ウェルカムページの紹介文をDM向けに更新

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Aug 23, 2025

Walkthrough

MLS/E2EE 関連のクライアント実装・ドキュメント・OpenAPI を削除し、ActivityPub ベースのダイレクトメッセージ(DM)とファイル API に置換。チャット UI/設定を大幅簡素化。DM 送受信用のクライアント API と IndexedDB キャッシュを新規追加。設定・プロフィール・ウェルカムページ文言を更新。

Changes

Cohort / File(s) Summary
Docs・OpenAPI 更新
README.md, docs/openapi.yaml, docs/chat_ux.md, docs/key-sharing.md, docs/multi-device-sync.md, docs/ui_ux.md
MLS/鍵管理/暗号化関連説明とエンドポイントを削除。DM とファイル API 説明を追加・更新。ファイルアップロード設定環境変数を記載。
E2EE/MLS クライアント削除
app/client/src/components/e2ee/*
api.ts, binding.ts, mls_message.ts, mls_test.ts, mls_wrapper.ts, storage.ts, useMLS.ts を全削除。公開型・関数を全面的に撤去。
チャット UI 簡素化
app/client/src/components/chat/ChatRoomList.tsx, .../ChatSettingsOverlay.tsx, .../ChatTitleBar.tsx, .../FriendList.tsx, .../FriendRoomList.tsx
ルーム/メンバー管理・バインディング表示・検索/並び替え等を大幅削減。プロップ面を縮小し、最小表示に再構成。
プロフィールからDM送信
app/client/src/components/Profile.tsx
ルーム作成+初回メッセージ送信を、DM API での空メッセージ送信に変更。
アプリ初期化・設定の整理
app/client/src/components/Application.tsx, app/client/src/components/Setting/index.tsx
KeyPackage 自動補充・MLS DB 削除などの周期/ログアウト処理を除去。モバイル判定と WS 接続のみ維持。
新規クライアント API/キャッシュ
app/client/src/components/chat/api.ts, app/client/src/components/microblog/api.ts, app/client/src/components/utils/cache.ts
DM 取得/送信用 API を追加。投稿 API に to/attachments 対応を追加。IndexedDB ベースの簡易キャッシュを新設しユーザー情報取得を改修。
ホスト側ページ文言
app/takos_host/client/src/pages/WelcomePage.tsx
機能表記を E2EE から DM へ更新。比較表の E2EE 行を削除。

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant C as Client (Web)
  participant S as Server (API)
  participant ST as Storage

  rect rgba(230,240,255,0.5)
  Note right of C: DM 送信フロー(新規)
  U->>C: 「送信」操作(内容/宛先/添付)
  C->>S: POST /api/posts {author, to[], content, attachments?}
  S->>ST: 保存(ActivityPub Object)
  S-->>C: 200 OK
  end

  rect rgba(240,255,230,0.5)
  Note right of C: DM 取得フロー(新規)
  C->>S: GET /api/dm?actor=&peer?
  S->>ST: 検索
  S-->>C: 200 OK [objects]
  end
Loading
sequenceDiagram
  autonumber
  participant U as User
  participant C as Client (Web)
  participant S as Server (API)
  participant FS as File Store/CDN

  rect rgba(255,245,230,0.5)
  Note right of C: ファイルアップロード(平文・HTTP)
  U->>C: ファイル選択
  C->>S: POST /api/files (multipart: file)
  S->>FS: 保存
  S-->>C: 200 OK {id,url}
  end

  rect rgba(245,230,255,0.5)
  Note right of U: 公開取得(認証不要)
  U->>S: GET /api/files/:id
  S->>FS: 取得
  S-->>U: 200 OK (バイナリ)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

ぴょんと跳ねて、鍵はサヨナラ
手紙はまっすぐ、DM 空へ
ファイルはころんと、道しるべ URL
うさぎの端末、今日も軽やか
ピンと耳たて、新しい道をいく 🥕✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/remove-mls-and-e2ee-components

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/openapi.yaml (1)

483-507: OpenAPI仕様修正: /api/posts に DM 向け to/attachments フィールドを追加してください

クライアント実装では DM の送信に常に POST /api/posts を利用しており、ペイロードに to: string[] を渡していますが、現行の OpenAPI 仕様(docs/openapi.yaml の 483–507 行)は /api/users/{user}/messages を DM として定義しているため、実装と仕様が完全に乖離しています。
下記のファイル・行番号を要確認のうえ、仕様を /api/posts 側へ統一してください。

  • app/client/src/components/chat/api.ts: 行 31–40(sendDirectMessage/api/posts を呼び出し、to: string[] を渡している)
  • app/client/src/components/Profile.tsx: 行 249(sendDirectMessage(selfHandle, [handle], "") を呼び出し)
  • app/client/src/components/Chat.tsx: 行 71(sendDirectMessage(selfHandle, room.members, newMessage()) を呼び出し)
  • docs/openapi.yaml: 行 483–507(現在は /api/users/{user}/messages セクションのみ定義)

提案する diff(一例):

 /api/posts:
   post:
     summary: 新規投稿または DM 送信
     requestBody:
       required: true
       content:
         application/json:
           schema:
             type: object
             properties:
               author:
                 type: string
               content:
                 type: string
+              to:
+                type: array
+                description: 配送先 Actor IRI または識別子。指定時は非公開配信(DM)として扱われます。
+                items:
+                  type: string
+              attachments:
+                type: array
+                description: 添付メディア情報
+                items:
+                  type: object
+                  properties:
+                    url:
+                      type: string
+                    type:
+                      type: string
+                      enum: [image, video, audio]
               parentId: { type: string }
               quoteId:  { type: string }
               faspShare: { type: boolean }
             required:
               - author
               - content

この変更により、クライアント実装と OpenAPI 仕様が一致し、将来的な誤解や事故を防止できます。

🧹 Nitpick comments (9)
app/takos_host/client/src/pages/WelcomePage.tsx (1)

197-202: 「ロック」アイコンと文言がE2EEを連想させるため、非暗号化DMと誤解されるリスクがあります

DMが暗号化されていない前提であれば、アイコンと説明文で期待値を明確にしてください。最小修正は説明文に但し書きを入れることです。可能ならアイコンもメッセージ系に変更を。

提案Diff(最小修正: 但し書き追加+ハイライト文言の中立化):

-    icon: Lock,
+    icon: Lock,
     title: "ダイレクトメッセージ",
-    desc:
-      "ActivityPub ベースのシンプルなDM機能を搭載。プライベートなやり取りを支援します。",
-    highlight: "プライベート",
+    desc:
+      "ActivityPub ベースのシンプルなDM機能を搭載。プライベートなやり取りを支援します(現時点ではエンドツーエンド暗号化は提供していません)。",
+    highlight: "ダイレクト",

代替案(推奨: アイコンも変更。以下の新規アイコンをこのファイル上部に追加し、iconをMessageに差し替え):

-    icon: Lock,
+    icon: Message,

追加コンポーネント(このファイルの他アイコン群と同様の場所に配置):

const Message = () => (
  <svg
    class="w-8 h-8 text-indigo-400 mb-4"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    viewBox="0 0 24 24"
    stroke-linecap="round"
    stroke-linejoin="round"
    aria-hidden="true"
  >
    <path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z" />
  </svg>
);
app/client/src/components/Application.tsx (1)

23-33: WebSocket接続のクリーンアップがなく、再マウントやページ遷移でリークの恐れ

connectWebSocketがクローズ用ディスポーザを返さない/返すいずれの場合でも、onMountのクリーンアップで明示的に閉じられるようにしてください。

提案Diff(connectWebSocketがディスポーザを返す想定):

-    connectWebSocket();
+    const dispose = connectWebSocket?.();
 
-    return () => globalThis.removeEventListener("resize", checkMobile);
+    return () => {
+      globalThis.removeEventListener("resize", checkMobile);
+      if (typeof dispose === "function") dispose();
+    };

もし返さない場合は、utils/ws.ts側にdisconnectWebSocket()を用意し、上記で呼び出す実装をご検討ください。

app/client/src/components/chat/FriendList.tsx (1)

73-81: アバターimgに代替テキストと遅延読込を追加

装飾でなく識別情報ならaltを入れてください。回線節約のためlazyも推奨。

提案Diff:

-              {f.avatar
-                ? <img src={f.avatar} alt="" class="w-8 h-8 rounded-full" />
+              {f.avatar
+                ? <img src={f.avatar} alt={`${f.name}のアバター`} loading="lazy" class="w-8 h-8 rounded-full" />
                 : (
                   <div class="w-8 h-8 rounded-full bg-[#444] flex items-center justify-center text-white">
                     {f.name.charAt(0).toUpperCase()}
                   </div>
                 )}
app/client/src/components/chat/ChatSettingsOverlay.tsx (1)

13-27: モーダルのアクセシビリティとクローズ挙動を強化

  • role="dialog"/aria-modal="true" の付与
  • 背景クリックで閉じる(内容クリックはバブリング停止)
  • ESC キーで閉じる

差分例:

-      <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
-        <div class="w-80 rounded bg-[#222] p-4 shadow-lg">
+      <div
+        class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
+        role="dialog"
+        aria-modal="true"
+        onClick={(e) => {
+          if (e.currentTarget === e.target) props.onClose();
+        }}
+        onKeyDown={(e) => {
+          if (e.key === "Escape") props.onClose();
+        }}
+        tabindex={-1}
+      >
+        <div class="w-80 rounded bg-[#222] p-4 shadow-lg" onClick={(e) => e.stopPropagation()}>
           <h2 class="mb-4 text-lg text-white">チャット設定</h2>
           <p class="mb-4 text-sm text-gray-300">
             {props.room?.name ?? "選択中のチャット"}
           </p>
           <div class="text-right">
app/client/src/components/Profile.tsx (1)

230-241: normalizeActor の正規化を厳密に(小さな改善)

ドメイン/ユーザー名の大小文字や周辺スペースの混入を均すと取り回しが楽です。toLowerCase()trim() を追加しておくと安全です。

-  const normalizeActor = (actor: string): string => {
+  const normalizeActor = (actor: string): string => {
+    actor = actor.trim();
     if (actor.startsWith("http")) {
       try {
         const url = new URL(actor);
-        const name = url.pathname.split("/").pop()!;
-        return `${name}@${url.hostname}`;
+        const name = url.pathname.split("/").pop()!;
+        return `${name}@${url.hostname}`.toLowerCase();
       } catch {
-        return actor;
+        return actor.toLowerCase();
       }
     }
-    return actor;
+    return actor.toLowerCase();
   };
app/client/src/components/chat/api.ts (1)

23-26: DMAttachment のフィールド整合性を OpenAPI と突き合わせて確認

{ url: string; mediaType: string; } で問題なければOKですが、OpenAPI 側で namesize など追加属性が定義されていないかを確認してください。差異があると送受信時の整合が崩れます。差分がある場合は interface を更新しましょう。

README.md (3)

11-13: DMの方針明確化はOK。文言のつながりのみ微調整を提案

現状でも意味は通りますが、改行位置の都合で読みづらいので軽微に整形すると読みやすくなります。

-基本的に同一ドメインのユーザーは同一人物です。(サブアカウントなど) ActivityPub
-の投稿に加え、暗号化を行わないシンプルなダイレクトメッセージ機能を備え
-ています。
+基本的に同一ドメインのユーザーは同一人物です(サブアカウント等)。ActivityPub の投稿に加え、暗号化を行わないシンプルなダイレクトメッセージ機能を備えています。

176-180: 表記ゆれ/分割誤りの修正(「行わ れません」「エンドポイン ト」など)

日本語のスペース割れがいくつかあるため修正を提案します。意味変更はありません。

-ActivityPub を利用したシンプルなダイレクトメッセージ機能の API
-です。暗号化は行わ れません。 `/api/users/*`
-プレフィックスには公開ユーザー情報取得用のエンドポイン
-トも含まれますが、アカウント管理機能は `/api/accounts/*` で提供されます。
+ActivityPub を利用したシンプルなダイレクトメッセージ機能の API です。暗号化は行われません。`/api/users/*` プレフィックスには公開ユーザー情報取得用のエンドポイントも含まれますが、アカウント管理機能は `/api/accounts/*` で提供されます。

181-189: READMEのDMエンドポイントとクライアント実装の整合性確認をお願いします

本READMEは GET/POST /api/users/:user/messages を定義しています。一方、クライアントの chat/api.tsGET /api/dmPOST /api/posts を参照していました(別コメントで指摘済)。ドキュメント・OpenAPI・実装の 3 点セットで同一エンドポイントへ統一してください。相違があるとQAで発見しづらい不具合になります。

必要であれば、OpenAPIと実装を機械的に突き合わせるスクリプトを用意します。

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 579444d and d14354c.

📒 Files selected for processing (25)
  • README.md (2 hunks)
  • app/client/src/components/Application.tsx (2 hunks)
  • app/client/src/components/Profile.tsx (2 hunks)
  • app/client/src/components/Setting/index.tsx (0 hunks)
  • app/client/src/components/chat/ChatRoomList.tsx (1 hunks)
  • app/client/src/components/chat/ChatSettingsOverlay.tsx (1 hunks)
  • app/client/src/components/chat/ChatTitleBar.tsx (0 hunks)
  • app/client/src/components/chat/FriendList.tsx (2 hunks)
  • app/client/src/components/chat/FriendRoomList.tsx (2 hunks)
  • app/client/src/components/chat/api.ts (1 hunks)
  • app/client/src/components/e2ee/api.ts (0 hunks)
  • app/client/src/components/e2ee/binding.ts (0 hunks)
  • app/client/src/components/e2ee/mls_message.ts (0 hunks)
  • app/client/src/components/e2ee/mls_test.ts (0 hunks)
  • app/client/src/components/e2ee/mls_wrapper.ts (0 hunks)
  • app/client/src/components/e2ee/storage.ts (0 hunks)
  • app/client/src/components/e2ee/useMLS.ts (0 hunks)
  • app/client/src/components/microblog/api.ts (4 hunks)
  • app/client/src/components/utils/cache.ts (1 hunks)
  • app/takos_host/client/src/pages/WelcomePage.tsx (1 hunks)
  • docs/chat_ux.md (0 hunks)
  • docs/key-sharing.md (0 hunks)
  • docs/multi-device-sync.md (0 hunks)
  • docs/openapi.yaml (2 hunks)
  • docs/ui_ux.md (0 hunks)
💤 Files with no reviewable changes (13)
  • app/client/src/components/e2ee/useMLS.ts
  • app/client/src/components/e2ee/mls_test.ts
  • docs/chat_ux.md
  • docs/multi-device-sync.md
  • app/client/src/components/Setting/index.tsx
  • app/client/src/components/chat/ChatTitleBar.tsx
  • docs/ui_ux.md
  • app/client/src/components/e2ee/mls_message.ts
  • docs/key-sharing.md
  • app/client/src/components/e2ee/storage.ts
  • app/client/src/components/e2ee/binding.ts
  • app/client/src/components/e2ee/mls_wrapper.ts
  • app/client/src/components/e2ee/api.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: TypeScriptの型定義を活用する
エラーハンドリングを適切に実装する

Files:

  • app/takos_host/client/src/pages/WelcomePage.tsx
  • app/client/src/components/utils/cache.ts
  • app/client/src/components/Profile.tsx
  • app/client/src/components/chat/api.ts
  • app/client/src/components/chat/FriendRoomList.tsx
  • app/client/src/components/chat/FriendList.tsx
  • app/client/src/components/Application.tsx
  • app/client/src/components/microblog/api.ts
  • app/client/src/components/chat/ChatRoomList.tsx
  • app/client/src/components/chat/ChatSettingsOverlay.tsx
🧠 Learnings (1)
📚 Learning: 2025-08-13T10:14:15.274Z
Learnt from: CR
PR: takoserver/takos#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-13T10:14:15.274Z
Learning: Applies to app/api/{activity_handlers.ts,utils/activitypub*.ts} : ActivityPubの仕様に準拠したJSON-LD形式を使用する

Applied to files:

  • app/client/src/components/microblog/api.ts
🧬 Code graph analysis (6)
app/client/src/components/Profile.tsx (1)
app/client/src/components/chat/api.ts (1)
  • sendDirectMessage (31-53)
app/client/src/components/chat/api.ts (2)
app/client/src/components/microblog/types.ts (1)
  • ActivityPubObject (40-50)
app/client/src/utils/config.ts (1)
  • apiFetch (31-67)
app/client/src/components/chat/FriendList.tsx (1)
app/client/src/components/chat/types.ts (1)
  • Room (21-39)
app/client/src/components/microblog/api.ts (1)
app/client/src/components/utils/cache.ts (2)
  • getCache (22-31)
  • setCache (33-42)
app/client/src/components/chat/ChatRoomList.tsx (2)
app/client/src/components/chat/types.ts (1)
  • Room (21-39)
app/client/src/components/chat/FriendList.tsx (1)
  • FriendList (19-87)
app/client/src/components/chat/ChatSettingsOverlay.tsx (1)
app/client/src/components/chat/types.ts (1)
  • Room (21-39)
🔇 Additional comments (9)
app/client/src/components/Application.tsx (1)

35-43: registerUserの引数フォーマットを確認してください(userNameにドメイン重複の懸念)

${user.userName}@${getDomain()}の組み立ては、userNameが既に@domainを含む場合に二重ドメインになる可能性があります。API側の期待値に合わせて正規化を。

検証観点:

  • account().userNameのフォーマット(例: alice / alice@example.com
  • registerUserの想定引数(ローカル名かフルハンドルか)
app/client/src/components/chat/FriendRoomList.tsx (1)

15-19: type==="memo" はパーソナルメモ用であり、フレンドとの1:1チャット(DM)を抽出する条件ではありません
現状のフィルターでは、メンバーに friendId を含むグループチャットまで含めてしまい、かつ r.id === props.friendId の比較もルームIDと ActorID を混同していて誤りです。1:1 チャットに限定するには、ユーティリティ関数 isFriendRoom(room) (type ≠ "memo" かつ members.length === 1 を判定)を併用してください。

— 修正例 —

-import { createMemo } from "solid-js";
-const friendRooms = createMemo(() =>
-  props.rooms.filter(
-    (r) => r.members?.includes(props.friendId) || r.id === props.friendId,
-  )
-);
+import { createMemo } from "solid-js";
+import { isFriendRoom } from "./types.ts";
+
+const friendRooms = createMemo(() =>
+  props.rooms.filter(
+    (r) => isFriendRoom(r) && r.members.includes(props.friendId),
+  )
+);

Likely an incorrect or invalid review comment.

app/client/src/components/microblog/api.ts (3)

3-3: キャッシュAPIへの依存切替は妥当(LGTM)

e2ee/storage から共通キャッシュユーティリティへの移行は方向性として適切です。以降の利用箇所も getCache/setCache に統一されている点を確認しました。


354-364: アカウント依存のキャッシュキー導入は妥当(LGTM)

accountId をキーに含める設計でキャッシュ汚染リスクが下がっています。


455-467: /api/users/batch の実装場所およびレスポンス形状の確認をお願いします

現在、app/client/src/components/microblog/api.ts (行444付近)でサーバーから返却される fetchedInfos の配列順序を前提にマッピングしていますが、リポジトリ内で該当エンドポイントの実装が見つかりませんでした。以下を手動でご確認いただき、クライアント側の修正範囲を確定してください:

  • サーバー側実装ファイルのパス/モジュール名
  • レスポンスボディの契約
    • 配列(UserInfo[])なのか
    • オブジェクト(Record<identifier, UserInfo> または Array<{ identifier: string; userInfo: UserInfo }>)なのか
    • 順序保証の有無
  • 必要に応じてクライアントでの順序ずれ検知や識別子付きマッピングの導入

以上確認後、クライアントの最小差分案(配列長ミスマッチ検知や Object.entries を使ったマッピング)をご検討ください。

app/client/src/components/chat/ChatRoomList.tsx (1)

14-21: 最小構成への整理は妥当(LGTM)

左ペインを FriendList に委譲する簡素化で責務が明確になっています。selectedRoomselectedFriend の受け渡しも自然です。

app/client/src/components/chat/api.ts (2)

31-53: DM送信エンドポイントの仕様整合性を要確認

  • README.md(182行目)では
    POST /api/users/:user/messages – メッセージ送信
    が定義されていますが
  • クライアント実装(app/client/src/components/chat/api.ts)では
    POST /api/posts を叩いており、ドキュメントと一致していません。
  • リポジトリ内に /api/users/:user/messages を受け付けるサーバー側ハンドラ定義が見当たらなかったため(router.post('/api/users/:user/messages', …) が未発見)、エンドポイントの現行仕様がどちらなのか改めてご確認ください。

もしドキュメントが古い場合はREADMEを更新、実装を変更する場合はクライアント側をドキュメント準拠に修正してください。


7-21: DM取得エンドポイントの仕様確認が必要

クライアント側では現在 /api/dm?actor=…&peer=… を呼び出していますが、README(OpenAPI 定義)との整合性が取れていない可能性があります。以下を手動でご確認ください:

  • サーバ実装で実際に公開されている DM 取得エンドポイントが
    • /api/dm
    • /api/users/:user/messages
      のどちらか、あるいは両方なのか
  • docs/openapi.yaml に定義されているパスと、サーバ実装が一致しているか
  • 上記仕様に合わせてクライアントの呼び出しパスを修正する

修正例(README の /api/users/:user/messages に合わせる場合):

-  const res = await apiFetch(`/api/dm?${params.toString()}`);
+  const query = params.toString();
+  const res = await apiFetch(
+    `/api/users/${encodeURIComponent(actor)}/messages${query ? `?${query}` : ""}`,
+  );
-  if (!res.ok) throw new Error("DMの取得に失敗しました");
-  return await res.json();
+  if (!res.ok) throw new Error(`DMの取得に失敗しました: ${res.status} ${res.statusText}`);
+  return await res.json() as ActivityPubObject[];

上記確認・修正後、API 仕様と実装のパスが完全に一致するよう統一してください。

README.md (1)

170-170: アカウント取得の簡素化記述は妥当

MLS/鍵関連の説明削除と整合しており、この行の更新は問題ありません。

Comment on lines +26 to +34
const map = new Map<string, Friend>();
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)
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

友だち判定が「membersの先頭要素」を採用しており、本人を拾う/相手を取り違える重大リスク

2者DMでもメンバー順は保証されません。現在ユーザー(selfId)を除外して相手を特定し、DM以外は除外してください。表示名はdisplayName優先が無難です。

提案Diff(selfIdをPropsに追加し、相手判定とDM限定を実装):

 interface FriendListProps {
   rooms: Room[];
   onSelectFriend: (friendId: string) => void;
   selectedFriend?: string | null;
   query?: string;
   onQueryChange?: (v: string) => void;
   showSearch?: boolean;
+  selfId: string;
 }
@@
-  const friends = createMemo(() => {
-    const map = new Map<string, Friend>();
-    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 });
-    }
-    return Array.from(map.values()).sort((a, b) =>
-      (a.name || a.id).localeCompare(b.name || b.id)
-    );
-  });
+  const friends = createMemo(() => {
+    const map = new Map<string, Friend>();
+    for (const room of props.rooms) {
+      if (room.type !== "memo") continue; // DMのみ
+      const id = room.members?.find((m) => m !== props.selfId);
+      if (!id || map.has(id)) continue;
+      const name = room.displayName ?? room.name ?? id;
+      map.set(id, { id, name, avatar: room.avatar });
+    }
+    return Array.from(map.values()).sort((a, b) =>
+      (a.name || a.id).localeCompare(b.name || b.id)
+    );
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const map = new Map<string, Friend>();
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)
);
interface FriendListProps {
rooms: Room[];
onSelectFriend: (friendId: string) => void;
selectedFriend?: string | null;
query?: string;
onQueryChange?: (v: string) => void;
showSearch?: boolean;
selfId: string;
}
const friends = createMemo(() => {
const map = new Map<string, Friend>();
for (const room of props.rooms) {
if (room.type !== "memo") continue; // DMのみ
const id = room.members?.find((m) => m !== props.selfId);
if (!id || map.has(id)) continue;
const name = room.displayName ?? room.name ?? id;
map.set(id, { id, name, avatar: room.avatar });
}
return Array.from(map.values()).sort((a, b) =>
(a.name || a.id).localeCompare(b.name || b.id)
);
});
🤖 Prompt for AI Agents
In app/client/src/components/chat/FriendList.tsx around lines 26 to 34, the
current logic picks the first members[0] as the friend which can select the
current user or flip participants; update to accept a selfId prop, ignore any
member equal to selfId, only consider DM rooms (exactly two members) and use the
other participant as the friend key, prefer displayName over name when building
the Friend entry, and keep deduplication via Map keyed by the otherId; ensure
types/signature are updated to include selfId and any non-DM rooms are skipped.

Comment on lines +66 to +72
class={`flex items-center cursor-pointer p-2 rounded ${
props.selectedFriend === f.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectFriend(f.id)}
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

クリック可能なdivをbuttonに変更し、選択状態をariaで明示

アクセシビリティとキーボード操作性向上のための基本対応です。

提案Diff:

-            <div
-              class={`flex items-center cursor-pointer p-2 rounded ${
+            <button
+              type="button"
+              class={`flex items-center w-full text-left cursor-pointer p-2 rounded ${
                 props.selectedFriend === f.id
                   ? "bg-[#4a4a4a]"
                   : "hover:bg-[#3c3c3c]"
               }`}
-              onClick={() => props.onSelectFriend(f.id)}
+              onClick={() => props.onSelectFriend(f.id)}
+              aria-current={props.selectedFriend === f.id ? "true" : "false"}
             >
@@
-            </div>
+            </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class={`flex items-center cursor-pointer p-2 rounded ${
props.selectedFriend === f.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectFriend(f.id)}
>
<button
type="button"
class={`flex items-center w-full text-left cursor-pointer p-2 rounded ${
props.selectedFriend === f.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectFriend(f.id)}
aria-current={props.selectedFriend === f.id ? "true" : "false"}
>
{/* existing friend item content */}
</button>
🤖 Prompt for AI Agents
In app/client/src/components/chat/FriendList.tsx around lines 66 to 72, replace
the clickable div with a semantic button to improve accessibility and keyboard
support: change the element to a <button type="button">, use className instead
of class to preserve styles, add aria-pressed (e.g.
aria-pressed={props.selectedFriend === f.id}) to explicitly expose selection
state to assistive tech, keep the existing onClick handler, and ensure
styling/rounded/padding classes are retained; also ensure the button does not
submit a form by setting type="button".

Comment on lines +71 to +79
<div
class={`cursor-pointer rounded p-3 ${
props.selectedRoom === room.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectRoom(room.id)}
>
<div class="text-sm text-white">{room.name || room.id}</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

クリック可能なdivはアクセシビリティが低い—buttonに置換を

キーボード操作やスクリーンリーダー対応のため、divではなくbuttonを使用し、選択状態はaria属性で伝えてください。

提案Diff:

-            <div
-              class={`cursor-pointer rounded p-3 ${
+            <button
+              type="button"
+              class={`w-full text-left cursor-pointer rounded p-3 ${
                 props.selectedRoom === room.id
                   ? "bg-[#4a4a4a]"
                   : "hover:bg-[#3c3c3c]"
               }`}
               onClick={() => props.onSelectRoom(room.id)}
+              aria-selected={props.selectedRoom === room.id}
             >
               <div class="text-sm text-white">{room.name || room.id}</div>
               <div class="truncate text-xs text-gray-400">
                 {room.lastMessage || "メッセージがありません"}
               </div>
-            </div>
+            </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
class={`cursor-pointer rounded p-3 ${
props.selectedRoom === room.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectRoom(room.id)}
>
<div class="text-sm text-white">{room.name || room.id}</div>
<button
type="button"
class={`w-full text-left cursor-pointer rounded p-3 ${
props.selectedRoom === room.id
? "bg-[#4a4a4a]"
: "hover:bg-[#3c3c3c]"
}`}
onClick={() => props.onSelectRoom(room.id)}
aria-selected={props.selectedRoom === room.id}
>
<div class="text-sm text-white">{room.name || room.id}</div>
<div class="truncate text-xs text-gray-400">
{room.lastMessage || "メッセージがありません"}
</div>
</button>
🤖 Prompt for AI Agents
In app/client/src/components/chat/FriendRoomList.tsx around lines 71–79, replace
the clickable div with a semantic button (use type="button") so it is keyboard
and screen-reader accessible, change the JSX attribute from class to className,
keep the existing onClick handler, and add an aria-pressed (or aria-selected)
attribute set to props.selectedRoom === room.id to convey selection state to
assistive tech; preserve the visual classes (bg/hover) and remove any redundant
cursor-pointer since buttons are natively focusable and clickable.

Comment on lines +197 to +227
export const createPostWithTo = async (
content: string,
author: string,
to: string[],
attachments?: { url: string; type: "image" | "video" | "audio" }[],
parentId?: string,
quoteId?: string,
faspShare?: boolean,
): Promise<boolean> => {
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;
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

createPostWithTo: to の検証・重複排除・最小バリデーションを追加したい

空配列・空文字や重複がそのままサーバーへ送られる可能性があります。軽量なクライアント側バリデーションと正規化で事故を減らしましょう。将来的には ActivityPub 仕様的に to は IRI を想定するため、ハンドル文字列を受ける場合はサーバー側解決を前提にする旨のコメントもあると親切です。

以下の差分を提案します。

 export const createPostWithTo = async (
   content: string,
   author: string,
   to: string[],
   attachments?: { url: string; type: "image" | "video" | "audio" }[],
   parentId?: string,
   quoteId?: string,
   faspShare?: boolean,
 ): Promise<boolean> => {
   try {
+    // normalize + dedupe
+    const toUnique = Array.from(
+      new Set(to.map((s) => s.trim()).filter(Boolean)),
+    );
+    if (toUnique.length === 0) {
+      console.warn("createPostWithTo called with empty 'to'");
+      return false;
+    }
     const response = await apiFetch("/api/posts", {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
       },
       body: JSON.stringify({
         author,
         content,
-        to,
+        to: toUnique,
         attachments,
         parentId,
         quoteId,
         faspShare,
       }),
     });
     return response.ok;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const createPostWithTo = async (
content: string,
author: string,
to: string[],
attachments?: { url: string; type: "image" | "video" | "audio" }[],
parentId?: string,
quoteId?: string,
faspShare?: boolean,
): Promise<boolean> => {
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 createPostWithTo = async (
content: string,
author: string,
to: string[],
attachments?: { url: string; type: "image" | "video" | "audio" }[],
parentId?: string,
quoteId?: string,
faspShare?: boolean,
): Promise<boolean> => {
try {
// normalize + dedupe
const toUnique = Array.from(
new Set(to.map((s) => s.trim()).filter(Boolean)),
);
if (toUnique.length === 0) {
console.warn("createPostWithTo called with empty 'to'");
return false;
}
const response = await apiFetch("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
author,
content,
to: toUnique,
attachments,
parentId,
quoteId,
faspShare,
}),
});
return response.ok;
} catch (error) {
console.error("Error creating directed post:", error);
return false;
}
};
🤖 Prompt for AI Agents
In app/client/src/components/microblog/api.ts around lines 197 to 227, the
createPostWithTo function currently sends the raw `to` array to the server
allowing empty strings, empty arrays and duplicates; add lightweight client-side
normalization: filter out falsy/whitespace-only entries, trim each entry,
deduplicate (preserve order), and skip the API call or return false if the
resulting `to` array is empty (or send without `to` if that is acceptable). Also
add minimal validation to ensure entries look like IRIs (e.g., start with
http(s)://) or, if accepting handles, add a comment that handle resolution must
happen server-side per ActivityPub expectations; include the normalized `to` in
the JSON body and log/return false on invalid input.

Comment on lines +369 to 379
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<UserInfo>(
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<UserInfo>(key);
if (stored) {
userInfoCache.set(key, { userInfo: stored, timestamp: Date.now() });
return stored;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

永続キャッシュのTTL未管理(メモリのみ)により、古いユーザー情報が半永久的に再活性化される

getCachedUserInfo は永続層から取得した値を「現在時刻」でメモリに再投入するため、永続層側の古い値が常に新鮮扱いになります。TTLを永続層にも保存して判定するか、永続層を常に「stale」として扱いネットワーク再取得をトリガするのが安全です。

最小差分で「永続層にも timestamp を保存し、TTLを尊重」する修正案です(後方互換で素の UserInfo が保存されている場合も扱います)。

 export const getCachedUserInfo = async (
   identifier: string,
   accountId?: string,
 ): Promise<UserInfo | null> => {
   const key = cacheKey(identifier, accountId);
   const mem = userInfoCache.get(key);
   if (mem && Date.now() - mem.timestamp < CACHE_DURATION) {
     return mem.userInfo;
   }
 
-  const stored = await getCache<UserInfo>(key);
-  if (stored) {
-    userInfoCache.set(key, { userInfo: stored, timestamp: Date.now() });
-    return stored;
-  }
+  const stored = await getCache<UserInfo | { userInfo: UserInfo; timestamp?: number }>(key);
+  if (stored) {
+    // 後方互換: 旧フォーマット(UserInfo単体)の場合は stale とみなす
+    const record = (stored as any).userInfo
+      ? (stored as { userInfo: UserInfo; timestamp?: number })
+      : { userInfo: stored as UserInfo, timestamp: 0 };
+    const fresh = record.timestamp && Date.now() - record.timestamp < CACHE_DURATION;
+    userInfoCache.set(key, { userInfo: record.userInfo, timestamp: Date.now() });
+    if (fresh) return record.userInfo;
+    // stale の場合は null を返し呼び出し側でネットワーク取得させる
+  }
   return null;
 };
 
 export const setCachedUserInfo = async (
   identifier: string,
   userInfo: UserInfo,
   accountId?: string,
 ) => {
   const key = cacheKey(identifier, accountId);
-  userInfoCache.set(key, { userInfo, timestamp: Date.now() });
-  await setCache(key, userInfo);
+  const now = Date.now();
+  userInfoCache.set(key, { userInfo, timestamp: now });
+  await setCache(key, { userInfo, timestamp: now });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<UserInfo>(
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<UserInfo>(key);
if (stored) {
userInfoCache.set(key, { userInfo: stored, timestamp: Date.now() });
return stored;
}
export const getCachedUserInfo = async (
identifier: string,
accountId?: string,
): Promise<UserInfo | null> => {
const key = cacheKey(identifier, accountId);
const mem = userInfoCache.get(key);
if (mem && Date.now() - mem.timestamp < CACHE_DURATION) {
return mem.userInfo;
}
const stored = await getCache<UserInfo | { userInfo: UserInfo; timestamp?: number }>(key);
if (stored) {
// 後方互換: 旧フォーマット(UserInfo単体)の場合は stale とみなす
const record = (stored as any).userInfo
? (stored as { userInfo: UserInfo; timestamp?: number })
: { userInfo: stored as UserInfo, timestamp: 0 };
const fresh =
record.timestamp && Date.now() - record.timestamp < CACHE_DURATION;
userInfoCache.set(key, { userInfo: record.userInfo, timestamp: Date.now() });
if (fresh) return record.userInfo;
// stale の場合は null を返し呼び出し側でネットワーク取得をトリガする
}
return null;
};
export const setCachedUserInfo = async (
identifier: string,
userInfo: UserInfo,
accountId?: string,
) => {
const key = cacheKey(identifier, accountId);
const now = Date.now();
userInfoCache.set(key, { userInfo, timestamp: now });
await setCache(key, { userInfo, timestamp: now });
};
🤖 Prompt for AI Agents
In app/client/src/components/microblog/api.ts around lines 369-379, the code
re-inserts a value from persistent cache into memory with the current time,
which renews TTL incorrectly; change the persistent-cache protocol to store {
userInfo, timestamp } (but accept plain UserInfo for backward compatibility),
and when reading: if stored is an object with timestamp check Date.now() -
stored.timestamp < CACHE_DURATION and only then use it (set memory cache using
the stored.timestamp), if stored is plain UserInfo treat it as stale and do not
refresh memory cache (or treat as stale and trigger network fetch); when writing
to persistent cache always save { userInfo, timestamp: Date.now() } so TTL is
respected.

Comment on lines +248 to 252
const selfHandle = `${user.userName}@${getDomain()}`;
await sendDirectMessage(selfHandle, [handle], "");
setRoom(handle);
setApp("chat");
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

空メッセージを送って部屋を作るのは不自然。DM リストへの登録 API か DM 送信APIに統一を

  • 仕様では DM 送信は POST /api/users/{user}/messages、または DM 先の管理は POST /api/accounts/{id}/dms が用意されています。現在は sendDirectMessagePOST /api/posts に投げており、OpenAPI と齟齬があります。
  • 空文字コンテンツを送ると履歴に「空メッセージ」が残る実装になりやすくUX上の違和感も大きいです。

推奨案A(「DM先に追加」してからチャットへ遷移。空メッセージは送らない):

-    const selfHandle = `${user.userName}@${getDomain()}`;
-    await sendDirectMessage(selfHandle, [handle], "");
-    setRoom(handle);
-    setApp("chat");
+    const selfHandle = `${user.userName}@${getDomain()}`.toLowerCase();
+    const res = await apiFetch(`/api/accounts/${activeId()}/dms`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ target: handle }),
+    });
+    if (!res.ok) {
+      console.error("Failed to add DM target", await res.text());
+      return;
+    }
+    setRoom(handle);
+    setApp("chat");

案B(仕様に合わせて DM 送信エンドポイントを使う。空メッセージは避ける): sendDirectMessage/api/users/{user}/messages に切替えた上で、ここでは初回メッセージ入力を促す遷移のみを行うか、非空の挨拶文を送る設計に変更してください。

どちらに寄せるか決定後、chat/api.ts と OpenAPI の整合も併せて修正するのが望ましいです。

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const selfHandle = `${user.userName}@${getDomain()}`;
await sendDirectMessage(selfHandle, [handle], "");
setRoom(handle);
setApp("chat");
};
const selfHandle = `${user.userName}@${getDomain()}`.toLowerCase();
const res = await apiFetch(`/api/accounts/${activeId()}/dms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: handle }),
});
if (!res.ok) {
console.error("Failed to add DM target", await res.text());
return;
}
setRoom(handle);
setApp("chat");
};

Comment on lines +6 to +20
const openDB = (): Promise<IDBDatabase> => {
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;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

非ブラウザ環境ガードとblocked/versionchangeハンドリング不足により初期化失敗時に落ちます

SSRやWebView、古いブラウザでindexedDBが未定義の場合に例外化します。またopenがblockedになった場合やスキーマ更新時のversionchange未処理です。

提案Diff:

-  dbPromise = new Promise((resolve, reject) => {
-    const request = indexedDB.open(DB_NAME, 1);
+  dbPromise = new Promise((resolve, reject) => {
+    const idb = globalThis.indexedDB;
+    if (!idb) {
+      return reject(new Error("IndexedDB is not available in this environment"));
+    }
+    const request = idb.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);
+    request.onblocked = () => reject(new Error("IndexedDB open blocked"));
+    request.onsuccess = () => {
+      const db = request.result;
+      // 将来のスキーマ更新で古いコネクションが残らないように
+      db.onversionchange = () => db.close();
+      resolve(db);
+    };
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const openDB = (): Promise<IDBDatabase> => {
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;
};
const openDB = (): Promise<IDBDatabase> => {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const idb = globalThis.indexedDB;
if (!idb) {
return reject(new Error("IndexedDB is not available in this environment"));
}
const request = idb.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.onblocked = () => reject(new Error("IndexedDB open blocked"));
request.onsuccess = () => {
const db = request.result;
// 将来のスキーマ更新で古いコネクションが残らないように
db.onversionchange = () => db.close();
resolve(db);
};
});
return dbPromise;
};

Comment on lines +22 to +31
export const getCache = async <T>(key: string): Promise<T | null> => {
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);
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

トランザクション完了を待たずにresolveしており、整合性が崩れる可能性があります

get側でもtxのabort/errorを拾っていないため、不完全な状態でresolveし得ます。

提案Diff:

   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);
+    req.onsuccess = () => resolve((req.result as T) ?? null);
+    req.onerror = () => reject(req.error);
+    tx.onabort = () => reject(tx.error ?? new Error("IndexedDB tx aborted"));
+    tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx error"));
   });

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/client/src/components/utils/cache.ts around lines 22 to 31, getCache
currently resolves the value as soon as the request succeeds without waiting for
the transaction to complete and doesn't handle tx.onerror/tx.onabort, which can
yield inconsistent results; change the logic to not resolve inside req.onsuccess
but instead store the retrieved value and resolve only in tx.oncomplete, and add
tx.onerror and tx.onabort handlers that reject with the transaction or request
error so callers observe aborts/failures; ensure you still call resolve(null)
when req.result is undefined but only from tx.oncomplete, and reject with
req.error or tx.error from the error/abort handlers.

Comment on lines +33 to +42
export const setCache = async <T>(key: string, value: T): Promise<void> => {
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);
});
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

setCacheがreq成功時点でresolveしており、commit前に成功と判定しています

tx.oncompleteでresolveし、abort/errorを確実に拾ってください。

提案Diff:

   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);
+    req.onerror = () => reject(req.error);
+    tx.oncomplete = () => resolve();
+    tx.onabort = () => reject(tx.error ?? new Error("IndexedDB tx aborted"));
+    tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx error"));
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const setCache = async <T>(key: string, value: T): Promise<void> => {
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);
});
};
export const setCache = async <T>(key: string, value: T): Promise<void> => {
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.onerror = () => reject(req.error);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB tx aborted"));
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx error"));
});
};
🤖 Prompt for AI Agents
In app/client/src/components/utils/cache.ts around lines 33 to 42, setCache
currently resolves when the objectStore request succeeds (req.onsuccess) which
can occur before the transaction actually commits; change the logic to resolve
only on tx.oncomplete, and reject on tx.onabort and tx.onerror (and also keep
req.onerror to forward the underlying error) so that callers only get success
after the transaction commits and all failure modes are caught.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant