Dates: Support composer input (#6796)

This commit is contained in:
zubiden 2026-04-14 14:36:09 +02:00 committed by Alexander Zinchuk
parent e9955fa879
commit a2a601f56c
19 changed files with 830 additions and 227 deletions

View File

@ -1293,7 +1293,7 @@
"LiveLocationUpdatedMinutesAgo_one" = "updated 1 minute ago";
"LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago";
"LiveLocationUpdatedTodayAt" = "updated at {time}";
"RightNow" = "Just now";
"RightNow" = "Right now";
"Seconds_one" = "{count} second";
"Seconds_other" = "{count} seconds";
"Minutes_one" = "{count} minute";
@ -1421,8 +1421,19 @@
"FormattingMonospaceAria" = "Monospace text";
"FormattingUnderlineAria" = "Underlined text";
"FormattingStrikethroughAria" = "Strikethrough text";
"FormattingAddDateAria" = "Add Date";
"FormattingAddLinkAria" = "Add Link";
"FormattingEnterUrl" = "Enter URL...";
"FormattedDateModalTitle" = "Format Date";
"FormattedDatePreview" = "Preview";
"FormattedDateAbsolute" = "Absolute";
"FormattedDateNone" = "None";
"FormattedDateShort" = "Short";
"FormattedDateLong" = "Long";
"FormattedDateRelative" = "Relative";
"FormattedDateDayOfWeek" = "Day of week";
"FormattedDateDate" = "Date";
"FormattedDateTime" = "Time";
"PreviewWebPageClose" = "Clear Webpage Preview";
"MediaLocaltionImageAlt" = "Location on a map";
"MediaPollSolutionAria" = "Show solution";

View File

@ -628,6 +628,7 @@ const Composer = ({
) => {
if (inInputId === editableInputId && isComposerBlocked) return;
const selection = window.getSelection()!;
const savedSelectionRange = getSelectionRange();
let messageInput: HTMLDivElement;
if (inInputId === editableInputId) {
messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector)!;
@ -635,12 +636,33 @@ const Composer = ({
messageInput = document.getElementById(inInputId) as HTMLDivElement;
}
if (selection.rangeCount && !shouldPrepend) {
const selectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(selectionRange, inInputId)) {
insertHtmlInSelection(newHtml);
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
if (!shouldPrepend) {
let selectionRange: Range | undefined;
if (selection.rangeCount) {
const currentSelectionRange = selection.getRangeAt(0);
if (isSelectionInsideInput(currentSelectionRange, inInputId)) {
selectionRange = currentSelectionRange;
}
}
if (!selectionRange && savedSelectionRange && isSelectionInsideInput(savedSelectionRange, inInputId)) {
selectionRange = savedSelectionRange.cloneRange();
}
if (selectionRange) {
try {
if (!selection.rangeCount || selection.getRangeAt(0) !== selectionRange) {
selection.removeAllRanges();
selection.addRange(selectionRange);
}
insertHtmlInSelection(newHtml);
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
} catch {
// Fall back to appending below if restoring the previous range fails.
}
}
}
@ -1692,6 +1714,11 @@ const Composer = ({
insertTextAndUpdateCursor(text, EDITABLE_INPUT_MODAL_ID);
});
const handleFormattedDateInsert = useLastCallback((text: ApiFormattedText) => {
const targetInputId = attachments.length ? EDITABLE_INPUT_MODAL_ID : editableInputId;
insertFormattedTextAndUpdateCursor(text, targetInputId);
});
const removeSymbol = useLastCallback((inInputId = editableInputId) => {
const selection = window.getSelection()!;
@ -2387,7 +2414,9 @@ const Composer = ({
canSendVideos={canSendVideos}
canSendDocuments={canSendDocuments}
canSendAudios={canSendAudios}
canInsertDate={!isComposerBlocked}
onFileSelect={handleFileSelect}
onDateInsert={handleFormattedDateInsert}
onPollCreate={openPollModal}
onTodoListCreate={handleTodoListCreate}
isScheduled={isInScheduledList}

View File

@ -4,7 +4,11 @@ import { getActions } from '../../global';
import { type ApiMessageEntityFormattedDate, ApiMessageEntityTypes } from '../../api/types';
import { copyTextToClipboard } from '../../util/clipboard';
import { formatDateTime, secondsToDate } from '../../util/localization/dateFormat';
import {
formatFormattedDateText,
getCanonicalFormattedDate,
getFormattedDateFormatString,
} from '../../util/dates/formattedDate';
import { getServerTime } from '../../util/serverTime';
import useInterval from '../../hooks/schedulers/useInterval';
@ -39,11 +43,13 @@ const FormattedDate = ({
const lang = useLang();
const [requestCalendar, calendar] = useSchedule(undefined, undefined, entity.date);
const [requestCalendar, calendar] = useSchedule(
undefined, undefined, Math.max(entity.date, getServerTime()),
);
useInterval(
() => setCacheBreaker((prev) => prev + 1),
getUpdateInterval(Math.abs(entity.date - getServerTime())),
entity.relative && getUpdateInterval(Math.abs(entity.date - getServerTime())),
);
const canSetReminder = Boolean(chatId && messageId);
@ -52,25 +58,8 @@ const FormattedDate = ({
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,
});
const canonical = getCanonicalFormattedDate(lang, date);
const formatted = formatFormattedDateText(lang, date, formatOptions);
return { formattedDate: formatted, canonicalDate: canonical };
}, [lang, entity, cacheBreaker]);
@ -123,7 +112,7 @@ const FormattedDate = ({
dir="auto"
data-entity-type={ApiMessageEntityTypes.FormattedDate}
data-unix={entity.date}
data-format={formatToString(entity)}
data-format={getFormattedDateFormatString(entity)}
title={canonicalDate}
>
{formattedDate ?? children}
@ -161,17 +150,9 @@ function getUpdateInterval(diffInSeconds: number) {
return 60000;
}
if (diffInSeconds < 60 * 60 * 24) {
return 3600000;
}
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

@ -9,6 +9,7 @@ import { ApiMessageEntityTypes } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
import renderText from './renderText';
@ -749,6 +750,8 @@ function processEntityAsHtml(
class="blockquote"
data-entity-type="${ApiMessageEntityTypes.Blockquote}"
>${renderedContent}</blockquote>`;
case ApiMessageEntityTypes.FormattedDate:
return buildFormattedDateHtml(renderedContent, entity);
default:
return renderedContent;
}

View File

@ -25,6 +25,7 @@ import buildClassName from '../../util/buildClassName';
import { formatHumanDate, formatScheduledDateTime } from '../../util/dates/oldDateFormat';
import { convertTonFromNanos } from '../../util/formatCurrency';
import { compact } from '../../util/iteratees';
import { formatMessageListDate } from '../../util/localization/dateFormat';
import { formatStarsAsText, formatTonAsText } from '../../util/localization/format';
import { isAlbum, isDocumentGroup } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
@ -521,7 +522,7 @@ const MessageListContent = ({
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && (
oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true))
)}
{!isSchedule && formatHumanDate(oldLang, dateGroup.datetime)}
{!isSchedule && formatMessageListDate(lang, new Date(dateGroup.datetime))}
</span>
</div>
{senderGroups.flat()}

View File

@ -4,7 +4,7 @@ import {
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiAttachMenuPeerType, ApiMessage } from '../../../api/types';
import type { ApiAttachMenuPeerType, ApiFormattedText, ApiMessage } from '../../../api/types';
import type { GlobalState } from '../../../global/types';
import type { MessageListType, ThemeKey, ThreadId } from '../../../types';
@ -35,6 +35,7 @@ import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
import AttachBotItem from './AttachBotItem';
import FormattedDateModal from './FormattedDateModal';
import './AttachMenu.scss';
@ -58,7 +59,9 @@ export type OwnProps = {
editingMessage?: ApiMessage;
messageListType?: MessageListType;
paidMessagesStars?: number;
canInsertDate?: boolean;
onFileSelect: (files: File[]) => void;
onDateInsert: (text: ApiFormattedText) => void;
onPollCreate: NoneToVoidFunction;
onTodoListCreate: NoneToVoidFunction;
onMenuOpen: NoneToVoidFunction;
@ -85,7 +88,9 @@ const AttachMenu = ({
editingMessage,
messageListType,
paidMessagesStars,
canInsertDate,
onFileSelect,
onDateInsert,
onMenuOpen,
onMenuClose,
onPollCreate,
@ -96,6 +101,7 @@ const AttachMenu = ({
} = getActions();
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu);
const [isDateModalOpen, openDateModal, closeDateModal] = useFlag();
const canSendVideoAndPhoto = canSendPhotos && canSendVideos;
const canSendVideoOrPhoto = canSendPhotos || canSendVideos;
@ -179,106 +185,123 @@ const AttachMenu = ({
const oldLang = useOldLang();
const lang = useLang();
if (!isButtonVisible) {
const handleDateMenuClick = useLastCallback(() => {
closeAttachMenu();
openDateModal();
});
if (!isButtonVisible && !isDateModalOpen) {
return undefined;
}
return (
<div className="AttachMenu">
{
editingMessage && canEditMedia ? (
<ResponsiveHoverButton
id="replace-menu-button"
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Replace an attachment"
ariaControls="replace-menu-controls"
hasPopup
{isButtonVisible && (
<>
{
editingMessage && canEditMedia ? (
<ResponsiveHoverButton
id="replace-menu-button"
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Replace an attachment"
ariaControls="replace-menu-controls"
hasPopup
>
<Icon name="replace" />
</ResponsiveHoverButton>
) : (
<ResponsiveHoverButton
id="attach-menu-button"
disabled={Boolean(editingMessage)}
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Add an attachment"
ariaControls="attach-menu-controls"
hasPopup
>
<Icon name="attach" />
</ResponsiveHoverButton>
)
}
<Menu
id="attach-menu-controls"
isOpen={isMenuOpen}
autoClose
positionX="right"
positionY="bottom"
onClose={closeAttachMenu}
className="AttachMenu--menu fluid"
onCloseAnimationEnd={closeAttachMenu}
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
noCloseOnBackdrop={!IS_TOUCH_ENV}
ariaLabelledBy="attach-menu-button"
>
<Icon name="replace" />
</ResponsiveHoverButton>
) : (
<ResponsiveHoverButton
id="attach-menu-button"
disabled={Boolean(editingMessage)}
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
round
color="translucent"
onActivate={handleToggleAttachMenu}
ariaLabel="Add an attachment"
ariaControls="attach-menu-controls"
hasPopup
>
<Icon name="attach" />
</ResponsiveHoverButton>
)
}
<Menu
id="attach-menu-controls"
isOpen={isMenuOpen}
autoClose
positionX="right"
positionY="bottom"
onClose={closeAttachMenu}
className="AttachMenu--menu fluid"
onCloseAnimationEnd={closeAttachMenu}
onMouseEnter={!IS_TOUCH_ENV ? handleMouseEnter : undefined}
onMouseLeave={!IS_TOUCH_ENV ? handleMouseLeave : undefined}
noCloseOnBackdrop={!IS_TOUCH_ENV}
ariaLabelledBy="attach-menu-button"
>
{/*
** Using ternary operator here causes some attributes from first clause
** transferring to the fragment content in the second clause
*/}
{!canAttachMedia && (
<MenuItem className="media-disabled" disabled>
{lang(messageListType === 'scheduled' && paidMessagesStars
? 'DescriptionScheduledPaidMediaNotAllowed'
: 'DescriptionRestrictedMedia')}
</MenuItem>
)}
{canAttachMedia && (
<>
{canSendVideoOrPhoto && !isFile && (
<MenuItem icon="photo" onClick={handleQuickSelect}>
{oldLang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo'
: (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))}
{/*
** Using ternary operator here causes some attributes from first clause
** transferring to the fragment content in the second clause
*/}
{!canAttachMedia && (
<MenuItem className="media-disabled" disabled>
{lang(messageListType === 'scheduled' && paidMessagesStars
? 'DescriptionScheduledPaidMediaNotAllowed'
: 'DescriptionRestrictedMedia')}
</MenuItem>
)}
{((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
&& (
<MenuItem icon="document" onClick={handleDocumentSelect}>
{oldLang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')}
</MenuItem>
)}
{canSendDocuments && shouldCollectDebugLogs && (
<MenuItem icon="bug" onClick={handleSendLogs}>
{oldLang('DebugSendLogs')}
</MenuItem>
{canAttachMedia && (
<>
{canSendVideoOrPhoto && !isFile && (
<MenuItem icon="photo" onClick={handleQuickSelect}>
{oldLang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo'
: (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))}
</MenuItem>
)}
{((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
&& (
<MenuItem icon="document" onClick={handleDocumentSelect}>
{oldLang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')}
</MenuItem>
)}
{canSendDocuments && shouldCollectDebugLogs && (
<MenuItem icon="bug" onClick={handleSendLogs}>
{oldLang('DebugSendLogs')}
</MenuItem>
)}
</>
)}
{canAttachPolls && !editingMessage && (
<MenuItem icon="poll" onClick={onPollCreate}>{oldLang('Poll')}</MenuItem>
)}
{canAttachToDoLists && !editingMessage && (
<MenuItem icon="select" onClick={onTodoListCreate}>{lang('TitleToDoList')}</MenuItem>
)}
{canInsertDate && !editingMessage && (
<MenuItem icon="calendar" onClick={handleDateMenuClick}>{lang('GiftInfoDate')}</MenuItem>
)}
</>
)}
{canAttachPolls && !editingMessage && (
<MenuItem icon="poll" onClick={onPollCreate}>{oldLang('Poll')}</MenuItem>
)}
{canAttachToDoLists && !editingMessage && (
<MenuItem icon="select" onClick={onTodoListCreate}>{lang('TitleToDoList')}</MenuItem>
)}
{!editingMessage && !canEditMedia && !isScheduled && bots?.map((bot) => (
<AttachBotItem
bot={bot}
chatId={chatId}
threadId={threadId}
theme={theme}
onMenuOpened={markAttachmentBotMenuOpen}
onMenuClosed={unmarkAttachmentBotMenuOpen}
/>
))}
</Menu>
{!editingMessage && !canEditMedia && !isScheduled && bots?.map((bot) => (
<AttachBotItem
bot={bot}
chatId={chatId}
threadId={threadId}
theme={theme}
onMenuOpened={markAttachmentBotMenuOpen}
onMenuClosed={unmarkAttachmentBotMenuOpen}
/>
))}
</Menu>
</>
)}
<FormattedDateModal
isOpen={isDateModalOpen}
onClose={closeDateModal}
onSubmit={onDateInsert}
/>
</div>
);
};

View File

@ -0,0 +1,57 @@
.root {
background-color: var(--color-background-secondary);
}
.island {
padding: 1rem;
border-radius: var(--border-radius-island);
background-color: var(--color-background);
box-shadow: 0px 1px 4px 0px #0000000D;
}
.previewInput {
margin-bottom: 0;
&:global(.disabled) {
pointer-events: auto;
opacity: 1;
}
:global(.form-control) {
cursor: var(--custom-cursor, pointer);
text-align: center;
}
}
.options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.checkboxRow,
.tabGroup {
display: flex;
flex-direction: column;
}
.groupLabel {
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.tabList {
align-self: flex-start;
}
.modeTabList {
align-self: center;
}
.tabListDisabled {
pointer-events: none;
opacity: 0.5;
}

View File

@ -0,0 +1,250 @@
import {
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import type { ApiFormattedText } from '../../../api/types';
import type { FormattedDateEntityOptions } from '../../../util/dates/formattedDate';
import buildClassName from '../../../util/buildClassName';
import {
buildFormattedDateText,
formatFormattedDateText,
getCanonicalFormattedDate,
getDefaultFormattedDateText,
} from '../../../util/dates/formattedDate';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import CalendarModal from '../../common/CalendarModal.async';
import Button from '../../ui/Button';
import Checkbox from '../../ui/Checkbox';
import InputText from '../../ui/InputText';
import Modal from '../../ui/Modal';
import TabList from '../../ui/TabList';
import styles from './FormattedDateModal.module.scss';
export type OwnProps = {
isOpen: boolean;
onClose: NoneToVoidFunction;
onSubmit: (text: ApiFormattedText) => void;
};
const FormattedDateModal = ({
isOpen,
onClose,
onSubmit,
}: OwnProps) => {
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
const [selectedDateAt, setSelectedDateAt] = useState(() => roundDateToMinute(new Date()).getTime());
const [formattedDateOptions, setFormattedDateOptions] = useState<FormattedDateOptionsState>(
DEFAULT_FORMATTED_DATE_OPTIONS,
);
const lang = useLang();
useEffect(() => {
if (!isOpen) {
closeCalendar();
return;
}
setSelectedDateAt(roundDateToMinute(new Date()).getTime());
setFormattedDateOptions(DEFAULT_FORMATTED_DATE_OPTIONS);
}, [closeCalendar, isOpen]);
const unix = useMemo(() => Math.round(selectedDateAt / 1000), [selectedDateAt]);
const modeTabs = useMemo(() => ([
{ title: lang('FormattedDateRelative') },
{ title: lang('FormattedDateAbsolute') },
]), [lang]);
const formatTabs = useMemo(() => ([
{ title: lang('FormattedDateNone') },
{ title: lang('FormattedDateShort') },
{ title: lang('FormattedDateLong') },
]), [lang]);
const formattedDateEntityOptions = buildFormattedDateEntityOptions(formattedDateOptions);
const previewText = useMemo(() => formatFormattedDateText(
lang,
unix,
formattedDateEntityOptions,
), [formattedDateEntityOptions, lang, unix]);
const canonicalDate = useMemo(() => getCanonicalFormattedDate(lang, unix), [lang, unix]);
const areOtherDateOptionsDisabled = formattedDateOptions.relative;
const activeModeTab = formattedDateOptions.relative ? 0 : 1;
const activeDateTab = DATE_STYLE_TAB_VALUES.indexOf(formattedDateOptions.dateStyle);
const activeTimeTab = TIME_STYLE_TAB_VALUES.indexOf(formattedDateOptions.timeStyle);
const handleModeTabChange = useLastCallback((index: number) => {
setFormattedDateOptions((current) => ({
...current,
relative: index === 0,
}));
});
const handleDayOfWeekChange = useLastCallback((isChecked: boolean) => {
setFormattedDateOptions((current) => ({
...current,
dayOfWeek: isChecked,
}));
});
const handleDateStyleChange = useLastCallback((index: number) => {
if (areOtherDateOptionsDisabled) {
return;
}
setFormattedDateOptions((current) => ({
...current,
dateStyle: DATE_STYLE_TAB_VALUES[index],
}));
});
const handleTimeStyleChange = useLastCallback((index: number) => {
if (areOtherDateOptionsDisabled) {
return;
}
setFormattedDateOptions((current) => ({
...current,
timeStyle: TIME_STYLE_TAB_VALUES[index],
}));
});
const handleSubmit = useLastCallback(() => {
onSubmit(buildFormattedDateText(getDefaultFormattedDateText(lang, unix), unix, formattedDateEntityOptions));
onClose();
});
const handleCalendarSubmit = useLastCallback((date: Date) => {
setSelectedDateAt(date.getTime());
closeCalendar();
});
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
onEnter={handleSubmit}
title={lang('FormattedDateModalTitle')}
dialogClassName={styles.root}
isSlim
hasCloseButton
>
<div className={styles.island}>
<InputText
className={styles.previewInput}
label={lang('FormattedDatePreview')}
value={previewText || getDefaultFormattedDateText(lang, unix)}
readOnly
title={canonicalDate}
onClick={openCalendar}
/>
</div>
<div className={styles.options}>
<TabList
className={buildClassName(styles.tabList, styles.modeTabList)}
tabs={modeTabs}
activeTab={activeModeTab}
onSwitchTab={handleModeTabChange}
/>
<div className={styles.tabGroup}>
<div className={styles.groupLabel}>{lang('FormattedDateDate')}</div>
<TabList
className={buildClassName(styles.tabList, areOtherDateOptionsDisabled && styles.tabListDisabled)}
tabs={formatTabs}
activeTab={activeDateTab}
onSwitchTab={handleDateStyleChange}
/>
</div>
<div className={styles.tabGroup}>
<div className={styles.groupLabel}>{lang('FormattedDateTime')}</div>
<TabList
className={buildClassName(styles.tabList, areOtherDateOptionsDisabled && styles.tabListDisabled)}
tabs={formatTabs}
activeTab={activeTimeTab}
onSwitchTab={handleTimeStyleChange}
/>
</div>
<div className={styles.checkboxRow}>
<Checkbox
label={lang('FormattedDateDayOfWeek')}
checked={formattedDateOptions.dayOfWeek}
disabled={areOtherDateOptionsDisabled}
onCheck={handleDayOfWeekChange}
/>
</div>
</div>
<div className="dialog-buttons mt-2">
<Button className="confirm-dialog-button" onClick={handleSubmit}>
{lang('Save')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>
{lang('Cancel')}
</Button>
</div>
</Modal>
<CalendarModal
isOpen={isOpen && isCalendarOpen}
selectedAt={selectedDateAt}
withTimePicker
submitButtonLabel={lang('Save')}
onClose={closeCalendar}
onSubmit={handleCalendarSubmit}
/>
</>
);
};
export default memo(FormattedDateModal);
type DateStyle = 'none' | 'short' | 'long';
type TimeStyle = 'none' | 'short' | 'long';
type FormattedDateOptionsState = {
relative: boolean;
dayOfWeek: boolean;
dateStyle: DateStyle;
timeStyle: TimeStyle;
};
const DEFAULT_FORMATTED_DATE_OPTIONS: FormattedDateOptionsState = {
relative: false,
dayOfWeek: false,
dateStyle: 'long',
timeStyle: 'short',
};
const DATE_STYLE_TAB_VALUES: DateStyle[] = ['none', 'short', 'long'];
const TIME_STYLE_TAB_VALUES: TimeStyle[] = ['none', 'short', 'long'];
function roundDateToMinute(date: Date) {
const nextDate = new Date(date.getTime());
nextDate.setSeconds(0);
nextDate.setMilliseconds(0);
return nextDate;
}
function buildFormattedDateEntityOptions(options: FormattedDateOptionsState): FormattedDateEntityOptions {
if (options.relative) {
return { relative: true };
}
return {
dayOfWeek: options.dayOfWeek || undefined,
shortDate: options.dateStyle === 'short' ? true : undefined,
longDate: options.dateStyle === 'long' ? true : undefined,
shortTime: options.timeStyle === 'short' ? true : undefined,
longTime: options.timeStyle === 'long' ? true : undefined,
};
}

View File

@ -1,6 +1,4 @@
import type { ChangeEvent } from 'react';
import type { ElementRef, FC, TeactNode } from '../../../lib/teact/teact';
import type React from '../../../lib/teact/teact';
import type { ElementRef, TeactNode } from '../../../lib/teact/teact';
import {
memo, useEffect, useLayoutEffect,
useRef, useState,
@ -112,7 +110,7 @@ function clearSelection() {
}
}
const MessageInput: FC<OwnProps & StateProps> = ({
const MessageInput = ({
ref,
id,
chatId,
@ -143,7 +141,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
onScroll,
onFocus,
onBlur,
}) => {
}: OwnProps & StateProps) => {
const {
editLastMessage,
replyToNextMessage,
@ -408,7 +406,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
}
}
function handleChange(e: ChangeEvent<HTMLDivElement>) {
function handleChange(e: React.ChangeEvent<HTMLDivElement>) {
const { innerHTML, textContent } = e.currentTarget;
onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML);

View File

@ -12,6 +12,7 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment';
import { ensureProtocol } from '../../../util/browser/url';
import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
import getKeyFromEvent from '../../../util/getKeyFromEvent';
import stopEvent from '../../../util/stopEvent';
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
@ -22,6 +23,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
import CalendarModal from '../../common/CalendarModal';
import Button from '../../ui/Button';
import './TextFormatter.scss';
@ -66,16 +68,20 @@ const TextFormatter: FC<OwnProps> = ({
const linkUrlInputRef = useRef<HTMLInputElement>();
const { shouldRender, transitionClassNames } = useShowTransitionDeprecated(isOpen);
const [isLinkControlOpen, openLinkControl, closeLinkControl] = useFlag();
const [isDatePickerOpen, openDatePicker, closeDatePicker] = useFlag();
const [linkUrl, setLinkUrl] = useState('');
const [isEditingLink, setIsEditingLink] = useState(false);
const [inputClassName, setInputClassName] = useState<string | undefined>();
const [selectedTextFormats, setSelectedTextFormats] = useState<ISelectedTextFormats>({});
const [selectedDateAt, setSelectedDateAt] = useState(() => roundDateToMinute(new Date()).getTime());
const lang = useLang();
useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]);
useEffect(() => (
isOpen && !isDatePickerOpen ? captureEscKeyListener(onClose) : undefined
), [isDatePickerOpen, isOpen, onClose]);
useVirtualBackdrop(
isOpen,
isOpen && !isDatePickerOpen,
containerRef,
onClose,
true,
@ -93,10 +99,11 @@ const TextFormatter: FC<OwnProps> = ({
useEffect(() => {
if (!shouldRender) {
closeLinkControl();
closeDatePicker();
setSelectedTextFormats({});
setInputClassName(undefined);
}
}, [closeLinkControl, shouldRender]);
}, [closeDatePicker, closeLinkControl, shouldRender]);
useEffect(() => {
if (!isOpen || !selectedRange) {
@ -345,7 +352,38 @@ const TextFormatter: FC<OwnProps> = ({
onClose();
});
const handleOpenDatePicker = useLastCallback(() => {
closeLinkControl();
setSelectedDateAt(roundDateToMinute(new Date()).getTime());
openDatePicker();
});
const handleDateChange = useLastCallback((date: Date) => {
setSelectedDateAt(date.getTime());
});
const handleFormattedDateConfirm = useLastCallback((date: Date) => {
const text = getSelectedText();
if (!text || !selectedRange) {
return;
}
restoreSelection();
document.execCommand('insertHTML', false, buildFormattedDateHtml(text, {
type: ApiMessageEntityTypes.FormattedDate,
offset: 0,
length: selectedRange.toString().length,
date: Math.round(date.getTime() / 1000),
}));
closeDatePicker();
onClose();
});
const handleKeyDown = useLastCallback((e: KeyboardEvent) => {
if (isDatePickerOpen) {
return;
}
const HANDLERS_BY_KEY: Record<string, AnyToVoidFunction> = {
k: openLinkControl,
b: handleBoldText,
@ -460,6 +498,12 @@ const TextFormatter: FC<OwnProps> = ({
iconName="monospace"
/>
<div className="TextFormatter-divider" />
<Button
color="translucent"
ariaLabel={lang('FormattingAddDateAria')}
onClick={handleOpenDatePicker}
iconName="calendar"
/>
<Button
color="translucent"
ariaLabel={lang('FormattingAddLinkAria')}
@ -508,8 +552,24 @@ const TextFormatter: FC<OwnProps> = ({
</div>
</div>
</div>
<CalendarModal
isOpen={isDatePickerOpen}
selectedAt={selectedDateAt}
withTimePicker
submitButtonLabel={lang('Save')}
onClose={closeDatePicker}
onDateChange={handleDateChange}
onSubmit={handleFormattedDateConfirm}
/>
</div>
);
};
export default memo(TextFormatter);
function roundDateToMinute(date: Date) {
const nextDate = new Date(date.getTime());
nextDate.setSeconds(0);
nextDate.setMilliseconds(0);
return nextDate;
}

View File

@ -1,7 +1,7 @@
import type { FormatDateTimeOptions } from '../../util/localization/dateFormat';
import buildClassName from '../../util/buildClassName';
import { formatDateTime } from '../../util/localization/dateFormat';
import { formatDateTime, formatMessageListDate } from '../../util/localization/dateFormat';
import useLang from '../../hooks/useLang';
@ -45,7 +45,7 @@ const ABSOLUTE_CASES: Array<{ label: string; options: FormatDateTimeOptions }> =
},
];
const RELATIVE_CASES = [
const RELATIVE_CASES: Array<{ label: string; startDate: Date; anchorDate?: Date }> = [
{ label: '30 seconds later', startDate: new Date(2026, 2, 16, 12, 35, 26) },
{ label: '5 minutes later', startDate: new Date(2026, 2, 16, 12, 39, 56) },
{ label: '3 hours later', startDate: new Date(2026, 2, 16, 15, 34, 56) },
@ -56,6 +56,29 @@ const RELATIVE_CASES = [
{ label: '2 hours earlier', startDate: new Date(2026, 2, 16, 10, 34, 56) },
{ label: 'yesterday', startDate: new Date(2026, 2, 15, 12, 34, 56) },
{ label: '3 days earlier', startDate: new Date(2026, 2, 13, 12, 34, 56) },
{
label: '25 hours later, but 2 calendar days later',
anchorDate: new Date(2026, 2, 16, 23, 0, 0),
startDate: new Date(2026, 2, 18, 0, 0, 0),
},
{
label: '25 hours earlier, but 2 calendar days earlier',
anchorDate: new Date(2026, 2, 16, 1, 0, 0),
startDate: new Date(2026, 2, 14, 0, 0, 0),
},
];
const MESSAGE_LIST_CASES: Array<{ label: string; date: Date; anchorDate?: Date }> = [
{ label: 'today', date: new Date(2026, 2, 16, 9, 0, 0) },
{ label: 'yesterday', date: new Date(2026, 2, 15, 23, 0, 0) },
{ label: '3 days earlier', date: new Date(2026, 2, 13, 12, 0, 0) },
{ label: '8 days earlier', date: new Date(2026, 2, 8, 12, 0, 0) },
{ label: 'same year, older date', date: new Date(2026, 0, 14, 12, 0, 0) },
{ label: 'different year', date: new Date(2025, 0, 14, 12, 0, 0) },
{
label: 'yesterday across year boundary',
anchorDate: new Date(2026, 0, 1, 12, 0, 0),
date: new Date(2025, 11, 31, 12, 0, 0),
},
];
function DebugTable({ rows }: { rows: Row[] }) {
@ -88,12 +111,23 @@ const DateFormatTest = () => {
value: formatDateTime(lang, ANCHOR_DATE, options),
}));
const relativeRows: Row[] = RELATIVE_CASES.map(({ label, startDate }) => {
const relativeRows: Row[] = RELATIVE_CASES.map(({ label, startDate, anchorDate }) => {
const startDateLabel = startDate.toLocaleString();
const effectiveAnchorDate = anchorDate || ANCHOR_DATE;
const anchorDateLabel = anchorDate ? `; anchor ${anchorDate.toLocaleString()}` : '';
return {
label: `${label} (${startDateLabel})`,
value: formatDateTime(lang, startDate, { relative: 'auto', anchorDate: ANCHOR_DATE }),
label: `${label} (${startDateLabel}${anchorDateLabel})`,
value: formatDateTime(lang, startDate, { relative: 'auto', anchorDate: effectiveAnchorDate }),
};
});
const messageListRows: Row[] = MESSAGE_LIST_CASES.map(({ label, date, anchorDate }) => {
const effectiveAnchorDate = anchorDate || ANCHOR_DATE;
const anchorDateLabel = anchorDate ? `; anchor ${anchorDate.toLocaleString()}` : '';
return {
label: `${label} (${date.toLocaleString()}${anchorDateLabel})`,
value: formatMessageListDate(lang, date, { anchorDate: effectiveAnchorDate }),
};
});
@ -121,6 +155,9 @@ const DateFormatTest = () => {
<h3>Relative Formatting</h3>
<DebugTable rows={relativeRows} />
<h3>Message List Date Formatting</h3>
<DebugTable rows={messageListRows} />
</div>
);
};

View File

@ -23,6 +23,7 @@ type OwnProps = {
autoComplete?: string;
maxLength?: number;
tabIndex?: number;
title?: string;
teactExperimentControlled?: boolean;
inputMode?: 'text' | 'none' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
@ -49,6 +50,7 @@ const InputText = ({
inputMode,
maxLength,
tabIndex,
title,
teactExperimentControlled,
onChange,
onInput,
@ -94,6 +96,7 @@ const InputText = ({
onBlur={onBlur}
onPaste={onPaste}
aria-label={labelText}
title={title}
teactExperimentControlled={teactExperimentControlled}
onClick={onClick}
/>

View File

@ -1,85 +1,87 @@
.container,
.activeIndicator {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
@layer ui.tablist {
.container,
.activeIndicator {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
padding-block: 0.375rem;
padding-inline: 0.25rem;
}
.container {
user-select: none;
scrollbar-width: none;
position: relative;
overflow-x: auto;
border-radius: 1.5rem;
opacity: 0;
background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
transition: opacity 150ms;
&::-webkit-scrollbar {
display: none;
padding-block: 0.375rem;
padding-inline: 0.25rem;
}
&.ready {
opacity: 1;
}
}
.container {
user-select: none;
scrollbar-width: none;
.activeIndicator {
will-change: clip-path;
position: relative;
isolation: isolate;
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow-x: auto;
contain: layout style paint;
overflow: hidden;
border-radius: 1.5rem;
width: fit-content;
opacity: 0;
background-color: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
background-color: var(--color-primary-opacity);
transition: opacity 150ms;
transition: clip-path var(--slide-transition);
}
&::-webkit-scrollbar {
display: none;
}
.tab {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
padding: 0.375rem 1rem;
border-radius: 1.25rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
&:hover {
opacity: 0.85;
&.ready {
opacity: 1;
}
}
.activeIndicator & {
color: var(--color-primary);
.activeIndicator {
will-change: clip-path;
isolation: isolate;
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
contain: layout style paint;
overflow: hidden;
width: fit-content;
background-color: var(--color-primary-opacity);
transition: clip-path var(--slide-transition);
}
.tab {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
padding: 0.375rem 1rem;
border-radius: 1.25rem;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
&:hover {
opacity: 0.85;
}
.activeIndicator & {
color: var(--color-primary);
}
}
.lockIcon {
font-size: 0.875rem;
}
}
.lockIcon {
font-size: 0.875rem;
}

View File

@ -54,7 +54,7 @@
<style>
@layer reset, variables, ui, components;
@layer ui {
@layer spinner, button;
@layer tablist, spinner, button;
}
</style>
</head>

View File

@ -27,7 +27,8 @@
display: block;
padding: 0 0.3125rem;
padding: 0 0.5rem;
border-radius: 1rem;
font-size: 1rem;
font-weight: var(--font-weight-normal);

View File

@ -227,6 +227,7 @@
--border-radius-button-tiny: 0.875rem;
--border-radius-modal: 2rem;
--border-radius-toast: 1rem;
--border-radius-island: 1.5rem;
--border-radius-default: 0.75rem;
--border-radius-default-small: 0.625rem;
--border-radius-default-tiny: 0.375rem;

View File

@ -1208,8 +1208,19 @@ export interface LangPair {
'FormattingMonospaceAria': undefined;
'FormattingUnderlineAria': undefined;
'FormattingStrikethroughAria': undefined;
'FormattingAddDateAria': undefined;
'FormattingAddLinkAria': undefined;
'FormattingEnterUrl': undefined;
'FormattedDateModalTitle': undefined;
'FormattedDatePreview': undefined;
'FormattedDateAbsolute': undefined;
'FormattedDateNone': undefined;
'FormattedDateShort': undefined;
'FormattedDateLong': undefined;
'FormattedDateRelative': undefined;
'FormattedDateDayOfWeek': undefined;
'FormattedDateDate': undefined;
'FormattedDateTime': undefined;
'PreviewWebPageClose': undefined;
'MediaLocaltionImageAlt': undefined;
'MediaPollSolutionAria': undefined;

View File

@ -0,0 +1,89 @@
import type { ApiFormattedText, ApiMessageEntityFormattedDate } from '../../api/types';
import type { LangFn } from '../localization';
import { ApiMessageEntityTypes } from '../../api/types';
import { formatDateTime, secondsToDate } from '../localization/dateFormat';
export type FormattedDateEntityOptions = Pick<
ApiMessageEntityFormattedDate,
'relative' | 'shortTime' | 'longTime' | 'shortDate' | 'longDate' | 'dayOfWeek'
>;
export function hasFormattedDateFormat(options: FormattedDateEntityOptions) {
return Object.values(options).some(Boolean);
}
export function getFormattedDateFormatString(options: FormattedDateEntityOptions) {
return [
options.relative && 'r',
options.dayOfWeek && 'w',
options.shortDate && 'd',
options.longDate && 'D',
options.shortTime && 't',
options.longTime && 'T',
].filter(Boolean).join('');
}
export function getCanonicalFormattedDate(lang: LangFn, date: number) {
return formatDateTime(lang, secondsToDate(date), {
date: 'long',
includeYear: true,
includeDay: true,
time: 'long',
});
}
export function getDefaultFormattedDateText(lang: LangFn, date: number) {
return formatDateTime(lang, secondsToDate(date), {
date: 'long',
time: 'short',
});
}
export function formatFormattedDateText(
lang: LangFn,
date: number,
options: FormattedDateEntityOptions,
) {
if (!hasFormattedDateFormat(options)) {
return undefined;
}
return formatDateTime(lang, secondsToDate(date), {
relative: options.relative ? 'auto' : undefined,
time: options.shortTime ? 'short' : options.longTime ? 'long' : undefined,
date: options.shortDate ? 'short' : options.longDate ? 'long' : undefined,
weekday: options.dayOfWeek ? 'long' : undefined,
});
}
export function buildFormattedDateHtml(content: string, entity: ApiMessageEntityFormattedDate) {
const format = getFormattedDateFormatString(entity);
return `<a
class="text-entity-link"
data-entity-type="${ApiMessageEntityTypes.FormattedDate}"
data-unix="${entity.date}"
${format ? `data-format="${format}"` : ''}
contenteditable="false"
draggable="false"
dir="auto"
>${content}</a>`;
}
export function buildFormattedDateText(
text: string,
date: number,
options: FormattedDateEntityOptions,
): ApiFormattedText {
return {
text,
entities: [{
type: ApiMessageEntityTypes.FormattedDate,
offset: 0,
length: text.length,
date,
...options,
}],
};
}

View File

@ -8,7 +8,7 @@ import LimitedMap from '../primitives/LimitedMap';
type DateStyle = 'short' | 'long' | 'numeric' | false;
type TimeStyle = 'short' | 'long' | false;
type WeekdayStyle = 'short' | 'long' | boolean;
type RelativeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week';
type RelativeUnit = 'minute' | 'hour' | 'day';
type RelativeType = 'numeric' | 'auto';
type RelativePart = { unit: RelativeUnit; value: number };
@ -31,6 +31,10 @@ export interface FormatDateTimeOptions {
maxRelativeDays?: number;
}
export interface FormatMessageListDateOptions {
anchorDate?: Date;
}
const RESULT_CACHE_LIMIT = 200;
const DAY_IN_SECONDS = 24 * 60 * 60;
@ -58,6 +62,36 @@ export function formatDateTime(lang: LangFn, date: Date, options: FormatDateTime
return formatAbsoluteDateTime(lang, date, options);
}
export function formatMessageListDate(
lang: LangFn,
date: Date,
options: FormatMessageListDateOptions = {},
) {
const anchorDate = options.anchorDate || new Date();
const calendarDayDiff = getCalendarDayDiff(date, anchorDate);
if (calendarDayDiff === 0) {
return lang('WeekdayToday');
}
if (calendarDayDiff === -1) {
return lang('WeekdayYesterday');
}
if (date.getFullYear() !== anchorDate.getFullYear()) {
return formatDateTime(lang, date, { date: 'long' });
}
if (Math.abs(calendarDayDiff) < 7) {
return formatDateTime(lang, date, { weekday: 'long' });
}
return formatDateTime(lang, date, {
date: 'long',
includeYear: false,
});
}
function formatAbsoluteDateTime(lang: LangFn, date: Date, options: FormatDateTimeOptions) {
const intlOptions = buildAbsoluteFormatterOptions(lang, options);
const cacheKey = [
@ -86,6 +120,10 @@ function formatRelativeDateTime(
type: RelativeType = 'numeric',
options?: Pick<FormatDateTimeOptions, 'maxRelativeDays'>,
) {
if (type === 'auto' && Math.abs(targetDate.getTime() - anchorDate.getTime()) < 60 * 1000) {
return lang('RightNow');
}
const { maxRelativeDays } = options || {};
const relativePart = getRelativePart(targetDate.getTime(), anchorDate.getTime(), maxRelativeDays);
if (!relativePart) {
@ -162,10 +200,6 @@ function getRelativePart(targetTime: number, anchorTime: number, maxRelativeDays
return undefined;
}
if (absDiffInSeconds < 60) {
return { unit: 'second' as const, value: diffInSeconds };
}
if (absDiffInSeconds < 60 * 60) {
return { unit: 'minute' as const, value: Math.trunc(diffInSeconds / 60) };
}
@ -174,7 +208,10 @@ function getRelativePart(targetTime: number, anchorTime: number, maxRelativeDays
return { unit: 'hour' as const, value: Math.trunc(diffInSeconds / (60 * 60)) };
}
return { unit: 'day' as const, value: Math.trunc(diffInSeconds / DAY_IN_SECONDS) };
return {
unit: 'day' as const,
value: getCalendarDayDiff(new Date(targetTime), new Date(anchorTime)),
};
}
function getDateTimeFormatter(locale: string, timeFormat: TimeFormat, options: Intl.DateTimeFormatOptions) {
@ -243,6 +280,15 @@ export function secondsToDate(seconds: number) {
return new Date(seconds * 1000);
}
function getCalendarDayDiff(targetDate: Date, anchorDate: Date) {
return Math.trunc(
(
Date.UTC(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
- Date.UTC(anchorDate.getFullYear(), anchorDate.getMonth(), anchorDate.getDate())
) / (DAY_IN_SECONDS * 1000),
);
}
function serializeRecord(record: object) {
return Object.entries(record as Record<string, unknown>)
.filter(([, value]) => value !== undefined)