Skip to content
Snippets Groups Projects
Unverified Commit 5ad65ff3 authored by Guilherme Gazzo's avatar Guilherme Gazzo Committed by GitHub
Browse files

feat(message-parser): add timestamps pattern (#31810)

parent b876e4e0
No related branches found
No related tags found
No related merge requests found
Showing
with 407 additions and 40 deletions
---
"@rocket.chat/message-parser": patch
---
feat(message-parser): add timestamps pattern
### Usage
Pattern: <t:{timestamp}:?{format}>
- {timestamp} is a Unix timestamp
- {format} is an optional parameter that can be used to customize the date and time format.
#### Formats
| Format | Description | Example |
| ------ | ------------------------- | --------------------------------------- |
| `t` | Short time | 12:00 AM |
| `T` | Long time | 12:00:00 AM |
| `d` | Short date | 12/31/2020 |
| `D` | Long date | Thursday, December 31, 2020 |
| `f` | Full date and time | Thursday, December 31, 2020 12:00 AM |
| `F` | Full date and time (long) | Thursday, December 31, 2020 12:00:00 AM |
| `R` | Relative time | 1 year ago |
import type { IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { ChannelMention, UserMention } from '@rocket.chat/gazzodown';
import { MarkupInteractionContext } from '@rocket.chat/gazzodown';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useLayout, useRouter, useSetting, useUserPreference, useUserId } from '@rocket.chat/ui-contexts';
import type { UIEvent } from 'react';
import React, { useCallback, memo, useMemo } from 'react';
......@@ -25,6 +27,9 @@ type GazzodownTextProps = {
};
const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTextProps) => {
const enableTimestamp = useFeaturePreview('enable-timestamp-message-parser');
const [userLanguage] = useLocalStorage('userLanguage', 'en');
const highlights = useMessageListHighlights();
const { triggerProps, openUserCard } = useUserCard();
......@@ -125,6 +130,8 @@ const GazzodownText = ({ mentions, channels, searchText, children }: GazzodownTe
ownUserId,
showMentionSymbol,
triggerProps,
enableTimestamp,
language: userLanguage,
}}
>
{children}
......
......@@ -13,6 +13,7 @@ const styles = StyleSheet.create({
});
type MessageBlock =
| MessageParser.Timestamp
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
......
......@@ -9,4 +9,12 @@ module.exports = {
typescript: {
reactDocgen: 'react-docgen-typescript-plugin',
},
async webpackFinal(config) {
config.module.rules.push({
test: /(date-fns).*\.(ts|js|mjs)x?$/,
include: /node_modules/,
loader: 'babel-loader',
});
return config;
},
};
......@@ -77,6 +77,7 @@
"react": "*"
},
"dependencies": {
"date-fns": "^3.3.1",
"highlight.js": "^11.5.1",
"react-error-boundary": "^3.1.4"
},
......
......@@ -7,6 +7,7 @@ import outdent from 'outdent';
import { ReactElement, Suspense } from 'react';
import Markup from './Markup';
import { MarkupInteractionContext } from './MarkupInteractionContext';
export default {
title: 'Markup',
......@@ -14,46 +15,48 @@ export default {
decorators: [
(Story): ReactElement => (
<Suspense fallback={null}>
<MessageContainer>
<MessageBody>
<Box
className={css`
> blockquote {
padding-inline: 8px;
border-radius: 2px;
border-width: 2px;
border-style: solid;
background-color: var(--rcx-color-neutral-100, ${colors.n100});
border-color: var(--rcx-color-neutral-200, ${colors.n200});
border-inline-start-color: var(--rcx-color-neutral-600, ${colors.n600});
&:hover,
&:focus {
background-color: var(--rcx-color-neutral-200, ${colors.n200});
border-color: var(--rcx-color-neutral-300, ${colors.n300});
<MarkupInteractionContext.Provider value={{ enableTimestamp: true }}>
<MessageContainer>
<MessageBody>
<Box
className={css`
> blockquote {
padding-inline: 8px;
border-radius: 2px;
border-width: 2px;
border-style: solid;
background-color: var(--rcx-color-neutral-100, ${colors.n100});
border-color: var(--rcx-color-neutral-200, ${colors.n200});
border-inline-start-color: var(--rcx-color-neutral-600, ${colors.n600});
}
}
> ul.task-list {
> li::before {
display: none;
&:hover,
&:focus {
background-color: var(--rcx-color-neutral-200, ${colors.n200});
border-color: var(--rcx-color-neutral-300, ${colors.n300});
border-inline-start-color: var(--rcx-color-neutral-600, ${colors.n600});
}
}
> li > .rcx-check-box > .rcx-check-box__input:focus + .rcx-check-box__fake {
z-index: 1;
}
> ul.task-list {
> li::before {
display: none;
}
> li > .rcx-check-box > .rcx-check-box__input:focus + .rcx-check-box__fake {
z-index: 1;
}
list-style: none;
margin-inline-start: 0;
padding-inline-start: 0;
}
`}
>
<Story />
</Box>
</MessageBody>
</MessageContainer>
list-style: none;
margin-inline-start: 0;
padding-inline-start: 0;
}
`}
>
<Story />
</Box>
</MessageBody>
</MessageContainer>
</MarkupInteractionContext.Provider>
{/* workaround? */}
<Box />
</Suspense>
......@@ -75,6 +78,35 @@ Empty.args = {
tokens: [],
};
export const Timestamp = Template.bind({});
Timestamp.args = {
tokens: parse(`Short time: <t:1708551317:t>
Long time: <t:1708551317:T>
Short date: <t:1708551317:d>
Long date: <t:1708551317:D>
Full date: <t:1708551317:f>
Full date (long): <t:1708551317:F>
Relative time from past: <t:${((): number => {
const date = new Date();
date.setHours(date.getHours() - 1);
return date.getTime();
})()}:R>
Relative to Future: <t:${((): number => {
const date = new Date();
date.setHours(date.getHours() + 1);
return date.getTime();
})()}:R>
Relative Seconds: <t:${((): number => {
const date = new Date();
date.setSeconds(date.getSeconds() - 1);
return date.getTime();
})()}:R>
`),
};
export const BigEmoji = Template.bind({});
BigEmoji.args = {
tokens: [
......
......@@ -22,6 +22,8 @@ type MarkupInteractionContextValue = {
ownUserId?: string | null;
showMentionSymbol?: boolean;
triggerProps?: AriaButtonProps<'button'>;
enableTimestamp?: boolean;
language?: string;
};
export const MarkupInteractionContext = createContext<MarkupInteractionContextValue>({});
......@@ -3,6 +3,7 @@ import { ReactElement, useMemo } from 'react';
const flattenMarkup = (
markup:
| MessageParser.Timestamp
| MessageParser.Markup
| MessageParser.InlineCode
| MessageParser.Link
......
......@@ -12,12 +12,13 @@ import ItalicSpan from './ItalicSpan';
import LinkSpan from './LinkSpan';
import PlainSpan from './PlainSpan';
import StrikeSpan from './StrikeSpan';
import Timestamp from './Timestamp';
const CodeElement = lazy(() => import('../code/CodeElement'));
const KatexElement = lazy(() => import('../katex/KatexElement'));
type InlineElementsProps = {
children: MessageParser.Inlines[];
children: (MessageParser.Inlines | { fallback: MessageParser.Plain; type: undefined })[];
};
const InlineElements = ({ children }: InlineElementsProps): ReactElement => (
......@@ -70,8 +71,16 @@ const InlineElements = ({ children }: InlineElementsProps): ReactElement => (
</KatexErrorBoundary>
);
default:
case 'TIMESTAMP': {
return <Timestamp key={index} children={child} />;
}
default: {
if ('fallback' in child) {
return <InlineElements key={index} children={[child.fallback]} />;
}
return null;
}
}
})}
</>
......
......@@ -13,6 +13,7 @@ import PlainSpan from './PlainSpan';
const CodeElement = lazy(() => import('../code/CodeElement'));
type MessageBlock =
| MessageParser.Timestamp
| MessageParser.Emoji
| MessageParser.ChannelMention
| MessageParser.UserMention
......
import { Component } from 'react';
export class ErrorBoundary extends Component<{ fallback: React.ReactNode }, { hasError: boolean }> {
constructor(props: { fallback: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return this.props.fallback;
}
return this.props.children;
}
}
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { Tag } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import { format } from 'date-fns';
import { useContext, useEffect, useState, type ReactElement } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MarkupInteractionContext } from '../../MarkupInteractionContext';
import { timeAgo } from './timeago';
type BoldSpanProps = {
children: MessageParser.Timestamp;
};
// | `f` | Full date and time | Thursday, December 31, 2020 12:00 AM |
// | `F` | Full date and time (long) | Thursday, December 31, 2020 12:00:00 AM |
// | `R` | Relative time | 1 year ago |
const Timestamp = ({ children }: BoldSpanProps): ReactElement => {
const { enableTimestamp } = useContext(MarkupInteractionContext);
if (!enableTimestamp) {
return <>{`<t:${children.value.timestamp}:${children.value.format}>`}</>;
}
switch (children.value.format) {
case 't': // Short time format
return <ShortTime value={parseInt(children.value.timestamp)} />;
case 'T': // Long time format
return <LongTime value={parseInt(children.value.timestamp)} />;
case 'd': // Short date format
return <ShortDate value={parseInt(children.value.timestamp)} />;
case 'D': // Long date format
return <LongDate value={parseInt(children.value.timestamp)} />;
case 'f': // Full date and time format
return <FullDate value={parseInt(children.value.timestamp)} />;
case 'F': // Full date and time (long) format
return <FullDateLong value={parseInt(children.value.timestamp)} />;
case 'R': // Relative time format
return (
<ErrorBoundary fallback={<>{new Date().toUTCString()}</>}>
<RelativeTime key={children.value.timestamp} value={parseInt(children.value.timestamp)} />
</ErrorBoundary>
);
default:
return <time dateTime={children.value.timestamp}> {JSON.stringify(children.value.timestamp)}</time>;
}
};
// eslint-disable-next-line react/no-multi-comp
const ShortTime = ({ value }: { value: number }) => <Time value={format(new Date(value), 'p')} dateTime={new Date(value).toISOString()} />;
// eslint-disable-next-line react/no-multi-comp
const LongTime = ({ value }: { value: number }) => <Time value={format(new Date(value), 'pp')} dateTime={new Date(value).toISOString()} />;
// eslint-disable-next-line react/no-multi-comp
const ShortDate = ({ value }: { value: number }) => <Time value={format(new Date(value), 'P')} dateTime={new Date(value).toISOString()} />;
// eslint-disable-next-line react/no-multi-comp
const LongDate = ({ value }: { value: number }) => <Time value={format(new Date(value), 'Pp')} dateTime={new Date(value).toISOString()} />;
// eslint-disable-next-line react/no-multi-comp
const FullDate = ({ value }: { value: number }) => (
<Time value={format(new Date(value), 'PPPppp')} dateTime={new Date(value).toISOString()} />
);
// eslint-disable-next-line react/no-multi-comp
const FullDateLong = ({ value }: { value: number }) => (
<Time value={format(new Date(value), 'PPPPpppp')} dateTime={new Date(value).toISOString()} />
);
// eslint-disable-next-line react/no-multi-comp
const Time = ({ value, dateTime }: { value: string; dateTime: string }) => (
<time
title={new Date(dateTime).toLocaleString()}
dateTime={dateTime}
style={{
display: 'inline-block',
}}
>
<Tag> {value}</Tag>
</time>
);
// eslint-disable-next-line react/no-multi-comp
const RelativeTime = ({ value }: { value: number }) => {
const { language } = useContext(MarkupInteractionContext);
const [time, setTime] = useState(() => timeAgo(value, language ?? 'en'));
const [timeToRefresh, setTimeToRefresh] = useState(() => getTimeToRefresh(value));
useEffect(() => {
const interval = setInterval(() => {
setTime(timeAgo(value, 'en'));
setTimeToRefresh(getTimeToRefresh(value));
}, timeToRefresh);
return () => clearInterval(interval);
}, [value, timeToRefresh]);
return <Time value={time} dateTime={new Date(value).toISOString()} />;
};
const getTimeToRefresh = (time: number): number => {
const timeToRefresh = time - Date.now();
// less than 1 minute
if (timeToRefresh < 60000) {
return 1000;
}
// if the difference is in the minutes range, we should refresh the time in 1 minute / 2
if (timeToRefresh < 3600000) {
return 60000 / 2;
}
// if the difference is in the hours range, we should refresh the time in 5 minutes
if (timeToRefresh < 86400000) {
return 60000 * 5;
}
// refresh the time in 1 hour
return 3600000;
};
export default Timestamp;
export function timeAgo(dateParam: number, locale: string): string {
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');
}
......@@ -1905,6 +1905,8 @@
"Enable_Password_History": "Enable Password History",
"Enable_Password_History_Description": "When enabled, users won't be able to update their passwords to some of their most recently used passwords.",
"Enable_Svg_Favicon": "Enable SVG favicon",
"Enable_timestamp": "Enable timestamp parsing in messages",
"Enable_timestamp_description": "Enable timestamps to be parsed in messages",
"Enable_two-factor_authentication": "Enable two-factor authentication via TOTP",
"Enable_two-factor_authentication_email": "Enable two-factor authentication via Email",
"Enable_unlimited_apps": "Enable unlimited apps",
......@@ -6302,4 +6304,4 @@
"Seat_limit_reached": "Seat limit reached",
"Seat_limit_reached_Description": "Your workspace reached its contractual seat limit. Buy more seats to add more users.",
"Buy_more_seats": "Buy more seats"
}
\ No newline at end of file
}
......@@ -37,6 +37,32 @@ The grammar provides support for markdown, mentions and emojis.
- colors
- URI's
- mentions users/channels
- timestamps
## Timestamps
The timestamp tag is a special tag that allows you to convert a Unix timestamp to a human-readable date and time.
Timestamps are allowed inside strike elements.
### Usage
Pattern: <t:{timestamp}:?{format}>
- {timestamp} is a Unix timestamp
- {format} is an optional parameter that can be used to customize the date and time format.
#### Formats
| Format | Description | Example |
| ------ | ------------------------- | --------------------------------------- |
| `t` | Short time | 12:00 AM |
| `T` | Long time | 12:00:00 AM |
| `d` | Short date | 12/31/2020 |
| `D` | Long date | Thursday, December 31, 2020 |
| `f` | Full date and time | Thursday, December 31, 2020 12:00 AM |
| `F` | Full date and time (long) | Thursday, December 31, 2020 12:00:00 AM |
| `R` | Relative time | 1 year ago |
## Contributing
......
......@@ -42,7 +42,7 @@
"build": "run-s .:build:clean .:build:bundle",
".:build:clean": "rimraf dist",
".:build:bundle": "webpack",
"test": "jest --runInBand --coverage",
"testunit": "jest --runInBand --coverage",
"watch": "jest --watch",
"lint": "eslint src",
"docs": "typedoc"
......
......@@ -120,6 +120,7 @@ export type Strike = {
| ChannelMention
| InlineCode
| Italic
| Timestamp
>;
};
......@@ -174,6 +175,15 @@ export type ChannelMention = {
value: Plain;
};
export type Timestamp = {
type: 'TIMESTAMP';
value: {
timestamp: string;
format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
};
fallback?: Plain;
};
export type Types = {
BOLD: Bold;
PARAGRAPH: Paragraph;
......@@ -222,6 +232,7 @@ export type ASTNode =
export type TypesKeys = keyof Types;
export type Inlines =
| Timestamp
| Bold
| Plain
| Italic
......
......@@ -31,6 +31,7 @@
task,
tasks,
unorderedList,
timestamp,
} = require('./utils');
}}
......@@ -63,6 +64,14 @@ Blockquote = b:BlockquoteLine+ { return quote(b); }
BlockquoteLine = ">" [ \t]* @Paragraph
// <t:1630360800:?{format}>
TimestampType = "t" / "T" / "d" / "D" / "f" / "F" / "R"
Unixtime = d:Digit |10| { return d.join(''); }
Timestamp = "<t:" date:Unixtime ":" format:TimestampType ">" { return timestamp(date, format); } / "<t:" date:Unixtime ">" { return timestamp(date); }
/**
*
* Code Chunk
......@@ -202,6 +211,7 @@ Paragraph = value:Inline { return paragraph(value); }
Inline = value:(InlineItem / Any)+ EndOfLine? { return reducePlainTexts(value); }
InlineItem = Whitespace
/ Timestamp
/ References
/ AutolinkedPhone
/ AutolinkedEmail
......@@ -394,7 +404,7 @@ BoldContentItem = Whitespace / InlineCode / References / UserMention / ChannelMe
/* Strike */
Strikethrough = [\x7E] [\x7E] @StrikethroughContent [\x7E] [\x7E] / [\x7E] @StrikethroughContent [\x7E]
StrikethroughContent = text:(InlineCode / Whitespace / References / UserMention / ChannelMention / Italic / Bold / Emoji / Emoticon / AnyStrike / Line)+ {
StrikethroughContent = text:(Timestamp / InlineCode / Whitespace / References / UserMention / ChannelMention / Italic / Bold / Emoji / Emoticon / AnyStrike / Line)+ {
return strike(reducePlainTexts(text));
}
......
......@@ -16,6 +16,7 @@ import type {
KaTeX,
InlineKaTeX,
Link,
Timestamp,
} from './definitions';
const generate =
......@@ -234,3 +235,17 @@ export const phoneChecker = (text: string, number: string) => {
return link(`tel:${number}`, [plain(text)]);
};
export const timestamp = (
value: string,
type?: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'
): Timestamp => {
return {
type: 'TIMESTAMP',
value: {
timestamp: value,
format: type || 't',
},
fallback: plain(`<t:${value}:${type || 't'}>`),
};
};
import { parse } from '../src';
import { bold, paragraph, plain, strike, timestamp } from '../src/utils';
test.each([
[`<t:1708551317>`, [paragraph([timestamp('1708551317')])]],
[`<t:1708551317:R>`, [paragraph([timestamp('1708551317', 'R')])]],
[
'hello <t:1708551317>',
[paragraph([plain('hello '), timestamp('1708551317')])],
],
])('parses %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
});
test.each([
['<t:1708551317:I>', [paragraph([plain('<t:1708551317:I>')])]],
['<t:17>', [paragraph([plain('<t:17>')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
});
test.each([
['~<t:1708551317>~', [paragraph([strike([timestamp('1708551317')])])]],
['*<t:1708551317>*', [paragraph([bold([plain('<t:1708551317>')])])]],
])('parses %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment