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)"}
+
+
+
+
+
+ ${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("")}
+ 絞り込みに一致する feedback はありません。
+
+
+`;
+ }
+
+ 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 `
+
+
+
+
+ ${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..3966596 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,12 @@ 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({
+ formatScreenshotStatus,
+ safeLinkUrl,
+ GITHUB_CONFIGURED,
+ RECEIVER_TOKEN
+});
async function handleGetInbox(req, res) {
try {
@@ -1892,184 +1871,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)"}
-
-
-
-
-
- ${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("")}
- 絞り込みに一致する feedback はありません。
-
-
-`;
-}
-
-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 `
-
-
-
-
- ${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";
-}