Skip to content

Commit

Permalink
feat: show embedded timestamps in messages
Browse files Browse the repository at this point in the history
  • Loading branch information
nikkothari22 committed Oct 13, 2024
1 parent 64e1ce2 commit 3be6219
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 3 deletions.
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)
}
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

0 comments on commit 3be6219

Please sign in to comment.