From e5c80682b9dc9a38274dc81eeb35a3c41be6dfd0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 14 Oct 2026 14:41:26 +0400 Subject: [PATCH] refac --- backend/open_webui/models/channels.py | 24 +++ backend/open_webui/routers/channels.py | 44 +++++- src/lib/apis/channels/index.ts | 18 +++ src/lib/components/channel/ChannelMenu.svelte | 138 ++++++++++++++++++ src/lib/components/channel/Navbar.svelte | 2 + src/lib/components/icons/BellSlash.svelte | 20 +++ src/lib/components/layout/Sidebar.svelte | 13 +- .../layout/Sidebar/ChannelItem.svelte | 76 +++++----- .../layout/Sidebar/FolderHoverPreview.svelte | 114 +++++++++++++++ .../layout/Sidebar/RecursiveFolder.svelte | 120 +++++++++------ src/lib/i18n/locales/en-US/translation.json | 7 + src/lib/stores/index.ts | 11 +- src/lib/utils/channelNotifications.ts | 7 + src/routes/+layout.svelte | 26 +++- 14 files changed, 524 insertions(+), 96 deletions(-) create mode 100644 src/lib/components/channel/ChannelMenu.svelte create mode 100644 src/lib/components/icons/BellSlash.svelte create mode 100644 src/lib/components/layout/Sidebar/FolderHoverPreview.svelte create mode 100644 src/lib/utils/channelNotifications.ts 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 @@ + + +
+ + +
+ +
+ {$i18n.t('Notifications')} +
+ + + {#if onEdit || onHide}
{/if} + {#if onEdit} + + {/if} + {#if onHide} + + {/if} +
+
+
+
+ + 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)} -
- -
- {:else if $user?.role === 'admin' || channel.user_id === $user?.id} -
- -
- {/if} + } + : undefined} + /> +
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 @@ + + + + + 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} -
{#if isWritable}