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

302 lines
8.2 KiB
TypeScript

"use client"
import * as React from "react"
import { IconX, IconCrown, IconShield } from "@tabler/icons-react"
import { getChannelMembersWithPresence } from "@/app/actions/presence"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
type MemberWithPresence = {
readonly id: string
readonly displayName: string | null
readonly avatarUrl: string | null
readonly role: string
readonly status: string
readonly statusMessage: string | null
}
type MemberSidebarProps = {
channelId: string
isOpen: boolean
onClose: () => void
}
type MemberGroupProps = {
readonly title: string
readonly members: readonly MemberWithPresence[]
readonly statusColor: string
}
function getStatusColor(status: string): string {
switch (status) {
case "online":
return "bg-green-500"
case "idle":
return "bg-yellow-500"
case "dnd":
return "bg-red-500"
default:
return "bg-gray-400"
}
}
function getInitials(name: string | null): string {
if (!name) return "?"
const parts = name.trim().split(/\s+/)
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase()
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
}
function getRoleBadgeVariant(
role: string
): "default" | "secondary" | "outline" {
switch (role) {
case "owner":
return "default"
case "moderator":
return "secondary"
default:
return "outline"
}
}
function RoleIcon({ role }: { readonly role: string }) {
if (role === "owner") {
return <IconCrown className="h-3 w-3 text-yellow-500" />
}
if (role === "moderator") {
return <IconShield className="h-3 w-3 text-blue-500" />
}
return null
}
function MemberGroup({ title, members, statusColor }: MemberGroupProps) {
if (members.length === 0) return null
return (
<div className="mb-4">
<h3 className="mb-2 px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{title} {members.length}
</h3>
<ul className="space-y-0.5">
{members.map((member) => (
<li key={member.id}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="relative">
<Avatar size="sm">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.displayName ?? ""} />
)}
<AvatarFallback>
{getInitials(member.displayName)}
</AvatarFallback>
</Avatar>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background",
statusColor
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm">
{member.displayName ?? "Unknown"}
</span>
<RoleIcon role={member.role} />
</div>
{member.statusMessage && (
<p className="truncate text-xs text-muted-foreground">
{member.statusMessage}
</p>
)}
</div>
{(member.role === "owner" || member.role === "moderator") && (
<Badge variant={getRoleBadgeVariant(member.role)} className="text-[10px]">
{member.role}
</Badge>
)}
</button>
</li>
))}
</ul>
</div>
)
}
function MemberListContent({
channelId,
isOpen,
}: {
readonly channelId: string
readonly isOpen: boolean
}) {
const [members, setMembers] = React.useState<{
online: MemberWithPresence[]
idle: MemberWithPresence[]
dnd: MemberWithPresence[]
offline: MemberWithPresence[]
} | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
React.useEffect(() => {
let mounted = true
async function fetchMembers() {
try {
const result = await getChannelMembersWithPresence(channelId)
if (!mounted) return
if (result.success) {
setMembers(result.data)
} else {
setError(result.error)
}
} catch (err) {
if (!mounted) return
setError(err instanceof Error ? err.message : "Failed to load members")
} finally {
if (mounted) setLoading(false)
}
}
fetchMembers()
// 10-second polling interval when sidebar is open
let pollInterval: ReturnType<typeof setInterval> | null = null
if (isOpen) {
pollInterval = setInterval(fetchMembers, 10_000)
}
return () => {
mounted = false
if (pollInterval) {
clearInterval(pollInterval)
}
}
}, [channelId, isOpen])
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{error}
</div>
)
}
if (!members) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No members found
</div>
)
}
const totalMembers =
members.online.length +
members.idle.length +
members.dnd.length +
members.offline.length
return (
<ScrollArea className="h-full">
<div className="p-2">
<div className="mb-4 px-2 text-xs text-muted-foreground">
{totalMembers} member{totalMembers !== 1 ? "s" : ""}
</div>
<MemberGroup
title="Online"
members={members.online}
statusColor={getStatusColor("online")}
/>
<MemberGroup
title="Idle"
members={members.idle}
statusColor={getStatusColor("idle")}
/>
<MemberGroup
title="Do Not Disturb"
members={members.dnd}
statusColor={getStatusColor("dnd")}
/>
<MemberGroup
title="Offline"
members={members.offline}
statusColor={getStatusColor("offline")}
/>
</div>
</ScrollArea>
)
}
export function MemberSidebar({
channelId,
isOpen,
onClose,
}: MemberSidebarProps) {
return (
<>
{/* Desktop sidebar */}
<aside
className={cn(
"hidden w-60 shrink-0 border-l bg-background transition-all duration-200 lg:flex lg:flex-col",
!isOpen && "w-0 overflow-hidden border-l-0"
)}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<h2 className="text-sm font-semibold">Members</h2>
<Button
variant="ghost"
size="icon-xs"
onClick={onClose}
className="h-6 w-6"
>
<IconX className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<MemberListContent channelId={channelId} isOpen={isOpen} />
</div>
</aside>
{/* Mobile sheet */}
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-72 p-0 lg:hidden">
<SheetHeader className="h-14 shrink-0 border-b px-4">
<div className="flex items-center justify-between">
<SheetTitle className="text-sm">Members</SheetTitle>
</div>
</SheetHeader>
<div className="flex-1 overflow-hidden">
<MemberListContent channelId={channelId} isOpen={isOpen} />
</div>
</SheetContent>
</Sheet>
</>
)
}