Message: Display formatted dates (#6795)

This commit is contained in:
zubiden 2026-03-31 11:29:10 +02:00 committed by Alexander Zinchuk
parent bc7468265d
commit b98f790308
18 changed files with 299 additions and 16 deletions

View File

@ -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 { return {
type: type as `${ApiMessageEntityDefault['type']}`, type: type as `${ApiMessageEntityDefault['type']}`,
offset, offset,

View File

@ -420,6 +420,18 @@ export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMess
return new GramJs.MessageEntitySpoiler({ offset, length }); return new GramJs.MessageEntitySpoiler({ offset, length });
case ApiMessageEntityTypes.CustomEmoji: case ApiMessageEntityTypes.CustomEmoji:
return new GramJs.MessageEntityCustomEmoji({ offset, length, documentId: BigInt(entity.documentId) }); 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: default:
return new GramJs.MessageEntityUnknown({ offset, length }); return new GramJs.MessageEntityUnknown({ offset, length });
} }

View File

@ -509,7 +509,8 @@ export type ApiMessageEntityDefault = {
type: Exclude< type: Exclude<
`${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes}`,
`${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` |
`${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Timestamp}` `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` |
`${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}`
>; >;
offset: number; offset: number;
length: number; length: number;
@ -550,6 +551,19 @@ export type ApiMessageEntityCustomEmoji = {
documentId: string; 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 // Local entities
export type ApiMessageEntityTimestamp = { export type ApiMessageEntityTimestamp = {
type: ApiMessageEntityTypes.Timestamp; type: ApiMessageEntityTypes.Timestamp;
@ -559,7 +573,8 @@ export type ApiMessageEntityTimestamp = {
}; };
export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl |
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp; ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp |
ApiMessageEntityFormattedDate;
export enum ApiMessageEntityTypes { export enum ApiMessageEntityTypes {
Bold = 'MessageEntityBold', Bold = 'MessageEntityBold',
@ -582,6 +597,7 @@ export enum ApiMessageEntityTypes {
CustomEmoji = 'MessageEntityCustomEmoji', CustomEmoji = 'MessageEntityCustomEmoji',
Timestamp = 'MessageEntityTimestamp', Timestamp = 'MessageEntityTimestamp',
QuoteFocus = 'MessageEntityQuoteFocus', QuoteFocus = 'MessageEntityQuoteFocus',
FormattedDate = 'MessageEntityFormattedDate',
Unknown = 'MessageEntityUnknown', Unknown = 'MessageEntityUnknown',
} }

View File

@ -2772,5 +2772,8 @@
"RankEditTextOwn" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; "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."; "RankEditText" = "Add a short tag next to {user}'s name.";
"MenuAddCaption" = "Add Caption"; "MenuAddCaption" = "Add Caption";
"MenuCopyDate" = "Copy Date";
"DateCopiedToast" = "Date copied to clipboard";
"ReminderSetToast" = "You set up a reminder in **Saved Messages**";
"NoForwardsRequestReject" = "Reject"; "NoForwardsRequestReject" = "Reject";
"NoForwardsRequestAccept" = "Accept"; "NoForwardsRequestAccept" = "Accept";

View File

@ -1265,7 +1265,6 @@ const Composer = ({
effectId, effectId,
webPageMediaSize: attachmentSettings.webPageMediaSize, webPageMediaSize: attachmentSettings.webPageMediaSize,
webPageUrl: hasWebPagePreview ? webPagePreview.url : undefined, webPageUrl: hasWebPagePreview ? webPagePreview.url : undefined,
isForwarding,
}); });
} }

View File

@ -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<HTMLAnchorElement>();
const menuRef = useRef<HTMLDivElement>();
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 (
<a
ref={ref}
onClick={handleContextMenu}
onContextMenu={handleContextMenu}
className="text-entity-link"
dir="auto"
data-entity-type={ApiMessageEntityTypes.FormattedDate}
data-unix={entity.date}
data-format={formatToString(entity)}
title={canonicalDate}
>
{formattedDate ?? children}
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
autoClose
>
<MenuItem icon="copy" onClick={handleCopy}>{lang('MenuCopyDate')}</MenuItem>
{canSetReminder && (
<MenuItem icon="unmute" onClick={handleSetReminder}>{lang('SetReminder')}</MenuItem>
)}
</Menu>
{calendar}
</a>
);
};
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('');
}

