Skip to content
35 changes: 35 additions & 0 deletions desktop/src/features/channels/lib/channelLifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";

import { channelLifecycle, channelLifecycleLabel } from "./channelLifecycle.ts";

test("channelLifecycle prefers project home over TTL", () => {
assert.equal(
channelLifecycle({ projectHome: true, temporary: false }),
"project",
);
assert.equal(
channelLifecycle({ projectHome: true, temporary: true }),
"project",
);
});

test("channelLifecycle maps ongoing and temporary streams", () => {
assert.equal(
channelLifecycle({ projectHome: false, temporary: false }),
"ongoing",
);
assert.equal(
channelLifecycle({ projectHome: false, temporary: true }),
"temporary",
);
});

test("channelLifecycleLabel names project, ongoing, and temporary", () => {
assert.equal(channelLifecycleLabel("project", null), "Project");
assert.equal(channelLifecycleLabel("ongoing", null), "Ongoing");
assert.equal(
channelLifecycleLabel("temporary", 7 * 24 * 60 * 60),
"Temporary · 7d",
);
});
23 changes: 23 additions & 0 deletions desktop/src/features/channels/lib/channelLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { formatTtlDuration } from "@/features/channels/lib/ephemeralChannel";

export type ChannelLifecycle = "ongoing" | "temporary" | "project";

export function channelLifecycle(input: {
projectHome: boolean;
temporary: boolean;
}): ChannelLifecycle {
if (input.projectHome) return "project";
return input.temporary ? "temporary" : "ongoing";
}

export function channelLifecycleLabel(
lifecycle: ChannelLifecycle,
ttlSeconds: number | null,
): string {
if (lifecycle === "project") return "Project";
if (lifecycle === "temporary" && ttlSeconds != null) {
return `Temporary · ${formatTtlDuration(ttlSeconds)}`;
}
if (lifecycle === "temporary") return "Temporary";
return "Ongoing";
}
29 changes: 29 additions & 0 deletions desktop/src/features/channels/ui/ChannelGlyph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { FileText, Hash, Lock } from "lucide-react";

import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";

