ai / frontend /src /lib /components /channel /MessageInput /MentionList.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
7.01 kB
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
const i18n = getContext("i18n");
import { channels, models, user } from "$lib/stores";
import Tooltip from "$lib/components/common/Tooltip.svelte";
import Hashtag from "$lib/components/icons/Hashtag.svelte";
import Lock from "$lib/components/icons/Lock.svelte";
import { REXPRO_API_BASE_URL, REXPRO_BASE_URL } from "$lib/constants";
import { searchUsers } from "$lib/apis/users";
export let query = "";
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
export let label = "";
export let triggerChar = "@";
export let modelSuggestions = false;
export let userSuggestions = false;
export let channelSuggestions = false;
let _models = [];
let _users = [];
let _channels = [];
$: filteredItems = [..._users, ..._models, ..._channels].filter(
(u) =>
u.label.toLowerCase().includes(query.toLowerCase()) ||
u.id.toLowerCase().includes(query.toLowerCase()),
);
const getUserList = async () => {
const res = await searchUsers(localStorage.token, query).catch(
(error) => {
console.error("Error searching users:", error);
return null;
},
);
if (res) {
_users = [
...res.users.map((u) => ({
type: "user",
id: u.id,
label: u.name,
})),
].sort((a, b) => a.label.localeCompare(b.label));
}
};
$: if (query !== null && userSuggestions) {
getUserList();
}
const select = (index: number) => {
const item = filteredItems[index];
if (!item) return;
// Add the "U:", "M:" or "C:" prefix to the id
// and also append the label after a pipe |
// so that the mention renderer can show the label
if (item)
command({
id: `${item.type === "user" ? "U" : item.type === "model" ? "M" : "C"}:${item.id}|${item.label}`,
label: item.label,
});
};
const onKeyDown = (event: KeyboardEvent) => {
if (
!["ArrowUp", "ArrowDown", "Enter", "Tab", "Escape"].includes(
event.key,
)
)
return false;
if (event.key === "ArrowUp") {
selectedIndex = Math.max(0, selectedIndex - 1);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "instant",
});
return true;
}
if (event.key === "ArrowDown") {
selectedIndex = Math.min(
selectedIndex + 1,
filteredItems.length - 1,
);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "instant",
});
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
select(selectedIndex);
if (event.key === "Enter") {
event.preventDefault();
}
return true;
}
if (event.key === "Escape") {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
onMount(() => {
const keydownListener = (e: KeyboardEvent) => {
// required to prevent the default enter behavior
if (e.key === "Enter") {
e.preventDefault();
select(selectedIndex);
}
};
window.addEventListener("keydown", keydownListener);
if (channelSuggestions) {
// Add a dummy channel item
_channels = [
...$channels
.filter((c) => c?.type !== "dm")
.map((c) => ({
type: "channel",
id: c.id,
label: c.name,
data: c,
})),
];
} else {
if (userSuggestions) {
getUserList();
}
if (modelSuggestions) {
_models = [
...$models
.filter((m) => !m?.direct)
.map((m) => ({
type: "model",
id: m.id,
label: m.name,
data: m,
})),
];
}
}
return () => {
window.removeEventListener("keydown", keydownListener);
};
});
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === "user" &&
grant?.principal_id === "*" &&
grant?.permission === "read",
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === "group") {
if (typeof channel?.is_private === "boolean") {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
</script>
{#if filteredItems.length}
<div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
id="suggestions-container"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
{#each filteredItems as item, i}
{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === "user"}
{$i18n.t("Users")}
{:else if item?.type === "model"}
{$i18n.t("Models")}
{:else if item?.type === "channel"}
{$i18n.t("Channels")}
{/if}
</div>
{/if}
<Tooltip content={item?.id} placement="top-start">
<button
type="button"
on:click={() => select(i)}
on:mousemove={() => {
selectedIndex = i;
}}
class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
data-selected={i === selectedIndex}
>
{#if item.type === "channel"}
<div
class=" size-4 justify-center flex items-center mr-0.5"
>
{#if isPublicChannel(item?.data)}
<Hashtag
className="size-3"
strokeWidth="2.5"
/>
{:else}
<Lock
className="size-[15px]"
strokeWidth="2"
/>
{/if}
</div>
{:else if item.type === "model"}
<img
src={`${REXPRO_API_BASE_URL}/models/model/profile/image?id=${item.id}&lang=${$i18n.language}`}
alt={item?.data?.name ?? item.id}
class="rounded-full size-5 items-center mr-2"
on:error={(e) => {
e.currentTarget.src = "/favicon.png";
}}
/>
{:else if item.type === "user"}
<img
src={`${REXPRO_API_BASE_URL}/users/${item.id}/profile/image`}
alt={item?.label ?? item.id}
class="rounded-full size-5 items-center mr-2"
on:error={(e) => {
e.currentTarget.src = "/favicon.png";
}}
/>
{/if}
<div class="truncate flex-1 pr-2">
{item.label}
</div>
<div class="shrink-0 text-xs text-gray-500">
{#if item.type === "user"}
{$i18n.t("User")}
{:else if item.type === "model"}
{$i18n.t("Model")}
{:else if item.type === "channel"}
{$i18n.t("Channel")}
{/if}
</div>
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}