Dates: Support composer input (#6796)
This commit is contained in:
parent
e9955fa879
commit
a2a601f56c
@ -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";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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('');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
250
src/components/middle/composer/FormattedDateModal.tsx
Normal file
250
src/components/middle/composer/FormattedDateModal.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
<style>
|
||||
@layer reset, variables, ui, components;
|
||||
@layer ui {
|
||||
@layer spinner, button;
|
||||
@layer tablist, spinner, button;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
11
src/types/language.d.ts
vendored
11
src/types/language.d.ts
vendored
@ -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;
|
||||
|
||||
89
src/util/dates/formattedDate.ts
Normal file
89
src/util/dates/formattedDate.ts
Normal 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,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user