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
34c90dd5
Commit
34c90dd5
authored
Apr 25, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: remove duplicate tags from share image preview
parent
0fb83a74
Changes
4
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
220 additions
and
47 deletions
+220
-47
MemoShareImagePreview.tsx
web/src/components/MemoActionMenu/MemoShareImagePreview.tsx
+27
-46
memoShareImage.ts
web/src/components/MemoActionMenu/memoShareImage.ts
+1
-1
memoShareImagePreviewModel.ts
...c/components/MemoActionMenu/memoShareImagePreviewModel.ts
+58
-0
memo-share-image.test.ts
web/tests/memo-share-image.test.ts
+134
-0
No files found.
web/src/components/MemoActionMenu/MemoShareImagePreview.tsx
View file @
34c90dd5
import
{
timestampDate
}
from
"@bufbuild/protobuf/wkt"
;
import
{
forwardRef
,
useMemo
}
from
"react"
;
import
{
forwardRef
,
useMemo
}
from
"react"
;
import
MemoContent
from
"@/components/MemoContent"
;
import
MemoContent
from
"@/components/MemoContent"
;
import
{
separateAttachments
}
from
"@/components/MemoMetadata/Attachment/attachmentHelpers"
;
import
UserAvatar
from
"@/components/UserAvatar"
;
import
UserAvatar
from
"@/components/UserAvatar"
;
import
i18n
from
"@/i18n"
;
import
i18n
from
"@/i18n"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
buildAttachmentVisualItems
,
countLogicalAttachmentItems
}
from
"@/utils/media-item"
;
import
{
useMemoViewContext
}
from
"../MemoView/MemoViewContext"
;
import
{
useMemoViewContext
}
from
"../MemoView/MemoViewContext"
;
import
{
getMemoSharePreviewAvatarUrl
}
from
"./memoShareImage
"
;
import
{
buildMemoShareImagePreviewModel
}
from
"./memoShareImagePreviewModel
"
;
const
MemoShareImagePreview
=
forwardRef
<
HTMLDivElement
,
{
width
:
number
}
>
(({
width
},
ref
)
=>
{
const
MemoShareImagePreview
=
forwardRef
<
HTMLDivElement
,
{
width
:
number
}
>
(({
width
},
ref
)
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
{
memo
,
creator
,
blurred
,
showBlurredContent
}
=
useMemoViewContext
();
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
preview
=
useMemo
(
const
avatarUrl
=
getMemoSharePreviewAvatarUrl
(
creator
?.
avatarUrl
);
()
=>
const
displayTime
=
memo
.
displayTime
?
timestampDate
(
memo
.
displayTime
)
:
memo
.
createTime
?
timestampDate
(
memo
.
createTime
)
:
undefined
;
buildMemoShareImagePreviewModel
({
const
formattedDisplayTime
=
displayTime
?.
toLocaleString
(
i18n
.
language
,
{
memo
,
dateStyle
:
"medium"
,
creator
,
timeStyle
:
"short"
,
fallbackDisplayName
,
});
locale
,
const
{
attachmentCount
,
nonVisualAttachmentCount
,
visualItems
}
=
useMemo
(()
=>
{
}),
const
attachmentGroups
=
separateAttachments
(
memo
.
attachments
);
[
creator
,
fallbackDisplayName
,
locale
,
memo
],
const
previewVisualItems
=
buildAttachmentVisualItems
(
attachmentGroups
.
visual
);
);
const
totalAttachmentCount
=
countLogicalAttachmentItems
(
memo
.
attachments
);
return
{
attachmentCount
:
totalAttachmentCount
,
nonVisualAttachmentCount
:
totalAttachmentCount
-
previewVisualItems
.
length
,
visualItems
:
previewVisualItems
,
};
},
[
memo
.
attachments
]);
return
(
return
(
<
div
ref=
{
ref
}
className=
"overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5"
style=
{
{
width
}
}
>
<
div
ref=
{
ref
}
className=
"overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5"
style=
{
{
width
}
}
>
<
div
className=
"overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5"
>
<
div
className=
"overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"flex min-w-0 items-center gap-2.5"
>
<
div
className=
"flex min-w-0 items-center gap-2.5"
>
<
UserAvatar
avatarUrl=
{
avatarUrl
}
className=
"h-8 w-8 rounded-xl"
/>
<
UserAvatar
avatarUrl=
{
preview
.
avatarUrl
}
className=
"h-8 w-8 rounded-xl"
/>
<
div
className=
"min-w-0"
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-[13px] font-semibold text-foreground"
>
{
displayName
}
</
div
>
<
div
className=
"truncate text-[13px] font-semibold text-foreground"
>
{
preview
.
displayName
}
</
div
>
{
formattedDisplayTime
&&
<
div
className=
"truncate text-xs text-muted-foreground"
>
{
formattedDisplayTime
}
</
div
>
}
{
preview
.
formattedDisplayTime
&&
<
div
className=
"truncate text-xs text-muted-foreground"
>
{
preview
.
formattedDisplayTime
}
</
div
>
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -52,21 +43,21 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
...
@@ -52,21 +43,21 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
</
div
>
</
div
>
</
div
>
</
div
>
{
visualItems
.
length
>
0
&&
(
{
preview
.
visualItems
.
length
>
0
&&
(
<
div
className=
{
cn
(
"mt-4 grid gap-1.5"
,
visualItems
.
length
===
1
?
"grid-cols-1"
:
"grid-cols-2"
)
}
>
<
div
className=
{
cn
(
"mt-4 grid gap-1.5"
,
preview
.
visualItems
.
length
===
1
?
"grid-cols-1"
:
"grid-cols-2"
)
}
>
{
visualItems
.
slice
(
0
,
4
).
map
((
item
,
index
)
=>
(
{
preview
.
visualItems
.
slice
(
0
,
4
).
map
((
item
,
index
)
=>
(
<
div
<
div
key=
{
item
.
id
}
key=
{
item
.
id
}
className=
{
cn
(
className=
{
cn
(
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30"
,
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30"
,
visualItems
.
length
===
1
?
"aspect-[4/3]"
:
"aspect-square"
,
preview
.
visualItems
.
length
===
1
?
"aspect-[4/3]"
:
"aspect-square"
,
visualItems
.
length
===
3
&&
index
===
0
&&
"col-span-2 aspect-[2.2/1]"
,
preview
.
visualItems
.
length
===
3
&&
index
===
0
&&
"col-span-2 aspect-[2.2/1]"
,
)
}
)
}
>
>
<
img
src=
{
item
.
posterUrl
}
alt=
{
item
.
filename
}
className=
"h-full w-full object-cover"
loading=
"eager"
decoding=
"async"
/>
<
img
src=
{
item
.
posterUrl
}
alt=
{
item
.
filename
}
className=
"h-full w-full object-cover"
loading=
"eager"
decoding=
"async"
/>
{
index
===
3
&&
visualItems
.
length
>
4
&&
(
{
index
===
3
&&
preview
.
visualItems
.
length
>
4
&&
(
<
div
className=
"absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background"
>
<
div
className=
"absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background"
>
+
{
visualItems
.
length
-
4
}
+
{
preview
.
visualItems
.
length
-
4
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -74,26 +65,16 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
...
@@ -74,26 +65,16 @@ const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ w
</
div
>
</
div
>
)
}
)
}
{
(
memo
.
tags
.
length
>
0
||
nonVisualAttachmentCount
>
0
)
&&
(
{
preview
.
footerBadges
.
length
>
0
&&
(
<
div
className=
"mt-4 flex flex-wrap items-center gap-1.5"
>
<
div
className=
"mt-4 flex flex-wrap items-center gap-1.5"
>
{
memo
.
tags
.
slice
(
0
,
3
).
map
((
tag
)
=>
(
{
preview
.
footerBadges
.
map
((
badge
)
=>
(
<
span
<
span
key=
{
tag
}
key=
{
badge
.
type
}
className=
"inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
className=
"inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
>
>
#
{
tag
}
{
badge
.
count
}
{
t
(
"common.attachments"
).
toLowerCase
()
}
</
span
>
</
span
>
))
}
))
}
{
memo
.
tags
.
length
>
3
&&
(
<
span
className=
"inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
>
+
{
memo
.
tags
.
length
-
3
}
</
span
>
)
}
{
nonVisualAttachmentCount
>
0
&&
(
<
span
className=
"inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
>
{
attachmentCount
}
{
t
(
"common.attachments"
).
toLowerCase
()
}
</
span
>
)
}
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
...
web/src/components/MemoActionMenu/memoShareImage.ts
View file @
34c90dd5
...
@@ -55,7 +55,7 @@ const waitForPreviewAssets = async (node: HTMLElement) => {
...
@@ -55,7 +55,7 @@ const waitForPreviewAssets = async (node: HTMLElement) => {
};
};
export
const
buildMemoShareImageFileName
=
(
memoName
:
string
)
=>
{
export
const
buildMemoShareImageFileName
=
(
memoName
:
string
)
=>
{
const
suffix
=
memoName
.
split
(
"/"
).
pop
()
??
"memo"
;
const
suffix
=
memoName
.
split
(
"/"
).
pop
()
||
"memo"
;
return
`memo-
${
suffix
}
.png`
;
return
`memo-
${
suffix
}
.png`
;
};
};
...
...
web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts
0 → 100644
View file @
34c90dd5
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
,
};
};
web/tests/memo-share-image.test.ts
0 → 100644
View file @
34c90dd5
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
<
Memo
>
=
{})
=>
create
(
MemoSchema
,
{
name
:
"memos/test"
,
content
:
"hello"
,
tags
:
[],
attachments
:
[],
...
overrides
,
});
const
buildAttachment
=
(
overrides
:
Partial
<
Attachment
>
)
=>
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
();
});
});
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