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

View File

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

View File

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

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.";
"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";

View File

@ -1265,7 +1265,6 @@ const Composer = ({
effectId,
webPageMediaSize: attachmentSettings.webPageMediaSize,
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 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 <FormattedDate entity={entity} asPreview>{text}</FormattedDate>;
}
return text;
}
@ -668,6 +674,16 @@ function processEntity({
return (
<span className="matching-text-highlight is-quote">{renderNestedMessagePart()}</span>
);
case ApiMessageEntityTypes.FormattedDate:
return (
<FormattedDate
entity={entity}
chatId={chatId}
messageId={messageId}
>
{renderNestedMessagePart()}
</FormattedDate>
);
default:
return renderNestedMessagePart();
}

View File

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

View File

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

View File

@ -121,6 +121,7 @@ const ListItem = ({
if (ref) {
containerRef = ref;
}
const menuRef = useRef<HTMLDivElement>();
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<HTMLElement, MouseEvent>) => {
@ -271,6 +269,7 @@ const ListItem = ({
</ButtonElementTag>
{contextActions && contextMenuAnchor !== undefined && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}

View File

@ -2583,7 +2583,7 @@ addActionHandler('setForwardChatOrTopic', async (global, actions, payload): Prom
});
addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionReturnType => {
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 => {

View File

@ -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;

View File

@ -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;
}

View File

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

View File

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

View File

@ -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<string, unknown>)
.filter(([, value]) => value !== undefined)

View File

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

View File

@ -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;
}