Files
dumpsterChat/web/src/components/MentionDropdown.tsx
T
hobokenchicken 11b1089126 fix: client perms, @everyone/@channel, docs, unit tests
- usePermissions ORs current user roles + @everyone only (not all server roles)
- cache myRolesByServer; load on active server; refresh after self role edit
- gate/notify @everyone and @channel; plain @username push; special mention UI
- refresh FEATURE_PARITY (DMs exist; drop stale critical gaps)
- README production deploy notes dumpster.service
- unit tests for permission bits and broadcast mention tokens
2026-07-15 20:56:53 -04:00

113 lines
3.6 KiB
TypeScript

import type { Member } from "../stores/member.ts";
export type MentionOption =
| { kind: "special"; id: string; label: string; description: string }
| { kind: "user"; member: Member };
interface MentionDropdownProps {
query: string;
members: Member[];
selectedIndex: number;
onSelect: (username: string) => void;
canMentionEveryone?: boolean;
}
const SPECIALS: { id: string; label: string; description: string }[] = [
{ id: "everyone", label: "everyone", description: "Notify the entire server" },
{ id: "channel", label: "channel", description: "Notify everyone in this channel" },
];
export function buildMentionOptions(
query: string,
members: Member[],
canMentionEveryone: boolean,
): MentionOption[] {
const q = query.toLowerCase();
const options: MentionOption[] = [];
if (canMentionEveryone) {
for (const s of SPECIALS) {
if (!q || s.id.startsWith(q) || s.label.startsWith(q)) {
options.push({ kind: "special", id: s.id, label: s.label, description: s.description });
}
}
}
const users = members
.filter(
(m) =>
m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q),
)
.slice(0, 6)
.map((m): MentionOption => ({ kind: "user", member: m }));
return [...options, ...users].slice(0, 8);
}
export function MentionDropdown({
query,
members,
selectedIndex,
onSelect,
canMentionEveryone = false,
}: MentionDropdownProps) {
const options = buildMentionOptions(query, members, canMentionEveryone);
if (options.length === 0) return null;
return (
<div className="absolute bottom-full left-0 mb-1 z-50 w-72 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
MENTION
</div>
{options.map((opt, i) => {
const active = i === selectedIndex;
if (opt.kind === "special") {
return (
<button
key={opt.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
onSelect(opt.label);
}}
className={`w-full px-2 py-1.5 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
}`}
>
<span className={active ? "text-gb-bg" : "text-gb-orange"}>@</span>
<span className="font-bold">{opt.label}</span>
<span className={`text-xs truncate ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
{opt.description}
</span>
</button>
);
}
const m = opt.member;
return (
<button
key={m.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
onSelect(m.username);
}}
className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
}`}
>
<span className={active ? "text-gb-bg" : "text-gb-green"}></span>
<span className="truncate">{m.display_name || m.username}</span>
{m.display_name && m.display_name !== m.username && (
<span className={`text-xs ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
({m.username})
</span>
)}
</button>
);
})}
</div>
);
}