mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user