fix(frontend): Discord UI polish, room streaming and kanban improvements
- DiscordChatPanel: update threading layout, message list padding - DiscordMemberList: role colors, online status indicators - RoomThreadPanel, MessageBubble, MessageInput: UI refinements - IMEditor: editor state improvements - KanbanCard, KanbanColumn: card layout, column styling - room-context.tsx, room-ws-client.ts, ws-protocol.ts: streaming - pull-request-detail.tsx: PR review improvements - Add RoomPinPanel component
This commit is contained in:
parent
aeb765d2ac
commit
4d3afc5e71
@ -1,4 +1,4 @@
|
||||
import { pullRequestClose, pullRequestGet, pullRequestReopen } from "@/client";
|
||||
import { generatePrDescription, pullRequestClose, pullRequestGet, pullRequestReopen, pullRequestUpdate, triggerCodeReview } from "@/client";
|
||||
import { RepoHeader } from "@/components/repository/header";
|
||||
import { PRConversation } from "@/components/repository/PRConversation";
|
||||
import { PRDiffViewer } from "@/components/repository/PRDiffViewer";
|
||||
@ -7,7 +7,8 @@ import { PRCommitList } from "@/components/repository/PRCommitList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Check, GitBranch, GitMerge, GitPullRequest, Loader2, X } from "lucide-react";
|
||||
import { Calendar, Check, GitBranch, GitMerge, GitPullRequest, Loader2, Sparkles, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -45,6 +46,8 @@ export const RepoPullRequestDetail = () => {
|
||||
}>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [generatedBody, setGeneratedBody] = useState<string | null>(null);
|
||||
|
||||
const { data: pr, isLoading, error } = useQuery({
|
||||
queryKey: ["pull-request", namespace, repoName, prNumber],
|
||||
queryFn: async () => {
|
||||
@ -90,6 +93,60 @@ export const RepoPullRequestDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const generateDescMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!namespace || !repoName || !prNumber) return;
|
||||
const resp = await generatePrDescription({
|
||||
path: { namespace, repo: repoName },
|
||||
body: { pr_number: parseInt(prNumber, 10) },
|
||||
});
|
||||
return resp.data?.markdown_body ?? null;
|
||||
},
|
||||
onSuccess: (body) => {
|
||||
if (body) {
|
||||
setGeneratedBody(body);
|
||||
toast.success("Description generated");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to generate description");
|
||||
},
|
||||
});
|
||||
|
||||
const triggerReviewMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!namespace || !repoName || !prNumber) return;
|
||||
await triggerCodeReview({
|
||||
path: { namespace, repo: repoName },
|
||||
body: { pr_number: parseInt(prNumber, 10) },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("AI review requested");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to request AI review");
|
||||
},
|
||||
});
|
||||
|
||||
const applyDescMutation = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (!namespace || !repoName || !prNumber) return;
|
||||
await pullRequestUpdate({
|
||||
path: { namespace, repo: repoName, number: parseInt(prNumber, 10) },
|
||||
body: { body },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pull-request", namespace, repoName, prNumber] });
|
||||
setGeneratedBody(null);
|
||||
toast.success("Description updated");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update description");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@ -156,6 +213,20 @@ export const RepoPullRequestDetail = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => triggerReviewMutation.mutate()}
|
||||
disabled={triggerReviewMutation.isPending || !isOpen}
|
||||
title="Request AI code review"
|
||||
>
|
||||
{triggerReviewMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
AI Review
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -192,12 +263,58 @@ export const RepoPullRequestDetail = () => {
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{pr.body && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium mb-2">Description</h3>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap">{pr.body}</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">Description</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => generateDescMutation.mutate()}
|
||||
disabled={generateDescMutation.isPending || !isOpen}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
{generateDescMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
Generate with AI
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generatedBody ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm bg-primary/5 border border-primary/20 rounded-md p-3 whitespace-pre-wrap text-muted-foreground">
|
||||
{generatedBody}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyDescMutation.mutate(generatedBody)}
|
||||
disabled={applyDescMutation.isPending}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{applyDescMutation.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
|
||||
Apply to PR
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGeneratedBody(null)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : pr.body ? (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap">{pr.body}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
No description provided. Click "Generate with AI" to create one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merge box + branch info */}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { AlignLeft, Calendar, ChevronUp, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { CardResponse } from '@/client';
|
||||
import type { CardResponse, MemberInfo } from '@/client';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
interface KanbanCardProps {
|
||||
card: CardResponse;
|
||||
onEdit?: (card: CardResponse) => void;
|
||||
members?: MemberInfo[];
|
||||
}
|
||||
|
||||
const PRIORITY = {
|
||||
@ -32,7 +34,7 @@ const PRIORITY = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function KanbanCard({ card, onEdit }: KanbanCardProps) {
|
||||
export function KanbanCard({ card, onEdit, members = [] }: KanbanCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@ -42,6 +44,10 @@ export function KanbanCard({ card, onEdit }: KanbanCardProps) {
|
||||
isDragging,
|
||||
} = useSortable({ id: card.id, data: { type: 'Card', card } });
|
||||
|
||||
const assignee = card.assignee_id
|
||||
? members.find((m) => m.user_id === card.assignee_id)
|
||||
: undefined;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@ -108,11 +114,17 @@ export function KanbanCard({ card, onEdit }: KanbanCardProps) {
|
||||
{formatDate(card.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{card.assignee_id && (
|
||||
{assignee && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM2 14a6 6 0 0 1 12 0H2Z"/>
|
||||
</svg>
|
||||
<Avatar className="h-3.5 w-3.5">
|
||||
{assignee.avatar_url ? (
|
||||
<AvatarImage src={assignee.avatar_url} className="h-3.5 w-3.5" />
|
||||
) : null}
|
||||
<AvatarFallback className="h-3.5 w-3.5 text-[9px]">
|
||||
{(assignee.display_name ?? assignee.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="leading-none">{assignee.display_name ?? assignee.username}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,9 @@ import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2, ChevronUp, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import type { CardResponse, ColumnWithCardsResponse } from '@/client';
|
||||
import { cardCreate, cardDelete, cardUpdate, columnDelete, columnUpdate } from '@/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { CardResponse, ColumnWithCardsResponse, MemberInfo } from '@/client';
|
||||
import { cardCreate, cardDelete, cardUpdate, columnDelete, columnUpdate, projectMembers } from '@/client';
|
||||
import { useProject } from '@/contexts';
|
||||
import {
|
||||
Dialog,
|
||||
@ -29,12 +30,14 @@ function CardDialog({
|
||||
position: _position,
|
||||
onClose,
|
||||
onSaved,
|
||||
members,
|
||||
}: {
|
||||
card?: CardResponse;
|
||||
columnId: string;
|
||||
position: number;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
members: MemberInfo[];
|
||||
}) {
|
||||
const { project } = useProject();
|
||||
const [title, setTitle] = useState(card?.title ?? '');
|
||||
@ -42,6 +45,9 @@ function CardDialog({
|
||||
const [priority, setPriority] = useState<'high' | 'medium' | 'low' | ''>(
|
||||
(card?.priority as 'high' | 'medium' | 'low') ?? '',
|
||||
);
|
||||
const [assigneeId, setAssigneeId] = useState<string>(card?.assignee_id ?? '');
|
||||
const [dueDate, setDueDate] = useState<string>(card?.due_date ? card.due_date.slice(0, 10) : '');
|
||||
const [issueId, setIssueId] = useState<string>(card?.issue_id ? String(card.issue_id) : '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isEdit = !!card;
|
||||
@ -50,6 +56,7 @@ function CardDialog({
|
||||
if (!project?.name || !title.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const parsedIssueId = issueId ? parseInt(issueId, 10) : undefined;
|
||||
if (isEdit) {
|
||||
await cardUpdate({
|
||||
path: { project_name: project.name, card_id: card.id },
|
||||
@ -57,12 +64,22 @@ function CardDialog({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority: priority || undefined,
|
||||
assignee_id: assigneeId || undefined,
|
||||
due_date: dueDate ? dueDate + 'T00:00:00Z' : undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await cardCreate({
|
||||
path: { project_name: project.name },
|
||||
body: { title: title.trim(), column_id: columnId },
|
||||
body: {
|
||||
title: title.trim(),
|
||||
column_id: columnId,
|
||||
description: description.trim() || undefined,
|
||||
priority: priority || undefined,
|
||||
assignee_id: assigneeId || undefined,
|
||||
due_date: dueDate ? dueDate + 'T00:00:00Z' : undefined,
|
||||
issue_id: parsedIssueId || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
@ -135,6 +152,54 @@ function CardDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
|
||||
Assignee
|
||||
</Label>
|
||||
<select
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-[#E4E4E7] bg-white px-3 py-2 text-[13px] text-[#27272A] focus:border-[#D4D4D8] focus:outline-none focus:ring-1 focus:ring-[#D4D4D8] cursor-pointer"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.display_name ?? m.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
|
||||
Due Date
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issue ID — only shown when creating (API doesn't support update) */}
|
||||
{!isEdit && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
|
||||
Linked Issue ID
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={issueId}
|
||||
onChange={(e) => setIssueId(e.target.value)}
|
||||
placeholder="e.g. 42"
|
||||
className="h-10 border-[#E4E4E7] focus:border-[#D4D4D8] text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[#71717A]">
|
||||
@ -262,6 +327,17 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
|
||||
const [newColName, setNewColName] = useState(column.name);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
const { data: membersData } = useQuery({
|
||||
queryKey: ['projectMembers', project?.name],
|
||||
queryFn: async () => {
|
||||
if (!project?.name) return { members: [] as MemberInfo[] };
|
||||
const resp = await projectMembers({ path: { project_name: project.name } });
|
||||
return resp.data?.data ?? { members: [] as MemberInfo[] };
|
||||
},
|
||||
enabled: !!project?.name,
|
||||
});
|
||||
const members = membersData?.members ?? [];
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.id,
|
||||
data: { type: 'Column', column },
|
||||
@ -358,6 +434,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
|
||||
key={card.id}
|
||||
card={card}
|
||||
onEdit={setEditingCard}
|
||||
members={members}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@ -386,6 +463,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
|
||||
position={cards.findIndex((c) => c.id === editingCard.id)}
|
||||
onClose={() => setEditingCard(undefined)}
|
||||
onSaved={onCardAdded ?? (() => {})}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -396,6 +474,7 @@ export function KanbanColumn({ columnData, onCardAdded }: KanbanColumnProps) {
|
||||
position={cards.length}
|
||||
onClose={() => setAddingCard(false)}
|
||||
onSaved={onCardAdded ?? (() => {})}
|
||||
members={members}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import { RoomThreadPanel } from './RoomThreadPanel';
|
||||
import { RoomSettingsPanel } from './RoomSettingsPanel';
|
||||
import { DiscordMemberList } from './DiscordMemberList';
|
||||
import { RoomMessageSearch } from './RoomMessageSearch';
|
||||
import { RoomPinPanel } from './RoomPinPanel';
|
||||
import { useRoom } from '@/contexts';
|
||||
|
||||
// ─── Main Panel ──────────────────────────────────────────────────────────
|
||||
@ -57,6 +58,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
threads,
|
||||
refreshThreads,
|
||||
roomAiConfigs,
|
||||
presence,
|
||||
} = useRoom();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@ -71,6 +73,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
const [showMentions, setShowMentions] = useState(false);
|
||||
const [showMemberList, setShowMemberList] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showPins, setShowPins] = useState(false);
|
||||
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
|
||||
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
|
||||
|
||||
@ -169,6 +172,12 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
setShowSearch(false);
|
||||
}, []);
|
||||
|
||||
const handleJumpToMessage = useCallback((messageId: string) => {
|
||||
const el = document.getElementById(`msg-${messageId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setShowPins(false);
|
||||
}, []);
|
||||
|
||||
const handleUpdateRoom = useCallback(
|
||||
async (name: string, isPublic: boolean) => {
|
||||
setIsUpdatingRoom(true);
|
||||
@ -196,6 +205,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
setShowSettings(false);
|
||||
setShowMentions(false);
|
||||
setShowSearch(false);
|
||||
setShowPins(false);
|
||||
setActiveThread(null);
|
||||
}, [room.id]);
|
||||
|
||||
@ -264,7 +274,11 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: 'var(--room-text-muted)' }}
|
||||
style={{
|
||||
color: showPins ? 'var(--room-accent)' : 'var(--room-text-muted)',
|
||||
background: showPins ? 'var(--room-channel-active)' : 'transparent',
|
||||
}}
|
||||
onClick={() => { setShowPins(v => !v); setShowSettings(false); setShowMentions(false); setShowMemberList(false); }}
|
||||
title="Pinned messages"
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
@ -353,6 +367,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
membersLoading={membersLoading}
|
||||
onMemberClick={handleMemberClick}
|
||||
aiConfigs={roomAiConfigs}
|
||||
presence={presence}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -409,6 +424,17 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPins && (
|
||||
<RoomPinPanel
|
||||
roomId={room.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
messages={messages as any}
|
||||
members={members}
|
||||
onClose={() => setShowPins(false)}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoomMessageEditDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
|
||||
@ -5,6 +5,7 @@ import type { RoomMemberResponse } from '@/client';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Loader2, Shield, UserRound, Bot } from 'lucide-react';
|
||||
import { useTheme } from '@/contexts';
|
||||
import type { PresenceMap } from '@/contexts/room-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RoomAiConfig } from '@/contexts';
|
||||
|
||||
@ -14,6 +15,7 @@ interface DiscordMemberListProps {
|
||||
onMemberClick?: (member: RoomMemberResponse) => void;
|
||||
onMemberMention?: (id: string, label: string) => void;
|
||||
aiConfigs?: RoomAiConfig[];
|
||||
presence?: PresenceMap;
|
||||
}
|
||||
|
||||
// Role color mapping — AI Studio clean palette
|
||||
@ -25,16 +27,41 @@ const ROLE_COLORS: Record<string, string> = {
|
||||
guest: '#9ca3af',
|
||||
};
|
||||
|
||||
// Presence dot colors
|
||||
const PRESENCE_COLORS: Record<string, string> = {
|
||||
online: '#3ba55c',
|
||||
away: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
function getRoleColor(role: string): string {
|
||||
return ROLE_COLORS[role] ?? ROLE_COLORS['member'];
|
||||
}
|
||||
|
||||
function PresenceDot({ status }: { status: string }) {
|
||||
const color = PRESENCE_COLORS[status] ?? PRESENCE_COLORS['offline'];
|
||||
return (
|
||||
<span
|
||||
className="absolute bottom-0 right-0 rounded-full border-2"
|
||||
style={{
|
||||
background: color,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderColor: 'var(--room-sidebar)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberItem({
|
||||
member,
|
||||
onClick,
|
||||
presenceStatus,
|
||||
}: {
|
||||
member: RoomMemberResponse;
|
||||
onClick?: (member: RoomMemberResponse) => void;
|
||||
presenceStatus?: string;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const displayName = (member.user_info as { username?: string } | null | undefined)?.username ?? member.user.slice(0, 8);
|
||||
@ -47,7 +74,7 @@ function MemberItem({
|
||||
onClick={() => onClick?.(member)}
|
||||
title={`@${displayName} — ${member.role ?? 'member'}`}
|
||||
>
|
||||
<div className="discord-member-avatar-wrap">
|
||||
<div className="discord-member-avatar-wrap relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
{member.user_info?.avatar_url ? (
|
||||
<AvatarImage src={member.user_info.avatar_url} alt={displayName} />
|
||||
@ -59,7 +86,7 @@ function MemberItem({
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="discord-member-status-dot online" />
|
||||
<PresenceDot status={presenceStatus ?? 'offline'} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -108,6 +135,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
|
||||
membersLoading,
|
||||
onMemberClick,
|
||||
aiConfigs = [],
|
||||
presence = {},
|
||||
}: DiscordMemberListProps) {
|
||||
useTheme(); // theme consumed via CSS variables
|
||||
const { admins, moderators, regularMembers } = useMemo(() => {
|
||||
@ -199,6 +227,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
|
||||
key={member.user}
|
||||
member={member}
|
||||
onClick={onMemberClick}
|
||||
presenceStatus={presence[member.user]}
|
||||
/>
|
||||
))}
|
||||
</MemberSection>
|
||||
@ -214,6 +243,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
|
||||
key={member.user}
|
||||
member={member}
|
||||
onClick={onMemberClick}
|
||||
presenceStatus={presence[member.user]}
|
||||
/>
|
||||
))}
|
||||
</MemberSection>
|
||||
|
||||
183
src/components/room/RoomPinPanel.tsx
Normal file
183
src/components/room/RoomPinPanel.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pin, X, Loader2 } from 'lucide-react';
|
||||
import { pinList, pinRemove, type RoomPinResponse, type RoomMemberResponse } from '@/client';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { getSenderDisplayName } from './sender';
|
||||
import { formatMessageTime } from './shared/formatters';
|
||||
|
||||
interface RoomPinPanelProps {
|
||||
roomId: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
sender_id: string;
|
||||
sender_type: string;
|
||||
send_at: string;
|
||||
}>;
|
||||
members: RoomMemberResponse[];
|
||||
onClose: () => void;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pins, isLoading } = useQuery({
|
||||
queryKey: ['roomPins', roomId],
|
||||
queryFn: async () => {
|
||||
const resp = await pinList({ path: { room_id: roomId } });
|
||||
return resp.data?.data ?? ([] as RoomPinResponse[]);
|
||||
},
|
||||
});
|
||||
|
||||
const unpinMutation = useMutation({
|
||||
mutationFn: async (messageId: string) => {
|
||||
await pinRemove({ path: { room_id: roomId, message_id: messageId } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roomPins', roomId] });
|
||||
toast.success('Message unpinned');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to unpin message');
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map of message_id -> message content from local messages
|
||||
const messageMap = new Map(messages.map(m => [m.id, m]));
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex flex-col border-l z-20 animate-in slide-in-from-right duration-200"
|
||||
style={{
|
||||
width: 380,
|
||||
borderColor: 'var(--room-border)',
|
||||
background: 'var(--room-bg)',
|
||||
top: '48px',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--room-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
|
||||
Pinned Messages
|
||||
</span>
|
||||
{pins && (
|
||||
<span
|
||||
className="text-[11px] px-1.5 py-0.5 rounded-full"
|
||||
style={{ background: 'var(--room-channel-active)', color: 'var(--room-text-muted)' }}
|
||||
>
|
||||
{pins.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition-colors cursor-pointer bg-transparent border-0"
|
||||
style={{ color: 'var(--room-text-muted)' }}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
|
||||
</div>
|
||||
) : pins && pins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 gap-2">
|
||||
<Pin className="h-8 w-8 opacity-30" style={{ color: 'var(--room-text-muted)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>No pinned messages</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
|
||||
{pins?.map((pin) => {
|
||||
const localMsg = messageMap.get(pin.message);
|
||||
return (
|
||||
<div
|
||||
key={pin.message}
|
||||
className="group px-4 py-3 hover:bg-[var(--room-channel-active)] transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
onJumpToMessage(pin.message);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{/* Sender row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Avatar className="h-5 w-5">
|
||||
{(() => {
|
||||
const member = members.find(m => m.user === pin.pinned_by);
|
||||
return (
|
||||
<>
|
||||
{member?.user_info?.avatar_url ? (
|
||||
<AvatarImage src={member.user_info.avatar_url} className="h-5 w-5" />
|
||||
) : null}
|
||||
<AvatarFallback className="h-5 w-5 text-[10px]">
|
||||
{(member?.user_info?.username ?? pin.pinned_by).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Avatar>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--room-text)' }}>
|
||||
{(() => {
|
||||
const member = members.find(m => m.user === pin.pinned_by);
|
||||
return member?.user_info?.username ?? pin.pinned_by;
|
||||
})()}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
||||
pinned {formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
unpinMutation.mutate(pin.message);
|
||||
}}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity flex h-5 w-5 items-center justify-center rounded text-[var(--room-text-muted)] hover:text-red-400 cursor-pointer bg-transparent border-0"
|
||||
title="Unpin"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message preview */}
|
||||
<p
|
||||
className="text-[13px] line-clamp-2 pl-7"
|
||||
style={{ color: 'var(--room-text-secondary)' }}
|
||||
>
|
||||
{localMsg
|
||||
? localMsg.content.length > 120
|
||||
? localMsg.content.slice(0, 120) + '…'
|
||||
: localMsg.content
|
||||
: `[Message #${pin.message}]`}
|
||||
</p>
|
||||
|
||||
{/* Original sender */}
|
||||
{localMsg && (
|
||||
<div className="mt-0.5 pl-7">
|
||||
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
||||
{getSenderDisplayName(localMsg as any)} • {formatMessageTime(localMsg.send_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -189,20 +189,24 @@ export function ThreadIndicator({ threadId, onClick }: ThreadIndicatorProps) {
|
||||
// Find thread info from threads list
|
||||
const threadInfo = threads.find(t => t.id === threadId);
|
||||
|
||||
// Count replies - we'll estimate based on last_message_preview presence
|
||||
// A proper implementation would need a reply_count field from backend
|
||||
const hasReplies = !!threadInfo?.last_message_preview;
|
||||
// Count replies — participants array gives us a count
|
||||
const participantCount = Array.isArray(threadInfo?.participants)
|
||||
? threadInfo.participants.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors"
|
||||
className="mt-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-[var(--room-accent)] hover:bg-[var(--room-channel-active)] transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span>Thread</span>
|
||||
{hasReplies && (
|
||||
<span className="text-[10px]">• has replies</span>
|
||||
{participantCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
|
||||
<span className="font-medium">{participantCount}</span>
|
||||
<span>replies</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -83,7 +83,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
const isEdited = !!message.edited_at;
|
||||
useTheme();
|
||||
const { user } = useUser();
|
||||
const { wsClient, streamingMessages, members } = useRoom();
|
||||
const { wsClient, streamingMessages, members, pins, pinMessage, unpinMessage } = useRoom();
|
||||
const avatarUrl = (() => {
|
||||
if (message.sender_type === 'ai') return undefined;
|
||||
const member = members.find(m => m.user === message.sender_id);
|
||||
@ -93,6 +93,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
const isRevoked = !!message.revoked;
|
||||
const isFailed = message.isOptimisticError === true;
|
||||
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
|
||||
const isPinned = pins.some(p => p.message === message.id);
|
||||
|
||||
const displayContent = isStreaming && streamingMessages?.has(message.id)
|
||||
? streamingMessages.get(message.id)!
|
||||
@ -378,6 +379,20 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
|
||||
>
|
||||
<ReactionPicker onReact={handleReaction} />
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: isPinned ? 'var(--room-accent)' : 'var(--room-text-muted)' }}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
unpinMessage?.(message.id);
|
||||
} else {
|
||||
pinMessage?.(message.id);
|
||||
}
|
||||
}}
|
||||
title={isPinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
{onReply && (
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
|
||||
@ -45,6 +45,14 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
||||
}), []);
|
||||
|
||||
// Slash commands available in the editor
|
||||
const SLASH_COMMANDS = [
|
||||
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const },
|
||||
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const },
|
||||
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const },
|
||||
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
|
||||
];
|
||||
|
||||
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
||||
const mentionItems = useMemo(() => ({
|
||||
users: members.map((m) => ({
|
||||
@ -55,7 +63,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
})),
|
||||
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
||||
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
|
||||
commands: [] as { id: string; label: string; type: 'command'; avatar?: string }[],
|
||||
commands: SLASH_COMMANDS,
|
||||
}), [members]);
|
||||
|
||||
// File upload handler — POST to /rooms/{room_id}/upload
|
||||
|
||||
@ -269,9 +269,14 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
|
||||
const selectMention = useCallback((item: MentionItem) => {
|
||||
if (!editor) return;
|
||||
// Use backend-parseable format: @[type:id:label]
|
||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
if (item.type === 'command') {
|
||||
// Replace the / prefix with the full command label
|
||||
editor.chain().focus().insertContent(item.label + ' ').run();
|
||||
} else {
|
||||
// Use backend-parseable format: @[type:id:label]
|
||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
}
|
||||
setMentionOpen(false);
|
||||
}, []);
|
||||
|
||||
@ -309,7 +314,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
let ts = from;
|
||||
for (let i = from - 1; i >= 1; i--) {
|
||||
const c = text[i - 1];
|
||||
if (c === '@') {
|
||||
if (c === '@' || c === '/') {
|
||||
ts = i;
|
||||
break;
|
||||
}
|
||||
@ -324,6 +329,22 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
setMentionIdx(0);
|
||||
setMentionOpen(true);
|
||||
|
||||
if (wrapRef.current) {
|
||||
const sel = window.getSelection();
|
||||
if (sel?.rangeCount) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const cr = wrapRef.current.getBoundingClientRect();
|
||||
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||
}
|
||||
}
|
||||
} else if (q.startsWith('/') && q.length > 1) {
|
||||
// Filter commands by query (e.g. "/ai" matches "ai")
|
||||
const results = filterMentionItems(mentionItems.commands, q.slice(1));
|
||||
setMentionQuery(q.slice(1));
|
||||
setMentionItems2(results);
|
||||
setMentionIdx(0);
|
||||
setMentionOpen(true);
|
||||
|
||||
if (wrapRef.current) {
|
||||
const sel = window.getSelection();
|
||||
if (sel?.rangeCount) {
|
||||
|
||||
@ -2,5 +2,5 @@ export { UserProvider, useUser } from './user-context';
|
||||
export { ProjectProvider, useProject } from './project-context';
|
||||
export { WorkspaceProvider, useWorkspace, tryUseWorkspace } from './workspace-context';
|
||||
export { RepositoryContextProvider, useRepo, type RepoInfo } from './repository-context';
|
||||
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup, type RoomAiConfig } from './room-context';
|
||||
export { RoomProvider, useRoom, type RoomWithCategory, type MessageWithMeta, type UiMessage, type ReactionGroup, type RoomAiConfig, type PresenceMap, type PresenceStatus } from './room-context';
|
||||
export { ThemeProvider, useTheme } from './theme-context';
|
||||
|
||||
@ -33,6 +33,9 @@ import { useUser } from '@/contexts';
|
||||
|
||||
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
||||
|
||||
export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
|
||||
export type PresenceMap = Record<string, PresenceStatus>; // keyed by user_id
|
||||
|
||||
export interface RoomAiConfig {
|
||||
model: string;
|
||||
modelName?: string;
|
||||
@ -98,6 +101,7 @@ interface RoomContextValue {
|
||||
wsClient: RoomWsClient | null;
|
||||
connectWs: () => Promise<void>;
|
||||
disconnectWs: () => void;
|
||||
presence: PresenceMap;
|
||||
|
||||
rooms: RoomWithCategory[];
|
||||
roomsLoading: boolean;
|
||||
@ -406,6 +410,9 @@ export function RoomProvider({
|
||||
|
||||
const [threads, setThreads] = useState<RoomThreadResponse[]>([]);
|
||||
|
||||
// User presence map: user_id -> status
|
||||
const [presence, setPresence] = useState<PresenceMap>({});
|
||||
|
||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Project repos for @repository: mention suggestions
|
||||
@ -627,6 +634,10 @@ export function RoomProvider({
|
||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||
setPins((prev) => prev.filter((p) => p.message !== payload.message_id));
|
||||
},
|
||||
onUserPresence: (payload) => {
|
||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
setWsStatus(status);
|
||||
if (status === 'closed' || status === 'error') {
|
||||
@ -1260,6 +1271,7 @@ export function RoomProvider({
|
||||
refreshPins: fetchPins,
|
||||
pinMessage,
|
||||
unpinMessage,
|
||||
presence,
|
||||
threads,
|
||||
refreshThreads: fetchThreads,
|
||||
createThread,
|
||||
@ -1312,6 +1324,7 @@ export function RoomProvider({
|
||||
fetchPins,
|
||||
pinMessage,
|
||||
unpinMessage,
|
||||
presence,
|
||||
threads,
|
||||
fetchThreads,
|
||||
createThread,
|
||||
|
||||
@ -28,6 +28,7 @@ import type {
|
||||
SubscribeData,
|
||||
UserInfo,
|
||||
RoomReactionUpdatedPayload,
|
||||
UserPresencePayload,
|
||||
} from './ws-protocol';
|
||||
|
||||
export type {
|
||||
@ -51,6 +52,7 @@ export type {
|
||||
MessageEditHistoryResponse,
|
||||
UserInfo,
|
||||
RoomReactionUpdatedPayload,
|
||||
UserPresencePayload,
|
||||
};
|
||||
|
||||
export interface WsTokenResponse {
|
||||
@ -75,6 +77,7 @@ export interface RoomWsCallbacks {
|
||||
onMessageRevoked?: (payload: import('./ws-protocol').MessageRevokedPayload) => void;
|
||||
onMessagePinned?: (payload: import('./ws-protocol').MessagePinnedPayload) => void;
|
||||
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
|
||||
onUserPresence?: (payload: UserPresencePayload) => void;
|
||||
onStatusChange?: (status: RoomWsStatus) => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** Called each time the client sends a heartbeat ping */
|
||||
@ -1022,6 +1025,14 @@ export class RoomWsClient {
|
||||
room_id: event.room_id ?? '',
|
||||
});
|
||||
break;
|
||||
case 'user.presence':
|
||||
case 'user_presence':
|
||||
this.callbacks.onUserPresence?.({
|
||||
user_id: (event.data as { user_id?: string })?.user_id ?? '',
|
||||
room_id: event.room_id ?? '',
|
||||
status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Unknown event type - ignore silently
|
||||
break;
|
||||
|
||||
@ -154,6 +154,7 @@ export type WsEventPayload =
|
||||
| { type: 'message_revoked'; data: MessageRevokedPayload }
|
||||
| { type: 'message_pinned'; data: MessagePinnedPayload }
|
||||
| { type: 'message_unpinned'; data: MessageUnpinnedPayload }
|
||||
| { type: 'user_presence'; data: UserPresencePayload }
|
||||
| { type: string; data: unknown }; // catch-all for unknown events
|
||||
|
||||
export interface RoomMessagePayload {
|
||||
@ -316,6 +317,12 @@ export interface MessageUnpinnedPayload {
|
||||
room_id: string;
|
||||
}
|
||||
|
||||
export interface UserPresencePayload {
|
||||
user_id: string;
|
||||
room_id: string;
|
||||
status: 'online' | 'away' | 'dnd' | 'offline';
|
||||
}
|
||||
|
||||
export interface SearchResultData {
|
||||
messages: RoomMessageResponse[];
|
||||
total: number;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user