From e8c50d0b2a98231bb73b4f321fb2e988aeab54f9 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 16 Apr 2026 12:22:02 -0700 Subject: [PATCH] Add neater contact + channels. Closes #197. --- .../settings/SettingsFanoutSection.tsx | 299 ++++++++++++------ 1 file changed, 198 insertions(+), 101 deletions(-) diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 7b02c5d..416217d 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1811,6 +1811,162 @@ function getFilterKeys(filter: unknown): string[] { return []; } +const MAX_SCOPE_PILL_DISPLAY = 32; + +interface PillsSearchListItem { + key: string; + label: string; + /** Optional trailing monospace hint (e.g. pubkey prefix) */ + trailing?: string; +} + +/** + * Search-and-pills picker for the generic fanout scope selector. + * Shows selected items as removable pills (up to MAX_SCOPE_PILL_DISPLAY), + * a search input, and a scrollable list of filtered items with checkboxes. + * When more than MAX_SCOPE_PILL_DISPLAY items are selected, the pill row + * collapses to a single informational badge to keep the interface clean. + */ +function PillsSearchList({ + label, + labelSuffix, + items, + selectedKeys, + onToggle, + onAll, + onNone, + searchPlaceholder, + emptyItemsMessage, +}: { + label: string; + labelSuffix: string; + items: PillsSearchListItem[]; + selectedKeys: string[]; + onToggle: (key: string) => void; + onAll: () => void; + onNone: () => void; + searchPlaceholder: string; + emptyItemsMessage: string; +}) { + const [search, setSearch] = useState(''); + const searchLower = search.toLowerCase().trim(); + + const filtered = useMemo(() => { + const matches = items.filter((it) => { + if (!searchLower) return true; + return ( + it.label.toLowerCase().includes(searchLower) || it.key.toLowerCase().startsWith(searchLower) + ); + }); + // Selected items sort to top (mirrors the Home Assistant tracked-contacts picker) + return matches.sort((a, b) => { + const aSel = selectedKeys.includes(a.key) ? 0 : 1; + const bSel = selectedKeys.includes(b.key) ? 0 : 1; + if (aSel !== bSel) return aSel - bSel; + return a.label.localeCompare(b.label); + }); + }, [items, searchLower, selectedKeys]); + + const selectedDetails = useMemo( + () => items.filter((it) => selectedKeys.includes(it.key)), + [items, selectedKeys] + ); + const overPillLimit = selectedDetails.length > MAX_SCOPE_PILL_DISPLAY; + + return ( +
+
+ + + + / + + +
+ + {selectedDetails.length > 0 && ( +
+ {overPillLimit ? ( + + >{MAX_SCOPE_PILL_DISPLAY} selections made; hiding selection preview to keep the + interface clean + + ) : ( + selectedDetails.map((it) => ( + + {it.label} + + + )) + )} +
+ )} + + {items.length === 0 ? ( +

{emptyItemsMessage}

+ ) : ( + <> + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.length === 0 ? ( +

+ No {label.toLowerCase()} match “{search}” +

+ ) : ( + filtered.map((it) => ( + + )) + )} +
+ + )} +
+ ); +} + function ScopeSelector({ scope, onChange, @@ -1920,9 +2076,6 @@ function ScopeSelector({ selectedContacts.length >= filteredContacts.length); const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled; - const isChannelChecked = (key: string) => selectedChannels.includes(key); - const isContactChecked = (key: string) => selectedContacts.includes(key); - const listHint = mode === 'only' ? 'Newly added channels or contacts will not be automatically included.' @@ -1976,107 +2129,51 @@ function ScopeSelector({

{listHint}

{channels.length > 0 && ( -
-
- - - - / - - -
-
- {channels.map((ch) => ( - - ))} -
-
+ ({ key: ch.key, label: ch.name }))} + selectedKeys={selectedChannels} + onToggle={toggleChannel} + onAll={() => + onChange({ + ...scope, + messages: buildMessages( + channels.map((ch) => ch.key), + selectedContacts + ), + }) + } + onNone={() => onChange({ ...scope, messages: buildMessages([], selectedContacts) })} + searchPlaceholder={`Search ${channels.length} channel${channels.length === 1 ? '' : 's'}...`} + emptyItemsMessage="No channels available." + /> )} {filteredContacts.length > 0 && ( -
-
- - - - / - - -
-
- {filteredContacts.map((c) => ( - - ))} -
-
+ ({ + key: c.public_key, + label: c.name || c.public_key.slice(0, 12), + trailing: c.public_key.slice(0, 12), + }))} + selectedKeys={selectedContacts} + onToggle={toggleContact} + onAll={() => + onChange({ + ...scope, + messages: buildMessages( + selectedChannels, + filteredContacts.map((c) => c.public_key) + ), + }) + } + onNone={() => onChange({ ...scope, messages: buildMessages(selectedChannels, []) })} + searchPlaceholder={`Search ${filteredContacts.length} contact${filteredContacts.length === 1 ? '' : 's'}...`} + emptyItemsMessage="No contacts available." + /> )} )}