From a0c18fdc44affe60ebe6c930cabb1eed81db4ed5 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:46:42 +0100 Subject: [PATCH 01/14] feat(Icons): add IconCalendar and IconChevronUp Add a calendar icon for date fields and an up-pointing chevron for menus that open above their trigger. Both use the 20px stroke style of the existing icons and can be overridden through the ComponentContext icons slots. Refs: REACT-1387 --- src/components/Icons/icons.tsx | 24 ++++++++++++++++++++++++ src/components/Icons/slots.ts | 2 ++ 2 files changed, 26 insertions(+) diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 156bb66c40..8f369d898f 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -317,6 +317,18 @@ export const IconChevronDown = createIcon( />, ); +export const IconChevronUp = createIcon( + 'IconChevronUp', + , +); + export const IconChevronLeft = createIcon( 'IconChevronLeft', , ); +export const IconCalendar = createIcon( + 'IconCalendar', + , +); + export const IconClock = createIcon( 'IconClock', Date: Wed, 28 Oct 2026 15:46:56 +0100 Subject: [PATCH 02/14] feat(ScheduledMessages): add schedule time and preset utilities Add the folder's constants.ts and types.ts and move the hook types and the schedule-message capability there. The public exports stay the same. New pure utilities, each documented and unit tested: - scheduleTimeBounds: earliest and latest schedule time from the channel limits, with a one-minute clock-skew buffer, validation and the default custom time - schedulePresets: tomorrow 09:00, tomorrow 13:00 and next Monday 09:00 in local time, dropping the Monday preset when tomorrow is Monday - dateTimeInputValues: convert between dates and native date/time input values - timeZone: GMT offset label for the "Sent in your time zone" note - scheduledMessagePreview, scheduledMessageErrors and nativePicker helpers Refs: REACT-1387 --- src/components/ScheduledMessages/constants.ts | 40 +++++ .../__tests__/useCanScheduleMessage.test.tsx | 6 +- .../useScheduledMessageCount.test.tsx | 2 +- .../__tests__/useScheduledMessages.test.tsx | 2 +- .../hooks/useCanScheduleMessage.ts | 3 +- .../hooks/useScheduledMessageCount.ts | 6 +- .../hooks/useScheduledMessages.ts | 13 +- src/components/ScheduledMessages/index.ts | 6 + src/components/ScheduledMessages/types.ts | 74 +++++++++ .../__tests__/dateTimeInputValues.test.ts | 37 +++++ .../utils/__tests__/schedulePresets.test.ts | 141 ++++++++++++++++++ .../__tests__/scheduleTimeBounds.test.ts | 92 ++++++++++++ .../__tests__/scheduledMessageHelpers.test.ts | 55 +++++++ .../utils/__tests__/timeZone.test.ts | 21 +++ .../utils/dateTimeInputValues.ts | 49 ++++++ .../ScheduledMessages/utils/index.ts | 7 + .../ScheduledMessages/utils/nativePicker.ts | 15 ++ .../utils/schedulePresets.ts | 83 +++++++++++ .../utils/scheduleTimeBounds.ts | 82 ++++++++++ .../utils/scheduledMessageErrors.ts | 15 ++ .../utils/scheduledMessagePreview.ts | 28 ++++ .../ScheduledMessages/utils/timeZone.ts | 27 ++++ 22 files changed, 779 insertions(+), 25 deletions(-) create mode 100644 src/components/ScheduledMessages/constants.ts create mode 100644 src/components/ScheduledMessages/types.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts create mode 100644 src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts create mode 100644 src/components/ScheduledMessages/utils/dateTimeInputValues.ts create mode 100644 src/components/ScheduledMessages/utils/index.ts create mode 100644 src/components/ScheduledMessages/utils/nativePicker.ts create mode 100644 src/components/ScheduledMessages/utils/schedulePresets.ts create mode 100644 src/components/ScheduledMessages/utils/scheduleTimeBounds.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessageErrors.ts create mode 100644 src/components/ScheduledMessages/utils/scheduledMessagePreview.ts create mode 100644 src/components/ScheduledMessages/utils/timeZone.ts diff --git a/src/components/ScheduledMessages/constants.ts b/src/components/ScheduledMessages/constants.ts new file mode 100644 index 0000000000..f399213d48 --- /dev/null +++ b/src/components/ScheduledMessages/constants.ts @@ -0,0 +1,40 @@ +export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message'; + +export const SCHEDULE_MESSAGE_MENU_ID = 'schedule-message-menu'; + +export const SCHEDULE_MESSAGE_NOTIFICATION_EMITTER = 'ScheduleMessage'; + +export const CLOCK_SKEW_BUFFER_MS = 60 * 1000; + +export const SchedulePresetHour = { + Afternoon: 13, + Morning: 9, +} as const; + +export type SchedulePresetHour = + (typeof SchedulePresetHour)[keyof typeof SchedulePresetHour]; + +export const ScheduleTimeError = { + TooFar: 'too_far', + TooSoon: 'too_soon', +} as const; + +export type ScheduleTimeError = + (typeof ScheduleTimeError)[keyof typeof ScheduleTimeError]; + +export const ScheduleMessageNoticeSeverity = { + Error: 'error', + Info: 'info', +} as const; + +export type ScheduleMessageNoticeSeverity = + (typeof ScheduleMessageNoticeSeverity)[keyof typeof ScheduleMessageNoticeSeverity]; + +export const ScheduledMessageNotificationType = { + CommandRejected: 'validation:scheduled-message:command', + CreateFailed: 'api:scheduled-message:create:failed', + CreateSucceeded: 'api:scheduled-message:create:success', +} as const; + +export type ScheduledMessageNotificationType = + (typeof ScheduledMessageNotificationType)[keyof typeof ScheduledMessageNotificationType]; diff --git a/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx index be8571e3b6..83307c5b88 100644 --- a/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx +++ b/src/components/ScheduledMessages/hooks/__tests__/useCanScheduleMessage.test.tsx @@ -5,10 +5,8 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { MessageComposer } from 'stream-chat'; import type { LocationComposerState, StreamChat } from 'stream-chat'; -import { - SCHEDULE_MESSAGE_CAPABILITY, - useCanScheduleMessage, -} from '../useCanScheduleMessage'; +import { SCHEDULE_MESSAGE_CAPABILITY } from '../../constants'; +import { useCanScheduleMessage } from '../useCanScheduleMessage'; import { ChannelStateProvider, ChatProvider } from '../../../../context'; import { MessageComposerControllerProvider } from '../../../MessageComposer'; import { diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx index dded2a7400..876c921c5a 100644 --- a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx +++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessageCount.test.tsx @@ -4,7 +4,7 @@ import { act, renderHook } from '@testing-library/react'; import type { StreamChat } from 'stream-chat'; import { useScheduledMessageCount } from '../useScheduledMessageCount'; -import type { UseScheduledMessageCountParams } from '../useScheduledMessageCount'; +import type { UseScheduledMessageCountParams } from '../../types'; import { ChatProvider } from '../../../../context'; import { generateScheduledMessage, diff --git a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx index 25664b57a5..ae8d5cfdc2 100644 --- a/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx +++ b/src/components/ScheduledMessages/hooks/__tests__/useScheduledMessages.test.tsx @@ -4,7 +4,7 @@ import { act, renderHook } from '@testing-library/react'; import type { StreamChat } from 'stream-chat'; import { useScheduledMessages } from '../useScheduledMessages'; -import type { UseScheduledMessagesParams } from '../useScheduledMessages'; +import type { UseScheduledMessagesParams } from '../../types'; import { ChatProvider } from '../../../../context'; import { generateScheduledMessage, diff --git a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts index ff31502c04..2abd1881bd 100644 --- a/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts +++ b/src/components/ScheduledMessages/hooks/useCanScheduleMessage.ts @@ -10,8 +10,7 @@ import type { import { useChannelStateContext, useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; - -export const SCHEDULE_MESSAGE_CAPABILITY = 'schedule-message'; +import { SCHEDULE_MESSAGE_CAPABILITY } from '../constants'; const COMMAND_PATTERN = /^\/(\S+)/; diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts index c7b25fe035..cf89b4a940 100644 --- a/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts +++ b/src/components/ScheduledMessages/hooks/useScheduledMessageCount.ts @@ -3,11 +3,7 @@ import type { ScheduledMessageManager } from 'stream-chat'; import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; - -export type UseScheduledMessageCountParams = { - cid: string; - parentId?: string; -}; +import type { UseScheduledMessageCountParams } from '../types'; const countThreadReplies = ( manager: ScheduledMessageManager, diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts index db61b09541..c736f8bf4e 100644 --- a/src/components/ScheduledMessages/hooks/useScheduledMessages.ts +++ b/src/components/ScheduledMessages/hooks/useScheduledMessages.ts @@ -7,18 +7,7 @@ import type { import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; - -export type UseScheduledMessagesParams = { - cid?: string; - parentId?: string; -}; - -export type ScheduledMessagesSnapshot = { - isDisabled: boolean; - isLoaded: boolean; - isLoading: boolean; - scheduledMessages: ScheduledMessageResponse[]; -}; +import type { ScheduledMessagesSnapshot, UseScheduledMessagesParams } from '../types'; const byScheduledAt = (a: ScheduledMessageResponse, b: ScheduledMessageResponse) => Date.parse(a.scheduled_at) - Date.parse(b.scheduled_at) || a.id.localeCompare(b.id); diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts index 4cc90d02bd..69bd828d23 100644 --- a/src/components/ScheduledMessages/index.ts +++ b/src/components/ScheduledMessages/index.ts @@ -1 +1,7 @@ +export { + SCHEDULE_MESSAGE_CAPABILITY, + ScheduleMessageNoticeSeverity, + ScheduleTimeError, +} from './constants'; export * from './hooks'; +export * from './types'; diff --git a/src/components/ScheduledMessages/types.ts b/src/components/ScheduledMessages/types.ts new file mode 100644 index 0000000000..9216dd34d1 --- /dev/null +++ b/src/components/ScheduledMessages/types.ts @@ -0,0 +1,74 @@ +import type { ChangeEventHandler, ReactNode } from 'react'; +import type { LocalAttachment, ScheduledMessageResponse } from 'stream-chat'; + +import type { ScheduleMessageNoticeSeverity, ScheduleTimeError } from './constants'; + +export type UseScheduledMessagesParams = { + cid?: string; + parentId?: string; +}; + +export type ScheduledMessagesSnapshot = { + isDisabled: boolean; + isLoaded: boolean; + isLoading: boolean; + scheduledMessages: ScheduledMessageResponse[]; +}; + +export type UseScheduledMessageCountParams = { + cid: string; + parentId?: string; +}; + +export type ScheduleTimeBounds = { + earliest: Date; + latest: Date; +}; + +export type ScheduledMessagePreviewSource = { + attachments: LocalAttachment[]; + id: string; + text: string; +}; + +export type ScheduleTimeForm = { + bounds: ScheduleTimeBounds; + dateValue: string; + onDateChange: ChangeEventHandler; + onTimeChange: ChangeEventHandler; + timeValue: string; + scheduledAt?: Date; + timeError?: ScheduleTimeError; +}; + +export type ScheduleMessageSubmit = { + hasFailed: boolean; + isScheduling: boolean; + submit: (scheduledAt: Date) => Promise; +}; + +export type ScheduleMessageDialogState = { + closeDialog: () => void; + isDialogOpen: boolean; + openDialog: () => void; +}; + +export type ScheduleMessageMenuProps = { + onCustomTimeSelect: () => void; + onPresetSelect: (scheduledAt: Date) => void; + presets: Date[]; +}; + +export type ScheduleMessagePresetButtonProps = { + onSelect: (scheduledAt: Date) => void; + scheduledAt: Date; +}; + +export type ScheduleMessageDialogProps = { + close: () => void; +}; + +export type ScheduleMessageDialogNoticeProps = { + children: ReactNode; + severity: ScheduleMessageNoticeSeverity; +}; diff --git a/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts new file mode 100644 index 0000000000..2feb5862f2 --- /dev/null +++ b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts @@ -0,0 +1,37 @@ +import { + fromDateAndTimeInputValues, + toDateInputValue, + toTimeInputValue, +} from '../dateTimeInputValues'; + +describe('dateTimeInputValues', () => { + it('formats the local date for a date input', () => { + expect(toDateInputValue(new Date(2026, 0, 5, 7, 4))).toBe('2026-01-05'); + }); + + it('formats the local time for a time input', () => { + expect(toTimeInputValue(new Date(2026, 0, 5, 7, 4))).toBe('07:04'); + }); + + it('parses the input values as local time', () => { + expect(fromDateAndTimeInputValues('2026-01-05', '07:04')).toEqual( + new Date(2026, 0, 5, 7, 4), + ); + }); + + it('round-trips a local date and time', () => { + const date = new Date(2026, 11, 31, 23, 59); + + expect( + fromDateAndTimeInputValues(toDateInputValue(date), toTimeInputValue(date)), + ).toEqual(date); + }); + + it.each([ + ['a missing date', '', '07:04'], + ['a missing time', '2026-01-05', ''], + ['an invalid date', 'not-a-date', '07:04'], + ])('returns undefined for %s', (_, dateValue, timeValue) => { + expect(fromDateAndTimeInputValues(dateValue, timeValue)).toBeUndefined(); + }); +}); diff --git a/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts b/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts new file mode 100644 index 0000000000..bf4f2af133 --- /dev/null +++ b/src/components/ScheduledMessages/utils/__tests__/schedulePresets.test.ts @@ -0,0 +1,141 @@ +import { + getAvailableSchedulePresets, + getDaysUntilNextMonday, + getLocalDateAtHour, + getSchedulePresets, + isSameLocalDay, +} from '../schedulePresets'; +import { getScheduleTimeBounds } from '../scheduleTimeBounds'; + +const DST_TIME_ZONE = 'Europe/Skopje'; + +const DEFAULT_LIMITS = { + maxLeadTimeSeconds: 120 * 24 * 60 * 60, + maxPerChannel: 10, + maxPerUser: 100, + minLeadTimeSeconds: 60, +}; + +const toLocalParts = (date: Date) => ({ + date: date.getDate(), + day: date.getDay(), + hours: date.getHours(), + minutes: date.getMinutes(), + month: date.getMonth(), +}); + +describe('schedulePresets', () => { + const originalTimeZone = process.env.TZ; + + beforeAll(() => { + process.env.TZ = DST_TIME_ZONE; + }); + + afterAll(() => { + process.env.TZ = originalTimeZone; + }); + + describe('getLocalDateAtHour', () => { + it('moves the date by whole days and sets the hour', () => { + const date = new Date(2026, 9, 28, 10, 30, 45); + + const localDate = getLocalDateAtHour(date, 2, 13); + + expect(localDate).toEqual(new Date(2026, 9, 30, 13, 0, 0, 0)); + expect(date).toEqual(new Date(2026, 9, 28, 10, 30, 45)); + }); + + it('keeps the wall-clock hour across a daylight saving change', () => { + const dayBeforeDstEnds = new Date(2026, 9, 24, 12, 0); + + const localDate = getLocalDateAtHour(dayBeforeDstEnds, 1, 9); + + expect(localDate.getHours()).toBe(9); + expect(localDate.getTimezoneOffset()).not.toBe( + dayBeforeDstEnds.getTimezoneOffset(), + ); + }); + }); + + describe('getDaysUntilNextMonday', () => { + it.each([ + ['Sunday', new Date(2026, 10, 1), 1], + ['Monday', new Date(2026, 10, 2), 7], + ['Wednesday', new Date(2026, 10, 4), 5], + ['Saturday', new Date(2026, 10, 7), 2], + ])('returns the days from %s', (_, date, expected) => { + expect(getDaysUntilNextMonday(date)).toBe(expected); + }); + }); + + describe('isSameLocalDay', () => { + it('compares the local calendar day only', () => { + expect( + isSameLocalDay(new Date(2026, 0, 1, 0, 1), new Date(2026, 0, 1, 23, 59)), + ).toBe(true); + expect( + isSameLocalDay(new Date(2026, 0, 1, 23, 59), new Date(2026, 0, 2, 0, 1)), + ).toBe(false); + }); + }); + + describe('getSchedulePresets', () => { + it('returns tomorrow at 09:00, tomorrow at 13:00 and next Monday at 09:00', () => { + const wednesday = new Date(2026, 9, 28, 10, 30); + + const presets = getSchedulePresets(wednesday); + + expect(presets.map(toLocalParts)).toEqual([ + { date: 29, day: 4, hours: 9, minutes: 0, month: 9 }, + { date: 29, day: 4, hours: 13, minutes: 0, month: 9 }, + { date: 2, day: 1, hours: 9, minutes: 0, month: 10 }, + ]); + }); + + it('drops the Monday preset when tomorrow is Monday', () => { + const sunday = new Date(2026, 10, 1, 18, 0); + + const presets = getSchedulePresets(sunday); + + expect(presets.map(toLocalParts)).toEqual([ + { date: 2, day: 1, hours: 9, minutes: 0, month: 10 }, + { date: 2, day: 1, hours: 13, minutes: 0, month: 10 }, + ]); + }); + + it('rolls over the end of the month and the year', () => { + const lastDayOfYear = new Date(2026, 11, 31, 22, 0); + + const [tomorrowMorning] = getSchedulePresets(lastDayOfYear); + + expect(tomorrowMorning).toEqual(new Date(2027, 0, 1, 9, 0)); + }); + }); + + describe('getAvailableSchedulePresets', () => { + it('keeps every preset within the default limits', () => { + const now = new Date(2026, 9, 28, 10, 30); + + const presets = getAvailableSchedulePresets( + now, + getScheduleTimeBounds(DEFAULT_LIMITS, now), + ); + + expect(presets).toHaveLength(3); + }); + + it('drops the presets beyond the maximum lead time', () => { + const now = new Date(2026, 9, 28, 10, 30); + const oneDayLimits = { ...DEFAULT_LIMITS, maxLeadTimeSeconds: 24 * 60 * 60 }; + + const presets = getAvailableSchedulePresets( + now, + getScheduleTimeBounds(oneDayLimits, now), + ); + + expect(presets.map(toLocalParts)).toEqual([ + { date: 29, day: 4, hours: 9, minutes: 0, month: 9 }, + ]); + }); + }); +}); diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts new file mode 100644 index 0000000000..8ac8f4c060 --- /dev/null +++ b/src/components/ScheduledMessages/utils/__tests__/scheduleTimeBounds.test.ts @@ -0,0 +1,92 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel } from 'stream-chat'; + +import { ScheduleTimeError } from '../../constants'; +import { + getChannelScheduleTimeBounds, + getDefaultScheduledAt, + getScheduleTimeBounds, + getScheduleTimeError, + isWithinScheduleTimeBounds, +} from '../scheduleTimeBounds'; + +const MINUTE_MS = 60 * 1000; + +const DEFAULT_LIMITS = { + maxLeadTimeSeconds: 120 * 24 * 60 * 60, + maxPerChannel: 10, + maxPerUser: 100, + minLeadTimeSeconds: 60, +}; + +const now = new Date(2026, 9, 28, 10, 30, 15); + +describe('getScheduleTimeBounds', () => { + it('adds a one-minute buffer to the minimum lead time', () => { + const { earliest, latest } = getScheduleTimeBounds(DEFAULT_LIMITS, now); + + expect(earliest.getTime() - now.getTime()).toBe(2 * MINUTE_MS); + expect(latest.getTime() - now.getTime()).toBe( + DEFAULT_LIMITS.maxLeadTimeSeconds * 1000, + ); + }); +}); + +describe('getChannelScheduleTimeBounds', () => { + it('reads the limits from the channel config', () => { + const channel = fromPartial({ + getConfig: () => + fromPartial({ + scheduled_messages_max_lead_time_seconds: 3600, + scheduled_messages_min_lead_time_seconds: 300, + }), + }); + + const { earliest, latest } = getChannelScheduleTimeBounds(channel, now); + + expect(earliest.getTime() - now.getTime()).toBe(6 * MINUTE_MS); + expect(latest.getTime() - now.getTime()).toBe(60 * MINUTE_MS); + }); + + it('falls back to the default limits', () => { + const channel = fromPartial({ getConfig: () => undefined }); + + expect(getChannelScheduleTimeBounds(channel, now)).toEqual( + getScheduleTimeBounds(DEFAULT_LIMITS, now), + ); + }); +}); + +describe('getScheduleTimeError', () => { + const bounds = getScheduleTimeBounds(DEFAULT_LIMITS, now); + + it('accepts both bounds', () => { + expect(getScheduleTimeError(bounds.earliest, bounds)).toBeUndefined(); + expect(getScheduleTimeError(bounds.latest, bounds)).toBeUndefined(); + expect(isWithinScheduleTimeBounds(bounds.earliest, bounds)).toBe(true); + }); + + it('rejects a time before the earliest time', () => { + const tooSoon = new Date(bounds.earliest.getTime() - 1); + + expect(getScheduleTimeError(tooSoon, bounds)).toBe(ScheduleTimeError.TooSoon); + expect(isWithinScheduleTimeBounds(tooSoon, bounds)).toBe(false); + }); + + it('rejects a time after the latest time', () => { + const tooFar = new Date(bounds.latest.getTime() + 1); + + expect(getScheduleTimeError(tooFar, bounds)).toBe(ScheduleTimeError.TooFar); + }); +}); + +describe('getDefaultScheduledAt', () => { + it('returns the first whole hour after the earliest time', () => { + const bounds = getScheduleTimeBounds(DEFAULT_LIMITS, now); + + const defaultScheduledAt = getDefaultScheduledAt(bounds); + + expect(defaultScheduledAt).toEqual(new Date(2026, 9, 28, 11, 0, 0, 0)); + expect(isWithinScheduleTimeBounds(defaultScheduledAt, bounds)).toBe(true); + }); +}); diff --git a/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts new file mode 100644 index 0000000000..4234cddd13 --- /dev/null +++ b/src/components/ScheduledMessages/utils/__tests__/scheduledMessageHelpers.test.ts @@ -0,0 +1,55 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import { ErrorFromResponse, ScheduledMessageErrorReason } from 'stream-chat'; +import type { LocalAttachment } from 'stream-chat'; + +import { isScheduledMessageError } from '../scheduledMessageErrors'; +import { toScheduledMessagePreview } from '../scheduledMessagePreview'; + +const createApiError = (code: number) => + new ErrorFromResponse('request failed', { + code, + response: fromPartial({ data: { code }, status: 400 }), + status: 400, + }); + +describe('isScheduledMessageError', () => { + it('matches an API error with the given reason', () => { + expect( + isScheduledMessageError(createApiError(119), ScheduledMessageErrorReason.IdTaken), + ).toBe(true); + }); + + it.each([ + ['another reason', createApiError(118)], + ['a network error', new Error('Network Error')], + ['a thrown string', 'failed'], + ])('does not match %s', (_, error) => { + expect(isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken)).toBe( + false, + ); + }); +}); + +describe('toScheduledMessagePreview', () => { + it('builds a pending local message from the composition', () => { + const now = new Date(2026, 9, 28, 10, 30); + const attachments = [fromPartial({ type: 'image' })]; + + const previewMessage = toScheduledMessagePreview( + { attachments, id: 'message-id', text: 'Launch moved to Monday' }, + now, + ); + + expect(previewMessage).toEqual({ + attachments, + created_at: now, + deleted_at: null, + id: 'message-id', + pinned_at: null, + status: 'pending', + text: 'Launch moved to Monday', + type: 'regular', + updated_at: now, + }); + }); +}); diff --git a/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts b/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts new file mode 100644 index 0000000000..477d27d1a0 --- /dev/null +++ b/src/components/ScheduledMessages/utils/__tests__/timeZone.test.ts @@ -0,0 +1,21 @@ +import { formatTimeZoneOffset } from '../timeZone'; + +describe('formatTimeZoneOffset', () => { + const originalTimeZone = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTimeZone; + }); + + it.each([ + ['UTC', new Date(Date.UTC(2026, 0, 15)), 'GMT'], + ['Europe/Skopje', new Date(Date.UTC(2026, 0, 15)), 'GMT+1'], + ['Europe/Skopje', new Date(Date.UTC(2026, 6, 15)), 'GMT+2'], + ['Asia/Kolkata', new Date(Date.UTC(2026, 0, 15)), 'GMT+5:30'], + ['America/New_York', new Date(Date.UTC(2026, 0, 15)), 'GMT-5'], + ])('formats %s on %s as %s', (timeZone, date, expected) => { + process.env.TZ = timeZone; + + expect(formatTimeZoneOffset(date)).toBe(expected); + }); +}); diff --git a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts new file mode 100644 index 0000000000..d0c613e606 --- /dev/null +++ b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts @@ -0,0 +1,49 @@ +const padTwoDigits = (value: number) => String(value).padStart(2, '0'); + +/** + * Returns the local date of `date` in the `YYYY-MM-DD` format of ``. + * + * @example + * toDateInputValue(new Date(2026, 0, 5, 7, 4)); // '2026-01-05' + */ +export const toDateInputValue = (date: Date) => { + const year = date.getFullYear(); + const month = padTwoDigits(date.getMonth() + 1); + const day = padTwoDigits(date.getDate()); + + const dateInputValue = `${year}-${month}-${day}`; + + return dateInputValue; +}; + +/** + * Returns the local time of `date` in the `HH:mm` format of ``. + * + * @example + * toTimeInputValue(new Date(2026, 0, 5, 7, 4)); // '07:04' + */ +export const toTimeInputValue = (date: Date) => { + const hours = padTwoDigits(date.getHours()); + const minutes = padTwoDigits(date.getMinutes()); + + const timeInputValue = `${hours}:${minutes}`; + + return timeInputValue; +}; + +/** + * Returns the local date and time picked in a date input and a time input, or `undefined` + * when either value is missing or invalid. + * + * @example + * fromDateAndTimeInputValues('2026-01-05', '07:04'); // 5 January 2026, 07:04 local time + */ +export const fromDateAndTimeInputValues = (dateValue: string, timeValue: string) => { + if (!dateValue || !timeValue) return undefined; + + const scheduledAt = new Date(`${dateValue}T${timeValue}`); + + const isValidDate = !Number.isNaN(scheduledAt.getTime()); + + return isValidDate ? scheduledAt : undefined; +}; diff --git a/src/components/ScheduledMessages/utils/index.ts b/src/components/ScheduledMessages/utils/index.ts new file mode 100644 index 0000000000..88f041ca3d --- /dev/null +++ b/src/components/ScheduledMessages/utils/index.ts @@ -0,0 +1,7 @@ +export * from './dateTimeInputValues'; +export * from './nativePicker'; +export * from './schedulePresets'; +export * from './scheduledMessageErrors'; +export * from './scheduledMessagePreview'; +export * from './scheduleTimeBounds'; +export * from './timeZone'; diff --git a/src/components/ScheduledMessages/utils/nativePicker.ts b/src/components/ScheduledMessages/utils/nativePicker.ts new file mode 100644 index 0000000000..46146f3125 --- /dev/null +++ b/src/components/ScheduledMessages/utils/nativePicker.ts @@ -0,0 +1,15 @@ +import type { MouseEvent } from 'react'; + +/** + * Opens the browser's own picker of a date or time input when the field is clicked. + * + * Browsers that do not support `showPicker`, or refuse it outside a user gesture, keep + * their default behaviour. + */ +export const openNativePicker = ({ currentTarget }: MouseEvent) => { + try { + currentTarget.showPicker?.(); + } catch { + return; + } +}; diff --git a/src/components/ScheduledMessages/utils/schedulePresets.ts b/src/components/ScheduledMessages/utils/schedulePresets.ts new file mode 100644 index 0000000000..85cb4a17c5 --- /dev/null +++ b/src/components/ScheduledMessages/utils/schedulePresets.ts @@ -0,0 +1,83 @@ +import { SchedulePresetHour } from '../constants'; +import type { ScheduleTimeBounds } from '../types'; +import { isWithinScheduleTimeBounds } from './scheduleTimeBounds'; + +const DAYS_IN_WEEK = 7; +const MONDAY = 1; +const TOMORROW = 1; + +/** + * Returns a copy of `date` moved by `dayOffset` days and set to `hour`:00 local time. + * + * Works on the local calendar, so month ends, year ends and daylight saving changes keep + * the requested wall-clock hour. + */ +export const getLocalDateAtHour = (date: Date, dayOffset: number, hour: number) => { + const localDate = new Date(date); + + localDate.setDate(date.getDate() + dayOffset); + localDate.setHours(hour, 0, 0, 0); + + return localDate; +}; + +/** + * Returns how many days are left until the next Monday, from 1 to 7. + * + * On a Monday the next Monday is a week away. + */ +export const getDaysUntilNextMonday = (date: Date) => { + const daysUntilMonday = (DAYS_IN_WEEK - date.getDay() + MONDAY) % DAYS_IN_WEEK; + + const daysUntilNextMonday = daysUntilMonday || DAYS_IN_WEEK; + + return daysUntilNextMonday; +}; + +/** + * Returns `true` when both dates fall on the same local calendar day. + */ +export const isSameLocalDay = (first: Date, second: Date) => + first.toDateString() === second.toDateString(); + +/** + * Returns the quick schedule times of the schedule menu, in menu order. + * + * The presets are tomorrow at 09:00, tomorrow at 13:00 and next Monday at 09:00, all in the + * local time zone. When tomorrow is Monday, the Monday preset is dropped because it would + * repeat "tomorrow at 09:00". + */ +export const getSchedulePresets = (now: Date) => { + const tomorrowMorning = getLocalDateAtHour(now, TOMORROW, SchedulePresetHour.Morning); + const tomorrowAfternoon = getLocalDateAtHour( + now, + TOMORROW, + SchedulePresetHour.Afternoon, + ); + const nextMondayMorning = getLocalDateAtHour( + now, + getDaysUntilNextMonday(now), + SchedulePresetHour.Morning, + ); + + const isTomorrowMonday = isSameLocalDay(nextMondayMorning, tomorrowMorning); + + const presets = isTomorrowMonday + ? [tomorrowMorning, tomorrowAfternoon] + : [tomorrowMorning, tomorrowAfternoon, nextMondayMorning]; + + return presets; +}; + +/** + * Returns the schedule presets that the channel's limits allow. + * + * A channel with a short maximum lead time can rule out "next Monday", for example. + */ +export const getAvailableSchedulePresets = (now: Date, bounds: ScheduleTimeBounds) => { + const availablePresets = getSchedulePresets(now).filter((preset) => + isWithinScheduleTimeBounds(preset, bounds), + ); + + return availablePresets; +}; diff --git a/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts new file mode 100644 index 0000000000..50f87f6caa --- /dev/null +++ b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts @@ -0,0 +1,82 @@ +import { getScheduledMessageLimits } from 'stream-chat'; +import type { Channel, ScheduledMessageLimits } from 'stream-chat'; + +import { CLOCK_SKEW_BUFFER_MS, ScheduleTimeError } from '../constants'; +import type { ScheduleTimeBounds } from '../types'; + +const MS_IN_SECOND = 1000; + +/** + * Returns the earliest and latest time a message can be scheduled for. + * + * The earliest time is the minimum lead time plus a one-minute buffer that absorbs + * clock skew between the device and the server; the latest is the maximum lead time. + * + * @example + * // limits: 60 s minimum, 120 days maximum, now: 10:30:00 + * getScheduleTimeBounds(limits, now); // { earliest: 10:32:00, latest: now + 120 days } + */ +export const getScheduleTimeBounds = ( + { maxLeadTimeSeconds, minLeadTimeSeconds }: ScheduledMessageLimits, + now: Date, +): ScheduleTimeBounds => { + const nowMs = now.getTime(); + + const earliest = new Date( + nowMs + minLeadTimeSeconds * MS_IN_SECOND + CLOCK_SKEW_BUFFER_MS, + ); + const latest = new Date(nowMs + maxLeadTimeSeconds * MS_IN_SECOND); + + const bounds = { earliest, latest }; + + return bounds; +}; + +/** + * Returns the schedule time bounds of a channel, read from its config limits. + * + * Channels without configured limits use the SDK defaults from `getScheduledMessageLimits`. + */ +export const getChannelScheduleTimeBounds = (channel: Channel, now: Date) => + getScheduleTimeBounds(getScheduledMessageLimits(channel), now); + +/** + * Returns why a time cannot be scheduled, or `undefined` when it can. + * + * Both bounds are inclusive. + */ +export const getScheduleTimeError = ( + scheduledAt: Date, + { earliest, latest }: ScheduleTimeBounds, +): ScheduleTimeError | undefined => { + if (scheduledAt < earliest) return ScheduleTimeError.TooSoon; + + if (scheduledAt > latest) return ScheduleTimeError.TooFar; + + return undefined; +}; + +/** + * Returns `true` when a time falls between the earliest and the latest allowed time. + */ +export const isWithinScheduleTimeBounds = ( + scheduledAt: Date, + bounds: ScheduleTimeBounds, +) => !getScheduleTimeError(scheduledAt, bounds); + +/** + * Returns the time a custom schedule starts at: the first whole hour after the earliest + * allowed time. + * + * @example + * // earliest: 10:32 + * getDefaultScheduledAt(bounds); // 11:00 + */ +export const getDefaultScheduledAt = ({ earliest }: ScheduleTimeBounds) => { + const defaultScheduledAt = new Date(earliest); + + defaultScheduledAt.setMinutes(0, 0, 0); + defaultScheduledAt.setHours(defaultScheduledAt.getHours() + 1); + + return defaultScheduledAt; +}; diff --git a/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts new file mode 100644 index 0000000000..d0879d294a --- /dev/null +++ b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts @@ -0,0 +1,15 @@ +import { getScheduledMessageErrorReason } from 'stream-chat'; +import type { ScheduledMessageErrorReason } from 'stream-chat'; + +/** + * Returns `true` when `error` is a scheduled message API error with the given reason. + * + * Errors that did not come from the API, such as network failures, never match. + * + * @example + * isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken); + */ +export const isScheduledMessageError = ( + error: unknown, + reason: ScheduledMessageErrorReason, +) => error instanceof Error && getScheduledMessageErrorReason(error) === reason; diff --git a/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts new file mode 100644 index 0000000000..ebe47ce587 --- /dev/null +++ b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts @@ -0,0 +1,28 @@ +import type { LocalMessageBase } from 'stream-chat'; + +import type { ScheduledMessagePreviewSource } from '../types'; + +/** + * Returns a local message built from the composer's current text and attachments, so the + * schedule dialog can render it with the quoted message preview. + * + * The message is never sent; it only carries what the preview reads. + */ +export const toScheduledMessagePreview = ( + { attachments, id, text }: ScheduledMessagePreviewSource, + now: Date, +): LocalMessageBase => { + const previewMessage: LocalMessageBase = { + attachments, + created_at: now, + deleted_at: null, + id, + pinned_at: null, + status: 'pending', + text, + type: 'regular', + updated_at: now, + }; + + return previewMessage; +}; diff --git a/src/components/ScheduledMessages/utils/timeZone.ts b/src/components/ScheduledMessages/utils/timeZone.ts new file mode 100644 index 0000000000..e86ef23bd3 --- /dev/null +++ b/src/components/ScheduledMessages/utils/timeZone.ts @@ -0,0 +1,27 @@ +const MINUTES_IN_HOUR = 60; + +/** + * Returns the local time zone of `date` as a GMT offset label. + * + * Whole hours drop the minutes and UTC itself is plain `GMT`. The offset is read for the + * given date, so a date after a daylight saving change gets the new offset. + * + * @example + * formatTimeZoneOffset(date); // 'GMT+2', 'GMT-5', 'GMT+5:30' or 'GMT' + */ +export const formatTimeZoneOffset = (date: Date) => { + const offsetMinutes = -date.getTimezoneOffset(); + + if (!offsetMinutes) return 'GMT'; + + const sign = offsetMinutes < 0 ? '-' : '+'; + const absoluteOffsetMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteOffsetMinutes / MINUTES_IN_HOUR); + const minutes = absoluteOffsetMinutes % MINUTES_IN_HOUR; + + const timeZoneOffset = minutes + ? `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}` + : `GMT${sign}${hours}`; + + return timeZoneOffset; +}; From e8b79012c776cf3b1f8dcae22ffeeb7d7ebbc559 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:05 +0100 Subject: [PATCH 03/14] feat(ScheduledMessages): add hooks for scheduling from the composer - useScheduleMessage: schedules the composition, shows a success toast with the server's rounded time, and refreshes the composer id on scheduled_message_id_taken without retrying (the first request may have succeeded) - useScheduleMessageSubmit: dialog submit state, guarded against updates after unmount - useScheduleTimeForm: date and time field state, validated against the channel limits - useSchedulePresets: presets computed only while the menu is open - useScheduleMessageDialog: dialog open state and focus return to the trigger, with the animation frame cancelled on unmount - useScheduledMessagePreview and useIsConnectionOnline Refs: REACT-1387 --- .../__tests__/scheduleMessageHooks.test.tsx | 233 ++++++++++++++++++ .../ScheduledMessages/hooks/index.ts | 7 + .../hooks/useIsConnectionOnline.ts | 24 ++ .../hooks/useScheduleMessage.ts | 54 ++++ .../hooks/useScheduleMessageDialog.ts | 43 ++++ .../hooks/useScheduleMessageSubmit.ts | 39 +++ .../hooks/useSchedulePresets.ts | 20 ++ .../hooks/useScheduleTimeForm.ts | 53 ++++ .../hooks/useScheduledMessagePreview.ts | 29 +++ 9 files changed, 502 insertions(+) create mode 100644 src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx create mode 100644 src/components/ScheduledMessages/hooks/useIsConnectionOnline.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessage.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts create mode 100644 src/components/ScheduledMessages/hooks/useSchedulePresets.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts create mode 100644 src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts diff --git a/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx b/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx new file mode 100644 index 0000000000..d2a4128119 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/__tests__/scheduleMessageHooks.test.tsx @@ -0,0 +1,233 @@ +import React, { createRef } from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { MessageComposer } from 'stream-chat'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { ChannelStateProvider, ChatProvider } from '../../../../context'; +import { MessageComposerControllerProvider } from '../../../MessageComposer'; +import { + generateScheduledMessage, + getTestClientWithUser, + mockChannelStateContext, + mockChatContext, +} from '../../../../mock-builders'; +import { useScheduleMessageDialog } from '../useScheduleMessageDialog'; +import { useScheduleMessageSubmit } from '../useScheduleMessageSubmit'; +import { useSchedulePresets } from '../useSchedulePresets'; +import { useScheduleTimeForm } from '../useScheduleTimeForm'; + +const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30); + +const createWrapper = ( + client: StreamChat, + channel: Channel, + composer: MessageComposer, +) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + + + + {children} + + + + ); + + return Wrapper; +}; + +const setup = async () => { + const client = await getTestClientWithUser({ id: 'user-id' }); + const channel = client.channel('messaging', 'general'); + const composer = new MessageComposer({ client, compositionContext: channel }); + const wrapper = createWrapper(client, channel, composer); + + return { client, composer, wrapper }; +}; + +const deferred = () => { + let resolve: (value: T) => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + + return { promise, reject, resolve }; +}; + +describe('scheduled message hooks', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] }); + vi.setSystemTime(WEDNESDAY_MORNING); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('useSchedulePresets', () => { + it('computes nothing while the menu is closed', async () => { + const { wrapper } = await setup(); + + const { rerender, result } = renderHook(() => useSchedulePresets(false), { + wrapper, + }); + const closedPresets = result.current; + rerender(); + + expect(closedPresets).toEqual([]); + expect(result.current).toBe(closedPresets); + }); + + it('keeps the same presets across re-renders while the menu is open', async () => { + const { wrapper } = await setup(); + + const { rerender, result } = renderHook(() => useSchedulePresets(true), { + wrapper, + }); + const openPresets = result.current; + rerender(); + + expect(openPresets).toHaveLength(3); + expect(result.current).toBe(openPresets); + }); + }); + + describe('useScheduleTimeForm', () => { + it('starts at the next whole hour after the earliest time', async () => { + const { wrapper } = await setup(); + + const { result } = renderHook(() => useScheduleTimeForm(), { wrapper }); + + expect(result.current.dateValue).toBe('2026-10-28'); + expect(result.current.timeValue).toBe('11:00'); + expect(result.current.scheduledAt).toEqual(new Date(2026, 9, 28, 11, 0)); + expect(result.current.timeError).toBeUndefined(); + }); + + it('keeps handlers and the picked time stable across re-renders', async () => { + const { wrapper } = await setup(); + + const { rerender, result } = renderHook(() => useScheduleTimeForm(), { wrapper }); + const { onDateChange, onTimeChange, scheduledAt } = result.current; + rerender(); + + expect(result.current.onDateChange).toBe(onDateChange); + expect(result.current.onTimeChange).toBe(onTimeChange); + expect(result.current.scheduledAt).toBe(scheduledAt); + }); + }); + + describe('useScheduleMessageSubmit', () => { + it('calls onScheduled after a successful schedule', async () => { + const { composer, wrapper } = await setup(); + vi.spyOn(composer, 'schedule').mockResolvedValue(generateScheduledMessage()); + const onScheduled = vi.fn(); + + const { result } = renderHook(() => useScheduleMessageSubmit(onScheduled), { + wrapper, + }); + await act(() => result.current.submit(new Date(2026, 9, 29, 9))); + + expect(onScheduled).toHaveBeenCalledTimes(1); + expect(result.current.hasFailed).toBe(false); + expect(result.current.isScheduling).toBe(false); + }); + + it('reports a failure and allows another attempt', async () => { + const { composer, wrapper } = await setup(); + vi.spyOn(composer, 'schedule').mockRejectedValue(new Error('failed')); + const onScheduled = vi.fn(); + + const { result } = renderHook(() => useScheduleMessageSubmit(onScheduled), { + wrapper, + }); + await act(() => result.current.submit(new Date(2026, 9, 29, 9))); + + expect(onScheduled).not.toHaveBeenCalled(); + expect(result.current.hasFailed).toBe(true); + expect(result.current.isScheduling).toBe(false); + }); + + it('does not call onScheduled after unmount when the request succeeds late', async () => { + const { composer, wrapper } = await setup(); + const pendingSchedule = deferred(); + vi.spyOn(composer, 'schedule').mockReturnValue(pendingSchedule.promise); + const onScheduled = vi.fn(); + const consoleError = vi.spyOn(console, 'error'); + + const { result, unmount } = renderHook( + () => useScheduleMessageSubmit(onScheduled), + { + wrapper, + }, + ); + let submitting: Promise = Promise.resolve(); + act(() => { + submitting = result.current.submit(new Date(2026, 9, 29, 9)); + }); + unmount(); + pendingSchedule.resolve(undefined); + await submitting; + + expect(onScheduled).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('useScheduleMessageDialog', () => { + it('returns focus to the trigger after closing when focus fell to the body', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + const returnFocusRef = createRef(); + Object.assign(returnFocusRef, { current: trigger }); + const requestFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + callback(0); + return 1; + }); + + const { result } = renderHook(() => useScheduleMessageDialog(returnFocusRef)); + act(() => result.current.openDialog()); + act(() => result.current.closeDialog()); + + expect(result.current.isDialogOpen).toBe(false); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(trigger); + + trigger.remove(); + }); + + it('cancels the pending focus restore on unmount', () => { + const returnFocusRef = createRef(); + vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(42); + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame'); + + const { result, unmount } = renderHook(() => + useScheduleMessageDialog(returnFocusRef), + ); + act(() => result.current.openDialog()); + act(() => result.current.closeDialog()); + unmount(); + + expect(cancelFrame).toHaveBeenCalledWith(42); + }); + + it('keeps open and close stable across re-renders', () => { + const returnFocusRef = createRef(); + + const { rerender, result } = renderHook(() => + useScheduleMessageDialog(returnFocusRef), + ); + const { closeDialog, openDialog } = result.current; + rerender(); + + expect(result.current.openDialog).toBe(openDialog); + expect(result.current.closeDialog).toBe(closeDialog); + }); + }); +}); diff --git a/src/components/ScheduledMessages/hooks/index.ts b/src/components/ScheduledMessages/hooks/index.ts index b1deb1e29e..f04801deb4 100644 --- a/src/components/ScheduledMessages/hooks/index.ts +++ b/src/components/ScheduledMessages/hooks/index.ts @@ -1,3 +1,10 @@ export * from './useCanScheduleMessage'; +export * from './useIsConnectionOnline'; export * from './useScheduledMessageCount'; +export * from './useScheduledMessagePreview'; export * from './useScheduledMessages'; +export * from './useScheduleMessage'; +export * from './useScheduleMessageDialog'; +export * from './useScheduleMessageSubmit'; +export * from './useSchedulePresets'; +export * from './useScheduleTimeForm'; diff --git a/src/components/ScheduledMessages/hooks/useIsConnectionOnline.ts b/src/components/ScheduledMessages/hooks/useIsConnectionOnline.ts new file mode 100644 index 0000000000..8fd207b88b --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useIsConnectionOnline.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import type { Event, StreamChat } from 'stream-chat'; + +import { useChatContext } from '../../../context'; + +const isClientOnline = ({ wsConnection }: StreamChat) => + wsConnection?.isHealthy !== false; + +export const useIsConnectionOnline = () => { + const { client } = useChatContext('useIsConnectionOnline'); + const [isOnline, setIsOnline] = useState(() => isClientOnline(client)); + + useEffect(() => { + setIsOnline(isClientOnline(client)); + + const { unsubscribe } = client.on('connection.changed', ({ online }: Event) => + setIsOnline(!!online), + ); + + return unsubscribe; + }, [client]); + + return isOnline; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts new file mode 100644 index 0000000000..e08cbeb7eb --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { ScheduledMessageErrorReason } from 'stream-chat'; +import type { ScheduledMessageResponse } from 'stream-chat'; + +import { useTranslationContext } from '../../../context'; +import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import { useNotificationApi } from '../../Notifications'; +import { + SCHEDULE_MESSAGE_NOTIFICATION_EMITTER, + ScheduledMessageNotificationType, +} from '../constants'; +import { isScheduledMessageError } from '../utils'; + +export const useScheduleMessage = () => { + const { t } = useTranslationContext(); + const { addNotification } = useNotificationApi(); + + const messageComposer = useMessageComposerController(); + + const notifyScheduled = useCallback( + ({ scheduled_at }: ScheduledMessageResponse) => { + const time = t('timestamp/ScheduledMessage', { timestamp: new Date(scheduled_at) }); + + addNotification({ + emitter: SCHEDULE_MESSAGE_NOTIFICATION_EMITTER, + message: t('Scheduled for {{ time }}', { time }), + severity: 'success', + type: ScheduledMessageNotificationType.CreateSucceeded, + }); + }, + [addNotification, t], + ); + + const scheduleMessage = useCallback( + async (scheduledAt: Date) => { + try { + const scheduledMessage = await messageComposer.schedule(scheduledAt); + + if (scheduledMessage) notifyScheduled(scheduledMessage); + + return scheduledMessage; + } catch (error) { + if (isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken)) { + messageComposer.refreshId(); + } + + throw error; + } + }, + [messageComposer, notifyScheduled], + ); + + return scheduleMessage; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts b/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts new file mode 100644 index 0000000000..8a9e7c5bed --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduleMessageDialog.ts @@ -0,0 +1,43 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; + +import type { ScheduleMessageDialogState } from '../types'; + +const hasFocusFallenToBody = () => { + const { activeElement } = document; + + return !activeElement || activeElement === document.body; +}; + +export const useScheduleMessageDialog = ( + returnFocusRef: RefObject, +) => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const shouldRestoreFocusRef = useRef(false); + + const openDialog = useCallback(() => setIsDialogOpen(true), []); + + const closeDialog = useCallback(() => { + shouldRestoreFocusRef.current = true; + setIsDialogOpen(false); + }, []); + + useEffect(() => { + if (isDialogOpen || !shouldRestoreFocusRef.current) return; + + const frame = requestAnimationFrame(() => { + shouldRestoreFocusRef.current = false; + + if (hasFocusFallenToBody()) returnFocusRef.current?.focus(); + }); + + return () => cancelAnimationFrame(frame); + }, [isDialogOpen, returnFocusRef]); + + const scheduleMessageDialog: ScheduleMessageDialogState = { + closeDialog, + isDialogOpen, + openDialog, + }; + + return scheduleMessageDialog; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts b/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts new file mode 100644 index 0000000000..ef51e417bd --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduleMessageSubmit.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; + +import { useIsMounted } from '../../Channel/hooks/useIsMounted'; +import type { ScheduleMessageSubmit } from '../types'; +import { useScheduleMessage } from './useScheduleMessage'; + +export const useScheduleMessageSubmit = (onScheduled: () => void) => { + const scheduleMessage = useScheduleMessage(); + const isMounted = useIsMounted(); + + const [hasFailed, setHasFailed] = useState(false); + const [isScheduling, setIsScheduling] = useState(false); + + const submit = useCallback( + async (scheduledAt: Date) => { + setHasFailed(false); + setIsScheduling(true); + + try { + await scheduleMessage(scheduledAt); + + if (isMounted.current) onScheduled(); + } catch { + if (isMounted.current) setHasFailed(true); + } finally { + if (isMounted.current) setIsScheduling(false); + } + }, + [isMounted, onScheduled, scheduleMessage], + ); + + const scheduleMessageSubmit: ScheduleMessageSubmit = { + hasFailed, + isScheduling, + submit, + }; + + return scheduleMessageSubmit; +}; diff --git a/src/components/ScheduledMessages/hooks/useSchedulePresets.ts b/src/components/ScheduledMessages/hooks/useSchedulePresets.ts new file mode 100644 index 0000000000..27394f8f8a --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useSchedulePresets.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import { getAvailableSchedulePresets, getChannelScheduleTimeBounds } from '../utils'; + +const NO_PRESETS: Date[] = []; + +export const useSchedulePresets = (isMenuOpen: boolean) => { + const { channel } = useMessageComposerController(); + + const presets = useMemo(() => { + if (!isMenuOpen) return NO_PRESETS; + + const now = new Date(); + + return getAvailableSchedulePresets(now, getChannelScheduleTimeBounds(channel, now)); + }, [channel, isMenuOpen]); + + return presets; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts b/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts new file mode 100644 index 0000000000..877ed54862 --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduleTimeForm.ts @@ -0,0 +1,53 @@ +import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; + +import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import type { ScheduleTimeForm } from '../types'; +import { + fromDateAndTimeInputValues, + getChannelScheduleTimeBounds, + getDefaultScheduledAt, + getScheduleTimeError, + toDateInputValue, + toTimeInputValue, +} from '../utils'; + +const readInputValue = ({ target }: ChangeEvent) => target.value; + +export const useScheduleTimeForm = () => { + const { channel } = useMessageComposerController(); + + const [bounds] = useState(() => getChannelScheduleTimeBounds(channel, new Date())); + const [dateValue, setDateValue] = useState(() => + toDateInputValue(getDefaultScheduledAt(bounds)), + ); + const [timeValue, setTimeValue] = useState(() => + toTimeInputValue(getDefaultScheduledAt(bounds)), + ); + + const scheduledAt = useMemo( + () => fromDateAndTimeInputValues(dateValue, timeValue), + [dateValue, timeValue], + ); + const timeError = scheduledAt ? getScheduleTimeError(scheduledAt, bounds) : undefined; + + const onDateChange = useCallback( + (event: ChangeEvent) => setDateValue(readInputValue(event)), + [], + ); + const onTimeChange = useCallback( + (event: ChangeEvent) => setTimeValue(readInputValue(event)), + [], + ); + + const scheduleTimeForm: ScheduleTimeForm = { + bounds, + dateValue, + onDateChange, + onTimeChange, + scheduledAt, + timeError, + timeValue, + }; + + return scheduleTimeForm; +}; diff --git a/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts new file mode 100644 index 0000000000..af6390602b --- /dev/null +++ b/src/components/ScheduledMessages/hooks/useScheduledMessagePreview.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import type { AttachmentManagerState, TextComposerState } from 'stream-chat'; + +import { useStateStore } from '../../../store'; +import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; +import { toScheduledMessagePreview } from '../utils'; + +const textComposerSelector = ({ text }: TextComposerState) => ({ text }); + +const attachmentManagerSelector = ({ attachments }: AttachmentManagerState) => ({ + attachments, +}); + +export const useScheduledMessagePreview = () => { + const messageComposer = useMessageComposerController(); + const { attachmentManager, id, textComposer } = messageComposer; + const { text } = useStateStore(textComposer.state, textComposerSelector); + const { attachments } = useStateStore( + attachmentManager.state, + attachmentManagerSelector, + ); + + const previewMessage = useMemo( + () => toScheduledMessagePreview({ attachments, id, text }, new Date()), + [attachments, id, text], + ); + + return previewMessage; +}; From 71002709e44b1b80f30b1f041eb0347bb2b42a09 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:16 +0100 Subject: [PATCH 04/14] feat(ScheduledMessages): add the schedule menu and custom time dialog Following the Figma web designs: - ScheduleMessageButton: chevron segment of the split send button that opens the "Schedule message" menu above it and hosts the custom time dialog - ScheduleMessageMenu, ScheduleMessageMenuHeader and ScheduleMessagePresetButton: the presets and "Custom time" - ScheduleMessageDialog: message preview, separate date and time fields with the native pickers, time zone note, offline and error notices - ScheduleMessageDialogNotice: inline info and error notice New ComponentContext slots: ScheduleMessageButton (null hides it) and ScheduleMessageDialog. Styles use the existing design tokens. Refs: REACT-1387 --- .../ScheduleMessageButton.tsx | 116 ++++++++++++++++ .../ScheduleMessageDialog.tsx | 131 ++++++++++++++++++ .../ScheduleMessageDialogNotice.tsx | 29 ++++ .../ScheduledMessages/ScheduleMessageMenu.tsx | 51 +++++++ .../ScheduleMessageMenuHeader.tsx | 14 ++ .../ScheduleMessagePresetButton.tsx | 25 ++++ src/components/ScheduledMessages/index.ts | 6 + .../styling/ScheduleMessageButton.scss | 50 +++++++ .../styling/ScheduleMessageDialog.scss | 75 ++++++++++ .../ScheduledMessages/styling/index.scss | 2 + src/context/ComponentContext.tsx | 5 + src/styling/index.scss | 1 + 12 files changed, 505 insertions(+) create mode 100644 src/components/ScheduledMessages/ScheduleMessageButton.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageDialog.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageMenu.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx create mode 100644 src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageButton.scss create mode 100644 src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss create mode 100644 src/components/ScheduledMessages/styling/index.scss diff --git a/src/components/ScheduledMessages/ScheduleMessageButton.tsx b/src/components/ScheduledMessages/ScheduleMessageButton.tsx new file mode 100644 index 0000000000..5dfe1c4e17 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageButton.tsx @@ -0,0 +1,116 @@ +import clsx from 'clsx'; +import React, { useCallback, useRef } from 'react'; + +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; +import { Button } from '../Button'; +import { CHANNEL_CONTAINER_ID } from '../Channel/constants'; +import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; +import { useMessageComposerHasSendableData } from '../MessageComposer/hooks'; +import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; +import { GlobalModal } from '../Modal'; +import { Portal } from '../Portal/Portal'; +import { SCHEDULE_MESSAGE_MENU_ID } from './constants'; +import { + useScheduleMessage, + useScheduleMessageDialog, + useSchedulePresets, +} from './hooks'; +import { ScheduleMessageDialog as DefaultScheduleMessageDialog } from './ScheduleMessageDialog'; +import { ScheduleMessageMenu } from './ScheduleMessageMenu'; +import { ScheduleMessageMenuHeader } from './ScheduleMessageMenuHeader'; + +const getChannelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID); + +const getMenuDialogId = (threadId?: string | null) => + threadId ? `${SCHEDULE_MESSAGE_MENU_ID}-thread` : SCHEDULE_MESSAGE_MENU_ID; + +const ignoreReportedError = () => undefined; + +export const ScheduleMessageButton = () => { + const { t } = useTranslationContext(); + const { IconChevronUp } = useComponentContextIcons(); + const { + ContextMenu: ContextMenuComponent = ContextMenu, + Modal = GlobalModal, + ScheduleMessageDialog = DefaultScheduleMessageDialog, + } = useComponentContext(); + + const { threadId } = useMessageComposerController(); + const hasSendableData = useMessageComposerHasSendableData(); + const scheduleMessage = useScheduleMessage(); + const menuDialogId = getMenuDialogId(threadId); + const { dialog: menuDialog, dialogManager } = useDialogOnNearestManager({ + id: menuDialogId, + }); + const isMenuOpen = useDialogIsOpen(menuDialogId, dialogManager?.id); + + const menuButtonRef = useRef(null); + + const presets = useSchedulePresets(isMenuOpen); + const { closeDialog, isDialogOpen, openDialog } = + useScheduleMessageDialog(menuButtonRef); + + const toggleMenu = useCallback(() => menuDialog.toggle(), [menuDialog]); + + const scheduleAtPreset = useCallback( + (scheduledAt: Date) => { + scheduleMessage(scheduledAt).catch(ignoreReportedError); + }, + [scheduleMessage], + ); + + return ( + <> + + + + + + + {isDialogOpen && } + + + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageDialog.tsx b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx new file mode 100644 index 0000000000..246d801e98 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageDialog.tsx @@ -0,0 +1,131 @@ +import React, { useCallback } from 'react'; +import type { TFunction } from 'i18next'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { useChannelDisplayName } from '../ChannelListItem/hooks/useChannelDisplayName'; +import { Prompt } from '../Dialog'; +import { TextInput } from '../Form'; +import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; +import { QuotedMessagePreviewUI } from '../MessageComposer/QuotedMessagePreview'; +import { ScheduleMessageNoticeSeverity, ScheduleTimeError } from './constants'; +import { + useIsConnectionOnline, + useScheduledMessagePreview, + useScheduleMessageSubmit, + useScheduleTimeForm, +} from './hooks'; +import { ScheduleMessageDialogNotice } from './ScheduleMessageDialogNotice'; +import type { ScheduleMessageDialogProps } from './types'; +import { formatTimeZoneOffset, openNativePicker, toDateInputValue } from './utils'; + +const translateScheduleTimeError = (t: TFunction, timeError?: ScheduleTimeError) => { + if (timeError === ScheduleTimeError.TooSoon) return t('Choose a later time'); + + if (timeError === ScheduleTimeError.TooFar) return t('Choose an earlier time'); + + return undefined; +}; + +export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => { + const { t } = useTranslationContext(); + const { IconCalendar, IconClock } = useComponentContextIcons(); + + const { channel } = useMessageComposerController(); + const channelDisplayName = useChannelDisplayName(channel); + const previewMessage = useScheduledMessagePreview(); + const isOnline = useIsConnectionOnline(); + const { + bounds, + dateValue, + onDateChange, + onTimeChange, + scheduledAt, + timeError, + timeValue, + } = useScheduleTimeForm(); + const { hasFailed, isScheduling, submit } = useScheduleMessageSubmit(close); + + const canSchedule = !!scheduledAt && !timeError && isOnline && !isScheduling; + const timeErrorMessage = translateScheduleTimeError(t, timeError); + const timeZone = formatTimeZoneOffset(scheduledAt ?? bounds.earliest); + const authorLabel = channelDisplayName + ? t('Message to {{ name }}', { name: channelDisplayName }) + : t('Schedule message'); + + const scheduleAtPickedTime = useCallback(() => { + if (scheduledAt) submit(scheduledAt); + }, [scheduledAt, submit]); + + return ( + + + + + {!isOnline && ( + + {t("You're offline. Reconnect to schedule this message.")} + + )} + {hasFailed && ( + + {t("Couldn't schedule message. Try again")} + + )} +
+ } + max={toDateInputValue(bounds.latest)} + min={toDateInputValue(bounds.earliest)} + onChange={onDateChange} + onClick={openNativePicker} + required + type='date' + value={dateValue} + /> + } + onChange={onTimeChange} + onClick={openNativePicker} + required + type='time' + value={timeValue} + /> +
+ + {t('Sent in your time zone ({{ timeZone }})', { timeZone })} + +
+ + + + {t('Cancel')} + + + {t('Schedule')} + + + +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx new file mode 100644 index 0000000000..ae75be05f1 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageDialogNotice.tsx @@ -0,0 +1,29 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { useComponentContextIcons } from '../../context'; +import { ScheduleMessageNoticeSeverity } from './constants'; +import type { ScheduleMessageDialogNoticeProps } from './types'; + +export const ScheduleMessageDialogNotice = ({ + children, + severity, +}: ScheduleMessageDialogNoticeProps) => { + const { IconExclamationCircleFill, IconInfo } = useComponentContextIcons(); + + const isError = severity === ScheduleMessageNoticeSeverity.Error; + const Icon = isError ? IconExclamationCircleFill : IconInfo; + + return ( +
+ + {children} +
+ ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageMenu.tsx b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx new file mode 100644 index 0000000000..3dbe775a0a --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageMenu.tsx @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { ContextMenuButton, useContextMenuContext } from '../Dialog'; +import { ScheduleMessagePresetButton } from './ScheduleMessagePresetButton'; +import type { ScheduleMessageMenuProps } from './types'; + +export const ScheduleMessageMenu = ({ + onCustomTimeSelect, + onPresetSelect, + presets, +}: ScheduleMessageMenuProps) => { + const { t } = useTranslationContext(); + const { IconClock } = useComponentContextIcons(); + const { closeMenu } = useContextMenuContext(); + + const selectPreset = useCallback( + (scheduledAt: Date) => { + closeMenu(); + onPresetSelect(scheduledAt); + }, + [closeMenu, onPresetSelect], + ); + + const selectCustomTime = useCallback(() => { + closeMenu(); + onCustomTimeSelect(); + }, [closeMenu, onCustomTimeSelect]); + + return ( + <> + {presets.map((scheduledAt) => ( + + ))} +
+ + {t('Custom time')} + + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx new file mode 100644 index 0000000000..289faee3c1 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessageMenuHeader.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { useTranslationContext } from '../../context'; +import { ContextMenuHeader } from '../Dialog'; + +export const ScheduleMessageMenuHeader = () => { + const { t } = useTranslationContext(); + + return ( + + {t('Schedule message')} + + ); +}; diff --git a/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx new file mode 100644 index 0000000000..365b861544 --- /dev/null +++ b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx @@ -0,0 +1,25 @@ +import React, { useCallback } from 'react'; + +import { useComponentContextIcons, useTranslationContext } from '../../context'; +import { ContextMenuButton } from '../Dialog'; +import type { ScheduleMessagePresetButtonProps } from './types'; + +export const ScheduleMessagePresetButton = ({ + onSelect, + scheduledAt, +}: ScheduleMessagePresetButtonProps) => { + const { t } = useTranslationContext(); + const { IconClock } = useComponentContextIcons(); + + const selectPreset = useCallback(() => onSelect(scheduledAt), [onSelect, scheduledAt]); + + return ( + + {t('timestamp/ScheduledMessage', { timestamp: scheduledAt })} + + ); +}; diff --git a/src/components/ScheduledMessages/index.ts b/src/components/ScheduledMessages/index.ts index 69bd828d23..c92f1e6606 100644 --- a/src/components/ScheduledMessages/index.ts +++ b/src/components/ScheduledMessages/index.ts @@ -4,4 +4,10 @@ export { ScheduleTimeError, } from './constants'; export * from './hooks'; +export * from './ScheduleMessageButton'; +export * from './ScheduleMessageDialog'; +export * from './ScheduleMessageDialogNotice'; +export * from './ScheduleMessageMenu'; +export * from './ScheduleMessageMenuHeader'; +export * from './ScheduleMessagePresetButton'; export * from './types'; diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss new file mode 100644 index 0000000000..d5033bdee1 --- /dev/null +++ b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss @@ -0,0 +1,50 @@ +@use '../../../styling/utils'; + +.str-chat { + --str-chat__split-send-button-segment-gap: 1px; + + .str-chat__split-send-button { + display: flex; + align-items: center; + gap: var(--str-chat__split-send-button-segment-gap); + + .str-chat__send-button { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + + .str-chat__schedule-message-button { + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: var(--str-chat__button-radius-full); + border-end-end-radius: var(--str-chat__button-radius-full); + padding-inline: var(--str-chat__spacing-xs); + + .str-chat__icon--chevron-up { + height: var(--str-chat__icon-size-sm); + width: var(--str-chat__icon-size-sm); + } + + &.str-chat__schedule-message-button--selected:not(:disabled) { + @include utils.overlay-after(var(--str-chat__background-utility-selected)); + } + } + } + + .str-chat__schedule-message-menu { + min-width: 224px; + + .str-chat__schedule-message-menu__header { + display: flex; + align-items: center; + min-height: 32px; + padding: var(--str-chat__spacing-xs); + } + + .str-chat__schedule-message-menu__separator { + height: 1px; + margin: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xs); + background-color: var(--str-chat__border-core-subtle); + } + } +} diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss new file mode 100644 index 0000000000..7a8183f1c3 --- /dev/null +++ b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss @@ -0,0 +1,75 @@ +.str-chat { + --str-chat__schedule-message-dialog-max-width: 504px; + --str-chat__schedule-message-dialog-notice-info-bg: color-mix( + in srgb, + var(--str-chat__accent-primary) 5%, + transparent + ); + --str-chat__schedule-message-dialog-notice-error-bg: color-mix( + in srgb, + var(--str-chat__accent-error) 5%, + transparent + ); + + .str-chat__schedule-message-dialog { + width: 100%; + max-width: var(--str-chat__schedule-message-dialog-max-width); + + .str-chat__schedule-message-dialog__body { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-md); + } + + .str-chat__schedule-message-dialog__preview { + background-color: var(--str-chat__chat-bg-outgoing); + } + + .str-chat__schedule-message-dialog__fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--str-chat__spacing-sm); + + input[type='date'], + input[type='time'] { + cursor: pointer; + } + + input::-webkit-calendar-picker-indicator { + display: none; + } + } + + .str-chat__schedule-message-dialog__notice { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm); + border-radius: var(--str-chat__radius-md); + color: var(--str-chat__text-primary); + font: var(--str-chat__font-caption-emphasis); + + .str-chat__schedule-message-dialog__notice-icon { + flex-shrink: 0; + height: var(--str-chat__icon-size-sm); + width: var(--str-chat__icon-size-sm); + } + } + + .str-chat__schedule-message-dialog__notice--info { + background-color: var(--str-chat__schedule-message-dialog-notice-info-bg); + + .str-chat__schedule-message-dialog__notice-icon { + color: var(--str-chat__accent-primary); + } + } + + .str-chat__schedule-message-dialog__notice--error { + background-color: var(--str-chat__schedule-message-dialog-notice-error-bg); + + .str-chat__schedule-message-dialog__notice-icon { + color: var(--str-chat__accent-error); + } + } + } +} diff --git a/src/components/ScheduledMessages/styling/index.scss b/src/components/ScheduledMessages/styling/index.scss new file mode 100644 index 0000000000..594c108d64 --- /dev/null +++ b/src/components/ScheduledMessages/styling/index.scss @@ -0,0 +1,2 @@ +@use 'ScheduleMessageButton'; +@use 'ScheduleMessageDialog'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index b2edd3081f..68f5601cf6 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -48,6 +48,7 @@ import { type ReactionSelectorProps, type RecordingPermissionDeniedNotificationProps, type ReminderNotificationProps, + type ScheduleMessageDialogProps, type ScrollToLatestMessageButtonProps, type SearchResultsPresearchProps, type SearchSourceResultListProps, @@ -270,6 +271,10 @@ export type ComponentContextValue = { SearchSourceResultsHeader?: React.ComponentType; /** Custom component to display the search source results UI during the search query execution, defaults to and accepts same props as: [SearchSourceResultsLoadingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx) */ SearchSourceResultsLoadingIndicator?: React.ComponentType; + /** Custom UI component rendered next to the send button that opens the schedule menu, defaults to: [ScheduleMessageButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ScheduledMessages/ScheduleMessageButton.tsx). Pass `null` to hide it. */ + ScheduleMessageButton?: React.ComponentType | null; + /** Custom UI component to render the custom-time dialog for scheduling a message, defaults to and accepts same props as: [ScheduleMessageDialog](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ScheduledMessages/ScheduleMessageDialog.tsx) */ + ScheduleMessageDialog?: React.ComponentType; /** Custom UI component for send button, defaults to and accepts same props as: [SendButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/icons.tsx) */ SendButton?: React.ComponentType; /** Custom UI component checkbox that indicates message to be sent to main channel, defaults to and accepts same props as: [SendToChannelCheckbox](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/SendToChannelCheckbox.tsx) */ diff --git a/src/styling/index.scss b/src/styling/index.scss index 744d72cba0..916ca73970 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -44,6 +44,7 @@ @use '../components/Notifications/styling' as Notifications; @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling' as Reactions; +@use '../components/ScheduledMessages/styling' as ScheduledMessages; @use '../components/Search/styling' as Search; @use '../components/SkipNavigation/styling' as SkipNavigation; @use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview; From 962a457d8860396628e2c8da528b7457d847ae4d Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:24 +0100 Subject: [PATCH 05/14] feat(i18n): translate scheduled message strings and notifications - Add the schedule menu and dialog strings to all 12 locales - Add timestamp/ScheduledMessage, reusing each locale's reminder calendar format ("Tomorrow at 09:00", "Monday at 09:00") - Translate the composer's scheduling errors by reason: disabled (114), too soon (115), too far (116), channel limit (117), user limit (118) and no permission (403), with a generic fallback; translate the slash command warning Refs: REACT-1387 --- .../notifications/translators.ts | 37 +++++++++ .../translatorsByNotificationType.ts | 5 ++ ...uledMessageNotificationTranslators.test.ts | 80 +++++++++++++++++++ src/i18n/de.json | 19 +++++ src/i18n/en.json | 19 +++++ src/i18n/es.json | 19 +++++ src/i18n/fr.json | 19 +++++ src/i18n/hi.json | 19 +++++ src/i18n/it.json | 19 +++++ src/i18n/ja.json | 19 +++++ src/i18n/ko.json | 19 +++++ src/i18n/nl.json | 19 +++++ src/i18n/pt.json | 19 +++++ src/i18n/ru.json | 19 +++++ src/i18n/tr.json | 19 +++++ 15 files changed, 350 insertions(+) create mode 100644 src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts index 1dc2781473..c4641563f9 100644 --- a/src/i18n/TranslationBuilder/notifications/translators.ts +++ b/src/i18n/TranslationBuilder/notifications/translators.ts @@ -1,3 +1,8 @@ +import { + ErrorFromResponse, + getScheduledMessageErrorReason, + ScheduledMessageErrorReason, +} from 'stream-chat'; import type { Notification } from 'stream-chat'; import type { NotificationTranslatorOptions } from './types'; @@ -88,3 +93,35 @@ export const translateCommandDisabled: Translator return t(notification?.message || 'Command not available'); }; + +const FORBIDDEN_STATUS = 403; + +const isForbiddenError = (error?: Error) => + error instanceof ErrorFromResponse && error.status === FORBIDDEN_STATUS; + +export const translateScheduledMessageCreateFailed: Translator< + NotificationTranslatorOptions +> = ({ options: { notification }, t }) => { + const error = notification?.originalError; + const reason = error ? getScheduledMessageErrorReason(error) : undefined; + + if (reason === ScheduledMessageErrorReason.Disabled) { + return t('Scheduled messages are turned off'); + } + + if (reason === ScheduledMessageErrorReason.TooSoon) return t('Choose a later time'); + + if (reason === ScheduledMessageErrorReason.TooFar) return t('Choose an earlier time'); + + if (reason === ScheduledMessageErrorReason.ChannelLimit) { + return t("You've reached the limit of scheduled messages in this channel"); + } + + if (reason === ScheduledMessageErrorReason.UserLimit) { + return t("You've reached the limit of scheduled messages"); + } + + if (isForbiddenError(error)) return t("You can't schedule messages here"); + + return t("Couldn't schedule message. Try again"); +}; diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts index 4707a0da30..4aa12fefb1 100644 --- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts +++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts @@ -1,3 +1,4 @@ +import { ScheduledMessageNotificationType } from '../../../components/ScheduledMessages/constants'; import type { NotificationTranslatorOptions } from './types'; import { translateAttachmentUploadBlocked, @@ -6,6 +7,7 @@ import { translateCommandDisabled, translatePollCreateFailed, translatePollEndFailed, + translateScheduledMessageCreateFailed, } from './translators'; import type { Translator } from '../../index'; @@ -20,6 +22,7 @@ export const translatorsByNotificationType: Record< 'api:poll:end:failed': translatePollEndFailed, 'api:poll:end:success': ({ t }) => t('Poll ended'), 'api:reply:search:failed': ({ t }) => t('Thread has not been found'), + [ScheduledMessageNotificationType.CreateFailed]: translateScheduledMessageCreateFailed, 'browser:audio:playback:error': translateBrowserAudioPlaybackError, 'browser:location:get:failed': ({ t }) => t('Failed to retrieve location'), 'channel:jumpToFirstUnread:failed': ({ t }) => @@ -34,4 +37,6 @@ export const translatorsByNotificationType: Record< 'validation:command:disabled': translateCommandDisabled, 'validation:poll:castVote:limit': ({ t }) => t('Reached the vote limit. Remove an existing vote first.'), + [ScheduledMessageNotificationType.CommandRejected]: ({ t }) => + t("Slash commands can't be scheduled"), }; diff --git a/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts new file mode 100644 index 0000000000..b7c2d6d321 --- /dev/null +++ b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts @@ -0,0 +1,80 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { TFunction } from 'i18next'; +import { ErrorFromResponse } from 'stream-chat'; +import type { Notification } from 'stream-chat'; + +import { translateScheduledMessageCreateFailed } from '../TranslationBuilder/notifications/translators'; +import { translatorsByNotificationType } from '../TranslationBuilder/notifications/translatorsByNotificationType'; + +const t = ((key: string) => key) as TFunction; + +const createApiError = (code: number | null, status = 400, reason?: string) => + new ErrorFromResponse('request failed', { + code, + response: fromPartial({ + data: { code, exception_fields: reason ? { reason } : {} }, + status, + }), + status, + }); + +const translate = (originalError?: Error) => + translateScheduledMessageCreateFailed({ + key: '', + options: { + notification: fromPartial({ + message: 'Failed to schedule the message', + originalError, + type: 'api:scheduled-message:create:failed', + }), + }, + t, + value: '', + }); + +describe('translateScheduledMessageCreateFailed', () => { + it.each([ + [114, 'Scheduled messages are turned off'], + [115, 'Choose a later time'], + [116, 'Choose an earlier time'], + [117, "You've reached the limit of scheduled messages in this channel"], + [118, "You've reached the limit of scheduled messages"], + ])('translates error code %s', (code, expected) => { + expect(translate(createApiError(code))).toBe(expected); + }); + + it('prefers the reason from exception_fields', () => { + expect(translate(createApiError(4, 400, 'scheduled_limit_user'))).toBe( + "You've reached the limit of scheduled messages", + ); + }); + + it('translates a missing permission', () => { + expect(translate(createApiError(17, 403))).toBe("You can't schedule messages here"); + }); + + it.each([ + ['an id conflict', createApiError(119)], + ['an unknown API error', createApiError(4)], + ['a network error', new Error('Network Error')], + ['a missing error', undefined], + ])('falls back to the generic copy for %s', (_, error) => { + expect(translate(error)).toBe("Couldn't schedule message. Try again"); + }); +}); + +describe('scheduled message notification types', () => { + it('registers translators for the composer notifications', () => { + expect(translatorsByNotificationType['api:scheduled-message:create:failed']).toBe( + translateScheduledMessageCreateFailed, + ); + expect( + translatorsByNotificationType['validation:scheduled-message:command']?.({ + key: '', + options: {}, + t, + value: '', + }), + ).toBe("Slash commands can't be scheduled"); + }); +}); diff --git a/src/i18n/de.json b/src/i18n/de.json index 449bc36c8f..b20662b7c4 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -160,6 +160,7 @@ "aria/Open image shared by {{ name }}": "Von {{ name }} geteiltes Bild öffnen", "aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen", "aria/Open Reaction Selector": "Reaktionsauswahl öffnen", + "aria/Open schedule message menu": "Menü zum Planen von Nachrichten öffnen", "aria/Open Thread": "Thread öffnen", "aria/Open threads view": "Thread-Ansicht öffnen", "aria/Open threads view with unread threads_one": "Thread-Ansicht öffnen, {{ count }} ungelesener Thread", @@ -260,6 +261,8 @@ "Channels": "Kanäle", "Chat deleted": "Chat deleted", "Chats": "Chats", + "Choose a later time": "Wähle eine spätere Uhrzeit", + "Choose an earlier time": "Wähle eine frühere Uhrzeit", "Choose between 2 to 10 options": "Wähle zwischen 2 und 10 Optionen", "Close": "Schließen", "Close dialog": "Dialog schließen", @@ -276,11 +279,14 @@ "Contact name": "Kontaktname", "Cooking up something good": "Ich koche etwas Feines zusammen", "Copy Message": "Nachricht kopieren", + "Couldn't schedule message. Try again": "Nachricht konnte nicht geplant werden. Versuche es erneut", "Create": "Erstellen", "Create a question, add options, and configure poll settings": "Erstelle eine Frage, füge Optionen hinzu und konfiguriere die Umfrageeinstellungen", "Create poll": "Umfrage erstellen", "Crunching the numbers": "Ich zerkaue die Zahlen", "Current location": "Aktueller Standort", + "Custom time": "Benutzerdefinierte Zeit", + "Date": "Datum", "Delete": "Löschen", "Delete attachment": "Anhang löschen", "Delete chat": "Chat löschen", @@ -474,6 +480,7 @@ "Message has been successfully flagged": "Nachricht wurde erfolgreich gemeldet", "Message marked as unread": "Nachricht als ungelesen markiert", "Message pinned": "Nachricht angeheftet", + "Message to {{ name }}": "Nachricht an {{ name }}", "Message unpinned": "Nachricht nicht mehr angeheftet", "Message was blocked by moderation policies": "Nachricht wurde durch moderationsrichtlinien blockiert", "Messages have been marked unread.": "Nachrichten wurden als ungelesen markiert.", @@ -580,6 +587,10 @@ "Save": "Speichern", "Save for later": "Für später speichern", "Saved for later": "Für später gespeichert", + "Schedule": "Planen", + "Schedule message": "Nachricht planen", + "Scheduled for {{ time }}": "Geplant für {{ time }}", + "Scheduled messages are turned off": "Geplante Nachrichten sind deaktiviert", "Search": "Suche", "Search GIFs": "GIFs suchen", "search-results-header-filter-source-button-label--channels": "Kanäle", @@ -605,6 +616,7 @@ "Send poll": "Umfrage senden", "Sending...": "Senden...", "Sent": "Gesendet", + "Sent in your time zone ({{ timeZone }})": "Wird in deiner Zeitzone gesendet ({{ timeZone }})", "Share": "Teilen", "Share a file to see it here": "Teile eine Datei, um sie hier zu sehen", "Share a photo or video to see it here": "Teile ein Foto oder Video, um es hier zu sehen", @@ -614,6 +626,7 @@ "Shared location": "Geteilter Standort", "Shuffle": "Mischen", "size limit": "Größenbeschränkung", + "Slash commands can't be scheduled": "Slash-Befehle können nicht geplant werden", "Slow Mode ON": "Langsamer Modus EIN", "Slow mode, wait {{ seconds }}s...": "Langsamer Modus, warte {{ seconds }}s...", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", @@ -643,6 +656,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} ungelesener Thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} ungelesene Threads", "Threads": "Diskussionen", + "Time": "Uhrzeit", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Gestern]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -656,6 +670,7 @@ "timestamp/relativeWeeksAgo": "vor {{ count }} Wo.", "timestamp/relativeYesterday": "Gestern", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", "To start recording, allow the microphone access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf das Mikrofon in Ihrem Browser", @@ -719,5 +734,9 @@ "Waiting for network…": "Warte auf Netzwerk…", "Working my magic": "Ich lasse meine Magie wirken", "You": "Du", + "You can't schedule messages here": "Du kannst hier keine Nachrichten planen", + "You're offline. Reconnect to schedule this message.": "Du bist offline. Stelle die Verbindung wieder her, um diese Nachricht zu planen.", + "You've reached the limit of scheduled messages": "Du hast das Limit für geplante Nachrichten erreicht", + "You've reached the limit of scheduled messages in this channel": "Du hast das Limit für geplante Nachrichten in diesem Kanal erreicht", "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht" } diff --git a/src/i18n/en.json b/src/i18n/en.json index a9d61b90bf..dc1256326e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -160,6 +160,7 @@ "aria/Open image shared by {{ name }}": "Open image shared by {{ name }}", "aria/Open Message Actions Menu": "Open Message Actions Menu", "aria/Open Reaction Selector": "Open Reaction Selector", + "aria/Open schedule message menu": "Open schedule message menu", "aria/Open Thread": "Open Thread", "aria/Open threads view": "Open threads view", "aria/Open threads view with unread threads_one": "Open threads view, {{ count }} unread thread", @@ -260,6 +261,8 @@ "Channels": "Channels", "Chat deleted": "Chat deleted", "Chats": "Chats", + "Choose a later time": "Choose a later time", + "Choose an earlier time": "Choose an earlier time", "Choose between 2 to 10 options": "Choose Between 2 to 10 Options", "Close": "Close", "Close dialog": "Close dialog", @@ -276,11 +279,14 @@ "Contact name": "Contact name", "Cooking up something good": "Cooking up something good", "Copy Message": "Copy Message", + "Couldn't schedule message. Try again": "Couldn't schedule message. Try again", "Create": "Create", "Create a question, add options, and configure poll settings": "Create a question, add options, and configure poll settings", "Create poll": "Create Poll", "Crunching the numbers": "Crunching the numbers", "Current location": "Current location", + "Custom time": "Custom time", + "Date": "Date", "Delete": "Delete", "Delete attachment": "Delete attachment", "Delete chat": "Delete chat", @@ -474,6 +480,7 @@ "Message has been successfully flagged": "Message has been successfully flagged", "Message marked as unread": "Message marked as unread", "Message pinned": "Message pinned", + "Message to {{ name }}": "Message to {{ name }}", "Message unpinned": "Message unpinned", "Message was blocked by moderation policies": "Message was blocked by moderation policies", "Messages have been marked unread.": "Messages have been marked unread.", @@ -580,6 +587,10 @@ "Save": "Save", "Save for later": "Save for later", "Saved for later": "Saved for later", + "Schedule": "Schedule", + "Schedule message": "Schedule message", + "Scheduled for {{ time }}": "Scheduled for {{ time }}", + "Scheduled messages are turned off": "Scheduled messages are turned off", "Search": "Search", "Search GIFs": "Search GIFs", "search-results-header-filter-source-button-label--channels": "channels", @@ -605,6 +616,7 @@ "Send poll": "Send Poll", "Sending...": "Sending...", "Sent": "Sent", + "Sent in your time zone ({{ timeZone }})": "Sent in your time zone ({{ timeZone }})", "Share": "Share", "Share a file to see it here": "Share a file to see it here", "Share a photo or video to see it here": "Share a photo or video to see it here", @@ -614,6 +626,7 @@ "Shared location": "Shared location", "Shuffle": "Shuffle", "size limit": "size limit", + "Slash commands can't be scheduled": "Slash commands can't be scheduled", "Slow Mode ON": "Slow Mode ON", "Slow mode, wait {{ seconds }}s...": "Slow mode, wait {{ seconds }}s...", "Some of the files will not be accepted": "Some of the files will not be accepted", @@ -643,6 +656,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} unread thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} unread threads", "Threads": "Threads", + "Time": "Time", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -656,6 +670,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}w ago", "timestamp/relativeYesterday": "Yesterday", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", "To start recording, allow the microphone access in your browser": "To start recording, allow the microphone access in your browser", @@ -719,5 +734,9 @@ "Waiting for network…": "Waiting for network…", "Working my magic": "Working my magic", "You": "You", + "You can't schedule messages here": "You can't schedule messages here", + "You're offline. Reconnect to schedule this message.": "You're offline. Reconnect to schedule this message.", + "You've reached the limit of scheduled messages": "You've reached the limit of scheduled messages", + "You've reached the limit of scheduled messages in this channel": "You've reached the limit of scheduled messages in this channel", "You've reached the maximum number of files": "You've reached the maximum number of files" } diff --git a/src/i18n/es.json b/src/i18n/es.json index aff84ebbba..af32866365 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -176,6 +176,7 @@ "aria/Open image shared by {{ name }}": "Abrir imagen compartida por {{ name }}", "aria/Open Message Actions Menu": "Abrir menú de acciones de mensaje", "aria/Open Reaction Selector": "Abrir selector de reacciones", + "aria/Open schedule message menu": "Abrir el menú de programación de mensajes", "aria/Open Thread": "Abrir hilo", "aria/Open threads view": "Abrir vista de hilos", "aria/Open threads view with unread threads_one": "Abrir vista de hilos, {{ count }} hilo no leído", @@ -277,6 +278,8 @@ "Channels": "Canales", "Chat deleted": "Chat deleted", "Chats": "Chats", + "Choose a later time": "Elige una hora posterior", + "Choose an earlier time": "Elige una hora anterior", "Choose between 2 to 10 options": "Elige entre 2 y 10 opciones", "Close": "Cerrar", "Close dialog": "Cerrar diálogo", @@ -293,11 +296,14 @@ "Contact name": "Nombre del contacto", "Cooking up something good": "Cocinando algo bueno", "Copy Message": "Copiar mensaje", + "Couldn't schedule message. Try again": "No se pudo programar el mensaje. Inténtalo de nuevo", "Create": "Crear", "Create a question, add options, and configure poll settings": "Crea una pregunta, añade opciones y configura los ajustes de la encuesta", "Create poll": "Crear encuesta", "Crunching the numbers": "Procesando los números", "Current location": "Ubicación actual", + "Custom time": "Hora personalizada", + "Date": "Fecha", "Delete": "Borrar", "Delete attachment": "Eliminar archivo adjunto", "Delete chat": "Eliminar chat", @@ -494,6 +500,7 @@ "Message has been successfully flagged": "El mensaje se marcó correctamente", "Message marked as unread": "Mensaje marcado como no leído", "Message pinned": "Mensaje fijado", + "Message to {{ name }}": "Mensaje para {{ name }}", "Message unpinned": "Mensaje desanclado", "Message was blocked by moderation policies": "El mensaje fue bloqueado por las políticas de moderación", "Messages have been marked unread.": "Los mensajes han sido marcados como no leídos.", @@ -603,6 +610,10 @@ "Save": "Guardar", "Save for later": "Guardar para más tarde", "Saved for later": "Guardado para más tarde", + "Schedule": "Programar", + "Schedule message": "Programar mensaje", + "Scheduled for {{ time }}": "Programado para {{ time }}", + "Scheduled messages are turned off": "Los mensajes programados están desactivados", "Search": "Buscar", "Search GIFs": "Buscar GIFs", "search-results-header-filter-source-button-label--channels": "canales", @@ -630,6 +641,7 @@ "Send poll": "Enviar encuesta", "Sending...": "Enviando...", "Sent": "Enviado", + "Sent in your time zone ({{ timeZone }})": "Se enviará en tu zona horaria ({{ timeZone }})", "Share": "Compartir", "Share a file to see it here": "Comparte un archivo para verlo aquí", "Share a photo or video to see it here": "Comparte una foto o un video para verlo aquí", @@ -639,6 +651,7 @@ "Shared location": "Ubicación compartida", "Shuffle": "Mezclar", "size limit": "límite de tamaño", + "Slash commands can't be scheduled": "Los comandos de barra no se pueden programar", "Slow Mode ON": "Modo lento activado", "Slow mode, wait {{ seconds }}s...": "Modo lento, espera {{ seconds }} s...", "Some of the files will not be accepted": "Algunos archivos no serán aceptados", @@ -669,6 +682,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} hilos no leídos", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} hilos no leídos", "Threads": "Hilos", + "Time": "Hora", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ayer]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -682,6 +696,7 @@ "timestamp/relativeWeeksAgo": "hace {{ count }} sem", "timestamp/relativeYesterday": "Ayer", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", "To start recording, allow the microphone access in your browser": "Para comenzar a grabar, permita el acceso al micrófono en su navegador", @@ -749,5 +764,9 @@ "Waiting for network…": "Esperando red…", "Working my magic": "Haciendo mi magia", "You": "Tú", + "You can't schedule messages here": "No puedes programar mensajes aquí", + "You're offline. Reconnect to schedule this message.": "Estás sin conexión. Vuelve a conectarte para programar este mensaje.", + "You've reached the limit of scheduled messages": "Has alcanzado el límite de mensajes programados", + "You've reached the limit of scheduled messages in this channel": "Has alcanzado el límite de mensajes programados en este canal", "You've reached the maximum number of files": "Has alcanzado el número máximo de archivos" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d5120dfd3f..67bf2d94d2 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -176,6 +176,7 @@ "aria/Open image shared by {{ name }}": "Ouvrir l'image partagée par {{ name }}", "aria/Open Message Actions Menu": "Ouvrir le menu des actions du message", "aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions", + "aria/Open schedule message menu": "Ouvrir le menu de programmation des messages", "aria/Open Thread": "Ouvrir le fil", "aria/Open threads view": "Ouvrir la vue des fils", "aria/Open threads view with unread threads_one": "Ouvrir la vue des fils, {{ count }} fil non lu", @@ -277,6 +278,8 @@ "Channels": "Canaux", "Chat deleted": "Chat deleted", "Chats": "Discussions", + "Choose a later time": "Choisissez une heure plus tardive", + "Choose an earlier time": "Choisissez une heure plus proche", "Choose between 2 to 10 options": "Choisir entre 2 et 10 options", "Close": "Fermer", "Close dialog": "Fermer la boîte de dialogue", @@ -293,11 +296,14 @@ "Contact name": "Nom du contact", "Cooking up something good": "Je mijote quelque chose de bon", "Copy Message": "Copier le message", + "Couldn't schedule message. Try again": "Impossible de programmer le message. Réessayez", "Create": "Créer", "Create a question, add options, and configure poll settings": "Créez une question, ajoutez des options et configurez les paramètres du sondage", "Create poll": "Créer un sondage", "Crunching the numbers": "Je fais chauffer les chiffres", "Current location": "Emplacement actuel", + "Custom time": "Heure personnalisée", + "Date": "Date", "Delete": "Supprimer", "Delete attachment": "Supprimer la pièce jointe", "Delete chat": "Supprimer le chat", @@ -494,6 +500,7 @@ "Message has been successfully flagged": "Le message a été signalé avec succès", "Message marked as unread": "Message marqué comme non lu", "Message pinned": "Message épinglé", + "Message to {{ name }}": "Message à {{ name }}", "Message unpinned": "Message désépinglé", "Message was blocked by moderation policies": "Le message a été bloqué par les politiques de modération", "Messages have been marked unread.": "Les messages ont été marqués comme non lus.", @@ -603,6 +610,10 @@ "Save": "Enregistrer", "Save for later": "Enregistrer pour plus tard", "Saved for later": "Enregistré pour plus tard", + "Schedule": "Programmer", + "Schedule message": "Programmer le message", + "Scheduled for {{ time }}": "Programmé pour {{ time }}", + "Scheduled messages are turned off": "Les messages programmés sont désactivés", "Search": "Rechercher", "Search GIFs": "Rechercher des GIFs", "search-results-header-filter-source-button-label--channels": "canaux", @@ -630,6 +641,7 @@ "Send poll": "Envoyer le sondage", "Sending...": "Envoi en cours...", "Sent": "Envoyé", + "Sent in your time zone ({{ timeZone }})": "Envoyé dans votre fuseau horaire ({{ timeZone }})", "Share": "Partager", "Share a file to see it here": "Partagez un fichier pour le voir ici", "Share a photo or video to see it here": "Partagez une photo ou une vidéo pour la voir ici", @@ -639,6 +651,7 @@ "Shared location": "Position partagée", "Shuffle": "Mélanger", "size limit": "limite de taille", + "Slash commands can't be scheduled": "Les commandes slash ne peuvent pas être programmées", "Slow Mode ON": "Mode lent activé", "Slow mode, wait {{ seconds }}s...": "Mode lent, attendez {{ seconds }} s...", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptés", @@ -669,6 +682,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} fils non lus", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} fils non lus", "Threads": "Fils", + "Time": "Heure", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Hier]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -682,6 +696,7 @@ "timestamp/relativeWeeksAgo": "il y a {{ count }} sem", "timestamp/relativeYesterday": "Hier", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", "To start recording, allow the microphone access in your browser": "Pour commencer l'enregistrement, autorisez l'accès au microphone dans votre navigateur", @@ -749,5 +764,9 @@ "Waiting for network…": "En attente du réseau…", "Working my magic": "Je fais ma magie", "You": "Vous", + "You can't schedule messages here": "Vous ne pouvez pas programmer de messages ici", + "You're offline. Reconnect to schedule this message.": "Vous êtes hors ligne. Reconnectez-vous pour programmer ce message.", + "You've reached the limit of scheduled messages": "Vous avez atteint la limite de messages programmés", + "You've reached the limit of scheduled messages in this channel": "Vous avez atteint la limite de messages programmés dans ce canal", "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 9ec9fa6dc6..c035226fe6 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -160,6 +160,7 @@ "aria/Open image shared by {{ name }}": "{{ name }} द्वारा साझा की गई छवि खोलें", "aria/Open Message Actions Menu": "संदेश क्रिया मेन्यू खोलें", "aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें", + "aria/Open schedule message menu": "संदेश शेड्यूल मेनू खोलें", "aria/Open Thread": "थ्रेड खोलें", "aria/Open threads view": "थ्रेड व्यू खोलें", "aria/Open threads view with unread threads_one": "थ्रेड व्यू खोलें, {{ count }} अपठित थ्रेड", @@ -260,6 +261,8 @@ "Channels": "चैनल", "Chat deleted": "Chat deleted", "Chats": "चैट", + "Choose a later time": "बाद का समय चुनें", + "Choose an earlier time": "पहले का समय चुनें", "Choose between 2 to 10 options": "2 से 10 विकल्प चुनें", "Close": "बंद करे", "Close dialog": "डायलॉग बंद करें", @@ -276,11 +279,14 @@ "Contact name": "संपर्क का नाम", "Cooking up something good": "कुछ बढ़िया पका रहा हूँ", "Copy Message": "संदेश कॉपी करें", + "Couldn't schedule message. Try again": "संदेश शेड्यूल नहीं हो सका। फिर से कोशिश करें", "Create": "बनाएँ", "Create a question, add options, and configure poll settings": "एक प्रश्न बनाएं, विकल्प जोड़ें और पोल सेटिंग्स कॉन्फ़िगर करें", "Create poll": "मतदान बनाएँ", "Crunching the numbers": "आँकड़ों का हिसाब लगा रहा हूँ", "Current location": "वर्तमान स्थान", + "Custom time": "कस्टम समय", + "Date": "तारीख", "Delete": "डिलीट", "Delete attachment": "अटैचमेंट हटाएँ", "Delete chat": "चैट हटाएं", @@ -475,6 +481,7 @@ "Message has been successfully flagged": "मैसेज को फ्लैग कर दिया गया है", "Message marked as unread": "संदेश को अपठित के रूप में चिह्नित किया गया", "Message pinned": "संदेश पिन किया गया", + "Message to {{ name }}": "{{ name }} को संदेश", "Message unpinned": "संदेश अनपिन किया गया", "Message was blocked by moderation policies": "संदेश को मॉडरेशन नीतियों द्वारा ब्लॉक कर दिया गया है", "Messages have been marked unread.": "संदेशों को अपठित चिह्नित किया गया है।", @@ -581,6 +588,10 @@ "Save": "सहेजें", "Save for later": "बाद के लिए सहेजें", "Saved for later": "बाद के लिए सहेजा गया", + "Schedule": "शेड्यूल करें", + "Schedule message": "संदेश शेड्यूल करें", + "Scheduled for {{ time }}": "{{ time }} के लिए शेड्यूल किया गया", + "Scheduled messages are turned off": "शेड्यूल किए गए संदेश बंद हैं", "Search": "खोज", "Search GIFs": "GIF खोजें", "search-results-header-filter-source-button-label--channels": "चैनल्स", @@ -606,6 +617,7 @@ "Send poll": "पोल भेजें", "Sending...": "भेजा जा रहा है", "Sent": "भेजा गया", + "Sent in your time zone ({{ timeZone }})": "आपके समय क्षेत्र में भेजा जाएगा ({{ timeZone }})", "Share": "साझा करें", "Share a file to see it here": "इसे यहाँ देखने के लिए एक फ़ाइल साझा करें", "Share a photo or video to see it here": "इसे यहाँ देखने के लिए एक फ़ोटो या वीडियो साझा करें", @@ -615,6 +627,7 @@ "Shared location": "साझा स्थान", "Shuffle": "मिश्रित करें", "size limit": "आकार सीमा", + "Slash commands can't be scheduled": "स्लैश कमांड शेड्यूल नहीं किए जा सकते", "Slow Mode ON": "स्लो मोड ऑन", "Slow mode, wait {{ seconds }}s...": "स्लो मोड, {{ seconds }} सेकंड प्रतीक्षा करें...", "Some of the files will not be accepted": "कुछ फ़ाइलें स्वीकार नहीं की जाएंगी", @@ -644,6 +657,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} अपठित थ्रेड", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} अपठित थ्रेड", "Threads": "थ्रेड्स", + "Time": "समय", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[बीता कल]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -657,6 +671,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} सप्ताह पहले", "timestamp/relativeYesterday": "बीता कल", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", "To start recording, allow the microphone access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में माइक्रोफ़ोन तक पहुँच दें", @@ -720,5 +735,9 @@ "Waiting for network…": "नेटवर्क की प्रतीक्षा…", "Working my magic": "अपना जादू चला रहा हूँ", "You": "आप", + "You can't schedule messages here": "आप यहां संदेश शेड्यूल नहीं कर सकते", + "You're offline. Reconnect to schedule this message.": "आप ऑफ़लाइन हैं। इस संदेश को शेड्यूल करने के लिए फिर से कनेक्ट करें।", + "You've reached the limit of scheduled messages": "आप शेड्यूल किए गए संदेशों की सीमा तक पहुंच गए हैं", + "You've reached the limit of scheduled messages in this channel": "आप इस चैनल में शेड्यूल किए गए संदेशों की सीमा तक पहुंच गए हैं", "You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं" } diff --git a/src/i18n/it.json b/src/i18n/it.json index b170b818e7..34b649ddda 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -176,6 +176,7 @@ "aria/Open image shared by {{ name }}": "Apri l'immagine condivisa da {{ name }}", "aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio", "aria/Open Reaction Selector": "Apri il selettore di reazione", + "aria/Open schedule message menu": "Apri il menu di programmazione dei messaggi", "aria/Open Thread": "Apri discussione", "aria/Open threads view": "Apri visualizzazione discussioni", "aria/Open threads view with unread threads_one": "Apri visualizzazione discussioni, {{ count }} discussione non letta", @@ -277,6 +278,8 @@ "Channels": "Canali", "Chat deleted": "Chat deleted", "Chats": "Chat", + "Choose a later time": "Scegli un orario successivo", + "Choose an earlier time": "Scegli un orario precedente", "Choose between 2 to 10 options": "Scegli tra 2 e 10 opzioni", "Close": "Chiudi", "Close dialog": "Chiudi finestra di dialogo", @@ -293,11 +296,14 @@ "Contact name": "Nome del contatto", "Cooking up something good": "Sto cucinando qualcosa di buono", "Copy Message": "Copia messaggio", + "Couldn't schedule message. Try again": "Impossibile programmare il messaggio. Riprova", "Create": "Crea", "Create a question, add options, and configure poll settings": "Crea una domanda, aggiungi opzioni e configura le impostazioni del sondaggio", "Create poll": "Crea sondaggio", "Crunching the numbers": "Sto elaborando i numeri", "Current location": "Posizione attuale", + "Custom time": "Orario personalizzato", + "Date": "Data", "Delete": "Elimina", "Delete attachment": "Elimina allegato", "Delete chat": "Elimina chat", @@ -494,6 +500,7 @@ "Message has been successfully flagged": "Il messaggio è stato segnalato con successo", "Message marked as unread": "Messaggio contrassegnato come non letto", "Message pinned": "Messaggio bloccato", + "Message to {{ name }}": "Messaggio a {{ name }}", "Message unpinned": "Messaggio rimosso dai fissati", "Message was blocked by moderation policies": "Il messaggio è stato bloccato dalle politiche di moderazione", "Messages have been marked unread.": "I messaggi sono stati contrassegnati come non letti.", @@ -603,6 +610,10 @@ "Save": "Salva", "Save for later": "Salva per dopo", "Saved for later": "Salvato per dopo", + "Schedule": "Programma", + "Schedule message": "Programma messaggio", + "Scheduled for {{ time }}": "Programmato per {{ time }}", + "Scheduled messages are turned off": "I messaggi programmati sono disattivati", "Search": "Cerca", "Search GIFs": "Cerca GIF", "search-results-header-filter-source-button-label--channels": "canali", @@ -630,6 +641,7 @@ "Send poll": "Invia sondaggio", "Sending...": "Invio in corso...", "Sent": "Inviato", + "Sent in your time zone ({{ timeZone }})": "Inviato nel tuo fuso orario ({{ timeZone }})", "Share": "Condividi", "Share a file to see it here": "Condividi un file per vederlo qui", "Share a photo or video to see it here": "Condividi una foto o un video per vederlo qui", @@ -639,6 +651,7 @@ "Shared location": "Posizione condivisa", "Shuffle": "Mescolare", "size limit": "limite di dimensione", + "Slash commands can't be scheduled": "I comandi slash non possono essere programmati", "Slow Mode ON": "Modalità lenta attivata", "Slow mode, wait {{ seconds }}s...": "Modalità lenta, attendi {{ seconds }} s...", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", @@ -669,6 +682,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} thread non letti", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} thread non letti", "Threads": "Thread", + "Time": "Ora", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ieri]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -682,6 +696,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} sett fa", "timestamp/relativeYesterday": "Ieri", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", "To start recording, allow the microphone access in your browser": "Per iniziare a registrare, consenti l'accesso al microfono nel tuo browser", @@ -749,5 +764,9 @@ "Waiting for network…": "In attesa della rete…", "Working my magic": "Faccio la mia magia", "You": "Tu", + "You can't schedule messages here": "Non puoi programmare messaggi qui", + "You're offline. Reconnect to schedule this message.": "Sei offline. Riconnettiti per programmare questo messaggio.", + "You've reached the limit of scheduled messages": "Hai raggiunto il limite di messaggi programmati", + "You've reached the limit of scheduled messages in this channel": "Hai raggiunto il limite di messaggi programmati in questo canale", "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index fe6db97016..419227e728 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -151,6 +151,7 @@ "aria/Open image shared by {{ name }}": "{{ name }}が共有した画像を開く", "aria/Open Message Actions Menu": "メッセージアクションメニューを開く", "aria/Open Reaction Selector": "リアクションセレクターを開く", + "aria/Open schedule message menu": "メッセージの予約メニューを開く", "aria/Open Thread": "スレッドを開く", "aria/Open threads view": "スレッドビューを開く", "aria/Open threads view with unread threads_one": "スレッドビューを開く、未読スレッド{{ count }}件", @@ -251,6 +252,8 @@ "Channels": "チャンネル", "Chat deleted": "Chat deleted", "Chats": "チャット", + "Choose a later time": "もっと後の時刻を選択してください", + "Choose an earlier time": "もっと前の時刻を選択してください", "Choose between 2 to 10 options": "2〜10の選択肢から選ぶ", "Close": "閉める", "Close dialog": "ダイアログを閉じる", @@ -267,11 +270,14 @@ "Contact name": "連絡先名", "Cooking up something good": "いいものを作っています", "Copy Message": "メッセージをコピー", + "Couldn't schedule message. Try again": "メッセージを予約できませんでした。もう一度お試しください", "Create": "作成", "Create a question, add options, and configure poll settings": "質問を作成し、選択肢を追加して投票設定を構成", "Create poll": "投票を作成", "Crunching the numbers": "数字を計算しています", "Current location": "現在の位置", + "Custom time": "カスタム時刻", + "Date": "日付", "Delete": "消去", "Delete attachment": "添付ファイルを削除", "Delete chat": "チャットを削除", @@ -462,6 +468,7 @@ "Message has been successfully flagged": "メッセージに正常にフラグが付けられました", "Message marked as unread": "メッセージを未読にしました", "Message pinned": "メッセージにピンが付けられました", + "Message to {{ name }}": "{{ name }}へのメッセージ", "Message unpinned": "メッセージのピン留めを解除しました", "Message was blocked by moderation policies": "メッセージはモデレーションポリシーによってブロックされました", "Messages have been marked unread.": "メッセージは未読としてマークされました。", @@ -566,6 +573,10 @@ "Save": "保存", "Save for later": "後で保存", "Saved for later": "後で保存済み", + "Schedule": "予約", + "Schedule message": "メッセージを予約", + "Scheduled for {{ time }}": "{{ time }}に予約済み", + "Scheduled messages are turned off": "予約メッセージはオフになっています", "Search": "探す", "Search GIFs": "GIFを検索", "search-results-header-filter-source-button-label--channels": "チャンネル", @@ -591,6 +602,7 @@ "Send poll": "アンケートを送信", "Sending...": "送信中...", "Sent": "送信済み", + "Sent in your time zone ({{ timeZone }})": "あなたのタイムゾーンで送信されます({{ timeZone }})", "Share": "共有", "Share a file to see it here": "ファイルを共有するとここに表示されます", "Share a photo or video to see it here": "写真や動画を共有するとここに表示されます", @@ -600,6 +612,7 @@ "Shared location": "共有された位置情報", "Shuffle": "シャッフル", "size limit": "サイズ制限", + "Slash commands can't be scheduled": "スラッシュコマンドは予約できません", "Slow Mode ON": "スローモードオン", "Slow mode, wait {{ seconds }}s...": "スローモード、{{ seconds }}秒お待ちください...", "Some of the files will not be accepted": "一部のファイルは受け付けられません", @@ -628,6 +641,7 @@ "ThreadListUnseenThreadsBanner/loading": "読み込み中...", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }}件の未読スレッド", "Threads": "スレッド", + "Time": "時刻", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[昨日]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -641,6 +655,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}週間前", "timestamp/relativeYesterday": "昨日", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", "To start recording, allow the microphone access in your browser": "録音を開始するには、ブラウザーでマイクロフォンへのアクセスを許可してください", @@ -701,5 +716,9 @@ "Waiting for network…": "ネットワークを待機中…", "Working my magic": "魔法をかけています", "You": "あなた", + "You can't schedule messages here": "ここではメッセージを予約できません", + "You're offline. Reconnect to schedule this message.": "オフラインです。このメッセージを予約するには再接続してください。", + "You've reached the limit of scheduled messages": "予約メッセージの上限に達しました", + "You've reached the limit of scheduled messages in this channel": "このチャンネルの予約メッセージの上限に達しました", "You've reached the maximum number of files": "ファイルの最大数に達しました" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 57017eff12..2b68df8ff0 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -151,6 +151,7 @@ "aria/Open image shared by {{ name }}": "{{ name }}님이 공유한 이미지 열기", "aria/Open Message Actions Menu": "메시지 액션 메뉴 열기", "aria/Open Reaction Selector": "반응 선택기 열기", + "aria/Open schedule message menu": "메시지 예약 메뉴 열기", "aria/Open Thread": "스레드 열기", "aria/Open threads view": "스레드 보기 열기", "aria/Open threads view with unread threads_one": "스레드 보기 열기, 읽지 않은 스레드 {{ count }}개", @@ -251,6 +252,8 @@ "Channels": "채널", "Chat deleted": "Chat deleted", "Chats": "채팅", + "Choose a later time": "더 늦은 시간을 선택하세요", + "Choose an earlier time": "더 이른 시간을 선택하세요", "Choose between 2 to 10 options": "2~10개의 선택지 중에서 선택", "Close": "닫기", "Close dialog": "대화 상자 닫기", @@ -267,11 +270,14 @@ "Contact name": "연락처 이름", "Cooking up something good": "맛있는 답변을 요리하는 중", "Copy Message": "메시지 복사", + "Couldn't schedule message. Try again": "메시지를 예약하지 못했습니다. 다시 시도하세요", "Create": "생성", "Create a question, add options, and configure poll settings": "질문을 만들고 옵션을 추가한 뒤 투표 설정 구성", "Create poll": "투표 생성", "Crunching the numbers": "숫자를 계산하는 중", "Current location": "현재 위치", + "Custom time": "사용자 지정 시간", + "Date": "날짜", "Delete": "삭제", "Delete attachment": "첨부 파일 삭제", "Delete chat": "채팅 삭제", @@ -462,6 +468,7 @@ "Message has been successfully flagged": "메시지에 플래그가 지정되었습니다.", "Message marked as unread": "메시지를 읽지 않음으로 표시했습니다", "Message pinned": "메시지 핀했습니다", + "Message to {{ name }}": "{{ name }}님에게 보낼 메시지", "Message unpinned": "메시지 고정이 해제됨", "Message was blocked by moderation policies": "메시지가 관리 정책에 의해 차단되었습니다.", "Messages have been marked unread.": "메시지가 읽지 않음으로 표시되었습니다.", @@ -566,6 +573,10 @@ "Save": "저장", "Save for later": "나중에 저장", "Saved for later": "나중에 저장됨", + "Schedule": "예약", + "Schedule message": "메시지 예약", + "Scheduled for {{ time }}": "{{ time }}에 예약됨", + "Scheduled messages are turned off": "예약 메시지가 꺼져 있습니다", "Search": "찾다", "Search GIFs": "GIF 검색", "search-results-header-filter-source-button-label--channels": "채널", @@ -591,6 +602,7 @@ "Send poll": "투표 보내기", "Sending...": "배상중...", "Sent": "전송됨", + "Sent in your time zone ({{ timeZone }})": "내 시간대 기준으로 전송됩니다 ({{ timeZone }})", "Share": "공유", "Share a file to see it here": "파일을 공유하면 여기에 표시됩니다", "Share a photo or video to see it here": "사진이나 동영상을 공유하면 여기에 표시됩니다", @@ -600,6 +612,7 @@ "Shared location": "공유된 위치", "Shuffle": "셔플", "size limit": "크기 제한", + "Slash commands can't be scheduled": "슬래시 명령어는 예약할 수 없습니다", "Slow Mode ON": "슬로우 모드 켜짐", "Slow mode, wait {{ seconds }}s...": "슬로우 모드, {{ seconds }}초 기다려 주세요...", "Some of the files will not be accepted": "일부 파일은 허용되지 않을 수 있습니다", @@ -628,6 +641,7 @@ "ThreadListUnseenThreadsBanner/loading": "로딩 중...", "ThreadListUnseenThreadsBanner/unreadThreads_other": "읽지 않은 스레드 {{ count }}개", "Threads": "스레드", + "Time": "시간", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[어제]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -641,6 +655,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}주 전", "timestamp/relativeYesterday": "어제", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", "To start recording, allow the microphone access in your browser": "브라우저에서 마이크로폰 액세스를 허용하여 녹음을 시작합니다", @@ -701,5 +716,9 @@ "Waiting for network…": "네트워크 대기 중…", "Working my magic": "마법을 부리는 중", "You": "당신", + "You can't schedule messages here": "여기에서는 메시지를 예약할 수 없습니다", + "You're offline. Reconnect to schedule this message.": "오프라인 상태입니다. 이 메시지를 예약하려면 다시 연결하세요.", + "You've reached the limit of scheduled messages": "예약 메시지 한도에 도달했습니다", + "You've reached the limit of scheduled messages in this channel": "이 채널의 예약 메시지 한도에 도달했습니다", "You've reached the maximum number of files": "최대 파일 수에 도달했습니다." } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index e1db9c66b3..3c5121f0b9 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -160,6 +160,7 @@ "aria/Open image shared by {{ name }}": "Door {{ name }} gedeelde afbeelding openen", "aria/Open Message Actions Menu": "Menu voor berichtacties openen", "aria/Open Reaction Selector": "Reactiekiezer openen", + "aria/Open schedule message menu": "Menu voor het inplannen van berichten openen", "aria/Open Thread": "Draad openen", "aria/Open threads view": "Threadweergave openen", "aria/Open threads view with unread threads_one": "Threadweergave openen, {{ count }} ongelezen thread", @@ -260,6 +261,8 @@ "Channels": "Kanalen", "Chat deleted": "Chat deleted", "Chats": "Chats", + "Choose a later time": "Kies een later tijdstip", + "Choose an earlier time": "Kies een eerder tijdstip", "Choose between 2 to 10 options": "Kies tussen 2 en 10 opties", "Close": "Sluit", "Close dialog": "Dialoog sluiten", @@ -276,11 +279,14 @@ "Contact name": "Contactnaam", "Cooking up something good": "Ik kook iets lekkers", "Copy Message": "Bericht kopiëren", + "Couldn't schedule message. Try again": "Bericht kon niet worden ingepland. Probeer het opnieuw", "Create": "Maak", "Create a question, add options, and configure poll settings": "Maak een vraag, voeg opties toe en stel de pollinstellingen in", "Create poll": "Maak peiling", "Crunching the numbers": "Ik kraak de cijfers", "Current location": "Huidige locatie", + "Custom time": "Aangepaste tijd", + "Date": "Datum", "Delete": "Verwijder", "Delete attachment": "Bijlage verwijderen", "Delete chat": "Chat verwijderen", @@ -474,6 +480,7 @@ "Message has been successfully flagged": "Bericht is succesvol gemarkeerd", "Message marked as unread": "Bericht gemarkeerd als ongelezen", "Message pinned": "Bericht vastgezet", + "Message to {{ name }}": "Bericht aan {{ name }}", "Message unpinned": "Bericht losgemaakt", "Message was blocked by moderation policies": "Bericht is geblokkeerd door moderatiebeleid", "Messages have been marked unread.": "Berichten zijn gemarkeerd als ongelezen.", @@ -580,6 +587,10 @@ "Save": "Opslaan", "Save for later": "Bewaren voor later", "Saved for later": "Bewaard voor later", + "Schedule": "Inplannen", + "Schedule message": "Bericht inplannen", + "Scheduled for {{ time }}": "Ingepland voor {{ time }}", + "Scheduled messages are turned off": "Ingeplande berichten zijn uitgeschakeld", "Search": "Zoeken", "Search GIFs": "GIF's zoeken", "search-results-header-filter-source-button-label--channels": "kanalen", @@ -605,6 +616,7 @@ "Send poll": "Peiling versturen", "Sending...": "Aan het verzenden...", "Sent": "Verzonden", + "Sent in your time zone ({{ timeZone }})": "Verzonden in je tijdzone ({{ timeZone }})", "Share": "Delen", "Share a file to see it here": "Deel een bestand om het hier te zien", "Share a photo or video to see it here": "Deel een foto of video om deze hier te zien", @@ -614,6 +626,7 @@ "Shared location": "Gedeelde locatie", "Shuffle": "Schudden", "size limit": "grootte limiet", + "Slash commands can't be scheduled": "Slash-commando's kunnen niet worden ingepland", "Slow Mode ON": "Langzame modus aan", "Slow mode, wait {{ seconds }}s...": "Langzame modus, wacht {{ seconds }}s...", "Slow wait, wait {{ seconds }}s": "Langzame modus, wacht {{ seconds }}s", @@ -645,6 +658,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} ongelezen thread", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} ongelezen threads", "Threads": "Discussies", + "Time": "Tijd", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Gisteren]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -658,6 +672,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} w geleden", "timestamp/relativeYesterday": "Gisteren", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", "To start recording, allow the microphone access in your browser": "Om te beginnen met opnemen, sta toegang tot de microfoon toe in uw browser", @@ -721,5 +736,9 @@ "Waiting for network…": "Wachten op netwerk…", "Working my magic": "Ik doe mijn toverkunsten", "You": "Jij", + "You can't schedule messages here": "Je kunt hier geen berichten inplannen", + "You're offline. Reconnect to schedule this message.": "Je bent offline. Maak opnieuw verbinding om dit bericht in te plannen.", + "You've reached the limit of scheduled messages": "Je hebt de limiet voor ingeplande berichten bereikt", + "You've reached the limit of scheduled messages in this channel": "Je hebt de limiet voor ingeplande berichten in dit kanaal bereikt", "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index b0e5c31925..17d211a423 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -176,6 +176,7 @@ "aria/Open image shared by {{ name }}": "Abrir imagem compartilhada por {{ name }}", "aria/Open Message Actions Menu": "Abrir menu de ações de mensagem", "aria/Open Reaction Selector": "Abrir seletor de reações", + "aria/Open schedule message menu": "Abrir menu de agendamento de mensagens", "aria/Open Thread": "Abrir tópico", "aria/Open threads view": "Abrir visualização de tópicos", "aria/Open threads view with unread threads_one": "Abrir visualização de tópicos, {{ count }} tópico não lido", @@ -277,6 +278,8 @@ "Channels": "Canais", "Chat deleted": "Chat deleted", "Chats": "Conversas", + "Choose a later time": "Escolha um horário posterior", + "Choose an earlier time": "Escolha um horário anterior", "Choose between 2 to 10 options": "Escolha entre 2 a 10 opções", "Close": "Fechar", "Close dialog": "Fechar diálogo", @@ -293,11 +296,14 @@ "Contact name": "Nome do contato", "Cooking up something good": "Cozinhando algo bom", "Copy Message": "Copiar mensagem", + "Couldn't schedule message. Try again": "Não foi possível agendar a mensagem. Tente novamente", "Create": "Criar", "Create a question, add options, and configure poll settings": "Crie uma pergunta, adicione opções e configure as definições da enquete", "Create poll": "Criar enquete", "Crunching the numbers": "Processando os números", "Current location": "Localização atual", + "Custom time": "Horário personalizado", + "Date": "Data", "Delete": "Excluir", "Delete attachment": "Excluir anexo", "Delete chat": "Excluir chat", @@ -494,6 +500,7 @@ "Message has been successfully flagged": "A mensagem foi reportada com sucesso", "Message marked as unread": "Mensagem marcada como não lida", "Message pinned": "Mensagem fixada", + "Message to {{ name }}": "Mensagem para {{ name }}", "Message unpinned": "Mensagem desafixada", "Message was blocked by moderation policies": "A mensagem foi bloqueada pelas políticas de moderação", "Messages have been marked unread.": "Mensagens foram marcadas como não lidas.", @@ -603,6 +610,10 @@ "Save": "Salvar", "Save for later": "Salvar para depois", "Saved for later": "Salvo para depois", + "Schedule": "Agendar", + "Schedule message": "Agendar mensagem", + "Scheduled for {{ time }}": "Agendada para {{ time }}", + "Scheduled messages are turned off": "As mensagens agendadas estão desativadas", "Search": "Buscar", "Search GIFs": "Pesquisar GIFs", "search-results-header-filter-source-button-label--channels": "canais", @@ -630,6 +641,7 @@ "Send poll": "Enviar enquete", "Sending...": "Enviando...", "Sent": "Enviado", + "Sent in your time zone ({{ timeZone }})": "Enviada no seu fuso horário ({{ timeZone }})", "Share": "Compartilhar", "Share a file to see it here": "Compartilhe um arquivo para vê-lo aqui", "Share a photo or video to see it here": "Compartilhe uma foto ou vídeo para vê-lo aqui", @@ -639,6 +651,7 @@ "Shared location": "Localização compartilhada", "Shuffle": "Embaralhar", "size limit": "limite de tamanho", + "Slash commands can't be scheduled": "Comandos de barra não podem ser agendados", "Slow Mode ON": "Modo lento LIGADO", "Slow mode, wait {{ seconds }}s...": "Modo lento, aguarde {{ seconds }} s...", "Some of the files will not be accepted": "Alguns arquivos não serão aceitos", @@ -669,6 +682,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} tópicos não lidos", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} tópicos não lidos", "Threads": "Tópicos", + "Time": "Hora", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Ontem]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -682,6 +696,7 @@ "timestamp/relativeWeeksAgo": "há {{ count }} sem", "timestamp/relativeYesterday": "Ontem", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", "To start recording, allow the microphone access in your browser": "Para começar a gravar, permita o acesso ao microfone no seu navegador", @@ -749,5 +764,9 @@ "Waiting for network…": "Aguardando rede…", "Working my magic": "Fazendo minha mágica", "You": "Você", + "You can't schedule messages here": "Você não pode agendar mensagens aqui", + "You're offline. Reconnect to schedule this message.": "Você está offline. Reconecte-se para agendar esta mensagem.", + "You've reached the limit of scheduled messages": "Você atingiu o limite de mensagens agendadas", + "You've reached the limit of scheduled messages in this channel": "Você atingiu o limite de mensagens agendadas neste canal", "You've reached the maximum number of files": "Você atingiu o número máximo de arquivos" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index bdb9f115e0..eb7b0c9969 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -193,6 +193,7 @@ "aria/Open image shared by {{ name }}": "Открыть изображение от {{ name }}", "aria/Open Message Actions Menu": "Открыть меню действий с сообщениями", "aria/Open Reaction Selector": "Открыть селектор реакций", + "aria/Open schedule message menu": "Открыть меню планирования сообщений", "aria/Open Thread": "Открыть тему", "aria/Open threads view": "Открыть вид веток", "aria/Open threads view with unread threads_one": "Открыть вид веток, {{ count }} непрочитанная ветка", @@ -295,6 +296,8 @@ "Channels": "Каналы", "Chat deleted": "Chat deleted", "Chats": "Чаты", + "Choose a later time": "Выберите более позднее время", + "Choose an earlier time": "Выберите более раннее время", "Choose between 2 to 10 options": "Выберите от 2 до 10 вариантов", "Close": "Закрыть", "Close dialog": "Закрыть диалог", @@ -311,11 +314,14 @@ "Contact name": "Имя контакта", "Cooking up something good": "Готовлю что-нибудь вкусное", "Copy Message": "Копировать сообщение", + "Couldn't schedule message. Try again": "Не удалось запланировать сообщение. Попробуйте ещё раз", "Create": "Создать", "Create a question, add options, and configure poll settings": "Создайте вопрос, добавьте варианты и настройте параметры опроса", "Create poll": "Создать опрос", "Crunching the numbers": "Перемалываю числа", "Current location": "Текущее местоположение", + "Custom time": "Своё время", + "Date": "Дата", "Delete": "Удалить", "Delete attachment": "Удалить вложение", "Delete chat": "Удалить чат", @@ -518,6 +524,7 @@ "Message has been successfully flagged": "Жалоба на сообщение была принята", "Message marked as unread": "Сообщение помечено как непрочитанное", "Message pinned": "Сообщение закреплено", + "Message to {{ name }}": "Сообщение для {{ name }}", "Message unpinned": "Сообщение откреплено", "Message was blocked by moderation policies": "Сообщение было заблокировано модерацией", "Messages have been marked unread.": "Сообщения были отмечены как непрочитанные.", @@ -630,6 +637,10 @@ "Save": "Сохранить", "Save for later": "Сохранить на потом", "Saved for later": "Сохранено на потом", + "Schedule": "Запланировать", + "Schedule message": "Запланировать сообщение", + "Scheduled for {{ time }}": "Запланировано на {{ time }}", + "Scheduled messages are turned off": "Запланированные сообщения отключены", "Search": "Поиск", "Search GIFs": "Поиск GIF", "search-results-header-filter-source-button-label--channels": "каналы", @@ -659,6 +670,7 @@ "Send poll": "Отправить опрос", "Sending...": "Отправка...", "Sent": "Отправлено", + "Sent in your time zone ({{ timeZone }})": "Будет отправлено в вашем часовом поясе ({{ timeZone }})", "Share": "Поделиться", "Share a file to see it here": "Поделитесь файлом, чтобы увидеть его здесь", "Share a photo or video to see it here": "Поделитесь фото или видео, чтобы увидеть их здесь", @@ -668,6 +680,7 @@ "Shared location": "Общее местоположение", "Shuffle": "Перемешать", "size limit": "ограничение размера", + "Slash commands can't be scheduled": "Слэш-команды нельзя запланировать", "Slow Mode ON": "Медленный режим включен", "Slow mode, wait {{ seconds }}s...": "Медленный режим: подождите {{ seconds }} с...", "Some of the files will not be accepted": "Некоторые файлы не будут приняты", @@ -699,6 +712,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_many": "{{ count }} непрочитанных веток", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} непрочитанных веток", "Threads": "Треды", + "Time": "Время", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Вчера]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -712,6 +726,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} нед. назад", "timestamp/relativeYesterday": "Вчера", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", "To start recording, allow the microphone access in your browser": "Для начала записи разрешите доступ к микрофону в вашем браузере", @@ -783,5 +798,9 @@ "Waiting for network…": "Ожидание сети…", "Working my magic": "Колдую", "You": "Вы", + "You can't schedule messages here": "Здесь нельзя планировать сообщения", + "You're offline. Reconnect to schedule this message.": "Вы не в сети. Подключитесь снова, чтобы запланировать это сообщение.", + "You've reached the limit of scheduled messages": "Вы достигли лимита запланированных сообщений", + "You've reached the limit of scheduled messages in this channel": "Вы достигли лимита запланированных сообщений в этом канале", "You've reached the maximum number of files": "Вы достигли максимального количества файлов" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index c947168176..1832f45518 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -160,6 +160,7 @@ "aria/Open image shared by {{ name }}": "{{ name }} tarafından paylaşılan resmi aç", "aria/Open Message Actions Menu": "Mesaj İşlemleri Menüsünü Aç", "aria/Open Reaction Selector": "Tepki Seçiciyi Aç", + "aria/Open schedule message menu": "Mesaj zamanlama menüsünü aç", "aria/Open Thread": "Konuyu Aç", "aria/Open threads view": "Konu görünümünü aç", "aria/Open threads view with unread threads_one": "Konu görünümünü aç, {{ count }} okunmamış konu", @@ -260,6 +261,8 @@ "Channels": "Kanallar", "Chat deleted": "Chat deleted", "Chats": "Sohbetler", + "Choose a later time": "Daha geç bir saat seçin", + "Choose an earlier time": "Daha erken bir saat seçin", "Choose between 2 to 10 options": "2 ile 10 seçenek arasından seçin", "Close": "Kapat", "Close dialog": "İletişim kutusunu kapat", @@ -276,11 +279,14 @@ "Contact name": "Kişi adı", "Cooking up something good": "Güzel bir şeyler pişiriyorum", "Copy Message": "Mesajı kopyala", + "Couldn't schedule message. Try again": "Mesaj zamanlanamadı. Tekrar deneyin", "Create": "Oluştur", "Create a question, add options, and configure poll settings": "Bir soru oluşturun, seçenekler ekleyin ve anket ayarlarını yapılandırın", "Create poll": "Anket oluştur", "Crunching the numbers": "Sayıları hesaplıyorum", "Current location": "Mevcut konum", + "Custom time": "Özel saat", + "Date": "Tarih", "Delete": "Sil", "Delete attachment": "Eki sil", "Delete chat": "Sohbeti sil", @@ -474,6 +480,7 @@ "Message has been successfully flagged": "Mesaj başarıyla bayraklandı", "Message marked as unread": "Mesaj okunmadı olarak işaretlendi", "Message pinned": "Mesaj sabitlendi", + "Message to {{ name }}": "{{ name }} için mesaj", "Message unpinned": "Mesaj sabitlemesi kaldırıldı", "Message was blocked by moderation policies": "Mesaj moderasyon politikaları tarafından engellendi", "Messages have been marked unread.": "Mesajlar okunmamış olarak işaretlendi.", @@ -580,6 +587,10 @@ "Save": "Kaydet", "Save for later": "Daha sonra kaydet", "Saved for later": "Daha sonra kaydedildi", + "Schedule": "Zamanla", + "Schedule message": "Mesajı zamanla", + "Scheduled for {{ time }}": "{{ time }} için zamanlandı", + "Scheduled messages are turned off": "Zamanlanmış mesajlar kapalı", "Search": "Arama", "Search GIFs": "GIF ara", "search-results-header-filter-source-button-label--channels": "kanallar", @@ -605,6 +616,7 @@ "Send poll": "Anketi gönder", "Sending...": "Gönderiliyor...", "Sent": "Gönderildi", + "Sent in your time zone ({{ timeZone }})": "Saat diliminizde gönderilecek ({{ timeZone }})", "Share": "Paylaş", "Share a file to see it here": "Burada görmek için bir dosya paylaşın", "Share a photo or video to see it here": "Burada görmek için bir fotoğraf veya video paylaşın", @@ -614,6 +626,7 @@ "Shared location": "Paylaşılan konum", "Shuffle": "Karıştır", "size limit": "boyut sınırı", + "Slash commands can't be scheduled": "Eğik çizgi komutları zamanlanamaz", "Slow Mode ON": "Yavaş Mod Açık", "Slow mode, wait {{ seconds }}s...": "Yavaş mod, {{ seconds }} sn bekleyin...", "Some of the files will not be accepted": "Bazı dosyalar kabul edilmeyecek", @@ -643,6 +656,7 @@ "ThreadListUnseenThreadsBanner/unreadThreads_one": "{{ count }} okunmamış ileti dizisi", "ThreadListUnseenThreadsBanner/unreadThreads_other": "{{ count }} okunmamış ileti dizisi", "Threads": "İleti dizileri", + "Time": "Saat", "timestamp/ChannelDetailPinnedMessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Yesterday]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", "timestamp/ChannelMembersLastActive": "{{ timestamp | timestampFormatter(relativeCompact: true) }}", "timestamp/ChannelPreviewTimestamp": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"LT\", \"lastDay\": \"[Dün]\", \"lastWeek\": \"dddd\", \"sameElse\": \"L\" }) }}", @@ -656,6 +670,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} hf önce", "timestamp/relativeYesterday": "Dün", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", + "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin", "To start recording, allow the microphone access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda mikrofona erişime izin verin", @@ -719,5 +734,9 @@ "Waiting for network…": "Ağ bekleniyor…", "Working my magic": "Büyümü yapıyorum", "You": "Sen", + "You can't schedule messages here": "Burada mesaj zamanlayamazsınız", + "You're offline. Reconnect to schedule this message.": "Çevrimdışısınız. Bu mesajı zamanlamak için yeniden bağlanın.", + "You've reached the limit of scheduled messages": "Zamanlanmış mesaj sınırına ulaştınız", + "You've reached the limit of scheduled messages in this channel": "Bu kanaldaki zamanlanmış mesaj sınırına ulaştınız", "You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız" } From 79312fd4fced30a76ce1adfa6461f4df00ec2413 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:34 +0100 Subject: [PATCH 06/14] feat(MessageComposer): schedule messages from a split send button Render the ScheduleMessageButton next to the send button when the user can schedule the current composition (schedule-message capability, feature enabled, no slash command, poll, location or sent-message edit). Otherwise the send button renders exactly as before, and Enter and Send keep sending immediately. Refs: REACT-1387 --- .../MessageComposerActions.tsx | 21 +- .../__tests__/ScheduleMessageButton.test.tsx | 383 ++++++++++++++++++ 2 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx diff --git a/src/components/MessageComposer/MessageComposerActions.tsx b/src/components/MessageComposer/MessageComposerActions.tsx index b5632623ad..207b3b44d4 100644 --- a/src/components/MessageComposer/MessageComposerActions.tsx +++ b/src/components/MessageComposer/MessageComposerActions.tsx @@ -15,6 +15,8 @@ import { useIsCooldownActive } from './hooks/useIsCooldownActive'; import type { MessageComposerState, TextComposerState } from 'stream-chat'; import { useStateStore } from '../../store'; import { useInertWhenHidden } from '../Accessibility'; +import { useCanScheduleMessage } from '../ScheduledMessages/hooks/useCanScheduleMessage'; +import { ScheduleMessageButton as DefaultScheduleMessageButton } from '../ScheduledMessages/ScheduleMessageButton'; const messageComposerStateSelector = ({ editedMessage }: MessageComposerState) => ({ editedMessage, @@ -33,9 +35,11 @@ export const MessageComposerActions = () => { const messageComposer = useMessageComposerController(); const { CooldownTimer = DefaultCooldownTimer, + ScheduleMessageButton: ScheduleMessageButtonOverride, SendButton, StopAIGenerationButton: StopAIGenerationButtonOverride, } = useComponentContext(); + const canScheduleMessage = useCanScheduleMessage(); const { editedMessage } = useStateStore( messageComposer.state, @@ -70,7 +74,12 @@ export const MessageComposerActions = () => { const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 - let content = SendButton ? ( + const ScheduleMessageButton = + ScheduleMessageButtonOverride === undefined + ? DefaultScheduleMessageButton + : ScheduleMessageButtonOverride; + + const sendButton = SendButton ? ( ) : ( @@ -78,6 +87,16 @@ export const MessageComposerActions = () => { ); + let content = + canScheduleMessage && ScheduleMessageButton ? ( +
+ {sendButton} + +
+ ) : ( + sendButton + ); + if (shouldDisplayStopAIGeneration) { content = ; } else if (hideSendButton) return null; diff --git a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx new file mode 100644 index 0000000000..c1b2073aeb --- /dev/null +++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx @@ -0,0 +1,383 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { ScheduledMessageErrorReason, SearchController } from 'stream-chat'; +import type { Channel as ChannelType, StreamChat } from 'stream-chat'; + +import { axe } from '../../../../axe-helper'; +import { Channel } from '../../Channel'; +import { DialogManagerProvider, TranslationProvider } from '../../../context'; +import { Streami18n } from '../../../i18n'; +import { type ChatContextValue, ChatProvider } from '../../../context/ChatContext'; +import { WithComponents } from '../../../context/WithComponents'; +import { MessageComposer } from '../../MessageComposer'; +import { + dispatchConnectionChangedEvent, + erroredScheduledMessageApi, + generateChannel, + generateScheduledMessage, + generateUser, + initClientWithChannels, + sendScheduledMessageApi, + useMockedApis, +} from '../../../mock-builders'; + +const user = generateUser({ id: 'user-id' }); + +const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30); + +const channelDataWith = (ownCapabilities: string[]) => + generateChannel({ + channel: { + id: 'general', + own_capabilities: ownCapabilities, + type: 'messaging', + }, + }); + +type SetupOptions = { + canSchedule?: boolean; + components?: Parameters[0]['overrides']; + text?: string; +}; + +const renderComposer = async ({ + canSchedule = true, + components = {}, + text = 'Launch moved to Monday', +}: SetupOptions = {}) => { + const ownCapabilities = canSchedule + ? ['send-message', 'schedule-message'] + : ['send-message']; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelDataWith(ownCapabilities)], + customUser: user, + }); + + const translators = await new Streami18n({ language: 'en' }).getTranslators(); + + await act(() => { + render( + + ({ + channel, + channelsQueryState: { queryInProgress: 'uninitialized' }, + client, + getAppSettings: vi.fn(), + latestMessageDatesByChannels: {}, + mutes: [], + searchController: new SearchController(), + })} + > + + + + + + + + + , + ); + }); + + if (text) { + await act(() => channel.messageComposer.textComposer.setText(text)); + } + + return { channel, client }; +}; + +const openMenu = async () => { + await act(() => fireEvent.click(screen.getByTestId('schedule-message-button'))); + + return screen.findByTestId('schedule-message-menu'); +}; + +const openDialog = async () => { + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Custom time'))); + + return screen.findByTestId('schedule-message-dialog'); +}; + +const getNotifications = (client: StreamChat) => + client.notifications.store.getLatestValue().notifications; + +const mockSchedule = (channel: ChannelType) => + vi.spyOn(channel.messageComposer, 'schedule'); + +describe('ScheduleMessageButton', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] }); + vi.setSystemTime(WEDNESDAY_MORNING); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('split send button', () => { + it('renders the chevron next to the send button with the capability', async () => { + await renderComposer(); + + const chevron = screen.getByTestId('schedule-message-button'); + + expect(screen.getByTestId('send-button')).toBeInTheDocument(); + expect(chevron).toHaveAttribute('aria-haspopup', 'menu'); + expect(chevron).toHaveAttribute('aria-expanded', 'false'); + expect(chevron.closest('.str-chat__split-send-button')).not.toBeNull(); + }); + + it('keeps the single send button without the capability', async () => { + await renderComposer({ canSchedule: false }); + + expect(screen.getByTestId('send-button')).toBeInTheDocument(); + expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument(); + expect(document.querySelector('.str-chat__split-send-button')).toBeNull(); + }); + + it('hides the chevron for a slash command', async () => { + await renderComposer({ text: '/giphy hello' }); + + expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument(); + }); + + it('can be removed through ComponentContext', async () => { + await renderComposer({ components: { ScheduleMessageButton: null } }); + + expect(screen.getByTestId('send-button')).toBeInTheDocument(); + expect(screen.queryByTestId('schedule-message-button')).not.toBeInTheDocument(); + }); + + it('still sends immediately from the send button', async () => { + const { channel } = await renderComposer(); + const schedule = mockSchedule(channel); + const sendMessage = vi + .spyOn(channel, 'sendMessage') + .mockResolvedValue(fromPartial({ message: { id: 'sent' } })); + + await act(() => fireEvent.click(screen.getByTestId('send-button'))); + + await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); + expect(sendMessage.mock.calls[0][1]?.scheduled_at).toBeUndefined(); + expect(schedule).not.toHaveBeenCalled(); + }); + }); + + describe('menu', () => { + it('shows the heading, the presets and Custom time', async () => { + await renderComposer(); + + const menu = await openMenu(); + + expect(menu).toHaveTextContent('Schedule message'); + expect(screen.getByText('Tomorrow at 09:00')).toBeInTheDocument(); + expect(screen.getByText('Tomorrow at 13:00')).toBeInTheDocument(); + expect(screen.getByText('Monday at 09:00')).toBeInTheDocument(); + expect(screen.getByText('Custom time')).toBeInTheDocument(); + expect(screen.getByTestId('schedule-message-button')).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('schedules at the picked preset and confirms with the server time', async () => { + const { channel, client } = await renderComposer(); + const serverScheduledAt = new Date(2026, 9, 29, 9, 0); + const scheduledMessage = generateScheduledMessage({ + cid: channel.cid, + scheduled_at: serverScheduledAt.toISOString(), + }); + const schedule = mockSchedule(channel).mockResolvedValue(scheduledMessage); + + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00'))); + + expect(schedule).toHaveBeenCalledWith(new Date(2026, 9, 29, 9, 0)); + await waitFor(() => + expect(getNotifications(client)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'Scheduled for Tomorrow at 09:00', + severity: 'success', + type: 'api:scheduled-message:create:success', + }), + ]), + ), + ); + expect(screen.queryByTestId('schedule-message-menu')).not.toBeInTheDocument(); + }); + + it('sends the composition with scheduled_at to the API', async () => { + const { channel, client } = await renderComposer(); + const scheduledMessage = generateScheduledMessage({ cid: channel.cid }); + useMockedApis(client, [sendScheduledMessageApi(scheduledMessage)]); + const sendMessage = vi.spyOn(channel, 'sendMessage'); + + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Tomorrow at 13:00'))); + + await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); + expect(sendMessage.mock.calls[0][0].text).toBe('Launch moved to Monday'); + expect(sendMessage.mock.calls[0][1]?.scheduled_at).toBe( + new Date(2026, 9, 29, 13, 0).toISOString(), + ); + await waitFor(() => expect(channel.messageComposer.textComposer.text).toBe('')); + }); + + it('refreshes the composer id when the server reports the id as taken', async () => { + const { channel, client } = await renderComposer(); + useMockedApis(client, [ + erroredScheduledMessageApi(119, ScheduledMessageErrorReason.IdTaken), + ]); + const idBefore = channel.messageComposer.id; + + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00'))); + + await waitFor(() => expect(channel.messageComposer.id).not.toBe(idBefore)); + expect(channel.messageComposer.textComposer.text).toBe('Launch moved to Monday'); + }); + + it('has no axe violations', async () => { + await renderComposer(); + + const menu = await openMenu(); + + expect(await axe(menu)).toHaveNoViolations(); + }); + }); + + describe('custom time dialog', () => { + it('opens with the message preview, the default time and the time zone', async () => { + await renderComposer(); + + const dialog = await openDialog(); + + expect(dialog).toHaveTextContent('Schedule message'); + expect(dialog).toHaveTextContent('Launch moved to Monday'); + expect(screen.getByLabelText('Date')).toHaveValue('2026-10-28'); + expect(screen.getByLabelText('Time')).toHaveValue('11:00'); + expect(dialog).toHaveTextContent('Sent in your time zone (GMT)'); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled(); + }); + + it('schedules at the picked date and time and closes', async () => { + const { channel } = await renderComposer(); + const schedule = mockSchedule(channel).mockResolvedValue( + generateScheduledMessage({ cid: channel.cid }), + ); + + await openDialog(); + fireEvent.change(screen.getByLabelText('Date'), { + target: { value: '2026-11-03' }, + }); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '16:45' } }); + await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' }))); + + expect(schedule).toHaveBeenCalledWith(new Date(2026, 10, 3, 16, 45)); + await waitFor(() => + expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(), + ); + }); + + it('blocks a time before the minimum lead time', async () => { + await renderComposer(); + + await openDialog(); + fireEvent.change(screen.getByLabelText('Time'), { target: { value: '10:31' } }); + + expect(screen.getByText('Choose a later time')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled(); + }); + + it('blocks a time beyond the maximum lead time', async () => { + await renderComposer(); + + await openDialog(); + fireEvent.change(screen.getByLabelText('Date'), { + target: { value: '2027-06-01' }, + }); + + expect(screen.getByText('Choose an earlier time')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled(); + }); + + it('keeps the dialog open and shows the error when scheduling fails', async () => { + const { channel } = await renderComposer(); + const schedule = mockSchedule(channel).mockRejectedValue(new Error('failed')); + + await openDialog(); + await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' }))); + + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't schedule message. Try again", + ); + expect(screen.getByTestId('schedule-message-dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled(); + + schedule.mockResolvedValue(generateScheduledMessage({ cid: channel.cid })); + await act(() => fireEvent.click(screen.getByRole('button', { name: 'Schedule' }))); + + expect(schedule).toHaveBeenCalledTimes(2); + await waitFor(() => + expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(), + ); + }); + + it('disables Schedule while offline and enables it again on reconnect', async () => { + const { client } = await renderComposer(); + + await openDialog(); + await act(() => dispatchConnectionChangedEvent(client, false)); + + expect( + screen.getByText("You're offline. Reconnect to schedule this message."), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeDisabled(); + + await act(() => dispatchConnectionChangedEvent(client, true)); + + expect( + screen.queryByText("You're offline. Reconnect to schedule this message."), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled(); + }); + + it('closes without scheduling on Cancel', async () => { + const { channel } = await renderComposer(); + const schedule = mockSchedule(channel); + + await openDialog(); + await act(() => fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))); + + expect(screen.queryByTestId('schedule-message-dialog')).not.toBeInTheDocument(); + expect(schedule).not.toHaveBeenCalled(); + }); + + it('renders a custom dialog from ComponentContext', async () => { + const CustomDialog = () =>
; + await renderComposer({ components: { ScheduleMessageDialog: CustomDialog } }); + + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Custom time'))); + + expect(await screen.findByTestId('custom-schedule-dialog')).toBeInTheDocument(); + }); + + it('has no axe violations', async () => { + await renderComposer(); + + const dialog = await openDialog(); + + expect(await axe(dialog)).toHaveNoViolations(); + }); + }); +}); From a27608640bfa5d447b8cab392d0ab4034db1d34e Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Thu, 29 Oct 2026 10:11:23 +0100 Subject: [PATCH 07/14] refactor(ScheduledMessages): drop comments from the scheduling utilities Remove the JSDoc blocks from the scheduling utilities and the new ComponentContext slots; names and unit test titles describe the behaviour. Refs: REACT-1387 --- .../utils/dateTimeInputValues.ts | 19 ------------ .../ScheduledMessages/utils/nativePicker.ts | 6 ---- .../utils/schedulePresets.ts | 26 ---------------- .../utils/scheduleTimeBounds.ts | 31 ------------------- .../utils/scheduledMessageErrors.ts | 8 ----- .../utils/scheduledMessagePreview.ts | 6 ---- .../ScheduledMessages/utils/timeZone.ts | 9 ------ 7 files changed, 105 deletions(-) diff --git a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts index d0c613e606..c6275750d5 100644 --- a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts +++ b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts @@ -1,11 +1,5 @@ const padTwoDigits = (value: number) => String(value).padStart(2, '0'); -/** - * Returns the local date of `date` in the `YYYY-MM-DD` format of ``. - * - * @example - * toDateInputValue(new Date(2026, 0, 5, 7, 4)); // '2026-01-05' - */ export const toDateInputValue = (date: Date) => { const year = date.getFullYear(); const month = padTwoDigits(date.getMonth() + 1); @@ -16,12 +10,6 @@ export const toDateInputValue = (date: Date) => { return dateInputValue; }; -/** - * Returns the local time of `date` in the `HH:mm` format of ``. - * - * @example - * toTimeInputValue(new Date(2026, 0, 5, 7, 4)); // '07:04' - */ export const toTimeInputValue = (date: Date) => { const hours = padTwoDigits(date.getHours()); const minutes = padTwoDigits(date.getMinutes()); @@ -31,13 +19,6 @@ export const toTimeInputValue = (date: Date) => { return timeInputValue; }; -/** - * Returns the local date and time picked in a date input and a time input, or `undefined` - * when either value is missing or invalid. - * - * @example - * fromDateAndTimeInputValues('2026-01-05', '07:04'); // 5 January 2026, 07:04 local time - */ export const fromDateAndTimeInputValues = (dateValue: string, timeValue: string) => { if (!dateValue || !timeValue) return undefined; diff --git a/src/components/ScheduledMessages/utils/nativePicker.ts b/src/components/ScheduledMessages/utils/nativePicker.ts index 46146f3125..f7d2412a6b 100644 --- a/src/components/ScheduledMessages/utils/nativePicker.ts +++ b/src/components/ScheduledMessages/utils/nativePicker.ts @@ -1,11 +1,5 @@ import type { MouseEvent } from 'react'; -/** - * Opens the browser's own picker of a date or time input when the field is clicked. - * - * Browsers that do not support `showPicker`, or refuse it outside a user gesture, keep - * their default behaviour. - */ export const openNativePicker = ({ currentTarget }: MouseEvent) => { try { currentTarget.showPicker?.(); diff --git a/src/components/ScheduledMessages/utils/schedulePresets.ts b/src/components/ScheduledMessages/utils/schedulePresets.ts index 85cb4a17c5..4a13c67ba1 100644 --- a/src/components/ScheduledMessages/utils/schedulePresets.ts +++ b/src/components/ScheduledMessages/utils/schedulePresets.ts @@ -6,12 +6,6 @@ const DAYS_IN_WEEK = 7; const MONDAY = 1; const TOMORROW = 1; -/** - * Returns a copy of `date` moved by `dayOffset` days and set to `hour`:00 local time. - * - * Works on the local calendar, so month ends, year ends and daylight saving changes keep - * the requested wall-clock hour. - */ export const getLocalDateAtHour = (date: Date, dayOffset: number, hour: number) => { const localDate = new Date(date); @@ -21,11 +15,6 @@ export const getLocalDateAtHour = (date: Date, dayOffset: number, hour: number) return localDate; }; -/** - * Returns how many days are left until the next Monday, from 1 to 7. - * - * On a Monday the next Monday is a week away. - */ export const getDaysUntilNextMonday = (date: Date) => { const daysUntilMonday = (DAYS_IN_WEEK - date.getDay() + MONDAY) % DAYS_IN_WEEK; @@ -34,19 +23,9 @@ export const getDaysUntilNextMonday = (date: Date) => { return daysUntilNextMonday; }; -/** - * Returns `true` when both dates fall on the same local calendar day. - */ export const isSameLocalDay = (first: Date, second: Date) => first.toDateString() === second.toDateString(); -/** - * Returns the quick schedule times of the schedule menu, in menu order. - * - * The presets are tomorrow at 09:00, tomorrow at 13:00 and next Monday at 09:00, all in the - * local time zone. When tomorrow is Monday, the Monday preset is dropped because it would - * repeat "tomorrow at 09:00". - */ export const getSchedulePresets = (now: Date) => { const tomorrowMorning = getLocalDateAtHour(now, TOMORROW, SchedulePresetHour.Morning); const tomorrowAfternoon = getLocalDateAtHour( @@ -69,11 +48,6 @@ export const getSchedulePresets = (now: Date) => { return presets; }; -/** - * Returns the schedule presets that the channel's limits allow. - * - * A channel with a short maximum lead time can rule out "next Monday", for example. - */ export const getAvailableSchedulePresets = (now: Date, bounds: ScheduleTimeBounds) => { const availablePresets = getSchedulePresets(now).filter((preset) => isWithinScheduleTimeBounds(preset, bounds), diff --git a/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts index 50f87f6caa..f40efcefea 100644 --- a/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts +++ b/src/components/ScheduledMessages/utils/scheduleTimeBounds.ts @@ -6,16 +6,6 @@ import type { ScheduleTimeBounds } from '../types'; const MS_IN_SECOND = 1000; -/** - * Returns the earliest and latest time a message can be scheduled for. - * - * The earliest time is the minimum lead time plus a one-minute buffer that absorbs - * clock skew between the device and the server; the latest is the maximum lead time. - * - * @example - * // limits: 60 s minimum, 120 days maximum, now: 10:30:00 - * getScheduleTimeBounds(limits, now); // { earliest: 10:32:00, latest: now + 120 days } - */ export const getScheduleTimeBounds = ( { maxLeadTimeSeconds, minLeadTimeSeconds }: ScheduledMessageLimits, now: Date, @@ -32,19 +22,9 @@ export const getScheduleTimeBounds = ( return bounds; }; -/** - * Returns the schedule time bounds of a channel, read from its config limits. - * - * Channels without configured limits use the SDK defaults from `getScheduledMessageLimits`. - */ export const getChannelScheduleTimeBounds = (channel: Channel, now: Date) => getScheduleTimeBounds(getScheduledMessageLimits(channel), now); -/** - * Returns why a time cannot be scheduled, or `undefined` when it can. - * - * Both bounds are inclusive. - */ export const getScheduleTimeError = ( scheduledAt: Date, { earliest, latest }: ScheduleTimeBounds, @@ -56,22 +36,11 @@ export const getScheduleTimeError = ( return undefined; }; -/** - * Returns `true` when a time falls between the earliest and the latest allowed time. - */ export const isWithinScheduleTimeBounds = ( scheduledAt: Date, bounds: ScheduleTimeBounds, ) => !getScheduleTimeError(scheduledAt, bounds); -/** - * Returns the time a custom schedule starts at: the first whole hour after the earliest - * allowed time. - * - * @example - * // earliest: 10:32 - * getDefaultScheduledAt(bounds); // 11:00 - */ export const getDefaultScheduledAt = ({ earliest }: ScheduleTimeBounds) => { const defaultScheduledAt = new Date(earliest); diff --git a/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts index d0879d294a..ca836edabd 100644 --- a/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts +++ b/src/components/ScheduledMessages/utils/scheduledMessageErrors.ts @@ -1,14 +1,6 @@ import { getScheduledMessageErrorReason } from 'stream-chat'; import type { ScheduledMessageErrorReason } from 'stream-chat'; -/** - * Returns `true` when `error` is a scheduled message API error with the given reason. - * - * Errors that did not come from the API, such as network failures, never match. - * - * @example - * isScheduledMessageError(error, ScheduledMessageErrorReason.IdTaken); - */ export const isScheduledMessageError = ( error: unknown, reason: ScheduledMessageErrorReason, diff --git a/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts index ebe47ce587..09fc3c900c 100644 --- a/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts +++ b/src/components/ScheduledMessages/utils/scheduledMessagePreview.ts @@ -2,12 +2,6 @@ import type { LocalMessageBase } from 'stream-chat'; import type { ScheduledMessagePreviewSource } from '../types'; -/** - * Returns a local message built from the composer's current text and attachments, so the - * schedule dialog can render it with the quoted message preview. - * - * The message is never sent; it only carries what the preview reads. - */ export const toScheduledMessagePreview = ( { attachments, id, text }: ScheduledMessagePreviewSource, now: Date, diff --git a/src/components/ScheduledMessages/utils/timeZone.ts b/src/components/ScheduledMessages/utils/timeZone.ts index e86ef23bd3..31cd005d45 100644 --- a/src/components/ScheduledMessages/utils/timeZone.ts +++ b/src/components/ScheduledMessages/utils/timeZone.ts @@ -1,14 +1,5 @@ const MINUTES_IN_HOUR = 60; -/** - * Returns the local time zone of `date` as a GMT offset label. - * - * Whole hours drop the minutes and UTC itself is plain `GMT`. The offset is read for the - * given date, so a date after a daylight saving change gets the new offset. - * - * @example - * formatTimeZoneOffset(date); // 'GMT+2', 'GMT-5', 'GMT+5:30' or 'GMT' - */ export const formatTimeZoneOffset = (date: Date) => { const offsetMinutes = -date.getTimezoneOffset(); From 0f9ce9aa3a72d2facfab246f63f953e5747a9482 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Thu, 29 Oct 2026 10:11:27 +0100 Subject: [PATCH 08/14] feat(i18n): translate blocked scheduled message warnings The composer now reports a composition that cannot be scheduled as validation:scheduled-message:blocked with the reason in its metadata. Translate it per reason (slash command, poll, shared location) with a generic fallback, and add the strings to all 12 locales. Refs: REACT-1387 --- src/components/ScheduledMessages/constants.ts | 2 +- .../notifications/translators.ts | 19 +++++++ .../translatorsByNotificationType.ts | 4 +- ...uledMessageNotificationTranslators.test.ts | 50 +++++++++++++++---- src/i18n/de.json | 3 ++ src/i18n/en.json | 3 ++ src/i18n/es.json | 3 ++ src/i18n/fr.json | 3 ++ src/i18n/hi.json | 3 ++ src/i18n/it.json | 3 ++ src/i18n/ja.json | 3 ++ src/i18n/ko.json | 3 ++ src/i18n/nl.json | 3 ++ src/i18n/pt.json | 3 ++ src/i18n/ru.json | 3 ++ src/i18n/tr.json | 3 ++ 16 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/components/ScheduledMessages/constants.ts b/src/components/ScheduledMessages/constants.ts index f399213d48..0823441a74 100644 --- a/src/components/ScheduledMessages/constants.ts +++ b/src/components/ScheduledMessages/constants.ts @@ -31,7 +31,7 @@ export type ScheduleMessageNoticeSeverity = (typeof ScheduleMessageNoticeSeverity)[keyof typeof ScheduleMessageNoticeSeverity]; export const ScheduledMessageNotificationType = { - CommandRejected: 'validation:scheduled-message:command', + Blocked: 'validation:scheduled-message:blocked', CreateFailed: 'api:scheduled-message:create:failed', CreateSucceeded: 'api:scheduled-message:create:success', } as const; diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts index c4641563f9..bae20c3c9f 100644 --- a/src/i18n/TranslationBuilder/notifications/translators.ts +++ b/src/i18n/TranslationBuilder/notifications/translators.ts @@ -1,6 +1,7 @@ import { ErrorFromResponse, getScheduledMessageErrorReason, + ScheduledMessageBlockReason, ScheduledMessageErrorReason, } from 'stream-chat'; import type { Notification } from 'stream-chat'; @@ -125,3 +126,21 @@ export const translateScheduledMessageCreateFailed: Translator< return t("Couldn't schedule message. Try again"); }; + +export const translateScheduledMessageBlocked: Translator< + NotificationTranslatorOptions +> = ({ options: { notification }, t }) => { + const { reason } = notification?.metadata ?? {}; + + if (reason === ScheduledMessageBlockReason.Command) { + return t("Slash commands can't be scheduled"); + } + + if (reason === ScheduledMessageBlockReason.Poll) return t("Polls can't be scheduled"); + + if (reason === ScheduledMessageBlockReason.SharedLocation) { + return t("Locations can't be scheduled"); + } + + return t("This message can't be scheduled"); +}; diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts index 4aa12fefb1..0373a1b50c 100644 --- a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts +++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts @@ -7,6 +7,7 @@ import { translateCommandDisabled, translatePollCreateFailed, translatePollEndFailed, + translateScheduledMessageBlocked, translateScheduledMessageCreateFailed, } from './translators'; import type { Translator } from '../../index'; @@ -37,6 +38,5 @@ export const translatorsByNotificationType: Record< 'validation:command:disabled': translateCommandDisabled, 'validation:poll:castVote:limit': ({ t }) => t('Reached the vote limit. Remove an existing vote first.'), - [ScheduledMessageNotificationType.CommandRejected]: ({ t }) => - t("Slash commands can't be scheduled"), + [ScheduledMessageNotificationType.Blocked]: translateScheduledMessageBlocked, }; diff --git a/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts index b7c2d6d321..9ff014578a 100644 --- a/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts +++ b/src/i18n/__tests__/scheduledMessageNotificationTranslators.test.ts @@ -1,9 +1,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { TFunction } from 'i18next'; -import { ErrorFromResponse } from 'stream-chat'; +import { ErrorFromResponse, ScheduledMessageBlockReason } from 'stream-chat'; import type { Notification } from 'stream-chat'; -import { translateScheduledMessageCreateFailed } from '../TranslationBuilder/notifications/translators'; +import { + translateScheduledMessageBlocked, + translateScheduledMessageCreateFailed, +} from '../TranslationBuilder/notifications/translators'; import { translatorsByNotificationType } from '../TranslationBuilder/notifications/translatorsByNotificationType'; const t = ((key: string) => key) as TFunction; @@ -63,18 +66,45 @@ describe('translateScheduledMessageCreateFailed', () => { }); }); +const translateBlocked = (reason?: string) => + translateScheduledMessageBlocked({ + key: '', + options: { + notification: fromPartial({ + message: 'This message cannot be scheduled', + metadata: reason ? { reason } : undefined, + type: 'validation:scheduled-message:blocked', + }), + }, + t, + value: '', + }); + +describe('translateScheduledMessageBlocked', () => { + it.each([ + [ScheduledMessageBlockReason.Command, "Slash commands can't be scheduled"], + [ScheduledMessageBlockReason.Poll, "Polls can't be scheduled"], + [ScheduledMessageBlockReason.SharedLocation, "Locations can't be scheduled"], + ])('translates the %s reason', (reason, expected) => { + expect(translateBlocked(reason)).toBe(expected); + }); + + it('falls back to the generic copy without a known reason', () => { + expect(translateBlocked()).toBe("This message can't be scheduled"); + expect(translateBlocked('unknown')).toBe("This message can't be scheduled"); + }); +}); + describe('scheduled message notification types', () => { it('registers translators for the composer notifications', () => { expect(translatorsByNotificationType['api:scheduled-message:create:failed']).toBe( translateScheduledMessageCreateFailed, ); - expect( - translatorsByNotificationType['validation:scheduled-message:command']?.({ - key: '', - options: {}, - t, - value: '', - }), - ).toBe("Slash commands can't be scheduled"); + expect(translatorsByNotificationType['validation:scheduled-message:blocked']).toBe( + translateScheduledMessageBlocked, + ); + expect(translatorsByNotificationType).not.toHaveProperty( + 'validation:scheduled-message:command', + ); }); }); diff --git a/src/i18n/de.json b/src/i18n/de.json index b20662b7c4..c74097c442 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -464,6 +464,7 @@ "Location": "Standort", "Location sharing ended": "Standortfreigabe beendet", "Location: {{ coordinates }}": "Standort: {{ coordinates }}", + "Locations can't be scheduled": "Standorte können nicht geplant werden", "Manage channel": "Kanal verwalten", "Manage members": "Mitglieder verwalten", "Mark as unread": "Als ungelesen markieren", @@ -546,6 +547,7 @@ "Poll options": "Umfrageoptionen", "Poll results": "Umfrageergebnisse", "Poll sent": "Umfrage gesendet", + "Polls can't be scheduled": "Umfragen können nicht geplant werden", "Previous": "Zurück", "Previous image": "Vorheriges Bild", "Putting on my thinking cap": "Ich setze meine Denkerkappe auf", @@ -644,6 +646,7 @@ "Thinking...": "Denken...", "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", "This field cannot be empty or contain only spaces": "Dieses Feld darf nicht leer sein oder nur Leerzeichen enthalten", + "This message can't be scheduled": "Diese Nachricht kann nicht geplant werden", "This message did not meet our content guidelines": "Diese Nachricht entsprach nicht unseren Inhaltsrichtlinien", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Dieser Benutzer kann dir wieder Nachrichten senden.", diff --git a/src/i18n/en.json b/src/i18n/en.json index dc1256326e..3c0d87b855 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -464,6 +464,7 @@ "Location": "Location", "Location sharing ended": "Location sharing ended", "Location: {{ coordinates }}": "Location: {{ coordinates }}", + "Locations can't be scheduled": "Locations can't be scheduled", "Manage channel": "Manage channel", "Manage members": "Manage members", "Mark as unread": "Mark as unread", @@ -546,6 +547,7 @@ "Poll options": "Poll Options", "Poll results": "Poll Results", "Poll sent": "Poll sent", + "Polls can't be scheduled": "Polls can't be scheduled", "Previous": "Previous", "Previous image": "Previous image", "Putting on my thinking cap": "Putting on my thinking cap", @@ -644,6 +646,7 @@ "Thinking...": "Thinking...", "this content could not be displayed": "this content could not be displayed", "This field cannot be empty or contain only spaces": "This field cannot be empty or contain only spaces", + "This message can't be scheduled": "This message can't be scheduled", "This message did not meet our content guidelines": "This message did not meet our content guidelines", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "This user will be able to message you again.", diff --git a/src/i18n/es.json b/src/i18n/es.json index af32866365..c2478ba6cc 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -484,6 +484,7 @@ "Location": "Ubicación", "Location sharing ended": "Compartir ubicación terminado", "Location: {{ coordinates }}": "Ubicación: {{ coordinates }}", + "Locations can't be scheduled": "Las ubicaciones no se pueden programar", "Manage channel": "Gestionar canal", "Manage members": "Gestionar miembros", "Mark as unread": "Marcar como no leído", @@ -566,6 +567,7 @@ "Poll options": "Opciones de la encuesta", "Poll results": "Resultados de la encuesta", "Poll sent": "Encuesta enviada", + "Polls can't be scheduled": "Las encuestas no se pueden programar", "Previous": "Anterior", "Previous image": "Imagen anterior", "Putting on my thinking cap": "Poniéndome el gorro de pensar", @@ -669,6 +671,7 @@ "Thinking...": "Pensando...", "this content could not be displayed": "Este contenido no se pudo mostrar", "This field cannot be empty or contain only spaces": "Este campo no puede estar vacío o contener solo espacios", + "This message can't be scheduled": "Este mensaje no se puede programar", "This message did not meet our content guidelines": "Este mensaje no cumple con nuestras directrices de contenido", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Este usuario podrá enviarte mensajes de nuevo.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 67bf2d94d2..c77dfb953a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -484,6 +484,7 @@ "Location": "Emplacement", "Location sharing ended": "Partage d'emplacement terminé", "Location: {{ coordinates }}": "Emplacement : {{ coordinates }}", + "Locations can't be scheduled": "Les positions ne peuvent pas être programmées", "Manage channel": "Gérer le canal", "Manage members": "Gérer les membres", "Mark as unread": "Marquer comme non lu", @@ -566,6 +567,7 @@ "Poll options": "Options du sondage", "Poll results": "Résultats du sondage", "Poll sent": "Sondage envoyé", + "Polls can't be scheduled": "Les sondages ne peuvent pas être programmés", "Previous": "Précédent", "Previous image": "Image précédente", "Putting on my thinking cap": "J'enfile mon bonnet de réflexion", @@ -669,6 +671,7 @@ "Thinking...": "Réflexion...", "this content could not be displayed": "ce contenu n'a pas pu être affiché", "This field cannot be empty or contain only spaces": "Ce champ ne peut pas être vide ou contenir uniquement des espaces", + "This message can't be scheduled": "Ce message ne peut pas être programmé", "This message did not meet our content guidelines": "Ce message ne respecte pas nos directives de contenu", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Cet utilisateur pourra à nouveau vous envoyer des messages.", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index c035226fe6..54c727e1be 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -465,6 +465,7 @@ "Location": "स्थान", "Location sharing ended": "स्थान साझा करना समाप्त", "Location: {{ coordinates }}": "स्थान: {{ coordinates }}", + "Locations can't be scheduled": "स्थान शेड्यूल नहीं किए जा सकते", "Manage channel": "चैनल प्रबंधित करें", "Manage members": "सदस्य प्रबंधित करें", "Mark as unread": "अपठित चिह्नित करें", @@ -547,6 +548,7 @@ "Poll options": "मतदान विकल्प", "Poll results": "मतदान परिणाम", "Poll sent": "पोल भेजा गया", + "Polls can't be scheduled": "पोल शेड्यूल नहीं किए जा सकते", "Previous": "पिछला", "Previous image": "पिछली छवि", "Putting on my thinking cap": "सोचने वाली टोपी पहन रहा हूँ", @@ -645,6 +647,7 @@ "Thinking...": "सोच रहा है...", "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", "This field cannot be empty or contain only spaces": "यह फ़ील्ड खाली नहीं हो सकता या केवल रिक्त स्थान नहीं रख सकता", + "This message can't be scheduled": "यह संदेश शेड्यूल नहीं किया जा सकता", "This message did not meet our content guidelines": "यह संदेश हमारे सामग्री दिशानिर्देशों के अनुरूप नहीं था", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "यह उपयोगकर्ता आपको फिर से संदेश भेज सकेगा।", diff --git a/src/i18n/it.json b/src/i18n/it.json index 34b649ddda..c9c7d94e4a 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -484,6 +484,7 @@ "Location": "Posizione", "Location sharing ended": "Condivisione posizione terminata", "Location: {{ coordinates }}": "Posizione: {{ coordinates }}", + "Locations can't be scheduled": "Le posizioni non possono essere programmate", "Manage channel": "Gestisci canale", "Manage members": "Gestisci membri", "Mark as unread": "Contrassegna come non letto", @@ -566,6 +567,7 @@ "Poll options": "Opzioni del sondaggio", "Poll results": "Risultati del sondaggio", "Poll sent": "Sondaggio inviato", + "Polls can't be scheduled": "I sondaggi non possono essere programmati", "Previous": "Indietro", "Previous image": "Immagine precedente", "Putting on my thinking cap": "Mi metto il cappello del pensatore", @@ -669,6 +671,7 @@ "Thinking...": "Pensando...", "this content could not be displayed": "questo contenuto non può essere mostrato", "This field cannot be empty or contain only spaces": "Questo campo non può essere vuoto o contenere solo spazi", + "This message can't be scheduled": "Questo messaggio non può essere programmato", "This message did not meet our content guidelines": "Questo messaggio non soddisfa le nostre linee guida sui contenuti", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Questo utente potrà inviarti di nuovo messaggi.", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 419227e728..7098644846 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -452,6 +452,7 @@ "Location": "位置情報", "Location sharing ended": "位置情報の共有が終了しました", "Location: {{ coordinates }}": "位置: {{ coordinates }}", + "Locations can't be scheduled": "位置情報は予約できません", "Manage channel": "チャンネルを管理", "Manage members": "メンバーを管理", "Mark as unread": "未読としてマーク", @@ -534,6 +535,7 @@ "Poll options": "投票オプション", "Poll results": "投票結果", "Poll sent": "アンケートを送信しました", + "Polls can't be scheduled": "投票は予約できません", "Previous": "前へ", "Previous image": "前の画像", "Putting on my thinking cap": "考える帽子をかぶっています", @@ -630,6 +632,7 @@ "Thinking...": "考え中...", "this content could not be displayed": "このコンテンツは表示できませんでした", "This field cannot be empty or contain only spaces": "このフィールドは空にすることはできません。また、空白文字のみを含むこともできません", + "This message can't be scheduled": "このメッセージは予約できません", "This message did not meet our content guidelines": "このメッセージはコンテンツガイドラインに適合していません", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "このユーザーは再びあなたにメッセージを送信できるようになります。", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 2b68df8ff0..4517d26884 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -452,6 +452,7 @@ "Location": "위치", "Location sharing ended": "위치 공유가 종료되었습니다", "Location: {{ coordinates }}": "위치: {{ coordinates }}", + "Locations can't be scheduled": "위치는 예약할 수 없습니다", "Manage channel": "채널 관리", "Manage members": "멤버 관리", "Mark as unread": "읽지 않음으로 표시", @@ -534,6 +535,7 @@ "Poll options": "투표 옵션", "Poll results": "투표 결과", "Poll sent": "투표 전송됨", + "Polls can't be scheduled": "투표는 예약할 수 없습니다", "Previous": "이전", "Previous image": "이전 이미지", "Putting on my thinking cap": "생각 모자를 쓰는 중", @@ -630,6 +632,7 @@ "Thinking...": "생각 중...", "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", "This field cannot be empty or contain only spaces": "이 필드는 비워둘 수 없으며 공백만 포함할 수도 없습니다", + "This message can't be scheduled": "이 메시지는 예약할 수 없습니다", "This message did not meet our content guidelines": "이 메시지는 콘텐츠 가이드라인을 충족하지 않습니다.", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "이 사용자가 다시 메시지를 보낼 수 있습니다.", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 3c5121f0b9..a6f0ff3930 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -464,6 +464,7 @@ "Location": "Locatie", "Location sharing ended": "Locatie delen beëindigd", "Location: {{ coordinates }}": "Locatie: {{ coordinates }}", + "Locations can't be scheduled": "Locaties kunnen niet worden ingepland", "Manage channel": "Kanaal beheren", "Manage members": "Leden beheren", "Mark as unread": "Markeren als ongelezen", @@ -546,6 +547,7 @@ "Poll options": "Peiling opties", "Poll results": "Peiling resultaten", "Poll sent": "Peiling verzonden", + "Polls can't be scheduled": "Peilingen kunnen niet worden ingepland", "Previous": "Vorige", "Previous image": "Vorige afbeelding", "Putting on my thinking cap": "Ik zet mijn denkpet op", @@ -646,6 +648,7 @@ "Thinking...": "Denken...", "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", "This field cannot be empty or contain only spaces": "Dit veld mag niet leeg zijn of alleen spaties bevatten", + "This message can't be scheduled": "Dit bericht kan niet worden ingepland", "This message did not meet our content guidelines": "Dit bericht voldeed niet aan onze inhoudsrichtlijnen", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Deze gebruiker kan je weer berichten sturen.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 17d211a423..1bea0ce3af 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -484,6 +484,7 @@ "Location": "Localização", "Location sharing ended": "Compartilhamento de localização encerrado", "Location: {{ coordinates }}": "Localização: {{ coordinates }}", + "Locations can't be scheduled": "Localizações não podem ser agendadas", "Manage channel": "Gerenciar canal", "Manage members": "Gerenciar membros", "Mark as unread": "Marcar como não lida", @@ -566,6 +567,7 @@ "Poll options": "Opções da pesquisa", "Poll results": "Resultados da pesquisa", "Poll sent": "Enquete enviada", + "Polls can't be scheduled": "Enquetes não podem ser agendadas", "Previous": "Anterior", "Previous image": "Imagem anterior", "Putting on my thinking cap": "Colocando meu chapéu de pensar", @@ -669,6 +671,7 @@ "Thinking...": "Pensando...", "this content could not be displayed": "este conteúdo não pôde ser exibido", "This field cannot be empty or contain only spaces": "Este campo não pode estar vazio ou conter apenas espaços", + "This message can't be scheduled": "Esta mensagem não pode ser agendada", "This message did not meet our content guidelines": "Esta mensagem não corresponde às nossas diretrizes de conteúdo", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Este usuário poderá enviar mensagens para você novamente.", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index eb7b0c9969..448c87dd67 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -508,6 +508,7 @@ "Location": "Местоположение", "Location sharing ended": "Обмен местоположением завершен", "Location: {{ coordinates }}": "Местоположение: {{ coordinates }}", + "Locations can't be scheduled": "Местоположение нельзя запланировать", "Manage channel": "Управлять каналом", "Manage members": "Управлять участниками", "Mark as unread": "Отметить как непрочитанное", @@ -590,6 +591,7 @@ "Poll options": "Опции опроса", "Poll results": "Результаты опроса", "Poll sent": "Опрос отправлен", + "Polls can't be scheduled": "Опросы нельзя запланировать", "Previous": "Назад", "Previous image": "Предыдущее изображение", "Putting on my thinking cap": "Надеваю шапку мыслителя", @@ -698,6 +700,7 @@ "Thinking...": "Думаю...", "this content could not be displayed": "Этот контент не может быть отображен в данный момент", "This field cannot be empty or contain only spaces": "Это поле не может быть пустым или содержать только пробелы", + "This message can't be scheduled": "Это сообщение нельзя запланировать", "This message did not meet our content guidelines": "Сообщение не соответствует правилам", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Этот пользователь снова сможет отправлять вам сообщения.", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 1832f45518..e2a3f0c586 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -464,6 +464,7 @@ "Location": "Konum", "Location sharing ended": "Konum paylaşımı sona erdi", "Location: {{ coordinates }}": "Konum: {{ coordinates }}", + "Locations can't be scheduled": "Konumlar zamanlanamaz", "Manage channel": "Kanalı yönet", "Manage members": "Üyeleri yönet", "Mark as unread": "Okunmamış olarak işaretle", @@ -546,6 +547,7 @@ "Poll options": "Anket seçenekleri", "Poll results": "Anket sonuçları", "Poll sent": "Anket gönderildi", + "Polls can't be scheduled": "Anketler zamanlanamaz", "Previous": "Geri", "Previous image": "Önceki görsel", "Putting on my thinking cap": "Düşünme şapkamı takıyorum", @@ -644,6 +646,7 @@ "Thinking...": "Düşünüyor...", "this content could not be displayed": "bu içerik gösterilemiyor", "This field cannot be empty or contain only spaces": "Bu alan boş olamaz veya sadece boşluk içeremez", + "This message can't be scheduled": "Bu mesaj zamanlanamaz", "This message did not meet our content guidelines": "Bu mesaj içerik yönergelerimize uygun değil", "This permanently deletes your message history with {{ user }}. This can't be undone.": "This permanently deletes your message history with {{ user }}. This can't be undone.", "This user will be able to message you again.": "Bu kullanıcı size tekrar mesaj gönderebilecek.", From e6331d6ecda5999364e1ecb20fe625624026550c Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Mon, 2 Nov 2026 13:56:36 +0100 Subject: [PATCH 09/14] fix(ScheduledMessages): label the next-Monday preset with its weekday On a Monday the next-Monday preset is more than seven days from the start of today, so the calendar format fell through to sameElse and rendered "Mon, 9 Nov at 09:00" instead of "Monday at 09:00". Presets now use a timestamp/SchedulePreset key whose sameElse matches nextWeek, added to all 12 locales with each locale's own weekday format. Refs: REACT-1387 --- .../ScheduledMessages/ScheduleMessagePresetButton.tsx | 2 +- .../__tests__/ScheduleMessageButton.test.tsx | 11 +++++++++++ src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 1 + src/i18n/ko.json | 1 + src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + 14 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx index 365b861544..aa602509c3 100644 --- a/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx +++ b/src/components/ScheduledMessages/ScheduleMessagePresetButton.tsx @@ -19,7 +19,7 @@ export const ScheduleMessagePresetButton = ({ Icon={IconClock} onClick={selectPreset} > - {t('timestamp/ScheduledMessage', { timestamp: scheduledAt })} + {t('timestamp/SchedulePreset', { timestamp: scheduledAt })} ); }; diff --git a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx index c1b2073aeb..50e07f9eee 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx @@ -25,6 +25,7 @@ import { const user = generateUser({ id: 'user-id' }); const WEDNESDAY_MORNING = new Date(2026, 9, 28, 10, 30); +const MONDAY_MORNING = new Date(2026, 10, 2, 13, 0); const channelDataWith = (ownCapabilities: string[]) => generateChannel({ @@ -188,6 +189,16 @@ describe('ScheduleMessageButton', () => { ); }); + it('labels the next-Monday preset with the weekday on a Monday', async () => { + vi.setSystemTime(MONDAY_MORNING); + await renderComposer(); + + await openMenu(); + + expect(screen.getByText('Tomorrow at 09:00')).toBeInTheDocument(); + expect(screen.getByText('Monday at 09:00')).toBeInTheDocument(); + }); + it('schedules at the picked preset and confirms with the server time', async () => { const { channel, client } = await renderComposer(); const serverScheduledAt = new Date(2026, 9, 29, 9, 0); diff --git a/src/i18n/de.json b/src/i18n/de.json index c74097c442..b174ee2377 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -673,6 +673,7 @@ "timestamp/relativeWeeksAgo": "vor {{ count }} Wo.", "timestamp/relativeYesterday": "Gestern", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"dddd [um] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3c0d87b855..4a2a516115 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -673,6 +673,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}w ago", "timestamp/relativeYesterday": "Yesterday", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", diff --git a/src/i18n/es.json b/src/i18n/es.json index c2478ba6cc..208ad90cc3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -699,6 +699,7 @@ "timestamp/relativeWeeksAgo": "hace {{ count }} sem", "timestamp/relativeYesterday": "Ayer", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"dddd [a las] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index c77dfb953a..9249b9232e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -699,6 +699,7 @@ "timestamp/relativeWeeksAgo": "il y a {{ count }} sem", "timestamp/relativeYesterday": "Hier", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"dddd [à] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 54c727e1be..fad29fdd1b 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -674,6 +674,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} सप्ताह पहले", "timestamp/relativeYesterday": "बीता कल", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", diff --git a/src/i18n/it.json b/src/i18n/it.json index c9c7d94e4a..98bba1be4f 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -699,6 +699,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} sett fa", "timestamp/relativeYesterday": "Ieri", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"dddd [alle] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 7098644846..b5d0f47fe8 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -658,6 +658,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}週間前", "timestamp/relativeYesterday": "昨日", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 4517d26884..d8d13eb064 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -658,6 +658,7 @@ "timestamp/relativeWeeksAgo": "{{ count }}주 전", "timestamp/relativeYesterday": "어제", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index a6f0ff3930..af22ca91a3 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -675,6 +675,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} w geleden", "timestamp/relativeYesterday": "Gisteren", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"dddd [om] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 1bea0ce3af..1796ffb260 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -699,6 +699,7 @@ "timestamp/relativeWeeksAgo": "há {{ count }} sem", "timestamp/relativeYesterday": "Ontem", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"dddd [às] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 448c87dd67..1011a27b9d 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -729,6 +729,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} нед. назад", "timestamp/relativeYesterday": "Вчера", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"dddd [в] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index e2a3f0c586..f27ca65b8b 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -673,6 +673,7 @@ "timestamp/relativeWeeksAgo": "{{ count }} hf önce", "timestamp/relativeYesterday": "Dün", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"dddd [saat] HH:mm\" }) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin", From a08d25225c7c818a597732ff0f032c48bb1e6789 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 14:24:28 +0100 Subject: [PATCH 10/14] fix(ScheduledMessages): match the split send button and menu to the design The send and schedule segments were two buttons of different heights with a seam between them. Build the split send button like the design's Chat / Web / Split Send Button: one 64px pill with the 16px send icon, and the schedule chevron as an xs solid button inside its trailing end. While the menu is open the pill shows the selected state and the chevron keeps its own fill. The menu separator now spans the full menu width. --- .../ScheduleMessageButton.tsx | 8 ++- .../styling/ScheduleMessageButton.scss | 49 ++++++++++++------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/components/ScheduledMessages/ScheduleMessageButton.tsx b/src/components/ScheduledMessages/ScheduleMessageButton.tsx index 5dfe1c4e17..8d26a04aef 100644 --- a/src/components/ScheduledMessages/ScheduleMessageButton.tsx +++ b/src/components/ScheduledMessages/ScheduleMessageButton.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx'; import React, { useCallback, useRef } from 'react'; import { @@ -70,14 +69,13 @@ export const ScheduleMessageButton = () => { aria-expanded={isMenuOpen} aria-haspopup='menu' aria-label={t('aria/Open schedule message menu')} - className={clsx('str-chat__schedule-message-button', { - 'str-chat__schedule-message-button--selected': isMenuOpen, - })} + circular + className='str-chat__schedule-message-button' data-testid='schedule-message-button' disabled={!hasSendableData} onClick={toggleMenu} ref={menuButtonRef} - size='sm' + size='xs' variant='primary' > diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss index d5033bdee1..5189782151 100644 --- a/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss +++ b/src/components/ScheduledMessages/styling/ScheduleMessageButton.scss @@ -1,34 +1,44 @@ @use '../../../styling/utils'; .str-chat { - --str-chat__split-send-button-segment-gap: 1px; + --str-chat__split-send-button-caret-size: var(--str-chat__button-visual-height-xs); .str-chat__split-send-button { - display: flex; - align-items: center; - gap: var(--str-chat__split-send-button-segment-gap); + position: relative; + display: inline-flex; - .str-chat__send-button { - border-start-end-radius: 0; - border-end-end-radius: 0; - } - - .str-chat__schedule-message-button { - border-start-start-radius: 0; - border-end-start-radius: 0; - border-start-end-radius: var(--str-chat__button-radius-full); - border-end-end-radius: var(--str-chat__button-radius-full); - padding-inline: var(--str-chat__spacing-xs); + .str-chat__send-button.str-chat__button--circular { + aspect-ratio: auto; - .str-chat__icon--chevron-up { + .str-chat__icon.str-chat__icon--send { + position: relative; + z-index: 1; height: var(--str-chat__icon-size-sm); width: var(--str-chat__icon-size-sm); } - &.str-chat__schedule-message-button--selected:not(:disabled) { - @include utils.overlay-after(var(--str-chat__background-utility-selected)); + padding-inline: var(--str-chat__spacing-sm) + calc( + var(--str-chat__spacing-xs) + var(--str-chat__split-send-button-caret-size) + + var(--str-chat__spacing-xxs) + ); + } + + .str-chat__schedule-message-button { + position: absolute; + inset-inline-end: var(--str-chat__spacing-xxs); + inset-block-start: 50%; + translate: 0 -50%; + + &[aria-expanded='true']:not(:hover)::after { + content: none; } } + + &:has(.str-chat__schedule-message-button[aria-expanded='true']) + .str-chat__send-button:not(:disabled) { + @include utils.overlay-after(var(--str-chat__background-utility-selected)); + } } .str-chat__schedule-message-menu { @@ -43,7 +53,8 @@ .str-chat__schedule-message-menu__separator { height: 1px; - margin: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xs); + margin-block: var(--str-chat__spacing-xxs); + margin-inline: calc(-1 * var(--str-chat__spacing-xxs)); background-color: var(--str-chat__border-core-subtle); } } From c1a3ad9d85086384c05ddbbb0c0b1e3f87cc1558 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 14:44:18 +0100 Subject: [PATCH 11/14] fix(ScheduledMessages): match the custom time dialog to the design Show the date as "Fri, Oct 23, 2026" with the native date input stretched invisibly over the field, so it still opens the native picker. Add the caret-down icon to the Date and Time fields, use the caption text style for their values and the md input height, color the preview bar as an outgoing reply, and use the metadata text style for the time zone note. --- src/components/Icons/icons.tsx | 8 +++ src/components/Icons/slots.ts | 1 + .../ScheduleMessageDialog.tsx | 27 ++++++++-- .../__tests__/ScheduleMessageButton.test.tsx | 13 +++++ .../styling/ScheduleMessageDialog.scss | 49 ++++++++++++++++++- .../__tests__/dateTimeInputValues.test.ts | 10 ++++ .../utils/dateTimeInputValues.ts | 10 ++++ 7 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 8f369d898f..bc6a8f7f28 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -305,6 +305,14 @@ export const IconCheckmark = createIcon( />, ); +export const IconCaretDown = createIcon( + 'IconCaretDown', + , +); + export const IconChevronDown = createIcon( 'IconChevronDown', { if (timeError === ScheduleTimeError.TooSoon) return t('Choose a later time'); @@ -28,7 +33,7 @@ const translateScheduleTimeError = (t: TFunction, timeError?: ScheduleTimeError) export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => { const { t } = useTranslationContext(); - const { IconCalendar, IconClock } = useComponentContextIcons(); + const { IconCalendar, IconCaretDown, IconClock } = useComponentContextIcons(); const { channel } = useMessageComposerController(); const channelDisplayName = useChannelDisplayName(channel); @@ -48,6 +53,10 @@ export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => const canSchedule = !!scheduledAt && !timeError && isOnline && !isScheduling; const timeErrorMessage = translateScheduleTimeError(t, timeError); const timeZone = formatTimeZoneOffset(scheduledAt ?? bounds.earliest); + const selectedDate = fromDateInputValue(dateValue); + const dateLabel = selectedDate + ? t('timestamp/ScheduleDate', { timestamp: selectedDate }) + : undefined; const authorLabel = channelDisplayName ? t('Message to {{ name }}', { name: channelDisplayName }) : t('Schedule message'); @@ -82,12 +91,23 @@ export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => } + leading={ + <> + + + {dateLabel} + + + } max={toDateInputValue(bounds.latest)} min={toDateInputValue(bounds.earliest)} onChange={onDateChange} onClick={openNativePicker} required + trailing={} type='date' value={dateValue} /> @@ -100,6 +120,7 @@ export const ScheduleMessageDialog = ({ close }: ScheduleMessageDialogProps) => onChange={onTimeChange} onClick={openNativePicker} required + trailing={} type='time' value={timeValue} /> diff --git a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx index 50e07f9eee..497192ea3e 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx @@ -280,6 +280,19 @@ describe('ScheduleMessageButton', () => { expect(screen.getByRole('button', { name: 'Schedule' })).toBeEnabled(); }); + it('shows the picked date in the design format', async () => { + await renderComposer(); + + const dialog = await openDialog(); + fireEvent.change(screen.getByLabelText('Date'), { + target: { value: '2026-11-03' }, + }); + + expect( + dialog.querySelector('.str-chat__schedule-message-dialog__date-value'), + ).toHaveTextContent('Tue, Nov 3, 2026'); + }); + it('schedules at the picked date and time and closes', async () => { const { channel } = await renderComposer(); const schedule = mockSchedule(channel).mockResolvedValue( diff --git a/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss index 7a8183f1c3..0f523e02ba 100644 --- a/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss +++ b/src/components/ScheduledMessages/styling/ScheduleMessageDialog.scss @@ -1,3 +1,5 @@ +@use '../../../styling/utils'; + .str-chat { --str-chat__schedule-message-dialog-max-width: 504px; --str-chat__schedule-message-dialog-notice-info-bg: color-mix( @@ -23,6 +25,10 @@ .str-chat__schedule-message-dialog__preview { background-color: var(--str-chat__chat-bg-outgoing); + + .str-chat__quoted-message-indicator { + background-color: var(--str-chat__chat-reply-indicator-outgoing); + } } .str-chat__schedule-message-dialog__fields { @@ -35,11 +41,52 @@ cursor: pointer; } + .str-chat__form-text-input__wrapper { + box-sizing: border-box; + justify-content: center; + min-height: var(--str-chat__button-visual-height-md); + } + + .str-chat__form-text-input__input { + font: var(--str-chat__font-caption-default); + } + + .str-chat__form-text-input__trailing { + pointer-events: none; + } + input::-webkit-calendar-picker-indicator { display: none; } } + .str-chat__schedule-message-dialog__date { + .str-chat__form-text-input__wrapper { + position: relative; + } + + .str-chat__form-text-input__leading { + display: flex; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: var(--str-chat__spacing-xs); + min-width: 0; + } + + .str-chat__form-text-input__input { + position: absolute; + inset: 0; + opacity: 0; + } + } + + .str-chat__schedule-message-dialog__date-value { + @include utils.ellipsis-text(); + font: var(--str-chat__font-caption-default); + color: var(--str-chat__input-text-default); + } + .str-chat__schedule-message-dialog__notice { display: flex; align-items: center; @@ -47,7 +94,7 @@ padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm); border-radius: var(--str-chat__radius-md); color: var(--str-chat__text-primary); - font: var(--str-chat__font-caption-emphasis); + font: var(--str-chat__font-metadata-emphasis); .str-chat__schedule-message-dialog__notice-icon { flex-shrink: 0; diff --git a/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts index 2feb5862f2..b847401d45 100644 --- a/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts +++ b/src/components/ScheduledMessages/utils/__tests__/dateTimeInputValues.test.ts @@ -1,5 +1,6 @@ import { fromDateAndTimeInputValues, + fromDateInputValue, toDateInputValue, toTimeInputValue, } from '../dateTimeInputValues'; @@ -34,4 +35,13 @@ describe('dateTimeInputValues', () => { ])('returns undefined for %s', (_, dateValue, timeValue) => { expect(fromDateAndTimeInputValues(dateValue, timeValue)).toBeUndefined(); }); + + it('reads a date input value as local midnight', () => { + expect(fromDateInputValue('2026-11-03')).toEqual(new Date(2026, 10, 3)); + }); + + it('returns undefined for an empty or invalid date input value', () => { + expect(fromDateInputValue('')).toBeUndefined(); + expect(fromDateInputValue('2026-13-45')).toBeUndefined(); + }); }); diff --git a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts index c6275750d5..819f4bd31b 100644 --- a/src/components/ScheduledMessages/utils/dateTimeInputValues.ts +++ b/src/components/ScheduledMessages/utils/dateTimeInputValues.ts @@ -28,3 +28,13 @@ export const fromDateAndTimeInputValues = (dateValue: string, timeValue: string) return isValidDate ? scheduledAt : undefined; }; + +export const fromDateInputValue = (dateValue: string) => { + if (!dateValue) return undefined; + + const date = new Date(`${dateValue}T00:00`); + + const isValidDate = !Number.isNaN(date.getTime()); + + return isValidDate ? date : undefined; +}; From 8c0aad5f1f881d5cabf6a97711e492352ae7d6e2 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 14:44:22 +0100 Subject: [PATCH 12/14] feat(i18n): add the scheduled message date format --- src/i18n/de.json | 3 ++- src/i18n/en.json | 3 ++- src/i18n/es.json | 3 ++- src/i18n/fr.json | 3 ++- src/i18n/hi.json | 3 ++- src/i18n/it.json | 3 ++- src/i18n/ja.json | 3 ++- src/i18n/ko.json | 3 ++- src/i18n/nl.json | 3 ++- src/i18n/pt.json | 3 ++- src/i18n/ru.json | 3 ++- src/i18n/tr.json | 3 ++- 12 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index b174ee2377..459e3de670 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -673,8 +673,9 @@ "timestamp/relativeWeeksAgo": "vor {{ count }} Wo.", "timestamp/relativeYesterday": "Gestern", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"dddd [um] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D. MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"dddd [um] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", "To start recording, allow the microphone access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf das Mikrofon in Ihrem Browser", diff --git a/src/i18n/en.json b/src/i18n/en.json index 4a2a516115..55cf358279 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -673,8 +673,9 @@ "timestamp/relativeWeeksAgo": "{{ count }}w ago", "timestamp/relativeYesterday": "Yesterday", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", "To start recording, allow the microphone access in your browser": "To start recording, allow the microphone access in your browser", diff --git a/src/i18n/es.json b/src/i18n/es.json index 208ad90cc3..e8558a07c1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -699,8 +699,9 @@ "timestamp/relativeWeeksAgo": "hace {{ count }} sem", "timestamp/relativeYesterday": "Ayer", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"dddd [a las] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"dddd [a las] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", "To start recording, allow the microphone access in your browser": "Para comenzar a grabar, permita el acceso al micrófono en su navegador", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 9249b9232e..e0865e0ca1 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -699,8 +699,9 @@ "timestamp/relativeWeeksAgo": "il y a {{ count }} sem", "timestamp/relativeYesterday": "Hier", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"dddd [à] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"dddd [à] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", "To start recording, allow the microphone access in your browser": "Pour commencer l'enregistrement, autorisez l'accès au microphone dans votre navigateur", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index fad29fdd1b..cb37ffb3b0 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -674,8 +674,9 @@ "timestamp/relativeWeeksAgo": "{{ count }} सप्ताह पहले", "timestamp/relativeYesterday": "बीता कल", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", "To start recording, allow the microphone access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में माइक्रोफ़ोन तक पहुँच दें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 98bba1be4f..bbc4268280 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -699,8 +699,9 @@ "timestamp/relativeWeeksAgo": "{{ count }} sett fa", "timestamp/relativeYesterday": "Ieri", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"dddd [alle] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"dddd [alle] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", "To start recording, allow the microphone access in your browser": "Per iniziare a registrare, consenti l'accesso al microfono nel tuo browser", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index b5d0f47fe8..1c6556b069 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -658,8 +658,9 @@ "timestamp/relativeWeeksAgo": "{{ count }}週間前", "timestamp/relativeYesterday": "昨日", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: YYYY[年]M[月]D[日] ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", "To start recording, allow the microphone access in your browser": "録音を開始するには、ブラウザーでマイクロフォンへのアクセスを許可してください", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index d8d13eb064..1b751fd09f 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -658,8 +658,9 @@ "timestamp/relativeWeeksAgo": "{{ count }}주 전", "timestamp/relativeYesterday": "어제", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: YYYY[년] M[월] D[일] ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", "To start recording, allow the microphone access in your browser": "브라우저에서 마이크로폰 액세스를 허용하여 녹음을 시작합니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index af22ca91a3..d3d6bc2ede 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -675,8 +675,9 @@ "timestamp/relativeWeeksAgo": "{{ count }} w geleden", "timestamp/relativeYesterday": "Gisteren", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"dddd [om] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"dddd [om] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", "To start recording, allow the microphone access in your browser": "Om te beginnen met opnemen, sta toegang tot de microfoon toe in uw browser", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 1796ffb260..228b7511c1 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -699,8 +699,9 @@ "timestamp/relativeWeeksAgo": "há {{ count }} sem", "timestamp/relativeYesterday": "Ontem", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"dddd [às] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D [de] MMM [de] YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"dddd [às] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", "To start recording, allow the microphone access in your browser": "Para começar a gravar, permita o acesso ao microfone no seu navegador", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1011a27b9d..59b57c1da2 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -729,8 +729,9 @@ "timestamp/relativeWeeksAgo": "{{ count }} нед. назад", "timestamp/relativeYesterday": "Вчера", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"dddd [в] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"dddd [в] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", "To start recording, allow the microphone access in your browser": "Для начала записи разрешите доступ к микрофону в вашем браузере", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index f27ca65b8b..bbde0dc2a3 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -673,8 +673,9 @@ "timestamp/relativeWeeksAgo": "{{ count }} hf önce", "timestamp/relativeYesterday": "Dün", "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", - "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"dddd [saat] HH:mm\" }) }}", + "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: D MMM YYYY ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", + "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"dddd [saat] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin", "To start recording, allow the microphone access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda mikrofona erişime izin verin", From 1d97564f473b66a4fd01fad6eb92b3d623082366 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 14:49:56 +0100 Subject: [PATCH 13/14] fix(ScheduledMessages): confirm a scheduled message with the design copy The toast now reads "Message scheduled for Fri, Oct 23 at 09:00", or only the time when the message goes out later today, as in the Confirmation and Rounded Time designs. --- .../__tests__/ScheduleMessageButton.test.tsx | 22 ++++++++++++++++++- .../hooks/useScheduleMessage.ts | 6 +++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx index 497192ea3e..aa05d46d43 100644 --- a/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx +++ b/src/components/ScheduledMessages/__tests__/ScheduleMessageButton.test.tsx @@ -199,6 +199,26 @@ describe('ScheduleMessageButton', () => { expect(screen.getByText('Monday at 09:00')).toBeInTheDocument(); }); + it('confirms a time later today with the time only', async () => { + const { channel, client } = await renderComposer(); + const scheduledMessage = generateScheduledMessage({ + cid: channel.cid, + scheduled_at: new Date(2026, 9, 28, 10, 43).toISOString(), + }); + mockSchedule(channel).mockResolvedValue(scheduledMessage); + + await openMenu(); + await act(() => fireEvent.click(screen.getByText('Tomorrow at 09:00'))); + + await waitFor(() => + expect(getNotifications(client)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: 'Message scheduled for 10:43' }), + ]), + ), + ); + }); + it('schedules at the picked preset and confirms with the server time', async () => { const { channel, client } = await renderComposer(); const serverScheduledAt = new Date(2026, 9, 29, 9, 0); @@ -216,7 +236,7 @@ describe('ScheduleMessageButton', () => { expect(getNotifications(client)).toEqual( expect.arrayContaining([ expect.objectContaining({ - message: 'Scheduled for Tomorrow at 09:00', + message: 'Message scheduled for Thu, Oct 29 at 09:00', severity: 'success', type: 'api:scheduled-message:create:success', }), diff --git a/src/components/ScheduledMessages/hooks/useScheduleMessage.ts b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts index e08cbeb7eb..3e8faf00d5 100644 --- a/src/components/ScheduledMessages/hooks/useScheduleMessage.ts +++ b/src/components/ScheduledMessages/hooks/useScheduleMessage.ts @@ -19,11 +19,13 @@ export const useScheduleMessage = () => { const notifyScheduled = useCallback( ({ scheduled_at }: ScheduledMessageResponse) => { - const time = t('timestamp/ScheduledMessage', { timestamp: new Date(scheduled_at) }); + const time = t('timestamp/ScheduledMessageConfirmation', { + timestamp: new Date(scheduled_at), + }); addNotification({ emitter: SCHEDULE_MESSAGE_NOTIFICATION_EMITTER, - message: t('Scheduled for {{ time }}', { time }), + message: t('Message scheduled for {{ time }}', { time }), severity: 'success', type: ScheduledMessageNotificationType.CreateSucceeded, }); From ddea99f52e1ce4cf0e2a464608c9c495ec819d05 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Tue, 3 Nov 2026 14:50:00 +0100 Subject: [PATCH 14/14] feat(i18n): translate the scheduled message confirmation --- src/i18n/de.json | 3 ++- src/i18n/en.json | 3 ++- src/i18n/es.json | 3 ++- src/i18n/fr.json | 3 ++- src/i18n/hi.json | 3 ++- src/i18n/it.json | 3 ++- src/i18n/ja.json | 3 ++- src/i18n/ko.json | 3 ++- src/i18n/nl.json | 3 ++- src/i18n/pt.json | 3 ++- src/i18n/ru.json | 3 ++- src/i18n/tr.json | 3 ++- 12 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/i18n/de.json b/src/i18n/de.json index 459e3de670..199b82a9cc 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -481,6 +481,7 @@ "Message has been successfully flagged": "Nachricht wurde erfolgreich gemeldet", "Message marked as unread": "Nachricht als ungelesen markiert", "Message pinned": "Nachricht angeheftet", + "Message scheduled for {{ time }}": "Nachricht geplant für {{ time }}", "Message to {{ name }}": "Nachricht an {{ name }}", "Message unpinned": "Nachricht nicht mehr angeheftet", "Message was blocked by moderation policies": "Nachricht wurde durch moderationsrichtlinien blockiert", @@ -591,7 +592,6 @@ "Saved for later": "Für später gespeichert", "Schedule": "Planen", "Schedule message": "Nachricht planen", - "Scheduled for {{ time }}": "Geplant für {{ time }}", "Scheduled messages are turned off": "Geplante Nachrichten sind deaktiviert", "Search": "Suche", "Search GIFs": "GIFs suchen", @@ -675,6 +675,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D. MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [um] HH:mm\", \"lastDay\": \"ddd, D MMM [um] HH:mm\", \"nextWeek\": \"ddd, D MMM [um] HH:mm\", \"lastWeek\": \"ddd, D MMM [um] HH:mm\", \"sameElse\": \"ddd, D MMM [um] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Heute] [um] HH:mm\", \"nextDay\": \"[Morgen] [um] HH:mm\", \"lastDay\": \"[Gestern] [um] HH:mm\", \"nextWeek\": \"dddd [um] HH:mm\", \"lastWeek\": \"[letzten] dddd [um] HH:mm\", \"sameElse\": \"dddd [um] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Um mit der Aufnahme zu beginnen, erlauben Sie den Zugriff auf die Kamera in Ihrem Browser", diff --git a/src/i18n/en.json b/src/i18n/en.json index 55cf358279..5bfab15994 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -481,6 +481,7 @@ "Message has been successfully flagged": "Message has been successfully flagged", "Message marked as unread": "Message marked as unread", "Message pinned": "Message pinned", + "Message scheduled for {{ time }}": "Message scheduled for {{ time }}", "Message to {{ name }}": "Message to {{ name }}", "Message unpinned": "Message unpinned", "Message was blocked by moderation policies": "Message was blocked by moderation policies", @@ -591,7 +592,6 @@ "Saved for later": "Saved for later", "Schedule": "Schedule", "Schedule message": "Schedule message", - "Scheduled for {{ time }}": "Scheduled for {{ time }}", "Scheduled messages are turned off": "Scheduled messages are turned off", "Search": "Search", "Search GIFs": "Search GIFs", @@ -675,6 +675,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, MMM D, YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"ddd, D MMM [at] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, MMM D [at] HH:mm\", \"lastDay\": \"ddd, MMM D [at] HH:mm\", \"nextWeek\": \"ddd, MMM D [at] HH:mm\", \"lastWeek\": \"ddd, MMM D [at] HH:mm\", \"sameElse\": \"ddd, MMM D [at] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Today] [at] HH:mm\", \"nextDay\": \"[Tomorrow] [at] HH:mm\", \"lastDay\": \"[Yesterday] [at] HH:mm\", \"nextWeek\": \"dddd [at] HH:mm\", \"lastWeek\": \"[Last] dddd [at] HH:mm\", \"sameElse\": \"dddd [at] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "To start recording, allow the camera access in your browser", diff --git a/src/i18n/es.json b/src/i18n/es.json index e8558a07c1..5d9e7cb480 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -501,6 +501,7 @@ "Message has been successfully flagged": "El mensaje se marcó correctamente", "Message marked as unread": "Mensaje marcado como no leído", "Message pinned": "Mensaje fijado", + "Message scheduled for {{ time }}": "Mensaje programado para {{ time }}", "Message to {{ name }}": "Mensaje para {{ name }}", "Message unpinned": "Mensaje desanclado", "Message was blocked by moderation policies": "El mensaje fue bloqueado por las políticas de moderación", @@ -614,7 +615,6 @@ "Saved for later": "Guardado para más tarde", "Schedule": "Programar", "Schedule message": "Programar mensaje", - "Scheduled for {{ time }}": "Programado para {{ time }}", "Scheduled messages are turned off": "Los mensajes programados están desactivados", "Search": "Buscar", "Search GIFs": "Buscar GIFs", @@ -701,6 +701,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [a las] HH:mm\", \"lastDay\": \"ddd, D MMM [a las] HH:mm\", \"nextWeek\": \"ddd, D MMM [a las] HH:mm\", \"lastWeek\": \"ddd, D MMM [a las] HH:mm\", \"sameElse\": \"ddd, D MMM [a las] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoy] [a las] HH:mm\", \"nextDay\": \"[Mañana] [a las] HH:mm\", \"lastDay\": \"[Ayer] [a las] HH:mm\", \"nextWeek\": \"dddd [a las] HH:mm\", \"lastWeek\": \"[Último] dddd [a las] HH:mm\", \"sameElse\": \"dddd [a las] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para comenzar a grabar, permita el acceso a la cámara en su navegador", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index e0865e0ca1..9686309672 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -501,6 +501,7 @@ "Message has been successfully flagged": "Le message a été signalé avec succès", "Message marked as unread": "Message marqué comme non lu", "Message pinned": "Message épinglé", + "Message scheduled for {{ time }}": "Message programmé pour {{ time }}", "Message to {{ name }}": "Message à {{ name }}", "Message unpinned": "Message désépinglé", "Message was blocked by moderation policies": "Le message a été bloqué par les politiques de modération", @@ -614,7 +615,6 @@ "Saved for later": "Enregistré pour plus tard", "Schedule": "Programmer", "Schedule message": "Programmer le message", - "Scheduled for {{ time }}": "Programmé pour {{ time }}", "Scheduled messages are turned off": "Les messages programmés sont désactivés", "Search": "Rechercher", "Search GIFs": "Rechercher des GIFs", @@ -701,6 +701,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [à] HH:mm\", \"lastDay\": \"ddd, D MMM [à] HH:mm\", \"nextWeek\": \"ddd, D MMM [à] HH:mm\", \"lastWeek\": \"ddd, D MMM [à] HH:mm\", \"sameElse\": \"ddd, D MMM [à] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Aujourd'hui] [à] HH:mm\", \"nextDay\": \"[Demain] [à] HH:mm\", \"lastDay\": \"[Hier] [à] HH:mm\", \"nextWeek\": \"dddd [à] HH:mm\", \"lastWeek\": \"dddd [dernier à] HH:mm\", \"sameElse\": \"dddd [à] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Pour commencer l'enregistrement, autorisez l'accès à la caméra dans votre navigateur", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index cb37ffb3b0..73e2a67f80 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -482,6 +482,7 @@ "Message has been successfully flagged": "मैसेज को फ्लैग कर दिया गया है", "Message marked as unread": "संदेश को अपठित के रूप में चिह्नित किया गया", "Message pinned": "संदेश पिन किया गया", + "Message scheduled for {{ time }}": "संदेश {{ time }} के लिए शेड्यूल किया गया", "Message to {{ name }}": "{{ name }} को संदेश", "Message unpinned": "संदेश अनपिन किया गया", "Message was blocked by moderation policies": "संदेश को मॉडरेशन नीतियों द्वारा ब्लॉक कर दिया गया है", @@ -592,7 +593,6 @@ "Saved for later": "बाद के लिए सहेजा गया", "Schedule": "शेड्यूल करें", "Schedule message": "संदेश शेड्यूल करें", - "Scheduled for {{ time }}": "{{ time }} के लिए शेड्यूल किया गया", "Scheduled messages are turned off": "शेड्यूल किए गए संदेश बंद हैं", "Search": "खोज", "Search GIFs": "GIF खोजें", @@ -676,6 +676,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM HH:mm\", \"lastDay\": \"ddd, D MMM HH:mm\", \"nextWeek\": \"ddd, D MMM HH:mm\", \"lastWeek\": \"ddd, D MMM HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[आज] HH:mm\", \"nextDay\": \"[कल] HH:mm\", \"lastDay\": \"[बीता कल] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[पिछले] dddd, HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "रिकॉर्डिंग शुरू करने के लिए, अपने ब्राउज़र में कैमरा तक पहुँच दें", diff --git a/src/i18n/it.json b/src/i18n/it.json index bbc4268280..07655992b6 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -501,6 +501,7 @@ "Message has been successfully flagged": "Il messaggio è stato segnalato con successo", "Message marked as unread": "Messaggio contrassegnato come non letto", "Message pinned": "Messaggio bloccato", + "Message scheduled for {{ time }}": "Messaggio programmato per {{ time }}", "Message to {{ name }}": "Messaggio a {{ name }}", "Message unpinned": "Messaggio rimosso dai fissati", "Message was blocked by moderation policies": "Il messaggio è stato bloccato dalle politiche di moderazione", @@ -614,7 +615,6 @@ "Saved for later": "Salvato per dopo", "Schedule": "Programma", "Schedule message": "Programma messaggio", - "Scheduled for {{ time }}": "Programmato per {{ time }}", "Scheduled messages are turned off": "I messaggi programmati sono disattivati", "Search": "Cerca", "Search GIFs": "Cerca GIF", @@ -701,6 +701,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [alle] HH:mm\", \"lastDay\": \"ddd, D MMM [alle] HH:mm\", \"nextWeek\": \"ddd, D MMM [alle] HH:mm\", \"lastWeek\": \"ddd, D MMM [alle] HH:mm\", \"sameElse\": \"ddd, D MMM [alle] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Oggi] [alle] HH:mm\", \"nextDay\": \"[Domani] [alle] HH:mm\", \"lastDay\": \"[Ieri] [alle] HH:mm\", \"nextWeek\": \"dddd [alle] HH:mm\", \"lastWeek\": \"[lo scorso] dddd [alle] HH:mm\", \"sameElse\": \"dddd [alle] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Per iniziare a registrare, consenti l'accesso alla fotocamera nel tuo browser", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 1c6556b069..d4183c0ab8 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -469,6 +469,7 @@ "Message has been successfully flagged": "メッセージに正常にフラグが付けられました", "Message marked as unread": "メッセージを未読にしました", "Message pinned": "メッセージにピンが付けられました", + "Message scheduled for {{ time }}": "メッセージを{{ time }}に予約しました", "Message to {{ name }}": "{{ name }}へのメッセージ", "Message unpinned": "メッセージのピン留めを解除しました", "Message was blocked by moderation policies": "メッセージはモデレーションポリシーによってブロックされました", @@ -577,7 +578,6 @@ "Saved for later": "後で保存済み", "Schedule": "予約", "Schedule message": "メッセージを予約", - "Scheduled for {{ time }}": "{{ time }}に予約済み", "Scheduled messages are turned off": "予約メッセージはオフになっています", "Search": "探す", "Search GIFs": "GIFを検索", @@ -660,6 +660,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: YYYY[年]M[月]D[日] ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM HH:mm\", \"lastDay\": \"ddd, D MMM HH:mm\", \"nextWeek\": \"ddd, D MMM HH:mm\", \"lastWeek\": \"ddd, D MMM HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[今日] HH:mm\", \"nextDay\": \"[明日] HH:mm\", \"lastDay\": \"[昨日] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "録音を開始するには、ブラウザーでカメラへのアクセスを許可してください", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 1b751fd09f..ec49dd78df 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -469,6 +469,7 @@ "Message has been successfully flagged": "메시지에 플래그가 지정되었습니다.", "Message marked as unread": "메시지를 읽지 않음으로 표시했습니다", "Message pinned": "메시지 핀했습니다", + "Message scheduled for {{ time }}": "메시지가 {{ time }}에 예약되었습니다", "Message to {{ name }}": "{{ name }}님에게 보낼 메시지", "Message unpinned": "메시지 고정이 해제됨", "Message was blocked by moderation policies": "메시지가 관리 정책에 의해 차단되었습니다.", @@ -577,7 +578,6 @@ "Saved for later": "나중에 저장됨", "Schedule": "예약", "Schedule message": "메시지 예약", - "Scheduled for {{ time }}": "{{ time }}에 예약됨", "Scheduled messages are turned off": "예약 메시지가 꺼져 있습니다", "Search": "찾다", "Search GIFs": "GIF 검색", @@ -660,6 +660,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: YYYY[년] M[월] D[일] ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM HH:mm\", \"lastDay\": \"ddd, D MMM HH:mm\", \"nextWeek\": \"ddd, D MMM HH:mm\", \"lastWeek\": \"ddd, D MMM HH:mm\", \"sameElse\": \"ddd, D MMM HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[오늘] HH:mm\", \"nextDay\": \"[내일] HH:mm\", \"lastDay\": \"[어제] HH:mm\", \"nextWeek\": \"dddd HH:mm\", \"lastWeek\": \"[지난] dddd HH:mm\", \"sameElse\": \"dddd HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "브라우저에서 카메라 액세스를 허용하여 녹음을 시작합니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index d3d6bc2ede..479c427102 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -481,6 +481,7 @@ "Message has been successfully flagged": "Bericht is succesvol gemarkeerd", "Message marked as unread": "Bericht gemarkeerd als ongelezen", "Message pinned": "Bericht vastgezet", + "Message scheduled for {{ time }}": "Bericht gepland voor {{ time }}", "Message to {{ name }}": "Bericht aan {{ name }}", "Message unpinned": "Bericht losgemaakt", "Message was blocked by moderation policies": "Bericht is geblokkeerd door moderatiebeleid", @@ -591,7 +592,6 @@ "Saved for later": "Bewaard voor later", "Schedule": "Inplannen", "Schedule message": "Bericht inplannen", - "Scheduled for {{ time }}": "Ingepland voor {{ time }}", "Scheduled messages are turned off": "Ingeplande berichten zijn uitgeschakeld", "Search": "Zoeken", "Search GIFs": "GIF's zoeken", @@ -677,6 +677,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [om] HH:mm\", \"lastDay\": \"ddd, D MMM [om] HH:mm\", \"nextWeek\": \"ddd, D MMM [om] HH:mm\", \"lastWeek\": \"ddd, D MMM [om] HH:mm\", \"sameElse\": \"ddd, D MMM [om] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Vandaag] [om] HH:mm\", \"nextDay\": \"[Morgen] [om] HH:mm\", \"lastDay\": \"[Gisteren] [om] HH:mm\", \"nextWeek\": \"dddd [om] HH:mm\", \"lastWeek\": \"[afgelopen] dddd [om] HH:mm\", \"sameElse\": \"dddd [om] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Om te beginnen met opnemen, sta toegang tot de camera toe in uw browser", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 228b7511c1..4ac67eb05d 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -501,6 +501,7 @@ "Message has been successfully flagged": "A mensagem foi reportada com sucesso", "Message marked as unread": "Mensagem marcada como não lida", "Message pinned": "Mensagem fixada", + "Message scheduled for {{ time }}": "Mensagem agendada para {{ time }}", "Message to {{ name }}": "Mensagem para {{ name }}", "Message unpinned": "Mensagem desafixada", "Message was blocked by moderation policies": "A mensagem foi bloqueada pelas políticas de moderação", @@ -614,7 +615,6 @@ "Saved for later": "Salvo para depois", "Schedule": "Agendar", "Schedule message": "Agendar mensagem", - "Scheduled for {{ time }}": "Agendada para {{ time }}", "Scheduled messages are turned off": "As mensagens agendadas estão desativadas", "Search": "Buscar", "Search GIFs": "Pesquisar GIFs", @@ -701,6 +701,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D [de] MMM [de] YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [às] HH:mm\", \"lastDay\": \"ddd, D MMM [às] HH:mm\", \"nextWeek\": \"ddd, D MMM [às] HH:mm\", \"lastWeek\": \"ddd, D MMM [às] HH:mm\", \"sameElse\": \"ddd, D MMM [às] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Hoje] [às] HH:mm\", \"nextDay\": \"[Amanhã] [às] HH:mm\", \"lastDay\": \"[Ontem] [às] HH:mm\", \"nextWeek\": \"dddd [às] HH:mm\", \"lastWeek\": \"dddd [passada às] HH:mm\", \"sameElse\": \"dddd [às] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Para começar a gravar, permita o acesso à câmera no seu navegador", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 59b57c1da2..071fe612a1 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -525,6 +525,7 @@ "Message has been successfully flagged": "Жалоба на сообщение была принята", "Message marked as unread": "Сообщение помечено как непрочитанное", "Message pinned": "Сообщение закреплено", + "Message scheduled for {{ time }}": "Сообщение запланировано на {{ time }}", "Message to {{ name }}": "Сообщение для {{ name }}", "Message unpinned": "Сообщение откреплено", "Message was blocked by moderation policies": "Сообщение было заблокировано модерацией", @@ -641,7 +642,6 @@ "Saved for later": "Сохранено на потом", "Schedule": "Запланировать", "Schedule message": "Запланировать сообщение", - "Scheduled for {{ time }}": "Запланировано на {{ time }}", "Scheduled messages are turned off": "Запланированные сообщения отключены", "Search": "Поиск", "Search GIFs": "Поиск GIF", @@ -731,6 +731,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: ddd, D MMM YYYY) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [в] HH:mm\", \"lastDay\": \"ddd, D MMM [в] HH:mm\", \"nextWeek\": \"ddd, D MMM [в] HH:mm\", \"lastWeek\": \"ddd, D MMM [в] HH:mm\", \"sameElse\": \"ddd, D MMM [в] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Сегодня] [в] HH:mm\", \"nextDay\": \"[Завтра] [в] HH:mm\", \"lastDay\": \"[Вчера] [в] HH:mm\", \"nextWeek\": \"dddd [в] HH:mm\", \"lastWeek\": \"dddd [в] HH:mm\", \"sameElse\": \"dddd [в] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Для начала записи разрешите доступ к камере в вашем браузере", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index bbde0dc2a3..6806c353af 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -481,6 +481,7 @@ "Message has been successfully flagged": "Mesaj başarıyla bayraklandı", "Message marked as unread": "Mesaj okunmadı olarak işaretlendi", "Message pinned": "Mesaj sabitlendi", + "Message scheduled for {{ time }}": "Mesaj {{ time }} için planlandı", "Message to {{ name }}": "{{ name }} için mesaj", "Message unpinned": "Mesaj sabitlemesi kaldırıldı", "Message was blocked by moderation policies": "Mesaj moderasyon politikaları tarafından engellendi", @@ -591,7 +592,6 @@ "Saved for later": "Daha sonra kaydedildi", "Schedule": "Zamanla", "Schedule message": "Mesajı zamanla", - "Scheduled for {{ time }}": "{{ time }} için zamanlandı", "Scheduled messages are turned off": "Zamanlanmış mesajlar kapalı", "Search": "Arama", "Search GIFs": "GIF ara", @@ -675,6 +675,7 @@ "timestamp/ReminderNotification": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", "timestamp/ScheduleDate": "{{ timestamp | timestampFormatter(format: D MMM YYYY ddd) }}", "timestamp/ScheduledMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", + "timestamp/ScheduledMessageConfirmation": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"HH:mm\", \"nextDay\": \"ddd, D MMM [saat] HH:mm\", \"lastDay\": \"ddd, D MMM [saat] HH:mm\", \"nextWeek\": \"ddd, D MMM [saat] HH:mm\", \"lastWeek\": \"ddd, D MMM [saat] HH:mm\", \"sameElse\": \"ddd, D MMM [saat] HH:mm\" }) }}", "timestamp/SchedulePreset": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: { \"sameDay\": \"[Bugün] [saat] HH:mm\", \"nextDay\": \"[Yarın] [saat] HH:mm\", \"lastDay\": \"[Dün] [saat] HH:mm\", \"nextWeek\": \"dddd [saat] HH:mm\", \"lastWeek\": \"[Geçen] dddd [saat] HH:mm\", \"sameElse\": \"dddd [saat] HH:mm\" }) }}", "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: dddd L) }}", "To start recording, allow the camera access in your browser": "Kayıt yapmaya başlamak için tarayıcınızda kameraya erişime izin verin",