Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
2ccb98a6
Unverified
Commit
2ccb98a6
authored
Mar 07, 2026
by
memoclaw
Committed by
GitHub
Mar 07, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: render audio attachments as inline players (#5699)
Co-authored-by:
Claude Opus 4.6
<
noreply@anthropic.com
>
parent
45036791
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
57 additions
and
27 deletions
+57
-27
AttachmentCard.tsx
...omponents/MemoView/components/metadata/AttachmentCard.tsx
+4
-0
AttachmentList.tsx
...omponents/MemoView/components/metadata/AttachmentList.tsx
+53
-27
No files found.
web/src/components/MemoView/components/metadata/AttachmentCard.tsx
View file @
2ccb98a6
...
@@ -28,6 +28,10 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
...
@@ -28,6 +28,10 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
return
<
video
src=
{
sourceUrl
}
className=
{
cn
(
"w-full h-full object-cover rounded-lg"
,
className
)
}
controls
preload=
"metadata"
/>;
return
<
video
src=
{
sourceUrl
}
className=
{
cn
(
"w-full h-full object-cover rounded-lg"
,
className
)
}
controls
preload=
"metadata"
/>;
}
}
if
(
attachmentType
===
"audio/*"
)
{
return
<
audio
src=
{
sourceUrl
}
className=
{
cn
(
"w-full rounded-lg"
,
className
)
}
controls
preload=
"metadata"
/>;
}
return
null
;
return
null
;
};
};
...
...
web/src/components/MemoView/components/metadata/AttachmentList.tsx
View file @
2ccb98a6
import
{
FileIcon
,
PaperclipIcon
}
from
"lucide-react"
;
import
{
File
AudioIcon
,
File
Icon
,
PaperclipIcon
}
from
"lucide-react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
...
@@ -11,25 +11,26 @@ interface AttachmentListProps {
...
@@ -11,25 +11,26 @@ interface AttachmentListProps {
attachments
:
Attachment
[];
attachments
:
Attachment
[];
}
}
// Type guards for attachment types
const
isImageAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"image/*"
;
const
isImageAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"image/*"
;
const
isVideoAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"video/*"
;
const
isVideoAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"video/*"
;
const
is
MediaAttachment
=
(
attachment
:
Attachment
):
boolean
=>
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
)
;
const
is
AudioAttachment
=
(
attachment
:
Attachment
):
boolean
=>
getAttachmentType
(
attachment
)
===
"audio/*"
;
// Separate attachments into media (images/videos) and documents
const
separateAttachments
=
(
attachments
:
Attachment
[])
=>
{
const
separateMediaAndDocs
=
(
attachments
:
Attachment
[]):
{
media
:
Attachment
[];
docs
:
Attachment
[]
}
=>
{
const
visual
:
Attachment
[]
=
[];
const
media
:
Attachment
[]
=
[];
const
audio
:
Attachment
[]
=
[];
const
docs
:
Attachment
[]
=
[];
const
docs
:
Attachment
[]
=
[];
for
(
const
attachment
of
attachments
)
{
for
(
const
attachment
of
attachments
)
{
if
(
isMediaAttachment
(
attachment
))
{
if
(
isImageAttachment
(
attachment
)
||
isVideoAttachment
(
attachment
))
{
media
.
push
(
attachment
);
visual
.
push
(
attachment
);
}
else
if
(
isAudioAttachment
(
attachment
))
{
audio
.
push
(
attachment
);
}
else
{
}
else
{
docs
.
push
(
attachment
);
docs
.
push
(
attachment
);
}
}
}
}
return
{
media
,
docs
};
return
{
visual
,
audio
,
docs
};
};
};
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
DocumentItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
...
@@ -60,16 +61,30 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
...
@@ -60,16 +61,30 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
);
};
};
interface
MediaItemProps
{
const
AudioItem
=
({
attachment
}:
{
attachment
:
Attachment
})
=>
{
const
sourceUrl
=
getAttachmentUrl
(
attachment
);
return
(
<
div
className=
"flex flex-col gap-1 px-1 py-1"
>
<
div
className=
"flex items-center gap-1 text-xs text-muted-foreground"
>
<
FileAudioIcon
className=
"w-3 h-3 shrink-0"
/>
<
span
className=
"truncate"
title=
{
attachment
.
filename
}
>
{
attachment
.
filename
}
</
span
>
</
div
>
<
audio
src=
{
sourceUrl
}
controls
preload=
"metadata"
className=
"w-full h-8"
/>
</
div
>
);
};
interface
VisualItemProps
{
attachment
:
Attachment
;
attachment
:
Attachment
;
onImageClick
:
(
url
:
string
)
=>
void
;
onImageClick
:
(
url
:
string
)
=>
void
;
}
}
const
MediaItem
=
({
attachment
,
onImageClick
}:
MediaItemProps
)
=>
{
const
VisualItem
=
({
attachment
,
onImageClick
}:
VisualItemProps
)
=>
{
const
isImage
=
isImageAttachment
(
attachment
);
const
handleClick
=
()
=>
{
const
handleClick
=
()
=>
{
if
(
isImage
)
{
if
(
isImage
Attachment
(
attachment
)
)
{
onImageClick
(
getAttachmentUrl
(
attachment
));
onImageClick
(
getAttachmentUrl
(
attachment
));
}
}
};
};
...
@@ -84,15 +99,18 @@ const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => {
...
@@ -84,15 +99,18 @@ const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => {
);
);
};
};
interface
MediaGridProps
{
const
VisualGrid
=
({
attachments
,
onImageClick
}:
{
attachments
:
Attachment
[];
onImageClick
:
(
url
:
string
)
=>
void
})
=>
(
attachments
:
Attachment
[];
onImageClick
:
(
url
:
string
)
=>
void
;
}
const
MediaGrid
=
({
attachments
,
onImageClick
}:
MediaGridProps
)
=>
(
<
div
className=
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"
>
<
div
className=
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"
>
{
attachments
.
map
((
attachment
)
=>
(
{
attachments
.
map
((
attachment
)
=>
(
<
MediaItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
onImageClick=
{
onImageClick
}
/>
<
VisualItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
onImageClick=
{
onImageClick
}
/>
))
}
</
div
>
);
const
AudioList
=
({
attachments
}:
{
attachments
:
Attachment
[]
})
=>
(
<
div
className=
"flex flex-col gap-1"
>
{
attachments
.
map
((
attachment
)
=>
(
<
AudioItem
key=
{
attachment
.
name
}
attachment=
{
attachment
}
/>
))
}
))
}
</
div
>
</
div
>
);
);
...
@@ -107,6 +125,8 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
...
@@ -107,6 +125,8 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
</
div
>
</
div
>
);
);
const
Divider
=
()
=>
<
div
className=
"border-t mt-1 border-border opacity-60"
/>;
const
AttachmentList
=
({
attachments
}:
AttachmentListProps
)
=>
{
const
AttachmentList
=
({
attachments
}:
AttachmentListProps
)
=>
{
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
;
mimeType
?:
string
}
>
({
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
;
mimeType
?:
string
}
>
({
open
:
false
,
open
:
false
,
...
@@ -115,10 +135,9 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
...
@@ -115,10 +135,9 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
mimeType
:
undefined
,
mimeType
:
undefined
,
});
});
const
{
media
:
mediaItems
,
docs
:
docItems
}
=
useMemo
(()
=>
separateMediaAndDoc
s
(
attachments
),
[
attachments
]);
const
{
visual
,
audio
,
docs
}
=
useMemo
(()
=>
separateAttachment
s
(
attachments
),
[
attachments
]);
// Pre-compute image URLs for preview dialog to avoid filtering on every click
const
imageAttachments
=
useMemo
(()
=>
visual
.
filter
(
isImageAttachment
),
[
visual
]);
const
imageAttachments
=
useMemo
(()
=>
mediaItems
.
filter
(
isImageAttachment
),
[
mediaItems
]);
const
imageUrls
=
useMemo
(()
=>
imageAttachments
.
map
(
getAttachmentUrl
),
[
imageAttachments
]);
const
imageUrls
=
useMemo
(()
=>
imageAttachments
.
map
(
getAttachmentUrl
),
[
imageAttachments
]);
if
(
attachments
.
length
===
0
)
{
if
(
attachments
.
length
===
0
)
{
...
@@ -131,17 +150,24 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
...
@@ -131,17 +150,24 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
setPreviewImage
({
open
:
true
,
urls
:
imageUrls
,
index
,
mimeType
});
setPreviewImage
({
open
:
true
,
urls
:
imageUrls
,
index
,
mimeType
});
};
};
const
sections
=
[
visual
.
length
>
0
,
audio
.
length
>
0
,
docs
.
length
>
0
];
const
sectionCount
=
sections
.
filter
(
Boolean
).
length
;
return
(
return
(
<>
<>
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
div
className=
"w-full rounded-lg border border-border bg-muted/20 overflow-hidden"
>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
<
SectionHeader
icon=
{
PaperclipIcon
}
title=
"Attachments"
count=
{
attachments
.
length
}
/>
<
div
className=
"p-1.5 flex flex-col gap-1"
>
<
div
className=
"p-1.5 flex flex-col gap-1"
>
{
mediaItems
.
length
>
0
&&
<
MediaGrid
attachments=
{
mediaItems
}
onImageClick=
{
handleImageClick
}
/>
}
{
visual
.
length
>
0
&&
<
VisualGrid
attachments=
{
visual
}
onImageClick=
{
handleImageClick
}
/>
}
{
visual
.
length
>
0
&&
sectionCount
>
1
&&
<
Divider
/>
}
{
audio
.
length
>
0
&&
<
AudioList
attachments=
{
audio
}
/>
}
{
mediaItems
.
length
>
0
&&
docItems
.
length
>
0
&&
<
div
className=
"border-t mt-1 border-border opacity-60"
/>
}
{
audio
.
length
>
0
&&
docs
.
length
>
0
&&
<
Divider
/>
}
{
doc
Items
.
length
>
0
&&
<
DocsList
attachments=
{
docItem
s
}
/>
}
{
doc
s
.
length
>
0
&&
<
DocsList
attachments=
{
doc
s
}
/>
}
</
div
>
</
div
>
</
div
>
</
div
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment