This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 14:41:26 +04:00
parent 097ecdb69b
commit e5c80682b9
14 changed files with 524 additions and 96 deletions

View file

@ -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:

View file

@ -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,

View file

@ -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[];

View 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>

View file

@ -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>

View 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>

View file

@ -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);

View file

@ -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>

View 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>

View file

@ -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}

View file

@ -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": "",

View file

@ -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';

View 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);

View file

@ -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,