From 6c572e3f5afcd67f35ff4773234b4d85aeec3655 Mon Sep 17 00:00:00 2001 From: kosako Date: Sat, 11 Jul 2026 18:18:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?server:=20inbox=20/=20login=20=E3=81=AE=20H?= =?UTF-8?q?TML=20=E7=94=9F=E6=88=90=E3=82=92=20inbox-view.js=20=E3=81=B8?= =?UTF-8?q?=E5=88=87=E3=82=8A=E5=87=BA=E3=81=99=20(#109=20R-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 挙動を変えないリファクタリング(Issue #109 スライス R-2)。HTML 出力は バイト単位で不変(inbox の data 属性・削除ボタン等を pin する既存テストは 無変更で pass)。 - renderInbox / renderImportPanel / renderFilterPanel / renderGitHubCell / renderScreenshotPreview / formatSlackStatus / feedbackStatusOf / renderLoginPage を server/inbox-view.js(CJS)へ verbatim move - inbox-view.js は shared/format.js と store.js の FEEDBACK_STATUSES を 直接 require し、receiver の設定・状態に依存するもの(screenshotUrlFor / formatScreenshotStatus / safeLinkUrl / GITHUB_CONFIGURED 等)は createInboxView(deps) factory で注入 - responder(handleGetInbox / respondLoginPage 等)と Slack / GitHub 整形と 共有するヘルパは receive.js に残置 実装: Codex(Claude 作成のブリーフに基づく)。検証: npm run check 全 113 テスト pass。 Co-Authored-By: Codex --- server/inbox-view.js | 220 +++++++++++++++++++++++++++++++++++++++++++ server/receive.js | 219 ++---------------------------------------- 2 files changed, 229 insertions(+), 210 deletions(-) create mode 100644 server/inbox-view.js diff --git a/server/inbox-view.js b/server/inbox-view.js new file mode 100644 index 0000000..c4ad7e2 --- /dev/null +++ b/server/inbox-view.js @@ -0,0 +1,220 @@ +"use strict"; + +const { escapeHtml } = require("../shared/format.js"); +const { FEEDBACK_STATUSES } = require("./store.js"); + +function createInboxView(deps) { + const { formatScreenshotStatus, safeLinkUrl, GITHUB_CONFIGURED, RECEIVER_TOKEN } = deps; + function feedbackStatusOf(item) { + return FEEDBACK_STATUSES.includes(item.status) ? item.status : "new"; + } + + function renderInbox(items) { + const cards = items.map((item) => { + const target = item.target || {}; + const env = item.environment || {}; + const page = item.page || {}; + const slack = item.integrations && item.integrations.slack; + const screenshot = item.screenshot; + const kind = escapeHtml(target.kind || "?"); + const selector = escapeHtml(target.selector || ""); + const pageUrl = escapeHtml(page.url || ""); + const pageTitle = escapeHtml(page.title || ""); + const comment = escapeHtml(item.comment || ""); + const reviewer = escapeHtml(item.reviewer || ""); + const createdAt = escapeHtml(item.createdAt || ""); + const receivedAt = escapeHtml(item.receivedAt || ""); + const source = escapeHtml(item.source || "receiver"); + const project = escapeHtml(item.projectId || ""); + const demo = escapeHtml(item.demoId || ""); + const importedAt = escapeHtml(item.importedAt || ""); + const status = feedbackStatusOf(item); + const slackStatus = escapeHtml((slack && slack.status) || "unknown"); + const github = item.integrations && item.integrations.github; + const githubStatus = escapeHtml((github && github.status) || "none"); + const searchText = escapeHtml([item.comment, item.reviewer, target.selector, page.url, page.title, item.id] + .filter(Boolean).join(" ").toLowerCase()); + const statusOptions = FEEDBACK_STATUSES + .map((value) => ``) + .join(""); + const viewport = env.viewport + ? `${env.viewport.width}×${env.viewport.height}` + : ""; + return ` +
+
+ ${kind} + ${reviewer || "(no name)"} + + + +
+

${comment}

+ ${renderScreenshotPreview(screenshot)} +
+
URL
${safeLinkUrl(page.url) ? `${pageUrl}` : pageUrl}
+
Title
${pageTitle}
+
Selector
${selector}
+
Viewport
${escapeHtml(viewport)}
+
Source
${source}
+
Slack
${escapeHtml(formatSlackStatus(slack))}
+
GitHub
${renderGitHubCell(github, item.id)}
+
Created
${createdAt}
+ ${importedAt ? `
Imported
${importedAt}
` : ""} +
+
+ raw payload +
${escapeHtml(JSON.stringify(item, null, 2))}
+
+
+ `; + }); + + return ` + + + + + PatchLoop Inbox + + + +

PatchLoop Inbox

+

${items.length} feedback received · raw JSON

+ ${RECEIVER_TOKEN ? '
' : ""} + ${renderImportPanel()} + ${items.length === 0 ? "" : renderFilterPanel(items)} + ${items.length === 0 ? '

まだフィードバックはありません。widget からコメントを送ると、ここに表示されます。

' : cards.join("")} + + + +`; + } + + function renderImportPanel() { + return ` +
+
+

Import feedback bundle

+

Download mode で保存した .patchloop-feedback.json を読み込みます。

+
+
+ + + +
+
`; + } + + function renderFilterPanel(items) { + const optionList = (values, allLabel) => [``] + .concat(values.map((value) => ``)) + .join(""); + const unique = (mapper) => Array.from(new Set(items.map(mapper).filter(Boolean))).sort(); + const projects = unique((item) => item.projectId || ""); + const demos = unique((item) => item.demoId || ""); + const reviewers = unique((item) => item.reviewer || ""); + const sources = unique((item) => item.source || "receiver"); + const slackStatuses = unique((item) => (item.integrations && item.integrations.slack && item.integrations.slack.status) || "unknown"); + const githubStatuses = unique((item) => (item.integrations && item.integrations.github && item.integrations.github.status) || "none"); + + return ` +
+ + + + + + + + + + +
`; + } + + function renderGitHubCell(github, id) { + if (github && github.status === "created") { + const number = github.issueNumber != null ? `#${escapeHtml(String(github.issueNumber))}` : "issue"; + const url = safeLinkUrl(github.url); + return url + ? `${number} created` + : `${number} created`; + } + + if (!GITHUB_CONFIGURED) return "not configured"; + + const button = ``; + if (github && github.status === "failed") { + const code = github.statusCode ? ` (${escapeHtml(String(github.statusCode))})` : ""; + return `failed${code}: ${escapeHtml(github.error || "unknown error")} ${button}`; + } + return button; + } + + function renderScreenshotPreview(screenshot) { + if (!screenshot) return ""; + if (screenshot.status === "saved" && safeLinkUrl(screenshot.url)) { + const url = escapeHtml(safeLinkUrl(screenshot.url)); + const size = screenshot.width && screenshot.height + ? `${screenshot.width}×${screenshot.height}` + : ""; + const bytes = screenshot.bytes ? `${screenshot.bytes} bytes` : ""; + const caption = [size, bytes].filter(Boolean).join(" · "); + return ` +
+ + PatchLoop screenshot preview + +
${escapeHtml(caption || "screenshot saved")}
+
+ `; + } + + return `

Screenshot: ${escapeHtml(formatScreenshotStatus(screenshot))}

`; + } + + function formatSlackStatus(slack) { + if (!slack) return "unknown"; + const image = slack.image && slack.image.status + ? `, image ${slack.image.status}` + : ""; + if (slack.status === "sent") return `sent${slack.statusCode ? ` (${slack.statusCode})` : ""}${image}`; + if (slack.status === "failed") return `failed${slack.statusCode ? ` (${slack.statusCode})` : ""}: ${slack.error || "unknown error"}`; + return slack.status || "unknown"; + } + + function renderLoginPage(failed) { + return ` + + + + + PatchLoop Inbox — Login + + + +

PatchLoop Inbox

+ ${failed ? '

トークンが違います。

' : ""} +
+ + +
+ +`; + } + + return { renderInbox, renderLoginPage }; +} + +module.exports = { createInboxView }; diff --git a/server/receive.js b/server/receive.js index abdb04a..21e026f 100644 --- a/server/receive.js +++ b/server/receive.js @@ -6,8 +6,9 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); -const { safeFilePart, truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = require("../shared/format.js"); +const { safeFilePart, truncateText, present, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = require("../shared/format.js"); const { createStore, FEEDBACK_STATUSES } = require("./store.js"); +const { createInboxView } = require("./inbox-view.js"); const CONFIG_PATH = process.env.PATCHLOOP_RECEIVER_CONFIG || path.join(__dirname, "receiver.config.json"); const config = loadConfig(CONFIG_PATH); @@ -1223,34 +1224,6 @@ function respondLoginPage(res, status, failed) { res.end(renderLoginPage(failed)); } -function renderLoginPage(failed) { - return ` - - - - - PatchLoop Inbox — Login - - - -

PatchLoop Inbox

- ${failed ? '

トークンが違います。

' : ""} -
- - -
- -`; -} - // The inbox renders screenshots via their public URL, which can differ from the // origin the browser used to reach the inbox (e.g. publicBaseUrl behind a // tunnel), so img-src lists it next to 'self'. @@ -1263,6 +1236,13 @@ const PUBLIC_ORIGIN = (() => { } })(); const INBOX_CSP = `default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'${PUBLIC_ORIGIN ? ` ${PUBLIC_ORIGIN}` : ""}; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`; +const { renderInbox, renderLoginPage } = createInboxView({ + screenshotUrlFor, + formatScreenshotStatus, + safeLinkUrl, + GITHUB_CONFIGURED, + RECEIVER_TOKEN +}); async function handleGetInbox(req, res) { try { @@ -1892,184 +1872,3 @@ function shouldSendSlackImageBlock(screenshotUrl) { if (SLACK_IMAGE_MODE === "block") return /^https?:\/\//.test(screenshotUrl); return isLikelyPublicHttpUrl(screenshotUrl); } - -function feedbackStatusOf(item) { - return FEEDBACK_STATUSES.includes(item.status) ? item.status : "new"; -} - -function renderInbox(items) { - const cards = items.map((item) => { - const target = item.target || {}; - const env = item.environment || {}; - const page = item.page || {}; - const slack = item.integrations && item.integrations.slack; - const screenshot = item.screenshot; - const kind = escapeHtml(target.kind || "?"); - const selector = escapeHtml(target.selector || ""); - const pageUrl = escapeHtml(page.url || ""); - const pageTitle = escapeHtml(page.title || ""); - const comment = escapeHtml(item.comment || ""); - const reviewer = escapeHtml(item.reviewer || ""); - const createdAt = escapeHtml(item.createdAt || ""); - const receivedAt = escapeHtml(item.receivedAt || ""); - const source = escapeHtml(item.source || "receiver"); - const project = escapeHtml(item.projectId || ""); - const demo = escapeHtml(item.demoId || ""); - const importedAt = escapeHtml(item.importedAt || ""); - const status = feedbackStatusOf(item); - const slackStatus = escapeHtml((slack && slack.status) || "unknown"); - const github = item.integrations && item.integrations.github; - const githubStatus = escapeHtml((github && github.status) || "none"); - const searchText = escapeHtml([item.comment, item.reviewer, target.selector, page.url, page.title, item.id] - .filter(Boolean).join(" ").toLowerCase()); - const statusOptions = FEEDBACK_STATUSES - .map((value) => ``) - .join(""); - const viewport = env.viewport - ? `${env.viewport.width}×${env.viewport.height}` - : ""; - return ` -
-
- ${kind} - ${reviewer || "(no name)"} - - - -
-

${comment}

- ${renderScreenshotPreview(screenshot)} -
-
URL
${safeLinkUrl(page.url) ? `${pageUrl}` : pageUrl}
-
Title
${pageTitle}
-
Selector
${selector}
-
Viewport
${escapeHtml(viewport)}
-
Source
${source}
-
Slack
${escapeHtml(formatSlackStatus(slack))}
-
GitHub
${renderGitHubCell(github, item.id)}
-
Created
${createdAt}
- ${importedAt ? `
Imported
${importedAt}
` : ""} -
-
- raw payload -
${escapeHtml(JSON.stringify(item, null, 2))}
-
-
- `; - }); - - return ` - - - - - PatchLoop Inbox - - - -

PatchLoop Inbox

-

${items.length} feedback received · raw JSON

- ${RECEIVER_TOKEN ? '
' : ""} - ${renderImportPanel()} - ${items.length === 0 ? "" : renderFilterPanel(items)} - ${items.length === 0 ? '

まだフィードバックはありません。widget からコメントを送ると、ここに表示されます。

' : cards.join("")} - - - -`; -} - -function renderImportPanel() { - return ` -
-
-

Import feedback bundle

-

Download mode で保存した .patchloop-feedback.json を読み込みます。

-
-
- - - -
-
`; -} - -function renderFilterPanel(items) { - const optionList = (values, allLabel) => [``] - .concat(values.map((value) => ``)) - .join(""); - const unique = (mapper) => Array.from(new Set(items.map(mapper).filter(Boolean))).sort(); - const projects = unique((item) => item.projectId || ""); - const demos = unique((item) => item.demoId || ""); - const reviewers = unique((item) => item.reviewer || ""); - const sources = unique((item) => item.source || "receiver"); - const slackStatuses = unique((item) => (item.integrations && item.integrations.slack && item.integrations.slack.status) || "unknown"); - const githubStatuses = unique((item) => (item.integrations && item.integrations.github && item.integrations.github.status) || "none"); - - return ` -
- - - - - - - - - - -
`; -} - -function renderGitHubCell(github, id) { - if (github && github.status === "created") { - const number = github.issueNumber != null ? `#${escapeHtml(String(github.issueNumber))}` : "issue"; - const url = safeLinkUrl(github.url); - return url - ? `${number} created` - : `${number} created`; - } - - if (!GITHUB_CONFIGURED) return "not configured"; - - const button = ``; - if (github && github.status === "failed") { - const code = github.statusCode ? ` (${escapeHtml(String(github.statusCode))})` : ""; - return `failed${code}: ${escapeHtml(github.error || "unknown error")} ${button}`; - } - return button; -} - -function renderScreenshotPreview(screenshot) { - if (!screenshot) return ""; - if (screenshot.status === "saved" && safeLinkUrl(screenshot.url)) { - const url = escapeHtml(safeLinkUrl(screenshot.url)); - const size = screenshot.width && screenshot.height - ? `${screenshot.width}×${screenshot.height}` - : ""; - const bytes = screenshot.bytes ? `${screenshot.bytes} bytes` : ""; - const caption = [size, bytes].filter(Boolean).join(" · "); - return ` -
- - PatchLoop screenshot preview - -
${escapeHtml(caption || "screenshot saved")}
-
- `; - } - - return `

Screenshot: ${escapeHtml(formatScreenshotStatus(screenshot))}

`; -} - -function formatSlackStatus(slack) { - if (!slack) return "unknown"; - const image = slack.image && slack.image.status - ? `, image ${slack.image.status}` - : ""; - if (slack.status === "sent") return `sent${slack.statusCode ? ` (${slack.statusCode})` : ""}${image}`; - if (slack.status === "failed") return `failed${slack.statusCode ? ` (${slack.statusCode})` : ""}: ${slack.error || "unknown error"}`; - return slack.status || "unknown"; -} From de34fd39fc57ed795e9c5df2bbfe1e8648ff4b2b Mon Sep 17 00:00:00 2001 From: kosako Date: Sat, 11 Jul 2026 18:24:49 +0900 Subject: [PATCH 2/2] =?UTF-8?q?server:=20createInboxView=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=20dep(screenshotUrlFor)=E3=82=92?= =?UTF-8?q?=E8=90=BD=E3=81=A8=E3=81=99=20(#109=20R-2=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inbox-view は screenshot.url を safeLinkUrl で直接検証しており screenshotUrlFor を読まない(Claude レビュー 🟡)。 Co-Authored-By: Codex --- server/receive.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/receive.js b/server/receive.js index 21e026f..3966596 100644 --- a/server/receive.js +++ b/server/receive.js @@ -1237,7 +1237,6 @@ const PUBLIC_ORIGIN = (() => { })(); const INBOX_CSP = `default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'${PUBLIC_ORIGIN ? ` ${PUBLIC_ORIGIN}` : ""}; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`; const { renderInbox, renderLoginPage } = createInboxView({ - screenshotUrlFor, formatScreenshotStatus, safeLinkUrl, GITHUB_CONFIGURED,