Add neater contact + channels. Closes #197.

This commit is contained in:
Jack Kingsman
2026-04-16 12:22:02 -07:00
parent 7f3bb89323
commit e8c50d0b2a
@@ -1811,6 +1811,162 @@ function getFilterKeys(filter: unknown): string[] {
return []; 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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">
{label} <span className="text-muted-foreground font-normal">({labelSuffix})</span>
</Label>
<span className="flex gap-1">
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onAll}
>
All
</button>
<span className="text-xs text-muted-foreground">/</span>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onNone}
>
None
</button>
</span>
</div>
{selectedDetails.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{overPillLimit ? (
<span className="inline-flex items-center text-[0.6875rem] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
&gt;{MAX_SCOPE_PILL_DISPLAY} selections made; hiding selection preview to keep the
interface clean
</span>
) : (
selectedDetails.map((it) => (
<span
key={it.key}
className="inline-flex items-center gap-1 text-[0.6875rem] px-2 py-0.5 rounded-full bg-primary/10 text-primary"
>
{it.label}
<button
type="button"
className="ml-0.5 hover:text-destructive transition-colors"
onClick={() => onToggle(it.key)}
aria-label={`Remove ${it.label}`}
>
&times;
</button>
</span>
))
)}
</div>
)}
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">{emptyItemsMessage}</p>
) : (
<>
<Input
type="text"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
/>
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-1">
No {label.toLowerCase()} match &ldquo;{search}&rdquo;
</p>
) : (
filtered.map((it) => (
<label key={it.key} className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={selectedKeys.includes(it.key)}
onChange={() => onToggle(it.key)}
className="h-3.5 w-3.5 rounded border-input accent-primary"
/>
<span className="truncate">{it.label}</span>
{it.trailing && (
<span className="text-[0.625rem] text-muted-foreground ml-auto font-mono shrink-0">
{it.trailing}
</span>
)}
</label>
))
)}
</div>
</>
)}
</div>
);
}
function ScopeSelector({ function ScopeSelector({
scope, scope,
onChange, onChange,
@@ -1920,9 +2076,6 @@ function ScopeSelector({
selectedContacts.length >= filteredContacts.length); selectedContacts.length >= filteredContacts.length);
const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled; const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
const isChannelChecked = (key: string) => selectedChannels.includes(key);
const isContactChecked = (key: string) => selectedContacts.includes(key);
const listHint = const listHint =
mode === 'only' mode === 'only'
? 'Newly added channels or contacts will not be automatically included.' ? 'Newly added channels or contacts will not be automatically included.'
@@ -1976,107 +2129,51 @@ function ScopeSelector({
<p className="text-xs text-muted-foreground">{listHint}</p> <p className="text-xs text-muted-foreground">{listHint}</p>
{channels.length > 0 && ( {channels.length > 0 && (
<div className="space-y-1"> <PillsSearchList
<div className="flex items-center justify-between"> label="Channels"
<Label className="text-xs"> labelSuffix={checkboxLabel}
Channels{' '} items={channels.map((ch) => ({ key: ch.key, label: ch.name }))}
<span className="text-muted-foreground font-normal">({checkboxLabel})</span> selectedKeys={selectedChannels}
</Label> onToggle={toggleChannel}
<span className="flex gap-1"> onAll={() =>
<button onChange({
type="button" ...scope,
className="text-xs text-muted-foreground hover:text-foreground transition-colors" messages: buildMessages(
onClick={() => channels.map((ch) => ch.key),
onChange({ selectedContacts
...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."
} />
>
All
</button>
<span className="text-xs text-muted-foreground">/</span>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
onChange({ ...scope, messages: buildMessages([], selectedContacts) })
}
>
None
</button>
</span>
</div>
<div className="max-h-32 overflow-y-auto border border-input rounded-md p-2 space-y-1">
{channels.map((ch) => (
<label key={ch.key} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isChannelChecked(ch.key)}
onChange={() => toggleChannel(ch.key)}
className="h-3.5 w-3.5 rounded border-input accent-primary"
/>
<span className="text-sm truncate">{ch.name}</span>
</label>
))}
</div>
</div>
)} )}
{filteredContacts.length > 0 && ( {filteredContacts.length > 0 && (
<div className="space-y-1"> <PillsSearchList
<div className="flex items-center justify-between"> label="Contacts"
<Label className="text-xs"> labelSuffix={checkboxLabel}
Contacts{' '} items={filteredContacts.map((c) => ({
<span className="text-muted-foreground font-normal">({checkboxLabel})</span> key: c.public_key,
</Label> label: c.name || c.public_key.slice(0, 12),
<span className="flex gap-1"> trailing: c.public_key.slice(0, 12),
<button }))}
type="button" selectedKeys={selectedContacts}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" onToggle={toggleContact}
onClick={() => onAll={() =>
onChange({ onChange({
...scope, ...scope,
messages: buildMessages( messages: buildMessages(
selectedChannels, selectedChannels,
filteredContacts.map((c) => c.public_key) filteredContacts.map((c) => c.public_key)
), ),
}) })
} }
> onNone={() => onChange({ ...scope, messages: buildMessages(selectedChannels, []) })}
All searchPlaceholder={`Search ${filteredContacts.length} contact${filteredContacts.length === 1 ? '' : 's'}...`}
</button> emptyItemsMessage="No contacts available."
<span className="text-xs text-muted-foreground">/</span> />
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
onChange({ ...scope, messages: buildMessages(selectedChannels, []) })
}
>
None
</button>
</span>
</div>
<div className="max-h-32 overflow-y-auto border border-input rounded-md p-2 space-y-1">
{filteredContacts.map((c) => (
<label key={c.public_key} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isContactChecked(c.public_key)}
onChange={() => toggleContact(c.public_key)}
className="h-3.5 w-3.5 rounded border-input accent-primary"
/>
<span className="text-sm truncate">
{c.name || c.public_key.substring(0, 12) + '...'}
</span>
</label>
))}
</div>
</div>
)} )}
</> </>
)} )}