Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,29 @@ one they are, so those match too.
No configuration changes and nothing is stored differently; a deployment that was already on the
light theme sees no difference at all.

### A conversation can be deleted

Nothing removed a channel. Starting one was the only lever the product gave a person, and every
conversation with every coworker sat in the roster forever, growing on every message the way
`DEFAULT_CHANNEL_PAGE`'s own note already described: a page that was instant in a demo returns
thousands of rows for anybody who has actually been using the product a while, one that never shrinks
again.

Deleting a channel now removes it for good. The channel row goes, and its memberships, its linked
coworkers, and its Intelligence thread mapping go with it through the same foreign-key cascades that
already existed for them — no migration needed, only a query that finally uses them. The deployment
also asks Intelligence to permanently delete the thread itself, so the message history is not just
unlisted, it is gone from the platform too.

A thread the platform refuses to delete does not hold the channel hostage. The local removal already
committed by the time that call runs, so a rejected or unreachable upstream delete leaves the channel
gone from the roster regardless, with an audit row (`channel.deleted`) naming the thread and whether
Intelligence actually forgot it. A channel that is gone locally with an orphaned thread still on the
platform is a smaller, more honest failure than a channel sitting in the roster with its history
silently wiped out from under it, and the audit trail is where an administrator finds the one that
did not clean up completely. `DELETE /api/channels/:channelId` answers with `historyLeftBehind`, so a
screen showing the outcome does not have to guess which of the two happened.

## 0.0.4

### A click citing a ref this deployment cannot resolve is refused
Expand Down
174 changes: 149 additions & 25 deletions app/src/components/app-sidebar/channel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import { Link } from "@tanstack/react-router";
import { memo } from "react";
import { IconDots } from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { memo, useState } from "react";
import { deleteChannelMutationOptions } from "@/lib/channels/mutations";
import { ChannelAvatar } from "../channels/avatar";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";

