fix(channel): update channel components and article draft
This commit is contained in:
parent
82475e95d5
commit
29e6f6214d
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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]" />
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user