fix(channel): update channel components and article draft

This commit is contained in:
zhenyi 2026-05-31 13:11:44 +08:00
parent 82475e95d5
commit 29e6f6214d
18 changed files with 158 additions and 103 deletions

View File

@ -23,7 +23,7 @@ export default function ArticleCard({ article, onClick, className }: Props) {
return ( return (
<article <article
className={cn( className={cn(
"group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border/30 bg-card shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5", "group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border/30 bg-card shadow-sm transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-300 hover:shadow-lg hover:-translate-y-0.5",
article.is_pinned && "ring-1 ring-amber-500/20", article.is_pinned && "ring-1 ring-amber-500/20",
className, className,
)} )}
@ -53,7 +53,7 @@ export default function ArticleCard({ article, onClick, className }: Props) {
{/* Pinned badge */} {/* Pinned badge */}
{article.is_pinned && ( {article.is_pinned && (
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-amber-500/90 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm"> <span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-warning/90 px-2 py-0.5 text-[10px] font-medium text-warning-foreground backdrop-blur-sm">
<Pin className="size-2.5" /> <Pin className="size-2.5" />
Pinned Pinned
</span> </span>

View File

@ -43,7 +43,7 @@ export default function ArticleComposer({
); );
// Auto-save indicator // Auto-save indicator
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>(); const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!draft) return; if (!draft) return;
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);

View File

@ -162,7 +162,7 @@ export default function ArticleDetail({
<h2 className="truncate text-sm font-semibold">Article Detail</h2> <h2 className="truncate text-sm font-semibold">Article Detail</h2>
</div> </div>
{article.is_pinned && ( {article.is_pinned && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600/70"> <span className="inline-flex items-center gap-1 rounded-full bg-warning/10 px-2 py-0.5 text-[11px] font-medium text-warning/70">
<Pin className="size-3" /> <Pin className="size-3" />
Pinned Pinned
</span> </span>
@ -249,7 +249,7 @@ export default function ArticleDetail({
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<button <button
className={cn( className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200", "inline-flex cursor-pointer items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200",
liked liked
? "bg-rose-500/10 text-rose-500 ring-1 ring-rose-500/20" ? "bg-rose-500/10 text-rose-500 ring-1 ring-rose-500/20"
: "bg-muted/50 text-muted-foreground/60 hover:bg-rose-500/[0.04] hover:text-rose-500/70", : "bg-muted/50 text-muted-foreground/60 hover:bg-rose-500/[0.04] hover:text-rose-500/70",
@ -260,7 +260,7 @@ export default function ArticleDetail({
> >
<Heart <Heart
className={cn( className={cn(
"size-[18px] transition-all", "size-[18px] transition-[fill,color,transform]",
liked && "fill-rose-500", liked && "fill-rose-500",
)} )}
/> />

View File

@ -143,7 +143,7 @@ export default function ArticleFeed({ roomId, roomName, currentUserId, onCompose
const { onEvent } = useChannelSocket(); const { onEvent } = useChannelSocket();
useEffect(() => { useEffect(() => {
return onEvent((event) => { return onEvent((event) => {
if (!event.room || event.room.id !== roomId) return; if (!('room' in event) || !event.room || event.room.id !== roomId) return;
if (event.type === "article.liked" || event.type === "article.unliked") { if (event.type === "article.liked" || event.type === "article.unliked") {
const d = event.data as { article_id: string; like_count: number }; const d = event.data as { article_id: string; like_count: number };

View File

@ -56,7 +56,7 @@ export function ChannelHeader({
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
aria-label="Channel settings" aria-label="Channel settings"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground" className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings} onClick={onToggleSettings}
> >
<Settings className="size-[18px]" /> <Settings className="size-[18px]" />

View File

@ -57,9 +57,9 @@ function RoomLink({
return ( return (
<Link <Link
className={cn( className={cn(
"group flex h-8 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-all duration-150", "group flex h-9 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-[background-color,color,box-shadow] duration-150",
active active
? "bg-primary/[0.08] font-semibold text-foreground shadow-[inset_0_0_0_1px_rgba(var(--color-primary),0.08)]" ? "bg-primary/[0.08] font-semibold text-foreground ring-1 ring-primary/10"
: "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground", : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)} )}
to={`/${projectName}/channel/${room.id}`} to={`/${projectName}/channel/${room.id}`}
@ -203,7 +203,7 @@ export default function ChannelSidebar({
{onCreateChannel && ( {onCreateChannel && (
<button <button
aria-label="Create channel" aria-label="Create channel"
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground/40 transition-all duration-150 hover:bg-accent hover:text-foreground" className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground/40 transition-[background-color,color] duration-150 hover:bg-accent hover:text-foreground"
onClick={onCreateChannel} onClick={onCreateChannel}
type="button" type="button"
> >
@ -250,7 +250,7 @@ export default function ChannelSidebar({
</p> </p>
{onCreateChannel && ( {onCreateChannel && (
<button <button
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-primary transition-all duration-150 hover:bg-primary/[0.08]" className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-primary transition-[background-color,color] duration-150 hover:bg-primary/10"
onClick={onCreateChannel} onClick={onCreateChannel}
type="button" type="button"
> >

View File

@ -96,7 +96,8 @@ export default function MessageComposer({
</p> </p>
</div> </div>
<Button <Button
className="size-6 shrink-0 cursor-pointer text-muted-foreground/30 hover:text-foreground" aria-label="Cancel reply"
className="size-8 shrink-0 cursor-pointer text-muted-foreground/30 hover:text-foreground"
onClick={onCancelReply} onClick={onCancelReply}
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -108,7 +109,7 @@ export default function MessageComposer({
<div <div
className={cn( className={cn(
"relative rounded-2xl border bg-card/80 shadow-sm transition-all duration-200", "relative rounded-2xl border bg-card/80 shadow-sm transition-[background-color,border-color,box-shadow] duration-200",
"focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06] focus-within:bg-card", "focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06] focus-within:bg-card",
replyTarget ? "border-primary/[0.12]" : "border-border/40", replyTarget ? "border-primary/[0.12]" : "border-border/40",
)} )}
@ -133,8 +134,9 @@ export default function MessageComposer({
<div className="absolute right-2 bottom-2 flex items-center gap-1"> <div className="absolute right-2 bottom-2 flex items-center gap-1">
<FileUploadButton disabled={disabled || sending} /> <FileUploadButton disabled={disabled || sending} />
<Button <Button
aria-label="Send message"
className={cn( className={cn(
"size-8 rounded-lg transition-all duration-200", "size-9 rounded-lg transition-[opacity,transform,background-color,color] duration-200",
hasContent hasContent
? "scale-100 opacity-100" ? "scale-100 opacity-100"
: "scale-90 opacity-40", : "scale-90 opacity-40",

View File

@ -157,7 +157,8 @@ export default function FileUploadButton({
)} )}
</div> </div>
<Button <Button
className="size-6 shrink-0 cursor-pointer rounded-md text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground" aria-label="Remove file"
className="size-8 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
onClick={() => removeFile(file.id)} onClick={() => removeFile(file.id)}
size="icon" size="icon"
variant="ghost" variant="ghost"

View File

@ -1,6 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import RepoEmbedCard from "./repo-embed-card"; import RepoEmbedCard from "./repo-embed-card";
import { parseRepoLinks, type RepoLinkMatch } from "./repo-link-parser"; import XEmbedCard from "./x-embed-card";
import { parseRepoLinks } from "./repo-link-parser";
import { parseXLinks } from "./x-link-parser";
type Props = { type Props = {
content: string; content: string;
@ -9,60 +11,86 @@ type Props = {
/** /**
* Renders message content, detecting same-origin repo links and * Renders message content, detecting same-origin repo links and
* replacing them with RepoEmbedCards. * X/Twitter links, replacing them with embed cards.
*/ */
export default function MessageContent({ content, contentType }: Props) { export default function MessageContent({ content, contentType }: Props) {
const { textParts, embeds } = useMemo(() => {
const links = parseRepoLinks(content);
if (links.length === 0) return { textParts: [content], embeds: [] };
const embeds: RepoLinkMatch[] = [];
const textParts: string[] = [];
let lastIndex = 0;
for (const link of links) {
const idx = content.indexOf(link.url, lastIndex);
if (idx === -1) continue;
// Text before this link
if (idx > lastIndex) {
textParts.push(content.slice(lastIndex, idx));
}
embeds.push(link);
lastIndex = idx + link.url.length;
}
// Remaining text after last link
if (lastIndex < content.length) {
textParts.push(content.slice(lastIndex));
}
return { textParts, embeds };
}, [content]);
const isPlainText = contentType === "text" || !contentType; const isPlainText = contentType === "text" || !contentType;
return ( const elements = useMemo(() => {
<div> const repoLinks = parseRepoLinks(content);
{textParts.map((part, i) => { const xLinks = parseXLinks(content);
const trimmed = part.trim();
if (!trimmed && embeds.length > 0) return null; const allLinks = [
return ( ...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
].sort((a, b) => a.index - b.index);
if (allLinks.length === 0) {
return [
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key="only"
>
{content}
</p>,
];
}
const result: React.ReactNode[] = [];
let cursor = 0;
for (const link of allLinks) {
if (link.index > cursor) {
const text = content.slice(cursor, link.index);
if (text.trim()) {
result.push(
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key={`t-${cursor}`}
>
{text}
</p>,
);
}
}
if (link.kind === "repo") {
result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />);
} else {
result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />);
}
cursor = link.index + link.url.length;
}
if (cursor < content.length) {
const text = content.slice(cursor);
if (text.trim()) {
result.push(
<p <p
className={ className={
isPlainText isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85" ? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85" : "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
} }
key={`t-${i}`} key={`t-${cursor}`}
> >
{isPlainText ? part : part} {text}
</p> </p>,
); );
})} }
{embeds.map((link) => ( }
<RepoEmbedCard key={link.url} link={link} />
))} return result;
</div> }, [content, isPlainText]);
);
return <div>{elements}</div>;
} }

View File

@ -184,8 +184,8 @@ export default function MessageItem({
className={cn( className={cn(
"group relative flex gap-3 transition-colors duration-150", "group relative flex gap-3 transition-colors duration-150",
isCompact isCompact
? "items-start px-4 py-0 hover:bg-black/[0.025]" ? "items-start px-4 py-0 hover:bg-muted/30"
: "items-start px-4 py-1 hover:bg-black/[0.03]", : "items-start px-4 py-1 hover:bg-muted/40",
)} )}
> >
{isCompact ? ( {isCompact ? (
@ -228,7 +228,7 @@ export default function MessageItem({
</span> </span>
)} )}
{isPinned && ( {isPinned && (
<span className="text-amber-500/60" title="Pinned"> <span className="text-warning/60" title="Pinned">
<Pin className="size-3" /> <Pin className="size-3" />
</span> </span>
)} )}
@ -237,16 +237,18 @@ export default function MessageItem({
{/* Reply context indicator */} {/* Reply context indicator */}
{message.in_reply_to && ( {message.in_reply_to && (
<div <button
className={cn( className={cn(
"mb-1 flex items-center gap-1.5 rounded-md border border-border/20 bg-muted/[0.08] px-2.5 py-1 text-left", "mb-1 flex w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/20 px-2.5 py-1 text-left",
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.15] hover:border-border/30", repliedMessage && "cursor-pointer transition-colors hover:border-border/50 hover:bg-muted/40",
!repliedMessage && "cursor-default",
)} )}
disabled={!repliedMessage}
onClick={() => { onClick={() => {
if (repliedMessage) onReply?.(repliedMessage); if (repliedMessage) onReply?.(repliedMessage);
}} }}
title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"} title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"}
role={repliedMessage ? "button" : undefined} type="button"
> >
<CornerDownRight className="size-3 shrink-0 text-primary/50" /> <CornerDownRight className="size-3 shrink-0 text-primary/50" />
{replyAuthor ? ( {replyAuthor ? (
@ -263,7 +265,7 @@ export default function MessageItem({
{replyPreview} {replyPreview}
</span> </span>
)} )}
</div> </button>
)} )}
{editing ? ( {editing ? (
@ -290,7 +292,7 @@ export default function MessageItem({
{threadForMessage && !message.thread && ( {threadForMessage && !message.thread && (
<button <button
className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/20 bg-muted/[0.05] px-2.5 py-1 text-[12px] font-medium text-primary/60 transition-all duration-150 hover:bg-primary/[0.05] hover:text-primary hover:border-primary/15 hover:shadow-sm" className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/30 bg-muted/20 px-2.5 py-1 text-[12px] font-medium text-primary/70 transition-[background-color,border-color,color,box-shadow] duration-150 hover:border-primary/20 hover:bg-primary/10 hover:text-primary hover:shadow-sm"
onClick={() => onViewThread?.(threadForMessage.id, message.seq)} onClick={() => onViewThread?.(threadForMessage.id, message.seq)}
type="button" type="button"
> >
@ -304,7 +306,7 @@ export default function MessageItem({
{message.reactions.map((r) => ( {message.reactions.map((r) => (
<button <button
className={cn( className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-all duration-150", "inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-[background-color,border-color,color] duration-150",
r.reacted_by_me r.reacted_by_me
? "border-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]" ? "border-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
: "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40", : "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40",
@ -320,7 +322,7 @@ export default function MessageItem({
<ReactionPicker <ReactionPicker
onSelect={(emoji) => handleReaction(emoji)} onSelect={(emoji) => handleReaction(emoji)}
trigger={ trigger={
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100"> <button aria-label="Add reaction" className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/30 opacity-60 transition-[border-color,color,opacity] duration-150 hover:border-primary/25 hover:text-primary/60 group-hover:opacity-100" type="button">
<SmilePlus className="size-3" /> <SmilePlus className="size-3" />
</button> </button>
} }
@ -329,12 +331,13 @@ export default function MessageItem({
)} )}
</div> </div>
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100"> <div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-xl border border-border/30 bg-card/95 px-1 py-1 opacity-0 shadow-md backdrop-blur-sm transition-opacity duration-150 group-focus-within:opacity-100 group-hover:opacity-100">
<ReactionPicker <ReactionPicker
onSelect={(emoji) => handleReaction(emoji)} onSelect={(emoji) => handleReaction(emoji)}
trigger={ trigger={
<Button <Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50" aria-label="Add reaction"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
size="icon" size="icon"
title="Add reaction" title="Add reaction"
variant="ghost" variant="ghost"
@ -345,7 +348,8 @@ export default function MessageItem({
/> />
<Button <Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50" aria-label="Reply"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
onClick={() => onReply?.(message)} onClick={() => onReply?.(message)}
size="icon" size="icon"
title="Reply" title="Reply"
@ -355,7 +359,8 @@ export default function MessageItem({
</Button> </Button>
<Button <Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50" aria-label={threadForMessage ? "View thread" : "Start thread"}
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
onClick={() => onClick={() =>
threadForMessage threadForMessage
? onViewThread?.(threadForMessage.id, message.seq) ? onViewThread?.(threadForMessage.id, message.seq)
@ -371,7 +376,8 @@ export default function MessageItem({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button <Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50" aria-label="More message actions"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
size="icon" size="icon"
title="More" title="More"
variant="ghost" variant="ghost"
@ -482,7 +488,7 @@ function ReactionPicker({
<div className="grid grid-cols-8 gap-0.5"> <div className="grid grid-cols-8 gap-0.5">
{REACTIONS_PALETTE.map((emoji) => ( {REACTIONS_PALETTE.map((emoji) => (
<button <button
className="grid size-8 cursor-pointer place-items-center rounded-md text-lg transition-all duration-150 hover:scale-110 hover:bg-accent/50" className="grid size-9 cursor-pointer place-items-center rounded-lg text-lg transition-[background-color,transform] duration-150 hover:scale-105 hover:bg-accent/50"
key={emoji} key={emoji}
onClick={() => onSelect(emoji)} onClick={() => onSelect(emoji)}
title={emoji} title={emoji}

View File

@ -91,6 +91,7 @@ export default function MessageView({
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true); const [atBottom, setAtBottom] = useState(true);
const [newMsgCount, setNewMsgCount] = useState(0); const [newMsgCount, setNewMsgCount] = useState(0);
const [initialLoaded, setInitialLoaded] = useState(false);
const prevMsgCountRef = useRef(0); const prevMsgCountRef = useRef(0);
const [replyTarget, setReplyTarget] = useState<MessageNewService | null>(null); const [replyTarget, setReplyTarget] = useState<MessageNewService | null>(null);
@ -141,26 +142,45 @@ export default function MessageView({
[renderedItems], [renderedItems],
); );
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => { useEffect(() => {
prevMsgCountRef.current = 0; prevMsgCountRef.current = 0;
setNewMsgCount(0); setNewMsgCount(0);
bottomRef.current?.scrollIntoView(); setInitialLoaded(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]); }, [roomId]);
/* eslint-enable react-hooks/set-state-in-effect */
// Scroll to bottom after initial messages load
useEffect(() => {
if (!initialLoaded && !loading && messageCount > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time scroll after load
setInitialLoaded(true);
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
}
}, [initialLoaded, loading, messageCount]);
// Auto-scroll on new messages (after initial load)
useEffect(() => { useEffect(() => {
const prev = prevMsgCountRef.current; const prev = prevMsgCountRef.current;
prevMsgCountRef.current = messageCount; prevMsgCountRef.current = messageCount;
if (!initialLoaded) return;
if (messageCount > prev && prev > 0 && !loading) { if (messageCount > prev && prev > 0 && !loading) {
if (atBottom) { if (atBottom) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
/* eslint-disable react-hooks/set-state-in-effect */
setNewMsgCount(0); setNewMsgCount(0);
/* eslint-enable react-hooks/set-state-in-effect */
} else { } else {
setNewMsgCount((c) => c + (messageCount - prev)); setNewMsgCount((c) => c + (messageCount - prev));
} }
} }
}, [messageCount, loading, atBottom]); }, [messageCount, loading, atBottom, initialLoaded]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
@ -205,12 +225,12 @@ export default function MessageView({
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{pinnedMessages.length > 0 && ( {pinnedMessages.length > 0 && (
<button <button
className="flex shrink-0 w-full cursor-pointer items-center gap-2 border-b border-amber-500/10 bg-amber-500/[0.03] px-4 py-2 text-left transition-colors hover:bg-amber-500/[0.06]" className="flex shrink-0 w-full cursor-pointer items-center gap-2 border-b border-warning/10 bg-warning/[0.03] px-4 py-2 text-left transition-colors hover:bg-warning/[0.06]"
type="button" type="button"
title={`${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? "s" : ""}`} title={`${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? "s" : ""}`}
> >
<Pin className="size-3.5 text-amber-500/50" /> <Pin className="size-3.5 text-warning/50" />
<span className="truncate text-[12px] font-medium text-amber-600/60"> <span className="truncate text-[12px] font-medium text-warning/60">
{pinnedMessages.length} pinned message {pinnedMessages.length} pinned message
{pinnedMessages.length > 1 ? "s" : ""} {pinnedMessages.length > 1 ? "s" : ""}
</span> </span>
@ -320,12 +340,12 @@ export default function MessageView({
</div> </div>
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} className="mb-4" />
</div> </div>
{newMsgCount > 0 && ( {newMsgCount > 0 && (
<button <button
className="absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-lg transition-all duration-200 hover:bg-primary/90 hover:shadow-xl" className="absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-lg transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:bg-primary/90 hover:shadow-xl"
onClick={handleScrollToBottom} onClick={handleScrollToBottom}
> >
<ChevronDown className="size-3.5" /> <ChevronDown className="size-3.5" />

View File

@ -1,6 +1,7 @@
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import RightDrawer from "@/components/right-drawer"; import RightDrawer from "@/components/right-drawer";
import { useDrawer } from "@/hooks/use-drawer"; import { useDrawer } from "@/hooks/use-drawer";
import RepoView from "@/components/repo/repo-view";
type Props = { type Props = {
workspace: string; workspace: string;
@ -33,12 +34,9 @@ export default function RepoDrawer({ workspace, repo, children }: Props) {
onClose={closeDrawer} onClose={closeDrawer}
open={open} open={open}
title={`${workspace}/${repo}`} title={`${workspace}/${repo}`}
width="max-w-[90vw]"
> >
<iframe <RepoView repo={repo} workspace={workspace} />
className="min-h-0 flex-1 w-full border-0"
src={repoUrl}
title={`${workspace}/${repo}`}
/>
</RightDrawer> </RightDrawer>
</> </>
); );

View File

@ -105,7 +105,7 @@ export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
return ( return (
<RepoDrawer repo={link.repo} workspace={link.workspace}> <RepoDrawer repo={link.repo} workspace={link.workspace}>
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-all duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"> <div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm">
{loading ? ( {loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50"> <div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />

View File

@ -116,7 +116,7 @@ export default function RoomCreateDialog({
<Label className="text-[13px]">Channel type</Label> <Label className="text-[13px]">Channel type</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${ className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-[background-color,border-color,color,box-shadow] ${
channelType === "channel" channelType === "channel"
? "border-primary/40 bg-primary/[0.06] text-foreground" ? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50" : "border-border/30 text-muted-foreground/60 hover:border-border/50"
@ -128,7 +128,7 @@ export default function RoomCreateDialog({
Chat Chat
</button> </button>
<button <button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${ className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-[background-color,border-color,color,box-shadow] ${
channelType === "article" channelType === "article"
? "border-primary/40 bg-primary/[0.06] text-foreground" ? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50" : "border-border/30 text-muted-foreground/60 hover:border-border/50"

View File

@ -50,9 +50,9 @@ export default function RoomDndToggle({ roomId }: Props) {
<PopoverTrigger> <PopoverTrigger>
<Button <Button
className={cn( className={cn(
"size-8 cursor-pointer rounded-lg transition-all duration-150", "size-8 cursor-pointer rounded-lg transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150",
dnd.do_not_disturb dnd.do_not_disturb
? "text-amber-500 hover:bg-accent/40" ? "text-warning hover:bg-accent/40"
: "text-muted-foreground/40 hover:bg-accent/40 hover:text-muted-foreground", : "text-muted-foreground/40 hover:bg-accent/40 hover:text-muted-foreground",
)} )}
size="icon" size="icon"

View File

@ -128,7 +128,7 @@ export default function ThreadPane({ thread, roomId, onClose }: Props) {
{/* Header */} {/* Header */}
<div className="flex h-[52px] shrink-0 items-center gap-3 border-b border-border/40 px-4"> <div className="flex h-[52px] shrink-0 items-center gap-3 border-b border-border/40 px-4">
<Button <Button
className="size-7 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground" className="size-8 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
onClick={onClose} onClick={onClose}
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -229,7 +229,7 @@ export default function ThreadPane({ thread, roomId, onClose }: Props) {
<div className="shrink-0 border-t border-border/40 p-3"> <div className="shrink-0 border-t border-border/40 p-3">
<div <div
className={cn( className={cn(
"flex items-end gap-2 rounded-xl border bg-card px-3 py-2 transition-all duration-200", "flex items-end gap-2 rounded-xl border bg-card px-3 py-2 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200",
"focus-within:border-primary/20 focus-within:ring-1 focus-within:ring-primary/[0.06]", "focus-within:border-primary/20 focus-within:ring-1 focus-within:ring-primary/[0.06]",
"border-border/50", "border-border/50",
)} )}

View File

@ -109,7 +109,7 @@ export default function ThreadSidebar({
</div> </div>
{onClose && ( {onClose && (
<Button <Button
className="size-7 cursor-pointer rounded-lg text-muted-foreground/30 hover:bg-accent/50 hover:text-foreground" className="size-8 cursor-pointer rounded-lg text-muted-foreground/30 hover:bg-accent/50 hover:text-foreground"
onClick={onClose} onClick={onClose}
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -136,7 +136,7 @@ export default function ThreadSidebar({
return ( return (
<button <button
className={cn( className={cn(
"relative flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[11px] font-medium transition-all duration-150", "relative flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[11px] font-medium transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150",
isActive isActive
? "text-primary" ? "text-primary"
: "text-muted-foreground/40 hover:text-muted-foreground/70", : "text-muted-foreground/40 hover:text-muted-foreground/70",
@ -193,7 +193,7 @@ export default function ThreadSidebar({
return ( return (
<button <button
className="group flex w-full cursor-pointer gap-3 border-b border-border/[0.06] px-4 py-3 text-left transition-all duration-150 hover:bg-accent/[0.03] active:bg-accent/[0.06]" className="group flex w-full cursor-pointer gap-3 border-b border-border/[0.06] px-4 py-3 text-left transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150 hover:bg-accent/[0.03] active:bg-accent/[0.06]"
key={thread.id} key={thread.id}
onClick={() => onSelect?.(thread)} onClick={() => onSelect?.(thread)}
type="button" type="button"

View File

@ -36,8 +36,8 @@ function saveDraft(draft: ArticleDraft | null) {
} }
export function useArticleDraft(defaultChannel?: string) { export function useArticleDraft(defaultChannel?: string) {
const [draft, setDraftState] = useState<ArticleDraft | null>(loadDraft); const [draft, setDraftState] = useState<ArticleDraft | null>(() => loadDraft());
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const draftRef = useRef(draft); const draftRef = useRef(draft);
// Debounced persist // Debounced persist