diff --git a/frontend/src/components/feature/GlobalSearch/MessageBox.tsx b/frontend/src/components/feature/GlobalSearch/MessageBox.tsx index 997c67582..d26e56c12 100644 --- a/frontend/src/components/feature/GlobalSearch/MessageBox.tsx +++ b/frontend/src/components/feature/GlobalSearch/MessageBox.tsx @@ -10,7 +10,7 @@ import { DateMonthYear } from "@/utils/dateConversions" type MessageBoxProps = { message: Message & { workspace?: string } - handleScrollToMessage: (messageName: string, channelID: string, workspace?: string) => void + handleScrollToMessage?: (messageName: string, channelID: string, workspace?: string) => void } export const MessageBox = ({ message, handleScrollToMessage }: MessageBoxProps) => { @@ -44,15 +44,15 @@ export const MessageBox = ({ message, handleScrollToMessage }: MessageBoxProps) - handleScrollToMessage(message.name, channel_id, message.workspace)}> + {handleScrollToMessage ? handleScrollToMessage(message.name, channel_id, message.workspace)}> View in channel - + : null} - - + + diff --git a/frontend/src/components/feature/channel-details/rename-channel/EditChannelNameButton.tsx b/frontend/src/components/feature/channel-details/rename-channel/EditChannelNameButton.tsx index eb63a9b22..67fb3ae7e 100644 --- a/frontend/src/components/feature/channel-details/rename-channel/EditChannelNameButton.tsx +++ b/frontend/src/components/feature/channel-details/rename-channel/EditChannelNameButton.tsx @@ -7,14 +7,16 @@ import { useState } from "react" import { DIALOG_CONTENT_CLASS } from '@/utils/layout/dialog' import { useIsDesktop } from '@/hooks/useMediaQuery' import { DrawerContent, DrawerTrigger, Drawer } from '@/components/layout/Drawer' +import clsx from 'clsx' interface EditChannelNameButtonProps extends IconButtonProps { channelID: string, channel_name: string, - channelType: ChannelListItem['type'] + channelType: ChannelListItem['type'], + buttonVisible?: boolean, } -export const EditChannelNameButton = ({ channelID, channel_name, channelType, ...props }: EditChannelNameButtonProps) => { +export const EditChannelNameButton = ({ channelID, channel_name, channelType, buttonVisible = false, ...props }: EditChannelNameButtonProps) => { const [open, setOpen] = useState(false); @@ -31,7 +33,7 @@ export const EditChannelNameButton = ({ channelID, channel_name, channelType, .. @@ -53,7 +55,7 @@ export const EditChannelNameButton = ({ channelID, channel_name, channelType, .. diff --git a/frontend/src/components/feature/chat-header/ChannelHeader.tsx b/frontend/src/components/feature/chat-header/ChannelHeader.tsx index 39944c5c9..e823947b8 100644 --- a/frontend/src/components/feature/chat-header/ChannelHeader.tsx +++ b/frontend/src/components/feature/chat-header/ChannelHeader.tsx @@ -7,6 +7,7 @@ import ChannelHeaderMenu from "./ChannelHeaderMenu" import { ViewChannelMemberAvatars } from "./ViewChannelMemberAvatars" import { BiChevronLeft } from "react-icons/bi" import { Link } from "react-router-dom" +import { ViewPinnedMessagesButton } from "../pinned-messages/ViewPinnedMessagesButton" interface ChannelHeaderProps { channelData: ChannelListItem @@ -34,7 +35,8 @@ export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => { }} className="mb-0.5 text-ellipsis line-clamp-1">{channelData.channel_name} - + + diff --git a/frontend/src/components/feature/chat/ChatMessage/LeftRightLayout/LeftRightLayout.tsx b/frontend/src/components/feature/chat/ChatMessage/LeftRightLayout/LeftRightLayout.tsx index 53ff3f236..7be0811a1 100644 --- a/frontend/src/components/feature/chat/ChatMessage/LeftRightLayout/LeftRightLayout.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/LeftRightLayout/LeftRightLayout.tsx @@ -4,7 +4,7 @@ import { MessageContent, MessageSenderAvatar, UserHoverCard } from "../MessageIt import { Box, BoxProps, ContextMenu, Flex, Text } from "@radix-ui/themes" import { MessageReactions } from "../MessageReactions" import { DateTooltip, DateTooltipShort } from "../Renderers/DateTooltip" -import { RiShareForwardFill } from "react-icons/ri" +import { RiPushpinFill, RiShareForwardFill } from "react-icons/ri" import { ReplyMessageBox } from "../ReplyMessageBox/ReplyMessageBox" import { useContext, useMemo, useState } from "react" import clsx from "clsx" @@ -106,7 +106,7 @@ export const LeftRightLayout = ({ message, user, isActive, isHighlighted, onRepl : null} {message.is_forwarded === 1 && forwarded} - + {message.is_pinned === 1 && Pinned} {linked_message && replied_message_details && } + @@ -165,4 +167,32 @@ const SaveMessageAction = ({ message }: { message: Message }) => { +} + + +const PinMessageAction = ({ message }: { message: Message }) => { + + const isPinned = message.is_pinned + const { call } = useContext(FrappeContext) as FrappeConfig + + const handlePin = () => { + call.post('raven.api.raven_channel.toggle_pin_message', { + channel_id: message.channel_id, + message_id: message.name, + }).then(() => { + toast.success(`Message ${isPinned ? 'unpinned' : 'pinned'}`) + }).catch((e) => { + toast.error('Could not perform the action', { + description: getErrorMessage(e) + }) + }) + } + + return + + {!isPinned ? : } + {!isPinned ? "Pin" : "Unpin"} + + + } \ No newline at end of file diff --git a/frontend/src/components/feature/chat/ChatMessage/MessageItem.tsx b/frontend/src/components/feature/chat/ChatMessage/MessageItem.tsx index d4b8fc908..f49ff88f7 100644 --- a/frontend/src/components/feature/chat/ChatMessage/MessageItem.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/MessageItem.tsx @@ -19,7 +19,7 @@ import { ReplyMessageBox } from './ReplyMessageBox/ReplyMessageBox' import { generateAvatarColor } from '../../selectDropdowns/GenerateAvatarColor' import { DoctypeLinkRenderer } from './Renderers/DoctypeLinkRenderer' import { useDebounce } from '@/hooks/useDebounce' -import { RiRobot2Fill, RiShareForwardFill } from 'react-icons/ri' +import { RiPushpinFill, RiRobot2Fill, RiShareForwardFill } from 'react-icons/ri' import { useIsDesktop } from '@/hooks/useMediaQuery' import { useDoubleTap } from 'use-double-tap' import useOutsideClick from '@/hooks/useOutsideClick' @@ -179,6 +179,7 @@ export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyM : null} {/* Message content goes here */} {message.is_forwarded === 1 && forwarded} + {message.is_pinned === 1 && Pinned} {/* If it's a reply, then show the linked message */} {linked_message && replied_message_details && { maxFileSize={10000000}> {canUserSendMessage && diff --git a/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx b/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx index 19e2bf082..be0661154 100644 --- a/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx +++ b/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx @@ -66,15 +66,16 @@ import SystemMessageBlock from '../ChatMessage/SystemMessageBlock' type Props = { channelID: string, replyToMessage: (message: Message) => void, - showThreadButton?: boolean + showThreadButton?: boolean, + pinnedMessagesString?: string } -const ChatStream = ({ channelID, replyToMessage, showThreadButton = true }: Props) => { +const ChatStream = ({ channelID, replyToMessage, showThreadButton = true, pinnedMessagesString }: Props) => { const scrollRef = useRef(null) - const { messages, hasOlderMessages, loadOlderMessages, goToLatestMessages, hasNewMessages, error, loadNewerMessages, isLoading, highlightedMessage, scrollToMessage } = useChatStream(channelID, scrollRef) + const { messages, hasOlderMessages, loadOlderMessages, goToLatestMessages, hasNewMessages, error, loadNewerMessages, isLoading, highlightedMessage, scrollToMessage } = useChatStream(channelID, scrollRef, pinnedMessagesString) const { setDeleteMessage, ...deleteProps } = useDeleteMessage() const { setEditMessage, ...editProps } = useEditMessage() diff --git a/frontend/src/components/feature/chat/ChatStream/useChatStream.ts b/frontend/src/components/feature/chat/ChatStream/useChatStream.ts index b6dd1c231..2222ab5b9 100644 --- a/frontend/src/components/feature/chat/ChatStream/useChatStream.ts +++ b/frontend/src/components/feature/chat/ChatStream/useChatStream.ts @@ -24,7 +24,7 @@ type MessageDateBlock = Message | { /** * Hook to fetch messages to be rendered on the chat interface */ -const useChatStream = (channelID: string, scrollRef: MutableRefObject) => { +const useChatStream = (channelID: string, scrollRef: MutableRefObject, pinnedMessagesString?: string) => { const location = useLocation() const navigate = useNavigate() @@ -427,6 +427,9 @@ const useChatStream = (channelID: string, scrollRef: MutableRefObject messageID.trim()) + const messages = [...data.message.messages] // Messages are already sorted by date - from latest to oldest @@ -445,7 +448,11 @@ const useChatStream = (channelID: string, scrollRef: MutableRefObject= 0; i--) { @@ -469,15 +476,17 @@ const useChatStream = (channelID: string, scrollRef: MutableRefObject 120000) { messagesWithDateSeparators.push({ ...message, - is_continuation: 0 + is_continuation: 0, + is_pinned: pinnedMessageIDs.includes(message.name) ? 1 : 0 }) } else { - messagesWithDateSeparators.push({ ...message, is_continuation: 1 }) + messagesWithDateSeparators.push({ ...message, is_continuation: 1, is_pinned: pinnedMessageIDs.includes(message.name) ? 1 : 0 }) } currentDate = messageDate currentDateTime = new Date(message.creation).getTime() @@ -489,7 +498,7 @@ const useChatStream = (channelID: string, scrollRef: MutableRefObject { // Check if the message is in the messages array diff --git a/frontend/src/components/feature/pinned-messages/PinnedMessageModalContent.tsx b/frontend/src/components/feature/pinned-messages/PinnedMessageModalContent.tsx new file mode 100644 index 000000000..df3d9afd7 --- /dev/null +++ b/frontend/src/components/feature/pinned-messages/PinnedMessageModalContent.tsx @@ -0,0 +1,37 @@ +import { Dialog, Flex, IconButton, Text } from '@radix-ui/themes' +import { useFrappeGetCall } from 'frappe-react-sdk' +import { useParams } from 'react-router-dom' +import { Message } from '../../../../../types/Messaging/Message' +import { ErrorBanner } from '@/components/layout/AlertBanner/ErrorBanner' +import { MessageBox } from '../GlobalSearch/MessageBox' +import { IoClose } from 'react-icons/io5' + +export const PinnedMessageModalContent = ({ onClose }: { onClose: () => void }) => { + + const { channelID } = useParams<{ channelID: string }>() + + const { data, error } = useFrappeGetCall<{ message: Message[] }>("raven.api.raven_message.get_pinned_messages", { 'channel_id': channelID }, undefined, { + revalidateOnFocus: false + }) + + return ( + <> + + + Pinned Messages + + + + + + + + {data?.message?.map((message) => { + return ( + + ) + })} + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/feature/pinned-messages/ViewPinnedMessagesButton.tsx b/frontend/src/components/feature/pinned-messages/ViewPinnedMessagesButton.tsx new file mode 100644 index 000000000..b44c23dc2 --- /dev/null +++ b/frontend/src/components/feature/pinned-messages/ViewPinnedMessagesButton.tsx @@ -0,0 +1,38 @@ +import { Button, Dialog } from '@radix-ui/themes' +import { RiPushpinLine } from 'react-icons/ri' +import { useState } from 'react' +import { DIALOG_CONTENT_CLASS } from '@/utils/layout/dialog' +import { PinnedMessageModalContent } from './PinnedMessageModalContent' + +interface ViewPinnedMessagesButtonProps { + pinnedMessagesString: string +} + +export const ViewPinnedMessagesButton = ({ pinnedMessagesString }: ViewPinnedMessagesButtonProps) => { + + const pinnedMessages = pinnedMessagesString ? pinnedMessagesString.split('\n').length : 0 + + const [open, setOpen] = useState(false) + + const onClose = () => { + setOpen(false) + } + + if (!pinnedMessages) { + return null + } + + return ( + + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/types/Raven/RavenPinnedMessages.ts b/frontend/src/types/Raven/RavenPinnedMessages.ts new file mode 100644 index 000000000..aea403467 --- /dev/null +++ b/frontend/src/types/Raven/RavenPinnedMessages.ts @@ -0,0 +1,15 @@ + +export interface RavenPinnedMessages{ + name: string + creation: string + modified: string + owner: string + modified_by: string + docstatus: 0 | 1 | 2 + parent?: string + parentfield?: string + parenttype?: string + idx?: number + /** Message ID : Link - Raven Message */ + message_id: string +} \ No newline at end of file diff --git a/frontend/src/types/RavenChannelManagement/RavenChannel.ts b/frontend/src/types/RavenChannelManagement/RavenChannel.ts index ac568500a..d46502a9f 100644 --- a/frontend/src/types/RavenChannelManagement/RavenChannel.ts +++ b/frontend/src/types/RavenChannelManagement/RavenChannel.ts @@ -1,3 +1,4 @@ +import { RavenPinnedMessages } from '../Raven/RavenPinnedMessages' export interface RavenChannel{ creation: string @@ -38,6 +39,10 @@ export interface RavenChannel{ last_message_timestamp?: string /** Last Message Details : JSON */ last_message_details?: any + /** Pinned Messages : Table - Raven Pinned Messages */ + pinned_messages?: RavenPinnedMessages[] + /** Pinned Messages String : Small Text */ + pinned_messages_string?: string /** Is AI Thread : Check */ is_ai_thread?: 0 | 1 /** OpenAI Thread ID : Data */ diff --git a/frontend/src/utils/channel/ChannelListProvider.tsx b/frontend/src/utils/channel/ChannelListProvider.tsx index 88f22f155..cf1af4d11 100644 --- a/frontend/src/utils/channel/ChannelListProvider.tsx +++ b/frontend/src/utils/channel/ChannelListProvider.tsx @@ -1,10 +1,10 @@ import { FrappeConfig, FrappeContext, FrappeError, useFrappeDocTypeEventListener, useFrappeGetCall } from 'frappe-react-sdk' import { PropsWithChildren, createContext, useContext, useMemo } from 'react' import { KeyedMutator } from 'swr' -import { RavenChannel } from '../../../../types/RavenChannelManagement/RavenChannel' import { useSWRConfig } from 'frappe-react-sdk' import { toast } from 'sonner' import { getErrorMessage } from '@/components/layout/AlertBanner/ErrorBanner' +import { RavenChannel } from '@/types/RavenChannelManagement/RavenChannel' export type UnreadChannelCountItem = { name: string, user_id?: string, unread_count: number, is_direct_message: 0 | 1 } @@ -16,7 +16,7 @@ export type UnreadCountData = { export type ChannelListItem = Pick + 'is_archived' | 'creation' | 'owner' | 'last_message_details' | 'last_message_timestamp' | 'workspace' | 'pinned_messages_string'> export interface DMChannelListItem extends ChannelListItem { peer_user_id: string, diff --git a/raven/api/raven_channel.py b/raven/api/raven_channel.py index 5dcb023ee..29d140efe 100644 --- a/raven/api/raven_channel.py +++ b/raven/api/raven_channel.py @@ -62,6 +62,7 @@ def get_channel_list(hide_archived=False): channel.owner, channel.last_message_timestamp, channel.last_message_details, + channel.pinned_messages_string, channel.workspace, ) .distinct() @@ -221,6 +222,39 @@ def leave_channel(channel_id): return "Ok" +@frappe.whitelist() +def toggle_pin_message(channel_id, message_id): + """ + Toggle pin/unpin a message in a channel. + """ + channel = frappe.get_doc("Raven Channel", channel_id) + + pinned_message = None + + # Check whether the message exists in the channel + message_exists = frappe.db.get_value("Raven Message", message_id, "channel_id") == channel_id + + if not message_exists: + frappe.throw(_("Message does not exist in this channel")) + + # Check if the message is already pinned + for pm in channel.pinned_messages: + if pm.message_id == message_id: + pinned_message = pm + break + + if pinned_message: + # Unpin the message + channel.remove(pinned_message) + else: + # Pin the message if it's not pinned + channel.append("pinned_messages", {"message_id": message_id}) + + channel.save() + + return "Ok" + + @frappe.whitelist() def mark_all_messages_as_read(channel_ids: list): """ diff --git a/raven/api/raven_message.py b/raven/api/raven_message.py index 7b64d5b18..3d7660ee8 100644 --- a/raven/api/raven_message.py +++ b/raven/api/raven_message.py @@ -120,6 +120,43 @@ def save_message(message_id, add=False): return "message saved" +@frappe.whitelist() +def get_pinned_messages(channel_id): + + # check if the user has permission to view the channel + frappe.has_permission("Raven Channel", doc=channel_id, ptype="read", throw=True) + + pinnedMessagesString = frappe.db.get_value("Raven Channel", channel_id, "pinned_messages_string") + pinnedMessages = pinnedMessagesString.split("\n") if pinnedMessagesString else [] + + return frappe.db.get_all( + "Raven Message", + filters={"name": ["in", pinnedMessages]}, + fields=[ + "name", + "owner", + "creation", + "text", + "file", + "message_type", + "message_reactions", + "_liked_by", + "channel_id", + "thumbnail_width", + "thumbnail_height", + "file_thumbnail", + "link_doctype", + "link_document", + "replied_message_details", + "content", + "is_edited", + "is_thread", + "is_forwarded", + ], + order_by="creation asc", + ) + + @frappe.whitelist() def get_saved_messages(): """ diff --git a/raven/raven/doctype/raven_pinned_messages/__init__.py b/raven/raven/doctype/raven_pinned_messages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.json b/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.json new file mode 100644 index 000000000..f62495720 --- /dev/null +++ b/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-13 16:40:20.096165", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "message_id" + ], + "fields": [ + { + "fieldname": "message_id", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Message ID", + "options": "Raven Message", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-10-13 16:41:54.688859", + "modified_by": "Administrator", + "module": "Raven", + "name": "Raven Pinned Messages", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.py b/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.py new file mode 100644 index 000000000..20b66611c --- /dev/null +++ b/raven/raven/doctype/raven_pinned_messages/raven_pinned_messages.py @@ -0,0 +1,23 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RavenPinnedMessages(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + message_id: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index f6b62654f..10ff630d8 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -27,6 +27,9 @@ "last_message_timestamp", "column_break_eckt", "last_message_details", + "section_break_acpc", + "pinned_messages", + "pinned_messages_string", "ai_tab", "is_ai_thread", "openai_thread_id", @@ -174,6 +177,21 @@ "options": "Raven Bot", "read_only": 1 }, + { + "fieldname": "section_break_acpc", + "fieldtype": "Section Break" + }, + { + "fieldname": "pinned_messages", + "fieldtype": "Table", + "label": "Pinned Messages", + "options": "Raven Pinned Messages" + }, + { + "fieldname": "pinned_messages_string", + "fieldtype": "Small Text", + "label": "Pinned Messages String" + }, { "fieldname": "workspace", "fieldtype": "Link", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index d80a8a211..62b170f48 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -17,6 +17,8 @@ class RavenChannel(Document): if TYPE_CHECKING: from frappe.types import DF + from raven.raven.doctype.raven_pinned_messages.raven_pinned_messages import RavenPinnedMessages + channel_description: DF.SmallText | None channel_name: DF.Data is_ai_thread: DF.Check @@ -31,6 +33,8 @@ class RavenChannel(Document): linked_doctype: DF.Link | None linked_document: DF.DynamicLink | None openai_thread_id: DF.Data | None + pinned_messages: DF.Table[RavenPinnedMessages] + pinned_messages_string: DF.SmallText | None thread_bot: DF.Link | None type: DF.Literal["Private", "Public", "Open"] workspace: DF.Link | None @@ -169,6 +173,11 @@ def before_validate(self): if len(workspaces) == 1: self.workspace = workspaces[0].name + self.set_pinned_messages_string() + + def set_pinned_messages_string(self): + self.pinned_messages_string = "\n".join([message.message_id for message in self.pinned_messages]) + def add_members(self, members, is_admin=0): # members is a list of Raven User IDs for member in members: diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 2cea95007..d72361f38 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -584,6 +584,21 @@ def on_trash(self): thread_channel_doc = frappe.get_doc("Raven Channel", self.name) thread_channel_doc.delete(ignore_permissions=True) + # delete the pinned message + is_pinned = frappe.get_all( + "Raven Pinned Messages", {"message_id": self.name, "parent": self.channel_id} + ) + if is_pinned: + channel_doc = frappe.get_doc("Raven Channel", self.channel_id) + pinned_row = None + for pinned_message in channel_doc.pinned_messages: + if pinned_message.message_id == self.name: + pinned_row = pinned_message + break + if pinned_row: + channel_doc.remove(pinned_row) + channel_doc.save() + def on_doctype_update(): """ diff --git a/types/Messaging/Message.ts b/types/Messaging/Message.ts index 0a50a860a..ee079e1b2 100644 --- a/types/Messaging/Message.ts +++ b/types/Messaging/Message.ts @@ -23,6 +23,7 @@ export interface BaseMessage { bot?: string, hide_link_preview?: 1 | 0, is_thread: 1 | 0, + is_pinned: 1 | 0, } export interface FileMessage extends BaseMessage {