diff --git a/packages/markups/src/elements/InlineElements.js b/packages/markups/src/elements/InlineElements.js index 0ab5a7fb1e..2584196e89 100644 --- a/packages/markups/src/elements/InlineElements.js +++ b/packages/markups/src/elements/InlineElements.js @@ -10,6 +10,7 @@ import ChannelMention from '../mentions/ChannelMention'; import ColorElement from './ColorElement'; import LinkSpan from './LinkSpan'; import UserMention from '../mentions/UserMention'; +import TimestampElement from './TimestampElement'; const InlineElements = ({ contents }) => contents.map((content, index) => { @@ -53,6 +54,10 @@ const InlineElements = ({ contents }) => } /> ); + + case 'TIMESTAMP': + return ; + default: return null; } diff --git a/packages/markups/src/elements/TimestampElement.js b/packages/markups/src/elements/TimestampElement.js new file mode 100644 index 0000000000..fd860046d7 --- /dev/null +++ b/packages/markups/src/elements/TimestampElement.js @@ -0,0 +1,118 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CodeElement from './CodeElement'; + +function timeAgo(dateParam, locale) { + const int = new Intl.RelativeTimeFormat(locale, { style: 'long' }); + + const date = new Date(dateParam).getTime(); + const today = new Date().getTime(); + const seconds = Math.round((date - today) / 1000); + const minutes = Math.round(seconds / 60); + const hours = Math.round(minutes / 60); + const days = Math.round(hours / 24); + const weeks = Math.round(days / 7); + const months = new Date(date).getMonth() - new Date().getMonth(); + const years = new Date(date).getFullYear() - new Date().getFullYear(); + + if (Math.abs(seconds) < 60) { + return int.format(seconds, 'seconds'); + } + if (Math.abs(minutes) < 60) { + return int.format(minutes, 'minutes'); + } + if (Math.abs(hours) < 24) { + return int.format(hours, 'hours'); + } + if (Math.abs(days) < 7) { + return int.format(days, 'days'); + } + if (Math.abs(weeks) < 4) { + return int.format(weeks, 'weeks'); + } + if (Math.abs(months) < 12) { + return int.format(months, 'months'); + } + return int.format(years, 'years'); +} + +const formatTimestamp = (timestamp, format) => { + const date = new Date(timestamp * 1000); + + const getOrdinalDate = (day) => { + const suffix = ['th', 'st', 'nd', 'rd']; + const val = day % 100; + return day + (suffix[(val - 20) % 10] || suffix[val] || suffix[0]); + }; + + const timeZoneOffset = date.getTimezoneOffset(); + const sign = timeZoneOffset > 0 ? '-' : '+'; + const hours = Math.floor(Math.abs(timeZoneOffset) / 60); + const minutes = Math.abs(timeZoneOffset) % 60; + const timeZone = `GMT${sign}${String(hours).padStart(2, '0')}:${String( + minutes + ).padStart(2, '0')}`; + + const month = date.toLocaleString('en-US', { month: 'long' }); + const day = getOrdinalDate(date.getDate()); + const year = date.getFullYear(); + const time = date.toLocaleString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const shortDate = date.toLocaleDateString('en-US'); + const shortTime = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + + switch (format) { + case 't': { + return shortTime; + } + case 'T': { + return time; + } + case 'd': { + return shortDate; + } + case 'D': { + return `${shortDate}, ${shortTime}`; + } + case 'f': { + return `${month} ${day}, ${year} at ${time} ${timeZone}`; + } + case 'F': { + const weekday = date.toLocaleString('en-US', { weekday: 'long' }); + return `${weekday}, ${month} ${day} ${year} at ${time} ${timeZone}`; + } + case 'R': { + return timeAgo(timestamp * 1000, 'en'); + } + default: { + return date.toLocaleString(); + } + } +}; + +const TimestampElement = ({ contents }) => { + if (typeof contents === 'object' && contents.timestamp && contents.format) { + const { timestamp, format } = contents; + + const formattedTimestamp = formatTimestamp(parseInt(timestamp, 10), format); + return ; + } + + return null; +}; + +export default TimestampElement; + +TimestampElement.propTypes = { + contents: PropTypes.shape({ + timestamp: PropTypes.string.isRequired, + format: PropTypes.string.isRequired, + }).isRequired, +}; diff --git a/packages/react/src/views/ChatInput/ChatInput.styles.js b/packages/react/src/views/ChatInput/ChatInput.styles.js index b193d06922..ba70b721e9 100644 --- a/packages/react/src/views/ChatInput/ChatInput.styles.js +++ b/packages/react/src/views/ChatInput/ChatInput.styles.js @@ -172,3 +172,137 @@ export const getInsertLinkModalStyles = (theme) => { return styles; }; + +export const getTimestampStyles = (theme, mode) => { + const styles = { + timestampModal: css` + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1300; + + // Not hardcoding the color; this value will be the same for all themes. + background-color: rgba(0, 0, 0, 0.2); + `, + timestampModalContent: css` + background-color: ${theme.colors.card}; + color: ${theme.colors.cardForeground}; + border-radius: 10px; + padding: 20px; + max-width: 400px; + width: 100%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1); + `, + modalHeader: css` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 1px solid ${theme.colors.border}; + padding-bottom: 10px; + `, + timestampPreview: css` + margin-bottom: 20px; + padding: 10px; + background-color: ${mode === 'light' + ? theme.commonColors.white + : theme.commonColors.black}; + border: 1px solid ${theme.colors.border}; + border-radius: 5px; + `, + previewText: css` + font-weight: bold; + `, + previewCode: css` + font-family: monospace; + color: ${theme.colors.info}; + word-wrap: break-word; + `, + timestampInputs: css` + display: flex; + justify-content: space-between; + margin-bottom: 20px; + `, + dateInput: css` + width: 48%; + `, + timeInput: css` + width: 48%; + `, + inputLabel: css` + display: block; + margin-bottom: 5px; + font-size: 14px; + font-weight: bold; + color: ${theme.colors.foreground}; + `, + inputField: css` + width: 100%; + padding: 8px; + border-radius: 5px; + border: 1px solid ${theme.colors.input}; + font-size: 14px; + background-color: ${lighten(theme.colors.background, 1)}; + color: ${theme.colors.foreground}; + `, + formatSelection: css` + margin-bottom: 20px; + `, + formatOptions: css` + max-height: 200px; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + gap: 10px; + `, + formatOption: css` + width: calc(50% - 5px); + display: flex; + align-items: flex-start; + padding: 10px; + border: 1px solid ${theme.colors.border}; + border-radius: 5px; + margin-bottom: 10px; + cursor: pointer; + + &:nth-last-child(-n + 3) { + width: 100%; + } + `, + formatOptionSelected: css` + background-color: ${theme.colors.accent}; + border-color: ${theme.colors.primary}; + `, + formatRadio: css` + margin-right: 10px; + `, + formatDetails: css` + flex: 1; + cursor: pointer; + `, + formatLabel: css` + font-size: 17px; + font-weight: bold; + color: ${theme.colors.foreground}; + `, + formatDescription: css` + font-size: 14px; + color: ${theme.colors.mutedForeground}; + `, + formatExample: css` + font-size: 12px; + color: ${theme.colors.accentForeground}; + `, + modalFooter: css` + display: flex; + justify-content: space-between; + margin-top: 20px; + `, + }; + + return styles; +}; diff --git a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js index 5d8c20a600..1a3fa1c48b 100644 --- a/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js +++ b/packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js @@ -16,15 +16,24 @@ import VideoMessageRecorder from './VideoMessageRecoder'; import { getChatInputFormattingToolbarStyles } from './ChatInput.styles'; import formatSelection from '../../lib/formatSelection'; import InsertLinkToolBox from './InsertLinkToolBox'; +import { TimestampSelector } from './TimestampSelector'; const ChatInputFormattingToolbar = ({ messageRef, inputRef, triggerButton, optionConfig = { - surfaceItems: ['emoji', 'formatter', 'link', 'audio', 'video', 'file'], + surfaceItems: [ + 'emoji', + 'formatter', + 'link', + 'audio', + 'video', + 'file', + 'timestamp', + ], formatters: ['bold', 'italic', 'strike', 'code', 'multiline'], - smallScreenSurfaceItems: ['emoji', 'video', 'audio', 'file'], + smallScreenSurfaceItems: ['emoji', 'video', 'audio', 'file', 'timestamp'], popOverItems: ['formatter', 'link'], }, }) => { @@ -46,6 +55,7 @@ const ChatInputFormattingToolbar = ({ (state) => state.isRecordingMessage ); + const [isTimestampSelectorOpen, setIsTimestampSelectorOpen] = useState(false); const [isEmojiOpen, setEmojiOpen] = useState(false); const [isInsertLinkOpen, setInsertLinkOpen] = useState(false); const [isPopoverOpen, setPopoverOpen] = useState(false); @@ -83,6 +93,23 @@ const ChatInputFormattingToolbar = ({ setInsertLinkOpen(false); }; + const handleTimestampSelect = (timestamp) => { + const messageInput = messageRef.current; + + const start = messageInput.selectionStart; + const end = messageInput.selectionEnd; + const msg = messageInput.value; + + const updatedMessage = msg.slice(0, start) + timestamp + msg.slice(end); + messageInput.value = updatedMessage; + + const newCursorPosition = start + timestamp.length; + messageInput.selectionStart = newCursorPosition; + messageInput.selectionEnd = newCursorPosition; + + triggerButton?.(null, updatedMessage); + }; + const chatToolMap = { emoji: isPopoverOpen && popOverItems.includes('emoji') ? ( @@ -123,6 +150,7 @@ const ChatInputFormattingToolbar = ({ popOverItemStyles={styles.popOverItemStyles} /> ), + video: ( ), + file: isPopoverOpen && popOverItems.includes('file') ? ( ), + link: isPopoverOpen && popOverItems.includes('link') ? ( ), + + timestamp: ( + + { + if (isRecordingMessage) return; + setIsTimestampSelectorOpen(!isTimestampSelectorOpen); + }} + > + + + + ), + formatter: formatters .map((name) => formatter.find((item) => item.name === name)) .map((item) => @@ -356,6 +403,14 @@ const ChatInputFormattingToolbar = ({ onClose={() => setInsertLinkOpen(false)} /> )} + + {isTimestampSelectorOpen && ( + setIsTimestampSelectorOpen(false)} + /> + )} ); }; diff --git a/packages/react/src/views/ChatInput/TimestampSelector.js b/packages/react/src/views/ChatInput/TimestampSelector.js new file mode 100644 index 0000000000..8a7bbd5aa5 --- /dev/null +++ b/packages/react/src/views/ChatInput/TimestampSelector.js @@ -0,0 +1,184 @@ +import React, { useState, useEffect } from 'react'; +import { + ActionButton, + Box, + Button, + Input, + Icon, + Tooltip, + useTheme, +} from '@embeddedchat/ui-elements'; +import { getTimestampStyles } from './ChatInput.styles'; + +const formatOptions = [ + { + value: 'R', + label: 'Relative', + description: 'Shows relative time', + example: '2 hours ago', + }, + { + value: 't', + label: 'Short Time', + description: 'Shows only time', + example: '12:00 AM', + }, + { + value: 'T', + label: 'Long Time', + description: 'Shows detailed time', + example: '12:00:00 AM', + }, + { + value: 'd', + label: 'Short Date', + description: 'Shows date briefly', + example: '12/31/2020', + }, + { + value: 'D', + label: 'Long Date', + description: 'Shows full date', + example: 'Thursday, December 31, 2020', + }, + { + value: 'f', + label: 'Full DateTime', + description: 'Shows date and time', + example: 'December 31, 2020 12:00 AM', + }, + { + value: 'F', + label: 'Long DateTime', + description: 'Shows detailed date and time', + example: 'Thursday, December 31, 2020 12:00:00 AM', + }, +]; + +export function TimestampSelector({ onSelect, isTimestampSelectorOpen }) { + const { theme } = useTheme(); + const { mode } = useTheme(); + const styles = getTimestampStyles(theme, mode); + + const [isOpen, setIsOpen] = useState(isTimestampSelectorOpen); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedFormat, setSelectedFormat] = useState('f'); + const [previewTimestamp, setPreviewTimestamp] = useState(''); + + useEffect(() => { + if (isOpen) { + const timestamp = Math.floor(selectedDate.getTime() / 1000); + const formatted = ``; + setPreviewTimestamp(formatted); + } + }, [selectedDate, selectedFormat, isOpen]); + + const handleDateChange = (e) => { + setSelectedDate(new Date(e.target.value)); + }; + + const handleTimeChange = (e) => { + const [hours, minutes] = e.target.value.split(':'); + const newDate = new Date(selectedDate); + newDate.setHours(parseInt(hours, 10), parseInt(minutes, 10)); + setSelectedDate(newDate); + }; + + const handleInsert = () => { + onSelect(previewTimestamp); + setIsOpen(false); + }; + + return ( + isOpen && ( + + + +

Insert Timestamp

+ setIsOpen(false)}> + + +
+ + + Preview: + {previewTimestamp} + + + + + Date + + + + + Time + + + + + + Format + + {formatOptions.map((format) => ( + + setSelectedFormat(format.value)} + css={styles.formatRadio} + /> + + + ))} + + + + + + + +
+
+ ) + ); +}