View File

@ -16,6 +16,7 @@ import MentionLink from '../../middle/message/MentionLink';
import Blockquote from '../Blockquote'; import Blockquote from '../Blockquote';
import CodeBlock from '../code/CodeBlock'; import CodeBlock from '../code/CodeBlock';
import CustomEmoji from '../CustomEmoji'; import CustomEmoji from '../CustomEmoji';
import FormattedDate from '../FormattedDate';
import SafeLink from '../SafeLink'; import SafeLink from '../SafeLink';
import Spoiler from '../spoiler/Spoiler'; 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 <FormattedDate entity={entity} asPreview>{text}</FormattedDate>;
}
return text; return text;
} }
@ -668,6 +674,16 @@ function processEntity({
return ( return (
<span className="matching-text-highlight is-quote">{renderNestedMessagePart()}</span> <span className="matching-text-highlight is-quote">{renderNestedMessagePart()}</span>
); );
case ApiMessageEntityTypes.FormattedDate:
return (
<FormattedDate
entity={entity}
chatId={chatId}
messageId={messageId}
>
{renderNestedMessagePart()}
</FormattedDate>
);
default: default:
return renderNestedMessagePart(); return renderNestedMessagePart();
} }

View File

@ -89,7 +89,7 @@ const ForwardRecipientPicker: FC<OwnProps & StateProps> = ({
: 'Conversation.ForwardTooltip.SavedMessages.One', : 'Conversation.ForwardTooltip.SavedMessages.One',
); );
forwardToSavedMessages(); forwardToSavedMessages({});
showNotification({ message }); showNotification({ message });
} else { } else {
const chatId = recipientId; const chatId = recipientId;

View File

@ -369,7 +369,7 @@ const Main = ({
loadCountryList({ langCode: lang.code }); loadCountryList({ langCode: lang.code });
} }
}, [lang, isMasterTab]); }, [lang.code, isMasterTab]);
// Re-fetch cached saved emoji for `localDb` // Re-fetch cached saved emoji for `localDb`
useEffect(() => { useEffect(() => {

View File

@ -121,6 +121,7 @@ const ListItem = ({
if (ref) { if (ref) {
containerRef = ref; containerRef = ref;
} }
const menuRef = useRef<HTMLDivElement>();
const [isTouched, markIsTouched, unmarkIsTouched] = useFlag(); const [isTouched, markIsTouched, unmarkIsTouched] = useFlag();
const { const {
@ -131,10 +132,7 @@ const ListItem = ({
const getTriggerElement = useLastCallback(() => containerRef.current); const getTriggerElement = useLastCallback(() => containerRef.current);
const getRootElement = useLastCallback(() => containerRef.current!.closest('.custom-scroll')); const getRootElement = useLastCallback(() => containerRef.current!.closest('.custom-scroll'));
const getMenuElement = useLastCallback(() => { const getMenuElement = useLastCallback(() => menuRef.current);
return (withPortalForMenu ? document.querySelector('#portals') : containerRef.current)!
.querySelector('.ListItem-context-menu .bubble');
});
const getLayout = useLastCallback(() => ({ withPortal: withPortalForMenu })); const getLayout = useLastCallback(() => ({ withPortal: withPortalForMenu }));
const handleClickEvent = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => { const handleClickEvent = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
@ -271,6 +269,7 @@ const ListItem = ({
</ButtonElementTag> </ButtonElementTag>
{contextActions && contextMenuAnchor !== undefined && ( {contextActions && contextMenuAnchor !== undefined && (
<Menu <Menu
ref={menuRef}
isOpen={isContextMenuOpen} isOpen={isContextMenuOpen}
anchor={contextMenuAnchor} anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement} getTriggerElement={getTriggerElement}

View File

@ -2583,7 +2583,7 @@ addActionHandler('setForwardChatOrTopic', async (global, actions, payload): Prom
}); });
addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionReturnType => { addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {}; const { scheduledAt, tabId = getCurrentTabId() } = payload || {};
global = updateTabState(global, { global = updateTabState(global, {
forwardMessages: { forwardMessages: {
...selectTabState(global, tabId).forwardMessages, ...selectTabState(global, tabId).forwardMessages,
@ -2593,7 +2593,7 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet
setGlobal(global); setGlobal(global);
actions.exitMessageSelectMode({ tabId }); actions.exitMessageSelectMode({ tabId });
actions.forwardMessages({ isSilent: true, tabId }); actions.forwardMessages({ isSilent: true, scheduledAt, tabId });
}); });
addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => { addActionHandler('forwardStory', (global, actions, payload): ActionReturnType => {

View File

@ -2023,7 +2023,9 @@ export interface ActionPayloads {
} & WithTabId; } & WithTabId;
exitForwardMode: WithTabId | undefined; exitForwardMode: WithTabId | undefined;
changeRecipient: WithTabId | undefined; changeRecipient: WithTabId | undefined;
forwardToSavedMessages: WithTabId | undefined; forwardToSavedMessages: {
scheduledAt?: number;
} & WithTabId;
forwardStory: { forwardStory: {
toChatId: string; toChatId: string;
} & WithTabId; } & WithTabId;

View File

@ -210,7 +210,7 @@ function createClasses(classesType: 'constructor' | 'request', params: Generatio
const flagGroupSuffix = arg.flagGroup > 1 ? arg.flagGroup : ''; const flagGroupSuffix = arg.flagGroup > 1 ? arg.flagGroup : '';
const flagValue = args[`flags${flagGroupSuffix}`] & (1 << arg.flagIndex); const flagValue = args[`flags${flagGroupSuffix}`] & (1 << arg.flagIndex);
if (arg.type === 'true') { if (arg.type === 'true') {
args[argName] = Boolean(flagValue); args[argName] = flagValue ? true : undefined;
continue; continue;
} }

View File

@ -777,7 +777,6 @@ export type SendMessageParams = {
messagePriceInStars?: number; messagePriceInStars?: number;
localMessage?: ApiMessage; localMessage?: ApiMessage;
forwardedLocalMessagesSlice?: ForwardedLocalMessagesSlice; forwardedLocalMessagesSlice?: ForwardedLocalMessagesSlice;
isForwarding?: boolean;
forwardParams?: ForwardMessagesParams; forwardParams?: ForwardMessagesParams;
isStoryReply?: boolean; isStoryReply?: boolean;
suggestedMedia?: MediaContent; suggestedMedia?: MediaContent;

View File

@ -2036,6 +2036,9 @@ export interface LangPair {
'RankEditSave': undefined; 'RankEditSave': undefined;
'RankEditTextOwn': undefined; 'RankEditTextOwn': undefined;
'MenuAddCaption': undefined; 'MenuAddCaption': undefined;
'MenuCopyDate': undefined;
'DateCopiedToast': undefined;
'ReminderSetToast': undefined;
'NoForwardsRequestReject': undefined; 'NoForwardsRequestReject': undefined;
'NoForwardsRequestAccept': undefined; 'NoForwardsRequestAccept': undefined;
} }

View File

@ -239,6 +239,10 @@ function getHourCycle(timeFormat: TimeFormat) {
return timeFormat === '12h' ? 'h12' : 'h23'; return timeFormat === '12h' ? 'h12' : 'h23';
} }
export function secondsToDate(seconds: number) {
return new Date(seconds * 1000);
}
function serializeRecord(record: object) { function serializeRecord(record: object) {
return Object.entries(record as Record<string, unknown>) return Object.entries(record as Record<string, unknown>)
.filter(([, value]) => value !== undefined) .filter(([, value]) => value !== undefined)

View File

@ -356,7 +356,7 @@ export function setTimeFormat(timeFormat: TimeFormat) {
currentTimeFormat = timeFormat; currentTimeFormat = timeFormat;
resetDateFormatCache(); resetDateFormatCache();
translationFn.timeFormat = currentTimeFormat; translationFn = createTranslationFn();
scheduleCallbacks(); scheduleCallbacks();
} }

View File

@ -198,13 +198,21 @@ function getEntityDataFromNode(
} }
if (type === ApiMessageEntityTypes.CustomEmoji) { 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 { return {
index, index,
entity: { entity: {
type, type,
offset, offset,
length, 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 { return {
index, index,
entity: { 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; return undefined;
} }