Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show embedded timestamps in messages #1097

Merged
merged 2 commits into from
Oct 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -72,4 +73,4 @@
"@types/turndown": "^5.0.4",
"typescript": "^5.3.3"
}
}
}
47 changes: 47 additions & 0 deletions frontend/src/components/feature/chat/ChatInput/useSendMessage.ts
Original file line number Diff line number Diff line change
@@ -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<void>, 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) + `<span class="timestamp" ${attributes}">${text}</span>` + parsedContent.slice(index + text.length)
})
return parsedContent
}

const sendMessage = async (content: string, json?: any): Promise<void> => {

if (content) {
// Parse the content to replace any "human" dates with a timestamp element
content = parseDates(content)

return call({
channel_id: channelID,
text: content,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <BiTime />

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 = <TbSun />
} else if (hour > 11 && hour <= 16) {
icon = <TbSunHigh />
} else if (hour > 16 && hour < 19) {
icon = <TbSunset2 />
} else if (hour >= 19 || hour <= 4) {
icon = <BiMoon />
}
}

} 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 <NodeViewWrapper as='span'>
<HoverCard.Root>
<HoverCard.Trigger>
<Text as='span' className='bg-accent-3 px-0.5 text-accent-11'>
<BiTime className='mr-1 -mb-0.5' />
<NodeViewContent as='span' />
</Text>
</HoverCard.Trigger>
<HoverCard.Content size='1' className='rounded-sm dark:bg-gray-4 py-2 px-2'>
<Stack>
<HStack align='center'>
{icon}
<Text as='span' size='2' weight='medium'>
{label}
</Text>
</HStack>
</Stack>

</HoverCard.Content>
</HoverCard.Root>
</NodeViewWrapper >
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -159,7 +160,8 @@ export const TiptapRenderer = ({ message, user, isScrolling = false, showMiniIma
pluginKey: new PluginKey('channelMention'),
},
}),
Details
Details,
TimestampRenderer
]
})

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/utils/dateConversions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions raven/raven_messaging/doctype/raven_message/raven_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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==
Expand Down
Loading