compassmock/src/components/conversations/pinned-messages-panel.tsx
Nicholai 40fdf48cbf
feat: add conversations, desktop (Tauri), and offline sync (#81)
* feat: add conversations, desktop (Tauri), and offline sync

Major new features:
- conversations module: Slack-like channels, threads, reactions, pins
- Tauri desktop app with local SQLite for offline-first operation
- Hybrid logical clock sync engine with conflict resolution
- DB provider abstraction (D1/Tauri/memory) with React context

Conversations:
- Text/voice/announcement channels with categories
- Message threads, reactions, attachments, pinning
- Real-time presence and typing indicators
- Full-text search across messages

Desktop (Tauri):
- Local SQLite database with sync to cloud D1
- Offline mutation queue with automatic replay
- Window management and keyboard shortcuts
- Desktop shell with offline banner

Sync infrastructure:
- Vector clock implementation for causality tracking
- Last-write-wins with semantic conflict resolution
- Delta sync via checkpoints for bandwidth efficiency
- Comprehensive test coverage

Also adds e2e test setup with Playwright and CI workflows
for desktop releases.

* fix(tests): sync engine test schema and checkpoint logic

- Add missing process_after column and sync_tombstone table to test schemas
- Fix checkpoint update to save cursor even when records array is empty
- Revert claude-code-review.yml workflow changes to match main

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-14 19:32:14 -07:00

215 lines
7.3 KiB
TypeScript

"use client"
import * as React from "react"
import { formatDistanceToNow, format, parseISO } from "date-fns"
import { IconPin, IconPinnedOff, IconLoader2 } from "@tabler/icons-react"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import { getPinnedMessages, unpinMessage } from "@/app/actions/message-search"
type PinnedMessage = {
id: string
channelId: string
threadId: string | null
content: string
contentHtml: string | null
editedAt: string | null
isPinned: boolean
replyCount: number
lastReplyAt: string | null
createdAt: string
user: {
id: string
displayName: string | null
email: string
avatarUrl: string | null
} | null
}
type PinnedMessagesPanelProps = {
channelId: string
isOpen: boolean
onClose: () => void
onJumpToMessage?: (messageId: string) => void
}
export function PinnedMessagesPanel({
channelId,
isOpen,
onClose,
onJumpToMessage,
}: PinnedMessagesPanelProps) {
const [messages, setMessages] = React.useState<PinnedMessage[]>([])
const [isLoading, setIsLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [unpinningId, setUnpinningId] = React.useState<string | null>(null)
// fetch pinned messages when panel opens
React.useEffect(() => {
async function fetchPinned() {
if (!isOpen || !channelId) return
setIsLoading(true)
setError(null)
const result = await getPinnedMessages(channelId)
if (result.success) {
setMessages(result.data as PinnedMessage[])
} else {
setError(result.error)
setMessages([])
}
setIsLoading(false)
}
fetchPinned()
}, [channelId, isOpen])
const handleUnpin = async (messageId: string) => {
setUnpinningId(messageId)
const result = await unpinMessage(messageId)
setUnpinningId(null)
if (result.success) {
setMessages((prev) => prev.filter((m) => m.id !== messageId))
} else {
// show error briefly - could use toast here
console.error("Failed to unpin:", result.error)
}
}
const handleMessageClick = (message: PinnedMessage) => {
if (onJumpToMessage) {
onJumpToMessage(message.id)
onClose()
}
}
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<IconPin className="size-5" />
Pinned Messages
</SheetTitle>
</SheetHeader>
<ScrollArea className="mt-4 h-[calc(100vh-8rem)]">
{isLoading && (
<div className="flex items-center justify-center py-8">
<IconLoader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
{!isLoading && !error && messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<IconPin className="mb-2 size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No pinned messages in this channel
</p>
<p className="mt-1 text-xs text-muted-foreground">
Important messages can be pinned for easy reference
</p>
</div>
)}
{!isLoading && !error && messages.length > 0 && (
<div className="space-y-3 pr-4">
{messages.map((message) => {
const displayName =
message.user?.displayName ??
message.user?.email?.split("@")[0] ??
"Unknown User"
const avatarFallback = displayName.substring(0, 2).toUpperCase()
const timestamp = parseISO(message.createdAt)
const isRecent =
Date.now() - timestamp.getTime() < 24 * 60 * 60 * 1000
const timeDisplay = isRecent
? formatDistanceToNow(timestamp, { addSuffix: true })
: format(timestamp, "MMM d, yyyy 'at' h:mm a")
return (
<div
key={message.id}
className="group rounded-lg border bg-muted/30 p-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-start gap-3">
<Avatar className="mt-0.5 h-8 w-8 shrink-0">
{message.user?.avatarUrl && (
<AvatarImage
src={message.user.avatarUrl}
alt={displayName}
/>
)}
<AvatarFallback className="text-xs">
{avatarFallback}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
{displayName}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => handleUnpin(message.id)}
disabled={unpinningId === message.id}
aria-label="Unpin message"
>
{unpinningId === message.id ? (
<IconLoader2 className="size-3 animate-spin" />
) : (
<IconPinnedOff className="size-3 text-muted-foreground" />
)}
</Button>
</div>
</div>
<button
className="mt-1 w-full cursor-pointer text-left text-sm"
onClick={() => handleMessageClick(message)}
>
<p className="line-clamp-3 text-muted-foreground">
{message.content}
</p>
</button>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{timeDisplay}</span>
{message.editedAt && (
<span className="opacity-60">(edited)</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
)
}