From e9df126e678ca2a2607dfc29f30c8cf671a871b7 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:46:42 +0100 Subject: [PATCH 1/8] 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 Co-Authored-By: Claude Opus 5.5 --- 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 2/8] 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 Co-Authored-By: Claude Opus 5.5 --- 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 17b737de56c3b4851b6ed0d19bd8c5253d9e7f20 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:05 +0100 Subject: [PATCH 3/8] 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 Co-Authored-By: Claude Opus 5.5 --- .../__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 84dabde248061843c9bc6abe77e69fea8a781931 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:16 +0100 Subject: [PATCH 4/8] 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 Co-Authored-By: Claude Opus 5.5 --- .../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 7141a4f1f7dce2287fd46b7506de16f8158193c4 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:24 +0100 Subject: [PATCH 5/8] 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 Co-Authored-By: Claude Opus 5.5 --- .../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 f5011ef22014d6921c31636f39cad6095fc4390e Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 28 Oct 2026 15:47:34 +0100 Subject: [PATCH 6/8] 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 Co-Authored-By: Claude Opus 5.5 --- .../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 baeb72fac2f7ba2b3bd8834908723b5e5e412c3f Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Thu, 29 Oct 2026 10:11:23 +0100 Subject: [PATCH 7/8] 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 Co-Authored-By: Claude Opus 5.5 --- .../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 9c85359afbfe001486d88ec34ab6ae934352363e Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Thu, 29 Oct 2026 10:11:27 +0100 Subject: [PATCH 8/8] 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 Co-Authored-By: Claude Opus 5.5 --- 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.",