mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-15 04:18:18 +00:00
refac
This commit is contained in:
parent
097ecdb69b
commit
e5c80682b9
14 changed files with 524 additions and 96 deletions
|
|
@ -638,6 +638,30 @@ class ChannelTable:
|
|||
await db.commit()
|
||||
return True
|
||||
|
||||
async def update_member_mute_status(
|
||||
self, channel_id: str, user_id: str, is_channel_muted: bool, db: Optional[AsyncSession] = None
|
||||
) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
membership = await db.scalar(select(ChannelMember).filter_by(channel_id=channel_id, user_id=user_id))
|
||||
now = time.time_ns()
|
||||
if membership is None:
|
||||
# Accessible legacy channels may not have a membership yet. Muting is not reading.
|
||||
membership = ChannelMember(
|
||||
id=str(uuid.uuid4()),
|
||||
channel_id=channel_id,
|
||||
user_id=user_id,
|
||||
status='joined',
|
||||
is_active=True,
|
||||
joined_at=now,
|
||||
last_read_at=None,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(membership)
|
||||
membership.is_channel_muted = is_channel_muted
|
||||
membership.updated_at = now
|
||||
await db.commit()
|
||||
return membership.is_channel_muted
|
||||
|
||||
async def update_member_last_read_at(
|
||||
self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> bool:
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ async def check_channels_access(request: Request, user: Optional[UserModel] = No
|
|||
|
||||
|
||||
class ChannelListItemResponse(ChannelModel):
|
||||
is_channel_muted: bool = False
|
||||
user_ids: Optional[list[str]] = None # 'dm' channels only
|
||||
users: Optional[list[UserIdNameStatusResponse]] = None # 'dm' channels only
|
||||
|
||||
|
|
@ -229,6 +230,7 @@ async def get_channels(
|
|||
users=users,
|
||||
last_message_at=last_message_at,
|
||||
unread_count=unread_count,
|
||||
is_channel_muted=channel_member.is_channel_muted if channel_member else False,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -391,6 +393,7 @@ async def create_new_channel(
|
|||
|
||||
|
||||
class ChannelFullResponse(ChannelResponse):
|
||||
is_channel_muted: bool = False
|
||||
user_ids: Optional[list[str]] = None # 'group'/'dm' channels only
|
||||
users: Optional[list[UserIdNameStatusResponse]] = None # 'group'/'dm' channels only
|
||||
|
||||
|
|
@ -444,6 +447,7 @@ async def get_channel_by_id(
|
|||
'write_access': True,
|
||||
'user_count': len(users),
|
||||
'last_read_at': channel_member.last_read_at if channel_member else None,
|
||||
'is_channel_muted': channel_member.is_channel_muted if channel_member else False,
|
||||
'unread_count': unread_count,
|
||||
}
|
||||
)
|
||||
|
|
@ -481,6 +485,7 @@ async def get_channel_by_id(
|
|||
'write_access': write_access or user.role == 'admin',
|
||||
'user_count': user_count,
|
||||
'last_read_at': channel_member.last_read_at if channel_member else None,
|
||||
'is_channel_muted': channel_member.is_channel_muted if channel_member else False,
|
||||
'unread_count': unread_count,
|
||||
}
|
||||
)
|
||||
|
|
@ -605,6 +610,37 @@ class UpdateActiveMemberForm(BaseModel):
|
|||
is_active: bool
|
||||
|
||||
|
||||
class UpdateMutedMemberForm(BaseModel):
|
||||
is_channel_muted: bool
|
||||
|
||||
|
||||
@router.post('/{id}/members/mute', response_model=UpdateMutedMemberForm)
|
||||
async def update_member_mute_status(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: UpdateMutedMemberForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await check_channels_access(request, user)
|
||||
channel = await Channels.get_channel_by_id(id, db=db)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
if channel.type in ['group', 'dm']:
|
||||
if not await Channels.is_user_channel_member(id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
elif user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
is_channel_muted = await Channels.update_member_mute_status(id, user.id, form_data.is_channel_muted, db=db)
|
||||
await emit_to_users(
|
||||
'events:channel',
|
||||
{'channel_id': id, 'data': {'type': 'channel:mute', 'data': {'is_channel_muted': is_channel_muted}}},
|
||||
[user.id],
|
||||
)
|
||||
return {'is_channel_muted': is_channel_muted}
|
||||
|
||||
|
||||
@router.post('/{id}/members/active', response_model=bool)
|
||||
async def update_is_active_member_by_id_and_user_id(
|
||||
request: Request,
|
||||
|
|
@ -1065,11 +1101,15 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
|
|||
users = await get_channel_users_with_access(channel, 'read', db=db)
|
||||
|
||||
# Batch fetch channel members in 1 query (fixes N+1)
|
||||
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
|
||||
members = {m.user_id: m for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
|
||||
mentioned_user_ids = {m['id'] for m in extract_mentions(message.content) if m['id_type'] == 'U'}
|
||||
url = f'{webui_url}/channels/{channel.id}'
|
||||
|
||||
for u in users:
|
||||
if (u.id not in active_user_ids) and u.id in member_ids:
|
||||
member = members.get(u.id)
|
||||
if member and member.is_channel_muted and u.id not in mentioned_user_ids:
|
||||
continue
|
||||
if (u.id not in active_user_ids) and member:
|
||||
if enable_user_webhooks and u.settings:
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -230,6 +230,24 @@ export const updateChannelMemberActiveStatusById = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const updateChannelMemberMuteStatusById = async (
|
||||
token: string,
|
||||
channel_id: string,
|
||||
is_channel_muted: boolean
|
||||
): Promise<{ is_channel_muted: boolean }> => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/members/mute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ is_channel_muted })
|
||||
});
|
||||
if (!res.ok) throw (await res.json()).detail;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
type UpdateMembersForm = {
|
||||
user_ids?: string[];
|
||||
group_ids?: string[];
|
||||
|
|
|
|||
138
src/lib/components/channel/ChannelMenu.svelte
Normal file
138
src/lib/components/channel/ChannelMenu.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { channels } from '$lib/stores';
|
||||
import { updateChannelMemberMuteStatusById } from '$lib/apis/channels';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import AppNotification from '$lib/components/icons/AppNotification.svelte';
|
||||
import BellSlash from '$lib/components/icons/BellSlash.svelte';
|
||||
import Settings from '$lib/components/icons/Settings.svelte';
|
||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||
|
||||
const i18n = getContext<any>('i18n');
|
||||
export let channel: { id: string; type?: string | null; is_channel_muted?: boolean };
|
||||
export let sidebar = false;
|
||||
export let onEdit: (() => void) | undefined = undefined;
|
||||
export let onHide: (() => Promise<void>) | undefined = undefined;
|
||||
let show = false;
|
||||
let saving = false;
|
||||
$: isMuted =
|
||||
$channels.find((item) => item.id === channel.id)?.is_channel_muted ??
|
||||
channel.is_channel_muted ??
|
||||
false;
|
||||
|
||||
const setMuted = async (muted: boolean) => {
|
||||
if (saving) return;
|
||||
if (muted === isMuted) {
|
||||
show = false;
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
const channelId = channel.id;
|
||||
try {
|
||||
const result = await updateChannelMemberMuteStatusById(localStorage.token, channelId, muted);
|
||||
channels.update((items) =>
|
||||
items.map((item) => (item.id === channelId ? { ...item, ...result } : item))
|
||||
);
|
||||
if (channel.id === channelId) channel = { ...channel, ...result };
|
||||
show = false;
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class:sidebar class:open={show} class="channel-menu self-center shrink-0">
|
||||
<Dropdown bind:show align={sidebar ? 'start' : 'end'}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex {sidebar
|
||||
? 'size-4.5'
|
||||
: 'size-7'} items-center justify-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 touch-auto"
|
||||
aria-label={$i18n.t(channel.type === 'dm' ? 'Conversation options' : 'Channel options')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={show}
|
||||
>
|
||||
<EllipsisHorizontal className="size-3.5" />
|
||||
</button>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="w-44 border-gray-200/30! dark:border-gray-700/30!">
|
||||
<div class="px-2 pt-1 pb-0.5 text-[0.6875rem] text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Notifications')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!isMuted}
|
||||
disabled={saving}
|
||||
class="disabled:opacity-50"
|
||||
on:click={() => setMuted(false)}
|
||||
>
|
||||
<AppNotification className="size-3.5 shrink-0" />
|
||||
<span class="flex-1 text-left">{$i18n.t('All messages')}</span>
|
||||
<span class="size-3.5"
|
||||
>{#if !isMuted}<Check className="size-3.5" />{/if}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={isMuted}
|
||||
disabled={saving}
|
||||
class="disabled:opacity-50"
|
||||
on:click={() => setMuted(true)}
|
||||
>
|
||||
<BellSlash className="size-3.5 shrink-0" />
|
||||
<span class="flex-1 text-left">{$i18n.t('Mentions only')}</span>
|
||||
<span class="size-3.5"
|
||||
>{#if isMuted}<Check className="size-3.5" />{/if}</span
|
||||
>
|
||||
</button>
|
||||
{#if onEdit || onHide}<hr class="border-gray-200/30 dark:border-gray-700/30" />{/if}
|
||||
{#if onEdit}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click={() => {
|
||||
onEdit?.();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<Settings className="size-3.5 shrink-0" />
|
||||
<span>{$i18n.t('Edit channel')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onHide}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click={async () => {
|
||||
await onHide?.();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<EyeSlash className="size-3.5 shrink-0" />
|
||||
<span>{$i18n.t('Hide conversation')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.sidebar:not(.open) {
|
||||
opacity: 0;
|
||||
}
|
||||
:global(.group:hover) .sidebar,
|
||||
:global(.group:focus-within) .sidebar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
import Users from '../icons/Users.svelte';
|
||||
import Pin from '../icons/Pin.svelte';
|
||||
import PinnedMessagesModal from './PinnedMessagesModal.svelte';
|
||||
import ChannelMenu from './ChannelMenu.svelte';
|
||||
|
||||
const i18n: any = getContext('i18n');
|
||||
|
||||
|
|
@ -188,6 +189,7 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<ChannelMenu {channel} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
20
src/lib/components/icons/BellSlash.svelte
Normal file
20
src/lib/components/icons/BellSlash.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m3 3 18 18M9 19a3 3 0 0 0 6 0M6 6.5A6 6 0 0 0 6 8c0 7-3 7-3 9h14M10 2.35A6 6 0 0 1 18 8c0 1.6.16 2.84.41 3.83"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -654,6 +654,12 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const socketInstance = $socket;
|
||||
socketInstance?.on('connect', initChannels);
|
||||
return () => socketInstance?.off('connect', initChannels);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const width = Number(localStorage.getItem('sidebarWidth'));
|
||||
|
|
@ -716,11 +722,6 @@
|
|||
}
|
||||
|
||||
const socketInstance = $socket;
|
||||
const scheduleChannelRefresh = () => {
|
||||
socketInstance?.off('connect', initChannels);
|
||||
socketInstance?.once('connect', initChannels);
|
||||
};
|
||||
socketInstance?.on('access:updated', scheduleChannelRefresh);
|
||||
socketInstance?.on('events', chatActiveEventHandler);
|
||||
socketInstance?.on('connect', refreshChatRows);
|
||||
|
||||
|
|
@ -760,8 +761,6 @@
|
|||
dropZone.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
|
||||
socketInstance?.off('access:updated', scheduleChannelRefresh);
|
||||
socketInstance?.off('connect', initChannels);
|
||||
socketInstance?.off('events', chatActiveEventHandler);
|
||||
socketInstance?.off('connect', refreshChatRows);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import ChannelModal from './ChannelModal.svelte';
|
||||
import ChannelMenu from '$lib/components/channel/ChannelMenu.svelte';
|
||||
import BellSlash from '$lib/components/icons/BellSlash.svelte';
|
||||
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
|
||||
import Emoji from '$lib/components/common/Emoji.svelte';
|
||||
import HashtagIcon from '$lib/components/icons/Hashtag.svelte';
|
||||
import LockIcon from '$lib/components/icons/Lock.svelte';
|
||||
import SettingsIcon from '$lib/components/icons/Settings.svelte';
|
||||
import UserIcon from './icons/User.svelte';
|
||||
import XMarkIcon from './icons/XMark.svelte';
|
||||
|
||||
export let onUpdate: Function = () => {};
|
||||
|
||||
|
|
@ -84,7 +84,8 @@
|
|||
class=" w-full {className} rounded-xl flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page
|
||||
.url.pathname === `/channels/${channel.id}`
|
||||
? 'bg-gray-100 dark:bg-gray-900 selected'
|
||||
: ''} {channel?.type === 'dm' ? 'px-1 py-[0.1875rem]' : 'p-1'} {channel?.unread_count > 0
|
||||
: ''} {channel?.type === 'dm' ? 'px-1 py-[0.1875rem]' : 'p-1'} {channel?.unread_count > 0 &&
|
||||
!channel?.is_channel_muted
|
||||
? 'font-normal dark:text-white text-black'
|
||||
: ' dark:text-gray-400 text-gray-600'} cursor-pointer select-none"
|
||||
>
|
||||
|
|
@ -193,7 +194,17 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if channel?.unread_count > 0}
|
||||
{#if channel?.is_channel_muted}
|
||||
<span
|
||||
class="shrink-0 text-gray-400 dark:text-gray-500"
|
||||
title={$i18n.t('Muted. Direct @mentions can still notify you.')}
|
||||
aria-label={$i18n.t('Mentions only')}
|
||||
>
|
||||
<BellSlash className="size-3" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if channel?.unread_count > 0 && !channel?.is_channel_muted}
|
||||
<div
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-md bg-sky-500/10 px-1 text-[0.625rem] font-semibold leading-4 text-sky-600 dark:bg-sky-400/10 dark:text-sky-300"
|
||||
title={$i18n.t('Unread')}
|
||||
|
|
@ -205,44 +216,25 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
{#if ['dm'].includes(channel?.type)}
|
||||
<div class="ml-0.5 mr-1 hover-reveal self-center flex shrink-0 items-center dark:text-gray-300">
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto"
|
||||
on:click={async (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
|
||||
channels.update((chs) =>
|
||||
chs.filter((ch) => {
|
||||
return ch.id !== channel.id;
|
||||
})
|
||||
);
|
||||
|
||||
await updateChannelMemberActiveStatusById(localStorage.token, channel.id, false).catch(
|
||||
(error) => {
|
||||
<div class="ml-0.5 mr-1 self-center shrink-0">
|
||||
<ChannelMenu
|
||||
{channel}
|
||||
sidebar
|
||||
onEdit={channel.type !== 'dm' && ($user?.role === 'admin' || channel.user_id === $user?.id)
|
||||
? () => {
|
||||
showEditChannelModal = true;
|
||||
}
|
||||
: undefined}
|
||||
onHide={channel.type === 'dm'
|
||||
? async () => {
|
||||
try {
|
||||
await updateChannelMemberActiveStatusById(localStorage.token, channel.id, false);
|
||||
channels.update((items) => items.filter((item) => item.id !== channel.id));
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if $user?.role === 'admin' || channel.user_id === $user?.id}
|
||||
<div class="ml-0.5 mr-1 hover-reveal self-center flex shrink-0 items-center dark:text-gray-300">
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto"
|
||||
on:click={(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.stopPropagation();
|
||||
showEditChannelModal = true;
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="size-3.5" strokeWidth="1.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
114
src/lib/components/layout/Sidebar/FolderHoverPreview.svelte
Normal file
114
src/lib/components/layout/Sidebar/FolderHoverPreview.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import { getFolderById, getSharedFolderChats } from '$lib/apis/folders';
|
||||
import { models } from '$lib/stores';
|
||||
import Emoji from '$lib/components/common/Emoji.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import FolderIcon from './icons/Folder.svelte';
|
||||
import EditPencil from './icons/EditPencil.svelte';
|
||||
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
|
||||
import Document from '$lib/components/icons/Document.svelte';
|
||||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||
|
||||
const i18n: any = getContext('i18n');
|
||||
|
||||
export let folder;
|
||||
export let isWritable = false;
|
||||
export let onEdit = () => {};
|
||||
|
||||
let details: { data?: { model_ids?: string[]; files?: unknown[] } } | null = null;
|
||||
let total: number | null = null;
|
||||
let loading = true;
|
||||
|
||||
$: modelNames = (details?.data?.model_ids ?? []).map(
|
||||
(id) => $models.find((model) => model.id === id)?.name ?? id
|
||||
);
|
||||
$: files = details?.data?.files ?? [];
|
||||
|
||||
onMount(async () => {
|
||||
const [loadedFolder, chats] = await Promise.all([
|
||||
getFolderById(localStorage.token, folder.id).catch(() => null),
|
||||
getSharedFolderChats(localStorage.token, folder.id, { page: 1 }).catch(() => null)
|
||||
]);
|
||||
details = loadedFolder;
|
||||
total = chats?.total ?? null;
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<LinkPreview.Portal>
|
||||
<LinkPreview.Content
|
||||
class="z-[9999] hidden w-[20rem] max-w-[calc(100vw-1.5rem)] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-2xl bg-white text-[0.8125rem] font-normal text-gray-900 shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 md:block dark:bg-gray-850 dark:text-gray-100 dark:ring-white/10"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
>
|
||||
<div class="space-y-1.5 px-3 py-2">
|
||||
<div class="flex items-center gap-2 leading-5">
|
||||
<div class="shrink-0 text-gray-500 dark:text-gray-400">
|
||||
{#if folder.meta?.icon}
|
||||
<Emoji className="size-3.5" shortCode={folder.meta.icon} />
|
||||
{:else}
|
||||
<FolderIcon className="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 break-words">{folder.name}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<ChatBubble className="size-3.5 shrink-0" />
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
{:else if total !== null}
|
||||
<span>{total.toLocaleString()} {$i18n.t(total === 1 ? 'Chat' : 'Chats')}</span>
|
||||
{:else}
|
||||
<span>{$i18n.t('Something went wrong :/')}</span>
|
||||
{/if}
|
||||
{#if !folder.shared && (folder.unread_count ?? 0) > 0}
|
||||
<span>·</span>
|
||||
<span>{folder.unread_count.toLocaleString()} {$i18n.t('Unread')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if modelNames.length || files.length || folder.shared}
|
||||
<div class="space-y-1.5 border-t border-gray-50/60 px-3 py-2 dark:border-gray-800/25">
|
||||
{#if folder.shared}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Shared')}{folder.owner_name ? ` · ${folder.owner_name}` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modelNames.length}
|
||||
<div class="flex items-start gap-2">
|
||||
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-gray-500 dark:text-gray-400" />
|
||||
<div class="min-w-0 break-words" title={$i18n.t('Default Model')}>
|
||||
{modelNames.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if files.length}
|
||||
<div class="flex items-center gap-2">
|
||||
<Document className="size-3.5 shrink-0 text-gray-500 dark:text-gray-400" />
|
||||
<span>
|
||||
{$i18n.t('Knowledge')} · {files.length.toLocaleString()}
|
||||
{$i18n.t(files.length === 1 ? 'File' : 'Files')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isWritable}
|
||||
<div class="border-t border-gray-50/60 p-1 dark:border-gray-800/25">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] transition hover:bg-gray-50/60 dark:hover:bg-gray-800/60"
|
||||
on:click={onEdit}
|
||||
>
|
||||
<EditPencil className="size-3.5 text-gray-500 dark:text-gray-400" />
|
||||
{$i18n.t('Edit Folder')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</LinkPreview.Content>
|
||||
</LinkPreview.Portal>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
import MoreHorizontal from './icons/MoreHorizontal.svelte';
|
||||
|
||||
import ChatItem from './ChatItem.svelte';
|
||||
import FolderHoverPreview from './FolderHoverPreview.svelte';
|
||||
import FolderMenu from './Folders/FolderMenu.svelte';
|
||||
import FolderShareModal from './Folders/FolderShareModal.svelte';
|
||||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
|
@ -65,6 +67,7 @@
|
|||
|
||||
let folderElement;
|
||||
|
||||
let openPreview = false;
|
||||
let showFolderModal = false;
|
||||
let showShareModal = false;
|
||||
let edit = false;
|
||||
|
|
@ -77,6 +80,18 @@
|
|||
|
||||
let clickTimer = null;
|
||||
|
||||
$: if (
|
||||
$mobile ||
|
||||
edit ||
|
||||
dragged ||
|
||||
parentDragged ||
|
||||
draggedOver ||
|
||||
showFolderModal ||
|
||||
showShareModal
|
||||
) {
|
||||
openPreview = false;
|
||||
}
|
||||
|
||||
let name = '';
|
||||
|
||||
const formatUnreadCount = (count) =>
|
||||
|
|
@ -812,59 +827,78 @@
|
|||
class="w-full h-full bg-transparent outline-hidden"
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
href={`/folders/${folderId}`}
|
||||
draggable="false"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5"
|
||||
on:dblclick={(e) => {
|
||||
if (!isWritable || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer); // cancel the single-click action
|
||||
clickTimer = null;
|
||||
}
|
||||
renameHandler();
|
||||
}}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
}
|
||||
|
||||
clickTimer = setTimeout(async () => {
|
||||
await openFolderHandler();
|
||||
clickTimer = null;
|
||||
}, 100); // 100ms delay (typical double-click threshold)
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
openFolderHandler();
|
||||
}
|
||||
}}
|
||||
<LinkPreview.Root
|
||||
openDelay={300}
|
||||
closeDelay={0}
|
||||
disabled={$mobile || dragged || parentDragged || draggedOver}
|
||||
bind:open={openPreview}
|
||||
>
|
||||
<div class="min-w-0 truncate">
|
||||
{folders[folderId].name}
|
||||
</div>
|
||||
<LinkPreview.Trigger
|
||||
href={`/folders/${folderId}`}
|
||||
draggable="false"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5"
|
||||
ondblclick={(e) => {
|
||||
if (!isWritable || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer); // cancel the single-click action
|
||||
clickTimer = null;
|
||||
}
|
||||
renameHandler();
|
||||
}}
|
||||
onclick={(e) => {
|
||||
openPreview = false;
|
||||
e.stopPropagation();
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer);
|
||||
clickTimer = null;
|
||||
}
|
||||
|
||||
{#if !folders[folderId]?.shared && (folders[folderId]?.unread_count ?? 0) > 0}
|
||||
<div
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-md bg-sky-500/10 px-1 text-[0.625rem] font-semibold leading-4 text-sky-600 dark:bg-sky-400/10 dark:text-sky-300"
|
||||
title={$i18n.t('Unread')}
|
||||
>
|
||||
{formatUnreadCount(folders[folderId].unread_count)}
|
||||
clickTimer = setTimeout(async () => {
|
||||
await openFolderHandler();
|
||||
clickTimer = null;
|
||||
}, 100); // 100ms delay (typical double-click threshold)
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
openFolderHandler();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0 truncate">
|
||||
{folders[folderId].name}
|
||||
</div>
|
||||
|
||||
{#if !folders[folderId]?.shared && (folders[folderId]?.unread_count ?? 0) > 0}
|
||||
<div
|
||||
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-md bg-sky-500/10 px-1 text-[0.625rem] font-semibold leading-4 text-sky-600 dark:bg-sky-400/10 dark:text-sky-300"
|
||||
title={$i18n.t('Unread')}
|
||||
>
|
||||
{formatUnreadCount(folders[folderId].unread_count)}
|
||||
</div>
|
||||
{/if}
|
||||
</LinkPreview.Trigger>
|
||||
{#if openPreview}
|
||||
<FolderHoverPreview
|
||||
folder={folders[folderId]}
|
||||
{isWritable}
|
||||
onEdit={() => {
|
||||
openPreview = false;
|
||||
showFolderModal = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
</LinkPreview.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isWritable}
|
||||
<button
|
||||
class="absolute z-10 right-2 hover-reveal self-center flex items-center dark:text-gray-300"
|
||||
on:pointerdown={() => (openPreview = false)}
|
||||
>
|
||||
<FolderMenu
|
||||
canShare={$user?.role === 'admin' || !folders[folderId]?.shared}
|
||||
|
|
|
|||
|
|
@ -1613,6 +1613,13 @@
|
|||
"Multi-factor authentication": "",
|
||||
"Multipart Upload": "",
|
||||
"Mute": "",
|
||||
"All messages": "",
|
||||
"Mentions only": "",
|
||||
"Channel options": "",
|
||||
"Conversation options": "",
|
||||
"Edit channel": "",
|
||||
"Hide conversation": "",
|
||||
"Muted. Direct @mentions can still notify you.": "",
|
||||
"Muted": "",
|
||||
"My Terminal": "",
|
||||
"Name": "",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,16 @@ export const TTSWorker = writable(null);
|
|||
export const chatId = writable('');
|
||||
export const chatTitle = writable('');
|
||||
|
||||
export const channels = writable([]);
|
||||
type ChannelListItem = {
|
||||
id: string;
|
||||
type: string | null;
|
||||
is_channel_muted: boolean;
|
||||
unread_count: number;
|
||||
last_message_at: number | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const channels = writable<ChannelListItem[]>([]);
|
||||
export const channelId = writable(null);
|
||||
|
||||
export { chats, pinnedChats } from './chatList';
|
||||
|
|
|
|||
7
src/lib/utils/channelNotifications.ts
Normal file
7
src/lib/utils/channelNotifications.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const shouldNotifyChannelMessage = (
|
||||
isMuted: boolean,
|
||||
content: string,
|
||||
userId: string
|
||||
): boolean =>
|
||||
!isMuted ||
|
||||
Array.from(content.matchAll(/<@U:([^|>]+)(?:\|[^>]+)?>/g)).some((match) => match[1] === userId);
|
||||
|
|
@ -82,7 +82,8 @@
|
|||
import MfaManagement from '$lib/components/auth/MfaManagement.svelte';
|
||||
import { getOutputText } from '$lib/components/chat/Messages/structuredOutput';
|
||||
import { getUserSettings } from '$lib/apis/users';
|
||||
import { getChannels } from '$lib/apis/channels';
|
||||
import { getChannelById, getChannels } from '$lib/apis/channels';
|
||||
import { shouldNotifyChannelMessage } from '$lib/utils/channelNotifications';
|
||||
import { resolveTerminalConnection, terminalRequest } from '$lib/apis/terminal';
|
||||
|
||||
const unregisterServiceWorkers = async () => {
|
||||
|
|
@ -827,6 +828,16 @@
|
|||
|
||||
const channelEventHandler = async (event) => {
|
||||
console.log('channelEventHandler', event);
|
||||
if (event.data?.type === 'channel:mute') {
|
||||
channels.update((items) =>
|
||||
items.map((item) =>
|
||||
item.id === event.channel_id
|
||||
? { ...item, is_channel_muted: event.data.data.is_channel_muted }
|
||||
: item
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'typing') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -901,6 +912,19 @@
|
|||
}
|
||||
|
||||
if (type === 'message') {
|
||||
const preference =
|
||||
$channels?.find((item) => item.id === event.channel_id) ??
|
||||
(await getChannelById(localStorage.token, event.channel_id).catch(() => null));
|
||||
if (
|
||||
!preference ||
|
||||
!shouldNotifyChannelMessage(
|
||||
preference.is_channel_muted ?? false,
|
||||
data?.content ?? '',
|
||||
$user?.id ?? ''
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const title = `${data?.user?.name}${event?.channel?.type !== 'dm' ? ` (#${event?.channel?.name})` : ''}`;
|
||||
const content = data?.content?.replace(
|
||||
/<([@#])([^|>\s]+)(?:\|([^>]*))?>/g,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue