+
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..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"
/>
)}
-
+
+
{text ? (
-
+
{autolink(text)}
) : null}
diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx
index 9bcae66..62d9db6 100644
--- a/src/components/messageStream/overlays/ImageLightbox.tsx
+++ b/src/components/messageStream/overlays/ImageLightbox.tsx
@@ -12,6 +12,7 @@ import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import { postNoBody } from "../../../api";
import type { Attachment } from "../../../types/post";
import { autolink } from "../utils/autolink";
+import { downloadFile } from "../utils/downloadFile";
type LightboxState = {
images: Attachment[];
@@ -120,7 +121,7 @@ function LightboxView({
return createPortal(
-
{
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 ? (
<>
@@ -180,42 +179,52 @@ function LightboxView({
>
-
+
{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"
+ }`}
>
-

- {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;
+ }}
+ >
+

+
+
+ {caption ? (
+
e.stopPropagation()}
+ >
+
+ {autolink(caption)}
+
+
+ ) : null}
,
document.body,
);
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/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 "";
+}
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);
+}
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index ddf3d1d..d5247de 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]"
/>
-
-
-
-
+