diff --git a/frontend/package.json b/frontend/package.json index f8d407adc..722e53c59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "cal-sans": "^1.0.1", + "chrono-node": "^2.7.7", "clsx": "^2.1.0", "cmdk": "^1.0.0", "cva": "npm:class-variance-authority", @@ -72,4 +73,4 @@ "@types/turndown": "^5.0.4", "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/frontend/src/components/feature/chat/ChatInput/useSendMessage.ts b/frontend/src/components/feature/chat/ChatInput/useSendMessage.ts index 27ed1df89..ce8066b00 100644 --- a/frontend/src/components/feature/chat/ChatInput/useSendMessage.ts +++ b/frontend/src/components/feature/chat/ChatInput/useSendMessage.ts @@ -1,13 +1,60 @@ import { useFrappePostCall } from 'frappe-react-sdk' +import * as chrono from 'chrono-node'; import { Message } from '../../../../../../types/Messaging/Message' export const useSendMessage = (channelID: string, noOfFiles: number, uploadFiles: () => Promise, handleCancelReply: VoidFunction, selectedMessage?: Message | null) => { const { call, loading } = useFrappePostCall('raven.api.raven_message.send_message') + const parseDates = (content: string): string => { + + let parsedContent = content + + const parsedDates = chrono.parse(parsedContent, undefined, { + forwardDate: true + }) + + // Sort parsedDates in reverse order based on their index. This is to ensure that we replace from the end to preserve the indices of the replaced strings. + parsedDates.sort((a, b) => b.index - a.index) + + parsedDates.forEach(date => { + + const hasStartTime = date.start.isCertain('hour') && date.start.isCertain('minute') + + const startTime: number = date.start.date().getTime() + + const endTime: number | null = date.end?.date().getTime() ?? null + + const hasEndTime = endTime ? date.end?.isCertain('hour') && date.end?.isCertain('minute') : false + + // Replace the text with a span containing the timestamp after the given "index") + const index = date.index + const text = date.text + + let attributes = '' + if (startTime) attributes += `data-timestamp-start="${startTime}"` + + if (endTime) attributes += ` data-timestamp-end="${endTime}"` + + if (!hasStartTime) { + attributes += ' data-timestamp-start-all-day="true"' + } + + if (!hasEndTime) { + attributes += ' data-timestamp-end-all-day="true"' + } + + parsedContent = parsedContent.slice(0, index) + `${text}` + parsedContent.slice(index + text.length) + }) + return parsedContent + } + const sendMessage = async (content: string, json?: any): Promise => { if (content) { + // Parse the content to replace any "human" dates with a timestamp element + content = parseDates(content) + return call({ channel_id: channelID, text: content, diff --git a/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TimestampRenderer.tsx b/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TimestampRenderer.tsx new file mode 100644 index 000000000..b56a8647e --- /dev/null +++ b/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TimestampRenderer.tsx @@ -0,0 +1,178 @@ +import { HStack, Stack } from '@/components/layout/Stack' +import { convertMillisecondsToReadableDate } from '@/utils/dateConversions/utils' +import { HoverCard, Text } from '@radix-ui/themes' +import { mergeAttributes, Node } from '@tiptap/core' +import { NodeViewWrapper, ReactNodeViewRenderer, NodeViewRendererProps, NodeViewContent } from '@tiptap/react' +import dayjs from 'dayjs' +import { useMemo } from 'react' +import { BiMoon, BiTime } from 'react-icons/bi' +import { TbSun, TbSunHigh, TbSunset2 } from 'react-icons/tb' + +export default Node.create({ + name: 'timestamp-renderer', + + group: 'inline', + + inline: true, + + content: 'inline*', + + atom: true, + + addAttributes() { + return { + 'data-timestamp-start': { + default: '', + }, + 'data-timestamp-end': { + default: '', + }, + 'data-timestamp-start-all-day': { + default: 'false', + }, + 'data-timestamp-end-all-day': { + default: 'false', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'span.timestamp', + }, + ] + }, + + renderHTML({ HTMLAttributes, node }) { + return ['span', mergeAttributes(HTMLAttributes)] + }, + + addNodeView() { + return ReactNodeViewRenderer(TimestampComponent) + }, +}) + +const TimestampComponent = ({ node }: NodeViewRendererProps) => { + + // Convert the timestamp to readable date format + + const startTimeInMilliseconds = node.attrs['data-timestamp-start'] + const endTimeInMilliseconds = node.attrs['data-timestamp-end'] + const startIsAllDay = node.attrs['data-timestamp-start-all-day'] === 'true' || node.attrs['data-timestamp-start-all-day'] === true + + + const { icon, label } = useMemo(() => { + + let label = '' + let icon = + + const startTime = convertMillisecondsToReadableDate(startTimeInMilliseconds) + const endTime = endTimeInMilliseconds ? convertMillisecondsToReadableDate(endTimeInMilliseconds) : '' + + const today = dayjs() + + const isToday = startTime.isSame(today, 'day') + + const isTomorrow = startTime.isSame(today.add(1, 'day'), 'day') + + const isYesterday = startTime.isSame(today.subtract(1, 'day'), 'day') + + const isThisYear = startTime.isSame(today, 'year') + + // According to the time of day, decide the icon to be shown. + + //If there's a end time, we need to parse the text accordingly. + + // If it's an all day event, we don't need to show the time. + + if (!endTime) { + // Only check the start time. + + if (startIsAllDay) { + if (isToday) label = "Today" + else if (isTomorrow) label = "Tomorrow" + else if (isYesterday) label = "Yesterday" + else if (isThisYear) label = startTime.format('Do MMMM') + else label = startTime.format('Do MMMM YYYY') + } else { + if (isToday) { + label = startTime.format('hh:mm A') + ' today' + } else if (isTomorrow) { + label = startTime.format('hh:mm A') + ' tomorrow' + } else if (isYesterday) { + label = startTime.format('hh:mm A') + ' yesterday' + } else if (isThisYear) { + label = startTime.format('hh:mm A [on] Do MMM') + } else { + label = startTime.format('hh:mm A [on] Do MMM YYYY') + } + + const hour = startTime.hour() + + // If between 5AM and 3PM, show full sun icon. + // If between 3PM and 7PM, show half sun icon. + + // If between 7PM and 5AM, show crescent moon icon. + if (hour <= 11 && hour >= 5) { + icon = + } else if (hour > 11 && hour <= 16) { + icon = + } else if (hour > 16 && hour < 19) { + icon = + } else if (hour >= 19 || hour <= 4) { + icon = + } + } + + } else { + + // There's a start and end time. + // Need to check if both of them are on the same day or not. + + const isSameDay = startTime.isSame(endTime, 'day') + + if (isSameDay) { + if (isToday) { + label = startTime.format('hh:mm A') + ' - ' + endTime.format('hh:mm A') + ' today' + } else if (isTomorrow) { + label = startTime.format('hh:mm A') + ' - ' + endTime.format('hh:mm A') + ' tomorrow' + } else if (isYesterday) { + label = startTime.format('hh:mm A') + ' - ' + endTime.format('hh:mm A') + ' yesterday' + } else { + label = startTime.format('hh:mm A') + ' - ' + endTime.format('hh:mm A') + ' on Do MMM' + } + } else { + if (startIsAllDay) { + label = startTime.format('Do MMM') + ' - ' + endTime.format('Do MMM') + } else { + label = startTime.format('hh:mm A [on] Do MMM') + ' - ' + endTime.format('hh:mm A [on] Do MMM') + } + } + } + + return { icon, label } + }, [startTimeInMilliseconds, endTimeInMilliseconds]) + + return + + + + + + + + + + + {icon} + + {label} + + + + + + + +} \ 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 183563372..b050ea46a 100644 --- a/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx +++ b/frontend/src/components/feature/chat/ChatMessage/Renderers/TiptapRenderer/TiptapRenderer.tsx @@ -28,6 +28,7 @@ import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import TableRow from '@tiptap/extension-table-row' import Details from './Details' +import TimestampRenderer from './TimestampRenderer' const lowlight = createLowlight(common) @@ -159,7 +160,8 @@ export const TiptapRenderer = ({ message, user, isScrolling = false, showMiniIma pluginKey: new PluginKey('channelMention'), }, }), - Details + Details, + TimestampRenderer ] }) diff --git a/frontend/src/utils/dateConversions/utils.ts b/frontend/src/utils/dateConversions/utils.ts index 8cbf8594c..d10d9feae 100644 --- a/frontend/src/utils/dateConversions/utils.ts +++ b/frontend/src/utils/dateConversions/utils.ts @@ -22,4 +22,9 @@ export const FRAPPE_TIME_FORMAT = 'HH:mm:ss' export const getDateObject = (timestamp: string): dayjs.Dayjs => { return dayjs.tz(timestamp, SYSTEM_TIMEZONE).local() +} + +export const convertMillisecondsToReadableDate = (timestampInMilliseconds: number, format: string = 'hh:mm A (Do MMM)') => { + + return dayjs.unix(timestampInMilliseconds / 1000) } \ No newline at end of file diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 4d40b27be..690cf917d 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -298,6 +298,9 @@ def send_push_notification(self): # 3. If the message is a reply, send a push notification to the user who is being replied to # 4. If the message is in a channel, send a push notification to all the users in the channel (topic) + if self.message_type == "System": + return + channel_doc = frappe.get_cached_doc("Raven Channel", self.channel_id) if channel_doc.is_direct_message: diff --git a/yarn.lock b/yarn.lock index 7939f857a..59f0b7095 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3185,6 +3185,13 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chrono-node@^2.7.7: + version "2.7.7" + resolved "https://registry.yarnpkg.com/chrono-node/-/chrono-node-2.7.7.tgz#d0e66f1a33f4c3f4ff75f24eee251a7dc9b42d60" + integrity sha512-p3S7gotuTPu5oqhRL2p1fLwQXGgdQaRTtWR3e8Di9P1Pa9mzkK5DWR5AWBieMUh2ZdOnPgrK+zCrbbtyuA+D/Q== + dependencies: + dayjs "^1.10.0" + classnames@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" @@ -3363,7 +3370,7 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@^1.11.11: +dayjs@^1.10.0, dayjs@^1.11.11: version "1.11.13" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==