/** Stream/forum glyph for a channel, using the project mark on project homes. */
export function ChannelGlyph({
channel,
className,
}: {
channel: Pick<Channel, "channelType" | "id" | "visibility">;
className?: string;
}) {
const projectHome = useIsProjectHomeChannel(channel.id);
const iconClass = cn("size-4 shrink-0", className);

if (projectHome) {
return <ProjectChannelIcon className={iconClass} />;
}
if (channel.visibility === "private") {
return <Lock className={iconClass} />;
}
if (channel.channelType === "forum") {
return <FileText className={iconClass} />;
}
return <Hash className={iconClass} />;
}
25 changes: 10 additions & 15 deletions desktop/src/features/channels/ui/ChannelManagementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ import {
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelWorkflowsQuery } from "@/features/workflows/hooks";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
import type { Channel, ChannelMember, Workflow } from "@/shared/api/types";
import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext";
import { useFeatureEnabled } from "@/shared/features";
Expand Down Expand Up @@ -65,7 +62,10 @@ import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "./channelFormStyles";
import { ChannelTypeSettings } from "./ChannelTypeSettings";
import {
ChannelTypeDetailRow,
ChannelTypeSettings,
} from "./ChannelTypeSettings";
import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings";
import {
ActionFieldRow,
Expand Down Expand Up @@ -555,6 +555,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-lifecycle"
>
<ChannelTypeSettings
channelId={resolvedChannel.id}
disabled={isSavingChannelEdits}
onTemporaryChange={(temporary) => {
setIsEphemeralDraft(temporary);
Expand Down Expand Up @@ -778,16 +779,10 @@ function ChannelManagementPanelContent({
<FieldGroup testId="channel-management-details" title="Details">
{resolvedChannel.channelType !== "dm" ? (
<>
<EditableInfoFieldRow
editTestId="channel-management-edit-channel-type"
label="Channel type"
onEdit={canEditChannel ? onOpenEdit : undefined}
testId="channel-management-type"
value={
resolvedChannel.ttlSeconds === null
? "Ongoing"
: `Temporary · ${formatTtlDuration(resolvedChannel.ttlSeconds)}`
}
<ChannelTypeDetailRow
canEdit={canEditChannel}
channel={resolvedChannel}
onEdit={onOpenEdit}
/>
<EditableInfoFieldRow
editTestId="channel-management-edit-visibility"
Expand Down
16 changes: 6 additions & 10 deletions desktop/src/features/channels/ui/ChannelManagementSheetRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import {
Check,
ChevronRight,
Copy,
FileText,
Hash,
Info,
MessageSquare,
Pencil,
Expand All @@ -12,26 +10,20 @@ import {
import * as React from "react";
import { toast } from "sonner";

import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { PanelSectionGroup } from "@/shared/ui/PanelSectionGroup";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";

function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
if (channelType === "forum") return FileText;
if (channelType === "dm") return MessageSquare;
return Hash;
}

export function ChannelHero({
channel,
onEdit,
}: {
channel: Channel;
onEdit?: () => void;
}) {
const Icon = getChannelIcon(channel.channelType);
const channelDescription = channel.description.trim();
const description =
channelDescription || (onEdit ? "Add a description" : null);
Expand All @@ -42,7 +34,11 @@ export function ChannelHero({
data-testid="channel-management-hero"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon className="h-8 w-8" />
{channel.channelType === "dm" ? (
<MessageSquare className="h-8 w-8" />
) : (
<ChannelGlyph channel={channel} className="h-8 w-8" />
)}
</div>
{channel.channelType !== "dm" && onEdit ? (
<button
Expand Down
73 changes: 40 additions & 33 deletions desktop/src/features/channels/ui/ChannelMembersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { AddChannelBotDialog } from "./AddChannelBotDialog";
type ChannelMembersBarProps = {
channel: Channel;
currentPubkey?: string;
endActions?: React.ReactNode;
isAddBotOpen?: boolean;
onAddBotOpenChange?: (open: boolean) => void;
onManageChannel: () => void;
Expand All @@ -45,6 +46,7 @@ type ChannelMembersBarProps = {
export function ChannelMembersBar({
channel,
currentPubkey,
endActions,
isAddBotOpen: isAddBotOpenProp,
onAddBotOpenChange,
onManageChannel,
Expand Down Expand Up @@ -189,39 +191,42 @@ export function ChannelMembersBar({

const controls =
variant === "compact" ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-[6px]">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{endActions}
</div>
) : (
<div className="flex items-center gap-[6px]">
<Tooltip disableHoverableContent>
Expand Down Expand Up @@ -260,6 +265,8 @@ export function ChannelMembersBar({
</TooltipTrigger>
<TooltipContent>Channel settings</TooltipContent>
</Tooltip>

{endActions}
</div>
);

Expand Down
29 changes: 29 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getChannelIntroKind } from "./ChannelPane.helpers.ts";

function channel(overrides = {}) {
return {
ttlDeadline: null,
ttlSeconds: null,
visibility: "open",
...overrides,
};
}

test("getChannelIntroKind names project homes ahead of regular streams", () => {
assert.equal(getChannelIntroKind(channel(), true), "project channel");
assert.equal(getChannelIntroKind(channel(), false), "regular channel");
});

test("getChannelIntroKind keeps private and ephemeral labels for other streams", () => {
assert.equal(
getChannelIntroKind(channel({ visibility: "private" })),
"private channel",
);
assert.equal(
getChannelIntroKind(channel({ ttlSeconds: 3600 })),
"ephemeral channel",
);
});
9 changes: 8 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";

export function getChannelIntroKind(channel: Channel): string {
export function getChannelIntroKind(
channel: Channel,
projectHome = false,
): string {
if (projectHome) {
return "project channel";
}

const isPrivate = channel.visibility === "private";
const isEphemeral = isEphemeralChannel(channel);

Expand Down
Loading