feat/enh: pinned messages in channels

This commit is contained in:
Timothy Jaeryang Baek 2025-11-28 09:58:44 -05:00
parent 451907cc92
commit aae2fce173
7 changed files with 510 additions and 58 deletions

View file

@ -111,6 +111,10 @@ class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None reply_to_message: Optional[MessageUserResponse] = None
class MessageWithReactionsResponse(MessageUserResponse):
reactions: list[Reactions]
class MessageResponse(MessageReplyToResponse): class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int] latest_reply_at: Optional[int]
reply_count: int reply_count: int
@ -306,6 +310,20 @@ class MessageTable:
) )
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None
def get_pinned_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, is_pinned=True)
.order_by(Message.pinned_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id( def update_message_by_id(
self, id: str, form_data: MessageForm self, id: str, form_data: MessageForm
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
@ -325,7 +343,7 @@ class MessageTable:
db.refresh(message) db.refresh(message)
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None
def update_message_pin_by_id( def update_is_pinned_by_id(
self, id: str, is_pinned: bool, pinned_by: Optional[str] = None self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
with get_db() as db: with get_db() as db:
@ -333,7 +351,6 @@ class MessageTable:
message.is_pinned = is_pinned message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_at = int(time.time_ns()) if is_pinned else None
message.pinned_by = pinned_by if is_pinned else None message.pinned_by = pinned_by if is_pinned else None
message.updated_at = int(time.time_ns())
db.commit() db.commit()
db.refresh(message) db.refresh(message)
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None

View file

@ -31,6 +31,7 @@ from open_webui.models.messages import (
Messages, Messages,
MessageModel, MessageModel,
MessageResponse, MessageResponse,
MessageWithReactionsResponse,
MessageForm, MessageForm,
) )
@ -463,6 +464,62 @@ async def get_channel_messages(
return messages return messages
############################
# GetPinnedChannelMessages
############################
PAGE_ITEM_COUNT_PINNED = 20
@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse])
async def get_pinned_channel_messages(
id: str, page: int = 1, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type == "dm":
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
page = max(1, page)
skip = (page - 1) * PAGE_ITEM_COUNT_PINNED
limit = PAGE_ITEM_COUNT_PINNED
message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit)
users = {}
messages = []
for message in message_list:
if message.user_id not in users:
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
messages.append(
MessageWithReactionsResponse(
**{
**message.model_dump(),
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################ ############################
# PostNewMessage # PostNewMessage
############################ ############################
@ -834,6 +891,69 @@ async def get_channel_message(
) )
############################
# PinChannelMessage
############################
class PinMessageForm(BaseModel):
is_pinned: bool
@router.post(
"/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse]
)
async def pin_channel_message(
id: str, message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type == "dm":
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id)
message = Messages.get_message_by_id(message_id)
return MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
),
}
)
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# GetChannelThreadMessages # GetChannelThreadMessages
############################ ############################

View file

@ -299,6 +299,44 @@ export const getChannelMessages = async (
return res; return res;
}; };
export const getChannelPinnedMessages = async (
token: string = '',
channel_id: string,
page: number = 1
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/pinned?page=${page}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChannelThreadMessages = async ( export const getChannelThreadMessages = async (
token: string = '', token: string = '',
channel_id: string, channel_id: string,
@ -379,6 +417,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag
return res; return res;
}; };
export const pinMessage = async (
token: string = '',
channel_id: string,
message_id: string,
is_pinned: boolean
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/pin`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ is_pinned })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateMessage = async ( export const updateMessage = async (
token: string = '', token: string = '',
channel_id: string, channel_id: string,

View file

@ -16,7 +16,13 @@
import Message from './Messages/Message.svelte'; import Message from './Messages/Message.svelte';
import Loader from '../common/Loader.svelte'; import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels'; import {
addReaction,
deleteMessage,
pinMessage,
removeReaction,
updateMessage
} from '$lib/apis/channels';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -155,6 +161,26 @@
onReply={(message) => { onReply={(message) => {
onReply(message); onReply(message);
}} }}
onPin={async (message) => {
messages = messages.map((m) => {
if (m.id === message.id) {
m.is_pinned = !m.is_pinned;
m.pinned_by = !m.is_pinned ? null : $user?.id;
m.pinned_at = !m.is_pinned ? null : Date.now() * 1000000;
}
return m;
});
const updatedMessage = await pinMessage(
localStorage.token,
message.channel_id,
message.id,
message.is_pinned
).catch((error) => {
toast.error(`${error}`);
return null;
});
}}
onThread={(id) => { onThread={(id) => {
onThread(id); onThread(id);
}} }}

View file

@ -36,6 +36,10 @@
import Emoji from '$lib/components/common/Emoji.svelte'; import Emoji from '$lib/components/common/Emoji.svelte';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte'; import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
export let className = '';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
@ -47,6 +51,7 @@
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
export let onEdit: Function = () => {}; export let onEdit: Function = () => {};
export let onReply: Function = () => {}; export let onReply: Function = () => {};
export let onPin: Function = () => {};
export let onThread: Function = () => {}; export let onThread: Function = () => {};
export let onReaction: Function = () => {}; export let onReaction: Function = () => {};
@ -69,13 +74,17 @@
{#if message} {#if message}
<div <div
id="message-{message.id}" id="message-{message.id}"
class="flex flex-col justify-between px-5 {showUserProfile class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {className
? 'pt-1.5 pb-0.5' ? className
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage : `px-5 ${
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) === } ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id $user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4' ? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}" : ''}"
> >
{#if !edit && !disabled} {#if !edit && !disabled}
@ -85,6 +94,7 @@
<div <div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850" class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850"
> >
{#if onReaction}
<EmojiPicker <EmojiPicker
onClose={() => (showButtons = false)} onClose={() => (showButtons = false)}
onSubmit={(name) => { onSubmit={(name) => {
@ -103,7 +113,9 @@
</button> </button>
</Tooltip> </Tooltip>
</EmojiPicker> </EmojiPicker>
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}> <Tooltip content={$i18n.t('Reply')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
@ -114,8 +126,24 @@
<ArrowUpLeftAlt className="size-5" /> <ArrowUpLeftAlt className="size-5" />
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if !thread} <Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onPin(message);
}}
>
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{/if}
</button>
</Tooltip>
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}> <Tooltip content={$i18n.t('Reply in Thread')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -129,6 +157,7 @@
{/if} {/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'} {#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}> <Tooltip content={$i18n.t('Edit')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -140,7 +169,9 @@
<Pencil /> <Pencil />
</button> </button>
</Tooltip> </Tooltip>
{/if}
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}> <Tooltip content={$i18n.t('Delete')}>
<button <button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1" class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@ -150,6 +181,16 @@
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{/if}
</div>
</div>
{/if}
{#if message?.is_pinned}
<div class="flex {showUserProfile ? 'mb-0.5' : 'mt-0.5'}">
<div class="ml-8.5 flex items-center gap-1 px-1 rounded-full text-xs">
<Pin className="size-3 text-yellow-500 dark:text-yellow-300" />
<span class="text-gray-500">{$i18n.t('Pinned')}</span>
</div> </div>
</div> </div>
{/if} {/if}
@ -203,12 +244,13 @@
</button> </button>
</div> </div>
{/if} {/if}
<div <div
class=" flex w-full message-{message.id} " class=" flex w-full message-{message.id} "
id="message-{message.id}" id="message-{message.id}"
dir={$settings.chatDirection} dir={$settings.chatDirection}
> >
<div class={`shrink-0 mr-3 w-9`}> <div class={`shrink-0 mr-1 w-9`}>
{#if showUserProfile} {#if showUserProfile}
{#if message?.meta?.model_id} {#if message?.meta?.model_id}
<img <img
@ -239,7 +281,7 @@
{/if} {/if}
</div> </div>
<div class="flex-auto w-0 pl-1"> <div class="flex-auto w-0 pl-2">
{#if showUserProfile} {#if showUserProfile}
<Name> <Name>
<div class=" self-end text-base shrink-0 font-medium truncate"> <div class=" self-end text-base shrink-0 font-medium truncate">

View file

@ -18,16 +18,20 @@
import UserAlt from '../icons/UserAlt.svelte'; import UserAlt from '../icons/UserAlt.svelte';
import ChannelInfoModal from './ChannelInfoModal.svelte'; import ChannelInfoModal from './ChannelInfoModal.svelte';
import Users from '../icons/Users.svelte'; import Users from '../icons/Users.svelte';
import Pin from '../icons/Pin.svelte';
import PinnedMessagesModal from './PinnedMessagesModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let showChannelPinnedMessagesModal = false;
let showChannelInfoModal = false; let showChannelInfoModal = false;
export let channel; export let channel;
</script> </script>
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} /> <ChannelInfoModal bind:show={showChannelInfoModal} {channel} />
<nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region"> <nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region flex flex-col">
<div <div
id="navbar-bg-gradient-to-b" id="navbar-bg-gradient-to-b"
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]" class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
@ -111,6 +115,21 @@
</div> </div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1"> <div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
class=" flex cursor-pointer py-1.5 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="Pinned Messages"
type="button"
on:click={() => {
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{#if channel?.user_count !== undefined} {#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}> <Tooltip content={$i18n.t('Users')}>
<button <button

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Hashtag from '../icons/Hashtag.svelte';
import Lock from '../icons/Lock.svelte';
import UserList from './ChannelInfoModal/UserList.svelte';
import { getChannelPinnedMessages, pinMessage } from '$lib/apis/channels';
import Message from './Messages/Message.svelte';
import { user } from '$lib/stores';
import Loader from '../common/Loader.svelte';
export let show = false;
export let channel = null;
let page = 1;
let pinnedMessages = [];
let allItemsLoaded = false;
let loading = false;
const getPinnedMessages = async () => {
if (!channel) return;
if (allItemsLoaded) return;
loading = true;
try {
const res = await getChannelPinnedMessages(localStorage.token, channel.id, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
pinnedMessages = [...pinnedMessages, ...res];
}
if (res.length === 0) {
allItemsLoaded = true;
}
} catch (error) {
console.error('Error fetching pinned messages:', error);
} finally {
loading = false;
}
};
const init = () => {
page = 1;
pinnedMessages = [];
getPinnedMessages();
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
{#if channel}
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0">
{$i18n.t('Pinned Messages')}
</div>
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<div class="flex flex-col w-full h-full pb-2 gap-1">
<div
class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent py-2"
>
{#each pinnedMessages as message, messageIdx (message.id)}
<Message
className="rounded-xl px-2"
{message}
{channel}
onPin={async (message) => {
pinnedMessages = pinnedMessages.filter((m) => m.id !== message.id);
const updatedMessage = await pinMessage(
localStorage.token,
message.channel_id,
message.id,
!message.is_pinned
).catch((error) => {
toast.error(`${error}`);
return null;
});
init();
}}
onReaction={false}
onThread={false}
onReply={false}
onEdit={false}
onDelete={false}
/>
{#if messageIdx === pinnedMessages.length - 1 && !allItemsLoaded}
<Loader
on:visible={(e) => {
console.log('visible');
if (!loading) {
page += 1;
getPinnedMessages();
}
}}
>
<div
class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/each}
</div>
</div>
</div>
</div>
</div>
</Modal>
{/if}