mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
feat(desktop): add smart channel sections
Signed-off-by: Andrew Kuttig <akuttig@squareup.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export default defineConfig({
|
||||
"**/custom-emoji-ui.spec.ts",
|
||||
"**/channel-mute.spec.ts",
|
||||
"**/channel-star.spec.ts",
|
||||
"**/smart-channel-sections.spec.ts",
|
||||
"**/channel-controls.spec.ts",
|
||||
"**/channel-activity-popover.spec.ts",
|
||||
"**/active-turn-resilience.spec.ts",
|
||||
|
||||
@@ -388,3 +388,57 @@ test("parseChannelSectionPayload: omits icon field when empty or whitespace", ()
|
||||
{ id: "s3", name: "C", order: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: preserves Smart Section rules and manual overrides", () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
id: "smart",
|
||||
name: "Engineering",
|
||||
order: 0,
|
||||
smartRule: {
|
||||
pattern: "^eng-",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
assignments: {},
|
||||
manuallyUnassignedChannelIds: ["channel-1", "channel-1", 42],
|
||||
};
|
||||
assert.deepEqual(parseChannelSectionPayload(payload), {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
id: "smart",
|
||||
name: "Engineering",
|
||||
order: 0,
|
||||
smartRule: {
|
||||
pattern: "^eng-",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
assignments: {},
|
||||
manuallyUnassignedChannelIds: ["channel-1"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: ignores malformed Smart Section rules", () => {
|
||||
const result = parseChannelSectionPayload({
|
||||
sections: [
|
||||
{
|
||||
id: "regular",
|
||||
name: "Regular",
|
||||
order: 0,
|
||||
smartRule: { pattern: "eng", isRegex: "yes", caseSensitive: false },
|
||||
},
|
||||
],
|
||||
assignments: {},
|
||||
});
|
||||
assert.deepEqual(result?.sections, [
|
||||
{ id: "regular", name: "Regular", order: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,29 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
|
||||
const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
|
||||
export const MAX_CHANNEL_SECTIONS = 100;
|
||||
export const MAX_CHANNEL_SECTION_ASSIGNMENTS = 1_000;
|
||||
export const MAX_MANUALLY_UNASSIGNED_CHANNELS = 1_000;
|
||||
export const MAX_SMART_SECTION_PATTERN_LENGTH = 200;
|
||||
|
||||
export type SmartSectionRule = {
|
||||
pattern: string;
|
||||
isRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
};
|
||||
|
||||
export type ChannelSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
order: number;
|
||||
smartRule?: SmartSectionRule;
|
||||
};
|
||||
|
||||
export type ChannelSectionStore = {
|
||||
version: 1;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
/** Channels explicitly moved to the default Channels group by the user. */
|
||||
manuallyUnassignedChannelIds?: string[];
|
||||
};
|
||||
|
||||
export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
|
||||
@@ -52,13 +63,25 @@ export function boundChannelSectionsStore(
|
||||
.filter(([, sectionId]) => sectionIds.has(sectionId))
|
||||
.slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS),
|
||||
);
|
||||
const manuallyUnassignedChannelIds = Array.from(
|
||||
new Set(store.manuallyUnassignedChannelIds ?? []),
|
||||
).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS);
|
||||
if (
|
||||
sections.length === store.sections.length &&
|
||||
Object.keys(assignments).length === Object.keys(store.assignments).length
|
||||
Object.keys(assignments).length === Object.keys(store.assignments).length &&
|
||||
manuallyUnassignedChannelIds.length ===
|
||||
(store.manuallyUnassignedChannelIds?.length ?? 0)
|
||||
) {
|
||||
return store;
|
||||
}
|
||||
return { ...store, sections, assignments };
|
||||
return {
|
||||
...store,
|
||||
sections,
|
||||
assignments,
|
||||
...(manuallyUnassignedChannelIds.length > 0
|
||||
? { manuallyUnassignedChannelIds }
|
||||
: { manuallyUnassignedChannelIds: undefined }),
|
||||
};
|
||||
}
|
||||
|
||||
export function stripOrphanedAssignments(
|
||||
@@ -95,12 +118,14 @@ export function parseChannelSectionPayload(
|
||||
typeof section.icon === "string" && section.icon.trim().length > 0
|
||||
? section.icon.trim()
|
||||
: undefined;
|
||||
const smartRule = parseSmartSectionRule(section.smartRule);
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
...(icon ? { icon } : {}),
|
||||
order: section.order,
|
||||
...(smartRule ? { smartRule } : {}),
|
||||
},
|
||||
];
|
||||
})
|
||||
@@ -115,7 +140,44 @@ export function parseChannelSectionPayload(
|
||||
),
|
||||
)
|
||||
: {};
|
||||
return stripOrphanedAssignments({ version: 1, sections, assignments });
|
||||
const manuallyUnassignedChannelIds = Array.isArray(
|
||||
obj.manuallyUnassignedChannelIds,
|
||||
)
|
||||
? Array.from(
|
||||
new Set(
|
||||
obj.manuallyUnassignedChannelIds.filter(
|
||||
(channelId): channelId is string => typeof channelId === "string",
|
||||
),
|
||||
),
|
||||
).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS)
|
||||
: [];
|
||||
return stripOrphanedAssignments({
|
||||
version: 1,
|
||||
sections,
|
||||
assignments,
|
||||
...(manuallyUnassignedChannelIds.length > 0
|
||||
? { manuallyUnassignedChannelIds }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseSmartSectionRule(value: unknown): SmartSectionRule | undefined {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
const rule = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof rule.pattern !== "string" ||
|
||||
rule.pattern.length === 0 ||
|
||||
rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH ||
|
||||
typeof rule.isRegex !== "boolean" ||
|
||||
typeof rule.caseSensitive !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pattern: rule.pattern,
|
||||
isRegex: rule.isRegex,
|
||||
caseSensitive: rule.caseSensitive,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRaw(raw: string | null): ChannelSectionStore | null {
|
||||
|
||||
@@ -280,3 +280,46 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes Smart Section rules and manual placement overrides", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", () => Promise.resolve());
|
||||
const fw = makeFakeWindow();
|
||||
const restore = installFakeWindow(fw);
|
||||
const tauri = installTauriMock("");
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-smart", RELAY);
|
||||
manager.publishSections(
|
||||
makeStore({
|
||||
sections: [
|
||||
{
|
||||
id: "smart",
|
||||
name: "Engineering",
|
||||
order: 0,
|
||||
smartRule: {
|
||||
pattern: "^eng-",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
manuallyUnassignedChannelIds: ["channel-1"],
|
||||
}),
|
||||
);
|
||||
fw._fireTimer();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const plaintext = tauri.capturedPlaintext();
|
||||
assert.ok(plaintext, "Smart Section payload should be encrypted");
|
||||
const payload = JSON.parse(plaintext);
|
||||
assert.deepEqual(payload.sections[0].smartRule, {
|
||||
pattern: "^eng-",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
});
|
||||
assert.deepEqual(payload.manuallyUnassignedChannelIds, ["channel-1"]);
|
||||
} finally {
|
||||
tauri.restore();
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,7 +162,10 @@ export class ChannelSectionSyncManager {
|
||||
last.id !== current.id ||
|
||||
last.name !== current.name ||
|
||||
last.icon !== current.icon ||
|
||||
last.order !== current.order
|
||||
last.order !== current.order ||
|
||||
last.smartRule?.pattern !== current.smartRule?.pattern ||
|
||||
last.smartRule?.isRegex !== current.smartRule?.isRegex ||
|
||||
last.smartRule?.caseSensitive !== current.smartRule?.caseSensitive
|
||||
)
|
||||
return false;
|
||||
}
|
||||
@@ -173,6 +176,13 @@ export class ChannelSectionSyncManager {
|
||||
if (this.lastPublishedStore.assignments[key] !== store.assignments[key])
|
||||
return false;
|
||||
}
|
||||
const lastManual =
|
||||
this.lastPublishedStore.manuallyUnassignedChannelIds ?? [];
|
||||
const currentManual = store.manuallyUnassignedChannelIds ?? [];
|
||||
if (lastManual.length !== currentManual.length) return false;
|
||||
for (let i = 0; i < currentManual.length; i++) {
|
||||
if (lastManual[i] !== currentManual[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -191,6 +201,11 @@ export class ChannelSectionSyncManager {
|
||||
version: 1,
|
||||
sections: merged.sections,
|
||||
assignments: merged.assignments,
|
||||
...(merged.manuallyUnassignedChannelIds
|
||||
? {
|
||||
manuallyUnassignedChannelIds: merged.manuallyUnassignedChannelIds,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
|
||||
const createdAt = Math.max(
|
||||
|
||||
148
desktop/src/features/sidebar/lib/smartChannelSections.test.mjs
Normal file
148
desktop/src/features/sidebar/lib/smartChannelSections.test.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyAutomaticSmartSectionAssignments,
|
||||
assignChannelsToSection,
|
||||
channelMatchesSmartRule,
|
||||
getSmartSectionRuleError,
|
||||
matchingChannelsForSection,
|
||||
manuallyUnassignChannel,
|
||||
} from "./smartChannelSections.ts";
|
||||
|
||||
function smartSection(overrides = {}) {
|
||||
return {
|
||||
id: "smart-1",
|
||||
name: "Engineering",
|
||||
order: 0,
|
||||
smartRule: {
|
||||
pattern: "eng-",
|
||||
isRegex: false,
|
||||
caseSensitive: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function store(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
sections: [smartSection()],
|
||||
assignments: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("literal smart rules match channel names by substring", () => {
|
||||
assert.equal(
|
||||
channelMatchesSmartRule("ENG-Platform", {
|
||||
pattern: "eng-",
|
||||
isRegex: false,
|
||||
caseSensitive: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
channelMatchesSmartRule("ENG-Platform", {
|
||||
pattern: "eng-",
|
||||
isRegex: false,
|
||||
caseSensitive: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("regex rules honor case sensitivity and reject invalid expressions", () => {
|
||||
assert.equal(
|
||||
channelMatchesSmartRule("eng-platform", {
|
||||
pattern: "^ENG-(platform|mobile)$",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
channelMatchesSmartRule("eng-platform", {
|
||||
pattern: "^ENG-(platform|mobile)$",
|
||||
isRegex: true,
|
||||
caseSensitive: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSmartSectionRuleError({
|
||||
pattern: "[",
|
||||
isRegex: true,
|
||||
caseSensitive: false,
|
||||
}),
|
||||
"Enter a valid regular expression.",
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic assignment places only channels without a saved location", () => {
|
||||
const original = store({
|
||||
assignments: { manual: "other", already: "smart-1" },
|
||||
manuallyUnassignedChannelIds: ["kept-in-channels"],
|
||||
});
|
||||
const result = applyAutomaticSmartSectionAssignments(original, [
|
||||
{ id: "new-match", name: "eng-infra" },
|
||||
{ id: "manual", name: "eng-manual" },
|
||||
{ id: "already", name: "eng-existing" },
|
||||
{ id: "kept-in-channels", name: "eng-opted-out" },
|
||||
{ id: "not-a-match", name: "design" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.assignments, {
|
||||
manual: "other",
|
||||
already: "smart-1",
|
||||
"new-match": "smart-1",
|
||||
});
|
||||
assert.deepEqual(result.manuallyUnassignedChannelIds, ["kept-in-channels"]);
|
||||
});
|
||||
|
||||
test("the first matching Smart Section by sidebar order wins", () => {
|
||||
const result = applyAutomaticSmartSectionAssignments(
|
||||
store({
|
||||
sections: [
|
||||
smartSection({ id: "second", order: 2 }),
|
||||
smartSection({ id: "first", order: 1 }),
|
||||
],
|
||||
}),
|
||||
[{ id: "channel", name: "eng-platform" }],
|
||||
);
|
||||
assert.equal(result.assignments.channel, "first");
|
||||
});
|
||||
|
||||
test("bulk matching returns matches regardless of current placement", () => {
|
||||
const channels = [
|
||||
{ id: "one", name: "eng-one" },
|
||||
{ id: "two", name: "design" },
|
||||
{ id: "three", name: "ENG-three" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
matchingChannelsForSection(smartSection(), channels).map(
|
||||
(channel) => channel.id,
|
||||
),
|
||||
["one", "three"],
|
||||
);
|
||||
});
|
||||
|
||||
test("manual moves override automatic placement until explicitly moved back", () => {
|
||||
const assigned = store({ assignments: { channel: "smart-1" } });
|
||||
const unassigned = manuallyUnassignChannel(assigned, "channel");
|
||||
assert.deepEqual(unassigned.assignments, {});
|
||||
assert.deepEqual(unassigned.manuallyUnassignedChannelIds, ["channel"]);
|
||||
|
||||
const afterAutomaticScan = applyAutomaticSmartSectionAssignments(unassigned, [
|
||||
{ id: "channel", name: "eng-platform" },
|
||||
]);
|
||||
assert.equal(afterAutomaticScan, unassigned);
|
||||
|
||||
const movedBack = assignChannelsToSection(
|
||||
afterAutomaticScan,
|
||||
["channel"],
|
||||
"smart-1",
|
||||
);
|
||||
assert.equal(movedBack.assignments.channel, "smart-1");
|
||||
assert.equal(movedBack.manuallyUnassignedChannelIds, undefined);
|
||||
});
|
||||
131
desktop/src/features/sidebar/lib/smartChannelSections.ts
Normal file
131
desktop/src/features/sidebar/lib/smartChannelSections.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
ChannelSection,
|
||||
ChannelSectionStore,
|
||||
SmartSectionRule,
|
||||
} from "./channelSectionsStorage";
|
||||
import { MAX_SMART_SECTION_PATTERN_LENGTH } from "./channelSectionsStorage";
|
||||
|
||||
export type SmartSectionChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function getSmartSectionRuleError(
|
||||
rule: SmartSectionRule,
|
||||
): string | null {
|
||||
if (rule.pattern.trim().length === 0) return "Enter a channel name pattern.";
|
||||
if (rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH) {
|
||||
return `Keep the pattern under ${MAX_SMART_SECTION_PATTERN_LENGTH} characters.`;
|
||||
}
|
||||
if (!rule.isRegex) return null;
|
||||
try {
|
||||
new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid regular expression.";
|
||||
}
|
||||
}
|
||||
|
||||
export function channelMatchesSmartRule(
|
||||
channelName: string,
|
||||
rule: SmartSectionRule,
|
||||
): boolean {
|
||||
if (getSmartSectionRuleError(rule)) return false;
|
||||
if (rule.isRegex) {
|
||||
return new RegExp(rule.pattern, rule.caseSensitive ? "" : "i").test(
|
||||
channelName,
|
||||
);
|
||||
}
|
||||
const pattern = rule.caseSensitive
|
||||
? rule.pattern
|
||||
: rule.pattern.toLocaleLowerCase();
|
||||
const candidate = rule.caseSensitive
|
||||
? channelName
|
||||
: channelName.toLocaleLowerCase();
|
||||
return candidate.includes(pattern);
|
||||
}
|
||||
|
||||
export function matchingChannelsForSection<T extends SmartSectionChannel>(
|
||||
section: ChannelSection,
|
||||
channels: readonly T[],
|
||||
): T[] {
|
||||
const rule = section.smartRule;
|
||||
if (!rule) return [];
|
||||
return channels.filter((channel) =>
|
||||
channelMatchesSmartRule(channel.name, rule),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns only channels without a saved placement. Persisting the assignment
|
||||
* makes the rule a one-time arrival decision: later manual moves remain stable.
|
||||
*/
|
||||
export function applyAutomaticSmartSectionAssignments(
|
||||
store: ChannelSectionStore,
|
||||
channels: readonly SmartSectionChannel[],
|
||||
): ChannelSectionStore {
|
||||
const smartSections = store.sections
|
||||
.filter((section) => section.smartRule)
|
||||
.slice()
|
||||
.sort((left, right) => left.order - right.order);
|
||||
if (smartSections.length === 0) return store;
|
||||
|
||||
const manuallyUnassigned = new Set(store.manuallyUnassignedChannelIds ?? []);
|
||||
let assignments: Record<string, string> | null = null;
|
||||
for (const channel of channels) {
|
||||
if (store.assignments[channel.id] || manuallyUnassigned.has(channel.id)) {
|
||||
continue;
|
||||
}
|
||||
const matchingSection = smartSections.find(
|
||||
(section) =>
|
||||
section.smartRule &&
|
||||
channelMatchesSmartRule(channel.name, section.smartRule),
|
||||
);
|
||||
if (!matchingSection) continue;
|
||||
assignments ??= { ...store.assignments };
|
||||
assignments[channel.id] = matchingSection.id;
|
||||
}
|
||||
return assignments ? { ...store, assignments } : store;
|
||||
}
|
||||
|
||||
export function assignChannelsToSection(
|
||||
store: ChannelSectionStore,
|
||||
channelIds: readonly string[],
|
||||
sectionId: string,
|
||||
): ChannelSectionStore {
|
||||
if (channelIds.length === 0) return store;
|
||||
const movedIds = new Set(channelIds);
|
||||
const assignments = { ...store.assignments };
|
||||
for (const channelId of channelIds) {
|
||||
delete assignments[channelId];
|
||||
assignments[channelId] = sectionId;
|
||||
}
|
||||
const manuallyUnassignedChannelIds = (
|
||||
store.manuallyUnassignedChannelIds ?? []
|
||||
).filter((channelId) => !movedIds.has(channelId));
|
||||
return {
|
||||
...store,
|
||||
assignments,
|
||||
...(manuallyUnassignedChannelIds.length > 0
|
||||
? { manuallyUnassignedChannelIds }
|
||||
: { manuallyUnassignedChannelIds: undefined }),
|
||||
};
|
||||
}
|
||||
|
||||
export function manuallyUnassignChannel(
|
||||
store: ChannelSectionStore,
|
||||
channelId: string,
|
||||
): ChannelSectionStore {
|
||||
const assignments = { ...store.assignments };
|
||||
delete assignments[channelId];
|
||||
return {
|
||||
...store,
|
||||
assignments,
|
||||
manuallyUnassignedChannelIds: [
|
||||
...(store.manuallyUnassignedChannelIds ?? []).filter(
|
||||
(existingId) => existingId !== channelId,
|
||||
),
|
||||
channelId,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -21,9 +21,11 @@ after(() => dom.window.close());
|
||||
test("assignChannel refreshes an existing assignment before the next eviction", async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import(
|
||||
"./channelSectionsStorage.ts"
|
||||
);
|
||||
const {
|
||||
MAX_CHANNEL_SECTION_ASSIGNMENTS,
|
||||
readChannelSectionsStore,
|
||||
storageKey,
|
||||
} = await import("./channelSectionsStorage.ts");
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
|
||||
const originalFetchEvents = relayClient.fetchEvents;
|
||||
@@ -68,6 +70,19 @@ test("assignChannel refreshes an existing assignment before the next eviction",
|
||||
Object.keys(result.current.assignments).length,
|
||||
MAX_CHANNEL_SECTION_ASSIGNMENTS,
|
||||
);
|
||||
|
||||
act(() => result.current.unassignChannel("chan-0000"));
|
||||
assert.equal(result.current.assignments["chan-0000"], undefined);
|
||||
assert.deepEqual(
|
||||
readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds,
|
||||
["chan-0000"],
|
||||
);
|
||||
|
||||
act(() => result.current.assignChannel("chan-0000", "section-2"));
|
||||
assert.equal(
|
||||
readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds,
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
|
||||
@@ -11,27 +11,50 @@ import {
|
||||
import { ChannelSectionSyncManager } from "./channelSectionsSync";
|
||||
import type { RemoteSections } from "./channelSectionsSync";
|
||||
import { swapSectionOrder } from "./channelSectionsHelpers";
|
||||
import {
|
||||
applyAutomaticSmartSectionAssignments,
|
||||
assignChannelsToSection,
|
||||
manuallyUnassignChannel,
|
||||
type SmartSectionChannel,
|
||||
} from "./smartChannelSections";
|
||||
|
||||
export type { ChannelSection } from "./channelSectionsStorage";
|
||||
const EMPTY_SMART_SECTION_CHANNELS: readonly SmartSectionChannel[] = [];
|
||||
|
||||
export type {
|
||||
ChannelSection,
|
||||
SmartSectionRule,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
import type {
|
||||
ChannelSection,
|
||||
ChannelSectionStore,
|
||||
SmartSectionRule,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
export function useChannelSections(
|
||||
pubkey: string | undefined,
|
||||
relayUrl?: string,
|
||||
channels: readonly SmartSectionChannel[] = EMPTY_SMART_SECTION_CHANNELS,
|
||||
): {
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
createSection: (name: string, icon?: string) => ChannelSection | null;
|
||||
renameSection: (sectionId: string, newName: string, icon?: string) => void;
|
||||
createSection: (
|
||||
name: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
) => ChannelSection | null;
|
||||
renameSection: (
|
||||
sectionId: string,
|
||||
newName: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
) => void;
|
||||
deleteSection: (sectionId: string) => void;
|
||||
moveSectionUp: (sectionId: string) => void;
|
||||
moveSectionDown: (sectionId: string) => void;
|
||||
reorderSections: (orderedIds: string[]) => void;
|
||||
assignChannel: (channelId: string, sectionId: string) => void;
|
||||
assignChannels: (channelIds: string[], sectionId: string) => void;
|
||||
unassignChannel: (channelId: string) => void;
|
||||
} {
|
||||
const [store, setStore] = React.useState<ChannelSectionStore>(() => {
|
||||
@@ -168,7 +191,11 @@ export function useChannelSections(
|
||||
);
|
||||
|
||||
const createSection = React.useCallback(
|
||||
(name: string, icon?: string): ChannelSection | null => {
|
||||
(
|
||||
name: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
): ChannelSection | null => {
|
||||
if (!pubkey) return null;
|
||||
const prev = readChannelSectionsStore(pubkey, relayUrl);
|
||||
const maxOrder =
|
||||
@@ -180,6 +207,7 @@ export function useChannelSections(
|
||||
name,
|
||||
...(icon ? { icon } : {}),
|
||||
order: maxOrder + 1,
|
||||
...(smartRule ? { smartRule } : {}),
|
||||
};
|
||||
setStore((current) => {
|
||||
const next = boundChannelSectionsStore({
|
||||
@@ -196,7 +224,12 @@ export function useChannelSections(
|
||||
);
|
||||
|
||||
const renameSection = React.useCallback(
|
||||
(sectionId: string, newName: string, icon?: string) => {
|
||||
(
|
||||
sectionId: string,
|
||||
newName: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
@@ -206,10 +239,11 @@ export function useChannelSections(
|
||||
sections: prev.sections.map((s) =>
|
||||
s.id === sectionId
|
||||
? {
|
||||
id: s.id,
|
||||
...s,
|
||||
name: newName,
|
||||
...(icon ? { icon } : {}),
|
||||
order: s.order,
|
||||
...(icon ? {} : { icon: undefined }),
|
||||
...(smartRule ? { smartRule } : {}),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
@@ -302,13 +336,9 @@ export function useChannelSections(
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
assignments[channelId] = sectionId;
|
||||
const next = boundChannelSectionsStore({
|
||||
...prev,
|
||||
assignments,
|
||||
});
|
||||
const next = boundChannelSectionsStore(
|
||||
assignChannelsToSection(prev, [channelId], sectionId),
|
||||
);
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -319,15 +349,30 @@ export function useChannelSections(
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const assignChannels = React.useCallback(
|
||||
(channelIds: string[], sectionId: string) => {
|
||||
if (!pubkey || channelIds.length === 0) return;
|
||||
setStore((prev) => {
|
||||
const next = boundChannelSectionsStore(
|
||||
assignChannelsToSection(prev, channelIds, sectionId),
|
||||
);
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const unassignChannel = React.useCallback(
|
||||
(channelId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
const next: ChannelSectionStore = { ...prev, assignments };
|
||||
const next = boundChannelSectionsStore(
|
||||
manuallyUnassignChannel(prev, channelId),
|
||||
);
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -338,6 +383,26 @@ export function useChannelSections(
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const smartRulesKey = JSON.stringify(
|
||||
store.sections.flatMap((section) =>
|
||||
section.smartRule
|
||||
? [[section.id, section.order, section.smartRule] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey || channels.length === 0 || smartRulesKey === "[]") return;
|
||||
setStore((prev) => {
|
||||
const matched = applyAutomaticSmartSectionAssignments(prev, channels);
|
||||
if (matched === prev) return prev;
|
||||
const next = boundChannelSectionsStore(matched);
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
}, [pubkey, relayUrl, channels, smartRulesKey]);
|
||||
|
||||
return {
|
||||
sections,
|
||||
assignments: store.assignments,
|
||||
@@ -348,6 +413,7 @@ export function useChannelSections(
|
||||
moveSectionDown,
|
||||
reorderSections,
|
||||
assignChannel,
|
||||
assignChannels,
|
||||
unassignChannel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
SectionQuickAction,
|
||||
} from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||
import { useSmartSectionDialogs } from "@/features/sidebar/ui/useSmartSectionDialogs";
|
||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||
import { HuddleProfileControl } from "@/features/huddle";
|
||||
import type {
|
||||
@@ -346,18 +347,12 @@ export function AppSidebar({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
sections: channelSections,
|
||||
assignments: channelAssignments,
|
||||
createSection,
|
||||
renameSection,
|
||||
deleteSection,
|
||||
moveSectionUp,
|
||||
moveSectionDown,
|
||||
reorderSections,
|
||||
assignChannel,
|
||||
unassignChannel,
|
||||
} = useChannelSections(currentPubkey, activeCommunity?.relayUrl);
|
||||
const streamChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "stream"),
|
||||
[channels],
|
||||
);
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
const { sections: channelSections, assignments: channelAssignments, createSection, renameSection, deleteSection, moveSectionUp, moveSectionDown, reorderSections, assignChannel, assignChannels, unassignChannel } = useChannelSections(currentPubkey, activeCommunity?.relayUrl, streamChannels);
|
||||
|
||||
const sectionIds = React.useMemo(
|
||||
() => channelSections.map((s) => s.id),
|
||||
@@ -385,11 +380,8 @@ export function AppSidebar({
|
||||
if (channel.id === selectedChannelId) onSelectHome();
|
||||
});
|
||||
|
||||
const streamChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "stream"),
|
||||
[channels],
|
||||
);
|
||||
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
const smartSectionDialogs = useSmartSectionDialogs({ sections: channelSections, channels: streamChannels, assignments: channelAssignments, createSection, renameSection, assignChannels });
|
||||
const sectionBuckets = React.useMemo(() => {
|
||||
const bySection: Record<string, Channel[]> = {};
|
||||
const unassigned: Channel[] = [];
|
||||
@@ -717,6 +709,10 @@ export function AppSidebar({
|
||||
handleCreateChannelInSection(section.id)
|
||||
}
|
||||
onRenameSection={() => setRenameSectionTarget(section)}
|
||||
onEditSmartSection={smartSectionDialogs.openEditDialog}
|
||||
onFindMatchingChannels={
|
||||
smartSectionDialogs.openMatchesDialog
|
||||
}
|
||||
onDeleteSection={() => setDeleteSectionTarget(section)}
|
||||
onMoveSectionUp={() => moveSectionUp(section.id)}
|
||||
onMoveSectionDown={() => moveSectionDown(section.id)}
|
||||
@@ -760,6 +756,9 @@ export function AppSidebar({
|
||||
onAssignChannel={assignChannel}
|
||||
onUnassignChannel={unassignChannel}
|
||||
onCreateSectionForChannel={handleCreateSectionForChannel}
|
||||
onCreateSmartSection={
|
||||
smartSectionDialogs.openCreateDialog
|
||||
}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={onMuteChannel}
|
||||
onUnmuteChannel={onUnmuteChannel}
|
||||
@@ -952,7 +951,7 @@ export function AppSidebar({
|
||||
}}
|
||||
onConfirm={handleCreateSectionConfirm}
|
||||
/>
|
||||
|
||||
{smartSectionDialogs.dialogs}
|
||||
<RenameSectionDialog
|
||||
open={renameSectionTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EllipsisVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -128,9 +129,11 @@ export function SectionActionsMenu({
|
||||
browseLabel,
|
||||
onCreate,
|
||||
createLabel,
|
||||
onCreateSmartSection,
|
||||
onNewMessage,
|
||||
newMessageLabel,
|
||||
onRenameSection,
|
||||
onRunSmartSection,
|
||||
onMoveSectionUp,
|
||||
onMoveSectionDown,
|
||||
onDeleteSection,
|
||||
@@ -149,9 +152,11 @@ export function SectionActionsMenu({
|
||||
browseLabel?: string;
|
||||
onCreate?: () => void;
|
||||
createLabel?: string;
|
||||
onCreateSmartSection?: () => void;
|
||||
onNewMessage?: () => void;
|
||||
newMessageLabel?: string;
|
||||
onRenameSection?: () => void;
|
||||
onRunSmartSection?: () => void;
|
||||
onMoveSectionUp?: () => void;
|
||||
onMoveSectionDown?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
@@ -213,6 +218,14 @@ export function SectionActionsMenu({
|
||||
<span>{createLabel ?? "Create channel"}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCreateSmartSection ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => deferMenuAction(onCreateSmartSection)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span>New smart section</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showSectionManagement ? (
|
||||
<>
|
||||
{onRenameSection ? (
|
||||
@@ -220,7 +233,17 @@ export function SectionActionsMenu({
|
||||
onSelect={() => deferMenuAction(onRenameSection)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span>Rename section</span>
|
||||
<span>
|
||||
{onRunSmartSection ? "Edit smart section" : "Rename section"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRunSmartSection ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => deferMenuAction(onRunSmartSection)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span>Find matching channels</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMoveSectionUp ? (
|
||||
@@ -345,6 +368,7 @@ export function ChannelGroupSection({
|
||||
listTestId,
|
||||
onBrowseClick,
|
||||
onCreateClick,
|
||||
onCreateSmartSection,
|
||||
onQuickCreateClick,
|
||||
quickCreateLabel,
|
||||
showQuickCreate,
|
||||
@@ -385,6 +409,7 @@ export function ChannelGroupSection({
|
||||
listTestId: string;
|
||||
onBrowseClick?: () => void;
|
||||
onCreateClick?: () => void;
|
||||
onCreateSmartSection?: () => void;
|
||||
/**
|
||||
* Overrides the quick-create (`+`) button's click handler. Defaults to
|
||||
* `onCreateClick`. Used to point the sidebar `+` at the unified
|
||||
@@ -521,6 +546,7 @@ export function ChannelGroupSection({
|
||||
browseLabel={browseLabel}
|
||||
onCreate={onCreateClick}
|
||||
createLabel={createLabel}
|
||||
onCreateSmartSection={onCreateSmartSection}
|
||||
sortMode={sortMode}
|
||||
onSortModeChange={onSortModeChange}
|
||||
/>
|
||||
@@ -566,6 +592,8 @@ export function CustomChannelSection({
|
||||
onCreateSectionForChannel,
|
||||
onCreateChannel,
|
||||
onRenameSection,
|
||||
onEditSmartSection,
|
||||
onFindMatchingChannels,
|
||||
onDeleteSection,
|
||||
onMoveSectionUp,
|
||||
onMoveSectionDown,
|
||||
@@ -606,6 +634,8 @@ export function CustomChannelSection({
|
||||
onCreateSectionForChannel: (channelId: string) => void;
|
||||
onCreateChannel: () => void;
|
||||
onRenameSection: () => void;
|
||||
onEditSmartSection: (section: ChannelSection) => void;
|
||||
onFindMatchingChannels: (section: ChannelSection) => void;
|
||||
onDeleteSection: () => void;
|
||||
onMoveSectionUp: () => void;
|
||||
onMoveSectionDown: () => void;
|
||||
@@ -620,6 +650,12 @@ export function CustomChannelSection({
|
||||
}) {
|
||||
const contentId = `sidebar-section-${section.id}`;
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const onEditSection = section.smartRule
|
||||
? () => onEditSmartSection(section)
|
||||
: onRenameSection;
|
||||
const onRunSmartSection = section.smartRule
|
||||
? () => onFindMatchingChannels(section)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SortableSectionShell sectionId={section.id}>
|
||||
@@ -638,14 +674,16 @@ export function CustomChannelSection({
|
||||
<div className="relative" {...dragHandleProps}>
|
||||
<SidebarGroupLabel
|
||||
asChild
|
||||
className={section.icon ? undefined : "pl-8"}
|
||||
className={
|
||||
section.icon || section.smartRule ? undefined : "pl-8"
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={cn(
|
||||
SECTION_LABEL_BUTTON_CLASS,
|
||||
section.icon && "gap-2",
|
||||
(section.icon || section.smartRule) && "gap-2",
|
||||
)}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
@@ -661,6 +699,15 @@ export function CustomChannelSection({
|
||||
value={section.icon}
|
||||
/>
|
||||
</span>
|
||||
) : section.smartRule ? (
|
||||
<span
|
||||
aria-label="Smart section"
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center text-sidebar-foreground/60"
|
||||
role="img"
|
||||
title="Smart section"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="truncate"
|
||||
@@ -694,7 +741,8 @@ export function CustomChannelSection({
|
||||
onOpenChange={setActionsMenuOpen}
|
||||
hasUnread={hasUnread}
|
||||
onMarkAllRead={onMarkSectionRead}
|
||||
onRenameSection={onRenameSection}
|
||||
onRenameSection={onEditSection}
|
||||
onRunSmartSection={onRunSmartSection}
|
||||
onMoveSectionUp={onMoveSectionUp}
|
||||
onMoveSectionDown={onMoveSectionDown}
|
||||
onDeleteSection={onDeleteSection}
|
||||
@@ -707,10 +755,16 @@ export function CustomChannelSection({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onRenameSection}>
|
||||
<ContextMenuItem onClick={onEditSection}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename section
|
||||
{section.smartRule ? "Edit smart section" : "Rename section"}
|
||||
</ContextMenuItem>
|
||||
{onRunSmartSection ? (
|
||||
<ContextMenuItem onClick={onRunSmartSection}>
|
||||
<Search className="h-4 w-4" />
|
||||
Find matching channels
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem disabled={isFirst} onClick={onMoveSectionUp}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Move up
|
||||
|
||||
326
desktop/src/features/sidebar/ui/SmartSectionDialogs.tsx
Normal file
326
desktop/src/features/sidebar/ui/SmartSectionDialogs.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { CaseSensitive, Hash, Regex, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
ChannelSection,
|
||||
SmartSectionRule,
|
||||
} from "@/features/sidebar/lib/channelSectionsStorage";
|
||||
import {
|
||||
getSmartSectionRuleError,
|
||||
matchingChannelsForSection,
|
||||
} from "@/features/sidebar/lib/smartChannelSections";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Toggle } from "@/shared/ui/toggle";
|
||||
|
||||
export type SmartSectionDialogValue = {
|
||||
name: string;
|
||||
smartRule: SmartSectionRule;
|
||||
};
|
||||
|
||||
export function SmartSectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section?: ChannelSection | null;
|
||||
onConfirm: (value: SmartSectionDialogValue) => void;
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [pattern, setPattern] = React.useState("");
|
||||
const [isRegex, setIsRegex] = React.useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = React.useState(false);
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isEditing = Boolean(section);
|
||||
const smartRule = React.useMemo(
|
||||
() => ({ pattern, isRegex, caseSensitive }),
|
||||
[pattern, isRegex, caseSensitive],
|
||||
);
|
||||
const ruleError = getSmartSectionRuleError(smartRule);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(section?.name ?? "");
|
||||
setPattern(section?.smartRule?.pattern ?? "");
|
||||
setIsRegex(section?.smartRule?.isRegex ?? false);
|
||||
setCaseSensitive(section?.smartRule?.caseSensitive ?? false);
|
||||
const timerId = globalThis.setTimeout(
|
||||
() => nameInputRef.current?.focus(),
|
||||
50,
|
||||
);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [open, section]);
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPattern = pattern.trim();
|
||||
const nextRule = { ...smartRule, pattern: trimmedPattern };
|
||||
if (!trimmedName || getSmartSectionRuleError(nextRule)) return;
|
||||
onConfirm({ name: trimmedName, smartRule: nextRule });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? "Edit smart section" : "Create smart section"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
New matching channels are placed here automatically. Manual moves
|
||||
always take precedence.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="smart-section-name">
|
||||
Section name
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
id="smart-section-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Engineering"
|
||||
ref={nameInputRef}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="smart-section-pattern"
|
||||
>
|
||||
Channel name pattern
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-describedby="smart-section-pattern-help"
|
||||
aria-invalid={Boolean(ruleError)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="pr-20"
|
||||
id="smart-section-pattern"
|
||||
onChange={(event) => setPattern(event.target.value)}
|
||||
placeholder={isRegex ? "^eng-.*" : "eng-"}
|
||||
spellCheck={false}
|
||||
value={pattern}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5">
|
||||
<Toggle
|
||||
aria-label="Use regular expression"
|
||||
className="h-7 min-w-7 rounded-md px-1.5"
|
||||
data-testid="smart-section-regex-toggle"
|
||||
onPressedChange={setIsRegex}
|
||||
pressed={isRegex}
|
||||
size="sm"
|
||||
title="Regular expression"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Regex />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Match case"
|
||||
className="h-7 min-w-7 rounded-md px-1.5"
|
||||
data-testid="smart-section-case-toggle"
|
||||
onPressedChange={setCaseSensitive}
|
||||
pressed={caseSensitive}
|
||||
size="sm"
|
||||
title="Case sensitive"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CaseSensitive />
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
ruleError
|
||||
? "text-xs text-destructive"
|
||||
: "text-xs text-muted-foreground"
|
||||
}
|
||||
id="smart-section-pattern-help"
|
||||
>
|
||||
{ruleError ??
|
||||
(isRegex
|
||||
? "Matches channel names using a regular expression."
|
||||
: "Matches channel names containing this text.")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={!name.trim() || Boolean(ruleError)} type="submit">
|
||||
{isEditing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartSectionMatchesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
sections,
|
||||
channels,
|
||||
assignments,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section: ChannelSection | null;
|
||||
sections: ChannelSection[];
|
||||
channels: Channel[];
|
||||
assignments: Record<string, string>;
|
||||
onConfirm: (channelIds: string[]) => void;
|
||||
}) {
|
||||
const matches = React.useMemo(
|
||||
() => (section ? matchingChannelsForSection(section, channels) : []),
|
||||
[section, channels],
|
||||
);
|
||||
const movableIds = React.useMemo(
|
||||
() =>
|
||||
matches
|
||||
.filter((channel) => assignments[channel.id] !== section?.id)
|
||||
.map((channel) => channel.id),
|
||||
[matches, assignments, section?.id],
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
const sectionNames = React.useMemo(
|
||||
() => new Map(sections.map((candidate) => [candidate.id, candidate.name])),
|
||||
[sections],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setSelectedIds(new Set(movableIds));
|
||||
}, [open, movableIds]);
|
||||
|
||||
function toggleChannel(channelId: string, checked: boolean) {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(channelId);
|
||||
else next.delete(channelId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matching channels</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review every channel matching{" "}
|
||||
{section?.name ?? "this smart section"}
|
||||
{section?.smartRule ? ` (${section.smartRule.pattern})` : ""}.
|
||||
Selected channels will be moved into the section.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{matches.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{matches.length} matching channels</span>
|
||||
{movableIds.length > 0 ? (
|
||||
<button
|
||||
className="font-medium text-foreground hover:underline"
|
||||
onClick={() =>
|
||||
setSelectedIds(
|
||||
selectedIds.size === movableIds.length
|
||||
? new Set()
|
||||
: new Set(movableIds),
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{selectedIds.size === movableIds.length
|
||||
? "Clear selection"
|
||||
: "Select all"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-border p-1">
|
||||
{matches.map((channel) => {
|
||||
const currentSectionId = assignments[channel.id];
|
||||
const alreadyInSection = currentSectionId === section?.id;
|
||||
const location = alreadyInSection
|
||||
? "Already in section"
|
||||
: currentSectionId
|
||||
? (sectionNames.get(currentSectionId) ?? "Another section")
|
||||
: "Channels";
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/70"
|
||||
htmlFor={`smart-section-match-${channel.id}`}
|
||||
key={channel.id}
|
||||
>
|
||||
<Checkbox
|
||||
checked={alreadyInSection || selectedIds.has(channel.id)}
|
||||
disabled={alreadyInSection}
|
||||
id={`smart-section-match-${channel.id}`}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleChannel(channel.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{channel.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{location}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-10 text-center">
|
||||
<Search className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No matching channels</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Edit the smart section to broaden its pattern.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => onConfirm(Array.from(selectedIds))}
|
||||
type="button"
|
||||
>
|
||||
Move {selectedIds.size}{" "}
|
||||
{selectedIds.size === 1 ? "channel" : "channels"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
96
desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx
Normal file
96
desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
ChannelSection,
|
||||
SmartSectionRule,
|
||||
} from "@/features/sidebar/lib/channelSectionsStorage";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
SmartSectionDialog,
|
||||
SmartSectionMatchesDialog,
|
||||
} from "./SmartSectionDialogs";
|
||||
|
||||
export function useSmartSectionDialogs({
|
||||
sections,
|
||||
channels,
|
||||
assignments,
|
||||
createSection,
|
||||
renameSection,
|
||||
assignChannels,
|
||||
}: {
|
||||
sections: ChannelSection[];
|
||||
channels: Channel[];
|
||||
assignments: Record<string, string>;
|
||||
createSection: (
|
||||
name: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
) => ChannelSection | null;
|
||||
renameSection: (
|
||||
sectionId: string,
|
||||
name: string,
|
||||
icon?: string,
|
||||
smartRule?: SmartSectionRule,
|
||||
) => void;
|
||||
assignChannels: (channelIds: string[], sectionId: string) => void;
|
||||
}) {
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [editTarget, setEditTarget] = React.useState<ChannelSection | null>(
|
||||
null,
|
||||
);
|
||||
const [matchesTarget, setMatchesTarget] =
|
||||
React.useState<ChannelSection | null>(null);
|
||||
const openCreateDialog = React.useCallback(() => setIsCreateOpen(true), []);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<SmartSectionDialog
|
||||
onConfirm={(value) => {
|
||||
const created = createSection(value.name, undefined, value.smartRule);
|
||||
if (created) setIsCreateOpen(false);
|
||||
}}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
open={isCreateOpen}
|
||||
/>
|
||||
<SmartSectionDialog
|
||||
onConfirm={(value) => {
|
||||
if (!editTarget) return;
|
||||
renameSection(
|
||||
editTarget.id,
|
||||
value.name,
|
||||
editTarget.icon,
|
||||
value.smartRule,
|
||||
);
|
||||
setEditTarget(null);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditTarget(null);
|
||||
}}
|
||||
open={editTarget !== null}
|
||||
section={editTarget}
|
||||
/>
|
||||
<SmartSectionMatchesDialog
|
||||
assignments={assignments}
|
||||
channels={channels}
|
||||
onConfirm={(channelIds) => {
|
||||
if (!matchesTarget) return;
|
||||
assignChannels(channelIds, matchesTarget.id);
|
||||
setMatchesTarget(null);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setMatchesTarget(null);
|
||||
}}
|
||||
open={matchesTarget !== null}
|
||||
section={matchesTarget}
|
||||
sections={sections}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
dialogs,
|
||||
openCreateDialog,
|
||||
openEditDialog: setEditTarget,
|
||||
openMatchesDialog: setMatchesTarget,
|
||||
};
|
||||
}
|
||||
86
desktop/tests/e2e/smart-channel-sections.spec.ts
Normal file
86
desktop/tests/e2e/smart-channel-sections.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/smart-channel-sections";
|
||||
|
||||
test("creates a smart section and restores a manually removed match", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
const channelsGroup = page
|
||||
.getByTestId("stream-list-section-label")
|
||||
.locator('xpath=ancestor::*[@data-sidebar="group"]');
|
||||
await expect(channelsGroup).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await channelsGroup.screenshot({ path: `${SHOTS}/00-before.png` });
|
||||
|
||||
await page.getByTestId("section-actions-channels").click();
|
||||
await page.getByRole("menuitem", { name: "New smart section" }).click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", {
|
||||
name: "Create smart section",
|
||||
});
|
||||
await expect(createDialog).toBeVisible();
|
||||
await createDialog.getByLabel("Section name").fill("Product & Engineering");
|
||||
await createDialog
|
||||
.getByLabel("Channel name pattern")
|
||||
.fill("^(engineering|agents)$");
|
||||
await createDialog.getByLabel("Use regular expression").click();
|
||||
await createDialog.getByLabel("Match case").click();
|
||||
await expect(
|
||||
createDialog.getByLabel("Use regular expression"),
|
||||
).toHaveAttribute("data-state", "on");
|
||||
await expect(createDialog.getByLabel("Match case")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
await createDialog.screenshot({
|
||||
path: `${SHOTS}/01-create-smart-section.png`,
|
||||
});
|
||||
|
||||
await createDialog.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
const smartSection = page
|
||||
.getByText("Product & Engineering", { exact: true })
|
||||
.locator('xpath=ancestor::*[@data-sidebar="group"]');
|
||||
await expect(smartSection).toBeVisible();
|
||||
await expect(smartSection.getByTestId("channel-agents")).toBeVisible();
|
||||
await expect(smartSection.getByTestId("channel-engineering")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await smartSection.screenshot({ path: `${SHOTS}/02-matched-section.png` });
|
||||
|
||||
await smartSection.getByTestId("channel-engineering").click({
|
||||
button: "right",
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Move to section" }).hover();
|
||||
await page.getByRole("menuitem", { name: "Remove from section" }).click();
|
||||
await expect(smartSection.getByTestId("channel-engineering")).toHaveCount(0);
|
||||
|
||||
const sectionActions = page.locator(
|
||||
'button[aria-label="More actions for Product & Engineering"]',
|
||||
);
|
||||
await sectionActions.click();
|
||||
await page.getByRole("menuitem", { name: "Find matching channels" }).click();
|
||||
|
||||
const matchesDialog = page.getByRole("dialog", {
|
||||
name: "Matching channels",
|
||||
});
|
||||
await expect(matchesDialog).toContainText("2 matching channels");
|
||||
await expect(matchesDialog).toContainText("Already in section");
|
||||
await expect(matchesDialog).toContainText("Channels");
|
||||
await expect(
|
||||
matchesDialog.getByRole("button", { name: "Move 1 channel" }),
|
||||
).toBeEnabled();
|
||||
await waitForAnimations(page);
|
||||
await matchesDialog.screenshot({
|
||||
path: `${SHOTS}/03-review-matches.png`,
|
||||
});
|
||||
|
||||
await matchesDialog.getByRole("button", { name: "Move 1 channel" }).click();
|
||||
await expect(smartSection.getByTestId("channel-engineering")).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user