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_one" = "updated 1 minute ago";
|
||||||
"LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago";
|
"LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago";
|
||||||
"LiveLocationUpdatedTodayAt" = "updated at {time}";
|
"LiveLocationUpdatedTodayAt" = "updated at {time}";
|
||||||
"RightNow" = "Just now";
|
"RightNow" = "Right now";
|
||||||
"Seconds_one" = "{count} second";
|
"Seconds_one" = "{count} second";
|
||||||
"Seconds_other" = "{count} seconds";
|
"Seconds_other" = "{count} seconds";
|
||||||
"Minutes_one" = "{count} minute";
|
"Minutes_one" = "{count} minute";
|
||||||
@ -1421,8 +1421,19 @@
|
|||||||
"FormattingMonospaceAria" = "Monospace text";
|
"FormattingMonospaceAria" = "Monospace text";
|
||||||
"FormattingUnderlineAria" = "Underlined text";
|
"FormattingUnderlineAria" = "Underlined text";
|
||||||
"FormattingStrikethroughAria" = "Strikethrough text";
|
"FormattingStrikethroughAria" = "Strikethrough text";
|
||||||
|
"FormattingAddDateAria" = "Add Date";
|
||||||
"FormattingAddLinkAria" = "Add Link";
|
"FormattingAddLinkAria" = "Add Link";
|
||||||
"FormattingEnterUrl" = "Enter URL...";
|
"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";
|
"PreviewWebPageClose" = "Clear Webpage Preview";
|
||||||
"MediaLocaltionImageAlt" = "Location on a map";
|
"MediaLocaltionImageAlt" = "Location on a map";
|
||||||
"MediaPollSolutionAria" = "Show solution";
|
"MediaPollSolutionAria" = "Show solution";
|
||||||
|
|||||||
@ -628,6 +628,7 @@ const Composer = ({
|
|||||||
) => {
|
) => {
|
||||||
if (inInputId === editableInputId && isComposerBlocked) return;
|
if (inInputId === editableInputId && isComposerBlocked) return;
|
||||||
const selection = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
|
const savedSelectionRange = getSelectionRange();
|
||||||
let messageInput: HTMLDivElement;
|
let messageInput: HTMLDivElement;
|
||||||
if (inInputId === editableInputId) {
|
if (inInputId === editableInputId) {
|
||||||
messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector)!;
|
messageInput = document.querySelector<HTMLDivElement>(editableInputCssSelector)!;
|
||||||
@ -635,12 +636,33 @@ const Composer = ({
|
|||||||
messageInput = document.getElementById(inInputId) as HTMLDivElement;
|
messageInput = document.getElementById(inInputId) as HTMLDivElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.rangeCount && !shouldPrepend) {
|
if (!shouldPrepend) {
|
||||||
const selectionRange = selection.getRangeAt(0);
|
let selectionRange: Range | undefined;
|
||||||
if (isSelectionInsideInput(selectionRange, inInputId)) {
|
|
||||||
insertHtmlInSelection(newHtml);
|
if (selection.rangeCount) {
|
||||||
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
const currentSelectionRange = selection.getRangeAt(0);
|
||||||
return;
|
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);
|
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 removeSymbol = useLastCallback((inInputId = editableInputId) => {
|
||||||
const selection = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
|
|
||||||
@ -2387,7 +2414,9 @@ const Composer = ({
|
|||||||
canSendVideos={canSendVideos}
|
canSendVideos={canSendVideos}
|
||||||
canSendDocuments={canSendDocuments}
|
canSendDocuments={canSendDocuments}
|
||||||
canSendAudios={canSendAudios}
|
canSendAudios={canSendAudios}
|
||||||
|
canInsertDate={!isComposerBlocked}
|
||||||
onFileSelect={handleFileSelect}
|
onFileSelect={handleFileSelect}
|
||||||
|
onDateInsert={handleFormattedDateInsert}
|
||||||
onPollCreate={openPollModal}
|
onPollCreate={openPollModal}
|
||||||
onTodoListCreate={handleTodoListCreate}
|
onTodoListCreate={handleTodoListCreate}
|
||||||
isScheduled={isInScheduledList}
|
isScheduled={isInScheduledList}
|
||||||
|
|||||||
@ -4,7 +4,11 @@ import { getActions } from '../../global';
|
|||||||
import { type ApiMessageEntityFormattedDate, ApiMessageEntityTypes } from '../../api/types';
|
import { type ApiMessageEntityFormattedDate, ApiMessageEntityTypes } from '../../api/types';
|
||||||
|
|
||||||
import { copyTextToClipboard } from '../../util/clipboard';
|
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 { getServerTime } from '../../util/serverTime';
|
||||||
|
|
||||||
import useInterval from '../../hooks/schedulers/useInterval';
|
import useInterval from '../../hooks/schedulers/useInterval';
|
||||||
@ -39,11 +43,13 @@ const FormattedDate = ({
|
|||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
const [requestCalendar, calendar] = useSchedule(undefined, undefined, entity.date);
|
const [requestCalendar, calendar] = useSchedule(
|
||||||
|
undefined, undefined, Math.max(entity.date, getServerTime()),
|
||||||
|
);
|
||||||
|
|
||||||
useInterval(
|
useInterval(
|
||||||
() => setCacheBreaker((prev) => prev + 1),
|
() => setCacheBreaker((prev) => prev + 1),
|
||||||
getUpdateInterval(Math.abs(entity.date - getServerTime())),
|
entity.relative && getUpdateInterval(Math.abs(entity.date - getServerTime())),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canSetReminder = Boolean(chatId && messageId);
|
const canSetReminder = Boolean(chatId && messageId);
|
||||||
@ -52,25 +58,8 @@ const FormattedDate = ({
|
|||||||
void cacheBreaker;
|
void cacheBreaker;
|
||||||
|
|
||||||
const { type, offset, length, date, ...formatOptions } = entity;
|
const { type, offset, length, date, ...formatOptions } = entity;
|
||||||
const canonical = formatDateTime(lang, secondsToDate(date), {
|
const canonical = getCanonicalFormattedDate(lang, date);
|
||||||
date: 'long',
|
const formatted = formatFormattedDateText(lang, date, formatOptions);
|
||||||
includeYear: true,
|
|
||||||
includeDay: true,
|
|
||||||
time: 'long',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.values(formatOptions).every((value) => value === undefined)) {
|
|
||||||
return { formattedDate: undefined, canonicalDate: canonical };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { relative, shortTime, longTime, shortDate, longDate, dayOfWeek } = formatOptions;
|
|
||||||
|
|
||||||
const formatted = formatDateTime(lang, secondsToDate(date), {
|
|
||||||
relative: relative ? 'auto' : undefined,
|
|
||||||
time: shortTime ? 'short' : longTime ? 'long' : undefined,
|
|
||||||
date: shortDate ? 'short' : longDate ? 'long' : undefined,
|
|
||||||
weekday: dayOfWeek ? 'long' : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { formattedDate: formatted, canonicalDate: canonical };
|
return { formattedDate: formatted, canonicalDate: canonical };
|
||||||
}, [lang, entity, cacheBreaker]);
|
}, [lang, entity, cacheBreaker]);
|
||||||
@ -123,7 +112,7 @@ const FormattedDate = ({
|
|||||||
dir="auto"
|
dir="auto"
|
||||||
data-entity-type={ApiMessageEntityTypes.FormattedDate}
|
data-entity-type={ApiMessageEntityTypes.FormattedDate}
|
||||||
data-unix={entity.date}
|
data-unix={entity.date}
|
||||||
data-format={formatToString(entity)}
|
data-format={getFormattedDateFormatString(entity)}
|
||||||
title={canonicalDate}
|
title={canonicalDate}
|
||||||
>
|
>
|
||||||
{formattedDate ?? children}
|
{formattedDate ?? children}
|
||||||
@ -161,17 +150,9 @@ function getUpdateInterval(diffInSeconds: number) {
|
|||||||
return 60000;
|
return 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (diffInSeconds < 60 * 60 * 24) {
|
||||||
|
return 3600000;
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
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 buildClassName from '../../../util/buildClassName';
|
||||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||||
|
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
|
||||||
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
|
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
|
||||||
import renderText from './renderText';
|
import renderText from './renderText';
|
||||||
|
|
||||||
@ -749,6 +750,8 @@ function processEntityAsHtml(
|
|||||||
class="blockquote"
|
class="blockquote"
|
||||||
data-entity-type="${ApiMessageEntityTypes.Blockquote}"
|
data-entity-type="${ApiMessageEntityTypes.Blockquote}"
|
||||||
>${renderedContent}</blockquote>`;
|
>${renderedContent}</blockquote>`;
|
||||||
|
case ApiMessageEntityTypes.FormattedDate:
|
||||||
|
return buildFormattedDateHtml(renderedContent, entity);
|
||||||
default:
|
default:
|
||||||
return renderedContent;
|
return renderedContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import buildClassName from '../../util/buildClassName';
|
|||||||
import { formatHumanDate, formatScheduledDateTime } from '../../util/dates/oldDateFormat';
|
import { formatHumanDate, formatScheduledDateTime } from '../../util/dates/oldDateFormat';
|
||||||
import { convertTonFromNanos } from '../../util/formatCurrency';
|
import { convertTonFromNanos } from '../../util/formatCurrency';
|
||||||
import { compact } from '../../util/iteratees';
|
import { compact } from '../../util/iteratees';
|
||||||
|
import { formatMessageListDate } from '../../util/localization/dateFormat';
|
||||||
import { formatStarsAsText, formatTonAsText } from '../../util/localization/format';
|
import { formatStarsAsText, formatTonAsText } from '../../util/localization/format';
|
||||||
import { isAlbum, isDocumentGroup } from './helpers/groupMessages';
|
import { isAlbum, isDocumentGroup } from './helpers/groupMessages';
|
||||||
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
|
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
|
||||||
@ -521,7 +522,7 @@ const MessageListContent = ({
|
|||||||
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && (
|
{isSchedule && dateGroup.originalDate !== SCHEDULED_WHEN_ONLINE && (
|
||||||
oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true))
|
oldLang('MessageScheduledOn', formatHumanDate(oldLang, dateGroup.datetime, undefined, true))
|
||||||
)}
|
)}
|
||||||
{!isSchedule && formatHumanDate(oldLang, dateGroup.datetime)}
|
{!isSchedule && formatMessageListDate(lang, new Date(dateGroup.datetime))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{senderGroups.flat()}
|
{senderGroups.flat()}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
} from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import { getActions } from '../../../global';
|
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 { GlobalState } from '../../../global/types';
|
||||||
import type { MessageListType, ThemeKey, ThreadId } from '../../../types';
|
import type { MessageListType, ThemeKey, ThreadId } from '../../../types';
|
||||||
|
|
||||||
@ -35,6 +35,7 @@ import Menu from '../../ui/Menu';
|
|||||||
import MenuItem from '../../ui/MenuItem';
|
import MenuItem from '../../ui/MenuItem';
|
||||||
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
|
import ResponsiveHoverButton from '../../ui/ResponsiveHoverButton';
|
||||||
import AttachBotItem from './AttachBotItem';
|
import AttachBotItem from './AttachBotItem';
|
||||||
|
import FormattedDateModal from './FormattedDateModal';
|
||||||
|
|
||||||
import './AttachMenu.scss';
|
import './AttachMenu.scss';
|
||||||
|
|
||||||
@ -58,7 +59,9 @@ export type OwnProps = {
|
|||||||
editingMessage?: ApiMessage;
|
editingMessage?: ApiMessage;
|
||||||
messageListType?: MessageListType;
|
messageListType?: MessageListType;
|
||||||
paidMessagesStars?: number;
|
paidMessagesStars?: number;
|
||||||
|
canInsertDate?: boolean;
|
||||||
onFileSelect: (files: File[]) => void;
|
onFileSelect: (files: File[]) => void;
|
||||||
|
onDateInsert: (text: ApiFormattedText) => void;
|
||||||
onPollCreate: NoneToVoidFunction;
|
onPollCreate: NoneToVoidFunction;
|
||||||
onTodoListCreate: NoneToVoidFunction;
|
onTodoListCreate: NoneToVoidFunction;
|
||||||
onMenuOpen: NoneToVoidFunction;
|
onMenuOpen: NoneToVoidFunction;
|
||||||
@ -85,7 +88,9 @@ const AttachMenu = ({
|
|||||||
editingMessage,
|
editingMessage,
|
||||||
messageListType,
|
messageListType,
|
||||||
paidMessagesStars,
|
paidMessagesStars,
|
||||||
|
canInsertDate,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
|
onDateInsert,
|
||||||
onMenuOpen,
|
onMenuOpen,
|
||||||
onMenuClose,
|
onMenuClose,
|
||||||
onPollCreate,
|
onPollCreate,
|
||||||
@ -96,6 +101,7 @@ const AttachMenu = ({
|
|||||||
} = getActions();
|
} = getActions();
|
||||||
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
|
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
|
||||||
const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu);
|
const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu);
|
||||||
|
const [isDateModalOpen, openDateModal, closeDateModal] = useFlag();
|
||||||
|
|
||||||
const canSendVideoAndPhoto = canSendPhotos && canSendVideos;
|
const canSendVideoAndPhoto = canSendPhotos && canSendVideos;
|
||||||
const canSendVideoOrPhoto = canSendPhotos || canSendVideos;
|
const canSendVideoOrPhoto = canSendPhotos || canSendVideos;
|
||||||
@ -179,106 +185,123 @@ const AttachMenu = ({
|
|||||||
const oldLang = useOldLang();
|
const oldLang = useOldLang();
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
if (!isButtonVisible) {
|
const handleDateMenuClick = useLastCallback(() => {
|
||||||
|
closeAttachMenu();
|
||||||
|
openDateModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isButtonVisible && !isDateModalOpen) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="AttachMenu">
|
<div className="AttachMenu">
|
||||||
{
|
{isButtonVisible && (
|
||||||
editingMessage && canEditMedia ? (
|
<>
|
||||||
<ResponsiveHoverButton
|
{
|
||||||
id="replace-menu-button"
|
editingMessage && canEditMedia ? (
|
||||||
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
|
<ResponsiveHoverButton
|
||||||
round
|
id="replace-menu-button"
|
||||||
color="translucent"
|
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
|
||||||
onActivate={handleToggleAttachMenu}
|
round
|
||||||
ariaLabel="Replace an attachment"
|
color="translucent"
|
||||||
ariaControls="replace-menu-controls"
|
onActivate={handleToggleAttachMenu}
|
||||||
hasPopup
|
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>
|
** Using ternary operator here causes some attributes from first clause
|
||||||
) : (
|
** transferring to the fragment content in the second clause
|
||||||
<ResponsiveHoverButton
|
*/}
|
||||||
id="attach-menu-button"
|
{!canAttachMedia && (
|
||||||
disabled={Boolean(editingMessage)}
|
<MenuItem className="media-disabled" disabled>
|
||||||
className={buildClassName('AttachMenu--button composer-action-button', isAttachMenuOpen && 'activated')}
|
{lang(messageListType === 'scheduled' && paidMessagesStars
|
||||||
round
|
? 'DescriptionScheduledPaidMediaNotAllowed'
|
||||||
color="translucent"
|
: 'DescriptionRestrictedMedia')}
|
||||||
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'))}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
|
{canAttachMedia && (
|
||||||
&& (
|
<>
|
||||||
<MenuItem icon="document" onClick={handleDocumentSelect}>
|
{canSendVideoOrPhoto && !isFile && (
|
||||||
{oldLang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')}
|
<MenuItem icon="photo" onClick={handleQuickSelect}>
|
||||||
</MenuItem>
|
{oldLang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo'
|
||||||
)}
|
: (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))}
|
||||||
{canSendDocuments && shouldCollectDebugLogs && (
|
</MenuItem>
|
||||||
<MenuItem icon="bug" onClick={handleSendLogs}>
|
)}
|
||||||
{oldLang('DebugSendLogs')}
|
{((canSendDocuments || canSendAudios) && !isPhotoOrVideo)
|
||||||
</MenuItem>
|
&& (
|
||||||
|
<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) => (
|
{!editingMessage && !canEditMedia && !isScheduled && bots?.map((bot) => (
|
||||||
<AttachBotItem
|
<AttachBotItem
|
||||||
bot={bot}
|
bot={bot}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onMenuOpened={markAttachmentBotMenuOpen}
|
onMenuOpened={markAttachmentBotMenuOpen}
|
||||||
onMenuClosed={unmarkAttachmentBotMenuOpen}
|
onMenuClosed={unmarkAttachmentBotMenuOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FormattedDateModal
|
||||||
|
isOpen={isDateModalOpen}
|
||||||
|
onClose={closeDateModal}
|
||||||
|
onSubmit={onDateInsert}
|
||||||
|
/>
|
||||||
</div>
|
</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, TeactNode } from '../../../lib/teact/teact';
|
||||||
import type { ElementRef, FC, TeactNode } from '../../../lib/teact/teact';
|
|
||||||
import type React from '../../../lib/teact/teact';
|
|
||||||
import {
|
import {
|
||||||
memo, useEffect, useLayoutEffect,
|
memo, useEffect, useLayoutEffect,
|
||||||
useRef, useState,
|
useRef, useState,
|
||||||
@ -112,7 +110,7 @@ function clearSelection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageInput: FC<OwnProps & StateProps> = ({
|
const MessageInput = ({
|
||||||
ref,
|
ref,
|
||||||
id,
|
id,
|
||||||
chatId,
|
chatId,
|
||||||
@ -143,7 +141,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
|
|||||||
onScroll,
|
onScroll,
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
}) => {
|
}: OwnProps & StateProps) => {
|
||||||
const {
|
const {
|
||||||
editLastMessage,
|
editLastMessage,
|
||||||
replyToNextMessage,
|
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;
|
const { innerHTML, textContent } = e.currentTarget;
|
||||||
|
|
||||||
onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML);
|
onUpdate(innerHTML === SAFARI_BR ? '' : innerHTML);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { IS_TAURI } from '../../../util/browser/globalEnvironment';
|
|||||||
import { ensureProtocol } from '../../../util/browser/url';
|
import { ensureProtocol } from '../../../util/browser/url';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
||||||
|
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
|
||||||
import getKeyFromEvent from '../../../util/getKeyFromEvent';
|
import getKeyFromEvent from '../../../util/getKeyFromEvent';
|
||||||
import stopEvent from '../../../util/stopEvent';
|
import stopEvent from '../../../util/stopEvent';
|
||||||
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
|
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
|
||||||
@ -22,6 +23,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
|
|||||||
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
|
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
|
||||||
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
|
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
|
||||||
|
|
||||||
|
import CalendarModal from '../../common/CalendarModal';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
import './TextFormatter.scss';
|
import './TextFormatter.scss';
|
||||||
@ -66,16 +68,20 @@ const TextFormatter: FC<OwnProps> = ({
|
|||||||
const linkUrlInputRef = useRef<HTMLInputElement>();
|
const linkUrlInputRef = useRef<HTMLInputElement>();
|
||||||
const { shouldRender, transitionClassNames } = useShowTransitionDeprecated(isOpen);
|
const { shouldRender, transitionClassNames } = useShowTransitionDeprecated(isOpen);
|
||||||
const [isLinkControlOpen, openLinkControl, closeLinkControl] = useFlag();
|
const [isLinkControlOpen, openLinkControl, closeLinkControl] = useFlag();
|
||||||
|
const [isDatePickerOpen, openDatePicker, closeDatePicker] = useFlag();
|
||||||
const [linkUrl, setLinkUrl] = useState('');
|
const [linkUrl, setLinkUrl] = useState('');
|
||||||
const [isEditingLink, setIsEditingLink] = useState(false);
|
const [isEditingLink, setIsEditingLink] = useState(false);
|
||||||
const [inputClassName, setInputClassName] = useState<string | undefined>();
|
const [inputClassName, setInputClassName] = useState<string | undefined>();
|
||||||
const [selectedTextFormats, setSelectedTextFormats] = useState<ISelectedTextFormats>({});
|
const [selectedTextFormats, setSelectedTextFormats] = useState<ISelectedTextFormats>({});
|
||||||
|
const [selectedDateAt, setSelectedDateAt] = useState(() => roundDateToMinute(new Date()).getTime());
|
||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]);
|
useEffect(() => (
|
||||||
|
isOpen && !isDatePickerOpen ? captureEscKeyListener(onClose) : undefined
|
||||||
|
), [isDatePickerOpen, isOpen, onClose]);
|
||||||
useVirtualBackdrop(
|
useVirtualBackdrop(
|
||||||
isOpen,
|
isOpen && !isDatePickerOpen,
|
||||||
containerRef,
|
containerRef,
|
||||||
onClose,
|
onClose,
|
||||||
true,
|
true,
|
||||||
@ -93,10 +99,11 @@ const TextFormatter: FC<OwnProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldRender) {
|
if (!shouldRender) {
|
||||||
closeLinkControl();
|
closeLinkControl();
|
||||||
|
closeDatePicker();
|
||||||
setSelectedTextFormats({});
|
setSelectedTextFormats({});
|
||||||
setInputClassName(undefined);
|
setInputClassName(undefined);
|
||||||
}
|
}
|
||||||
}, [closeLinkControl, shouldRender]);
|
}, [closeDatePicker, closeLinkControl, shouldRender]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !selectedRange) {
|
if (!isOpen || !selectedRange) {
|
||||||
@ -345,7 +352,38 @@ const TextFormatter: FC<OwnProps> = ({
|
|||||||
onClose();
|
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) => {
|
const handleKeyDown = useLastCallback((e: KeyboardEvent) => {
|
||||||
|
if (isDatePickerOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLERS_BY_KEY: Record<string, AnyToVoidFunction> = {
|
const HANDLERS_BY_KEY: Record<string, AnyToVoidFunction> = {
|
||||||
k: openLinkControl,
|
k: openLinkControl,
|
||||||
b: handleBoldText,
|
b: handleBoldText,
|
||||||
@ -460,6 +498,12 @@ const TextFormatter: FC<OwnProps> = ({
|
|||||||
iconName="monospace"
|
iconName="monospace"
|
||||||
/>
|
/>
|
||||||
<div className="TextFormatter-divider" />
|
<div className="TextFormatter-divider" />
|
||||||
|
<Button
|
||||||
|
color="translucent"
|
||||||
|
ariaLabel={lang('FormattingAddDateAria')}
|
||||||
|
onClick={handleOpenDatePicker}
|
||||||
|
iconName="calendar"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="translucent"
|
color="translucent"
|
||||||
ariaLabel={lang('FormattingAddLinkAria')}
|
ariaLabel={lang('FormattingAddLinkAria')}
|
||||||
@ -508,8 +552,24 @@ const TextFormatter: FC<OwnProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CalendarModal
|
||||||
|
isOpen={isDatePickerOpen}
|
||||||
|
selectedAt={selectedDateAt}
|
||||||
|
withTimePicker
|
||||||
|
submitButtonLabel={lang('Save')}
|
||||||
|
onClose={closeDatePicker}
|
||||||
|
onDateChange={handleDateChange}
|
||||||
|
onSubmit={handleFormattedDateConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(TextFormatter);
|
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 type { FormatDateTimeOptions } from '../../util/localization/dateFormat';
|
||||||
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { formatDateTime } from '../../util/localization/dateFormat';
|
import { formatDateTime, formatMessageListDate } from '../../util/localization/dateFormat';
|
||||||
|
|
||||||
import useLang from '../../hooks/useLang';
|
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: '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: '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) },
|
{ 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: '2 hours earlier', startDate: new Date(2026, 2, 16, 10, 34, 56) },
|
||||||
{ label: 'yesterday', startDate: new Date(2026, 2, 15, 12, 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: '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[] }) {
|
function DebugTable({ rows }: { rows: Row[] }) {
|
||||||
@ -88,12 +111,23 @@ const DateFormatTest = () => {
|
|||||||
value: formatDateTime(lang, ANCHOR_DATE, options),
|
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 startDateLabel = startDate.toLocaleString();
|
||||||
|
const effectiveAnchorDate = anchorDate || ANCHOR_DATE;
|
||||||
|
const anchorDateLabel = anchorDate ? `; anchor ${anchorDate.toLocaleString()}` : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${label} (${startDateLabel})`,
|
label: `${label} (${startDateLabel}${anchorDateLabel})`,
|
||||||
value: formatDateTime(lang, startDate, { relative: 'auto', anchorDate: ANCHOR_DATE }),
|
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>
|
<h3>Relative Formatting</h3>
|
||||||
<DebugTable rows={relativeRows} />
|
<DebugTable rows={relativeRows} />
|
||||||
|
|
||||||
|
<h3>Message List Date Formatting</h3>
|
||||||
|
<DebugTable rows={messageListRows} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,7 @@ type OwnProps = {
|
|||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
title?: string;
|
||||||
teactExperimentControlled?: boolean;
|
teactExperimentControlled?: boolean;
|
||||||
inputMode?: 'text' | 'none' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
|
inputMode?: 'text' | 'none' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
|
||||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
@ -49,6 +50,7 @@ const InputText = ({
|
|||||||
inputMode,
|
inputMode,
|
||||||
maxLength,
|
maxLength,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
title,
|
||||||
teactExperimentControlled,
|
teactExperimentControlled,
|
||||||
onChange,
|
onChange,
|
||||||
onInput,
|
onInput,
|
||||||
@ -94,6 +96,7 @@ const InputText = ({
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
aria-label={labelText}
|
aria-label={labelText}
|
||||||
|
title={title}
|
||||||
teactExperimentControlled={teactExperimentControlled}
|
teactExperimentControlled={teactExperimentControlled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,85 +1,87 @@
|
|||||||
.container,
|
@layer ui.tablist {
|
||||||
.activeIndicator {
|
.container,
|
||||||
display: flex;
|
.activeIndicator {
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
padding-block: 0.375rem;
|
padding-block: 0.375rem;
|
||||||
padding-inline: 0.25rem;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ready {
|
.container {
|
||||||
opacity: 1;
|
user-select: none;
|
||||||
}
|
scrollbar-width: none;
|
||||||
}
|
|
||||||
|
|
||||||
.activeIndicator {
|
position: relative;
|
||||||
will-change: clip-path;
|
|
||||||
|
|
||||||
isolation: isolate;
|
overflow-x: auto;
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
contain: layout style paint;
|
border-radius: 1.5rem;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
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 {
|
&.ready {
|
||||||
cursor: var(--custom-cursor, pointer);
|
opacity: 1;
|
||||||
|
}
|
||||||
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 & {
|
.activeIndicator {
|
||||||
color: var(--color-primary);
|
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>
|
<style>
|
||||||
@layer reset, variables, ui, components;
|
@layer reset, variables, ui, components;
|
||||||
@layer ui {
|
@layer ui {
|
||||||
@layer spinner, button;
|
@layer tablist, spinner, button;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
padding: 0 0.3125rem;
|
padding: 0 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
|
|||||||
@ -227,6 +227,7 @@
|
|||||||
--border-radius-button-tiny: 0.875rem;
|
--border-radius-button-tiny: 0.875rem;
|
||||||
--border-radius-modal: 2rem;
|
--border-radius-modal: 2rem;
|
||||||
--border-radius-toast: 1rem;
|
--border-radius-toast: 1rem;
|
||||||
|
--border-radius-island: 1.5rem;
|
||||||
--border-radius-default: 0.75rem;
|
--border-radius-default: 0.75rem;
|
||||||
--border-radius-default-small: 0.625rem;
|
--border-radius-default-small: 0.625rem;
|
||||||
--border-radius-default-tiny: 0.375rem;
|
--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;
|
'FormattingMonospaceAria': undefined;
|
||||||
'FormattingUnderlineAria': undefined;
|
'FormattingUnderlineAria': undefined;
|
||||||
'FormattingStrikethroughAria': undefined;
|
'FormattingStrikethroughAria': undefined;
|
||||||
|
'FormattingAddDateAria': undefined;
|
||||||
'FormattingAddLinkAria': undefined;
|
'FormattingAddLinkAria': undefined;
|
||||||
'FormattingEnterUrl': 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;
|
'PreviewWebPageClose': undefined;
|
||||||
'MediaLocaltionImageAlt': undefined;
|
'MediaLocaltionImageAlt': undefined;
|
||||||
'MediaPollSolutionAria': 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 DateStyle = 'short' | 'long' | 'numeric' | false;
|
||||||
type TimeStyle = 'short' | 'long' | false;
|
type TimeStyle = 'short' | 'long' | false;
|
||||||
type WeekdayStyle = 'short' | 'long' | boolean;
|
type WeekdayStyle = 'short' | 'long' | boolean;
|
||||||
type RelativeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week';
|
type RelativeUnit = 'minute' | 'hour' | 'day';
|
||||||
type RelativeType = 'numeric' | 'auto';
|
type RelativeType = 'numeric' | 'auto';
|
||||||
type RelativePart = { unit: RelativeUnit; value: number };
|
type RelativePart = { unit: RelativeUnit; value: number };
|
||||||
|
|
||||||
@ -31,6 +31,10 @@ export interface FormatDateTimeOptions {
|
|||||||
maxRelativeDays?: number;
|
maxRelativeDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FormatMessageListDateOptions {
|
||||||
|
anchorDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const RESULT_CACHE_LIMIT = 200;
|
const RESULT_CACHE_LIMIT = 200;
|
||||||
const DAY_IN_SECONDS = 24 * 60 * 60;
|
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);
|
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) {
|
function formatAbsoluteDateTime(lang: LangFn, date: Date, options: FormatDateTimeOptions) {
|
||||||
const intlOptions = buildAbsoluteFormatterOptions(lang, options);
|
const intlOptions = buildAbsoluteFormatterOptions(lang, options);
|
||||||
const cacheKey = [
|
const cacheKey = [
|
||||||
@ -86,6 +120,10 @@ function formatRelativeDateTime(
|
|||||||
type: RelativeType = 'numeric',
|
type: RelativeType = 'numeric',
|
||||||
options?: Pick<FormatDateTimeOptions, 'maxRelativeDays'>,
|
options?: Pick<FormatDateTimeOptions, 'maxRelativeDays'>,
|
||||||
) {
|
) {
|
||||||
|
if (type === 'auto' && Math.abs(targetDate.getTime() - anchorDate.getTime()) < 60 * 1000) {
|
||||||
|
return lang('RightNow');
|
||||||
|
}
|
||||||
|
|
||||||
const { maxRelativeDays } = options || {};
|
const { maxRelativeDays } = options || {};
|
||||||
const relativePart = getRelativePart(targetDate.getTime(), anchorDate.getTime(), maxRelativeDays);
|
const relativePart = getRelativePart(targetDate.getTime(), anchorDate.getTime(), maxRelativeDays);
|
||||||
if (!relativePart) {
|
if (!relativePart) {
|
||||||
@ -162,10 +200,6 @@ function getRelativePart(targetTime: number, anchorTime: number, maxRelativeDays
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (absDiffInSeconds < 60) {
|
|
||||||
return { unit: 'second' as const, value: diffInSeconds };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (absDiffInSeconds < 60 * 60) {
|
if (absDiffInSeconds < 60 * 60) {
|
||||||
return { unit: 'minute' as const, value: Math.trunc(diffInSeconds / 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: '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) {
|
function getDateTimeFormatter(locale: string, timeFormat: TimeFormat, options: Intl.DateTimeFormatOptions) {
|
||||||
@ -243,6 +280,15 @@ export function secondsToDate(seconds: number) {
|
|||||||
return new Date(seconds * 1000);
|
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) {
|
function serializeRecord(record: object) {
|
||||||
return Object.entries(record as Record<string, unknown>)
|
return Object.entries(record as Record<string, unknown>)
|
||||||
.filter(([, value]) => value !== undefined)
|
.filter(([, value]) => value !== undefined)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user