From b98f79030869ae79bf5f9ea0ec71797ebeb95bc5 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:29:10 +0200 Subject: [PATCH] Message: Display formatted dates (#6795) --- src/api/gramjs/apiBuilders/common.ts | 15 ++ src/api/gramjs/gramjsBuilders/index.ts | 12 ++ src/api/types/messages.ts | 20 +- src/assets/localization/fallback.strings | 3 + src/components/common/Composer.tsx | 1 - src/components/common/FormattedDate.tsx | 177 ++++++++++++++++++ .../common/helpers/renderTextWithEntities.tsx | 16 ++ .../main/ForwardRecipientPicker.tsx | 2 +- src/components/main/Main.tsx | 2 +- src/components/ui/ListItem.tsx | 7 +- src/global/actions/api/messages.ts | 4 +- src/global/types/actions.ts | 4 +- src/lib/gramjs/tl/apiHelpers.ts | 2 +- src/types/index.ts | 1 - src/types/language.d.ts | 3 + src/util/localization/dateFormat.ts | 4 + src/util/localization/index.ts | 2 +- src/util/parseHtmlAsFormattedText.ts | 40 +++- 18 files changed, 299 insertions(+), 16 deletions(-) create mode 100644 src/components/common/FormattedDate.tsx diff --git a/src/api/gramjs/apiBuilders/common.ts b/src/api/gramjs/apiBuilders/common.ts index fe5a77c53..0bafc7b3a 100644 --- a/src/api/gramjs/apiBuilders/common.ts +++ b/src/api/gramjs/apiBuilders/common.ts @@ -286,6 +286,21 @@ export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMess }; } + if (entity instanceof GramJs.MessageEntityFormattedDate) { + return { + type: ApiMessageEntityTypes.FormattedDate, + offset, + length, + date: entity.date, + relative: entity.relative, + shortTime: entity.shortTime, + longTime: entity.longTime, + shortDate: entity.shortDate, + longDate: entity.longDate, + dayOfWeek: entity.dayOfWeek, + }; + } + return { type: type as `${ApiMessageEntityDefault['type']}`, offset, diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 7e5483126..8b326f358 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -420,6 +420,18 @@ export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMess return new GramJs.MessageEntitySpoiler({ offset, length }); case ApiMessageEntityTypes.CustomEmoji: return new GramJs.MessageEntityCustomEmoji({ offset, length, documentId: BigInt(entity.documentId) }); + case ApiMessageEntityTypes.FormattedDate: + return new GramJs.MessageEntityFormattedDate({ + offset, + length, + date: entity.date, + relative: entity.relative, + shortTime: entity.shortTime, + longTime: entity.longTime, + shortDate: entity.shortDate, + longDate: entity.longDate, + dayOfWeek: entity.dayOfWeek, + }); default: return new GramJs.MessageEntityUnknown({ offset, length }); } diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 11cbe2fca..fffe31da7 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -509,7 +509,8 @@ export type ApiMessageEntityDefault = { type: Exclude< `${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | - `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Timestamp}` + `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | + `${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}` >; offset: number; length: number; @@ -550,6 +551,19 @@ export type ApiMessageEntityCustomEmoji = { documentId: string; }; +export type ApiMessageEntityFormattedDate = { + type: ApiMessageEntityTypes.FormattedDate; + offset: number; + length: number; + date: number; + relative?: true; + shortTime?: true; + longTime?: true; + shortDate?: true; + longDate?: true; + dayOfWeek?: true; +}; + // Local entities export type ApiMessageEntityTimestamp = { type: ApiMessageEntityTypes.Timestamp; @@ -559,7 +573,8 @@ export type ApiMessageEntityTimestamp = { }; export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | - ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp; + ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp | + ApiMessageEntityFormattedDate; export enum ApiMessageEntityTypes { Bold = 'MessageEntityBold', @@ -582,6 +597,7 @@ export enum ApiMessageEntityTypes { CustomEmoji = 'MessageEntityCustomEmoji', Timestamp = 'MessageEntityTimestamp', QuoteFocus = 'MessageEntityQuoteFocus', + FormattedDate = 'MessageEntityFormattedDate', Unknown = 'MessageEntityUnknown', } diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 11a54d73d..3221c2048 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2772,5 +2772,8 @@ "RankEditTextOwn" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; "RankEditText" = "Add a short tag next to {user}'s name."; "MenuAddCaption" = "Add Caption"; +"MenuCopyDate" = "Copy Date"; +"DateCopiedToast" = "Date copied to clipboard"; +"ReminderSetToast" = "You set up a reminder in **Saved Messages**"; "NoForwardsRequestReject" = "Reject"; "NoForwardsRequestAccept" = "Accept"; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index b5b0ffc38..91e69eb8f 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -1265,7 +1265,6 @@ const Composer = ({ effectId, webPageMediaSize: attachmentSettings.webPageMediaSize, webPageUrl: hasWebPagePreview ? webPagePreview.url : undefined, - isForwarding, }); } diff --git a/src/components/common/FormattedDate.tsx b/src/components/common/FormattedDate.tsx new file mode 100644 index 000000000..7f7521364 --- /dev/null +++ b/src/components/common/FormattedDate.tsx @@ -0,0 +1,177 @@ +import { type TeactNode, useMemo, useRef, useState } from '@teact'; +import { getActions } from '../../global'; + +import { type ApiMessageEntityFormattedDate, ApiMessageEntityTypes } from '../../api/types'; + +import { copyTextToClipboard } from '../../util/clipboard'; +import { formatDateTime, secondsToDate } from '../../util/localization/dateFormat'; +import { getServerTime } from '../../util/serverTime'; + +import useInterval from '../../hooks/schedulers/useInterval'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; +import useSchedule from '../../hooks/useSchedule'; + +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; + +type OwnProps = { + children?: TeactNode; + entity: ApiMessageEntityFormattedDate; + asPreview?: boolean; + chatId?: string; + messageId?: number; +}; + +const FormattedDate = ({ + children, + chatId, + messageId, + entity, + asPreview, +}: OwnProps) => { + const { showNotification, openForwardMenu, forwardToSavedMessages } = getActions(); + const [cacheBreaker, setCacheBreaker] = useState(0); + + const ref = useRef(); + const menuRef = useRef(); + + const lang = useLang(); + + const [requestCalendar, calendar] = useSchedule(undefined, undefined, entity.date); + + useInterval( + () => setCacheBreaker((prev) => prev + 1), + getUpdateInterval(Math.abs(entity.date - getServerTime())), + ); + + const canSetReminder = Boolean(chatId && messageId); + + const { formattedDate, canonicalDate } = useMemo(() => { + void cacheBreaker; + + const { type, offset, length, date, ...formatOptions } = entity; + const canonical = formatDateTime(lang, secondsToDate(date), { + date: 'long', + includeYear: true, + includeDay: true, + time: 'long', + }); + + if (Object.values(formatOptions).every((value) => value === undefined)) { + return { formattedDate: undefined, canonicalDate: canonical }; + } + + const { relative, shortTime, longTime, shortDate, longDate, dayOfWeek } = formatOptions; + + const formatted = formatDateTime(lang, secondsToDate(date), { + relative: relative ? 'auto' : undefined, + time: shortTime ? 'short' : longTime ? 'long' : undefined, + date: shortDate ? 'short' : longDate ? 'long' : undefined, + weekday: dayOfWeek ? 'long' : undefined, + }); + + return { formattedDate: formatted, canonicalDate: canonical }; + }, [lang, entity, cacheBreaker]); + + const { + isContextMenuOpen, + contextMenuAnchor, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, asPreview); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + const getLayout = useLastCallback(() => ({ withPortal: true })); + + const handleCopy = useLastCallback(() => { + copyTextToClipboard(canonicalDate); + showNotification({ message: lang('DateCopiedToast') }); + }); + + const handleSetReminder = useLastCallback(() => { + if (!chatId || !messageId) return; + requestCalendar((scheduledAt) => { + openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); + forwardToSavedMessages({ scheduledAt }); + showNotification({ + message: { + key: 'ReminderSetToast', + options: { + withNodes: true, + withMarkdown: true, + }, + }, + }); + }); + }); + + if (asPreview) { + return formattedDate ?? children; + } + + return ( + + {formattedDate ?? children} + + {lang('MenuCopyDate')} + {canSetReminder && ( + {lang('SetReminder')} + )} + + {calendar} + + ); +}; + +export default FormattedDate; + +function getUpdateInterval(diffInSeconds: number) { + if (diffInSeconds < 60) { + return 1000; + } + + if (diffInSeconds < 60 * 60) { + return 60000; + } + + return undefined; +} + +function formatToString(entity: ApiMessageEntityFormattedDate) { + const { relative, shortTime, longTime, shortDate, longDate, dayOfWeek } = entity; + return [ + relative && 'r', + dayOfWeek && 'w', + shortDate && 'd', + longDate && 'D', + shortTime && 't', + longTime && 'T', + ].filter(Boolean).join(''); +} diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 8f2224c3b..283d5bc4e 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -16,6 +16,7 @@ import MentionLink from '../../middle/message/MentionLink'; import Blockquote from '../Blockquote'; import CodeBlock from '../code/CodeBlock'; import CustomEmoji from '../CustomEmoji'; +import FormattedDate from '../FormattedDate'; import SafeLink from '../SafeLink'; import Spoiler from '../spoiler/Spoiler'; @@ -511,6 +512,11 @@ function processEntity({ /> ); } + + if (entity.type === ApiMessageEntityTypes.FormattedDate && entity.date) { // Old entities can have missing fields + return {text}; + } + return text; } @@ -668,6 +674,16 @@ function processEntity({ return ( {renderNestedMessagePart()} ); + case ApiMessageEntityTypes.FormattedDate: + return ( + + {renderNestedMessagePart()} + + ); default: return renderNestedMessagePart(); } diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index 2de8a57f9..99ac79342 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -89,7 +89,7 @@ const ForwardRecipientPicker: FC = ({ : 'Conversation.ForwardTooltip.SavedMessages.One', ); - forwardToSavedMessages(); + forwardToSavedMessages({}); showNotification({ message }); } else { const chatId = recipientId; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index f397ec93e..a4949ac0b 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -369,7 +369,7 @@ const Main = ({ loadCountryList({ langCode: lang.code }); } - }, [lang, isMasterTab]); + }, [lang.code, isMasterTab]); // Re-fetch cached saved emoji for `localDb` useEffect(() => { diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index 89ac0b8df..0475a52c1 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -121,6 +121,7 @@ const ListItem = ({ if (ref) { containerRef = ref; } + const menuRef = useRef(); const [isTouched, markIsTouched, unmarkIsTouched] = useFlag(); const { @@ -131,10 +132,7 @@ const ListItem = ({ const getTriggerElement = useLastCallback(() => containerRef.current); const getRootElement = useLastCallback(() => containerRef.current!.closest('.custom-scroll')); - const getMenuElement = useLastCallback(() => { - return (withPortalForMenu ? document.querySelector('#portals') : containerRef.current)! - .querySelector('.ListItem-context-menu .bubble'); - }); + const getMenuElement = useLastCallback(() => menuRef.current); const getLayout = useLastCallback(() => ({ withPortal: withPortalForMenu })); const handleClickEvent = useLastCallback((e: React.MouseEvent) => { @@ -271,6 +269,7 @@ const ListItem = ({ {contextActions && contextMenuAnchor !== undefined && ( { - const { tabId = getCurrentTabId() } = payload || {}; + const { scheduledAt, tabId = getCurrentTabId() } = payload || {}; global = updateTabState(global, { forwardMessages: { ...selectTabState(global, tabId).forwardMessages, @@ -2593,7 +2593,7 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet setGlobal(global); actions.exitMessageSelectMode({ tabId }); - actions.forwardMessages({ isSilent: true, tabId }); + actions.forwardMessages({ isSilent: true, scheduledAt, tabId }); }); addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => { diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 2f3753ea5..282c8e1ca 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -2023,7 +2023,9 @@ export interface ActionPayloads { } & WithTabId; exitForwardMode: WithTabId | undefined; changeRecipient: WithTabId | undefined; - forwardToSavedMessages: WithTabId | undefined; + forwardToSavedMessages: { + scheduledAt?: number; + } & WithTabId; forwardStory: { toChatId: string; } & WithTabId; diff --git a/src/lib/gramjs/tl/apiHelpers.ts b/src/lib/gramjs/tl/apiHelpers.ts index ec274a365..e0f6b62c1 100644 --- a/src/lib/gramjs/tl/apiHelpers.ts +++ b/src/lib/gramjs/tl/apiHelpers.ts @@ -210,7 +210,7 @@ function createClasses(classesType: 'constructor' | 'request', params: Generatio const flagGroupSuffix = arg.flagGroup > 1 ? arg.flagGroup : ''; const flagValue = args[`flags${flagGroupSuffix}`] & (1 << arg.flagIndex); if (arg.type === 'true') { - args[argName] = Boolean(flagValue); + args[argName] = flagValue ? true : undefined; continue; } diff --git a/src/types/index.ts b/src/types/index.ts index f064f731e..ba988aaea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -777,7 +777,6 @@ export type SendMessageParams = { messagePriceInStars?: number; localMessage?: ApiMessage; forwardedLocalMessagesSlice?: ForwardedLocalMessagesSlice; - isForwarding?: boolean; forwardParams?: ForwardMessagesParams; isStoryReply?: boolean; suggestedMedia?: MediaContent; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index a0b2c4269..967b40d6c 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -2036,6 +2036,9 @@ export interface LangPair { 'RankEditSave': undefined; 'RankEditTextOwn': undefined; 'MenuAddCaption': undefined; + 'MenuCopyDate': undefined; + 'DateCopiedToast': undefined; + 'ReminderSetToast': undefined; 'NoForwardsRequestReject': undefined; 'NoForwardsRequestAccept': undefined; } diff --git a/src/util/localization/dateFormat.ts b/src/util/localization/dateFormat.ts index d348560ed..cce1a45c2 100644 --- a/src/util/localization/dateFormat.ts +++ b/src/util/localization/dateFormat.ts @@ -239,6 +239,10 @@ function getHourCycle(timeFormat: TimeFormat) { return timeFormat === '12h' ? 'h12' : 'h23'; } +export function secondsToDate(seconds: number) { + return new Date(seconds * 1000); +} + function serializeRecord(record: object) { return Object.entries(record as Record) .filter(([, value]) => value !== undefined) diff --git a/src/util/localization/index.ts b/src/util/localization/index.ts index 087a4b997..ae01cac47 100644 --- a/src/util/localization/index.ts +++ b/src/util/localization/index.ts @@ -356,7 +356,7 @@ export function setTimeFormat(timeFormat: TimeFormat) { currentTimeFormat = timeFormat; resetDateFormatCache(); - translationFn.timeFormat = currentTimeFormat; + translationFn = createTranslationFn(); scheduleCallbacks(); } diff --git a/src/util/parseHtmlAsFormattedText.ts b/src/util/parseHtmlAsFormattedText.ts index 093b506dc..b3ba66569 100644 --- a/src/util/parseHtmlAsFormattedText.ts +++ b/src/util/parseHtmlAsFormattedText.ts @@ -198,13 +198,21 @@ function getEntityDataFromNode( } if (type === ApiMessageEntityTypes.CustomEmoji) { + const nodeElement = node as HTMLElement; + const documentId = nodeElement.dataset.documentId || nodeElement.getAttribute('emoji-id'); + if (!documentId) { + return { + index, + entity: undefined, + }; + } return { index, entity: { type, offset, length, - documentId: (node as HTMLImageElement).dataset.documentId!, + documentId, }, }; } @@ -229,6 +237,28 @@ function getEntityDataFromNode( }; } + if (type === ApiMessageEntityTypes.FormattedDate) { + const date = Number((node as HTMLElement).dataset.unix); + if (Number.isNaN(date)) { + return { + index, + entity: undefined, + }; + } + const format = (node as HTMLElement).dataset.format; + const relative = format?.includes('r') || undefined; + const dayOfWeek = format?.includes('w') || undefined; + const shortDate = format?.includes('d') || undefined; + const longDate = format?.includes('D') || undefined; + const shortTime = format?.includes('t') || undefined; + const longTime = format?.includes('T') || undefined; + + return { + index, + entity: { type, offset, length, date, relative, dayOfWeek, shortDate, longDate, shortTime, longTime }, + }; + } + return { index, entity: { @@ -279,5 +309,13 @@ function getEntityTypeFromNode(node: ChildNode): ApiMessageEntityTypes | undefin } } + if (node.nodeName === 'TG-TIME') { + return ApiMessageEntityTypes.FormattedDate; + } + + if (node.nodeName === 'TG-EMOJI') { + return ApiMessageEntityTypes.CustomEmoji; + } + return undefined; }