Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/* Colors are manually synced with tailwind.preset.js dark theme tokens.
Update these if the theme palette changes. */

.announcement-modal-content .ProseMirror {
outline: none;
color: #e1e4ea;
font-size: 0.875rem;
line-height: 1.625;
}

.announcement-modal-content .ProseMirror h1 {
font-size: 1.5rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
}

.announcement-modal-content .ProseMirror h1:first-child {
margin-top: 0;
}

.announcement-modal-content .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.3;
}

.announcement-modal-content .ProseMirror h3 {
font-size: 1.1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.3;
}

.announcement-modal-content .ProseMirror p {
margin-bottom: 0.75rem;
}

.announcement-modal-content .ProseMirror p:last-child {
margin-bottom: 0;
}

.announcement-modal-content .ProseMirror ul,
.announcement-modal-content .ProseMirror ol {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}

.announcement-modal-content .ProseMirror ul {
list-style: disc;
}

.announcement-modal-content .ProseMirror ol {
list-style: decimal;
}

.announcement-modal-content .ProseMirror li {
margin-bottom: 0.25rem;
}

.announcement-modal-content .ProseMirror blockquote {
border-left: 3px solid #383d47;
padding-left: 1rem;
margin-left: 0;
margin-bottom: 0.75rem;
color: #8b8f99;
font-style: italic;
}

.announcement-modal-content .ProseMirror code {
background: #1e2127;
border: 1px solid #2c2f36;
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.8125rem;
}

.announcement-modal-content .ProseMirror pre {
background: #1e2127;
border: 1px solid #2c2f36;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
overflow-x: auto;
}

.announcement-modal-content .ProseMirror pre code {
background: none;
border: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.5;
}

.announcement-modal-content .ProseMirror a {
color: #667acc;
text-decoration: underline;
text-underline-offset: 2px;
}

.announcement-modal-content .ProseMirror a:hover {
opacity: 0.8;
}

.announcement-modal-content .ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 0.75rem 0;
}

.announcement-modal-content .ProseMirror hr {
border: none;
border-top: 1px solid #2c2f36;
margin: 1.25rem 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useId } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { MegaphoneIcon, XMarkIcon } from "@heroicons/react/24/outline";
import BaseDialog from "@/components/common/BaseDialog";
import { formatDateShort } from "@/utils/date";
import { isAllowedUrl } from "@/utils/url";
import type { Announcement } from "@/client";
import "./AnnouncementModal.css";

interface AnnouncementContentProps {
content: string;
}

function AnnouncementContent({ content }: AnnouncementContentProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: true,
validate: (url) => isAllowedUrl(url),
HTMLAttributes: {
rel: "noopener noreferrer",
target: "_blank",
},
}),
Image.configure({ allowBase64: false }),
Markdown,
],
content,
contentType: "markdown",
editable: false,
});

if (!editor) return <div className="min-h-[80px]" />;

return (
<div className="announcement-modal-content">
<EditorContent editor={editor} />
</div>
);
}

interface AnnouncementModalProps {
open: boolean;
onClose: () => void;
announcement: Announcement;
}

export default function AnnouncementModal({
open,
onClose,
announcement,
}: AnnouncementModalProps) {
const titleId = useId();

return (
<BaseDialog open={open} onClose={onClose} size="md" aria-labelledby={titleId}>
<div className="flex items-start justify-between gap-4 p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center shrink-0">
<MegaphoneIcon className="w-5 h-5 text-primary" strokeWidth={1.5} />
</div>
<div>
<h2
id={titleId}
className="text-base font-semibold text-text-primary leading-snug"
>
{announcement.title}
</h2>
<p className="text-xs text-text-muted font-mono mt-0.5">
{formatDateShort(announcement.date)}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-border/40 transition-colors shrink-0 -mt-0.5 -mr-1"
aria-label="Close announcement"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>

<div className="p-6 overflow-y-auto max-h-[60vh]">
<AnnouncementContent key={announcement.uuid} content={announcement.content} />
</div>

<div className="flex justify-end gap-2 p-5 border-t border-border">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Got it
</button>
</div>
</BaseDialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState } from "react";
import { getConfig } from "@/env";
import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement";
import AnnouncementModal from "./AnnouncementModal";
import type { Announcement } from "@/client";

const STORAGE_KEY = "announcement";

function computeHash(announcement: Announcement): string {
return btoa(JSON.stringify(announcement));
}
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime crash on non-ASCII content (high)

btoa() only accepts Latin-1 strings (U+0000–U+00FF). When an admin saves an announcement with emoji, accented characters, or CJK text, btoa(JSON.stringify(announcement)) throws InvalidCharacterError. This affects both call sites — the show check (line 34) and markSeen (line 37) — so the modal either crashes on render or cannot be dismissed.

Compatibility note: The Vue UI uses the identical btoa(JSON.stringify(...)) pattern with the same "announcement" localStorage key (AnnouncementsModal.vue:57, UserWarning.vue:112). Any fix here must either be applied to both UIs simultaneously, or use an approach that stays compatible.

The simplest compatible fix is the standard UTF-8 → Latin-1 encoding trick:

Suggested change
function computeHash(announcement: Announcement): string {
return btoa(JSON.stringify(announcement));
}
function computeHash(announcement: Announcement): string {
const json = JSON.stringify(announcement);
return btoa(
Array.from(new TextEncoder().encode(json), (b) =>
String.fromCharCode(b),
).join(""),
);
}

This produces the same output as btoa(JSON.stringify(...)) for Latin-1 content (preserving backward compatibility with existing localStorage entries) while correctly handling non-ASCII characters. The Vue UI would need the equivalent change.

Fix this →


function getStoredHash(): string {
return localStorage.getItem(STORAGE_KEY) ?? "";
}

function markSeen(announcement: Announcement): void {
localStorage.setItem(STORAGE_KEY, computeHash(announcement));
}

export default function AnnouncementModalTrigger() {
if (!getConfig().announcements) return null;

return <AnnouncementModalInner />;
}

function AnnouncementModalInner() {
const { announcement } = useLatestAnnouncement();
const [dismissed, setDismissed] = useState(false);

const show =
!!announcement &&
!dismissed &&
computeHash(announcement) !== getStoredHash();

const handleClose = () => {
if (announcement) markSeen(announcement);
setDismissed(true);
};

if (!show || !announcement) return null;

return (
<AnnouncementModal open={show} onClose={handleClose} announcement={announcement} />
);
}
Loading
Loading