diff --git a/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx index 4a6144dd0..b29acc1e8 100644 --- a/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx +++ b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx @@ -1,47 +1,38 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; import { forwardRef, useMemo } from "react"; import MemoContent from "@/components/MemoContent"; -import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; import UserAvatar from "@/components/UserAvatar"; import i18n from "@/i18n"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; -import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item"; import { useMemoViewContext } from "../MemoView/MemoViewContext"; -import { getMemoSharePreviewAvatarUrl } from "./memoShareImage"; +import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel"; const MemoShareImagePreview = forwardRef(({ width }, ref) => { const t = useTranslate(); const { memo, creator, blurred, showBlurredContent } = useMemoViewContext(); + const fallbackDisplayName = t("common.memo"); + const locale = i18n.language; - const displayName = creator?.displayName || creator?.username || t("common.memo"); - const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl); - const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined; - const formattedDisplayTime = displayTime?.toLocaleString(i18n.language, { - dateStyle: "medium", - timeStyle: "short", - }); - const { attachmentCount, nonVisualAttachmentCount, visualItems } = useMemo(() => { - const attachmentGroups = separateAttachments(memo.attachments); - const previewVisualItems = buildAttachmentVisualItems(attachmentGroups.visual); - const totalAttachmentCount = countLogicalAttachmentItems(memo.attachments); - - return { - attachmentCount: totalAttachmentCount, - nonVisualAttachmentCount: totalAttachmentCount - previewVisualItems.length, - visualItems: previewVisualItems, - }; - }, [memo.attachments]); + const preview = useMemo( + () => + buildMemoShareImagePreviewModel({ + memo, + creator, + fallbackDisplayName, + locale, + }), + [creator, fallbackDisplayName, locale, memo], + ); return (
- +
-
{displayName}
- {formattedDisplayTime &&
{formattedDisplayTime}
} +
{preview.displayName}
+ {preview.formattedDisplayTime &&
{preview.formattedDisplayTime}
}
@@ -52,21 +43,21 @@ const MemoShareImagePreview = forwardRef(({ w
- {visualItems.length > 0 && ( -
- {visualItems.slice(0, 4).map((item, index) => ( + {preview.visualItems.length > 0 && ( +
+ {preview.visualItems.slice(0, 4).map((item, index) => (
{item.filename} - {index === 3 && visualItems.length > 4 && ( + {index === 3 && preview.visualItems.length > 4 && (
- +{visualItems.length - 4} + +{preview.visualItems.length - 4}
)}
@@ -74,26 +65,16 @@ const MemoShareImagePreview = forwardRef(({ w
)} - {(memo.tags.length > 0 || nonVisualAttachmentCount > 0) && ( + {preview.footerBadges.length > 0 && (
- {memo.tags.slice(0, 3).map((tag) => ( + {preview.footerBadges.map((badge) => ( - #{tag} + {badge.count} {t("common.attachments").toLowerCase()} ))} - {memo.tags.length > 3 && ( - - +{memo.tags.length - 3} - - )} - {nonVisualAttachmentCount > 0 && ( - - {attachmentCount} {t("common.attachments").toLowerCase()} - - )}
)}
diff --git a/web/src/components/MemoActionMenu/memoShareImage.ts b/web/src/components/MemoActionMenu/memoShareImage.ts index d239df1c5..ab0516c75 100644 --- a/web/src/components/MemoActionMenu/memoShareImage.ts +++ b/web/src/components/MemoActionMenu/memoShareImage.ts @@ -55,7 +55,7 @@ const waitForPreviewAssets = async (node: HTMLElement) => { }; export const buildMemoShareImageFileName = (memoName: string) => { - const suffix = memoName.split("/").pop() ?? "memo"; + const suffix = memoName.split("/").pop() || "memo"; return `memo-${suffix}.png`; }; diff --git a/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts new file mode 100644 index 000000000..369121401 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts @@ -0,0 +1,58 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { User } from "@/types/proto/api/v1/user_service_pb"; +import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item"; +import { getMemoSharePreviewAvatarUrl } from "./memoShareImage"; + +interface BuildMemoShareImagePreviewModelOptions { + memo: Memo; + creator?: User; + fallbackDisplayName: string; + locale: string; +} + +export interface MemoShareImageAttachmentSummaryBadge { + type: "attachment-summary"; + count: number; +} + +export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge; + +export interface MemoShareImagePreviewModel { + displayName: string; + avatarUrl?: string; + formattedDisplayTime?: string; + visualItems: AttachmentVisualItem[]; + footerBadges: MemoShareImageFooterBadge[]; +} + +export const buildMemoShareImagePreviewModel = ({ + memo, + creator, + fallbackDisplayName, + locale, +}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => { + const displayName = creator?.displayName || creator?.username || fallbackDisplayName; + const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl); + const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : memo.createTime ? timestampDate(memo.createTime) : undefined; + const formattedDisplayTime = displayTime?.toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + + const attachmentGroups = separateAttachments(memo.attachments); + const visualItems = buildAttachmentVisualItems(attachmentGroups.visual); + const attachmentCount = countLogicalAttachmentItems(memo.attachments); + const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0); + const footerBadges: MemoShareImageFooterBadge[] = + nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : []; + + return { + displayName, + avatarUrl, + formattedDisplayTime, + visualItems, + footerBadges, + }; +}; diff --git a/web/tests/memo-share-image.test.ts b/web/tests/memo-share-image.test.ts new file mode 100644 index 000000000..5c91b5537 --- /dev/null +++ b/web/tests/memo-share-image.test.ts @@ -0,0 +1,134 @@ +import { create } from "@bufbuild/protobuf"; +import { describe, expect, it } from "vitest"; +import { + buildMemoShareImageFileName, + getMemoShareDialogWidth, + getMemoSharePreviewAvatarUrl, + getMemoSharePreviewWidth, + getMemoShareRenderWidth, +} from "@/components/MemoActionMenu/memoShareImage"; +import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel"; +import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb"; + +const buildMemo = (overrides: Partial = {}) => + create(MemoSchema, { + name: "memos/test", + content: "hello", + tags: [], + attachments: [], + ...overrides, + }); + +const buildAttachment = (overrides: Partial) => + create(AttachmentSchema, { + name: "attachments/test", + filename: "test.bin", + type: "application/octet-stream", + ...overrides, + }); + +const buildPreviewModel = (memo: Memo) => + buildMemoShareImagePreviewModel({ + memo, + fallbackDisplayName: "Memo", + locale: "en-US", + }); + +describe("memo share image preview model", () => { + it("does not create footer chips for memo tags already rendered in content", () => { + const memo = buildMemo({ + content: "Investigate #bug", + tags: ["bug"], + }); + + const model = buildPreviewModel(memo); + + expect(model.footerBadges).toEqual([]); + }); + + it("keeps non-visual attachments visible as a footer summary", () => { + const memo = buildMemo({ + attachments: [ + buildAttachment({ + name: "attachments/doc", + filename: "doc.pdf", + type: "application/pdf", + }), + ], + }); + + const model = buildPreviewModel(memo); + + expect(model.visualItems).toEqual([]); + expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 1 }]); + }); + + it("keeps visual attachments in the media grid without adding a footer summary", () => { + const memo = buildMemo({ + attachments: [ + buildAttachment({ + name: "attachments/image", + filename: "image.png", + type: "image/png", + }), + ], + }); + + const model = buildPreviewModel(memo); + + expect(model.visualItems).toHaveLength(1); + expect(model.visualItems[0]?.posterUrl).toContain("/file/attachments/image/image.png?thumbnail=true"); + expect(model.footerBadges).toEqual([]); + }); + + it("counts mixed visual and non-visual attachments in the summary", () => { + const memo = buildMemo({ + attachments: [ + buildAttachment({ + name: "attachments/image", + filename: "image.png", + type: "image/png", + }), + buildAttachment({ + name: "attachments/archive", + filename: "archive.zip", + type: "application/zip", + }), + ], + }); + + const model = buildPreviewModel(memo); + + expect(model.visualItems).toHaveLength(1); + expect(model.footerBadges).toEqual([{ type: "attachment-summary", count: 2 }]); + }); +}); + +describe("memo share image utilities", () => { + it("builds filenames from memo resource names", () => { + expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png"); + expect(buildMemoShareImageFileName("")).toBe("memo-memo.png"); + }); + + it("clamps preview and dialog widths", () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 }); + + expect(getMemoSharePreviewWidth(100)).toBe(260); + expect(getMemoSharePreviewWidth(800)).toBe(520); + expect(getMemoShareDialogWidth(520)).toBe(600); + expect(getMemoShareRenderWidth(520, 600)).toBe(560); + }); + + it("uses the viewport when no card width is available", () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 }); + + expect(getMemoSharePreviewWidth(0)).toBe(317); + }); + + it("keeps only exportable avatar URLs", () => { + expect(getMemoSharePreviewAvatarUrl("/avatars/a.png")).toBe("/avatars/a.png"); + expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc"); + expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined(); + }); +});