Fix channel visibility controls (#940)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-10 07:46:43 -07:00
committed by GitHub
co-authored by Pinky
parent 3e56331e9c
commit 2dc466fe0b
7 changed files with 240 additions and 37 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ const overrides = new Map([
["src-tauri/src/managed_agents/persona_card.rs", 1050],
["src-tauri/src/huddle/tts.rs", 1364],
["src/shared/api/tauri.ts", 1196],
["src-tauri/src/nostr_convert.rs", 1116],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
["src-tauri/src/managed_agents/teams.rs", 1020],
+16 -10
View File
@@ -79,11 +79,10 @@ pub fn channel_info_from_event(
"stream".to_string()
}
});
// Prefer explicit ["public"] tag; fall back to NIP-29's absence-of-"private"
// convention for relays that don't yet emit the explicit tag.
let visibility = if has_tag(event, "public") {
let visibility_tag = first_tag_value(event, "visibility");
let visibility = if has_tag(event, "public") || visibility_tag == Some("open") {
"open".to_string()
} else if has_tag(event, "private") {
} else if has_tag(event, "private") || visibility_tag == Some("private") {
"private".to_string()
} else {
"open".to_string()
@@ -161,10 +160,10 @@ pub fn channel_detail_from_event(event: &Event) -> Result<ChannelDetailInfo, Str
"stream".to_string()
}
});
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
let visibility = if has_tag(event, "public") {
let visibility_tag = first_tag_value(event, "visibility");
let visibility = if has_tag(event, "public") || visibility_tag == Some("open") {
"open".to_string()
} else if has_tag(event, "private") {
} else if has_tag(event, "private") || visibility_tag == Some("private") {
"private".to_string()
} else {
"open".to_string()
@@ -678,8 +677,7 @@ mod tests {
}
#[test]
fn channel_info_private_when_private_tag_present() {
// Explicit ["private"] tag → private (NIP-29 convention).
fn channel_info_private_when_visibility_tag_present() {
let e = ev(
39000,
"",
@@ -687,12 +685,14 @@ mod tests {
vec!["d", "u"],
vec!["name", "n"],
vec!["t", "forum"],
vec!["private"],
vec!["visibility", "private"],
vec!["ttl", "86400"],
],
);
let info = channel_info_from_event(&e, None, None).unwrap();
assert_eq!(info.visibility, "private");
assert_eq!(info.channel_type, "forum");
assert_eq!(info.ttl_seconds, Some(86400));
}
#[test]
@@ -753,6 +753,9 @@ mod tests {
vec!["topic", "tt"],
vec!["purpose", "pp"],
vec!["t", "dm"],
vec!["visibility", "private"],
vec!["ttl", "86400"],
vec!["ttl_deadline", "2026-06-11T00:00:00Z"],
],
);
let d = channel_detail_from_event(&e).unwrap();
@@ -760,6 +763,9 @@ mod tests {
assert_eq!(d.topic.as_deref(), Some("tt"));
assert_eq!(d.purpose.as_deref(), Some("pp"));
assert_eq!(d.channel_type, "dm");
assert_eq!(d.visibility, "private");
assert_eq!(d.ttl_seconds, Some(86400));
assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z"));
assert!(d.created_at.ends_with("Z"));
assert_eq!(d.created_by, e.pubkey.to_hex());
}
@@ -68,6 +68,8 @@ type ChannelManagementSheetProps = {
open: boolean;
};
const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
function MetadataPill({
icon: Icon,
label,
@@ -123,7 +125,8 @@ export function ChannelManagementSheet({
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
const updateChannelMutation = useUpdateChannelMutation(channelId);
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
const updateChannelLifecycleMutation = useUpdateChannelMutation(channelId);
const setTopicMutation = useSetChannelTopicMutation(channelId);
const setPurposeMutation = useSetChannelPurposeMutation(channelId);
const archiveChannelMutation = useArchiveChannelMutation(channelId);
@@ -243,24 +246,19 @@ export function ChannelManagementSheet({
const nextVisibility: "open" | "private" = isPrivateDraft
? "private"
: "open";
// Ephemeral on with a parsed duration → set those seconds; ephemeral off →
// clear (null). An ephemeral toggle with an empty/invalid field leaves the
// existing TTL untouched (undefined) so an accidental clear can't happen.
const nextTtlSeconds: number | null | undefined = isEphemeralDraft
? (parsedTtlSeconds ?? undefined)
const nextTtlSeconds: number | null = isEphemeralDraft
? (parsedTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS)
: null;
const lifecycleDirty =
nextVisibility !== currentVisibility ||
(nextTtlSeconds !== undefined && nextTtlSeconds !== currentTtlSeconds);
nextTtlSeconds !== currentTtlSeconds;
function handleSaveLifecycle() {
void updateChannelMutation.mutateAsync({
void updateChannelLifecycleMutation.mutateAsync({
visibility:
nextVisibility !== currentVisibility ? nextVisibility : undefined,
ttlSeconds:
nextTtlSeconds !== undefined && nextTtlSeconds !== currentTtlSeconds
? nextTtlSeconds
: undefined,
nextTtlSeconds !== currentTtlSeconds ? nextTtlSeconds : undefined,
});
}
@@ -360,7 +358,7 @@ export function ChannelManagementSheet({
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void updateChannelMutation.mutateAsync({
void updateChannelDetailsMutation.mutateAsync({
description: descriptionDraft.trim() || undefined,
name: nameDraft.trim() || undefined,
});
@@ -372,7 +370,9 @@ export function ChannelManagementSheet({
</label>
<Input
data-testid="channel-management-name"
disabled={!canManageChannel || updateChannelMutation.isPending}
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
@@ -388,7 +388,9 @@ export function ChannelManagementSheet({
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={!canManageChannel || updateChannelMutation.isPending}
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
id="channel-description"
onChange={(event) => setDescriptionDraft(event.target.value)}
value={descriptionDraft}
@@ -396,15 +398,19 @@ export function ChannelManagementSheet({
</div>
<Button
data-testid="channel-management-save-details"
disabled={!canManageChannel || updateChannelMutation.isPending}
disabled={
!canManageChannel || updateChannelDetailsMutation.isPending
}
size="sm"
type="submit"
>
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
{updateChannelDetailsMutation.isPending
? "Saving..."
: "Save details"}
</Button>
{updateChannelMutation.error instanceof Error ? (
{updateChannelDetailsMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateChannelMutation.error.message}
{updateChannelDetailsMutation.error.message}
</p>
) : null}
</form>
@@ -425,7 +431,8 @@ export function ChannelManagementSheet({
checked={isPrivateDraft}
data-testid="channel-management-private-toggle"
disabled={
!canManageChannel || updateChannelMutation.isPending
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsPrivateDraft}
/>
@@ -442,7 +449,8 @@ export function ChannelManagementSheet({
checked={isEphemeralDraft}
data-testid="channel-management-ephemeral-toggle"
disabled={
!canManageChannel || updateChannelMutation.isPending
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
onCheckedChange={setIsEphemeralDraft}
/>
@@ -457,7 +465,8 @@ export function ChannelManagementSheet({
aria-invalid={ttlInvalid}
data-testid="channel-management-ttl"
disabled={
!canManageChannel || updateChannelMutation.isPending
!canManageChannel ||
updateChannelLifecycleMutation.isPending
}
id="channel-ttl"
onChange={(event) => setTtlDraft(event.target.value)}
@@ -472,7 +481,7 @@ export function ChannelManagementSheet({
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Resets the deletion countdown from now whenever changed."}
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
@@ -481,7 +490,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-save-lifecycle"
disabled={
!canManageChannel ||
updateChannelMutation.isPending ||
updateChannelLifecycleMutation.isPending ||
ttlInvalid ||
!lifecycleDirty
}
@@ -489,7 +498,7 @@ export function ChannelManagementSheet({
size="sm"
type="button"
>
{updateChannelMutation.isPending
{updateChannelLifecycleMutation.isPending
? "Saving..."
: "Save visibility"}
</Button>
+12
View File
@@ -44,6 +44,7 @@ type E2eConfig = {
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
updateChannelDelayMs?: number;
stallWebsocketSends?: boolean;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
// semantics. These three drive the archive-button gate matrix in
@@ -501,6 +502,10 @@ declare global {
interface Window {
__SPROUT_E2E__?: E2eConfig;
__SPROUT_E2E_COMMANDS__?: string[];
__SPROUT_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
__SPROUT_E2E_WEBVIEW_ZOOM__?: number;
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
@@ -3243,6 +3248,11 @@ async function handleUpdateChannel(
},
config: E2eConfig | undefined,
) {
const delayMs = config?.mock?.updateChannelDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const identity = getIdentity(config);
if (!identity) {
const channel = getMockChannel(args.channelId);
@@ -5386,6 +5396,7 @@ export function maybeInstallE2eTauriMocks() {
mockWebsocketSendMutexWedged = false;
mockWindows("main");
window.__SPROUT_E2E_COMMANDS__ = [];
window.__SPROUT_E2E_COMMAND_LOG__ = [];
window.__SPROUT_E2E_SIGNED_EVENTS__ = [];
window.__SPROUT_E2E_WEBVIEW_ZOOM__ = 1;
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
@@ -5502,6 +5513,7 @@ export function maybeInstallE2eTauriMocks() {
const activeConfig = getConfig();
const identity = getActiveIdentity(activeConfig);
window.__SPROUT_E2E_COMMANDS__?.push(command);
window.__SPROUT_E2E_COMMAND_LOG__?.push({ command, payload });
switch (command) {
case "mesh_availability":
@@ -121,4 +121,65 @@ test.describe("channel controls screenshots", () => {
await sheet.screenshot({ path: `${SHOTS}/06-management-sheet.png` });
});
test("07 — saving lifecycle leaves details save idle", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
const sheet = page.getByTestId("channel-management-sheet");
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toBeEnabled();
await page.getByTestId("channel-management-save-lifecycle").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toHaveText("Saving...");
await expect(
page.getByTestId("channel-management-save-details"),
).toHaveText("Save details");
await sheet.screenshot({
path: `${SHOTS}/07-lifecycle-saving-details-idle.png`,
});
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toHaveText("Save visibility");
});
test("08 — saved ephemeral lifecycle is reflected after reopen", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-save-lifecycle").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toHaveText("Save visibility");
await page.keyboard.press("Escape");
await expect(
page.getByTestId("channel-management-sheet"),
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("1d");
await settle(page);
await lifecycle.screenshot({
path: `${SHOTS}/08-ephemeral-persisted-after-reopen.png`,
});
});
});
+116 -2
View File
@@ -115,13 +115,25 @@ async function getManagedAgentPubkey(
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(window as Window & { __SPROUT_E2E_COMMANDS__?: string[] })
.__SPROUT_E2E_COMMANDS__ ?? []
);
});
}
async function readCommandPayloadLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(
window as Window & {
__SPROUT_E2E_COMMANDS__?: string[];
__SPROUT_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__SPROUT_E2E_COMMANDS__ ?? []
).__SPROUT_E2E_COMMAND_LOG__ ?? []
);
});
}
@@ -748,6 +760,108 @@ test("manage channel updates details and context", async ({ page }) => {
);
});
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
page,
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
const saveDetailsButton = page.getByTestId("channel-management-save-details");
const saveLifecycleButton = page.getByTestId(
"channel-management-save-lifecycle",
);
await expect(saveLifecycleButton).toBeDisabled();
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
await expect(saveLifecycleButton).toBeEnabled();
const commandCountBeforeEnable = (await readCommandPayloadLog(page)).length;
await saveLifecycleButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeEnable),
)
.toContainEqual(
expect.objectContaining({
command: "update_channel",
payload: expect.objectContaining({
input: expect.objectContaining({ ttlSeconds: 86400 }),
}),
}),
);
await expect(saveLifecycleButton).toHaveText("Save visibility");
await expect(saveDetailsButton).toHaveText("Save details");
const channelAfterEnable = await invokeMockCommand<{
ttl_seconds: number | null;
visibility: string;
}>(page, "get_channel_details", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
});
expect(channelAfterEnable).toMatchObject({
ttl_seconds: 86400,
visibility: "private",
});
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("1d");
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(saveLifecycleButton).toBeEnabled();
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
await saveLifecycleButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeDisable),
)
.toContainEqual(
expect.objectContaining({
command: "update_channel",
payload: expect.objectContaining({
input: expect.objectContaining({ ttlSeconds: null }),
}),
}),
);
await expect(saveLifecycleButton).toHaveText("Save visibility");
await expect(saveDetailsButton).toHaveText("Save details");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
const channelAfterDisable = await invokeMockCommand<{
ttl_seconds: number | null;
visibility: string;
}>(page, "get_channel_details", {
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
});
expect(channelAfterDisable).toMatchObject({
ttl_seconds: null,
visibility: "open",
});
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "unchecked");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
});
test("manage channel keeps canvas near the top of the sheet", async ({
page,
}) => {
+1
View File
@@ -51,6 +51,7 @@ type MockBridgeOptions = {
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
updateChannelDelayMs?: number;
stallWebsocketSends?: boolean;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.