{attachments.map((attachment) => (
@@ -172,9 +155,9 @@ const Divider = () =>
;
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
- const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]);
- const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
- const hasVisual = visual.length > 0;
+ const visualItems = useMemo(() => buildAttachmentVisualItems(visual), [visual]);
+ const previewItems = useMemo(() => visualItems.map((item) => item.previewItem), [visualItems]);
+ const hasVisual = visualItems.length > 0;
const hasAudio = audio.length > 0;
const hasDocs = docs.length > 0;
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
@@ -183,16 +166,21 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
return null;
}
- const handleImageClick = (imgUrl: string) => {
- const index = imageUrls.findIndex((url) => url === imgUrl);
- onImagePreview?.(imageUrls, index >= 0 ? index : 0);
+ const handlePreview = (itemId: string) => {
+ const index = previewItems.findIndex((item) => item.id === itemId);
+ onImagePreview?.(previewItems, index >= 0 ? index : 0);
};
return (
-
- {hasVisual && }
+
+ {hasVisual && }
{hasVisual && sectionCount > 1 && }
- {hasAudio && }
+ {hasAudio && }
{hasAudio && hasDocs && }
{hasDocs && }
diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx
index 7149a5a04..5da49f3ba 100644
--- a/web/src/components/MemoPreview/MemoPreview.tsx
+++ b/web/src/components/MemoPreview/MemoPreview.tsx
@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
-import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
+import { getAttachmentType, isMotionAttachment } from "@/utils/attachment";
+import { buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
@@ -36,28 +37,35 @@ const STUB_CONTEXT: MemoViewContextValue = {
};
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
- const images: Attachment[] = [];
- const others: Attachment[] = [];
- for (const a of attachments) {
- if (getAttachmentType(a) === "image/*") images.push(a);
- else others.push(a);
- }
+ const visualAttachments = attachments.filter(
+ (attachment) =>
+ getAttachmentType(attachment) === "image/*" || getAttachmentType(attachment) === "video/*" || isMotionAttachment(attachment),
+ );
+ const items = buildAttachmentVisualItems(visualAttachments);
+ const images = items.filter((item) => item.kind === "image" || item.kind === "motion");
+ const others = items.filter((item) => item.kind === "video");
return (
- {images.map((a) => (
-
})
+ {images.map((item) => (
+
+

+ {item.kind === "motion" && (
+
+ LIVE
+
+ )}
+
))}
- {others.map((a) => (
-
+ {others.map((item) => (
+
- {a.filename}
+ {item.filename}
))}
@@ -138,7 +146,7 @@ const MemoPreview = ({
(truncate ? (
- {attachments.length}
+ {countLogicalAttachmentItems(attachments)}
) : (
diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx
index 4010ba8da..e5c43997a 100644
--- a/web/src/components/MemoView/MemoView.tsx
+++ b/web/src/components/MemoView/MemoView.tsx
@@ -97,7 +97,7 @@ const MemoView: React.FC
= (props: MemoViewProps) => {
diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx
index d9c29ca2f..79413313e 100644
--- a/web/src/components/MemoView/MemoViewContext.tsx
+++ b/web/src/components/MemoView/MemoViewContext.tsx
@@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
+import type { PreviewMediaItem } from "@/utils/media-item";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
export interface MemoViewContextValue {
@@ -17,7 +18,7 @@ export interface MemoViewContextValue {
blurred: boolean;
openEditor: () => void;
toggleBlurVisibility: () => void;
- openPreview: (urls: string | string[], index?: number) => void;
+ openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
}
export const MemoViewContext = createContext(null);
diff --git a/web/src/components/MemoView/hooks/useImagePreview.ts b/web/src/components/MemoView/hooks/useImagePreview.ts
index 7a0e975ae..75fcb84fe 100644
--- a/web/src/components/MemoView/hooks/useImagePreview.ts
+++ b/web/src/components/MemoView/hooks/useImagePreview.ts
@@ -1,22 +1,24 @@
import { useCallback, useState } from "react";
+import type { PreviewMediaItem } from "@/utils/media-item";
export interface ImagePreviewState {
open: boolean;
- urls: string[];
+ items: PreviewMediaItem[];
index: number;
}
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
- openPreview: (urls: string | string[], index?: number) => void;
+ openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
setPreviewOpen: (open: boolean) => void;
}
export const useImagePreview = (): UseImagePreviewReturn => {
- const [previewState, setPreviewState] = useState({ open: false, urls: [], index: 0 });
+ const [previewState, setPreviewState] = useState({ open: false, items: [], index: 0 });
- const openPreview = useCallback((urls: string | string[], index = 0) => {
- setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index });
+ const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => {
+ const normalizedItems = normalizePreviewItems(items);
+ setPreviewState({ open: true, items: normalizedItems, index });
}, []);
const setPreviewOpen = useCallback((open: boolean) => {
@@ -25,3 +27,31 @@ export const useImagePreview = (): UseImagePreviewReturn => {
return { previewState, openPreview, setPreviewOpen };
};
+
+function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): PreviewMediaItem[] {
+ if (typeof items === "string") {
+ return [
+ {
+ id: items,
+ kind: "image",
+ sourceUrl: items,
+ posterUrl: items,
+ filename: "Image",
+ isMotion: false,
+ },
+ ];
+ }
+
+ if (Array.isArray(items) && (items.length === 0 || typeof items[0] === "string")) {
+ return (items as string[]).map((url) => ({
+ id: url,
+ kind: "image",
+ sourceUrl: url,
+ posterUrl: url,
+ filename: "Image",
+ isMotion: false,
+ }));
+ }
+
+ return items as PreviewMediaItem[];
+}
diff --git a/web/src/components/MemoView/hooks/useMemoHandlers.ts b/web/src/components/MemoView/hooks/useMemoHandlers.ts
index a57516124..b701606b7 100644
--- a/web/src/components/MemoView/hooks/useMemoHandlers.ts
+++ b/web/src/components/MemoView/hooks/useMemoHandlers.ts
@@ -1,10 +1,11 @@
import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
+import type { PreviewMediaItem } from "@/utils/media-item";
interface UseMemoHandlersOptions {
readonly: boolean;
openEditor: () => void;
- openPreview: (urls: string | string[], index?: number) => void;
+ openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
}
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx
index 794df77a1..f34169da3 100644
--- a/web/src/components/PreviewImageDialog.tsx
+++ b/web/src/components/PreviewImageDialog.tsx
@@ -2,16 +2,21 @@ import { X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
+import type { PreviewMediaItem } from "@/utils/media-item";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
- imgUrls: string[];
+ imgUrls?: string[];
+ items?: PreviewMediaItem[];
initialIndex?: number;
}
-function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
+function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
+ const previewItems =
+ items ??
+ imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image", isMotion: false }));
// Update current index when initialIndex prop changes
useEffect(() => {
@@ -28,7 +33,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
onOpenChange(false);
break;
case "ArrowRight":
- setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1));
+ setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1));
break;
case "ArrowLeft":
setCurrentIndex((prev) => Math.max(prev - 1, 0));
@@ -53,10 +58,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: P
};
// Return early if no images provided
- if (!imgUrls.length) return null;
+ if (!previewItems.length) return null;
// Ensure currentIndex is within bounds
- const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1));
+ const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1));
+ const currentItem = previewItems[safeIndex];
return (