Add AutoComplete for role completion

mentioneverything
Declan Chidlow 2025-06-02 10:47:08 +08:00
parent 9e293e0a30
commit 87dfca26d7
2 changed files with 98 additions and 5 deletions

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/ban-ts-comment */
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Channel, User } from "revolt.js"; import { Channel, User, Role } from "revolt.js";
import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis"; import { Emoji as CustomEmoji } from "revolt.js/esm/maps/Emojis";
import styled, { css } from "styled-components/macro"; import styled, { css } from "styled-components/macro";
@ -29,11 +29,16 @@ export type AutoCompleteState =
type: "channel"; type: "channel";
matches: Channel[]; matches: Channel[];
} }
| {
type: "role";
matches: Role[];
}
)); ));
export type SearchClues = { export type SearchClues = {
users?: { type: "channel"; id: string } | { type: "all" }; users?: { type: "channel"; id: string } | { type: "all" };
channels?: { server: string }; channels?: { server: string };
roles?: { server: string };
}; };
export type AutoCompleteProps = { export type AutoCompleteProps = {
@ -59,7 +64,7 @@ export function useAutoComplete(
function findSearchString( function findSearchString(
el: HTMLTextAreaElement, el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel", string, number] | undefined { ): ["emoji" | "user" | "channel" | "role", string, number] | undefined {
if (el.selectionStart === el.selectionEnd) { if (el.selectionStart === el.selectionEnd) {
const cursor = el.selectionStart; const cursor = el.selectionStart;
const content = el.value.slice(0, cursor); const content = el.value.slice(0, cursor);
@ -71,6 +76,8 @@ export function useAutoComplete(
return ["user", "", j]; return ["user", "", j];
} else if (content[j] === "#") { } else if (content[j] === "#") {
return ["channel", "", j]; return ["channel", "", j];
} else if (content[j] === "%") {
return ["role", "", j];
} }
while (j >= 0 && valid.test(content[j])) { while (j >= 0 && valid.test(content[j])) {
@ -80,7 +87,12 @@ export function useAutoComplete(
if (j === -1) return; if (j === -1) return;
const current = content[j]; const current = content[j];
if (current === ":" || current === "@" || current === "#") { if (
current === ":" ||
current === "@" ||
current === "#" ||
current === "%"
) {
const search = content.slice(j + 1, content.length); const search = content.slice(j + 1, content.length);
const minLen = current === ":" ? 2 : 1; const minLen = current === ":" ? 2 : 1;
@ -90,6 +102,8 @@ export function useAutoComplete(
? "channel" ? "channel"
: current === ":" : current === ":"
? "emoji" ? "emoji"
: current === "%"
? "role"
: "user", : "user",
search.toLowerCase(), search.toLowerCase(),
current === ":" ? j + 1 : j, current === ":" ? j + 1 : j,
@ -230,6 +244,42 @@ export function useAutoComplete(
return; return;
} }
} }
if (type === "role" && searchClues?.roles) {
const server = client.servers.get(searchClues.roles.server);
let roles: (Role & { id: string })[] = [];
if (server?.roles) {
roles = Object.entries(server.roles).map(([id, role]) => ({
...role,
id,
}));
}
const matches = (
search.length > 0
? roles.filter((role) =>
role.name.toLowerCase().match(regex),
)
: roles
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) {
const currentPosition =
state.type !== "none" ? state.selected : 0;
setState({
type: "role",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
} }
if (state.type !== "none") { if (state.type !== "none") {
@ -262,6 +312,14 @@ export function useAutoComplete(
state.matches[state.selected]._id, state.matches[state.selected]._id,
"> ", "> ",
); );
} else if (state.type === "role") {
content.splice(
index,
search.length + 1,
"<%",
state.matches[state.selected].id,
"> ",
);
} else { } else {
content.splice( content.splice(
index, index,
@ -492,7 +550,7 @@ export default function AutoComplete({
{state.type === "user" && {state.type === "user" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@ -517,7 +575,7 @@ export default function AutoComplete({
{state.type === "channel" && {state.type === "channel" &&
state.matches.map((match, i) => ( state.matches.map((match, i) => (
<button <button
key={match} key={match._id}
className={i === state.selected ? "active" : ""} className={i === state.selected ? "active" : ""}
onMouseEnter={() => onMouseEnter={() =>
(i !== state.selected || !state.within) && (i !== state.selected || !state.within) &&
@ -539,6 +597,40 @@ export default function AutoComplete({
{match.name} {match.name}
</button> </button>
))} ))}
{state.type === "role" &&
state.matches.map((match, i) => (
<button
key={match._id}
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<div
style={{
width: "16px",
height: "16px",
borderRadius: "50%",
backgroundColor: match.colour || "#7c7c7c",
marginRight: "8px",
flexShrink: 0,
}}
/>
{match.name}
</button>
))}
</div> </div>
</Base> </Base>
); );

View File

@ -567,6 +567,7 @@ export default observer(({ channel }: Props) => {
channel.channel_type === "TextChannel" channel.channel_type === "TextChannel"
? { server: channel.server_id! } ? { server: channel.server_id! }
: undefined, : undefined,
roles: { server: channel.server_id! },
}); });
return ( return (