diff --git a/raven-app/src/components/feature/chat/ChatInput/RightToolbarButtons.tsx b/raven-app/src/components/feature/chat/ChatInput/RightToolbarButtons.tsx index cb46325e2..27308b7ab 100644 --- a/raven-app/src/components/feature/chat/ChatInput/RightToolbarButtons.tsx +++ b/raven-app/src/components/feature/chat/ChatInput/RightToolbarButtons.tsx @@ -5,6 +5,7 @@ import { ToolbarFileProps } from './Tiptap' import { Flex, IconButton, Inset, Popover, Separator } from '@radix-ui/themes' import { Loader } from '@/components/common/Loader' import { Suspense, lazy } from 'react' +import { CreatePoll } from '../../polls/CreatePoll' import { HiOutlineGif } from "react-icons/hi2"; import { GIFPicker } from '@/components/common/GIFPicker/GIFPicker' @@ -21,9 +22,10 @@ type RightToolbarButtonsProps = { * Component to render the right toolbar buttons: * 1. User Mention * 2. Channel Mention - * 3. Emoji picker - * 4. File upload - * 5. Send button + * 3. Poll creation + * 4. Emoji picker + * 5. File upload + * 6. Send button * @param props * @returns */ @@ -32,6 +34,8 @@ export const RightToolbarButtons = ({ fileProps, ...sendProps }: RightToolbarBut + + @@ -215,4 +219,13 @@ const SendButton = ({ sendMessage, messageSending, setContent }: { } +} + +const CreatePollButton = () => { + + const { editor } = useCurrentEditor() + + return } \ No newline at end of file diff --git a/raven-app/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx b/raven-app/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx index 0196a809b..ede5eea68 100644 --- a/raven-app/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx +++ b/raven-app/src/components/feature/chat/ChatMessage/MessageActions/MessageActions.tsx @@ -31,7 +31,7 @@ export const MessageContextMenu = ({ message, onDelete, onEdit, onReply }: Messa Reply - + {/* */} {message.message_type === 'Text' && diff --git a/raven-app/src/components/feature/chat/ChatMessage/MessageItem.tsx b/raven-app/src/components/feature/chat/ChatMessage/MessageItem.tsx index d2a84c6a7..5de3998fc 100644 --- a/raven-app/src/components/feature/chat/ChatMessage/MessageItem.tsx +++ b/raven-app/src/components/feature/chat/ChatMessage/MessageItem.tsx @@ -12,6 +12,7 @@ import { BsFillCircleFill } from 'react-icons/bs' import { MessageReactions } from './MessageReactions' import { ImageMessageBlock } from './Renderers/ImageMessage' import { FileMessageBlock } from './Renderers/FileMessage' +import { PollMessageBlock } from './Renderers/PollMessage' import { TiptapRenderer } from './Renderers/TiptapRenderer/TiptapRenderer' import { QuickActions } from './MessageActions/QuickActions/QuickActions' import { memo, useMemo, useState } from 'react' @@ -76,7 +77,7 @@ export const MessageItem = ({ message, setDeleteMessage, isHighlighted, onReplyM onMouseLeave={onMouseLeave} // disabled={!isHoveredDebounced} className={clsx(`group - hover:bg-gray-100 + hover:bg-gray-2 hover:transition-all hover:delay-100 dark:hover:bg-gray-3 @@ -244,5 +245,6 @@ export const MessageContent = ({ message, user, ...props }: MessageContentProps) }} user={user} /> : null} {message.message_type === 'Image' && } {message.message_type === 'File' && } + {message.message_type === 'Poll' && } } \ No newline at end of file diff --git a/raven-app/src/components/feature/chat/ChatMessage/Renderers/PollMessage.tsx b/raven-app/src/components/feature/chat/ChatMessage/Renderers/PollMessage.tsx new file mode 100644 index 000000000..638b83b6b --- /dev/null +++ b/raven-app/src/components/feature/chat/ChatMessage/Renderers/PollMessage.tsx @@ -0,0 +1,215 @@ +import { Box, Checkbox, Flex, Text, RadioGroup, Button, Badge } from "@radix-ui/themes" +import { BoxProps } from "@radix-ui/themes/dist/cjs/components/box" +import { memo, useEffect, useMemo, useState } from "react" +import { UserFields } from "../../../../../utils/users/UserListProvider" +import { PollMessage } from "../../../../../../../types/Messaging/Message" +import { useFrappeDocumentEventListener, useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { RavenPoll } from "@/types/RavenMessaging/RavenPoll" +import { ErrorBanner } from "@/components/layout/AlertBanner" +import { RavenPollOption } from "@/types/RavenMessaging/RavenPollOption" +import { useToast } from "@/hooks/useToast" + +interface PollMessageBlockProps extends BoxProps { + message: PollMessage, + user?: UserFields, +} + +interface Poll { + 'poll': RavenPoll, + 'current_user_votes': { 'option': string }[] +} + +export const PollMessageBlock = memo(({ message, user, ...props }: PollMessageBlockProps) => { + + // fetch poll data using message_id + const { data, error, mutate } = useFrappeGetCall<{ message: Poll }>('raven.api.raven_poll.get_poll', { + 'message_id': message.name, + }, `poll_data_${message.poll_id}`, { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false + }) + + useFrappeDocumentEventListener('Raven Poll', message.poll_id, () => { + mutate() + }) + + return ( + + + {data && } + + ) +}) + +const PollMessageBox = ({ data, messageID }: { data: Poll, messageID: string }) => { + return ( + + + + {data.poll.question} + {data.poll.is_anonymous ? Anonymous : null} + + {data.current_user_votes.length > 0 ? + : + <> + {data.poll.is_multi_choice ? + : + + } + + } + {data.poll.is_disabled ? Poll is now closed : null} + + + ) +} + +const PollResults = ({ data }: { data: Poll }) => { + return ( + + {data.poll.options.map(option => { + return + })} + {data.poll.total_votes} vote{data.poll.total_votes > 1 ? 's' : ''} + + ) +} + +const PollOption = ({ data, option }: { data: Poll, option: RavenPollOption }) => { + + const getPercentage = (votes: number) => { + if (data.poll.is_multi_choice) { + const totalVotes = data.poll.options.reduce((acc, opt) => acc + (opt.votes ?? 0), 0) + return (votes / totalVotes) * 100 + } else return (votes / data.poll.total_votes) * 100 + } + + // State to track whether the animation should be triggered + const [triggerAnimation, setTriggerAnimation] = useState(false) + + // Use useEffect to trigger animation after the component is mounted + useEffect(() => { + setTriggerAnimation(true) + }, []) + + const isCurrentUserVote = useMemo(() => { + return data.current_user_votes.some(vote => vote.option === option.name) + }, [data.current_user_votes, option.name]) + + const percentage = useMemo(() => { + return getPercentage(option.votes ?? 0) + }, [option.votes]) + + const width = `${percentage}%` + + return ( + + + + {option.option} + {percentage.toFixed(1)}% + + ) +} + +const SingleChoicePoll = ({ data, messageID }: { data: Poll, messageID: string }) => { + + const { mutate } = useSWRConfig() + const { call } = useFrappePostCall('raven.api.raven_poll.add_vote') + const { toast } = useToast() + const onVoteSubmit = async (option: RavenPollOption) => { + return call({ + 'message_id': messageID, + 'option_id': option.name + }).then(() => { + mutate(`poll_data_${data.poll.name}`) + toast({ + title: "Your vote has been submitted!", + variant: 'success', + duration: 800 + }) + }) + } + + return ( + + {data.poll.options.map(option => ( +
+ + + onVoteSubmit(option)} /> + {option.option} + + +
+ ))} +
+ ) +} + +const MultiChoicePoll = ({ data, messageID }: { data: Poll, messageID: string }) => { + + const [selectedOptions, setSelectedOptions] = useState([]) + const { mutate } = useSWRConfig() + const { toast } = useToast() + + const handleCheckboxChange = (name: string, value: boolean | string) => { + if (value) { + setSelectedOptions((opts) => [...opts, name]) + } else { + setSelectedOptions((opts) => opts.filter(opt => opt !== name)) + } + } + + const { call } = useFrappePostCall('raven.api.raven_poll.add_vote') + const onVoteSubmit = async () => { + return call({ + 'message_id': messageID, + 'option_id': selectedOptions + }).then(() => { + mutate(`poll_data_${data.poll.name}`) + toast({ + title: "Your vote has been submitted!", + variant: 'success', + duration: 800 + }) + }) + } + + return ( +
+ {data.poll.options.map(option => ( +
+ + + handleCheckboxChange(option.name, v)} /> + {option.option} + + +
+ ))} + + To view the poll results, please submit your choice(s) + + +
+ ) +} \ No newline at end of file diff --git a/raven-app/src/components/feature/chat/ChatMessage/ReplyMessageBox/ReplyMessageBox.tsx b/raven-app/src/components/feature/chat/ChatMessage/ReplyMessageBox/ReplyMessageBox.tsx index 08a7d6f24..d9fb1280e 100644 --- a/raven-app/src/components/feature/chat/ChatMessage/ReplyMessageBox/ReplyMessageBox.tsx +++ b/raven-app/src/components/feature/chat/ChatMessage/ReplyMessageBox/ReplyMessageBox.tsx @@ -1,4 +1,4 @@ -import { FileMessage, Message, TextMessage } from "../../../../../../../types/Messaging/Message" +import { FileMessage, Message, PollMessage, TextMessage } from "../../../../../../../types/Messaging/Message" import { Box, Flex, Separator, Text } from "@radix-ui/themes" import { useGetUser } from "@/hooks/useGetUser" import { DateMonthAtHourMinuteAmPm } from "@/utils/dateConversions" @@ -7,6 +7,7 @@ import { getFileExtension, getFileName } from "@/utils/operations" import { FlexProps } from "@radix-ui/themes/dist/cjs/components/flex" import { clsx } from "clsx" import parse from 'html-react-parser'; +import { MdOutlineBarChart } from "react-icons/md" interface ReplyMessageBoxProps extends FlexProps { message: Partial } @@ -29,13 +30,18 @@ export const ReplyMessageBox = ({ message, children, className, ...props }: Repl
- {['File', 'Image'].includes(message.message_type ?? 'Text') ? - - {message.message_type === 'File' && message.file && } - {message.message_type === 'Image' && {`Image} - {getFileName((message as FileMessage).file)} - - : {parse((message as TextMessage).content ?? '')} + {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)} + + : {parse((message as TextMessage).content ?? '')} } diff --git a/raven-app/src/components/feature/polls/CreatePoll.tsx b/raven-app/src/components/feature/polls/CreatePoll.tsx new file mode 100644 index 000000000..7c6472c38 --- /dev/null +++ b/raven-app/src/components/feature/polls/CreatePoll.tsx @@ -0,0 +1,245 @@ +import { ErrorText, Label } from "@/components/common/Form" +import { ErrorBanner } from "@/components/layout/AlertBanner" +import { useToast } from "@/hooks/useToast" +import { RavenPoll } from "@/types/RavenMessaging/RavenPoll" +import { DIALOG_CONTENT_CLASS } from "@/utils/layout/dialog" +import { Button, Checkbox, Dialog, Flex, IconButton, TextArea, TextField, Text, Box } from "@radix-ui/themes" +import { useFrappePostCall } from "frappe-react-sdk" +import { useState } from "react" +import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form" +import { BiPlus, BiTrash } from "react-icons/bi" +import { MdOutlineBarChart } from "react-icons/md" +import { useParams } from "react-router-dom" + +interface CreatePollProps { + buttonStyle?: string, + isDisabled?: boolean +} + +export const CreatePoll = ({ buttonStyle, isDisabled = false }: CreatePollProps) => { + + const methods = useForm({ + // Initialize the form with 2 option fields by default + defaultValues: { + options: [{ + name: '', + creation: '', + modified: '', + owner: '', + modified_by: '', + docstatus: 0, + option: '' + }, { + name: '', + creation: '', + modified: '', + owner: '', + modified_by: '', + docstatus: 0, + option: '' + }], + } + }) + + const { register, handleSubmit, formState: { errors }, control, reset: resetForm } = methods + const [isOpen, setIsOpen] = useState(false) + const { toast } = useToast() + + const { fields, remove, append } = useFieldArray({ + control: control, + name: 'options' + }) + + const optionPlaceholders = ['Cersei Lannister', 'Jon Snow', 'Daenerys Targaryen', 'Tyrion Lannister', 'Night King', 'Arya Stark', 'Sansa Stark', 'Jaime Lannister', 'Bran Stark', 'The Hound'] + + const handleAddOption = () => { + // limit the number of options to 10 + if (fields.length >= 10) { + return + } else { + append({ + name: '', + creation: '', + modified: '', + owner: '', + modified_by: '', + docstatus: 0, + option: '' + }) + } + } + + const handleRemoveOption = (index: number) => { + // Do not remove the last 2 options + if (fields.length === 2) { + return + } + remove(index) + } + + const reset = () => { + resetForm() + } + + const onClose = () => { + setIsOpen(false) + reset() + } + + const onOpenChange = (open: boolean) => { + setIsOpen(open) + reset() + } + + const { call: createPoll, error } = useFrappePostCall('raven.api.raven_poll.create_poll') + const { channelID } = useParams<{ channelID: string }>() + + const onSubmit = (data: RavenPoll) => { + return createPoll({ + ...data, + "channel_id": channelID + }).then(() => { + toast({ + title: "Poll created successfully", + variant: 'success', + }) + onClose() + }).catch((err) => { + toast({ + title: "Error creating poll", + description: err.message, + variant: "destructive", + }) + }) + } + + return + + + + + + + + Create Poll + + + Create a quick poll to get everyone's thoughts on a topic. + + +
+ + + + + + +