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

Co-authored-by: Diego Sampaio <8591547+sampaiodiego@users.noreply.github.com>
pull/31558/head
Guilherme Gazzo 2 years ago committed by GitHub
parent b876e4e0fc
commit 5ad65ff3da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      .changeset/stupid-trains-trade.md
  2. 7
      apps/meteor/client/components/GazzodownText.tsx
  3. 1
      ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/StrikeSpan.tsx
  4. 8
      packages/gazzodown/.storybook/main.js
  5. 1
      packages/gazzodown/package.json
  6. 102
      packages/gazzodown/src/Markup.stories.tsx
  7. 2
      packages/gazzodown/src/MarkupInteractionContext.ts
  8. 1
      packages/gazzodown/src/elements/ImageElement.tsx
  9. 13
      packages/gazzodown/src/elements/InlineElements.tsx
  10. 1
      packages/gazzodown/src/elements/StrikeSpan.tsx
  11. 21
      packages/gazzodown/src/elements/Timestamp/ErrorBoundary.tsx
  12. 128
      packages/gazzodown/src/elements/Timestamp/index.tsx
  13. 41
      packages/gazzodown/src/elements/Timestamp/timeago.ts
  14. 4
      packages/i18n/src/locales/en.i18n.json
  15. 26
      packages/message-parser/README.md
  16. 2
      packages/message-parser/package.json
  17. 11
      packages/message-parser/src/definitions.ts
  18. 12
      packages/message-parser/src/grammar.pegjs
  19. 15
      packages/message-parser/src/utils.ts
  20. 27
      packages/message-parser/tests/timestamp.test.ts
  21. 10
      packages/ui-client/src/hooks/useFeaturePreviewList.ts
  22. 8
      yarn.lock

@ -0,0 +1,24 @@
---
"@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 |

@ -1,7 +1,9 @@
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

@ -0,0 +1,21 @@
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;
}
}

@ -0,0 +1,128 @@
/* 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;

@ -0,0 +1,41 @@
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"
}
}

@ -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'}>`),
};
};

@ -0,0 +1,27 @@
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);
});

@ -1,7 +1,7 @@
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
export type FeaturesAvailable = 'quickReactions' | 'navigationBar';
export type FeaturesAvailable = 'quickReactions' | 'navigationBar' | 'enable-timestamp-message-parser';
export type FeaturePreviewProps = {
name: FeaturesAvailable;
@ -31,6 +31,14 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [
value: false,
enabled: false,
},
{
name: 'enable-timestamp-message-parser',
i18n: 'Enable_timestamp',
description: 'Enable_timestamp_description',
group: 'Message',
value: false,
enabled: true,
},
];
export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled);

@ -8845,6 +8845,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": ~5.60.1
"@typescript-eslint/parser": ~5.60.1
babel-loader: ^8.3.0
date-fns: ^3.3.1
eslint: ~8.45.0
eslint-plugin-anti-trojan-source: ~1.1.1
eslint-plugin-react: ~7.32.2
@ -20231,6 +20232,13 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:^3.3.1":
version: 3.3.1
resolution: "date-fns@npm:3.3.1"
checksum: 6245e93a47de28ac96dffd4d62877f86e6b64854860ae1e00a4f83174d80bc8e59bd1259cf265223fb2ddce5c8e586dc9cc210f0d052faba2f7660e265877283
languageName: node
linkType: hard
"date.js@npm:~0.3.3":
version: 0.3.3
resolution: "date.js@npm:0.3.3"

Loading…
Cancel
Save