diff --git a/frontend/package.json b/frontend/package.json index d9abe5eeb..82366b01c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "raven-ui", "private": true, "license": "AGPL-3.0-only", - "version": "1.6.13", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 587be77c0..fd37bb9bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,9 @@ const router = createBrowserRouter( }> } > } /> + import('./components/feature/threads/Threads')}> + import('./components/feature/threads/ThreadDrawer/ThreadDrawer')} /> + import('./components/feature/saved-messages/SavedMessages')} /> import('./pages/settings/Settings')}> } /> @@ -31,7 +34,9 @@ const router = createBrowserRouter( import('./pages/settings/Integrations/FrappeHR')} /> {/* import('./components/feature/userSettings/Bots')} /> */} - import('@/pages/ChatSpace')} /> + import('@/pages/ChatSpace')}> + import('./components/feature/threads/ThreadDrawer/ThreadDrawer')} /> + {/* import('./pages/settings/Settings')}> diff --git a/frontend/src/components/feature/channel-details/leave-channel/LeaveChannelModal.tsx b/frontend/src/components/feature/channel-details/leave-channel/LeaveChannelModal.tsx index e6a0b7d34..d208e916a 100644 --- a/frontend/src/components/feature/channel-details/leave-channel/LeaveChannelModal.tsx +++ b/frontend/src/components/feature/channel-details/leave-channel/LeaveChannelModal.tsx @@ -1,7 +1,6 @@ -import { useFrappeDeleteDoc, useFrappeGetCall } from 'frappe-react-sdk' +import { useFrappePostCall } from 'frappe-react-sdk' import { Fragment, useContext } from 'react' import { useNavigate } from 'react-router-dom' -import { UserContext } from '../../../../utils/auth/UserProvider' import { ErrorBanner } from '../../../layout/AlertBanner' import { ChannelListContext, ChannelListContextType, ChannelListItem } from '@/utils/channel/ChannelListProvider' import { ChannelIcon } from '@/utils/layout/channelIcon' @@ -19,22 +18,13 @@ interface LeaveChannelModalProps { export const LeaveChannelModal = ({ onClose, channelData, isDrawer, closeDetailsModal }: LeaveChannelModalProps) => { - const { currentUser } = useContext(UserContext) - const { deleteDoc, loading: deletingDoc, error } = useFrappeDeleteDoc() + const { call, loading: deletingDoc, error } = useFrappePostCall('raven.api.raven_channel.leave_channel') const navigate = useNavigate() - const { data: channelMember } = useFrappeGetCall<{ message: { name: string } }>('frappe.client.get_value', { - doctype: "Raven Channel Member", - filters: JSON.stringify({ channel_id: channelData?.name, user_id: currentUser }), - fieldname: JSON.stringify(["name"]) - }, undefined, { - revalidateOnFocus: false - }) - const { mutate } = useContext(ChannelListContext) as ChannelListContextType const onSubmit = async () => { - return deleteDoc('Raven Channel Member', channelMember?.message.name).then(() => { + return call({ channel_id: channelData?.name }).then(() => { toast('You have left the channel') onClose() mutate() diff --git a/frontend/src/components/feature/chat-header/ChannelHeader.tsx b/frontend/src/components/feature/chat-header/ChannelHeader.tsx index 2bd2a8b82..812545edc 100644 --- a/frontend/src/components/feature/chat-header/ChannelHeader.tsx +++ b/frontend/src/components/feature/chat-header/ChannelHeader.tsx @@ -22,10 +22,15 @@ export const ChannelHeader = ({ channelData }: ChannelHeaderProps) => { - + - {channelData.channel_name} + {channelData.channel_name} diff --git a/frontend/src/components/feature/chat-header/DMChannelHeader.tsx b/frontend/src/components/feature/chat-header/DMChannelHeader.tsx index aa95cce5f..9bd9df188 100644 --- a/frontend/src/components/feature/chat-header/DMChannelHeader.tsx +++ b/frontend/src/components/feature/chat-header/DMChannelHeader.tsx @@ -12,6 +12,7 @@ import { useGetUser } from "@/hooks/useGetUser" import useIsUserOnLeave from "@/hooks/fetchers/useIsUserOnLeave" import { UserContext } from "@/utils/auth/UserProvider" import { replaceCurrentUserFromDMChannelName } from "@/utils/operations" +import { useIsDesktop } from "@/hooks/useMediaQuery" interface DMChannelHeaderProps { channelData: DMChannelListItem, @@ -49,6 +50,8 @@ export const DMChannelHeader = ({ channelData }: DMChannelHeaderProps) => { const userName = fullName ?? peer ?? replaceCurrentUserFromDMChannelName(channelData.channel_name, currentUser) + const isDesktop = useIsDesktop() + return ( @@ -63,8 +66,11 @@ export const DMChannelHeader = ({ channelData }: DMChannelHeaderProps) => { availabilityStatus={user?.availability_status} skeletonSize='6' isBot={isBot} - size='2' /> - + size={isDesktop ? '2' : '1'} /> +
{userName} {!user && Deleted} diff --git a/frontend/src/components/feature/chat/ChatInput/Tiptap.tsx b/frontend/src/components/feature/chat/ChatInput/Tiptap.tsx index 315e580f8..5eae63cf3 100644 --- a/frontend/src/components/feature/chat/ChatInput/Tiptap.tsx +++ b/frontend/src/components/feature/chat/ChatInput/Tiptap.tsx @@ -1,7 +1,7 @@ import { BubbleMenu, EditorContent, EditorContext, Extension, ReactRenderer, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' -import React, { Suspense, lazy, useContext, useEffect } from 'react' +import React, { Suspense, lazy, useContext, useEffect, useMemo } from 'react' import { TextFormattingMenu } from './TextFormattingMenu' import Highlight from '@tiptap/extension-highlight' import Link from '@tiptap/extension-link' @@ -33,6 +33,7 @@ import { EmojiSuggestion } from './EmojiSuggestion' import { useIsDesktop } from '@/hooks/useMediaQuery' import { BiPlus } from 'react-icons/bi' import clsx from 'clsx' +import { ChannelMembers } from '@/hooks/fetchers/useFetchChannelMembers' const MobileInputActions = lazy(() => import('./MobileActions/MobileInputActions')) const lowlight = createLowlight(common) @@ -59,7 +60,8 @@ type TiptapEditorProps = { onMessageSend: (message: string, json: any) => Promise, messageSending: boolean, defaultText?: string, - replyMessage?: Message | null + replyMessage?: Message | null, + channelMembers?: ChannelMembers } export const UserMention = Mention.extend({ @@ -82,10 +84,19 @@ export const ChannelMention = Mention.extend({ } }) -const Tiptap = ({ isEdit, slotBefore, fileProps, onMessageSend, replyMessage, clearReplyMessage, placeholder = 'Type a message...', messageSending, sessionStorageKey = 'tiptap-editor', disableSessionStorage = false, defaultText = '' }: TiptapEditorProps) => { +const Tiptap = ({ isEdit, slotBefore, fileProps, onMessageSend, channelMembers, replyMessage, clearReplyMessage, placeholder = 'Type a message...', messageSending, sessionStorageKey = 'tiptap-editor', disableSessionStorage = false, defaultText = '' }: TiptapEditorProps) => { const { enabledUsers } = useContext(UserListContext) + const channelMemberUsers = useMemo(() => { + if (channelMembers) { + // Filter enabled users to only include users that are in the channel + return enabledUsers.filter((user) => user.name in channelMembers) + } else { + return enabledUsers + } + }, [channelMembers, enabledUsers]) + const { channels } = useContext(ChannelListContext) as ChannelListContextType // this is a dummy extension only to create custom keydown behavior @@ -301,7 +312,7 @@ const Tiptap = ({ isEdit, slotBefore, fileProps, onMessageSend, replyMessage, cl }, suggestion: { items: (query) => { - return enabledUsers.filter((user) => user.full_name.toLowerCase().startsWith(query.query.toLowerCase())) + return channelMemberUsers.filter((user) => user.full_name.toLowerCase().startsWith(query.query.toLowerCase())) .slice(0, 10); }, // char: '@', @@ -466,7 +477,7 @@ const Tiptap = ({ isEdit, slotBefore, fileProps, onMessageSend, replyMessage, cl if (isDesktop) { return ( - + {slotBefore} diff --git a/frontend/src/components/feature/chat/ChatMessage/ActionModals/DeleteMessageModal.tsx b/frontend/src/components/feature/chat/ChatMessage/ActionModals/DeleteMessageModal.tsx index f18990555..25be3d348 100644 --- a/frontend/src/components/feature/chat/ChatMessage/ActionModals/DeleteMessageModal.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/ActionModals/DeleteMessageModal.tsx @@ -5,6 +5,7 @@ import { Loader } from "@/components/common/Loader" import { FiAlertTriangle } from "react-icons/fi" import { Message } from "../../../../../../../types/Messaging/Message" import { toast } from "sonner" +import { useNavigate } from "react-router-dom" interface DeleteMessageModalProps { onClose: (refresh?: boolean) => void, @@ -14,11 +15,12 @@ interface DeleteMessageModalProps { export const DeleteMessageModal = ({ onClose, message }: DeleteMessageModalProps) => { const { deleteDoc, error, loading: deletingDoc } = useFrappeDeleteDoc() + const navigate = useNavigate() const onSubmit = async () => { - return deleteDoc('Raven Message', message.name).then(() => { toast('Message deleted') + message.is_thread && navigate(`/channel/${message.channel_id}`) onClose() }) } @@ -26,7 +28,7 @@ export const DeleteMessageModal = ({ onClose, message }: DeleteMessageModalProps return ( <> - Delete Message + Delete {message.is_thread ? 'Thread' : 'Message'} @@ -40,7 +42,8 @@ export const DeleteMessageModal = ({ onClose, message }: DeleteMessageModalProps - Are you sure you want to delete this message? It will be deleted for all users. + {message.is_thread ? This message is a thread, deleting it will delete all messages in the thread. : + Are you sure you want to delete this message? It will be deleted for all users.} diff --git a/frontend/src/components/feature/chat/ChatMessage/MessageActions/DeleteMessage.tsx b/frontend/src/components/feature/chat/ChatMessage/MessageActions/DeleteMessage.tsx index 5bb75cc2e..68a965bad 100644 --- a/frontend/src/components/feature/chat/ChatMessage/MessageActions/DeleteMessage.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/MessageActions/DeleteMessage.tsx @@ -5,14 +5,13 @@ import { DIALOG_CONTENT_CLASS } from "@/utils/layout/dialog" import { DeleteMessageModal } from "@/components/feature/chat/ChatMessage/ActionModals/DeleteMessageModal" export const useDeleteMessage = () => { + const [message, setMessage] = useState(null) const onClose = useCallback(() => { setMessage(null) }, []) - - return { message, setDeleteMessage: setMessage, @@ -27,8 +26,6 @@ interface DeleteMessageDialogProps { onClose: () => void } export const DeleteMessageDialog = ({ message, isOpen, onClose }: DeleteMessageDialogProps) => { - - return {message && diff --git a/frontend/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx b/frontend/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx index 1ae811fb9..7a224638a 100644 --- a/frontend/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx @@ -11,26 +11,27 @@ import { getErrorMessage } from '@/components/layout/AlertBanner/ErrorBanner' import { AiOutlineEdit } from 'react-icons/ai' import { LuForward, LuReply } from 'react-icons/lu' import { MdOutlineEmojiEmotions } from "react-icons/md"; -import AttachFileToDocument from './AttachFileToDocument' +import { CreateThreadContextItem } from './QuickActions/CreateThreadButton' export interface MessageContextMenuProps { message?: Message | null, - onDelete: VoidFunction + onDelete: VoidFunction, onEdit: VoidFunction, onReply: VoidFunction, onForward: VoidFunction, - onViewReaction?: VoidFunction - onAttachDocument: VoidFunction + onViewReaction?: VoidFunction, + onAttachDocument: VoidFunction, + showThreadButton?: boolean } -export const MessageContextMenu = ({ message, onDelete, onEdit, onReply, onForward, onAttachDocument, onViewReaction }: MessageContextMenuProps) => { +export const MessageContextMenu = ({ message, onDelete, onEdit, onReply, onForward, showThreadButton, onAttachDocument, onViewReaction }: MessageContextMenuProps) => { const copy = useMessageCopy(message) const { currentUser } = useContext(UserContext) - const isOwner = currentUser === message?.owner; + const isOwner = currentUser === message?.owner && !message?.is_bot_message const isReactionsAvailable = Object.keys(JSON.parse(message?.message_reactions ?? '{}')).length !== 0 - + return ( {message ? <> @@ -49,7 +50,8 @@ export const MessageContextMenu = ({ message, onDelete, onEdit, onReply, onForwa Forward - {/* */} + {message && !message.is_thread && showThreadButton && } + {message.message_type === 'Text' && @@ -87,29 +89,10 @@ export const MessageContextMenu = ({ message, onDelete, onEdit, onReply, onForwa } - - - - - - {/* - - - Link with document - - - - - - - Send in an email - - */} - - + {isReactionsAvailable && @@ -173,7 +156,7 @@ const SaveMessageAction = ({ message }: { message: Message }) => { {!isSaved && } {isSaved && } - {!isSaved ? "Save" : "Unsave"} message + {!isSaved ? "Save" : "Unsave"} Message diff --git a/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/CreateThreadButton.tsx b/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/CreateThreadButton.tsx new file mode 100644 index 000000000..cdc2e05e9 --- /dev/null +++ b/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/CreateThreadButton.tsx @@ -0,0 +1,49 @@ +import { useFrappePostCall } from 'frappe-react-sdk' +import { toast } from 'sonner' +import { QuickActionButton } from './QuickActionButton' +import { BiMessageDetail } from 'react-icons/bi' +import { useNavigate } from 'react-router-dom' +import { ContextMenu, Flex } from '@radix-ui/themes' + +const useCreateThread = (messageID: string) => { + const navigate = useNavigate() + + const { call } = useFrappePostCall('raven.api.threads.create_thread') + const handleCreateThread = () => { + call({ 'message_id': messageID }).then((res) => { + toast.success('Thread created successfully!') + navigate(`/channel/${res.message.channel_id}/thread/${res.message.thread_id}`) + }).catch(() => { + toast.error('Failed to create thread') + }) + } + + return handleCreateThread +} + +export const CreateThreadActionButton = ({ messageID }: { messageID: string }) => { + + const handleCreateThread = useCreateThread(messageID) + + return ( + + + + ) +} + +export const CreateThreadContextItem = ({ messageID }: { messageID: string }) => { + + const handleCreateThread = useCreateThread(messageID) + + return + + + Create Thread + + + +} \ No newline at end of file diff --git a/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/QuickActions.tsx b/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/QuickActions.tsx index 824ffcadf..aafa26385 100644 --- a/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/QuickActions.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/MessageActions/QuickActions/QuickActions.tsx @@ -7,22 +7,23 @@ import { FrappeConfig, FrappeContext } from 'frappe-react-sdk' import { EmojiPickerButton } from './EmojiPickerButton' import { UserContext } from '@/utils/auth/UserProvider' import { AiOutlineEdit } from 'react-icons/ai' -import { LuForward, LuReply } from 'react-icons/lu' +import { LuReply } from 'react-icons/lu' import { toast } from 'sonner' import { getErrorMessage } from '@/components/layout/AlertBanner/ErrorBanner' +import { CreateThreadActionButton } from './CreateThreadButton' const QUICK_EMOJIS = ['👍', '✅', '👀', '🎉'] - interface QuickActionsProps extends MessageContextMenuProps { isEmojiPickerOpen: boolean, - setIsEmojiPickerOpen: (open: boolean) => void + setIsEmojiPickerOpen: (open: boolean) => void, } -export const QuickActions = ({ message, onReply, onEdit, onForward, isEmojiPickerOpen, setIsEmojiPickerOpen }: QuickActionsProps) => { + +export const QuickActions = ({ message, onReply, onEdit, isEmojiPickerOpen, setIsEmojiPickerOpen, showThreadButton = true }: QuickActionsProps) => { const { currentUser } = useContext(UserContext) - const isOwner = currentUser === message?.owner + const isOwner = currentUser === message?.owner && !message?.is_bot_message const toolbarRef = useRef(null) const { call } = useContext(FrappeContext) as FrappeConfig @@ -107,12 +108,7 @@ export const QuickActions = ({ message, onReply, onEdit, onForward, isEmojiPicke } - - - + {message && !message.is_thread && showThreadButton && } void, isHighlighted?: boolean, setReactionMessage: (message: Message) => void, + showThreadButton?: boolean } -export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyMessageClick, setEditMessage, replyToMessage, forwardMessage, onAttachDocument, setReactionMessage }: MessageBlockProps) => { +export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyMessageClick, setEditMessage, replyToMessage, forwardMessage, onAttachDocument, setReactionMessage, showThreadButton = true }: MessageBlockProps) => { const { name, owner: userID, is_bot_message, bot, creation: timestamp, message_reactions, is_continuation, linked_message, replied_message_details } = message @@ -108,6 +109,18 @@ export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyM return ( + {!message.is_continuation && message.is_thread ? +
+
: null} - : null} @@ -154,7 +166,9 @@ export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyM onClick={() => onReplyMessageClick(linked_message)} message={replyMessageDetails} /> } + { /* Show message according to type */} + } + + {message.is_thread === 1 ? : null} + + {(isHoveredDebounced || isEmojiPickerOpen) && } @@ -190,6 +209,7 @@ export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyM -
+
) } -interface MessageLeftElementProps extends BoxProps { +type MessageLeftElementProps = BoxProps & { message: MessageBlock['data'], user?: UserFields, isActive?: boolean @@ -311,7 +331,7 @@ export const UserHoverCard = memo(({ user, userID, isActive }: UserProps) => { }) -interface MessageContentProps extends BoxProps { +type MessageContentProps = BoxProps & { user?: UserFields message: Message, } diff --git a/frontend/src/components/feature/chat/ChatMessage/Renderers/ThreadMessage.tsx b/frontend/src/components/feature/chat/ChatMessage/Renderers/ThreadMessage.tsx new file mode 100644 index 000000000..a382feb01 --- /dev/null +++ b/frontend/src/components/feature/chat/ChatMessage/Renderers/ThreadMessage.tsx @@ -0,0 +1,46 @@ +import { Link } from "react-router-dom" +import { Message } from "../../../../../../../types/Messaging/Message" +import { Button, Flex, Text } from "@radix-ui/themes" +import { useFrappeGetDocCount } from "frappe-react-sdk" +import { RavenMessage } from "@/types/RavenMessaging/RavenMessage" +import { useFrappeDocumentEventListener } from "frappe-react-sdk" +import { useFrappeEventListener } from "frappe-react-sdk" + +export const ThreadMessage = ({ thread }: { thread: Message }) => { + + return ( +
+ + + + +
+ ) +} + +export const ThreadReplyCount = ({ thread }: { thread: Message }) => { + + const { data, mutate } = useFrappeGetDocCount("Raven Message", [["channel_id", "=", thread.name]], undefined, undefined, undefined, { + revalidateOnFocus: false, + shouldRetryOnError: false, + keepPreviousData: false + }) + + // Listen to realtime event for new message count + useFrappeDocumentEventListener('Raven Message', thread.name, () => { }) + + useFrappeEventListener('thread_reply_created', () => { + mutate() + }) + + return + {data ?? 0} + {data && data === 1 ? 'Reply' : 'Replies'} + +} \ No newline at end of file diff --git a/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx b/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx index e7bf3e96a..578992551 100644 --- a/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx @@ -41,17 +41,17 @@ type TiptapRendererProps = BoxProps & { user?: UserFields, showLinkPreview?: boolean, isScrolling?: boolean, - isTruncated?: boolean + showMiniImage?: boolean, } -export const TiptapRenderer = ({ message, user, isScrolling = false, isTruncated = false, showLinkPreview = true, ...props }: TiptapRendererProps) => { +export const TiptapRenderer = ({ message, user, isScrolling = false, showMiniImage = false, showLinkPreview = true, ...props }: TiptapRendererProps) => { const editor = useEditor({ content: message.text, editable: false, editorProps: { attributes: { - class: isTruncated ? 'tiptap-renderer line-clamp-3' : 'tiptap-renderer' + class: clsx('tiptap-renderer'), } }, enableCoreExtensions: true, @@ -122,7 +122,7 @@ export const TiptapRenderer = ({ message, user, isScrolling = false, isTruncated }), Image.configure({ HTMLAttributes: { - class: 'w-full max-w-48 sm:max-w-96 mt-1 h-auto' + class: showMiniImage ? 'w-auto h-16 max-h-16' : 'w-full max-w-48 sm:max-w-96 mt-1 h-auto' }, inline: true }), diff --git a/frontend/src/components/feature/chat/ChatStream/ChatBoxBody.tsx b/frontend/src/components/feature/chat/ChatStream/ChatBoxBody.tsx index 4110da8e0..fc8e5d59e 100644 --- a/frontend/src/components/feature/chat/ChatStream/ChatBoxBody.tsx +++ b/frontend/src/components/feature/chat/ChatStream/ChatBoxBody.tsx @@ -14,6 +14,8 @@ import { BiX } from "react-icons/bi" import ChatStream from "./ChatStream" import Tiptap from "../ChatInput/Tiptap" import useFetchChannelMembers from "@/hooks/fetchers/useFetchChannelMembers" +import { useParams } from "react-router-dom" +import clsx from "clsx" const COOL_PLACEHOLDERS = [ "Delivering messages atop dragons 🐉 is available on a chargeable basis.", @@ -79,8 +81,10 @@ export const ChatBoxBody = ({ channelData }: ChatBoxBodyProps) => { const isDM = channelData?.is_direct_message === 1 || channelData?.is_self_message === 1 + const { threadID } = useParams() + return ( - + { maxFiles={10} maxFileSize={10000000}> {channelData?.is_archived == 0 && (isUserInChannel || channelData?.type === 'Open') @@ -100,6 +105,7 @@ export const ChatBoxBody = ({ channelData }: ChatBoxBodyProps) => { addFile }} clearReplyMessage={handleCancelReply} + channelMembers={channelMembers} // placeholder={randomPlaceholder} replyMessage={selectedMessage} sessionStorageKey={`tiptap-${channelData.name}`} diff --git a/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx b/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx index c199a107b..4b8e6977b 100644 --- a/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx +++ b/frontend/src/components/feature/chat/ChatStream/ChatStream.tsx @@ -3,7 +3,6 @@ import { DeleteMessageDialog, useDeleteMessage } from '../ChatMessage/MessageAct import { EditMessageDialog, useEditMessage } from '../ChatMessage/MessageActions/EditMessage' import { MessageItem } from '../ChatMessage/MessageItem' import { ChannelHistoryFirstMessage } from '@/components/layout/EmptyState' -import { useParams } from 'react-router-dom' import useChatStream from './useChatStream' import { useRef } from 'react' import { Loader } from '@/components/common/Loader' @@ -64,16 +63,17 @@ import { ReactionAnalyticsDialog, useMessageReactionAnalytics } from '../ChatMes */ type Props = { + channelID: string, replyToMessage: (message: Message) => void, + showThreadButton?: boolean } -const ChatStream = ({ replyToMessage }: Props) => { +const ChatStream = ({ channelID, replyToMessage, showThreadButton = true }: Props) => { - const { channelID } = useParams() const scrollRef = useRef(null) - const { messages, hasOlderMessages, loadOlderMessages, goToLatestMessages, hasNewMessages, error, loadNewerMessages, isLoading, highlightedMessage, scrollToMessage } = useChatStream(scrollRef) + const { messages, hasOlderMessages, loadOlderMessages, goToLatestMessages, hasNewMessages, error, loadNewerMessages, isLoading, highlightedMessage, scrollToMessage } = useChatStream(channelID, scrollRef) const { setDeleteMessage, ...deleteProps } = useDeleteMessage() const { setEditMessage, ...editProps } = useEditMessage() @@ -142,9 +142,10 @@ const ChatStream = ({ replyToMessage }: Props) => { setEditMessage={setEditMessage} replyToMessage={replyToMessage} forwardMessage={setForwardMessage} + showThreadButton={showThreadButton} onAttachDocument={setAttachDocument} setDeleteMessage={setDeleteMessage} - setReactionMessage={setReactionMessage} + setReactionMessage={setReactionMessage} />
diff --git a/frontend/src/components/feature/chat/ChatStream/useChatStream.ts b/frontend/src/components/feature/chat/ChatStream/useChatStream.ts index 5500c7d2b..dd21778c2 100644 --- a/frontend/src/components/feature/chat/ChatStream/useChatStream.ts +++ b/frontend/src/components/feature/chat/ChatStream/useChatStream.ts @@ -1,6 +1,6 @@ import { useFrappeDocumentEventListener, useFrappeEventListener, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk' import { MutableRefObject, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useBeforeUnload, useLocation, useNavigate, useParams } from 'react-router-dom' +import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom' import { Message } from '../../../../../../types/Messaging/Message' import { getDateObject } from '@/utils/dateConversions/utils' import { useDebounce } from '@/hooks/useDebounce' @@ -23,9 +23,7 @@ type MessageDateBlock = Message | { /** * Hook to fetch messages to be rendered on the chat interface */ -const useChatStream = (scrollRef: MutableRefObject) => { - - const { channelID } = useParams() +const useChatStream = (channelID: string, scrollRef: MutableRefObject) => { const location = useLocation() const navigate = useNavigate() diff --git a/frontend/src/components/feature/chat/chat-footer/JoinChannelBox.tsx b/frontend/src/components/feature/chat/chat-footer/JoinChannelBox.tsx index 45af480e1..d3c5b22e8 100644 --- a/frontend/src/components/feature/chat/chat-footer/JoinChannelBox.tsx +++ b/frontend/src/components/feature/chat/chat-footer/JoinChannelBox.tsx @@ -4,24 +4,26 @@ import { useFrappeCreateDoc, useSWRConfig } from "frappe-react-sdk" import { Box, Flex, Text, Button } from "@radix-ui/themes" import { Loader } from "@/components/common/Loader" import { ChannelMembers } from "@/hooks/fetchers/useFetchChannelMembers" +import { useParams } from "react-router-dom" interface JoinChannelBoxProps { - channelData: ChannelListItem, + channelData?: ChannelListItem, channelMembers: ChannelMembers, - user: string + user: string, } export const JoinChannelBox = ({ channelData, user }: JoinChannelBoxProps) => { const { mutate } = useSWRConfig() + const { threadID } = useParams() const { createDoc, error, loading } = useFrappeCreateDoc() const joinChannel = async () => { return createDoc('Raven Channel Member', { - channel_id: channelData?.name, + channel_id: channelData ? channelData?.name : threadID, user_id: user }).then(() => { - mutate(["channel_members", channelData?.name]) + mutate(["channel_members", channelData ? channelData.name : threadID]) }) } @@ -30,20 +32,17 @@ export const JoinChannelBox = ({ channelData, user }: JoinChannelBoxProps) => { + p={channelData ? '4' : '3'}> - You are not a member of this channel. + You are not a member of this {channelData ? 'channel' : 'thread'}. diff --git a/frontend/src/components/feature/chat/chat-space/ChannelSpace.tsx b/frontend/src/components/feature/chat/chat-space/ChannelSpace.tsx index 8ce8386a8..dfa24de30 100644 --- a/frontend/src/components/feature/chat/chat-space/ChannelSpace.tsx +++ b/frontend/src/components/feature/chat/chat-space/ChannelSpace.tsx @@ -2,6 +2,7 @@ import { Box } from '@radix-ui/themes' import { ChannelListItem } from '@/utils/channel/ChannelListProvider' import { ChatBoxBody } from '../ChatStream/ChatBoxBody' import { ChannelHeader } from '../../chat-header/ChannelHeader' +import { useParams } from 'react-router-dom' interface ChannelSpaceProps { channelData: ChannelListItem @@ -9,6 +10,8 @@ interface ChannelSpaceProps { export const ChannelSpace = ({ channelData }: ChannelSpaceProps) => { + // const { threadID } = useParams() + return ( diff --git a/frontend/src/components/feature/polls/ViewPollVotes.tsx b/frontend/src/components/feature/polls/ViewPollVotes.tsx index 554ed5491..4bd8e5de0 100644 --- a/frontend/src/components/feature/polls/ViewPollVotes.tsx +++ b/frontend/src/components/feature/polls/ViewPollVotes.tsx @@ -35,7 +35,6 @@ export const ViewPollVotes = ({ poll }: ViewPollVotesProps) => { return ( - diff --git a/frontend/src/components/feature/threads/ThreadDrawer/ThreadDrawer.tsx b/frontend/src/components/feature/threads/ThreadDrawer/ThreadDrawer.tsx new file mode 100644 index 000000000..aa99ec21d --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadDrawer/ThreadDrawer.tsx @@ -0,0 +1,31 @@ +import { Box, Flex } from '@radix-ui/themes' +import { useParams } from 'react-router-dom' +import { ThreadMessages } from './ThreadMessages' +import { useFrappeGetDoc } from 'frappe-react-sdk' +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { FullPageLoader } from '@/components/layout/Loaders' +import { ThreadHeader } from './ThreadHeader' +import { Message } from '../../../../../../types/Messaging/Message' + +const ThreadDrawer = () => { + + const { threadID } = useParams() + const { data, error, isLoading } = useFrappeGetDoc('Raven Message', threadID, threadID, { + revalidateOnFocus: false, + shouldRetryOnError: false, + keepPreviousData: false + }) + + return ( +
+ + + {isLoading && } + {error && } + {data && } + +
+ ) +} + +export const Component = ThreadDrawer \ No newline at end of file diff --git a/frontend/src/components/feature/threads/ThreadDrawer/ThreadFirstMessage.tsx b/frontend/src/components/feature/threads/ThreadDrawer/ThreadFirstMessage.tsx new file mode 100644 index 000000000..6cb584947 --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadDrawer/ThreadFirstMessage.tsx @@ -0,0 +1,84 @@ +import { UserFields } from '@/utils/users/UserListProvider' +import { FileMessage, Message, PollMessage } from '../../../../../../types/Messaging/Message' +import { Box, BoxProps, Button, Flex, Text } from '@radix-ui/themes' +import { TiptapRenderer } from '../../chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer' +import { useGetUser } from '@/hooks/useGetUser' +import { MessageSenderAvatar, UserHoverCard } from '../../chat/ChatMessage/MessageItem' +import { useIsUserActive } from '@/hooks/useIsUserActive' +import { useLayoutEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { MdOutlineBarChart } from 'react-icons/md' +import { getFileExtension, getFileName } from '@/utils/operations' +import { FileExtensionIcon } from '@/utils/layout/FileExtIcon' + +type MessageContentProps = BoxProps & { + user?: UserFields + message: Message, +} +export const ThreadFirstMessage = ({ message, user, ...props }: MessageContentProps) => { + + const threadOwner = useGetUser(message.owner) + + const isActive = useIsUserActive(message.owner) + + const contentRef = useRef(null) + + const [showMore, setShowMore] = useState(!(message.text?.length)) + + const [contentHeight, setContentHeight] = useState(null) + + useLayoutEffect(() => { + setContentHeight(contentRef.current?.clientHeight ?? null) + }) + + const showButton = ((contentHeight && contentHeight == 24) || showMore) && message.message_type === 'Text' + + return + + + + + + + + {message.text ? + + : null} + + {message.message_type === 'Poll' ? + + Poll: {(message as PollMessage).content?.split("\n")?.[0]} + : + ['File', 'Image'].includes(message.message_type ?? 'Text') ? + + {message.message_type === 'File' && message.file && } + {message.message_type === 'Image' && {`Image} + + {getFileName((message as FileMessage).file)} + + : null + } + + + {showButton && + + } + + + +} + +export default ThreadFirstMessage \ No newline at end of file diff --git a/frontend/src/components/feature/threads/ThreadDrawer/ThreadHeader.tsx b/frontend/src/components/feature/threads/ThreadDrawer/ThreadHeader.tsx new file mode 100644 index 000000000..f8f11c879 --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadDrawer/ThreadHeader.tsx @@ -0,0 +1,151 @@ +import { useNavigate, useParams } from "react-router-dom" +import { DropdownMenu, Flex, Heading, IconButton } from "@radix-ui/themes" +import { BiBell, BiBellOff, BiDotsVerticalRounded, BiExit } from "react-icons/bi" +import { useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { toast } from "sonner" +import { getErrorMessage } from "@/components/layout/AlertBanner/ErrorBanner" +import { AiOutlineClose } from "react-icons/ai" +import { useUserData } from "@/hooks/useUserData" +import useFetchChannelMembers, { Member } from "@/hooks/fetchers/useFetchChannelMembers" +import { useMemo } from "react" +import useIsPushNotificationEnabled from "@/hooks/fetchers/useIsPushNotificationEnabled" + +export const ThreadHeader = () => { + + const navigate = useNavigate() + + const { threadID } = useParams() + + const { name: user } = useUserData() + const { channelMembers } = useFetchChannelMembers(threadID ?? '') + + const channelMember = useMemo(() => { + if (user && channelMembers) { + return channelMembers[user] ?? null + } + return null + }, [user, channelMembers]) + + + return ( +
+ + + Thread + + {channelMember && + + + + + + + + + + + + } + navigate('../', { replace: true })}> + + + + + +
+ ) +} + + +const LeaveThreadButton = () => { + + const { threadID } = useParams() + const { mutate } = useSWRConfig() + const navigate = useNavigate() + + const { call } = useFrappePostCall('raven.api.raven_channel.leave_channel') + + const onLeaveThread = () => { + + const promise = call({ channel_id: threadID }).then(() => { + navigate('../') + mutate(["channel_members", threadID]) + + return Promise.resolve() + }) + + toast.promise(promise, { + success: 'You have left the thread', + error: (e) => `Could not leave thread - ${getErrorMessage(e)}` + }) + } + + return ( + + + + Leave Thread + + + ) +} + +const ToggleNotificationButton = ({ channelMember }: { channelMember: Member }) => { + + const { threadID } = useParams() + const { mutate } = useSWRConfig() + + const isPushAvailable = useIsPushNotificationEnabled() + + const { call } = useFrappePostCall('raven.api.notification.toggle_push_notification_for_channel') + + const onToggle = () => { + if (channelMember) { + const promise = call({ + member: channelMember?.channel_member_name, + allow_notifications: channelMember?.allow_notifications ? 0 : 1 + }) + .then((res) => { + if (res && res.message) { + mutate(["channel_members", threadID], (existingMembers: any) => { + return { + message: { + ...existingMembers.message, + [channelMember.name]: { + ...existingMembers.message[channelMember.name], + allow_notifications: res.message.allow_notifications + } + } + } + }, { + revalidate: false + }) + } + + return Promise.resolve() + }) + + toast.promise(promise, { + success: 'Notification settings updated', + error: 'Failed to update notification settings' + }) + } + } + + if (!isPushAvailable) return null + + return ( + + + {channelMember.allow_notifications ? : } + {channelMember.allow_notifications ? 'Disable' : 'Enable'} Notifications + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx b/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx new file mode 100644 index 000000000..776d0fac2 --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx @@ -0,0 +1,103 @@ +import { IconButton, Flex, Box } from "@radix-ui/themes" +import { useMemo, useState } from "react" +import { BiX } from "react-icons/bi" +import useFileUpload from "../../chat/ChatInput/FileInput/useFileUpload" +import Tiptap from "../../chat/ChatInput/Tiptap" +import { useSendMessage } from "../../chat/ChatInput/useSendMessage" +import { ReplyMessageBox } from "../../chat/ChatMessage/ReplyMessageBox/ReplyMessageBox" +import { CustomFile } from "../../file-upload/FileDrop" +import { FileListItem } from "../../file-upload/FileListItem" +import { useParams } from "react-router-dom" +import { Message } from "../../../../../../types/Messaging/Message" +import ChatStream from "../../chat/ChatStream/ChatStream" +import { JoinChannelBox } from "../../chat/chat-footer/JoinChannelBox" +import { useUserData } from "@/hooks/useUserData" +import useFetchChannelMembers from "@/hooks/fetchers/useFetchChannelMembers" +import ThreadFirstMessage from "./ThreadFirstMessage" + +export const ThreadMessages = ({ threadMessage }: { threadMessage: Message }) => { + + const { threadID, channelID } = useParams() + + const { channelMembers } = useFetchChannelMembers(channelID ?? '') + + + const [selectedMessage, setSelectedMessage] = useState(null) + + const handleReplyAction = (message: Message) => { + setSelectedMessage(message) + } + + const handleCancelReply = () => { + setSelectedMessage(null) + } + + const { fileInputRef, files, removeFile, uploadFiles, addFile, fileUploadProgress } = useFileUpload(threadID ?? '') + + const { sendMessage, loading } = useSendMessage(threadID ?? '', files.length, uploadFiles, handleCancelReply, selectedMessage) + + const PreviousMessagePreview = ({ selectedMessage }: { selectedMessage: any }) => { + + if (selectedMessage) { + return + + + + + } + return null + } + + const { name: user } = useUserData() + const { channelMembers: threadMembers } = useFetchChannelMembers(threadID ?? '') + + const isUserInChannel = useMemo(() => { + if (user && threadMembers) { + return user in threadMembers + } + return false + }, [user, threadMembers]) + + + + return ( + + + + {!isUserInChannel && } + {isUserInChannel && } + />} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/feature/threads/ThreadParticipants.tsx b/frontend/src/components/feature/threads/ThreadParticipants.tsx new file mode 100644 index 000000000..e988f5854 --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadParticipants.tsx @@ -0,0 +1,37 @@ +import { Avatar } from "@radix-ui/themes" +import { UserAvatar } from "@/components/common/UserAvatar" +import { useGetUser } from "@/hooks/useGetUser" + +interface ViewThreadParticipantsProps { + participants: { user_id: string }[], +} + +export const ViewThreadParticipants = ({ participants }: ViewThreadParticipantsProps) => { + + const totalParticipants = Object.keys(participants).length + const extraNumber = Math.min(totalParticipants - 3, 9) + + return ( +
+ {participants.map((member, index) => { + const user = useGetUser(member.user_id) + if (index < 3) + return + })} + {totalParticipants > 3 && + + } +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/feature/threads/ThreadPreviewBox.tsx b/frontend/src/components/feature/threads/ThreadPreviewBox.tsx new file mode 100644 index 000000000..e160864a3 --- /dev/null +++ b/frontend/src/components/feature/threads/ThreadPreviewBox.tsx @@ -0,0 +1,76 @@ +import { Box, Button, Flex, Text } from '@radix-ui/themes' +import { DateMonthYear } from '@/utils/dateConversions' +import { MessageContent, MessageSenderAvatar, UserHoverCard } from '../chat/ChatMessage/MessageItem' +import { useGetUser } from '@/hooks/useGetUser' +import { useCurrentChannelData } from '@/hooks/useCurrentChannelData' +import { ChannelIcon } from '@/utils/layout/channelIcon' +import { Link } from 'react-router-dom' +import { ThreadMessage } from './Threads' +import { Message } from '../../../../../types/Messaging/Message' +import { ViewThreadParticipants } from './ThreadParticipants' +import { useMemo } from 'react' +import { DMChannelListItem } from '@/utils/channel/ChannelListProvider' +import { useGetUserRecords } from '@/hooks/useGetUserRecords' +import { ThreadReplyCount } from '../chat/ChatMessage/Renderers/ThreadMessage' + +export const ThreadPreviewBox = ({ thread }: { thread: ThreadMessage }) => { + + const user = useGetUser(thread.owner) + const users = useGetUserRecords() + const { channel } = useCurrentChannelData(thread.channel_id) + const channelData = channel?.channelData + const channelDetails = useMemo(() => { + if (channelData) { + if (channelData.is_direct_message) { + const peer_user_name = users[(channelData as DMChannelListItem).peer_user_id]?.full_name ?? (channelData as DMChannelListItem).peer_user_id + return { + channelIcon: '', + channelName: `DM with ${peer_user_name}` + } + } else { + return { + channelIcon: channelData.type, + channelName: channelData.channel_name + } + } + } + }, [channelData, users]) + + return ( + + + + {channelDetails?.channelIcon && } + {channelDetails?.channelName} + + + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/feature/threads/Threads.tsx b/frontend/src/components/feature/threads/Threads.tsx new file mode 100644 index 000000000..c8bd1a523 --- /dev/null +++ b/frontend/src/components/feature/threads/Threads.tsx @@ -0,0 +1,65 @@ +import { ErrorBanner } from "@/components/layout/AlertBanner" +import { EmptyStateForThreads } from "@/components/layout/EmptyState/EmptyState" +import { PageHeader } from "@/components/layout/Heading/PageHeader" +import { Box, Flex, Heading } from "@radix-ui/themes" +import { useFrappeGetCall } from "frappe-react-sdk" +import { BiChevronLeft } from "react-icons/bi" +import { Link } from "react-router-dom" +import { ThreadPreviewBox } from "./ThreadPreviewBox" + +export type ThreadMessage = { + bot: string, + channel_id: string, + content: string, + creation: string, + file: string, + hide_link_preview: 0 | 1, + image_height: string + image_width: string, + is_bot_message: 0 | 1, + last_message_details: string, + last_message_timestamp: string, + link_doctype: string, + link_document: string, + message_type: "Text" | "Image" | "File" | "Poll", + name: string, + owner: string, + poll_id: string, + text: string, + thread_message_id: string, + participants: { user_id: string }[], +} + +const Threads = () => { + + const { data, error } = useFrappeGetCall<{ message: ThreadMessage[] }>("raven.api.threads.get_all_threads", { + revalidateOnFocus: false + }) + + return ( + + + + + + + Threads + + + +
+ {data && <> + {data.message?.length === 0 ? + : + + {data.message.map((thread) => { + return + })} + } + } +
+
+ ) +} + +export const Component = Threads \ No newline at end of file diff --git a/frontend/src/components/layout/Divider/DateSeparator.tsx b/frontend/src/components/layout/Divider/DateSeparator.tsx index 694e6a779..2fc014037 100644 --- a/frontend/src/components/layout/Divider/DateSeparator.tsx +++ b/frontend/src/components/layout/Divider/DateSeparator.tsx @@ -10,7 +10,7 @@ export const DateSeparator = (props: FlexProps) => { - + { ) } +export const EmptyStateForThreads = () => { + return ( + + + No threads to show + + Threads are a way to keep conversations organized and focused. You can create a thread by replying to a message. + You can also start a thread by clicking on the Create Thread button on any message. + + + + ) +} + interface ChannelHistoryFirstMessageProps { channelID: string } diff --git a/frontend/src/components/layout/Heading/PageHeader.tsx b/frontend/src/components/layout/Heading/PageHeader.tsx index 8048a06e7..e425b93bf 100644 --- a/frontend/src/components/layout/Heading/PageHeader.tsx +++ b/frontend/src/components/layout/Heading/PageHeader.tsx @@ -1,15 +1,19 @@ import { PropsWithChildren } from 'react' import { Box, Flex } from '@radix-ui/themes' +import { useParams } from 'react-router-dom' +import clsx from 'clsx' export const PageHeader = ({ children }: PropsWithChildren) => { + + const { threadID } = useParams() + return ( -
+
+ className={clsx('border-gray-4 sm:dark:border-gray-6 border-b px-4 sm:px-0 sm:ml-4', + threadID ? 'sm:w-[calc((100vw-var(--sidebar-width)-var(--space-8))/2)] w-screen' : + 'sm:w-[calc(100vw-var(--sidebar-width)-var(--space-6))] w-screen')}> {children} diff --git a/frontend/src/components/layout/Sidebar/SidebarBody.tsx b/frontend/src/components/layout/Sidebar/SidebarBody.tsx index 2df80e5c0..55ce8b77c 100644 --- a/frontend/src/components/layout/Sidebar/SidebarBody.tsx +++ b/frontend/src/components/layout/Sidebar/SidebarBody.tsx @@ -2,9 +2,11 @@ import { ChannelList } from '../../feature/channels/ChannelList' import { DirectMessageList } from '../../feature/direct-messages/DirectMessageList' import { SidebarItem } from './SidebarComp' import { AccessibleIcon, Box, Flex, ScrollArea, Text } from '@radix-ui/themes' -import { BiSolidBookmark } from 'react-icons/bi' import useUnreadMessageCount from '@/hooks/useUnreadMessageCount' import PinnedChannels from './PinnedChannels' +import React from 'react' +import { BiMessageAltDetail } from 'react-icons/bi' +import { LuBookmark } from 'react-icons/lu' export const SidebarBody = () => { @@ -13,20 +15,17 @@ export const SidebarBody = () => { return ( - - - - - - - - Saved - - - + + } + iconLabel='Threads' /> + } + iconLabel='Saved Message' /> @@ -34,4 +33,29 @@ export const SidebarBody = () => { ) +} + +interface SidebarItemForPageProps { + to: string + label: string + icon: React.ReactNode + iconLabel: string +} + +const SidebarItemForPage = ({ to, label, icon, iconLabel }: SidebarItemForPageProps) => { + return ( + + + + {icon} + + + {label} + + + + ) } \ No newline at end of file diff --git a/frontend/src/pages/ChatSpace.tsx b/frontend/src/pages/ChatSpace.tsx index 5021471c9..b2605b80c 100644 --- a/frontend/src/pages/ChatSpace.tsx +++ b/frontend/src/pages/ChatSpace.tsx @@ -4,17 +4,17 @@ import { ErrorBanner } from "@/components/layout/AlertBanner" import { FullPageLoader } from "@/components/layout/Loaders" import { useCurrentChannelData } from "@/hooks/useCurrentChannelData" import { useEffect } from "react" -import { Box } from '@radix-ui/themes' -import { useLocation, useParams } from "react-router-dom" +import { Box, Grid } from '@radix-ui/themes' +import { Outlet, useLocation, useParams } from "react-router-dom" import { useSWRConfig } from "frappe-react-sdk" import { UnreadChannelCountItem, UnreadCountData } from "@/utils/channel/ChannelListProvider" +import { useIsMobile } from "@/hooks/useMediaQuery" const ChatSpace = () => { // only if channelID is present render ChatSpaceArea component' const { channelID } = useParams<{ channelID: string }>() // const className = 'bg-white dark:from-accent-1 dark:to-95% dark:to-accent-2 dark:bg-gradient-to-b' - return
{channelID && }
@@ -25,6 +25,10 @@ export const Component = ChatSpace const ChatSpaceArea = ({ channelID }: { channelID: string }) => { + const { threadID } = useParams() + + const isMobile = useIsMobile() + const { channel, error, isLoading } = useCurrentChannelData(channelID) const { mutate, cache } = useSWRConfig() @@ -93,13 +97,16 @@ const ChatSpaceArea = ({ channelID }: { channelID: string }) => { }, [channelID, state?.baseMessage]) - return - {isLoading && } - - {channel ? - channel.type === "dm" ? - - : - : null} - + return + {threadID && isMobile ? null : + {isLoading && } + + {channel ? + channel.type === "dm" ? + + : + : null} + } + + } \ No newline at end of file diff --git a/frontend/src/types/RavenChannelManagement/RavenChannel.ts b/frontend/src/types/RavenChannelManagement/RavenChannel.ts index a598bad14..a9756fd10 100644 --- a/frontend/src/types/RavenChannelManagement/RavenChannel.ts +++ b/frontend/src/types/RavenChannelManagement/RavenChannel.ts @@ -12,7 +12,7 @@ export interface RavenChannel{ idx?: number /** Channel Name : Data */ channel_name: string - /** Channel Description : Data */ + /** Channel Description : Small Text */ channel_description?: string /** Type : Select */ type: "Private" | "Public" | "Open" @@ -24,6 +24,8 @@ export interface RavenChannel{ linked_document?: string /** Is Direct Message : Check */ is_direct_message?: 0 | 1 + /** Is Thread : Check */ + is_thread?: 0 | 1 /** Is Self Message : Check */ is_self_message?: 0 | 1 /** Is Archived : Check */ diff --git a/frontend/src/types/RavenMessaging/RavenMessage.ts b/frontend/src/types/RavenMessaging/RavenMessage.ts index 326ac4cfd..5439dc389 100644 --- a/frontend/src/types/RavenMessaging/RavenMessage.ts +++ b/frontend/src/types/RavenMessaging/RavenMessage.ts @@ -1,8 +1,8 @@ import { RavenMention } from './RavenMention' -export interface RavenMessage { - creation: string +export interface RavenMessage{ name: string + creation: string modified: string owner: string modified_by: string @@ -25,6 +25,8 @@ export interface RavenMessage { linked_message?: string /** Replied Message Details : JSON */ replied_message_details?: any + /** Is Thread : Check - This message starts a thread */ + is_thread?: 0 | 1 /** Message Type : Select */ message_type?: "Text" | "Image" | "File" | "Poll" /** Content : Long Text */ diff --git a/package.json b/package.json index 10cd0808e..c10769ea4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "raven", - "version": "1.6.13", + "version": "1.7.0", "description": "Messaging Application", "workspaces": [ "frontend" diff --git a/raven/__init__.py b/raven/__init__.py index 765214ea8..14d9d2f58 100644 --- a/raven/__init__.py +++ b/raven/__init__.py @@ -1 +1 @@ -__version__ = "1.6.13" +__version__ = "1.7.0" diff --git a/raven/api/chat_stream.py b/raven/api/chat_stream.py index 573991993..1525f7dd6 100644 --- a/raven/api/chat_stream.py +++ b/raven/api/chat_stream.py @@ -53,6 +53,7 @@ def get_messages(channel_id: str, limit: int = 20, base_message: str | None = No message.is_bot_message, message.bot, message.hide_link_preview, + message.is_thread, ) .where(message.channel_id == channel_id) .orderby(message.creation, order=Order.desc) @@ -159,6 +160,7 @@ def fetch_older_messages( message.is_bot_message, message.bot, message.hide_link_preview, + message.is_thread, ) .where(message.channel_id == channel_id) .where( @@ -269,6 +271,7 @@ def fetch_newer_messages( message.is_bot_message, message.bot, message.hide_link_preview, + message.is_thread, ) .where(message.channel_id == channel_id) .where(condition) diff --git a/raven/api/raven_channel.py b/raven/api/raven_channel.py index 5da6bf615..5d035fe64 100644 --- a/raven/api/raven_channel.py +++ b/raven/api/raven_channel.py @@ -64,6 +64,7 @@ def get_channel_list(hide_archived=False): .left_join(channel_member) .on(channel.name == channel_member.channel_id) .where((channel.type != "Private") | (channel_member.user_id == frappe.session.user)) + .where(channel.is_thread == 0) ) if hide_archived: @@ -178,3 +179,19 @@ def toggle_pinned_channel(channel_id): raven_user.save() return raven_user + + +@frappe.whitelist() +def leave_channel(channel_id): + """ + Leave a channel + """ + members = frappe.get_all( + "Raven Channel Member", + filters={"channel_id": channel_id, "user_id": frappe.session.user}, + ) + + for member in members: + frappe.delete_doc("Raven Channel Member", member.name) + + return "Ok" diff --git a/raven/api/raven_message.py b/raven/api/raven_message.py index 34f626d0b..a902a169a 100644 --- a/raven/api/raven_message.py +++ b/raven/api/raven_message.py @@ -88,6 +88,7 @@ def get_messages(channel_id): "replied_message_details", "content", "is_edited", + "is_thread", "is_forwarded", ], order_by="creation asc", @@ -219,6 +220,7 @@ def get_unread_count_for_channels(): ) .where((channel.type == "Open") | (channel_member.user_id == frappe.session.user)) .where(channel.is_archived == 0) + .where(channel.is_thread == 0) .left_join(message) .on(channel.name == message.channel_id) ) diff --git a/raven/api/raven_mobile.py b/raven/api/raven_mobile.py new file mode 100644 index 000000000..f0432bb83 --- /dev/null +++ b/raven/api/raven_mobile.py @@ -0,0 +1,5 @@ +import frappe + +@frappe.whitelist(allow_guest=True) +def get_client_id(): + return frappe.db.get_single_value("Raven Settings", "oauth_client") \ No newline at end of file diff --git a/raven/api/threads.py b/raven/api/threads.py new file mode 100644 index 000000000..2fbe036f0 --- /dev/null +++ b/raven/api/threads.py @@ -0,0 +1,116 @@ +import frappe +from frappe import _ +from frappe.query_builder import Order + + +@frappe.whitelist() +def get_all_threads(): + """ + Get all the threads in which the user is a participant + (We are not fetching the messages inside a thread here, just the main thread message, + We will fetch the messages inside a thread when the user clicks on 'View Thread') + """ + + # Fetch all channels in which is_thread = 1 and the current user is a member + + channel = frappe.qb.DocType("Raven Channel") + channel_member = frappe.qb.DocType("Raven Channel Member") + message = frappe.qb.DocType("Raven Message") + + query = ( + frappe.qb.from_(channel) + .select( + channel.name, + channel.last_message_timestamp, + channel.last_message_details, + message.name.as_("thread_message_id"), + message.channel_id, + message.message_type, + message.text, + message.content, + message.file, + message.poll_id, + message.is_bot_message, + message.bot, + message.hide_link_preview, + message.link_doctype, + message.link_document, + message.image_height, + message.image_width, + message.owner, + message.creation, + ) + .left_join(message) + .on(channel.name == message.name) + .left_join(channel_member) + .on(channel.name == channel_member.channel_id) + .where(channel_member.user_id == frappe.session.user) + .where(channel.is_thread == 1) + ) + + query = query.orderby(channel.last_message_timestamp, order=Order.desc) + threads = query.run(as_dict=True) + + for thread in threads: + # Fetch the participants of the thread + thread["participants"] = frappe.get_all( + "Raven Channel Member", + filters={"channel_id": thread["name"]}, + fields=["user_id"], + ) + + return threads + + +@frappe.whitelist(methods="POST") +def create_thread(message_id): + """ + A thread can be created by any user with read access to the channel in which the message has been sent. + The thread will be created with this user as the first participant. + (If the user is not the sender of the message, the sender will be added as the second participant) + """ + + # Convert the message to a thread + thread_channel = frappe.get_doc( + { + "doctype": "Raven Channel", + "channel_name": message_id, + "type": "Private", + "is_thread": 1, + } + ).insert() + + # Add the creator of the original message as a participant + creator = frappe.get_cached_value("Raven Message", message_id, "owner") + + if creator != frappe.session.user: + frappe.get_doc( + {"doctype": "Raven Channel Member", "channel_id": thread_channel.name, "user_id": creator} + ).insert() + + # If the thread is created in a DM channel, add both DM channel members as participants + channel_id = frappe.get_cached_value("Raven Message", message_id, "channel_id") + if channel_id: + is_dm_channel = frappe.get_cached_value("Raven Channel", channel_id, "is_direct_message") == 1 + if is_dm_channel: + participants = frappe.get_all( + "Raven Channel Member", + filters={"channel_id": channel_id}, + fields=["user_id"], + ) + for participant in participants: + if participant["user_id"] != creator and participant["user_id"] != frappe.session.user: + frappe.get_doc( + { + "doctype": "Raven Channel Member", + "channel_id": thread_channel.name, + "user_id": participant["user_id"], + } + ).insert() + + # Update the message to mark it as a thread + thread_message = frappe.get_cached_doc("Raven Message", message_id) + thread_message.is_thread = 1 + thread_message.save(ignore_permissions=True) + + return {"channel_id": thread_message.channel_id, "thread_id": thread_channel.name} diff --git a/raven/hooks.py b/raven/hooks.py index 4d775a66d..4173d15d1 100644 --- a/raven/hooks.py +++ b/raven/hooks.py @@ -8,7 +8,7 @@ app_license = "AGPLv3" source_link = "https://github.com/The-Commit-Company/Raven" app_logo = "/assets/raven/raven-logo.png" -app_logo_url = "/assets/raven/raven-logo.png" +# app_logo_url = "/assets/raven/raven-logo.png" # Includes in # ------------------ diff --git a/raven/package.json b/raven/package.json index 6848da4ea..0af8618f9 100644 --- a/raven/package.json +++ b/raven/package.json @@ -1,6 +1,6 @@ { "name": "raven-app", - "version": "1.6.13", + "version": "1.7.0", "description": "", "main": "index.js", "scripts": { diff --git a/raven/permissions.py b/raven/permissions.py index 0200fa91c..cecfa4bb1 100644 --- a/raven/permissions.py +++ b/raven/permissions.py @@ -35,6 +35,11 @@ def channel_has_permission(doc, user=None, ptype=None): if doc.type == "Open" or doc.type == "Public": return True elif doc.type == "Private": + if doc.is_thread: + if ptype == "read" or ptype == "create": + # Only users who are part of the original channel can read the thread + return frappe.has_permission(doctype="Raven Message", doc=doc.name, ptype="read") + if frappe.db.exists("Raven Channel Member", {"channel_id": doc.name, "user_id": user}): return True elif ( diff --git a/raven/raven/doctype/raven_settings/raven_settings.json b/raven/raven/doctype/raven_settings/raven_settings.json index 735543484..6efedddbb 100644 --- a/raven/raven/doctype/raven_settings/raven_settings.json +++ b/raven/raven/doctype/raven_settings/raven_settings.json @@ -16,7 +16,9 @@ "auto_create_department_channel", "department_channel_type", "attendance_and_leaves_section", - "show_if_a_user_is_on_leave" + "show_if_a_user_is_on_leave", + "raven_mobile_tab", + "oauth_client" ], "fields": [ { @@ -79,12 +81,23 @@ "fieldname": "show_if_a_user_is_on_leave", "fieldtype": "Check", "label": "Show if a user is on leave" + }, + { + "fieldname": "raven_mobile_tab", + "fieldtype": "Tab Break", + "label": "Raven Mobile" + }, + { + "fieldname": "oauth_client", + "fieldtype": "Link", + "label": "OAuth Client", + "options": "OAuth Client" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-07-03 16:53:42.621934", + "modified": "2024-08-26 17:35:23.570104", "modified_by": "Administrator", "module": "Raven", "name": "Raven Settings", diff --git a/raven/raven/doctype/raven_settings/raven_settings.py b/raven/raven/doctype/raven_settings/raven_settings.py index ced56fb1a..5c77ecefb 100644 --- a/raven/raven/doctype/raven_settings/raven_settings.py +++ b/raven/raven/doctype/raven_settings/raven_settings.py @@ -17,6 +17,7 @@ class RavenSettings(Document): auto_add_system_users: DF.Check auto_create_department_channel: DF.Check department_channel_type: DF.Literal["Public", "Private"] + oauth_client: DF.Link | None show_if_a_user_is_on_leave: DF.Check show_raven_on_desk: DF.Check tenor_api_key: DF.Data | None 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 d1fa6a4fe..55d3a665e 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -16,6 +16,7 @@ "linked_document", "section_break_evg4", "is_direct_message", + "is_thread", "column_break_puci", "is_self_message", "column_break_ubts", @@ -67,7 +68,7 @@ }, { "fieldname": "channel_description", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "Channel Description" }, { @@ -134,6 +135,13 @@ "fieldtype": "Check", "label": "Is Synced", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_thread", + "fieldtype": "Check", + "label": "Is Thread", + "read_only": 1 } ], "index_web_pages_for_search": 1, @@ -147,7 +155,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2024-06-20 18:26:25.559899", + "modified": "2024-08-18 20:45:00.510115", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", 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 74f04ccad..99bef6715 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -15,12 +15,13 @@ class RavenChannel(Document): if TYPE_CHECKING: from frappe.types import DF - channel_description: DF.Data | None + channel_description: DF.SmallText | None channel_name: DF.Data is_archived: DF.Check is_direct_message: DF.Check is_self_message: DF.Check is_synced: DF.Check + is_thread: DF.Check last_message_details: DF.JSON | None last_message_timestamp: DF.Datetime | None linked_doctype: DF.Link | None diff --git a/raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py b/raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py index 052daaf17..8e31e1038 100644 --- a/raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py +++ b/raven/raven_channel_management/doctype/raven_channel_member/raven_channel_member.py @@ -84,6 +84,8 @@ def on_trash(self): frappe.PermissionError, ) + unsubscribe_user_to_topic(self.channel_id, self.user_id) + def check_if_user_is_member(self): is_member = True channel = frappe.db.get_value("Raven Channel", self.channel_id, ["type", "owner"], as_dict=True) diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.json b/raven/raven_messaging/doctype/raven_message/raven_message.json index f072eb9fe..860d1b08a 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.json +++ b/raven/raven_messaging/doctype/raven_message/raven_message.json @@ -16,6 +16,7 @@ "linked_message", "replied_message_details", "column_break_wvje", + "is_thread", "message_type", "content", "file", @@ -179,11 +180,18 @@ "fieldname": "is_forwarded", "fieldtype": "Check", "label": "Is Forwarded" + }, + { + "default": "0", + "description": "This message starts a thread", + "fieldname": "is_thread", + "fieldtype": "Check", + "label": "Is Thread" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-06-23 13:47:48.657331", + "modified": "2024-08-16 16:09:36.292391", "modified_by": "Administrator", "module": "Raven Messaging", "name": "Raven Message", diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index e1a5719bf..6c2676ba6 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -37,6 +37,7 @@ class RavenMessage(Document): is_edited: DF.Check is_forwarded: DF.Check is_reply: DF.Check + is_thread: DF.Check json: DF.JSON | None link_doctype: DF.Link | None link_document: DF.DynamicLink | None @@ -120,23 +121,23 @@ def after_insert(self): self.publish_unread_count_event() def publish_unread_count_event(self): - frappe.db.set_value( - "Raven Channel", self.channel_id, "last_message_timestamp", self.creation, update_modified=False - ) frappe.db.set_value( "Raven Channel", self.channel_id, - "last_message_details", - json.dumps( - { - "message_id": self.name, - "content": self.content if self.message_type == "Text" else self.file, - "message_type": self.message_type, - "owner": self.owner, - "is_bot_message": self.is_bot_message, - "bot": self.bot, - } - ), + { + "last_message_timestamp": self.creation, + "last_message_details": json.dumps( + { + "message_id": self.name, + "content": self.content if self.message_type == "Text" else self.file, + "message_type": self.message_type, + "owner": self.owner, + "is_bot_message": self.is_bot_message, + "bot": self.bot, + } + ), + }, + update_modified=False, ) channel_doc = frappe.get_cached_doc("Raven Channel", self.channel_id) @@ -174,6 +175,17 @@ def publish_unread_count_event(self): user=self.owner, after_commit=True, ) + elif channel_doc.is_thread: + frappe.publish_realtime( + "thread_reply_created", + { + "channel_id": self.channel_id, + "sent_by": self.owner, + }, + after_commit=True, + doctype="Raven Message", + docname=self.channel_id, + ) else: # This event needs to be published to all users on Raven (desk + website) frappe.publish_realtime( @@ -182,6 +194,7 @@ def publish_unread_count_event(self): "channel_id": self.channel_id, "play_sound": False, "sent_by": self.owner, + "is_thread": channel_doc.is_thread, }, after_commit=True, room="website", @@ -289,10 +302,15 @@ def send_notification_for_channel_message(self): """ message = self.get_notification_message_content() - channel_name = frappe.get_cached_value("Raven Channel", self.channel_id, "channel_name") + is_thread = frappe.get_cached_value("Raven Channel", self.channel_id, "is_thread") owner_name = self.get_message_owner_name() - title = f"{owner_name} in #{channel_name}" + + if is_thread: + title = f"{owner_name} in thread" + else: + channel_name = frappe.get_cached_value("Raven Channel", self.channel_id, "channel_name") + title = f"{owner_name} in #{channel_name}" send_notification_to_topic( channel_id=self.channel_id, @@ -371,7 +389,7 @@ def on_update(self): # TEMP: this is a temp fix for the Desk interface self.publish_deprecated_event_for_desk() - if self.is_edited: + if self.is_edited or self.is_thread: frappe.publish_realtime( "message_edited", { @@ -385,6 +403,7 @@ def on_update(self): "poll_id": self.poll_id, "message_type": self.message_type, "is_edited": 1 if self.is_edited else 0, + "is_thread": self.is_thread, "is_forwarded": self.is_forwarded, "is_reply": self.is_reply, "modified": self.modified, @@ -426,6 +445,7 @@ def on_update(self): "file": self.file, "message_type": self.message_type, "is_edited": 1 if self.is_edited else 0, + "is_thread": self.is_thread, "is_forwarded": self.is_forwarded, "is_reply": self.is_reply, "poll_id": self.poll_id, @@ -462,6 +482,11 @@ def on_update(self): def on_trash(self): # delete all the reactions for the message frappe.db.delete("Raven Message Reaction", {"message": self.name}) + # if the message is a thread, delete all messages in the thread and the thread channel + if self.is_thread: + frappe.db.delete("Raven Message", {"channel_id": self.name}) + # delete the channel for the thread + frappe.db.delete("Raven Channel", self.name) def on_doctype_update(): diff --git a/types/Messaging/Message.ts b/types/Messaging/Message.ts index 7675b1405..37c5c7f29 100644 --- a/types/Messaging/Message.ts +++ b/types/Messaging/Message.ts @@ -22,6 +22,7 @@ export interface BaseMessage { is_bot_message?: 1 | 0, bot?: string, hide_link_preview?: 1 | 0, + is_thread: 1 | 0, } export interface FileMessage extends BaseMessage {