relative_timestamp.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { Component } from 'react';
  2. import type { IntlShape } from 'react-intl';
  3. import { injectIntl, defineMessages } from 'react-intl';
  4. const messages = defineMessages({
  5. today: { id: 'relative_time.today', defaultMessage: 'today' },
  6. just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
  7. just_now_full: {
  8. id: 'relative_time.full.just_now',
  9. defaultMessage: 'just now',
  10. },
  11. seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
  12. seconds_full: {
  13. id: 'relative_time.full.seconds',
  14. defaultMessage: '{number, plural, one {# second} other {# seconds}} ago',
  15. },
  16. minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
  17. minutes_full: {
  18. id: 'relative_time.full.minutes',
  19. defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago',
  20. },
  21. hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
  22. hours_full: {
  23. id: 'relative_time.full.hours',
  24. defaultMessage: '{number, plural, one {# hour} other {# hours}} ago',
  25. },
  26. days: { id: 'relative_time.days', defaultMessage: '{number}d' },
  27. days_full: {
  28. id: 'relative_time.full.days',
  29. defaultMessage: '{number, plural, one {# day} other {# days}} ago',
  30. },
  31. moments_remaining: {
  32. id: 'time_remaining.moments',
  33. defaultMessage: 'Moments remaining',
  34. },
  35. seconds_remaining: {
  36. id: 'time_remaining.seconds',
  37. defaultMessage: '{number, plural, one {# second} other {# seconds}} left',
  38. },
  39. minutes_remaining: {
  40. id: 'time_remaining.minutes',
  41. defaultMessage: '{number, plural, one {# minute} other {# minutes}} left',
  42. },
  43. hours_remaining: {
  44. id: 'time_remaining.hours',
  45. defaultMessage: '{number, plural, one {# hour} other {# hours}} left',
  46. },
  47. days_remaining: {
  48. id: 'time_remaining.days',
  49. defaultMessage: '{number, plural, one {# day} other {# days}} left',
  50. },
  51. });
  52. const dateFormatOptions = {
  53. hour12: false,
  54. year: 'numeric',
  55. month: 'short',
  56. day: '2-digit',
  57. hour: '2-digit',
  58. minute: '2-digit',
  59. } as const;
  60. const shortDateFormatOptions = {
  61. month: 'short',
  62. day: 'numeric',
  63. } as const;
  64. const SECOND = 1000;
  65. const MINUTE = 1000 * 60;
  66. const HOUR = 1000 * 60 * 60;
  67. const DAY = 1000 * 60 * 60 * 24;
  68. const MAX_DELAY = 2147483647;
  69. const selectUnits = (delta: number) => {
  70. const absDelta = Math.abs(delta);
  71. if (absDelta < MINUTE) {
  72. return 'second';
  73. } else if (absDelta < HOUR) {
  74. return 'minute';
  75. } else if (absDelta < DAY) {
  76. return 'hour';
  77. }
  78. return 'day';
  79. };
  80. const getUnitDelay = (units: string) => {
  81. switch (units) {
  82. case 'second':
  83. return SECOND;
  84. case 'minute':
  85. return MINUTE;
  86. case 'hour':
  87. return HOUR;
  88. case 'day':
  89. return DAY;
  90. default:
  91. return MAX_DELAY;
  92. }
  93. };
  94. export const timeAgoString = (
  95. intl: IntlShape,
  96. date: Date,
  97. now: number,
  98. year: number,
  99. timeGiven: boolean,
  100. short?: boolean,
  101. ) => {
  102. const delta = now - date.getTime();
  103. let relativeTime;
  104. if (delta < DAY && !timeGiven) {
  105. relativeTime = intl.formatMessage(messages.today);
  106. } else if (delta < 10 * SECOND) {
  107. relativeTime = intl.formatMessage(
  108. short ? messages.just_now : messages.just_now_full,
  109. );
  110. } else if (delta < 7 * DAY) {
  111. if (delta < MINUTE) {
  112. relativeTime = intl.formatMessage(
  113. short ? messages.seconds : messages.seconds_full,
  114. { number: Math.floor(delta / SECOND) },
  115. );
  116. } else if (delta < HOUR) {
  117. relativeTime = intl.formatMessage(
  118. short ? messages.minutes : messages.minutes_full,
  119. { number: Math.floor(delta / MINUTE) },
  120. );
  121. } else if (delta < DAY) {
  122. relativeTime = intl.formatMessage(
  123. short ? messages.hours : messages.hours_full,
  124. { number: Math.floor(delta / HOUR) },
  125. );
  126. } else {
  127. relativeTime = intl.formatMessage(
  128. short ? messages.days : messages.days_full,
  129. { number: Math.floor(delta / DAY) },
  130. );
  131. }
  132. } else if (date.getFullYear() === year) {
  133. relativeTime = intl.formatDate(date, shortDateFormatOptions);
  134. } else {
  135. relativeTime = intl.formatDate(date, {
  136. ...shortDateFormatOptions,
  137. year: 'numeric',
  138. });
  139. }
  140. return relativeTime;
  141. };
  142. const timeRemainingString = (
  143. intl: IntlShape,
  144. date: Date,
  145. now: number,
  146. timeGiven = true,
  147. ) => {
  148. const delta = date.getTime() - now;
  149. let relativeTime;
  150. if (delta < DAY && !timeGiven) {
  151. relativeTime = intl.formatMessage(messages.today);
  152. } else if (delta < 10 * SECOND) {
  153. relativeTime = intl.formatMessage(messages.moments_remaining);
  154. } else if (delta < MINUTE) {
  155. relativeTime = intl.formatMessage(messages.seconds_remaining, {
  156. number: Math.floor(delta / SECOND),
  157. });
  158. } else if (delta < HOUR) {
  159. relativeTime = intl.formatMessage(messages.minutes_remaining, {
  160. number: Math.floor(delta / MINUTE),
  161. });
  162. } else if (delta < DAY) {
  163. relativeTime = intl.formatMessage(messages.hours_remaining, {
  164. number: Math.floor(delta / HOUR),
  165. });
  166. } else {
  167. relativeTime = intl.formatMessage(messages.days_remaining, {
  168. number: Math.floor(delta / DAY),
  169. });
  170. }
  171. return relativeTime;
  172. };
  173. interface Props {
  174. intl: IntlShape;
  175. timestamp: string;
  176. year: number;
  177. futureDate?: boolean;
  178. short?: boolean;
  179. }
  180. interface States {
  181. now: number;
  182. }
  183. class RelativeTimestamp extends Component<Props, States> {
  184. state = {
  185. now: Date.now(),
  186. };
  187. static defaultProps = {
  188. year: new Date().getFullYear(),
  189. short: true,
  190. };
  191. _timer: number | undefined;
  192. shouldComponentUpdate(nextProps: Props, nextState: States) {
  193. // As of right now the locale doesn't change without a new page load,
  194. // but we might as well check in case that ever changes.
  195. return (
  196. this.props.timestamp !== nextProps.timestamp ||
  197. this.props.intl.locale !== nextProps.intl.locale ||
  198. this.state.now !== nextState.now
  199. );
  200. }
  201. UNSAFE_componentWillReceiveProps(nextProps: Props) {
  202. if (this.props.timestamp !== nextProps.timestamp) {
  203. this.setState({ now: Date.now() });
  204. }
  205. }
  206. componentDidMount() {
  207. this._scheduleNextUpdate(this.props, this.state);
  208. }
  209. UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) {
  210. this._scheduleNextUpdate(nextProps, nextState);
  211. }
  212. componentWillUnmount() {
  213. window.clearTimeout(this._timer);
  214. }
  215. _scheduleNextUpdate(props: Props, state: States) {
  216. window.clearTimeout(this._timer);
  217. const { timestamp } = props;
  218. const delta = new Date(timestamp).getTime() - state.now;
  219. const unitDelay = getUnitDelay(selectUnits(delta));
  220. const unitRemainder = Math.abs(delta % unitDelay);
  221. const updateInterval = 1000 * 10;
  222. const delay =
  223. delta < 0
  224. ? Math.max(updateInterval, unitDelay - unitRemainder)
  225. : Math.max(updateInterval, unitRemainder);
  226. this._timer = window.setTimeout(() => {
  227. this.setState({ now: Date.now() });
  228. }, delay);
  229. }
  230. render() {
  231. const { timestamp, intl, year, futureDate, short } = this.props;
  232. const timeGiven = timestamp.includes('T');
  233. const date = new Date(timestamp);
  234. const relativeTime = futureDate
  235. ? timeRemainingString(intl, date, this.state.now, timeGiven)
  236. : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
  237. return (
  238. <time
  239. dateTime={timestamp}
  240. title={intl.formatDate(date, dateFormatOptions)}
  241. >
  242. {relativeTime}
  243. </time>
  244. );
  245. }
  246. }
  247. const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp);
  248. export { RelativeTimestampWithIntl as RelativeTimestamp };