feat(desktop): star channels (Slack-style favorites) (#860)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-04 15:32:28 -07:00
committed by GitHub
co-authored by Brain
parent f748f71268
commit c10b4f8f5c
10 changed files with 1024 additions and 24 deletions
+1
View File
@@ -27,6 +27,7 @@ export default defineConfig({
"**/custom-emoji.spec.ts",
"**/custom-emoji-screenshots.spec.ts",
"**/channel-mute-screenshots.spec.ts",
"**/channel-star-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
+7
View File
@@ -57,6 +57,7 @@ import { HuddleBar, HuddleProvider } from "@/features/huddle";
import { useMeshRelayOrchestrator } from "@/features/mesh-compute/hooks/useMeshRelayOrchestrator";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { relayClient } from "@/shared/api/relayClient";
@@ -208,6 +209,9 @@ export function AppShell() {
const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
identityQuery.data?.pubkey,
);
const { starredChannelIds, starChannel, unstarChannel } = useChannelStars(
identityQuery.data?.pubkey,
);
const profileQuery = useProfileQuery();
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
usePresenceSubscription();
@@ -818,6 +822,9 @@ export function AppShell() {
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
@@ -0,0 +1,258 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseStarPayload,
mergeStores,
starredChannelIdsFromStore,
} from "./channelStarsStorage.ts";
// ── parseStarPayload ──────────────────────────────────────────────────────────
test("parseStarPayload: valid payload with channels returns store", () => {
const payload = {
version: 1,
channels: {
"chan-1": { starred: true, updatedAt: 1000 },
"chan-2": { starred: false, updatedAt: 2000 },
},
};
const result = parseStarPayload(payload);
assert.deepEqual(result, {
version: 1,
channels: {
"chan-1": { starred: true, updatedAt: 1000 },
"chan-2": { starred: false, updatedAt: 2000 },
},
});
});
test("parseStarPayload: missing version returns null", () => {
assert.equal(
parseStarPayload({
channels: { "chan-1": { starred: true, updatedAt: 1 } },
}),
null,
);
});
test("parseStarPayload: wrong version returns null", () => {
assert.equal(
parseStarPayload({
version: 2,
channels: { "chan-1": { starred: true, updatedAt: 1 } },
}),
null,
);
});
test("parseStarPayload: null input returns null", () => {
assert.equal(parseStarPayload(null), null);
});
test("parseStarPayload: non-object input returns null", () => {
assert.equal(parseStarPayload("string"), null);
assert.equal(parseStarPayload(42), null);
assert.equal(parseStarPayload(true), null);
});
test("parseStarPayload: malformed channel entries missing starred/updatedAt are filtered out", () => {
const payload = {
version: 1,
channels: {
"no-starred": { updatedAt: 1000 },
"no-updated-at": { starred: true },
valid: { starred: false, updatedAt: 500 },
"starred-wrong-type": { starred: "yes", updatedAt: 1000 },
"updated-at-wrong-type": { starred: true, updatedAt: "now" },
null: null,
},
};
const result = parseStarPayload(payload);
assert.deepEqual(result, {
version: 1,
channels: {
valid: { starred: false, updatedAt: 500 },
},
});
});
test("parseStarPayload: NaN/Infinity/negative updatedAt entries are filtered out", () => {
const payload = {
version: 1,
channels: {
nan: { starred: true, updatedAt: NaN },
inf: { starred: true, updatedAt: Infinity },
"neg-inf": { starred: true, updatedAt: -Infinity },
neg: { starred: true, updatedAt: -1 },
valid: { starred: true, updatedAt: 100 },
},
};
const result = parseStarPayload(payload);
assert.deepEqual(result, {
version: 1,
channels: { valid: { starred: true, updatedAt: 100 } },
});
});
test("parseStarPayload: empty channels returns store with empty channels", () => {
const result = parseStarPayload({ version: 1, channels: {} });
assert.deepEqual(result, { version: 1, channels: {} });
});
test("parseStarPayload: version 1 with no channels key returns store with empty channels", () => {
const result = parseStarPayload({ version: 1 });
assert.deepEqual(result, { version: 1, channels: {} });
});
// ── mergeStores ───────────────────────────────────────────────────────────────
test("mergeStores: non-overlapping channels returns union of both", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: true, updatedAt: 100 } },
};
const remote = {
version: 1,
channels: { "chan-b": { starred: false, updatedAt: 200 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result, {
version: 1,
channels: {
"chan-a": { starred: true, updatedAt: 100 },
"chan-b": { starred: false, updatedAt: 200 },
},
});
});
test("mergeStores: overlapping channel with remote newer takes remote", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: false, updatedAt: 100 } },
};
const remote = {
version: 1,
channels: { "chan-a": { starred: true, updatedAt: 200 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result.channels["chan-a"], {
starred: true,
updatedAt: 200,
});
});
test("mergeStores: overlapping channel with local newer takes local", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: true, updatedAt: 300 } },
};
const remote = {
version: 1,
channels: { "chan-a": { starred: false, updatedAt: 100 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result.channels["chan-a"], {
starred: true,
updatedAt: 300,
});
});
test("mergeStores: overlapping channel with same updatedAt local wins", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: true, updatedAt: 500 } },
};
const remote = {
version: 1,
channels: { "chan-a": { starred: false, updatedAt: 500 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result.channels["chan-a"], {
starred: true,
updatedAt: 500,
});
});
test("mergeStores: unstar with higher updatedAt overrides star", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: true, updatedAt: 100 } },
};
const remote = {
version: 1,
channels: { "chan-a": { starred: false, updatedAt: 999 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result.channels["chan-a"], {
starred: false,
updatedAt: 999,
});
});
test("mergeStores: empty local returns remote entries", () => {
const local = { version: 1, channels: {} };
const remote = {
version: 1,
channels: { "chan-b": { starred: true, updatedAt: 42 } },
};
const result = mergeStores(local, remote);
assert.deepEqual(result.channels, {
"chan-b": { starred: true, updatedAt: 42 },
});
});
test("mergeStores: empty remote returns local entries", () => {
const local = {
version: 1,
channels: { "chan-a": { starred: false, updatedAt: 10 } },
};
const remote = { version: 1, channels: {} };
const result = mergeStores(local, remote);
assert.deepEqual(result.channels, {
"chan-a": { starred: false, updatedAt: 10 },
});
});
test("mergeStores: both empty returns empty", () => {
const result = mergeStores(
{ version: 1, channels: {} },
{ version: 1, channels: {} },
);
assert.deepEqual(result, { version: 1, channels: {} });
});
// ── starredChannelIdsFromStore ────────────────────────────────────────────────
test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => {
const store = {
version: 1,
channels: {
"chan-a": { starred: true, updatedAt: 100 },
"chan-b": { starred: true, updatedAt: 200 },
"chan-c": { starred: false, updatedAt: 300 },
},
};
const result = starredChannelIdsFromStore(store);
assert.equal(result.has("chan-a"), true);
assert.equal(result.has("chan-b"), true);
assert.equal(result.has("chan-c"), false);
assert.equal(result.size, 2);
});
test("starredChannelIdsFromStore: excludes IDs where starred=false", () => {
const store = {
version: 1,
channels: {
"chan-x": { starred: false, updatedAt: 1 },
"chan-y": { starred: false, updatedAt: 2 },
},
};
const result = starredChannelIdsFromStore(store);
assert.equal(result.size, 0);
});
test("starredChannelIdsFromStore: empty channels returns empty set", () => {
const result = starredChannelIdsFromStore({ version: 1, channels: {} });
assert.equal(result.size, 0);
});
@@ -0,0 +1,108 @@
const STORAGE_KEY_PREFIX = "sprout-channel-stars.v1";
export type ChannelStarEntry = {
starred: boolean;
updatedAt: number;
};
export type ChannelStarStore = {
version: 1;
channels: Record<string, ChannelStarEntry>;
};
export const DEFAULT_STORE: ChannelStarStore = Object.freeze({
version: 1,
channels: {},
});
export function storageKey(pubkey: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
}
export function parseStarPayload(json: unknown): ChannelStarStore | null {
if (typeof json !== "object" || json === null) return null;
const obj = json as Record<string, unknown>;
if (obj.version !== 1) return null;
const channels: Record<string, ChannelStarEntry> =
typeof obj.channels === "object" &&
obj.channels !== null &&
!Array.isArray(obj.channels)
? Object.fromEntries(
Object.entries(obj.channels as Record<string, unknown>).filter(
(entry): entry is [string, ChannelStarEntry] => {
const v = entry[1];
return (
typeof v === "object" &&
v !== null &&
typeof (v as Record<string, unknown>).starred === "boolean" &&
typeof (v as Record<string, unknown>).updatedAt === "number" &&
Number.isFinite(
(v as Record<string, unknown>).updatedAt as number,
) &&
((v as Record<string, unknown>).updatedAt as number) >= 0
);
},
),
)
: {};
return { version: 1, channels };
}
export function readChannelStarsStore(pubkey: string): ChannelStarStore {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) {
return DEFAULT_STORE;
}
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
return DEFAULT_STORE;
}
return parseStarPayload(parsed) ?? DEFAULT_STORE;
} catch {
return DEFAULT_STORE;
}
}
export function writeChannelStarsStore(
pubkey: string,
store: ChannelStarStore,
): boolean {
try {
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
return true;
} catch {
return false;
}
}
export function mergeStores(
local: ChannelStarStore,
remote: ChannelStarStore,
): ChannelStarStore {
const allIds = new Set([
...Object.keys(local.channels),
...Object.keys(remote.channels),
]);
const merged: Record<string, ChannelStarEntry> = {};
for (const id of allIds) {
const l = local.channels[id];
const r = remote.channels[id];
if (l && r) {
merged[id] = l.updatedAt >= r.updatedAt ? l : r;
} else {
merged[id] = (l ?? r) as ChannelStarEntry;
}
}
return { version: 1, channels: merged };
}
export function starredChannelIdsFromStore(
store: ChannelStarStore,
): Set<string> {
return new Set(
Object.entries(store.channels)
.filter(([, entry]) => entry.starred)
.map(([id]) => id),
);
}
@@ -0,0 +1,208 @@
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_CHANNEL_STARS } from "@/shared/constants/kinds";
import {
mergeStores,
parseStarPayload,
type ChannelStarStore,
} from "./channelStarsStorage";
const D_TAG = "channel-stars";
const DEBOUNCE_MS = 2_000;
export type RemoteStars = {
store: ChannelStarStore;
createdAt: number;
eventId: string;
};
async function decryptAndParse(event: RelayEvent): Promise<RemoteStars | null> {
try {
const plaintext = await nip44DecryptFromSelf(event.content);
const store = parseStarPayload(JSON.parse(plaintext));
if (!store) return null;
return { store, createdAt: event.created_at, eventId: event.id };
} catch {
return null;
}
}
export class ChannelStarSyncManager {
private pubkey: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt = 0;
private pendingStore: ChannelStarStore | null = null;
private lastPublishedStore: ChannelStarStore | null = null;
constructor(pubkey: string) {
this.pubkey = pubkey;
}
async fetchRemoteStars(): Promise<RemoteStars | null> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_STARS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return null;
if (events[0].pubkey !== this.pubkey) return null;
const result = await decryptAndParse(events[0]);
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
}
return result;
} catch {
return null;
}
}
cancelPendingStarPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
getPendingStarStore(): ChannelStarStore | null {
return this.pendingStore;
}
publishStars(store: ChannelStarStore): void {
this.pendingStore = store;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.doPublish(store);
}, DEBOUNCE_MS);
}
private async fetchOwnBlobBeforePublish(
store: ChannelStarStore,
): Promise<ChannelStarStore> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_STARS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const remote = await decryptAndParse(events[0]);
if (!remote) return store;
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
remote.createdAt,
);
return mergeStores(store, remote.store);
} catch {
return store;
}
}
private isIdenticalToLastPublished(store: ChannelStarStore): boolean {
if (!this.lastPublishedStore) return false;
const lastKeys = Object.keys(this.lastPublishedStore.channels);
const currentKeys = Object.keys(store.channels);
if (lastKeys.length !== currentKeys.length) return false;
for (const key of currentKeys) {
const last = this.lastPublishedStore.channels[key];
const current = store.channels[key];
if (
!last ||
last.starred !== current.starred ||
last.updatedAt !== current.updatedAt
)
return false;
}
return true;
}
private async doPublish(store: ChannelStarStore): Promise<void> {
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
if (this.isIdenticalToLastPublished(merged)) {
this.pendingStore = null;
return;
}
const payload = {
version: 1,
channels: merged.channels,
};
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
const createdAt = Math.max(
Math.floor(Date.now() / 1_000),
this.lastRemoteCreatedAt + 1,
);
const event = await signRelayEvent({
kind: KIND_CHANNEL_STARS,
content: ciphertext,
createdAt,
tags: [
["d", D_TAG],
["t", D_TAG], // relay discoverability; not used in our filters
],
});
await relayClient.publishEvent(
event,
"Timed out publishing channel stars.",
"Failed to publish channel stars.",
);
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
event.created_at,
);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
console.warn("[channelStarsSync] publish failed:", error);
}
}
async subscribeToStars(
onUpdate: (remote: RemoteStars) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_CHANNEL_STARS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 0,
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
void decryptAndParse(event).then((result) => {
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
onUpdate(result);
}
});
},
);
}
destroy(): void {
if (this.debounceTimer !== null && this.pendingStore !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
void this.doPublish(this.pendingStore);
} else if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
}
@@ -0,0 +1,190 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
DEFAULT_STORE,
mergeStores,
readChannelStarsStore,
starredChannelIdsFromStore,
storageKey,
writeChannelStarsStore,
type ChannelStarEntry,
type ChannelStarStore,
} from "./channelStarsStorage";
import { ChannelStarSyncManager } from "./channelStarsSync";
import type { RemoteStars } from "./channelStarsSync";
export function useChannelStars(pubkey: string | undefined): {
starredChannelIds: Set<string>;
starChannel: (channelId: string) => void;
unstarChannel: (channelId: string) => void;
} {
const [store, setStore] = React.useState<ChannelStarStore>(() => {
if (!pubkey) {
return DEFAULT_STORE;
}
return readChannelStarsStore(pubkey);
});
const managerRef = React.useRef<ChannelStarSyncManager | null>(null);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
return;
}
setStore(readChannelStarsStore(pubkey));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelStarSyncManager(pubkey);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey]);
React.useEffect(() => {
if (!pubkey) {
return;
}
const key = storageKey(pubkey);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
return;
}
setStore(readChannelStarsStore(pubkey));
};
window.addEventListener("storage", handler);
return () => {
window.removeEventListener("storage", handler);
};
}, [pubkey]);
const applyRemote = React.useCallback(
(remote: RemoteStars): ((prev: ChannelStarStore) => ChannelStarStore) => {
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingStarPublish();
const merged = mergeStores(prev, remote.store);
if (!writeChannelStarsStore(pubkey, merged)) return prev;
return merged;
};
},
[pubkey],
);
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
void managerRef.current?.fetchRemoteStars().then((remote) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
} else {
const local = readChannelStarsStore(pubkey);
if (Object.keys(local.channels).length > 0) {
managerRef.current?.publishStars(local);
}
}
});
return () => {
cancelled = true;
};
}, [pubkey, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
let cancelled = false;
void managerRef.current
?.subscribeToStars((remote) => {
if (cancelled) return;
setStore(applyRemote(remote));
})
.then((dispose) => {
if (cancelled) {
void dispose();
} else {
unsub = dispose;
}
});
return () => {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteStars().then((remote) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
}
const pending = managerRef.current?.getPendingStarStore();
if (pending) {
managerRef.current?.publishStars(pending);
}
});
});
return () => {
cancelled = true;
unsub();
};
}, [pubkey, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes)
const starredChannelIds = React.useMemo(
() => starredChannelIdsFromStore(store),
[store.channels],
);
const setStarState = React.useCallback(
(channelId: string, starred: boolean) => {
if (!pubkey) return;
const entry: ChannelStarEntry = {
starred,
updatedAt: Math.floor(Date.now() / 1000),
};
setStore((prev) => {
const next: ChannelStarStore = {
version: 1,
channels: { ...prev.channels, [channelId]: entry },
};
if (!writeChannelStarsStore(pubkey, next)) return prev;
managerRef.current?.publishStars(next);
return next;
});
},
[pubkey],
);
const starChannel = React.useCallback(
(channelId: string) => setStarState(channelId, true),
[setStarState],
);
const unstarChannel = React.useCallback(
(channelId: string) => setStarState(channelId, false),
[setStarState],
);
return {
starredChannelIds,
starChannel,
unstarChannel,
};
}
+58 -2
View File
@@ -62,7 +62,11 @@ import {
SidebarMenuSkeleton,
} from "@/shared/ui/sidebar";
type CollapsibleSidebarGroup = "channels" | "forums" | "directMessages";
type CollapsibleSidebarGroup =
| "starred"
| "channels"
| "forums"
| "directMessages";
// ---------------------------------------------------------------------------
// Types
@@ -149,6 +153,9 @@ type AppSidebarProps = {
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
};
// ---------------------------------------------------------------------------
@@ -207,6 +214,9 @@ export function AppSidebar({
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
starredChannelIds,
onStarChannel,
onUnstarChannel,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
@@ -230,6 +240,7 @@ export function AppSidebar({
const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean>
>({
starred: false,
channels: false,
forums: false,
directMessages: false,
@@ -293,6 +304,7 @@ export function AppSidebar({
const sectionIds = new Set(channelSections.map((s) => s.id));
for (const channel of streamChannels) {
if (starredChannelIds?.has(channel.id)) continue;
const sectionId = channelAssignments[channel.id];
if (sectionId && sectionIds.has(sectionId)) {
if (!bySection[sectionId]) {
@@ -304,7 +316,14 @@ export function AppSidebar({
}
}
return { bySection, unassigned };
}, [streamChannels, channelSections, channelAssignments]);
}, [streamChannels, channelSections, channelAssignments, starredChannelIds]);
const starredChannels = React.useMemo(() => {
if (!starredChannelIds || starredChannelIds.size === 0) return [];
return streamChannels.filter((channel) =>
starredChannelIds.has(channel.id),
);
}, [streamChannels, starredChannelIds]);
const handleCreateSectionForChannel = React.useCallback(
(channelId: string) => {
@@ -511,6 +530,37 @@ export function AppSidebar({
{!isLoading ? (
<>
{starredChannels.length > 0 ? (
<ChannelGroupSection
browseAriaLabel="Starred channels"
createAriaLabel="Starred channels"
hasUnread={starredChannels.some((c) =>
unreadChannelIds.has(c.id),
)}
isCollapsed={collapsedGroups.starred}
isActiveChannel={selectedView === "channel"}
items={starredChannels}
listTestId="starred-list"
onMarkAllRead={() => {
for (const channel of starredChannels) {
onMarkChannelRead(channel.id, channel.lastMessageAt);
}
}}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
selectedChannelId={selectedChannelId}
title="Starred"
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
/>
) : null}
<SidebarDndContext
channels={channels}
sections={channelSections}
@@ -558,6 +608,9 @@ export function AppSidebar({
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
/>
))}
<ChannelGroupSection
@@ -591,6 +644,9 @@ export function AppSidebar({
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
/>
</SidebarDndContext>
<ChannelGroupSection
@@ -12,6 +12,8 @@ import {
Pencil,
Plus,
Search,
Star,
StarOff,
Trash2,
} from "lucide-react";
@@ -117,12 +119,15 @@ export function ChannelContextMenuItems({
channel,
hasUnread,
isMuted,
isStarred,
sections,
assignments,
onMarkChannelRead,
onMarkChannelUnread,
onMuteChannel,
onUnmuteChannel,
onStarChannel,
onUnstarChannel,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
@@ -130,6 +135,7 @@ export function ChannelContextMenuItems({
channel: Channel;
hasUnread: boolean;
isMuted?: boolean;
isStarred?: boolean;
sections?: ChannelSection[];
assignments?: Record<string, string>;
onMarkChannelRead?: (
@@ -142,12 +148,32 @@ export function ChannelContextMenuItems({
) => void;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
}) {
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
: Boolean(onMarkChannelUnread);
return (
<>
{showStar ? (
isStarred ? (
<ContextMenuItem onClick={() => onUnstarChannel?.(channel.id)}>
<StarOff className="h-4 w-4" />
Unstar channel
</ContextMenuItem>
) : (
<ContextMenuItem onClick={() => onStarChannel?.(channel.id)}>
<Star className="h-4 w-4" />
Star channel
</ContextMenuItem>
)
) : null}
{showStar && showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
<ContextMenuItem
onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
@@ -219,8 +245,8 @@ function SectionHeaderActions({
className?: string;
createAriaLabel: string;
hasUnread?: boolean;
onBrowse: () => void;
onCreateClick: () => void;
onBrowse?: () => void;
onCreateClick?: () => void;
onMarkAllRead?: () => void;
}) {
return (
@@ -241,23 +267,27 @@ function SectionHeaderActions({
<CheckCheck className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
data-testid={browseTestId}
onClick={onBrowse}
type="button"
>
<Search className="h-3.5 w-3.5" />
</button>
<button
aria-label={createAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
onClick={onCreateClick}
type="button"
>
<Plus className="h-4 w-4" />
</button>
{onBrowse ? (
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
data-testid={browseTestId}
onClick={onBrowse}
type="button"
>
<Search className="h-3.5 w-3.5" />
</button>
) : null}
{onCreateClick ? (
<button
aria-label={createAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
onClick={onCreateClick}
type="button"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
);
}
@@ -295,6 +325,9 @@ export function ChannelGroupSection({
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
starredChannelIds,
onStarChannel,
onUnstarChannel,
}: {
browseAriaLabel: string;
browseTestId?: string;
@@ -305,8 +338,8 @@ export function ChannelGroupSection({
isActiveChannel: boolean;
items: Channel[];
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onBrowse?: () => void;
onCreateClick?: () => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
@@ -330,6 +363,9 @@ export function ChannelGroupSection({
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
}) {
const contentId = `sidebar-${listTestId}`;
@@ -370,12 +406,15 @@ export function ChannelGroupSection({
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isMuted={mutedChannelIds?.has(channel.id)}
isStarred={starredChannelIds?.has(channel.id)}
sections={sections}
assignments={assignments}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
@@ -462,6 +501,9 @@ export function CustomChannelSection({
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
starredChannelIds,
onStarChannel,
onUnstarChannel,
}: {
section: ChannelSection;
channels: Channel[];
@@ -495,6 +537,9 @@ export function CustomChannelSection({
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
}) {
const contentId = `sidebar-section-${section.id}`;
@@ -629,12 +674,15 @@ export function CustomChannelSection({
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isMuted={mutedChannelIds?.has(channel.id)}
isStarred={starredChannelIds?.has(channel.id)}
sections={sections}
assignments={assignments}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={
+2 -1
View File
@@ -17,10 +17,11 @@ export const KIND_FORUM_COMMENT = 45003;
export const KIND_APPROVAL_REQUEST = 46010;
export const KIND_TYPING_INDICATOR = 20002;
// NIP-78 application-specific data. All use kind 30078; the relay
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes").
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes", "channel-stars").
export const KIND_READ_STATE = 30078;
export const KIND_CHANNEL_SECTIONS = 30078;
export const KIND_CHANNEL_MUTES = 30078;
export const KIND_CHANNEL_STARS = 30078;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_MESH_STATUS_REPORT = 24620;
@@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const MOCK_PUBKEY = "deadbeef".repeat(8);
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const STAR_STORAGE_KEY = `sprout-channel-stars.v1:${MOCK_PUBKEY}`;
const SHOTS = "test-results/channel-star";
function seedStarState(
page: import("@playwright/test").Page,
channelId: string,
) {
return page.addInitScript(
({ key, id }) => {
localStorage.setItem(
key,
JSON.stringify({
version: 1,
channels: {
[id]: { starred: true, updatedAt: 1700000000 },
},
}),
);
},
{ key: STAR_STORAGE_KEY, id: channelId },
);
}
test.describe("channel starring screenshots", () => {
test("01 — context menu shows Star channel", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-engineering").click({ button: "right" });
const starItem = page.getByRole("menuitem", { name: "Star channel" });
await expect(starItem).toBeVisible();
await starItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
),
);
await page.screenshot({
path: `${SHOTS}/01-context-menu-star.png`,
clip: { x: 0, y: 0, width: 450, height: 720 },
});
});
test("02 — starred channel appears in Starred section", async ({ page }) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const starredList = page.getByTestId("starred-list");
await expect(starredList).toBeVisible();
await expect(starredList.getByTestId("channel-engineering")).toBeVisible();
await page.screenshot({
path: `${SHOTS}/02-starred-section.png`,
clip: { x: 0, y: 0, width: 256, height: 720 },
});
});
test("03 — context menu shows Unstar channel when starred", async ({
page,
}) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page
.getByTestId("starred-list")
.getByTestId("channel-engineering")
.click({ button: "right" });
const unstarItem = page.getByRole("menuitem", { name: "Unstar channel" });
await expect(unstarItem).toBeVisible();
await unstarItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
),
);
await page.screenshot({
path: `${SHOTS}/03-context-menu-unstar.png`,
clip: { x: 0, y: 0, width: 450, height: 720 },
});
});
test("04 — starred channel is removed from the Channels group", async ({
page,
}) => {
await seedStarState(page, ENGINEERING_CHANNEL_ID);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Exclusive behavior (Slack-style): the starred channel lives only in the
// Starred section and no longer appears in the default Channels group.
await expect(
page.getByTestId("starred-list").getByTestId("channel-engineering"),
).toBeVisible();
await expect(
page.getByTestId("stream-list").getByTestId("channel-engineering"),
).toHaveCount(0);
});
});