From a2a601f56c145140b3e5a3585f97471652a3a690 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:36:09 +0200 Subject: [PATCH] Dates: Support composer input (#6796) --- src/assets/localization/fallback.strings | 13 +- src/components/common/Composer.tsx | 41 ++- src/components/common/FormattedDate.tsx | 51 ++-- .../common/helpers/renderTextWithEntities.tsx | 3 + src/components/middle/MessageListContent.tsx | 3 +- src/components/middle/composer/AttachMenu.tsx | 205 +++++++------- .../composer/FormattedDateModal.module.scss | 57 ++++ .../middle/composer/FormattedDateModal.tsx | 250 ++++++++++++++++++ .../middle/composer/MessageInput.tsx | 10 +- .../middle/composer/TextFormatter.tsx | 66 ++++- src/components/test/TestDateFormat.tsx | 47 +++- src/components/ui/InputText.tsx | 3 + src/components/ui/TabList.module.scss | 144 +++++----- src/index.html | 2 +- src/styles/_forms.scss | 3 +- src/styles/_variables.scss | 1 + src/types/language.d.ts | 11 + src/util/dates/formattedDate.ts | 89 +++++++ src/util/localization/dateFormat.ts | 58 +++- 19 files changed, 830 insertions(+), 227 deletions(-) create mode 100644 src/components/middle/composer/FormattedDateModal.module.scss create mode 100644 src/components/middle/composer/FormattedDateModal.tsx create mode 100644 src/util/dates/formattedDate.ts diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 1e0904e5c..0ccd11466 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 6584057ed..71bbfda5d 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -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(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} diff --git a/src/components/common/FormattedDate.tsx b/src/components/common/FormattedDate.tsx index 7f7521364..67467c8f0 100644 --- a/src/components/common/FormattedDate.tsx +++ b/src/components/common/FormattedDate.tsx @@ -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(''); -} diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 4b9c264f1..8c0a83b99 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -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}`; + case ApiMessageEntityTypes.FormattedDate: + return buildFormattedDateHtml(renderedContent, entity); default: return renderedContent; } diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 59ebd3d97..8cf9e0134 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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))} {senderGroups.flat()} diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index ef95473de..4b75eb003 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -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 (
- { - editingMessage && canEditMedia ? ( - + { + editingMessage && canEditMedia ? ( + + + + ) : ( + + + + ) + } + - - - ) : ( - - - - ) - } - - {/* - ** Using ternary operator here causes some attributes from first clause - ** transferring to the fragment content in the second clause - */} - {!canAttachMedia && ( - - {lang(messageListType === 'scheduled' && paidMessagesStars - ? 'DescriptionScheduledPaidMediaNotAllowed' - : 'DescriptionRestrictedMedia')} - - )} - {canAttachMedia && ( - <> - {canSendVideoOrPhoto && !isFile && ( - - {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 && ( + + {lang(messageListType === 'scheduled' && paidMessagesStars + ? 'DescriptionScheduledPaidMediaNotAllowed' + : 'DescriptionRestrictedMedia')} )} - {((canSendDocuments || canSendAudios) && !isPhotoOrVideo) - && ( - - {oldLang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')} - - )} - {canSendDocuments && shouldCollectDebugLogs && ( - - {oldLang('DebugSendLogs')} - + {canAttachMedia && ( + <> + {canSendVideoOrPhoto && !isFile && ( + + {oldLang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo' + : (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))} + + )} + {((canSendDocuments || canSendAudios) && !isPhotoOrVideo) + && ( + + {oldLang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')} + + )} + {canSendDocuments && shouldCollectDebugLogs && ( + + {oldLang('DebugSendLogs')} + + )} + + )} + {canAttachPolls && !editingMessage && ( + {oldLang('Poll')} + )} + {canAttachToDoLists && !editingMessage && ( + {lang('TitleToDoList')} + )} + {canInsertDate && !editingMessage && ( + {lang('GiftInfoDate')} )} - - )} - {canAttachPolls && !editingMessage && ( - {oldLang('Poll')} - )} - {canAttachToDoLists && !editingMessage && ( - {lang('TitleToDoList')} - )} - {!editingMessage && !canEditMedia && !isScheduled && bots?.map((bot) => ( - - ))} - + {!editingMessage && !canEditMedia && !isScheduled && bots?.map((bot) => ( + + ))} + + + )} +
); }; diff --git a/src/components/middle/composer/FormattedDateModal.module.scss b/src/components/middle/composer/FormattedDateModal.module.scss new file mode 100644 index 000000000..85421da02 --- /dev/null +++ b/src/components/middle/composer/FormattedDateModal.module.scss @@ -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; +} diff --git a/src/components/middle/composer/FormattedDateModal.tsx b/src/components/middle/composer/FormattedDateModal.tsx new file mode 100644 index 000000000..42661a92d --- /dev/null +++ b/src/components/middle/composer/FormattedDateModal.tsx @@ -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( + 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 ( + <> + +
+ +
+ +
+ + +
+
{lang('FormattedDateDate')}
+ +
+ +
+
{lang('FormattedDateTime')}
+ +
+ +
+ +
+
+ +
+ + +
+
+ + + + ); +}; + +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, + }; +} diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index e502e338c..390751041 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -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 = ({ +const MessageInput = ({ ref, id, chatId, @@ -143,7 +141,7 @@ const MessageInput: FC = ({ onScroll, onFocus, onBlur, -}) => { +}: OwnProps & StateProps) => { const { editLastMessage, replyToNextMessage, @@ -408,7 +406,7 @@ const MessageInput: FC = ({ } } - function handleChange(e: ChangeEvent) { + function handleChange(e: React.ChangeEvent) { const { innerHTML, textContent } = e.currentTarget; onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML); diff --git a/src/components/middle/composer/TextFormatter.tsx b/src/components/middle/composer/TextFormatter.tsx index d76343cf1..ed7e75ef7 100644 --- a/src/components/middle/composer/TextFormatter.tsx +++ b/src/components/middle/composer/TextFormatter.tsx @@ -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 = ({ const linkUrlInputRef = useRef(); 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(); const [selectedTextFormats, setSelectedTextFormats] = useState({}); + 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 = ({ useEffect(() => { if (!shouldRender) { closeLinkControl(); + closeDatePicker(); setSelectedTextFormats({}); setInputClassName(undefined); } - }, [closeLinkControl, shouldRender]); + }, [closeDatePicker, closeLinkControl, shouldRender]); useEffect(() => { if (!isOpen || !selectedRange) { @@ -345,7 +352,38 @@ const TextFormatter: FC = ({ 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 = { k: openLinkControl, b: handleBoldText, @@ -460,6 +498,12 @@ const TextFormatter: FC = ({ iconName="monospace" />
+
+ ); }; export default memo(TextFormatter); + +function roundDateToMinute(date: Date) { + const nextDate = new Date(date.getTime()); + nextDate.setSeconds(0); + nextDate.setMilliseconds(0); + return nextDate; +} diff --git a/src/components/test/TestDateFormat.tsx b/src/components/test/TestDateFormat.tsx index 04d75d107..eabeda714 100644 --- a/src/components/test/TestDateFormat.tsx +++ b/src/components/test/TestDateFormat.tsx @@ -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 = () => {

Relative Formatting

+ +

Message List Date Formatting

+ ); }; diff --git a/src/components/ui/InputText.tsx b/src/components/ui/InputText.tsx index 9be2f5c77..1172be09f 100644 --- a/src/components/ui/InputText.tsx +++ b/src/components/ui/InputText.tsx @@ -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) => 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} /> diff --git a/src/components/ui/TabList.module.scss b/src/components/ui/TabList.module.scss index 9a970f259..9a840775e 100644 --- a/src/components/ui/TabList.module.scss +++ b/src/components/ui/TabList.module.scss @@ -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; -} diff --git a/src/index.html b/src/index.html index ac8756842..3c7fc2c9e 100644 --- a/src/index.html +++ b/src/index.html @@ -54,7 +54,7 @@ diff --git a/src/styles/_forms.scss b/src/styles/_forms.scss index 785761772..e0367736c 100644 --- a/src/styles/_forms.scss +++ b/src/styles/_forms.scss @@ -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); diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 857d8e14f..d0d944017 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -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; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index d86f9d626..d70f98a56 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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; diff --git a/src/util/dates/formattedDate.ts b/src/util/dates/formattedDate.ts new file mode 100644 index 000000000..a54450650 --- /dev/null +++ b/src/util/dates/formattedDate.ts @@ -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 `${content}`; +} + +export function buildFormattedDateText( + text: string, + date: number, + options: FormattedDateEntityOptions, +): ApiFormattedText { + return { + text, + entities: [{ + type: ApiMessageEntityTypes.FormattedDate, + offset: 0, + length: text.length, + date, + ...options, + }], + }; +} diff --git a/src/util/localization/dateFormat.ts b/src/util/localization/dateFormat.ts index cce1a45c2..b8c8eeb1a 100644 --- a/src/util/localization/dateFormat.ts +++ b/src/util/localization/dateFormat.ts @@ -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, ) { + 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) .filter(([, value]) => value !== undefined)