/**
* Memoized roster row. `use-channel-events` preserves unchanged row identity, and
* `content-visibility` keeps off-screen rows cheap without virtualization.
*
* State inside a row is no reason to drop the memo: `memo` compares the props it is handed and has
* nothing to say about a hook. Dropping it re-renders every row in the roster whenever the sidebar
* renders, which is the cost the identity-preserving patch in `use-channel-events` exists to avoid.
*/
export const Channel = memo(function Channel({
channelId,
Expand All @@ -19,32 +43,132 @@ export const Channel = memo(function Channel({
lastMessage?: string;
lastMessageAt?: string;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
// `strict: false`: this row renders in the sidebar on every screen, not only while its own
// channel is open, so there may be no `channelId` route param to read at all.
const { channelId: openChannelId } = useParams({ strict: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));

const handleDelete = async () => {
// Navigate away first: the row this menu lives on unmounts the moment the list invalidates,
// and a screen still pointed at a channel id that no longer resolves is worse than a screen
// that moved on a beat early.
if (openChannelId === channelId) {
await navigate({ to: "/" });
}
try {
await deleteChannel.mutateAsync(channelId);
/*
* Closed on success rather than left to the unmount.
*
* The row does go away when the roster invalidates, taking this dialog with it, but that is a
* side effect of a cache write and not something this component controls.
*/
setDeleteDialogOpen(false);
} catch {
/*
* Left open, deliberately. A delete that failed leaves the row exactly where it was, so
* closing would return the person to a roster that still lists the conversation they just
* asked to be rid of, with nothing anywhere saying why. The message is rendered below;
* `mutateAsync` rejects rather than swallowing, which is why this catch exists at all.
*/
}
};

return (
<Link
to="/channel/$channelId"
params={{ channelId }}
type="button"
className="flex flex-row py-2 px-2 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]"
activeProps={{
className: "bg-foreground/5",
}}
>
<div className="">
<ChannelAvatar participantIds={participantIds} size={32} />
</div>
<div className="flex-col min-w-0 flex-1">
<div className="flex flex-row items-center justify-between gap-2">
<span className="text-[14px] tracking-[-1%] truncate">{name}</span>
<div className="text-[12px] text-muted-foreground/70">
{lastMessageAt}
</div>
<div className="group/channel relative">
<Link
to="/channel/$channelId"
params={{ channelId }}
type="button"
className="flex flex-row py-2 px-2 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]"
activeProps={{
className: "bg-foreground/5",
}}
>
<div className="">
<ChannelAvatar participantIds={participantIds} size={32} />
</div>
<div className="mt-px flex h-4 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
{lastMessage}
</span>
<div className="flex-col min-w-0 flex-1">
<div className="flex flex-row items-center justify-between gap-2">
<span className="text-[14px] tracking-[-1%] truncate">{name}</span>
<div className="group-hover/channel:invisible text-[12px] text-muted-foreground/70">
{lastMessageAt}
</div>
</div>
<div className="mt-px flex h-4 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
{lastMessage}
</span>
</div>
</div>
</Link>
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/channel:opacity-100 focus-within:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`Options for ${name}`}
>
<IconDots />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{/* Only opens the dialog below; the menu closes on click, too early to confirm anything. */}
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Link>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
<AlertDialogDescription>
This deletes your conversation with{" "}
<span className="font-medium text-foreground">{name}</span>,
including its message history. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteChannel.isError ? (
<p className="text-destructive text-sm" role="alert">
{deleteChannel.error.message}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteChannel.isPending}>
Cancel
</AlertDialogCancel>
{/*
* A plain button, not `AlertDialogAction`.
*
* That one renders the primitive's `Close`, so it shuts the dialog the instant it is
* pressed, before the request it starts has been answered. Nothing then reports a
* delete that failed: the dialog is gone, the conversation is still in the roster, and
* the person is left to work out for themselves that the thing they asked for did not
* happen. It also means "Deleting…" below could never appear.
*/}
<Button
disabled={deleteChannel.isPending}
onClick={() => void handleDelete()}
variant="destructive"
>
{deleteChannel.isPending ? "Deleting…" : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
});
150 changes: 150 additions & 0 deletions app/src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import type * as React from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

/**
* A modal for an action a click cannot undo. Built on `AlertDialogRoot` rather than `Dialog`'s
* `DialogRoot`: it carries `role="alertdialog"` and is announced immediately, and has no corner
* close button — the only way out is one of the footer's own buttons.
*/
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}

function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}

function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}

function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}

function AlertDialogContent({
className,
...props
}: AlertDialogPrimitive.Popup.Props) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
className={cn(
"-translate-x-1/2 -translate-y-1/2 fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-md flex-col gap-4 rounded-xl border border-border bg-popover p-5 text-sm text-popover-foreground shadow-lg transition duration-150 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}

function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-1", className)}
{...props}
/>
);
}

function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-row items-center justify-end gap-2", className)}
{...props}
/>
);
}

function AlertDialogTitle({
className,
...props
}: AlertDialogPrimitive.Title.Props) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("font-heading font-medium text-base text-foreground", className)}
{...props}
/>
);
}

function AlertDialogDescription({
className,
...props
}: AlertDialogPrimitive.Description.Props) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm text-pretty", className)}
{...props}
/>
);
}

/** The button that answers "no" or "not now." Closes without running anything else. */
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<AlertDialogPrimitive.Close
render={<Button variant="outline" className={className} {...props} />}
/>
);
}

/**
* The button that carries out the action, styled destructive by default since that is the only
* reason this component exists rather than the ordinary `Dialog`. Pass `onClick` to run the action;
* closing is automatic, the same as `AlertDialogCancel`.
*/
function AlertDialogAction({
className,
variant = "destructive",
...props
}: React.ComponentProps<typeof Button>) {
return (
<AlertDialogPrimitive.Close
render={<Button variant={variant} className={className} {...props} />}
/>
);
}

export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
Loading