diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py
index e7a9c14730..657ceedde2 100644
--- a/backend/open_webui/models/channels.py
+++ b/backend/open_webui/models/channels.py
@@ -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:
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index 1b00358605..71d45733c5 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -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,
diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts
index 5b85837404..01a036ce8c 100644
--- a/src/lib/apis/channels/index.ts
+++ b/src/lib/apis/channels/index.ts
@@ -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[];
diff --git a/src/lib/components/channel/ChannelMenu.svelte b/src/lib/components/channel/ChannelMenu.svelte
new file mode 100644
index 0000000000..2a02a02202
--- /dev/null
+++ b/src/lib/components/channel/ChannelMenu.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte
index abb70b3845..cb28494a34 100644
--- a/src/lib/components/channel/Navbar.svelte
+++ b/src/lib/components/channel/Navbar.svelte
@@ -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 @@
{/if}
+
{/if}
diff --git a/src/lib/components/icons/BellSlash.svelte b/src/lib/components/icons/BellSlash.svelte
new file mode 100644
index 0000000000..343a22fb21
--- /dev/null
+++ b/src/lib/components/icons/BellSlash.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
index d3058fe5b5..e9b2fccf20 100644
--- a/src/lib/components/layout/Sidebar.svelte
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -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);
diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte
index 582146218c..0b13a5aa1f 100644
--- a/src/lib/components/layout/Sidebar/ChannelItem.svelte
+++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte
@@ -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}
+
+
+
+ {/if}
+
+ {#if channel?.unread_count > 0 && !channel?.is_channel_muted}
- {#if ['dm'].includes(channel?.type)}
-
-
diff --git a/src/lib/components/layout/Sidebar/FolderHoverPreview.svelte b/src/lib/components/layout/Sidebar/FolderHoverPreview.svelte
new file mode 100644
index 0000000000..321c07f001
--- /dev/null
+++ b/src/lib/components/layout/Sidebar/FolderHoverPreview.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+ {#if folder.meta?.icon}
+
+ {:else}
+
+ {/if}
+
+
{folder.name}
+
+
+
+ {#if loading}
+
+ {:else if total !== null}
+ {total.toLocaleString()} {$i18n.t(total === 1 ? 'Chat' : 'Chats')}
+ {:else}
+ {$i18n.t('Something went wrong :/')}
+ {/if}
+ {#if !folder.shared && (folder.unread_count ?? 0) > 0}
+ ·
+ {folder.unread_count.toLocaleString()} {$i18n.t('Unread')}
+ {/if}
+
+
+
+ {#if modelNames.length || files.length || folder.shared}
+
+ {#if folder.shared}
+
+ {$i18n.t('Shared')}{folder.owner_name ? ` · ${folder.owner_name}` : ''}
+
+ {/if}
+ {#if modelNames.length}
+
+
+
+ {modelNames.join(', ')}
+
+
+ {/if}
+ {#if files.length}
+
+
+
+ {$i18n.t('Knowledge')} · {files.length.toLocaleString()}
+ {$i18n.t(files.length === 1 ? 'File' : 'Files')}
+
+
+ {/if}
+
+ {/if}
+
+ {#if isWritable}
+
+
+
+ {$i18n.t('Edit Folder')}
+
+
+ {/if}
+
+
diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
index 867c1b2c8e..a147f1e872 100644
--- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
+++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
@@ -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}
- {
- 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();
- }
- }}
+
-
- {folders[folderId].name}
-
+ {
+ 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}
-
- {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();
+ }
+ }}
+ >
+
+ {folders[folderId].name}
+
+ {#if !folders[folderId]?.shared && (folders[folderId]?.unread_count ?? 0) > 0}
+
+ {formatUnreadCount(folders[folderId].unread_count)}
+
+ {/if}
+
+ {#if openPreview}
+
{
+ openPreview = false;
+ showFolderModal = true;
+ }}
+ />
{/if}
-
+
{/if}
{#if isWritable}
(openPreview = false)}
>
([]);
export const channelId = writable(null);
export { chats, pinnedChats } from './chatList';
diff --git a/src/lib/utils/channelNotifications.ts b/src/lib/utils/channelNotifications.ts
new file mode 100644
index 0000000000..39bb81679e
--- /dev/null
+++ b/src/lib/utils/channelNotifications.ts
@@ -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);
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d482219f1d..aa66ae180f 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -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,