mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Improve message markdown display and formatting (#7)
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
894
desktop/pnpm-lock.yaml
generated
894
desktop/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,14 @@ import {
|
||||
useChannelsQuery,
|
||||
useSelectedChannel,
|
||||
} from "@/features/channels/hooks";
|
||||
import { MessageComposer } from "@/features/chat/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/chat/ui/MessageTimeline";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
useSendMessageMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export type Message = {
|
||||
author: string;
|
||||
role: string;
|
||||
time: string;
|
||||
body: string;
|
||||
accent?: boolean;
|
||||
};
|
||||
|
||||
export const currentChannel = {
|
||||
name: "product-launch",
|
||||
description: "Static channel layout with a simple chat pane.",
|
||||
};
|
||||
|
||||
export const messages: Message[] = [
|
||||
{
|
||||
author: "Maya",
|
||||
role: "Product",
|
||||
time: "9:12 AM",
|
||||
body: "Let’s keep this first pass focused on layout: sidebar, message area, and composer.",
|
||||
},
|
||||
{
|
||||
author: "Elena",
|
||||
role: "Engineering",
|
||||
time: "9:24 AM",
|
||||
body: "Agreed. The content can stay minimal until we wire up real channels and messages.",
|
||||
accent: true,
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
@@ -31,7 +32,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="font-semibold tracking-tight">{message.author}</h3>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
@@ -44,9 +45,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="max-w-3xl break-words text-sm leading-7 text-foreground/90">
|
||||
{message.body}
|
||||
</p>
|
||||
<Markdown className="max-w-3xl" content={message.body} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
150
desktop/src/shared/ui/markdown.tsx
Normal file
150
desktop/src/shared/ui/markdown.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type * as React from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type MarkdownProps = {
|
||||
className?: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const markdownComponents: Components = {
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
br: () => <br />,
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"code"> & { inline?: boolean }) => {
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
const isBlock =
|
||||
typeof className === "string" && className.includes("language-")
|
||||
? true
|
||||
: code.includes("\n");
|
||||
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className={cn(
|
||||
"block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
{...props}
|
||||
className={cn(
|
||||
"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-lg font-semibold tracking-tight">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-base font-semibold tracking-tight">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="font-semibold tracking-tight">{children}</h3>
|
||||
),
|
||||
hr: () => <hr className="border-border/80" />,
|
||||
img: ({ alt, src }) => (
|
||||
<img
|
||||
alt={alt}
|
||||
className="max-h-96 rounded-2xl border border-border/70 object-cover"
|
||||
src={src}
|
||||
/>
|
||||
),
|
||||
li: ({ children }) => <li className="my-1 [&_p]:inline">{children}</li>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal space-y-1 pl-6 marker:text-muted-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
p: ({ children }) => <p className="leading-7">{children}</p>,
|
||||
pre: ({ children }) => (
|
||||
<pre className="overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 px-4 py-3 shadow-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold">{children}</strong>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/70">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border-t border-border/70 px-3 py-2 align-top">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="bg-muted/60 px-3 py-2 font-semibold text-foreground">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc space-y-1 pl-6 marker:text-muted-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
};
|
||||
|
||||
export function Markdown({ className, content }: MarkdownProps) {
|
||||
let processedContent = content;
|
||||
|
||||
if (/^(?:\s{2}\n)+/.test(content)) {
|
||||
processedContent = `\u200B${processedContent}`;
|
||||
}
|
||||
|
||||
if (/(?:\s{2}\n)+$/.test(content)) {
|
||||
processedContent = `${processedContent}\u200B`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user