From e0629c9df79ca4b26a0005168445354880f4d9fd Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 26 May 2026 18:03:07 +0800 Subject: [PATCH 1/4] fix: allow copying message stream text --- .../messageStream/bubbles/AlbumBubble.tsx | 4 ++-- .../messageStream/bubbles/FileDocBubble.tsx | 2 +- .../bubbles/ImageWithTextBubble.tsx | 2 +- .../messageStream/bubbles/TextBubble.tsx | 2 +- .../messageStream/bubbles/VideoBubble.tsx | 2 +- .../messageStream/overlays/ImageLightbox.tsx | 2 +- src/components/messageStream/utils/autolink.tsx | 2 +- src/index.css | 16 ++++++++++++++++ 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index c747ea0..0040cc5 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -38,7 +38,7 @@ export function AlbumBubble({ post }: { post: Post }) { ))} {text ? ( -
+
{autolink(text)}
) : null} @@ -80,7 +80,7 @@ export function AlbumBubble({ post }: { post: Post }) { })}
{text ? ( -
+
{autolink(text)}
) : null} diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 8bfee8c..0988555 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -63,7 +63,7 @@ export function FileDocBubble({ post }: { post: Post }) { ))} {text ? ( -
+
{text}
) : null} diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index 62e45d2..af4669d 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -31,7 +31,7 @@ export function ImageWithTextBubble({ post }: { post: Post }) { {text ? (
-
+
{autolink(text)}
diff --git a/src/components/messageStream/bubbles/TextBubble.tsx b/src/components/messageStream/bubbles/TextBubble.tsx index 22da71f..bfb8523 100644 --- a/src/components/messageStream/bubbles/TextBubble.tsx +++ b/src/components/messageStream/bubbles/TextBubble.tsx @@ -6,7 +6,7 @@ import { postDisplayText } from "../utils/postText"; export function TextBubble({ post }: { post: Post }) { const { lang } = useI18n(); return ( -
+
{autolink(postDisplayText(post, lang))}
); diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 5b2fcb5..4720ba8 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -112,7 +112,7 @@ export function VideoBubble({ post }: { post: Post }) { )}
{text ? ( -
+
{autolink(text)}
) : null} diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 9bcae66..92cb6a8 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -210,7 +210,7 @@ function LightboxView({ /> {caption ? (
-
+
{autolink(caption)}
diff --git a/src/components/messageStream/utils/autolink.tsx b/src/components/messageStream/utils/autolink.tsx index ba25da8..4ebebe0 100644 --- a/src/components/messageStream/utils/autolink.tsx +++ b/src/components/messageStream/utils/autolink.tsx @@ -24,7 +24,7 @@ export function autolink(text: string): ReactNode[] { href={url} target="_blank" rel="noopener noreferrer" - className="text-ark-gold underline underline-offset-2 break-all hover:text-ark-gold2" + className="message-stream-copyable-text select-text break-all text-ark-gold underline underline-offset-2 hover:text-ark-gold2" > {url} , diff --git a/src/index.css b/src/index.css index 37cbb7b..5b4e14d 100644 --- a/src/index.css +++ b/src/index.css @@ -41,3 +41,19 @@ header button { .gold-underline { box-shadow: inset 0 -2px 0 #eeb726; } + +.message-stream-copyable-text, +.message-stream-copyable-text * { + -webkit-user-select: text; + user-select: text; +} + +.message-stream-copyable-text { + -webkit-touch-callout: default; + cursor: text; +} + +.message-stream-copyable-text::selection, +.message-stream-copyable-text *::selection { + background: rgba(238, 183, 38, 0.35); +} From 532f0112fdcd94fe8434e0e78261ad0223b16914 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 26 May 2026 18:04:36 +0800 Subject: [PATCH 2/4] feat: polish message attachment downloads --- src/components/RecommendedCard.tsx | 3 +- .../messageStream/bubbles/FileDocBubble.tsx | 60 ++++++++------ .../bubbles/ImageWithTextBubble.tsx | 2 +- .../messageStream/bubbles/VideoBubble.tsx | 48 +++++------ .../messageStream/overlays/ImageLightbox.tsx | 13 ++- .../messageStream/utils/downloadFile.ts | 78 ++++++++++++++++++ .../utils/filenameDisplay.test.ts | 47 +++++++++++ .../messageStream/utils/filenameDisplay.ts | 82 +++++++++++++++++++ 8 files changed, 274 insertions(+), 59 deletions(-) create mode 100644 src/components/messageStream/utils/downloadFile.ts create mode 100644 src/components/messageStream/utils/filenameDisplay.test.ts create mode 100644 src/components/messageStream/utils/filenameDisplay.ts diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index d90d2a2..707c9e2 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -6,6 +6,7 @@ import { useI18n } from "../i18n"; import { useMemo } from "react"; import { formatDateYmd } from "../utils/format"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; +import { downloadFile } from "./messageStream/utils/downloadFile"; function isPlaceholderAsset(path: string | undefined | null) { return !path || path.includes("placeholder-cover"); @@ -98,7 +99,7 @@ export function RecommendedCard({ } catch { /* ignore */ } - window.open(dl, "_blank", "noopener,noreferrer"); + void downloadFile(dl, r.title).catch(() => {}); }} > diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 0988555..6df5051 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -1,38 +1,40 @@ -import { Download } from "lucide-react"; +import { ArrowDownToLine } from "lucide-react"; import { postNoBody } from "../../../api"; import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; +import { downloadFile } from "../utils/downloadFile"; import { fileIcon } from "../utils/fileIcon"; +import { + filenameWithExtension, + middleEllipsisFilename, +} from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { const isImageAsDoc = att.mime.startsWith("image/"); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); + const displayFilename = filenameWithExtension(att.filename, att.mime); + + const handleDownload = () => { + void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`); + void downloadFile(att.url, displayFilename).catch(() => {}); + }; return ( - { - void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`); - }} - > -
+
+
+
+ +
+
); } diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index af4669d..f072cff 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -30,7 +30,7 @@ export function ImageWithTextBubble({ post }: { post: Post }) { /> {text ? ( -
+
{autolink(text)}
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 4720ba8..c94ee14 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,10 +1,11 @@ -import { Download, Play } from "lucide-react"; +import { ArrowDownToLine, Play } from "lucide-react"; import { useRef, useState } from "react"; import { postNoBody } from "../../../api"; import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; +import { downloadFile } from "../utils/downloadFile"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; @@ -26,6 +27,7 @@ export function VideoBubble({ post }: { post: Post }) { const ratio = att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9"; const posterUrl = att.posterUrl ?? att.thumbnailUrl; + const duration = formatDuration(att.durationSec); const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`; return ( @@ -68,33 +70,31 @@ export function VideoBubble({ post }: { post: Post }) { aria-hidden="true" /> )} -
- { - e.stopPropagation(); - void postNoBody( - `/api/posts/${post.id}/attachments/${att.id}/download`, - ); - }} - className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75" - aria-label={`Download ${att.filename}`} - > - - -
- {formatDuration(att.durationSec) ? ( +
-
+ + - { e.stopPropagation(); if (postId) { @@ -149,12 +147,13 @@ function LightboxView({ `/api/posts/${postId}/attachments/${current.id}/download`, ); } + void downloadFile(current.url, current.filename).catch(() => {}); }} className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20" aria-label="Download" > - + {images.length > 1 ? ( <> @@ -210,7 +209,7 @@ function LightboxView({ /> {caption ? (
-
+
{autolink(caption)}
diff --git a/src/components/messageStream/utils/downloadFile.ts b/src/components/messageStream/utils/downloadFile.ts new file mode 100644 index 0000000..5ac05cf --- /dev/null +++ b/src/components/messageStream/utils/downloadFile.ts @@ -0,0 +1,78 @@ +type SaveFilePicker = (options?: { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; +}) => Promise<{ + createWritable: () => Promise<{ + write: (data: Blob) => Promise; + close: () => Promise; + }>; +}>; + +type NavigatorWithFileShare = Navigator & { + canShare?: (data: { files?: File[] }) => boolean; + share?: (data: { files?: File[]; title?: string }) => Promise; +}; + +type WindowWithSavePicker = Window & { + showSaveFilePicker?: SaveFilePicker; +}; + +export async function downloadFile(url: string, filename: string) { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) throw new Error(await res.text()); + + const blob = await res.blob(); + const safeName = filename || "download"; + + if (window.isSecureContext) { + const picker = (window as WindowWithSavePicker).showSaveFilePicker; + if (picker) { + const handle = await picker({ + suggestedName: safeName, + types: blob.type + ? [ + { + description: "File", + accept: { [blob.type]: [extensionFromName(safeName)] }, + }, + ] + : undefined, + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return; + } + } + + const file = new File([blob], safeName, { + type: blob.type || "application/octet-stream", + }); + const nav = navigator as NavigatorWithFileShare; + if (nav.canShare?.({ files: [file] }) && nav.share) { + await nav.share({ files: [file], title: safeName }); + return; + } + + const objectUrl = URL.createObjectURL(blob); + triggerDownload(objectUrl, safeName); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} + +function triggerDownload(url: string, filename: string) { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.style.display = "none"; + document.body.append(a); + a.click(); + a.remove(); +} + +function extensionFromName(filename: string) { + const match = /\.[^.]+$/.exec(filename); + return match?.[0] || ".bin"; +} diff --git a/src/components/messageStream/utils/filenameDisplay.test.ts b/src/components/messageStream/utils/filenameDisplay.test.ts new file mode 100644 index 0000000..3a0ef5b --- /dev/null +++ b/src/components/messageStream/utils/filenameDisplay.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + filenameWithExtension, + middleEllipsisFilename, +} from "./filenameDisplay"; + +describe("filenameWithExtension", () => { + it("keeps filenames that already have an extension", () => { + expect(filenameWithExtension("sample.pdf", "image/png")).toBe("sample.pdf"); + }); + + it("adds common extensions from mime type", () => { + expect(filenameWithExtension("uuid-file", "image/png")).toBe( + "uuid-file.png", + ); + expect(filenameWithExtension("uuid-file", "audio/mpeg")).toBe( + "uuid-file.mp3", + ); + expect(filenameWithExtension("uuid-file", "application/pdf")).toBe( + "uuid-file.pdf", + ); + }); +}); + +describe("middleEllipsisFilename", () => { + it("keeps short filenames unchanged", () => { + expect(middleEllipsisFilename("sample.pdf")).toBe("sample.pdf"); + }); + + it("preserves the extension when truncating", () => { + expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.jpg", 22)).toBe( + "afbb9ebe-5af2…-9d7.jpg", + ); + expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.png", 22)).toBe( + "afbb9ebe-5af2…-9d7.png", + ); + expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7.mp3", 22)).toBe( + "afbb9ebe-5af2…-9d7.mp3", + ); + }); + + it("handles filenames without extension", () => { + expect(middleEllipsisFilename("afbb9ebe-5af2-425a-9779-9d7", 18)).toBe( + "afbb9ebe-5af2…-9d7", + ); + }); +}); diff --git a/src/components/messageStream/utils/filenameDisplay.ts b/src/components/messageStream/utils/filenameDisplay.ts new file mode 100644 index 0000000..e9da4ff --- /dev/null +++ b/src/components/messageStream/utils/filenameDisplay.ts @@ -0,0 +1,82 @@ +const MIME_EXTENSION: Record = { + "application/pdf": ".pdf", + "application/postscript": ".ai", + "application/illustrator": ".ai", + "application/zip": ".zip", + "application/x-zip-compressed": ".zip", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": + ".pptx", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + ".docx", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "video/mp4": ".mp4", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +}; + +export function filenameWithExtension(filename: string, mime = ""): string { + if (hasFileExtension(filename)) return filename; + + const ext = extensionFromMime(mime); + return ext ? `${filename}${ext}` : filename; +} + +export function middleEllipsisFilename( + filename: string, + maxLength = 24, +): string { + if (filename.length <= maxLength) return filename; + + const { base, ext } = splitFilename(filename); + const ellipsis = "…"; + const availableBaseLength = maxLength - ext.length - ellipsis.length; + + if (availableBaseLength < 3) { + return `${filename.slice(0, Math.max(1, maxLength - 1))}${ellipsis}`; + } + + const tailLength = Math.min( + 4, + Math.floor(availableBaseLength / 2), + base.length, + ); + const headLength = availableBaseLength - tailLength; + + if (headLength + tailLength >= base.length) return filename; + return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`; +} + +function splitFilename(filename: string): { base: string; ext: string } { + const dotIndex = filename.lastIndexOf("."); + if (!hasFileExtension(filename)) return { base: filename, ext: "" }; + return { + base: filename.slice(0, dotIndex), + ext: filename.slice(dotIndex), + }; +} + +function hasFileExtension(filename: string): boolean { + const dotIndex = filename.lastIndexOf("."); + return ( + dotIndex > 0 && + dotIndex < filename.length - 1 && + filename.length - dotIndex <= 12 + ); +} + +function extensionFromMime(mime: string): string { + const cleanMime = mime.toLowerCase().split(";")[0].trim(); + if (!cleanMime) return ""; + if (MIME_EXTENSION[cleanMime]) return MIME_EXTENSION[cleanMime]; + + if (cleanMime.startsWith("image/")) return `.${cleanMime.slice(6)}`; + if (cleanMime.startsWith("video/")) return `.${cleanMime.slice(6)}`; + if (cleanMime.startsWith("audio/")) return `.${cleanMime.slice(6)}`; + + return ""; +} From 54f71c6ab38b6aec7acdcab8dc4e8027a59194b7 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 26 May 2026 18:37:17 +0800 Subject: [PATCH 3/4] feat: refine language menu and lightbox caption --- .../messageStream/overlays/ImageLightbox.tsx | 68 ++++---- src/layouts/PublicLayout.tsx | 156 ++++++++++++++---- 2 files changed, 159 insertions(+), 65 deletions(-) diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index c3237af..62d9db6 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -121,7 +121,7 @@ function LightboxView({ return createPortal(
-
+
{index + 1} / {images.length}
) : null}
e.stopPropagation()} - onTouchStart={(e) => { - touchStartX.current = e.touches[0].clientX; - }} - onTouchEnd={(e) => { - if (touchStartX.current == null) return; - const dx = e.changedTouches[0].clientX - touchStartX.current; - if (Math.abs(dx) > 40) { - if (dx > 0) goPrev(); - else goNext(); - } - touchStartX.current = null; - }} + className={`flex min-h-0 w-full flex-1 items-center justify-center px-4 pt-16 ${ + caption ? "pb-3" : "pb-16" + }`} > - {current.filename} - {caption ? ( -
-
- {autolink(caption)} -
-
- ) : null} +
e.stopPropagation()} + onTouchStart={(e) => { + touchStartX.current = e.touches[0].clientX; + }} + onTouchEnd={(e) => { + if (touchStartX.current == null) return; + const dx = e.changedTouches[0].clientX - touchStartX.current; + if (Math.abs(dx) > 40) { + if (dx > 0) goPrev(); + else goNext(); + } + touchStartX.current = null; + }} + > + {current.filename} +
+ + {caption ? ( +
e.stopPropagation()} + > +
+ {autolink(caption)} +
+
+ ) : null}
, document.body, ); diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index ddf3d1d..6ba44eb 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -1,5 +1,12 @@ -import { Globe, Menu, Search as SearchIcon, X } from "lucide-react"; -import { useState } from "react"; +import { + Check, + ChevronDown, + Globe, + Menu, + Search as SearchIcon, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { ArkLogoMark } from "../components/ArkLogoMark"; import { useI18n, type Lang } from "../i18n"; @@ -55,6 +62,105 @@ function navClassName(active: boolean) { ].join(" "); } +type LanguageDropdownProps = { + lang: Lang; + setLang: (lang: Lang) => void; + ariaLabel: string; + className?: string; +}; + +function LanguageDropdown({ + lang, + setLang, + ariaLabel, + className = "", +}: LanguageDropdownProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const selected = LANG_OPTIONS.find((option) => option.code === lang); + + useEffect(() => { + if (!open) return; + + const closeOnOutside = (event: MouseEvent | TouchEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("touchstart", closeOnOutside); + window.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("touchstart", closeOnOutside); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + return ( +
+ + + {open ? ( +
+ {LANG_OPTIONS.map((option) => { + const active = option.code === lang; + return ( + + ); + })} +
+ ) : null} +
+ ); +} + export function PublicLayout() { const { t, lang, setLang } = useI18n(); const { pathname, search, hash } = useLocation(); @@ -154,25 +260,12 @@ export function PublicLayout() { className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]" />
-
- - -
+
-
- - -
+ Date: Tue, 26 May 2026 20:02:40 +0800 Subject: [PATCH 4/4] fix: replace mobile wallet nav item --- src/layouts/PublicLayout.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 6ba44eb..d5247de 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -410,10 +410,13 @@ export function PublicLayout() { } />