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}
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+ );
+}