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: New Timestamp component on the MessageToolBar and Support for Displaying it #988

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions packages/markups/src/elements/InlineElements.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -53,6 +54,10 @@ const InlineElements = ({ contents }) =>
}
/>
);

case 'TIMESTAMP':
return <TimestampElement key={index} contents={content.value} />;

default:
return null;
}
Expand Down
118 changes: 118 additions & 0 deletions packages/markups/src/elements/TimestampElement.js
Original file line number Diff line number Diff line change
@@ -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 <CodeElement contents={{ value: formattedTimestamp }} />;
}

return null;
};

export default TimestampElement;

TimestampElement.propTypes = {
contents: PropTypes.shape({
timestamp: PropTypes.string.isRequired,
format: PropTypes.string.isRequired,
}).isRequired,
};
134 changes: 134 additions & 0 deletions packages/react/src/views/ChatInput/ChatInput.styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
59 changes: 57 additions & 2 deletions packages/react/src/views/ChatInput/ChatInputFormattingToolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
}) => {
Expand All @@ -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);
Expand Down Expand Up @@ -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') ? (
Expand Down Expand Up @@ -123,6 +150,7 @@ const ChatInputFormattingToolbar = ({
popOverItemStyles={styles.popOverItemStyles}
/>
),

video: (
<VideoMessageRecorder
displayName={
Expand All @@ -132,6 +160,7 @@ const ChatInputFormattingToolbar = ({
disabled={isRecordingMessage}
/>
),

file:
isPopoverOpen && popOverItems.includes('file') ? (
<Box
Expand Down Expand Up @@ -161,6 +190,7 @@ const ChatInputFormattingToolbar = ({
</ActionButton>
</Tooltip>
),

link:
isPopoverOpen && popOverItems.includes('link') ? (
<Box
Expand Down Expand Up @@ -190,6 +220,23 @@ const ChatInputFormattingToolbar = ({
</ActionButton>
</Tooltip>
),

timestamp: (
<Tooltip text="Insert timestamp" position="top" key="timestamp">
<ActionButton
square
ghost
disabled={isRecordingMessage}
onClick={() => {
if (isRecordingMessage) return;
setIsTimestampSelectorOpen(!isTimestampSelectorOpen);
}}
>
<Icon name="clock" size="1.25rem" />
</ActionButton>
</Tooltip>
),

formatter: formatters
.map((name) => formatter.find((item) => item.name === name))
.map((item) =>
Expand Down Expand Up @@ -356,6 +403,14 @@ const ChatInputFormattingToolbar = ({
onClose={() => setInsertLinkOpen(false)}
/>
)}

{isTimestampSelectorOpen && (
<TimestampSelector
onSelect={handleTimestampSelect}
isTimestampSelectorOpen={isTimestampSelectorOpen}
onClose={() => setIsTimestampSelectorOpen(false)}
/>
)}
</Box>
);
};
Expand Down
Loading
Loading