compassmock/src/components/conversations/create-channel-dialog.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

348 lines
11 KiB
TypeScript

"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { z } from "zod/v4"
import { zodResolver } from "@hookform/resolvers/zod"
import { Hash, Volume2, Megaphone, Lock, FolderOpen } from "lucide-react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { createChannel } from "@/app/actions/conversations"
import { listCategories } from "@/app/actions/channel-categories"
const channelSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name must be less than 50 characters")
.regex(
/^[a-z0-9-]+$/,
"Lowercase letters, numbers, and hyphens only"
),
type: z.enum(["text", "voice", "announcement"]),
categoryId: z.string().nullable(),
isPrivate: z.boolean(),
})
type ChannelFormData = z.infer<typeof channelSchema>
const channelTypes = [
{
value: "text",
label: "Text",
icon: Hash,
description: "Send messages, images, GIFs, and files",
disabled: false,
},
{
value: "voice",
label: "Voice",
icon: Volume2,
description: "Hang out together with voice, video, and screen share",
disabled: true,
},
{
value: "announcement",
label: "Announcement",
icon: Megaphone,
description: "Important updates that only admins can post",
disabled: false,
},
] as const
type CategoryData = {
readonly id: string
readonly name: string
readonly position: number
readonly channelCount: number
}
type CreateChannelDialogProps = {
readonly open: boolean
readonly onOpenChange: (open: boolean) => void
}
export function CreateChannelDialog({
open,
onOpenChange,
}: CreateChannelDialogProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [categories, setCategories] = React.useState<CategoryData[]>([])
const [loadingCategories, setLoadingCategories] = React.useState(true)
React.useEffect(() => {
async function loadCategories() {
if (open) {
const result = await listCategories()
if (result.success && result.data) {
setCategories(
result.data.map((cat) => ({
id: cat.id,
name: cat.name,
position: cat.position,
channelCount: cat.channelCount,
}))
)
}
setLoadingCategories(false)
}
}
loadCategories()
}, [open])
const form = useForm<ChannelFormData>({
resolver: zodResolver(channelSchema),
defaultValues: {
name: "",
type: "text",
categoryId: null,
isPrivate: false,
},
})
const onSubmit = async (data: ChannelFormData) => {
setIsSubmitting(true)
const result = await createChannel({
name: data.name,
type: data.type,
categoryId: data.categoryId,
isPrivate: data.isPrivate,
})
if (result.success && result.data) {
form.reset()
onOpenChange(false)
router.push(`/dashboard/conversations/${result.data.channelId}`)
router.refresh()
} else {
form.setError("root", {
message: result.error ?? "Failed to create channel",
})
}
setIsSubmitting(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Channel</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{/* channel type - vertical radio cards */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="space-y-2">
<FormLabel className="text-sm font-semibold">
Channel Type
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="space-y-1.5"
>
{channelTypes.map((ct) => {
const Icon = ct.icon
const selected = field.value === ct.value
return (
<label
key={ct.value}
className={cn(
"flex cursor-pointer items-center gap-3",
"rounded-md border px-3 py-2 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50",
ct.disabled &&
"cursor-not-allowed opacity-50"
)}
>
<RadioGroupItem
value={ct.value}
disabled={ct.disabled}
className="shrink-0"
/>
<Icon className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-sm font-medium">
{ct.label}
</div>
<div className="text-xs text-muted-foreground">
{ct.description}
</div>
</div>
</label>
)
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* channel name with # prefix */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-sm font-semibold">
Channel Name
</FormLabel>
<FormControl>
<div className="relative">
<Hash className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="new-channel"
className="pl-9"
{...field}
onChange={(e) =>
field.onChange(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* category selector */}
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-sm font-semibold">
Category
</FormLabel>
<FormControl>
<Select
value={field.value ?? "none"}
onValueChange={(value) =>
field.onChange(value === "none" ? null : value)
}
disabled={loadingCategories}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<span>No Category</span>
</div>
</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* private toggle */}
<FormField
control={form.control}
name="isPrivate"
render={({ field }) => (
<FormItem className="flex items-start gap-3 rounded-md border px-3 py-2">
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<FormLabel className="text-sm font-semibold">
Private Channel
</FormLabel>
<p className="text-xs text-muted-foreground">
Only selected members and roles will be able
to view this channel.
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="text-sm text-destructive">
{form.formState.errors.root.message}
</p>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create Channel"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}