diff --git a/.cursor/skills/make-plans/SKILL.md b/.cursor/skills/make-plans/SKILL.md index 837159bd28..eb76e7da53 100644 --- a/.cursor/skills/make-plans/SKILL.md +++ b/.cursor/skills/make-plans/SKILL.md @@ -7,6 +7,8 @@ description: Structures plan.md files for parallel, non-overlapping agent work. Plans enable **parallel, non-overlapping work**. Each task must be independent; agents work in a **dedicated git worktree** (see worktrees skill). +**Where to keep plan.md and spec files:** Create and edit plan.md (and the other Ralph spec files: goal.md/spec.md, state.json, decisions.md) in a **dedicated folder** under the **specs** folder at the **repo root**: `specs//`. Example: `specs/resizable-container/plan.md`. Do not place spec files next to source (e.g. not under `src/components/...`). + ## Required plan.md Structure 1. **Worktree section** (top) — path, branch, base branch. See worktrees skill. diff --git a/.cursor/skills/make-plans/reference.md b/.cursor/skills/make-plans/reference.md index a34901799a..8d3bc7c67f 100644 --- a/.cursor/skills/make-plans/reference.md +++ b/.cursor/skills/make-plans/reference.md @@ -2,6 +2,8 @@ Source: `.ai/MAKE_PLAN.md`. Condensed in SKILL.md; details here. +**Spec files location:** Create plan.md (and other Ralph spec files) under `specs//` at repo root. + ## Execution Order Example ```md diff --git a/.cursor/skills/ralph-protocol/SKILL.md b/.cursor/skills/ralph-protocol/SKILL.md index 57bdb5e3e7..644f445465 100644 --- a/.cursor/skills/ralph-protocol/SKILL.md +++ b/.cursor/skills/ralph-protocol/SKILL.md @@ -1,6 +1,6 @@ --- name: ralph-protocol -description: Collaboration protocol for Ralph loop (Plan → Act → Reflect → Refine). Use when working from goal.md, plan.md, state.json, decisions.md; when executing tasks in a shared plan; or when the user mentions Ralph, multi-agent, or file-based collaboration. +description: Collaboration protocol for Ralph loop (Plan → Act → Reflect → Refine). Use when working from spec files in specs// (goal.md/spec.md, plan.md, state.json, decisions.md); when executing tasks in a shared plan; or when the user mentions Ralph, multi-agent, or file-based collaboration. --- # Ralph Protocol (Agent Collaboration) @@ -9,27 +9,29 @@ Files are the source of truth. All agents share memory via files. No silent deci ## Required Files -| File | Purpose | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **goal.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if goal actually changes. No implementation details. | -| **plan.md** | How we achieve it. Ordered tasks, ownership, dependencies, status (`pending \| in-progress \| done \| blocked`). Propose changes before big deviations; don't rewrite completed sections. | -| **state.json** | Current memory. Task statuses, flags (`blocked`, `needs-review`, etc.). Update immediately after acting. Read before assuming anything. | -| **decisions.md** | Log of non-trivial decisions (what + why). Append only; never delete. Prevents reopening or contradicting past choices. | +**Location:** Generate and keep spec files in a **dedicated folder** inside the **specs** folder at the **root of the repo**: `specs//`. Example: `specs/resizable-container/goal.md`, `specs/resizable-container/plan.md`, `specs/resizable-container/state.json`, `specs/resizable-container/decisions.md`. One folder per feature or component; do not put spec files next to source (e.g. not under `src/components/...`). + +| File | Purpose | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **spec.md** or **goal.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if goal actually changes. No implementation details. | +| **plan.md** | How we achieve it. Ordered tasks, ownership, dependencies, status (`pending \| in-progress \| done \| blocked`). Propose changes before big deviations; don't rewrite completed sections. | +| **state.json** | Current memory. Task statuses, flags (`blocked`, `needs-review`, etc.). Update immediately after acting. Read before assuming anything. | +| **decisions.md** | Log of non-trivial decisions (what + why). Append only; never delete. Prevents reopening or contradicting past choices. | ## Workflow -**Before acting:** Read goal.md → plan.md → state.json → decisions.md. +**Before acting:** Read the spec files in `specs//`: spec.md (or goal.md) → plan.md → state.json → decisions.md. **During:** Follow the plan; no overlapping work unless coordinated; no undocumented decisions. -**After:** Update state.json → record decisions in decisions.md → update task status in plan.md. Optionally add learnings to observations.md. +**After:** Update `specs//state.json` → record decisions in `specs//decisions.md` → update task status in `specs//plan.md`. Optionally add learnings to observations.md in the same folder. **Prohibited:** Decisions without recording; using chat as memory; re-solving done problems; changing goals implicitly; overwriting files without explanation. ## Task ownership (critical) - Work on **exactly one** task at a time. -- That task must be in plan.md, marked `in-progress` and assigned to you. +- That task must be in the plan file (`specs//plan.md`), marked `in-progress` and assigned to you. - Do not change files for other tasks, even if small. ## Commit scope @@ -42,6 +44,6 @@ When acceptance criteria involve UI: use Playwright (MCP or project config). Tak ## Loop reminder -Each iteration: Plan (update plan.md if needed) → Act (scoped work) → Reflect (learnings) → Refine (plan or decisions). +Each iteration: Plan (update `specs//plan.md` if needed) → Act (scoped work) → Reflect (learnings) → Refine (plan or decisions). For decision log format and state.json example, see [reference.md](reference.md). Plan structure and worktrees: use make-plans and worktrees skills. diff --git a/.cursor/skills/ralph-protocol/reference.md b/.cursor/skills/ralph-protocol/reference.md index fe59891e7d..f417ed2258 100644 --- a/.cursor/skills/ralph-protocol/reference.md +++ b/.cursor/skills/ralph-protocol/reference.md @@ -2,6 +2,8 @@ Source: `.ai/RALPH.md` +**Spec files location:** Keep all spec files (spec.md, plan.md, state.json, decisions.md) in a dedicated folder at repo root: `specs//`. + ## state.json example ```json diff --git a/.cursor/skills/worktrees/SKILL.md b/.cursor/skills/worktrees/SKILL.md index b7b0fff506..bda610383f 100644 --- a/.cursor/skills/worktrees/SKILL.md +++ b/.cursor/skills/worktrees/SKILL.md @@ -23,7 +23,7 @@ git worktree add ../stream-chat-react-worktrees/gallery-redesign -b feat/gallery - **Branch:** `feat/` (repo conventions) - **Base:** current branch when creating -Then in plan.md include a **Worktree** section with path, branch, base branch. Agent must `cd` into the worktree before any work. +Then in the plan file (`specs//plan.md`) include a **Worktree** section with path, branch, base branch. Agent must `cd` into the worktree before any work. ```bash cd ../stream-chat-react-worktrees/ @@ -69,7 +69,7 @@ git push origin agent/ ``` - Do this after each meaningful milestone or when someone needs to preview. -- Document in plan.md: **Preview branch:** `agent/` — checkout to preview. +- Document in `specs//plan.md`: **Preview branch:** `agent/` — checkout to preview. ## Lifecycle diff --git a/examples/vite/package.json b/examples/vite/package.json index 6fd17cbc6c..3ccd0cd5bf 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port 5175", "build": "tsc && vite build", "preview": "vite preview" }, diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bca9c42dbc..fcc3f52550 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -17,6 +17,9 @@ import { Chat, ChatView, ReactionsList, + MessageInput, + type NotificationListProps, + NotificationList, WithComponents, defaultReactionOptions, type ReactionOptions, @@ -129,6 +132,12 @@ const EmojiPickerWithCustomOptions = ( return ; }; +const ConfigurableNotificationList = (props: NotificationListProps) => { + const { verticalAlignment } = useAppSettingsSelector((state) => state.notifications); + + return ; +}; + const App = () => { const { tokenProvider, userId } = useUser(); const chatView = useAppSettingsSelector((state) => state.chatView); @@ -245,6 +254,8 @@ const App = () => { overrides={{ emojiSearchIndex: SearchIndex, EmojiPicker: EmojiPickerWithCustomOptions, + MessageListNotifications: ConfigurableNotificationList, + NotificationList: ConfigurableNotificationList, ReactionsList: CustomMessageReactions, reactionOptions: newReactionOptions, Search: CustomChannelSearch, diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx new file mode 100644 index 0000000000..0b5ca4b884 --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -0,0 +1,116 @@ +import { useMemo, useState } from 'react'; +import type { ComponentProps } from 'react'; +import { + Button, + ContextMenu, + ContextMenuButton, + DialogManagerProvider, + IconThunder, + useDialogIsOpen, + useDialogOnNearestManager, + type ContextMenuItemComponent, +} from 'stream-chat-react'; +import { + NotificationPromptDialog, + notificationPromptDialogId, +} from './NotificationPromptDialog'; + +const actionsMenuDialogId = 'app-actions-menu'; + +const ActionsMenuButton = ({ + iconOnly, + isOpen, + onClick, + refCallback, +}: { + iconOnly: boolean; + isOpen: boolean; + onClick: ComponentProps<'button'>['onClick']; + refCallback: (element: HTMLButtonElement | null) => void; +}) => ( +
+ + {iconOnly && ( + + )} +
+); + +export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => ( + + + +); + +const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { + const [menuButtonElement, setMenuButtonElement] = useState( + null, + ); + const { dialog: actionsMenuDialog, dialogManager } = useDialogOnNearestManager({ + id: actionsMenuDialogId, + }); + const { dialog: notificationDialog } = useDialogOnNearestManager({ + id: notificationPromptDialogId, + }); + const menuIsOpen = useDialogIsOpen(actionsMenuDialogId, dialogManager?.id); + + const rootMenuItems = useMemo( + () => [ + function TriggerNotification({ closeMenu }) { + return ( + { + closeMenu(); + notificationDialog.open(); + }} + /> + ); + }, + ], + [notificationDialog], + ); + + return ( +
+ actionsMenuDialog.toggle()} + refCallback={setMenuButtonElement} + /> + + +
+ ); +}; diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx new file mode 100644 index 0000000000..6d381636a4 --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -0,0 +1,499 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Dispatch, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; +import type { NotificationSeverity } from 'stream-chat'; +import { + addNotificationTargetTag, + IconArrowDown, + IconArrowLeft, + IconArrowRight, + Button, + DialogAnchor, + IconArrowRotateRightLeftRepeatRefresh, + IconArrowUp, + IconCheckmark2, + IconCircleInfoTooltip, + IconClock, + IconCrossMedium, + IconExclamationCircle, + IconExclamationTriangle, + IconPlusSmall, + NumericInput, + Prompt, + TextInput, + useChatContext, + useDialogIsOpen, + useDialogOnNearestManager, + type NotificationListEnterFrom, + type NotificationTargetPanel, +} from 'stream-chat-react'; +import { + buildNotificationActions, + entryDirectionOptions, + initialDraft, + isDraftReady, + parseDuration, + severityOptions, + targetPanelOptions, + type NotificationDraft, + type QueuedNotification, +} from './triggerNotificationUtils'; + +export const notificationPromptDialogId = 'app-notification-prompt-dialog'; + +const VIEWPORT_MARGIN = 8; + +const clamp = (value: number, min: number, max: number) => { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +const severityIcons: Partial< + Record> +> = { + error: IconExclamationCircle, + info: IconCircleInfoTooltip, + loading: IconArrowRotateRightLeftRepeatRefresh, + success: IconCheckmark2, + warning: IconExclamationTriangle, +}; + +const directionIcons: Record< + NotificationListEnterFrom, + React.ComponentType<{ className?: string }> +> = { + bottom: IconArrowUp, + left: IconArrowRight, + right: IconArrowLeft, + top: IconArrowDown, +}; + +const formatDurationLabel = (duration: number) => { + if (duration === 0) return 'manual'; + if (duration < 1000) return `${duration}ms`; + if (duration % 1000 === 0) return `${duration / 1000}s`; + return `${(duration / 1000).toFixed(1)}s`; +}; + +const NotificationEntrySelect = ({ + label, + onChange, + options, + value, +}: { + label: string; + onChange: (value: string) => void; + options: readonly string[]; + value: string; +}) => ( + +); + +const NotificationChipList = ({ + notifications, + removeQueuedNotification, +}: { + notifications: QueuedNotification[]; + removeQueuedNotification: (id: string) => void; +}) => ( +
+ {notifications.map((notification) => { + const SeverityIcon = severityIcons[notification.severity]; + const DirectionIcon = directionIcons[notification.entryDirection]; + + return ( +
+ {SeverityIcon && ( + + )} + + {notification.message} + + + + + {formatDurationLabel(notification.duration)} + + + + {notification.entryDirection} + + + {notification.targetPanel} + + + +
+ ); + })} +
+); + +const NotificationDraftForm = ({ + draft, + queueCurrentDraft, + queuedNotifications, + registerQueuedNotifications, + removeQueuedNotification, + setDraft, +}: { + draft: NotificationDraft; + queueCurrentDraft: () => void; + queuedNotifications: QueuedNotification[]; + registerQueuedNotifications: () => void; + removeQueuedNotification: (id: string) => void; + setDraft: Dispatch>; +}) => { + const canQueueCurrentDraft = isDraftReady(draft); + + return ( + <> + +
+
+ + setDraft((current) => ({ ...current, message: event.target.value })) + } + placeholder='Notification message' + value={draft.message} + /> + + setDraft((current) => ({ + ...current, + severity: value as NotificationSeverity, + })) + } + options={severityOptions} + value={draft.severity} + /> + + + setDraft((current) => ({ + ...current, + entryDirection: value as NotificationListEnterFrom, + })) + } + options={entryDirectionOptions} + value={draft.entryDirection} + /> + + setDraft((current) => ({ + ...current, + targetPanel: value as NotificationTargetPanel, + })) + } + options={targetPanelOptions} + value={draft.targetPanel} + /> + + setDraft((current) => ({ ...current, actionLabel: event.target.value })) + } + placeholder='Optional button label' + value={draft.actionLabel} + /> + + setDraft((current) => ({ + ...current, + actionFeedback: event.target.value, + })) + } + placeholder='Optional notification triggered by the action button' + value={draft.actionFeedback} + /> +
+
+
+ +
+
+ +
+ +
+ + + Ok + + +
+ + ); +}; + +export const NotificationPromptDialog = ({ + referenceElement, +}: { + referenceElement: HTMLElement | null; +}) => { + const [draft, setDraft] = useState(initialDraft); + const [queuedNotifications, setQueuedNotifications] = useState( + [], + ); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const chipIdRef = useRef(0); + const shellRef = useRef(null); + const { client } = useChatContext(); + const { dialog, dialogManager } = useDialogOnNearestManager({ + id: notificationPromptDialogId, + }); + const dialogIsOpen = useDialogIsOpen(notificationPromptDialogId, dialogManager?.id); + + const resetState = useCallback(() => { + setDraft(initialDraft); + setQueuedNotifications([]); + setDragOffset({ x: 0, y: 0 }); + }, []); + + useEffect(() => { + if (dialogIsOpen) return; + resetState(); + }, [dialogIsOpen, resetState]); + + useEffect(() => { + if (!dialogIsOpen) return; + + const clampToViewport = () => { + const shell = shellRef.current; + if (!shell) return; + + const rect = shell.getBoundingClientRect(); + const nextLeft = clamp( + rect.left, + VIEWPORT_MARGIN, + window.innerWidth - rect.width - VIEWPORT_MARGIN, + ); + const nextTop = clamp( + rect.top, + VIEWPORT_MARGIN, + window.innerHeight - rect.height - VIEWPORT_MARGIN, + ); + + if (nextLeft === rect.left && nextTop === rect.top) return; + + setDragOffset((current) => ({ + x: current.x + (nextLeft - rect.left), + y: current.y + (nextTop - rect.top), + })); + }; + + window.addEventListener('resize', clampToViewport); + + return () => { + window.removeEventListener('resize', clampToViewport); + }; + }, [dialogIsOpen]); + + const closeDialog = useCallback(() => { + dialog.close(); + }, [dialog]); + + const addNotification = useCallback( + (notification: QueuedNotification) => { + client.notifications.add({ + message: notification.message, + origin: { + context: { + entryDirection: notification.entryDirection, + panel: notification.targetPanel, + }, + emitter: 'vite-preview/ActionsMenu', + }, + options: { + actions: buildNotificationActions(notification), + duration: notification.duration, + severity: notification.severity, + tags: addNotificationTargetTag(notification.targetPanel), + }, + }); + }, + [client], + ); + + const queueCurrentDraft = useCallback(() => { + const duration = parseDuration(draft.duration); + if (!isDraftReady(draft) || duration === null) return; + + chipIdRef.current += 1; + setQueuedNotifications((current) => [ + ...current, + { + actionFeedback: draft.actionFeedback, + actionLabel: draft.actionLabel, + duration, + entryDirection: draft.entryDirection as NotificationListEnterFrom, + id: `queued-notification-${chipIdRef.current}`, + message: draft.message.trim(), + severity: draft.severity as NotificationSeverity, + targetPanel: draft.targetPanel as NotificationTargetPanel, + }, + ]); + setDraft(initialDraft); + }, [draft]); + + const registerQueuedNotifications = useCallback(() => { + queuedNotifications.forEach(addNotification); + closeDialog(); + }, [addNotification, closeDialog, queuedNotifications]); + + const removeQueuedNotification = useCallback((id: string) => { + setQueuedNotifications((current) => current.filter((item) => item.id !== id)); + }, []); + + const handleHeaderPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + if (!(event.target instanceof HTMLElement)) return; + if (event.target.closest('button')) return; + + const shell = shellRef.current; + if (!shell) return; + + event.preventDefault(); + + const startClientX = event.clientX; + const startClientY = event.clientY; + const startOffset = dragOffset; + const startRect = shell.getBoundingClientRect(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextLeft = clamp( + startRect.left + (moveEvent.clientX - startClientX), + VIEWPORT_MARGIN, + window.innerWidth - startRect.width - VIEWPORT_MARGIN, + ); + const nextTop = clamp( + startRect.top + (moveEvent.clientY - startClientY), + VIEWPORT_MARGIN, + window.innerHeight - startRect.height - VIEWPORT_MARGIN, + ); + + setDragOffset({ + x: startOffset.x + (nextLeft - startRect.left), + y: startOffset.y + (nextTop - startRect.top), + }); + }; + + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }, + [dragOffset], + ); + + const shellStyle = { + transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`, + }; + + return ( + +
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/examples/vite/src/AppSettings/ActionsMenu/index.ts b/examples/vite/src/AppSettings/ActionsMenu/index.ts new file mode 100644 index 0000000000..63eb75d2a4 --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/index.ts @@ -0,0 +1 @@ +export * from './ActionsMenu'; diff --git a/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts b/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts new file mode 100644 index 0000000000..d623f61678 --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts @@ -0,0 +1,90 @@ +import type { NotificationAction, NotificationSeverity } from 'stream-chat'; +import type { + NotificationListEnterFrom, + NotificationTargetPanel, +} from 'stream-chat-react'; + +export const severityOptions = [ + 'error', + 'warning', + 'info', + 'success', + 'loading', +] as const satisfies NotificationSeverity[]; + +export const entryDirectionOptions = [ + 'bottom', + 'left', + 'right', + 'top', +] as const satisfies NotificationListEnterFrom[]; + +export const targetPanelOptions = [ + 'channel', + 'thread', + 'channel-list', + 'thread-list', +] as const satisfies NotificationTargetPanel[]; + +export type NotificationDraft = { + actionFeedback: string; + actionLabel: string; + duration: string; + entryDirection: NotificationListEnterFrom | ''; + message: string; + severity: NotificationSeverity | ''; + targetPanel: NotificationTargetPanel | ''; +}; + +export type QueuedNotification = { + actionFeedback: string; + actionLabel: string; + duration: number; + entryDirection: NotificationListEnterFrom; + id: string; + message: string; + severity: NotificationSeverity; + targetPanel: NotificationTargetPanel; +}; + +export const initialDraft: NotificationDraft = { + actionFeedback: '', + actionLabel: '', + duration: '5000', + entryDirection: 'bottom', + message: '', + severity: 'info', + targetPanel: 'channel', +}; + +export const parseDuration = (value: string) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +export const isDraftReady = (draft: NotificationDraft) => + Boolean( + draft.message.trim() && + draft.severity && + draft.entryDirection && + draft.targetPanel && + parseDuration(draft.duration) !== null, + ); + +export const buildNotificationActions = ( + queuedNotification: QueuedNotification, +): NotificationAction[] | undefined => { + if (!queuedNotification.actionLabel.trim()) return; + + return [ + { + handler: () => { + window.alert( + queuedNotification.actionFeedback.trim() || + `${queuedNotification.actionLabel.trim()} clicked`, + ); + }, + label: queuedNotification.actionLabel.trim(), + }, + ]; +}; diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 64e8eb4d13..37d41361bc 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -11,13 +11,247 @@ } } + .app__actions-menu-anchor { + position: relative; + + .str-chat__icon--thunder { + fill: none; + stroke: currentColor; + } + } + + .app__actions-menu { + min-width: min(320px, calc(100vw - 32px)); + max-width: min(320px, calc(100vw - 32px)); + } + + .app__notification-dialog { + min-width: min(420px, calc(100vw - 32px)); + max-width: min(420px, calc(100vw - 32px)); + } + + .app__notification-dialog__prompt { + display: flex; + flex-direction: column; + width: min(420px, calc(100vw - 32px)); + max-height: min(500px, calc(100vh - 32px)); + overflow: hidden; + } + + .app__notification-dialog__drag-handle { + cursor: grab; + touch-action: none; + } + + .app__notification-dialog__drag-handle:active { + cursor: grabbing; + } + + .app__notification-dialog__body { + flex: 1 1 auto; + min-height: 0; + padding-top: 4px; + } + + .app__notification-dialog__body-content { + display: flex; + flex-direction: column; + gap: 16px; + } + + .app__notification-dialog__form-grid { + display: grid; + gap: 12px; + } + + .app__notification-dialog__field { + display: flex; + flex-direction: column; + gap: 8px; + } + + .app__notification-dialog__field-label { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + } + + .app__notification-dialog__select { + min-height: 40px; + padding: 0 12px; + border: 1px solid var(--border-core-default); + border-radius: 10px; + background: var(--background-core-elevation-2); + color: var(--text-primary); + font: inherit; + } + + .app__notification-dialog__text-input { + width: 100%; + } + + .app__notification-dialog__text-input--wide { + grid-column: 1 / -1; + } + + .app__notification-dialog__footer-leading { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + .app__notification-dialog__queue-controls { + display: flex; + align-items: center; + min-width: 0; + } + + .app__notification-dialog__queue-trigger { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + } + + .app__notification-dialog__queue-trigger:disabled { + cursor: not-allowed; + } + + .app__notification-dialog__queue-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border: 1px solid var(--border-core-default); + border-radius: 999px; + background: transparent; + } + + .app__notification-dialog__queue-button-icon { + width: 16px; + height: 16px; + transition: + width 120ms ease, + height 120ms ease; + } + + .app__notification-dialog__queue-button-icon--enabled { + width: 20px; + height: 20px; + } + + .app__notification-dialog__queue-hint { + color: var(--text-secondary); + font-size: 13px; + } + + .app__notification-dialog__footer-leading .app__notification-dialog__queue-hint { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app__notification-dialog__chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 24px; + width: 100%; + } + + .app__notification-dialog__chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 6px 8px 6px 10px; + border: 1px solid var(--border-core-default); + border-radius: 999px; + background: var(--background-core-surface); + } + + .app__notification-dialog__chip-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + } + + .app__notification-dialog__chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app__notification-dialog__chip-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + } + + .app__notification-dialog__chip-meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .app__notification-dialog__chip-meta-icon { + width: 14px; + height: 14px; + flex-shrink: 0; + } + + .app__notification-dialog__chip-panel { + font-style: italic; + } + + .app__notification-dialog__chip-remove { + color: var(--text-secondary); + } + + .app__notification-dialog__footer { + display: flex; + align-items: stretch; + flex-direction: column; + flex-shrink: 0; + gap: 12px; + } + + .app__notification-dialog__footer-controls { + align-self: flex-end; + flex-shrink: 0; + margin-inline-start: 0; + } + + @media (max-width: 560px) { + .app__notification-dialog__queue-controls { + width: 100%; + } + + .app__notification-dialog__footer-leading .app__notification-dialog__queue-hint { + white-space: normal; + } + } + .app__settings-modal { display: flex; flex-direction: column; width: min(920px, 90vw); max-height: min(80vh, 760px); min-height: min(520px, 72vh); - background: var(--background-elevation-elevation-2); + background: var(--background-core-elevation-2); color: var(--text-primary); border: 1px solid var(--border-core-default); border-radius: 14px; @@ -63,7 +297,7 @@ .app__settings-modal__tab[aria-selected='true'], .app__settings-modal__tab.app__settings-modal__tab--active { - background: var(--background-core-selected); + background: var(--background-utility-selected); border-color: var(--border-utility-selected); color: var(--text-primary); font-weight: 600; @@ -100,7 +334,7 @@ .app__settings-modal__option-button[aria-pressed='true'] { border-color: var(--border-utility-selected); - background: var(--background-core-selected); + background: var(--background-utility-selected); font-weight: 600; } @@ -130,6 +364,16 @@ } @media (max-width: 760px) { + .app__actions-menu, + .app__notification-dialog { + min-width: min(360px, calc(100vw - 24px)); + max-width: min(360px, calc(100vw - 24px)); + } + + .app__notification-dialog__prompt { + width: min(360px, calc(100vw - 24px)); + } + .app__settings-modal { width: min(92vw, 680px); } diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 485d648246..52cc220c57 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -3,18 +3,22 @@ import { ChatViewSelectorButton, GlobalModal, IconBubble3ChatMessage, + IconBellNotification, IconEmojiSmile, IconLightBulbSimple, IconSettingsGear2, } from 'stream-chat-react'; import { type ComponentType, useState } from 'react'; +import { ActionsMenu } from './ActionsMenu'; +import { NotificationsTab } from './tabs/Notifications'; import { ReactionsTab } from './tabs/Reactions'; import { SidebarTab } from './tabs/Sidebar'; import { appSettingsStore, useAppSettingsState } from './state'; -type TabId = 'reactions' | 'sidebar'; +type TabId = 'notifications' | 'reactions' | 'sidebar'; const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [ + { Icon: IconBellNotification, id: 'notifications', title: 'Notifications' }, { Icon: IconBubble3ChatMessage, id: 'sidebar', title: 'Sidebar' }, { Icon: IconEmojiSmile, id: 'reactions', title: 'Reactions' }, ]; @@ -51,6 +55,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { return (
+ { id={`${activeTab}-content`} role='tabpanel' > + {activeTab === 'notifications' && } {activeTab === 'sidebar' && } {activeTab === 'reactions' && } diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 4f0ab4c07a..48fad761a1 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -15,6 +15,10 @@ export type ThemeSettingsState = { mode: 'dark' | 'light'; }; +export type NotificationsSettingsState = { + verticalAlignment: 'bottom' | 'top'; +}; + export const LEFT_PANEL_MIN_WIDTH = 360; export const THREAD_PANEL_MIN_WIDTH = 360; @@ -35,6 +39,7 @@ export type PanelLayoutSettingsState = { export type AppSettingsState = { chatView: ChatViewSettingsState; + notifications: NotificationsSettingsState; panelLayout: PanelLayoutSettingsState; reactions: ReactionsSettingsState; theme: ThemeSettingsState; @@ -59,6 +64,9 @@ const defaultAppSettingsState: AppSettingsState = { chatView: { iconOnly: true, }, + notifications: { + verticalAlignment: 'bottom', + }, panelLayout: { leftPanel: { collapsed: false, diff --git a/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx b/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx new file mode 100644 index 0000000000..468b5a1426 --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/Notifications/NotificationsTab.tsx @@ -0,0 +1,41 @@ +import { Button } from 'stream-chat-react'; +import { appSettingsStore, useAppSettingsState } from '../../state'; + +export const NotificationsTab = () => { + const { + notifications, + notifications: { verticalAlignment }, + } = useAppSettingsState(); + + return ( +
+
+
Vertical alignment
+
+ + +
+
+
+ ); +}; diff --git a/examples/vite/src/AppSettings/tabs/Notifications/index.ts b/examples/vite/src/AppSettings/tabs/Notifications/index.ts new file mode 100644 index 0000000000..4d9f9a415e --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/Notifications/index.ts @@ -0,0 +1 @@ +export * from './NotificationsTab'; diff --git a/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts b/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts index 836df32e7c..b186e61d53 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts +++ b/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts @@ -144,7 +144,6 @@ export const reactionsPreviewChannelState = { }; export const reactionsPreviewChannelActions = { - addNotification: () => undefined, closeThread: () => undefined, onMentionsClick: () => undefined, onMentionsHover: () => undefined, diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 281af3a046..14108b9827 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -105,7 +105,7 @@ body { width: 1px; min-width: 1px; height: 100%; - z-index: 2; + z-index: 1; } .app-chat-resize-handle__hitbox { diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 8fb5397f4c..66229903ae 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -37,7 +37,7 @@ //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-layout'; //@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-layout'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-layout'; -@use 'stream-chat-react/dist/scss/v2/Notification/Notification-layout'; +//@use 'stream-chat-react/dist/scss/v2/Notification/Notification-layout'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-layout'; //@use 'stream-chat-react/dist/scss/v2/Thread/Thread-layout'; //@use 'stream-chat-react/dist/scss/v2/Search/Search-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index f563d5017a..9829cc529b 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -31,7 +31,7 @@ //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-theme'; //@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-theme'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-theme'; -@use 'stream-chat-react/dist/scss/v2/Notification/Notification-theme'; +//@use 'stream-chat-react/dist/scss/v2/Notification/Notification-theme'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-theme'; //@use 'stream-chat-react/dist/scss/v2/Thread/Thread-theme'; //@use 'stream-chat-react/dist/scss/v2/Search/Search-theme'; diff --git a/package.json b/package.json index ddc16c5358..b03483a72d 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "emoji-mart": "^5.4.0", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^9.37.0" + "stream-chat": "^9.38.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -206,7 +206,7 @@ "react-dom": "^19.0.0", "sass": "^1.97.2", "semantic-release": "^25.0.2", - "stream-chat": "^9.37.0", + "stream-chat": "^9.38.0", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0", diff --git a/specs/notification-target-system/decisions.md b/specs/notification-target-system/decisions.md new file mode 100644 index 0000000000..a74879ce9d --- /dev/null +++ b/specs/notification-target-system/decisions.md @@ -0,0 +1,44 @@ +# Decisions + +## 2026-03-12 - Feature scope and naming + +- Use `useNotificationTarget` as the canonical hook name for panel targeting. +- Treat `channel`, `thread`, `channel-list`, `thread-list` as first-class notification panel targets. +- Keep NotificationManager behavior unchanged; implement targeting/queueing at consumer hook + UI layer. + +## 2026-03-12 - Target fallback behavior + +- Notifications without `origin.context.panel` are treated as `channel` by default in panel matching helpers. +- This keeps backward compatibility for existing emitters while enabling strict panel routing for new emitters. + +## 2026-03-12 - Queueing approach + +- Queueing is implemented at notification consumer level via `useQueuedNotifications`. +- `NotificationList` renders only the visible window (`maxVisibleCount`) while preserving overflow in queue. + +## 2026-03-12 - Panel mount strategy + +- Panel-scoped `NotificationList` instances are mounted at panel roots (`Channel`, `Thread`, `ChannelList`, `ThreadList`). +- Legacy `MessageListNotifications` no longer renders the unscoped client notification list to avoid duplicates. + +## 2026-03-12 - Emitter migration scope + +- Migrated panel detection in key emitters (`Message`, `MessageInput`, `useAudioController`, `ShareLocationDialog`, `Channel jumpToFirstUnread`) to use `useNotificationTarget`. +- Kept emitter IDs and options semantics intact while standardizing `origin.context.panel` generation. + +## 2026-03-12 - Notification target tags + +- Added `tags?: string[]` to `stream-chat-js` notification model/options and propagated through `NotificationManager`. +- `stream-chat-react` now assigns `target:` tags internally for notification emissions, while still keeping `origin.context.panel`. +- Panel resolution now prioritizes target tags and falls back to `origin.context.panel` for backward compatibility. + +## 2026-03-12 - Naming cleanup + +- Introduced `NotificationTargetPanel` as the primary panel target type. +- Kept `NotificationOriginPanel` and related helper aliases as deprecated compatibility exports. + +## 2026-03-12 - Notification translation optimization + +- Replaced per-file default notification translator functions with a single generic type-aware translator in NotificationTranslationTopic. +- Added explicit handling for notification types emitted from both stream-chat-js and stream-chat-react that were previously not translated. +- Kept custom translator registration override semantics: exact type translators still take precedence over fallback translator. diff --git a/specs/notification-target-system/goal.md b/specs/notification-target-system/goal.md new file mode 100644 index 0000000000..863b2eba52 --- /dev/null +++ b/specs/notification-target-system/goal.md @@ -0,0 +1,29 @@ +# Notification Target System + +## Goal + +Introduce a notification targeting system that lets SDK components emit notifications that are consumed by panel-specific `NotificationList` instances. + +## Success Criteria + +- Notifications can be targeted to one of the following panels: `channel`, `thread`, `channel-list`, `thread-list`. +- A reusable hook named `useNotificationTarget` is available and used instead of repeating local panel detection logic. +- `NotificationList` can consume notifications for a specific panel via shared targeting/filtering helpers. +- `NotificationList` supports queueing behavior when emitted notifications exceed the configured visible limit. +- Panel roots support rendering panel-scoped notification lists in: + - `Channel.tsx` + - `Thread.tsx` + - `ChannelList.tsx` + - `ThreadList.tsx` + +## Constraints + +- Preserve backward compatibility of existing notification manager behavior. +- Keep public API additions additive and typed. +- Keep styling and rendering conventions aligned with existing SDK patterns. + +## Non-goals + +- Replacing `stream-chat` `NotificationManager` internals. +- Redesigning notification visuals. +- Full migration of every notification emitter in the repository in this iteration. diff --git a/specs/notification-target-system/plan.md b/specs/notification-target-system/plan.md new file mode 100644 index 0000000000..0051c19167 --- /dev/null +++ b/specs/notification-target-system/plan.md @@ -0,0 +1,158 @@ +# Worktree + +- **Path:** `/Users/martincupela/Projects/stream/chat/stream-chat-react` +- **Branch:** `feat/toast-notification-ui` +- **Base branch:** `master` + +# Task Overview + +Tasks are self-contained and non-overlapping where possible; tasks touching shared notification modules are chained by dependencies. + +## Task 1: Define Notification Targeting Primitives + +**File(s) to create/modify:** `src/components/Notifications/notificationOrigin.ts`, `src/components/Notifications/hooks/useNotificationTarget.ts`, `src/components/Notifications/hooks/index.ts`, `src/components/Notifications/index.ts` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Expand panel target typing beyond channel/thread. +- Add `useNotificationTarget` hook to centralize target panel selection. +- Add reusable helpers for panel-based notification filtering. +- Export new APIs through Notifications barrels. + +**Acceptance Criteria:** + +- [x] Panel target type includes `channel`, `thread`, `channel-list`, `thread-list`. +- [x] `useNotificationTarget` resolves target consistently for channel/thread contexts. +- [x] Consumers can build panel filters without duplicating shape checks. +- [x] New hook/types are exported from Notifications public module. + +## Task 2: Add Queue-Aware Panel Consumption in NotificationList + +**File(s) to create/modify:** `src/components/Notifications/hooks/useNotifications.ts`, `src/components/Notifications/hooks/useQueuedNotifications.ts`, `src/components/Notifications/NotificationList.tsx`, `src/components/Notifications/hooks/index.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce queue-focused notifications hook for list consumption. +- Keep visible window bounded by `maxVisibleCount`, expose queued count. +- Support panel-based filtering in a typed and reusable way. + +**Acceptance Criteria:** + +- [x] Notification list behavior is deterministic when notifications exceed visible cap. +- [x] Queueing behavior is implemented through reusable hook(s), not ad hoc slicing in component code. +- [x] Existing NotificationList API remains backward compatible. + +## Task 3: Wire Panel-Level NotificationList Mount Points + +**File(s) to create/modify:** `src/components/Channel/Channel.tsx`, `src/components/Thread/Thread.tsx`, `src/components/ChannelList/ChannelList.tsx`, `src/components/Threads/ThreadList/ThreadList.tsx`, `src/components/MessageList/MessageListNotifications.tsx` + +**Dependencies:** Task 2 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Mount panel-targeted `NotificationList` in each panel root. +- Remove conflicting duplicate unfiltered client notification list usage in message list notifications. +- Keep existing per-channel legacy notifications + connection status behavior intact. + +**Acceptance Criteria:** + +- [x] Panel roots render NotificationList with panel-target filters. +- [x] No duplicate rendering of same client notification across multiple mounted lists. +- [x] Existing message list legacy notifications continue to render. + +## Task 4: Migrate Core Emitters to useNotificationTarget + +**File(s) to create/modify:** `src/components/Message/Message.tsx`, `src/components/MessageInput/hooks/useSubmitHandler.ts`, `src/components/Attachment/hooks/useAudioController.ts`, `src/components/Location/ShareLocationDialog.tsx`, `src/components/Channel/Channel.tsx` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Replace repeated thread/channel target detection with `useNotificationTarget`. +- Keep emitter IDs/options intact while unifying origin context shape. + +**Acceptance Criteria:** + +- [x] Repeated panel derivation logic is removed from listed files. +- [x] Emitted notifications from these files include `origin.context.panel` from shared hook. +- [x] TypeScript remains clean for changed files. + +## Task 5: Add/Adjust Tests for Targeting + Queueing + +**File(s) to create/modify:** `src/components/Notifications/__tests__/...` (new/updated), `src/components/Channel/__tests__/Channel.test.js` and/or `src/components/Message/__tests__/Message.test.js` (as needed) + +**Dependencies:** Task 2, Task 3, Task 4 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add focused tests for target filtering and queue behavior. +- Update impacted tests for new hook-driven origin context. + +**Acceptance Criteria:** + +- [x] Queue behavior is test-covered (overflow stays queued). +- [x] Panel filtering behavior is test-covered. +- [x] Existing adapted tests pass with new origin behavior. + +## Task 6: Validate and Finalize + +**File(s) to create/modify:** `specs/notification-target-system/state.json`, `specs/notification-target-system/decisions.md`, `specs/notification-target-system/plan.md` + +**Dependencies:** Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Run targeted test/typecheck command(s). +- Record final decisions and state updates. + +**Acceptance Criteria:** + +- [x] Relevant tests/types executed or documented if blocked. +- [x] Ralph files updated with final statuses and decisions. + +# Execution Order + +- **Phase 1 (sequential):** Task 1 +- **Phase 2 (sequential):** Task 2 +- **Phase 3 (parallel possible):** Task 3 and Task 4 +- **Phase 4 (sequential):** Task 5 +- **Phase 5 (sequential):** Task 6 + +# File Ownership Summary + +| Task | Creates/Modifies | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task 1 | `src/components/Notifications/notificationOrigin.ts`, `src/components/Notifications/hooks/useNotificationTarget.ts`, notifications barrels | +| Task 2 | `src/components/Notifications/hooks/useNotifications.ts`, `src/components/Notifications/hooks/useQueuedNotifications.ts`, `src/components/Notifications/NotificationList.tsx` | +| Task 3 | `src/components/Channel/Channel.tsx`, `src/components/Thread/Thread.tsx`, `src/components/ChannelList/ChannelList.tsx`, `src/components/Threads/ThreadList/ThreadList.tsx`, `src/components/MessageList/MessageListNotifications.tsx` | +| Task 4 | Notification emitters in Message/MessageInput/Attachment/Location/Channel | +| Task 5 | Notifications tests + impacted existing tests | +| Task 6 | Ralph protocol files in `specs/notification-target-system` | diff --git a/specs/notification-target-system/state.json b/specs/notification-target-system/state.json new file mode 100644 index 0000000000..97d31d6f6b --- /dev/null +++ b/specs/notification-target-system/state.json @@ -0,0 +1,28 @@ +{ + "feature": "notification-target-system", + "active_task": null, + "tasks": { + "1": "done", + "2": "done", + "3": "done", + "4": "done", + "5": "done", + "6": "done" + }, + "flags": { + "blocked": false, + "needs_review": false + }, + "notes": [ + "Optimized notification translation to a single generic fallback strategy in NotificationTranslationTopic.", + "Ran i18next extraction and updated locale files for newly handled notification keys.", + "Validated i18n tests, translation validation, and type checks." + ], + "verification": { + "tests": [ + "yarn test src/i18n/__tests__/NotificationTranslationBuilder.test.js src/i18n/__tests__/TranslationBuilder.test.js --runInBand --watchman=false" + ], + "translations": ["yarn build-translations", "yarn validate-translations"], + "types": ["yarn types"] + } +} diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 4abb80d5d7..d73bc5597f 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -2,7 +2,8 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; import { DownloadButton, FileSizeIndicator, ProgressBar } from './components'; -import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; +import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer'; +import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 9b2056e1e5..f12c09ac32 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -4,13 +4,11 @@ import type { Attachment } from 'stream-chat'; import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from './components'; import { FileIcon } from '../FileIcon'; import { useMessageContext, useTranslationContext } from '../../context'; -import { - type AudioPlayerState, - DurationDisplay, - useAudioPlayer, -} from '../AudioPlayback/'; +import { DurationDisplay } from '../AudioPlayback'; +import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer'; +import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../store'; -import type { AudioPlayer } from '../AudioPlayback'; +import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; import { PlayButton } from '../Button'; const rootClassName = 'str-chat__message-attachment__voice-recording-widget'; diff --git a/src/components/Attachment/__tests__/Card.test.js b/src/components/Attachment/__tests__/Card.test.js index 4005b42a1c..fc0cbb8107 100644 --- a/src/components/Attachment/__tests__/Card.test.js +++ b/src/components/Attachment/__tests__/Card.test.js @@ -33,8 +33,7 @@ const user = generateUser({ id: 'userId', name: 'username' }); jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(); jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(); jest.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(); -const addNotificationSpy = jest.fn(); -const channelActionContext = { addNotification: addNotificationSpy }; +const channelActionContext = {}; const mockedChannel = generateChannel({ members: [generateMember({ user })], diff --git a/src/components/Attachment/styling/Attachment.scss b/src/components/Attachment/styling/Attachment.scss index 7f55d9d795..7929eb6964 100644 --- a/src/components/Attachment/styling/Attachment.scss +++ b/src/components/Attachment/styling/Attachment.scss @@ -649,6 +649,7 @@ border: 1px solid var(--chat-border-on-chat-incoming); cursor: pointer; font-size: var(--typography-font-size-xs); + color: var(--control-playback-toggle-text); } .str-chat__message-attachment-with-actions.str-chat__message-attachment--giphy { @@ -720,7 +721,7 @@ padding: 0; background-color: transparent; border: 1px solid var(--chat-border-incoming); - box-shadow: var(--background-elevation-elevation-0); + box-shadow: var(--background-core-elevation-0); } .str-chat__message-attachment { @@ -741,7 +742,7 @@ &.str-chat__message--has-single-attachment.str-chat__message--has-no-text { .str-chat__message-bubble { border: 1px solid var(--chat-border-outgoing); - box-shadow: var(--background-elevation-elevation-0); + box-shadow: var(--background-core-elevation-0); } .str-chat__message-attachment { diff --git a/src/components/AudioPlayback/WithAudioPlayback.tsx b/src/components/AudioPlayback/WithAudioPlayback.tsx index bd6904de8d..b982125997 100644 --- a/src/components/AudioPlayback/WithAudioPlayback.tsx +++ b/src/components/AudioPlayback/WithAudioPlayback.tsx @@ -4,6 +4,7 @@ import type { AudioPlayerOptions } from './AudioPlayer'; import type { AudioPlayerPoolState } from './AudioPlayerPool'; import { AudioPlayerPool } from './AudioPlayerPool'; import { audioPlayerNotificationsPluginFactory } from './plugins/AudioPlayerNotificationsPlugin'; +import { useNotificationTarget } from '../Notifications'; import { useChatContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; @@ -67,6 +68,7 @@ export const useAudioPlayer = ({ waveformData, }: UseAudioPlayerProps) => { const { client } = useChatContext(); + const panel = useNotificationTarget(); const { t } = useTranslationContext(); const { audioPlayers } = useContext(AudioPlayerContext); @@ -91,12 +93,16 @@ export const useAudioPlayer = ({ * Avoid having to pass client and translation function to AudioPlayer instances * and instead provide plugin that takes care of translated notifications. */ - const notificationsPlugin = audioPlayerNotificationsPluginFactory({ client, t }); + const notificationsPlugin = audioPlayerNotificationsPluginFactory({ + client, + panel, + t, + }); audioPlayer.setPlugins((currentPlugins) => [ ...currentPlugins.filter((plugin) => plugin.id !== notificationsPlugin.id), notificationsPlugin, ]); - }, [audioPlayer, client, t]); + }, [audioPlayer, client, panel, t]); return audioPlayer; }; diff --git a/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts b/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts index fb1da11002..abd408ef27 100644 --- a/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts +++ b/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts @@ -2,12 +2,18 @@ import type { AudioPlayerPlugin } from './AudioPlayerPlugin'; import { type AudioPlayerErrorCode } from '../AudioPlayer'; import type { StreamChat } from 'stream-chat'; import type { TFunction } from 'i18next'; +import { + addNotificationTargetTag, + type NotificationTargetPanel, +} from '../../Notifications/notificationTarget'; export const audioPlayerNotificationsPluginFactory = ({ client, + panel = 'channel', t, }: { client: StreamChat; + panel?: NotificationTargetPanel; t: TFunction; }): AudioPlayerPlugin => { const errors: Record = { @@ -30,6 +36,7 @@ export const audioPlayerNotificationsPluginFactory = ({ message: error.message, options: { originalError: error, + tags: addNotificationTargetTag(panel), type: 'browser:audio:playback:error', }, origin: { diff --git a/src/components/Avatar/GroupAvatar.tsx b/src/components/Avatar/GroupAvatar.tsx index b4efc0a6c1..2891945813 100644 --- a/src/components/Avatar/GroupAvatar.tsx +++ b/src/components/Avatar/GroupAvatar.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import React, { type ComponentPropsWithoutRef } from 'react'; import { Avatar, type AvatarProps } from './Avatar'; +import { Badge, type BadgeSize } from '../Badge'; export type GroupAvatarMember = { imageUrl?: string; @@ -13,6 +14,7 @@ export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & { /** Optional count for the "+N" badge when there are more members than shown. */ overflowCount?: number; size: '2xl' | 'xl' | 'lg' | null; + badgeSize?: BadgeSize; isOnline?: boolean; }; @@ -22,6 +24,7 @@ export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & { */ // TODO: rename to AvatarGroup export const GroupAvatar = ({ + badgeSize, className, displayMembers = [], isOnline, @@ -80,7 +83,13 @@ export const GroupAvatar = ({ /> ))} {displayCountBadge && ( -
+{overflowCount}
+ + +{overflowCount} + )}
); diff --git a/src/components/Avatar/styling/Avatar.scss b/src/components/Avatar/styling/Avatar.scss index ebf94a120b..7c03fce3fa 100644 --- a/src/components/Avatar/styling/Avatar.scss +++ b/src/components/Avatar/styling/Avatar.scss @@ -48,7 +48,7 @@ position: absolute; width: calc(100% + 4px); height: calc(100% + 4px); - border: 2px solid var(--border-core-on-dark); + border: 2px solid var(--border-core-inverted); border-radius: inherit; } diff --git a/src/components/Badge/styling/Badge.scss b/src/components/Badge/styling/Badge.scss index e5c8cdf878..b25b1dd06b 100644 --- a/src/components/Badge/styling/Badge.scss +++ b/src/components/Badge/styling/Badge.scss @@ -76,6 +76,7 @@ background: var(--badge-bg-default); box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); font: var(--str-chat__numeric-xl-text); + color: var(--badge-text); &.str-chat__badge--size-xs { min-width: 20px; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 13d1011310..e6f55b0826 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -12,6 +12,8 @@ export type ButtonProps = ComponentProps<'button'> & { appearance?: ButtonAppearance; /** When true, uses full border-radius for icon-only/pill shape. */ circular?: boolean; + /** When true, button uses inverse theme (e.g. on dark surface in light theme). */ + inverseTheme?: boolean; /** Size: lg, md, or sm. */ size?: ButtonSize; }; @@ -36,7 +38,7 @@ const sizeToClass: Record = { }; export const Button = forwardRef(function Button( - { appearance, children, circular, className, size, variant, ...props }, + { appearance, children, circular, className, inverseTheme, size, variant, ...props }, ref, ) { return ( @@ -49,6 +51,7 @@ export const Button = forwardRef(function Button variant != null && variantToClass[variant], appearance != null && appearanceToClass[appearance], circular && 'str-chat__button--circular', + inverseTheme && 'str-chat__theme-inverse', size != null && sizeToClass[size], className, )} diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index b0f1299ba5..13f8ae316d 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -32,7 +32,7 @@ } &:disabled { - background-color: var(--background-core-disabled); + background-color: var(--background-utility-disabled); } } @@ -77,13 +77,13 @@ &.str-chat__button--outline, &.str-chat__button--ghost { &:not(:disabled):hover { - @include utils.overlay-after(var(--background-core-hover)); + @include utils.overlay-after(var(--background-utility-hover)); } &[aria-expanded='true'], &:not(:disabled):active { // pressed - @include utils.overlay-after(var(--background-core-pressed)); + @include utils.overlay-after(var(--background-utility-pressed)); } &:not(:disabled):focus-visible { @@ -93,7 +93,7 @@ &:not(:disabled)[aria-pressed='true'] { // toggled - @include utils.overlay-after(var(--background-core-selected)); + @include utils.overlay-after(var(--background-utility-selected)); } &:disabled { @@ -169,4 +169,8 @@ gap: var(--spacing-xs); } } + + .str-chat__theme-dark .str-chat__button.str-chat__button--floating { + box-shadow: var(--dark-elevation-2); + } } diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 3dd609bc57..db9036d278 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -46,12 +46,9 @@ import { LoadingChannel as DefaultLoadingIndicator, } from '../Loading'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; +import { addNotificationTargetTag } from '../Notifications'; -import type { - ChannelActionContextValue, - ChannelNotifications, - MarkReadWrapperOptions, -} from '../../context'; +import type { ChannelActionContextValue, MarkReadWrapperOptions } from '../../context'; import { ChannelActionProvider, ChannelStateProvider, @@ -75,7 +72,7 @@ import { useChannelContainerClasses, useImageFlagEmojisOnWindowsClass, } from './hooks/useChannelContainerClasses'; -import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils'; +import { findInMsgSetByDate, findInMsgSetById } from './utils'; import { useThreadContext } from '../Threads'; import { getChannel } from '../../utils'; import type { @@ -249,8 +246,6 @@ const ChannelInner = ( const thread = useThreadContext(); const [channelConfig, setChannelConfig] = useState(channel.getConfig()); - const [notifications, setNotifications] = useState([]); - const notificationTimeouts = useRef>([]); const [channelUnreadUiState, _setChannelUnreadUiState] = useState(); @@ -512,7 +507,6 @@ const ChannelInner = ( channel.on(handleEvent); } })(); - const notificationTimeoutsRef = notificationTimeouts.current; return () => { if (errored || !done) return; @@ -520,7 +514,6 @@ const ChannelInner = ( client.off('connection.changed', handleEvent); client.off('connection.recovered', handleEvent); client.off('user.deleted', handleEvent); - notificationTimeoutsRef.forEach(clearTimeout); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -572,12 +565,19 @@ const ChannelInner = ( }, [jumpToMessageFromSearch, handleHighlightedMessageChange]); /** MESSAGE */ - - // Adds a temporary notification to message list, will be removed after 5 seconds - const addNotification = useMemo( - () => makeAddNotifications(setNotifications, notificationTimeouts.current), - [], - ); + const notifyJumpToFirstUnreadError = useCallback(() => { + client.notifications.addError({ + message: t('Failed to jump to the first unread message'), + options: { + tags: addNotificationTargetTag('channel'), + type: 'channel:jumpToFirstUnread:failed', + }, + origin: { + context: { feature: 'jumpToFirstUnread' }, + emitter: 'Channel', + }, + }); + }, [client, t]); // eslint-disable-next-line react-hooks/exhaustive-deps const loadMoreFinished = useCallback( @@ -744,7 +744,7 @@ const ChannelInner = ( ) ).messages; } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); + notifyJumpToFirstUnreadError(); loadMoreFinished( channel.state.messagePagination.hasPrev, channel.state.messages, @@ -754,7 +754,7 @@ const ChannelInner = ( const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); if (!firstMessageWithCreationDate) { - addNotification(t('Failed to jump to the first unread message'), 'error'); + notifyJumpToFirstUnreadError(); loadMoreFinished( channel.state.messagePagination.hasPrev, channel.state.messages, @@ -779,7 +779,7 @@ const ChannelInner = ( } if (!firstUnreadMessageId && !lastReadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); + notifyJumpToFirstUnreadError(); return; } @@ -806,7 +806,7 @@ const ChannelInner = ( firstUnreadMessageId = firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1]?.id; } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); + notifyJumpToFirstUnreadError(); loadMoreFinished( channel.state.messagePagination.hasPrev, channel.state.messages, @@ -816,7 +816,7 @@ const ChannelInner = ( } if (!firstUnreadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); + notifyJumpToFirstUnreadError(); return; } if (!channelUnreadUiState.first_unread_message_id) @@ -831,11 +831,10 @@ const ChannelInner = ( }); }, [ - addNotification, channel, handleHighlightedMessageChange, loadMoreFinished, - t, + notifyJumpToFirstUnreadError, channelUnreadUiState, ], ); @@ -1089,7 +1088,7 @@ const ChannelInner = ( imageAttachmentSizeHandler: props.imageAttachmentSizeHandler || getImageAttachmentConfiguration, mutes, - notifications, + notifications: [], shouldGenerateVideoThumbnail: props.shouldGenerateVideoThumbnail || true, videoAttachmentSizeHandler: props.videoAttachmentSizeHandler || getVideoAttachmentConfiguration, @@ -1098,7 +1097,6 @@ const ChannelInner = ( const channelActionContextValue: ChannelActionContextValue = useMemo( () => ({ - addNotification, closeThread, deleteMessage, dispatch, diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 47bcf8a064..cfd4e19741 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -1250,8 +1250,8 @@ describe('Channel', () => { } } + const addErrorSpy = jest.spyOn(chatClient.notifications, 'addError'); let hasJumped; - let notifications; let highlightedMessageId; let channelUnreadUiStateAfterJump; await act(async () => { @@ -1261,7 +1261,6 @@ describe('Channel', () => { channelUnreadUiState, highlightedMessageId: highlightedMessageIdContext, jumpToFirstUnreadMessage, - notifications: contextNotifications, setChannelUnreadUiState, }) => { if (!channelUnreadUiState) return; @@ -1273,7 +1272,6 @@ describe('Channel', () => { return; } if (hasJumped) { - notifications = contextNotifications; highlightedMessageId = highlightedMessageIdContext; channelUnreadUiStateAfterJump = channelUnreadUiState; return; @@ -1291,11 +1289,11 @@ describe('Channel', () => { } if (loadScenario.match('query fails')) { - expect(notifications).toHaveLength(1); - expect(notifications[0].text).toBe(errorNotificationText); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: errorNotificationText }), + ); expect(highlightedMessageId).toBeUndefined(); } else { - expect(notifications).toHaveLength(0); expect(highlightedMessageId).toBe(first_unread_message_id); if (!ownReadState.first_unread_message_id) { expect(channelUnreadUiStateAfterJump.first_unread_message_id).toBe( @@ -1304,6 +1302,7 @@ describe('Channel', () => { } } }); + addErrorSpy.mockRestore(); }; it('should not query messages around the first unread message if it is already loaded in state', async () => { @@ -1470,10 +1469,10 @@ describe('Channel', () => { ], customUser: user, }); + const addErrorSpy = jest.spyOn(chatClient.notifications, 'addError'); let hasJumped; let hasMoreMessages; let highlightedMessageId; - let notifications; await renderComponent( { channel, chatClient }, ({ @@ -1481,12 +1480,10 @@ describe('Channel', () => { hasMore, highlightedMessageId: contextHighlightedMessageId, jumpToFirstUnreadMessage, - notifications: contextNotifications, }) => { if (hasJumped) { hasMoreMessages = hasMore; highlightedMessageId = contextHighlightedMessageId; - notifications = contextNotifications; return; } if (!channelUnreadUiState) return; @@ -1501,8 +1498,13 @@ describe('Channel', () => { await waitFor(() => { expect(hasMoreMessages).toBe(expectedHasMore); expect(highlightedMessageId).toBe(expectedJumpToId); - expect(notifications).toHaveLength(!expectedJumpToId ? 1 : 0); + if (!expectedJumpToId) { + expect(addErrorSpy).toHaveBeenCalled(); + } else { + expect(addErrorSpy).not.toHaveBeenCalled(); + } }); + addErrorSpy.mockRestore(); }, ); }); diff --git a/src/components/Channel/utils.ts b/src/components/Channel/utils.ts index e50e404bcf..969c73b3c9 100644 --- a/src/components/Channel/utils.ts +++ b/src/components/Channel/utils.ts @@ -1,32 +1,4 @@ -import { nanoid } from 'nanoid'; -import type { Dispatch, SetStateAction } from 'react'; -import type { ChannelState, MessageResponse, StreamChat } from 'stream-chat'; -import type { ChannelNotifications } from '../../context/ChannelStateContext'; - -export const makeAddNotifications = - ( - setNotifications: Dispatch>, - notificationTimeouts: NodeJS.Timeout[], - ) => - (text: string, type: 'success' | 'error') => { - if (typeof text !== 'string' || (type !== 'success' && type !== 'error')) { - return; - } - - const id = nanoid(); - - setNotifications((prevNotifications) => [...prevNotifications, { id, text, type }]); - - const timeout = setTimeout( - () => - setNotifications((prevNotifications) => - prevNotifications.filter((notification) => notification.id !== id), - ), - 5000, - ); - - notificationTimeouts.push(timeout); - }; +import type { ChannelState, MessageResponse } from 'stream-chat'; /** * Utility function for jumpToFirstUnreadMessage @@ -98,6 +70,3 @@ export const findInMsgSetByDate = ( } return { index: -1 }; }; - -export const generateMessageId = ({ client }: { client: StreamChat }) => - `${client.userID}-${nanoid()}`; diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss index 5ab360f70d..ae845238ec 100644 --- a/src/components/ChannelHeader/styling/ChannelHeader.scss +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -10,7 +10,7 @@ --str-chat__channel-header-color: 0; /* The background color of the component */ - --str-chat__channel-header-background-color: var(--background-elevation-elevation-1); + --str-chat__channel-header-background-color: var(--background-core-elevation-1); /* Top border of the component */ --str-chat__channel-header-border-block-start: none; diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 50ef9347a7..5d904571ad 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -1,6 +1,6 @@ +import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import type { ReactNode } from 'react'; import type { Channel, ChannelFilters, @@ -12,19 +12,27 @@ import type { import { useConnectionRecoveredListener } from './hooks/useConnectionRecoveredListener'; import { useMobileNavigation } from './hooks/useMobileNavigation'; +import type { CustomQueryChannelsFn } from './hooks/usePaginatedChannels'; import { usePaginatedChannels } from './hooks/usePaginatedChannels'; import { useChannelListShape, usePrepareShapeHandlers, } from './hooks/useChannelListShape'; import { useStateStore } from '../../store'; +import type { ChannelListMessengerProps } from './ChannelListMessenger'; import { ChannelListMessenger } from './ChannelListMessenger'; +import type { ChannelAvatarProps } from '../Avatar'; import { Avatar as DefaultAvatar } from '../Avatar'; +import type { ChannelPreviewUIComponentProps } from '../ChannelPreview/ChannelPreview'; import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; import { Search as DefaultSearch } from '../Search'; +import type { EmptyStateIndicatorProps } from '../EmptyStateIndicator'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import { LoadingChannels } from '../Loading/LoadingChannels'; +import type { LoadMorePaginatorProps } from '../LoadMore/LoadMorePaginator'; import { LoadMorePaginator } from '../LoadMore/LoadMorePaginator'; +import { NotificationList as DefaultNotificationList } from '../Notifications'; +import type { ChatContextValue } from '../../context'; import { ChannelListContextProvider, DialogManagerProvider, @@ -33,14 +41,6 @@ import { } from '../../context'; import { NullComponent } from '../UtilityComponents'; import { moveChannelUpwards } from './utils'; -import type { CustomQueryChannelsFn } from './hooks/usePaginatedChannels'; -import type { ChannelListMessengerProps } from './ChannelListMessenger'; -import type { ChannelPreviewUIComponentProps } from '../ChannelPreview/ChannelPreview'; -import type { SearchProps } from '../Search'; -import type { EmptyStateIndicatorProps } from '../EmptyStateIndicator'; -import type { LoadMorePaginatorProps } from '../LoadMore/LoadMorePaginator'; -import type { ChatContextValue } from '../../context'; -import type { ChannelAvatarProps } from '../Avatar'; import type { TranslationContextValue } from '../../context/TranslationContext'; import type { PaginatorProps } from '../../types/types'; import type { LoadingErrorIndicatorProps } from '../Loading'; @@ -215,7 +215,8 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { theme, useImageFlagEmojisOnWindows, } = useChatContext('ChannelList'); - const { Search = DefaultSearch } = useComponentContext(); // FIXME: use component context to retrieve ChannelPreview UI components too + const { NotificationList = DefaultNotificationList, Search = DefaultSearch } = + useComponentContext(); // FIXME: use component context to retrieve ChannelPreview UI components too const channelListRef = useRef(null); const [channelUpdateCount, setChannelUpdateCount] = useState(0); @@ -394,6 +395,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { )} )} + diff --git a/src/components/ChannelPreview/__tests__/utils.test.js b/src/components/ChannelPreview/__tests__/utils.test.js index 0967ac6043..a0e8393bd8 100644 --- a/src/components/ChannelPreview/__tests__/utils.test.js +++ b/src/components/ChannelPreview/__tests__/utils.test.js @@ -128,31 +128,6 @@ describe('ChannelPreview utils', () => { }); }); - describe('getDisplayImage', () => { - it('should return channel image, if it exists', async () => { - const image = nanoid(); - const channel = await getQueriedChannelInstance( - generateChannel({ channel: { image } }), - ); - - expect(channel.getDisplayImage()).toBe(image); - }); - - it('should return null when no image is available (image fallback removed)', async () => { - const otherUser = generateUser(); - const channel = await getQueriedChannelInstance( - generateChannel({ - members: [ - generateMember({ user: otherUser }), - generateMember({ user: clientUser }), - ], - }), - ); - // getDisplayImage no longer falls back to member image, only channel.data.image - expect(channel.getDisplayImage()).toBeNull(); - }); - }); - describe('getChannelDisplayImage (utils)', () => { it('returns channel.data.image when set', async () => { const image = nanoid(); diff --git a/src/components/ChannelPreview/styling/ChannelPreview.scss b/src/components/ChannelPreview/styling/ChannelPreview.scss index 7aef7abb4b..68c0719ccc 100644 --- a/src/components/ChannelPreview/styling/ChannelPreview.scss +++ b/src/components/ChannelPreview/styling/ChannelPreview.scss @@ -16,7 +16,7 @@ gap: var(--spacing-xs); border-radius: var(--radius-md, 8px); - background: var(--background-elevation-elevation-3, #fff); + background: var(--background-core-elevation-3, #fff); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), @@ -53,15 +53,15 @@ border-radius: var(--radius-lg); width: 100%; - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); &:not(:disabled):hover { - background: var(--background-core-hover); + background: var(--background-utility-hover); } &:not(:disabled):active { - background: var(--background-core-pressed); + background: var(--background-utility-pressed); } &:not(:disabled)[aria-pressed='true'] { - background: var(--background-core-selected); + background: var(--background-utility-selected); } .str-chat__avatar { diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 5b087569a7..37a4e8df95 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -35,12 +35,19 @@ type ChatViewContextValue = { setActiveChatView: (cv: ChatView) => void; }; -const ChatViewContext = createContext({ - activeChatView: 'channels', - setActiveChatView: () => undefined, -}); +export const ChatViewContext = createContext(undefined); + +export const useChatViewContext = () => { + const value = useContext(ChatViewContext); + + if (!value) { + throw new Error( + 'The useChatViewContext hook was called outside of the ChatView provider.', + ); + } -export const useChatViewContext = () => useContext(ChatViewContext); + return value; +}; export const ChatView = ({ children }: PropsWithChildren) => { const [activeChatView, setActiveChatView] = useState('channels'); diff --git a/src/components/Dialog/styling/Callout.scss b/src/components/Dialog/styling/Callout.scss index c017fe53be..e807e387c6 100644 --- a/src/components/Dialog/styling/Callout.scss +++ b/src/components/Dialog/styling/Callout.scss @@ -2,7 +2,7 @@ position: relative; max-width: 320px; border-radius: var(--radius-lg); - background-color: var(--background-elevation-elevation-2); + background-color: var(--background-core-elevation-2); box-shadow: var(--light-elevation-3); .str-chat__callout__close-button { diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index 7a68aa4097..88449f4395 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -9,7 +9,7 @@ */ --str-chat__dialog-menu-border-radius: var(--radius-lg); --str-chat__dialog-menu-color: var(--text-primary); - --str-chat__dialog-menu-background-color: var(--background-elevation-elevation-2); + --str-chat__dialog-menu-background-color: var(--background-core-elevation-2); --str-chat__dialog-menu-border-block-start: none; --str-chat__dialog-menu-border-block-end: none; --str-chat__dialog-menu-border-inline-start: none; @@ -19,15 +19,17 @@ --str-chat__dialog-menu-button-border-radius: var(--radius-md); --str-chat__dialog-menu-button-color: var(--text-primary); --str-chat__dialog-menu-button-background-color: transparent; - --str-chat__dialog-menu-button-hover-background-color: var(--background-core-hover); - --str-chat__dialog-menu-button-active-background-color: var(--background-core-pressed); + --str-chat__dialog-menu-button-hover-background-color: var(--background-utility-hover); + --str-chat__dialog-menu-button-active-background-color: var( + --background-utility-pressed + ); --str-chat__dialog-menu-button-focused-background-color: transparent; --str-chat__dialog-menu-button-disabled-background-color: transparent; --str-chat__dialog-menu-back-button-hover-background-color: var( - --background-core-hover + --background-utility-hover ); --str-chat__dialog-menu-back-button-active-background-color: var( - --background-core-pressed + --background-utility-pressed ); --str-chat__dialog-menu-back-button-focused-background-color: transparent; --str-chat__dialog-menu-back-button-disabled-background-color: transparent; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 4a13e428c9..d3c2ce9f37 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -1,3 +1,4 @@ export * from './FieldError'; +export * from './NumericInput'; export * from './TextInput'; export * from './TextInputFieldSet'; diff --git a/src/components/Form/styling/TextInput.scss b/src/components/Form/styling/TextInput.scss index 2e98e07aab..d58693d638 100644 --- a/src/components/Form/styling/TextInput.scss +++ b/src/components/Form/styling/TextInput.scss @@ -16,7 +16,7 @@ gap: var(--spacing-xs); min-height: var(--size-40); padding: 0 var(--spacing-sm); - background-color: var(--background-elevation-elevation-0); + background-color: var(--background-core-elevation-0); border-radius: var(--radius-md); outline: none; transition: @@ -28,7 +28,7 @@ // Outline variant — always 1px border on wrapper; + 2px focus ring on focus-within // --------------------------------------------------------------------------- &__wrapper--outline { - border: 1px solid var(--input-border-default); + border: 1px solid var(--border-core-default); box-shadow: none; } diff --git a/src/components/Icons/styling/Icons.scss b/src/components/Icons/styling/Icons.scss index b43a143475..d04c1fc786 100644 --- a/src/components/Icons/styling/Icons.scss +++ b/src/components/Icons/styling/Icons.scss @@ -6,6 +6,5 @@ } .str-chat__icon--exclamation-circle { - //fill: none; stroke: currentColor; } diff --git a/src/components/Location/ShareLocationDialog.tsx b/src/components/Location/ShareLocationDialog.tsx index edc16d37c0..35554a7f25 100644 --- a/src/components/Location/ShareLocationDialog.tsx +++ b/src/components/Location/ShareLocationDialog.tsx @@ -10,6 +10,7 @@ import { useMessageComposer } from '../MessageInput'; import { Prompt } from '../Dialog'; import { SwitchField } from '../Form/SwitchField'; import { Dropdown, useDropdownContext } from '../Form/Dropdown'; +import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; import type { Coords } from 'stream-chat'; const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute; @@ -35,13 +36,14 @@ export type ShareLocationDialogProps = { const DefaultGeolocationMap = () => null; -export const ShareLocationDialog = ({ +const ShareLocationDialog = ({ close, GeolocationMap = DefaultGeolocationMap, shareDurations = DEFAULT_SHARE_LOCATION_DURATIONS, }: ShareLocationDialogProps) => { const { client } = useChatContext(); const { t } = useTranslationContext(); + const panel = useNotificationTarget(); const messageComposer = useMessageComposer(); const [durations, setDurations] = useState([]); const [selectedDuration, setSelectedDuration] = useState(undefined); @@ -204,7 +206,8 @@ export const ShareLocationDialog = ({ message: t('Failed to retrieve location'), options: { originalError: e instanceof Error ? e : undefined, - type: 'browser-api:location:get:failed', + tags: addNotificationTargetTag(panel), + type: 'browser:location:get:failed', }, origin: { emitter: 'ShareLocationDialog' }, }); @@ -223,6 +226,7 @@ export const ShareLocationDialog = ({ message: t('Failed to share location'), options: { originalError: err instanceof Error ? err : undefined, + tags: addNotificationTargetTag(panel), type: 'api:location:share:failed', }, origin: { emitter: 'ShareLocationDialog' }, @@ -240,6 +244,7 @@ export const ShareLocationDialog = ({ ); }; +export default ShareLocationDialog; export type DurationDropdownItemsProps = { durations: number[]; diff --git a/src/components/Location/__tests__/ShareLocationDialog.test.js b/src/components/Location/__tests__/ShareLocationDialog.test.js index 7fe8a37dfa..b089e93244 100644 --- a/src/components/Location/__tests__/ShareLocationDialog.test.js +++ b/src/components/Location/__tests__/ShareLocationDialog.test.js @@ -4,7 +4,7 @@ import '@testing-library/jest-dom'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; import { initClientWithChannels } from '../../../mock-builders'; -import { ShareLocationDialog } from '../ShareLocationDialog'; +import ShareLocationDialog from '../ShareLocationDialog'; import { useMessageComposer } from '../../MessageInput'; jest.mock('../../MessageInput/hooks/useMessageComposer', () => ({ diff --git a/src/components/Location/index.ts b/src/components/Location/index.ts index 1a88f0c32e..7a05fd5e83 100644 --- a/src/components/Location/index.ts +++ b/src/components/Location/index.ts @@ -1 +1,2 @@ export * from './ShareLocationDialog'; +export { default as ShareLocationDialog } from './ShareLocationDialog'; diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx index abdeb35bc1..1743f34e59 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPlayback.tsx @@ -1,10 +1,8 @@ import React, { useEffect } from 'react'; import { WaveProgressBar } from '../../Attachment'; -import { - type AudioPlayerState, - DurationDisplay, - useAudioPlayer, -} from '../../AudioPlayback'; +import { DurationDisplay } from '../../AudioPlayback'; +import type { AudioPlayerState } from '../../AudioPlayback/AudioPlayer'; +import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; import { IconPause, IconPlaySolid } from '../../Icons'; import { Button } from '../../Button'; diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js index 31e2540b21..beb938696e 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js @@ -46,9 +46,7 @@ const AUDIO_RECORDER_TEST_ID = 'audio-recorder'; const AUDIO_RECORDER_COMPLETE_BTN_TEST_ID = 'audio-recorder-complete-button'; const DEFAULT_RENDER_PARAMS = { - channelActionCtx: { - addNotification: jest.fn(), - }, + channelActionCtx: {}, channelStateCtx: { channelCapabilities: [], }, diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 515d1c0309..124529232e 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -20,12 +20,12 @@ import { areMessagePropsEqual, getMessageActions, MESSAGE_ACTIONS } from './util import type { MessageContextValue } from '../../context'; import { MessageProvider, - useChannelActionContext, useChannelStateContext, useChatContext, useComponentContext, useMessageTranslationViewContext, } from '../../context'; +import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; import { MessageSimple as DefaultMessage } from './MessageSimple'; @@ -215,8 +215,22 @@ export const Message = (props: MessageProps) => { sortReactions, } = props; - const { addNotification } = useChannelActionContext('Message'); + const { client } = useChatContext('Message'); const { highlightedMessageId, mutes } = useChannelStateContext('Message'); + const panel = useNotificationTarget(); + + const notify = useCallback( + (text: string, type: 'success' | 'error') => { + const origin = { emitter: 'Message' }; + const options = { tags: addNotificationTargetTag(panel) }; + if (type === 'error') { + client.notifications.addError({ message: text, options, origin }); + } else { + client.notifications.addSuccess({ message: text, options, origin }); + } + }, + [client, panel], + ); const handleAction = useActionHandler(message); const handleOpenThread = useOpenThreadHandler(message, propOpenThread); @@ -226,30 +240,30 @@ export const Message = (props: MessageProps) => { const handleFetchReactions = useReactionsFetcher(message, { getErrorNotification: getFetchReactionsErrorNotification, - notify: addNotification, + notify, }); const handleDelete = useDeleteHandler(message, { getErrorNotification: getDeleteMessageErrorNotification, - notify: addNotification, + notify, }); const handleFlag = useFlagHandler(message, { getErrorNotification: getFlagMessageErrorNotification, getSuccessNotification: getFlagMessageSuccessNotification, - notify: addNotification, + notify, }); const handleMarkUnread = useMarkUnreadHandler(message, { getErrorNotification: getMarkMessageUnreadErrorNotification, getSuccessNotification: getMarkMessageUnreadSuccessNotification, - notify: addNotification, + notify, }); const handleMute = useMuteHandler(message, { getErrorNotification: getMuteUserErrorNotification, getSuccessNotification: getMuteUserSuccessNotification, - notify: addNotification, + notify, }); const { onMentionsClick, onMentionsHover } = useMentionsHandler(message, { @@ -259,7 +273,7 @@ export const Message = (props: MessageProps) => { const { canPin, handlePin } = usePinHandler(message, pinPermissions, { getErrorNotification: getPinMessageErrorNotification, - notify: addNotification, + notify, }); const highlighted = highlightedMessageId === message.id; diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx index f3f8643e19..361ccbe11a 100644 --- a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx +++ b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef } from 'react'; import { IconArrowRightUp } from '../Icons'; +import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; import { useChannelActionContext, useChannelStateContext, @@ -19,6 +20,7 @@ export const MessageAlsoSentInChannelIndicator = () => { const { channel } = useChannelStateContext(); const { jumpToMessage, openThread } = useChannelActionContext(); const { message, threadList } = useMessageContext('MessageAlsoSentInChannelIndicator'); + const panel = useNotificationTarget(); const targetMessageRef = useRef(undefined); const queryParent = () => @@ -36,7 +38,8 @@ export const MessageAlsoSentInChannelIndicator = () => { message: t('Thread has not been found'), options: { originalError: error, - type: 'api:message:search:not-found', + tags: addNotificationTargetTag(panel), + type: 'api:reply:search:failed', }, origin: { context: { threadReply: message }, diff --git a/src/components/Message/__tests__/Message.test.js b/src/components/Message/__tests__/Message.test.js index 1eb712fa50..9992965712 100644 --- a/src/components/Message/__tests__/Message.test.js +++ b/src/components/Message/__tests__/Message.test.js @@ -355,7 +355,7 @@ describe(' component', () => { it('should allow to mute a user and notify with custom success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const muteUser = jest.fn(() => Promise.resolve()); const userMutedNotification = 'User muted!'; const getMuteUserSuccessNotification = jest.fn(() => userMutedNotification); @@ -363,7 +363,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -376,20 +375,22 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(muteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(userMutedNotification, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: userMutedNotification }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to mute a user and notify with default success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const defaultSuccessMessage = '{{ user }} has been muted'; const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const muteUser = jest.fn(() => Promise.resolve()); client.muteUser = muteUser; let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -402,13 +403,16 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(muteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(defaultSuccessMessage, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultSuccessMessage }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to mute a user and notify with custom error message when muting a user fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const muteUser = jest.fn(() => Promise.reject()); const userMutedFailNotification = 'User mute failed!'; const getMuteUserErrorNotification = jest.fn(() => userMutedFailNotification); @@ -416,7 +420,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -430,20 +433,22 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(muteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(userMutedFailNotification, 'error'); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: userMutedFailNotification }), + ); + addErrorSpy.mockRestore(); }); it('should allow to mute a user and notify with default error message when muting a user fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const muteUser = jest.fn(() => Promise.reject()); const defaultFailNotification = 'Error muting a user ...'; client.muteUser = muteUser; let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -456,13 +461,16 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(muteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(defaultFailNotification, 'error'); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultFailNotification }), + ); + addErrorSpy.mockRestore(); }); it('should allow to unmute a user and notify with custom success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const unmuteUser = jest.fn(() => Promise.resolve()); const userUnmutedNotification = 'User unmuted!'; const getMuteUserSuccessNotification = jest.fn(() => userUnmutedNotification); @@ -470,7 +478,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [{ target: { id: bob.id } }] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -484,20 +491,22 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(unmuteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(userUnmutedNotification, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: userUnmutedNotification }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to unmute a user and notify with default success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const unmuteUser = jest.fn(() => Promise.resolve()); const defaultSuccessNotification = '{{ user }} has been unmuted'; client.unmuteUser = unmuteUser; let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [{ target: { id: bob.id } }] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -510,13 +519,16 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(unmuteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(defaultSuccessNotification, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultSuccessNotification }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to unmute a user and notify with custom error message when it fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const unmuteUser = jest.fn(() => Promise.reject()); const userMutedFailNotification = 'User muted failed!'; const getMuteUserErrorNotification = jest.fn(() => userMutedFailNotification); @@ -524,7 +536,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [{ target: { id: bob.id } }] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -538,20 +549,22 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(unmuteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(userMutedFailNotification, 'error'); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: userMutedFailNotification }), + ); + addErrorSpy.mockRestore(); }); it('should allow to unmute a user and notify with default error message when it fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const unmuteUser = jest.fn(() => Promise.reject()); const defaultFailNotification = 'Error unmuting a user ...'; client.unmuteUser = unmuteUser; let context; await renderComponent({ - channelActionOpts: { addNotification }, channelStateOpts: { mutes: [{ target: { id: bob.id } }] }, clientOpts: { client }, contextCallback: (ctx) => { @@ -564,7 +577,10 @@ describe(' component', () => { await context.handleMute(mouseEventMock); expect(unmuteUser).toHaveBeenCalledWith(bob.id); - expect(addNotification).toHaveBeenCalledWith(defaultFailNotification, 'error'); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultFailNotification }), + ); + addErrorSpy.mockRestore(); }); it.each([ @@ -735,7 +751,7 @@ describe(' component', () => { it('should allow to flag a message and notify with custom success notification when it is successful', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const flagMessage = jest.fn(() => Promise.resolve()); client.flagMessage = flagMessage; const messageFlaggedNotification = 'Message flagged!'; @@ -743,7 +759,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, clientOpts: { client }, contextCallback: (ctx) => { context = ctx; @@ -756,20 +771,22 @@ describe(' component', () => { await context.handleFlag(mouseEventMock); expect(flagMessage).toHaveBeenCalledWith(message.id); - expect(addNotification).toHaveBeenCalledWith(messageFlaggedNotification, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: messageFlaggedNotification }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to flag a message and notify with default success notification when it is successful', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addSuccessSpy = jest.spyOn(client.notifications, 'addSuccess'); const flagMessage = jest.fn(() => Promise.resolve()); client.flagMessage = flagMessage; const defaultSuccessNotification = 'Message has been successfully flagged'; let context; await renderComponent({ - channelActionOpts: { addNotification }, clientOpts: { client }, contextCallback: (ctx) => { context = ctx; @@ -781,13 +798,16 @@ describe(' component', () => { await context.handleFlag(mouseEventMock); expect(flagMessage).toHaveBeenCalledWith(message.id); - expect(addNotification).toHaveBeenCalledWith(defaultSuccessNotification, 'success'); + expect(addSuccessSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultSuccessNotification }), + ); + addSuccessSpy.mockRestore(); }); it('should allow to flag a message and notify with custom error message when it fails', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const flagMessage = jest.fn(() => Promise.reject()); client.flagMessage = flagMessage; const messageFlagFailedNotification = 'Message flagged failed!'; @@ -795,7 +815,6 @@ describe(' component', () => { let context; await renderComponent({ - channelActionOpts: { addNotification }, clientOpts: { client }, contextCallback: (ctx) => { context = ctx; @@ -808,20 +827,22 @@ describe(' component', () => { await context.handleFlag(mouseEventMock); expect(flagMessage).toHaveBeenCalledWith(message.id); - expect(addNotification).toHaveBeenCalledWith(messageFlagFailedNotification, 'error'); + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: messageFlagFailedNotification }), + ); + addErrorSpy.mockRestore(); }); it('should allow to flag a user and notify with default error message when it fails', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addNotification = jest.fn(); + const addErrorSpy = jest.spyOn(client.notifications, 'addError'); const flagMessage = jest.fn(() => Promise.reject()); client.flagMessage = flagMessage; const defaultFlagMessageFailedNotification = 'Error adding flag'; let context; await renderComponent({ - channelActionOpts: { addNotification }, clientOpts: { client }, contextCallback: (ctx) => { context = ctx; @@ -833,10 +854,10 @@ describe(' component', () => { await context.handleFlag(mouseEventMock); expect(flagMessage).toHaveBeenCalledWith(message.id); - expect(addNotification).toHaveBeenCalledWith( - defaultFlagMessageFailedNotification, - 'error', + expect(addErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: defaultFlagMessageFailedNotification }), ); + addErrorSpy.mockRestore(); }); it('should allow user to pin messages when permissions allow', async () => { diff --git a/src/components/MessageActions/defaults.tsx b/src/components/MessageActions/defaults.tsx index 5cd264dfe5..037424eec2 100644 --- a/src/components/MessageActions/defaults.tsx +++ b/src/components/MessageActions/defaults.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { + addNotificationTargetTag, GlobalModal, IconArrowRotateClockwise, IconBellNotification, @@ -24,6 +25,7 @@ import { isUserMuted, useMessageComposer, useMessageReminder, + useNotificationTarget, } from '../../components'; import { ReactionIcon as DefaultReactionIcon, @@ -293,9 +295,11 @@ const DefaultMessageActionComponents = { ); }, Delete({ closeMenu }: ContextMenuItemProps) { + const { client } = useChatContext(); const { Modal = GlobalModal } = useComponentContext(); const { removeMessage } = useChannelActionContext(); const { handleDelete, message } = useMessageContext(); + const panel = useNotificationTarget(); const { t } = useTranslationContext(); const [openModal, setOpenModal] = useState(false); @@ -312,7 +316,7 @@ const DefaultMessageActionComponents = { setOpenModal(true); }} > - {t('Delete')} + {t('Delete message')}
{ + onDelete={async () => { if (message.type === 'error') removeMessage(message); - else handleDelete(); + else { + try { + await handleDelete(); + client.notifications.addSuccess({ + message: t('Message deleted'), + options: { + tags: addNotificationTargetTag(panel), + }, + origin: { emitter: 'MessageActions' }, + }); + } catch (error) { + client.notifications.addError({ + message: t('Failed to delete the message'), + options: { + tags: addNotificationTargetTag(panel), + }, + origin: { emitter: 'MessageActions' }, + }); + } + } setOpenModal(false); closeMenu(); }} diff --git a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx index fbc4fd28f0..10402ffa85 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -13,11 +13,9 @@ import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from '../../Attachment'; import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; import { PlayButton } from '../../Button'; -import { - type AudioPlayerState, - DurationDisplay, - useAudioPlayer, -} from '../../AudioPlayback'; +import { DurationDisplay } from '../../AudioPlayback'; +import type { AudioPlayerState } from '../../AudioPlayback/AudioPlayer'; +import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; export type AudioAttachmentPreviewProps> = diff --git a/src/components/MessageInput/__tests__/AttachmentSelector.test.js b/src/components/MessageInput/__tests__/AttachmentSelector.test.js index 23ba634d69..bc94ea387a 100644 --- a/src/components/MessageInput/__tests__/AttachmentSelector.test.js +++ b/src/components/MessageInput/__tests__/AttachmentSelector.test.js @@ -97,7 +97,7 @@ const renderComponent = async ({ - + { export const useSubmitHandler = (props: MessageInputProps) => { const { overrideSubmitHandler } = props; - const { addNotification, editMessage, sendMessage } = - useChannelActionContext('useSubmitHandler'); + const { client } = useChatContext('useSubmitHandler'); + const { editMessage, sendMessage } = useChannelActionContext('useSubmitHandler'); const { t } = useTranslationContext('useSubmitHandler'); + const panel = useNotificationTarget(); const messageComposer = useMessageComposer(); const handleSubmit = useCallback( @@ -46,7 +49,11 @@ export const useSubmitHandler = (props: MessageInputProps) => { await editMessage(localMessage, sendOptions); messageComposer.clear(); } catch (err) { - addNotification(t('Edit message request failed'), 'error'); + client.notifications.addError({ + message: t('Edit message request failed'), + options: { tags: addNotificationTargetTag(panel) }, + origin: { emitter: 'MessageInput' }, + }); } } else { const restoreComposerStateSnapshot = takeStateSnapshot(messageComposer); @@ -77,18 +84,15 @@ export const useSubmitHandler = (props: MessageInputProps) => { await messageComposer.channel.stopTyping(); } catch (err) { restoreComposerStateSnapshot(); - addNotification(t('Send message request failed'), 'error'); + client.notifications.addError({ + message: t('Send message request failed'), + options: { tags: addNotificationTargetTag(panel) }, + origin: { emitter: 'MessageInput' }, + }); } } }, - [ - addNotification, - editMessage, - messageComposer, - overrideSubmitHandler, - sendMessage, - t, - ], + [client, editMessage, messageComposer, overrideSubmitHandler, panel, sendMessage, t], ); return { handleSubmit }; diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss index 74f2a00656..e48e831b94 100644 --- a/src/components/MessageInput/styling/AttachmentPreview.scss +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -59,10 +59,10 @@ --str-chat__attachment-preview-media-video-indicator-border-radius: var(--radius-max); --str-chat__attachment-preview-media-overlay-hover-background-color: var( - --background-core-hover + --background-utility-hover ); --str-chat__attachment-preview-media-overlay-pressed-background-color: var( - --background-core-pressed + --background-utility-pressed ); --str-chat__attachment-preview-media-uploading-overlay-background: linear-gradient( diff --git a/src/components/MessageInput/styling/AttachmentSelector.scss b/src/components/MessageInput/styling/AttachmentSelector.scss index f0eb154b9a..26afb39352 100644 --- a/src/components/MessageInput/styling/AttachmentSelector.scss +++ b/src/components/MessageInput/styling/AttachmentSelector.scss @@ -48,7 +48,7 @@ .str-chat__message-composer--floating { .str-chat__attachment-selector__menu-button { - background-color: var(--background-elevation-elevation-1); + background-color: var(--background-core-elevation-1); // todo: variable exists only in Figma, not added to tokens repo box-shadow: var(--shadow-web-light-elevation-2); } diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index 0521d33e99..bb85f4145b 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -28,7 +28,7 @@ --str-chat__cooldown-border-radius: var(--button-radius-full); --str-chat__cooldown-color: var(--text-disabled); - --str-chat__cooldown-background-color: var(--background-core-disabled); + --str-chat__cooldown-background-color: var(--background-utility-disabled); --str-chat__cooldown-border-block-start: 0; --str-chat__cooldown-border-block-end: 0; --str-chat__cooldown-border-inline-start: 0; @@ -48,7 +48,7 @@ padding: var(--str-chat__message-composer-padding); min-width: 0; border-top: 1px solid var(--border-core-default); - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); } .str-chat__message-composer { diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 9ce8955280..f3fc8f15c9 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -8,13 +8,12 @@ import { useUnreadMessagesNotification, } from './hooks/MessageList'; import { useMarkRead } from './hooks/useMarkRead'; - -import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; import { useChannelActionContext } from '../../context/ChannelActionContext'; +import type { ChannelStateContextValue } from '../../context/ChannelStateContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { DialogManagerProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; @@ -30,14 +29,13 @@ import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import { FloatingDateSeparator } from './FloatingDateSeparator'; +import type { MessageRenderer } from './renderMessages'; import { defaultRenderMessages } from './renderMessages'; import { useStableId } from '../UtilityComponents/useStableId'; import type { LocalMessage } from 'stream-chat'; -import type { MessageRenderer } from './renderMessages'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import type { MessageProps } from '../Message/types'; -import type { ChannelStateContextValue } from '../../context/ChannelStateContext'; import { DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD, @@ -45,6 +43,7 @@ import { } from '../../constants/limits'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; import { ScrollToLatestMessageButton } from './ScrollToLatestMessageButton'; +import { NotificationList, useNotificationTarget } from '../Notifications'; type MessageListWithContextProps = Omit< ChannelStateContextValue, @@ -75,7 +74,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, messages = [], noGroupByUser = false, - notifications, pinPermissions = defaultPinPermissions, reactionDetailsSort, renderMessages = defaultRenderMessages, @@ -97,12 +95,17 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { EmptyStateIndicator = DefaultEmptyStateIndicator, LoadingIndicator = DefaultLoadingIndicator, MessageListMainPanel = DefaultMessageListMainPanel, - MessageListNotifications = DefaultMessageListNotifications, + MessageListNotifications = undefined, MessageListWrapper = 'ul', NewMessageNotification = DefaultNewMessageNotification, + NotificationList: NotificationListFromContext = NotificationList, TypingIndicator = DefaultTypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, } = useComponentContext('MessageList'); + const MessageListNotificationsComponent = + MessageListNotifications ?? NotificationListFromContext; + + const notificationTarget = useNotificationTarget(); const { hasNewMessages, @@ -292,16 +295,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
)} - -
{ threadList={threadList} /> + - ); diff --git a/src/components/MessageList/MessageListNotifications.tsx b/src/components/MessageList/MessageListNotifications.tsx index 1ade8f98c0..f055433bcd 100644 --- a/src/components/MessageList/MessageListNotifications.tsx +++ b/src/components/MessageList/MessageListNotifications.tsx @@ -3,29 +3,8 @@ import React from 'react'; import { ConnectionStatus } from './ConnectionStatus'; import { CustomNotification } from './CustomNotification'; -import { useTranslationContext } from '../../context/TranslationContext'; -import { useNotifications } from '../Notifications/hooks/useNotifications'; import type { ChannelNotifications } from '../../context/ChannelStateContext'; -const ClientNotifications = () => { - const clientNotifications = useNotifications(); - const { t } = useTranslationContext(); - - return ( - <> - {clientNotifications.map((notification) => ( - - {t('translationBuilderTopic/notification', { notification })} - - ))} - - ); -}; - export type MessageListNotificationsProps = { notifications: ChannelNotifications; }; @@ -40,7 +19,6 @@ export const MessageListNotifications = (props: MessageListNotificationsProps) = {notification.text} ))} - ); diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 8391610ba8..51ff5454c3 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -23,8 +23,6 @@ import { useUnreadMessagesNotificationVirtualized, } from './hooks/VirtualizedMessageList'; import { useMarkRead } from './hooks/useMarkRead'; - -import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; @@ -48,6 +46,7 @@ import { } from '../MessageList'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; +import { NotificationList, useNotificationTarget } from '../Notifications'; import { DialogManagerProvider } from '../../context'; import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; @@ -215,7 +214,6 @@ const VirtualizedMessageListWithContext = ( messageActions, messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, messages, - notifications, openThread, // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component overscan = 0, @@ -247,17 +245,21 @@ const VirtualizedMessageListWithContext = ( DateSeparator = DefaultDateSeparator, GiphyPreviewMessage = DefaultGiphyPreviewMessage, MessageListMainPanel = DefaultMessageListMainPanel, - MessageListNotifications = DefaultMessageListNotifications, + MessageListNotifications = undefined, MessageSystem = DefaultMessageSystem, NewMessageNotification = DefaultNewMessageNotification, + NotificationList: NotificationListFromContext = NotificationList, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, VirtualMessage: MessageUIComponentFromContext = MessageSimple, } = useComponentContext('VirtualizedMessageList'); + const MessageListNotificationsComponent = + MessageListNotifications ?? NotificationListFromContext; const MessageUIComponent = MessageUIComponentFromProps || MessageUIComponentFromContext; const { client, customClasses } = useChatContext('VirtualizedMessageList'); + const notificationTarget = useNotificationTarget(); const virtuoso = useRef(null); @@ -594,8 +596,8 @@ const VirtualizedMessageListWithContext = ( /> + - {giphyPreviewMessage && } diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js index ad5bddcf5e..b080065f5c 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js @@ -37,7 +37,7 @@ const PREPEND_OFFSET = 10 ** 7; const Wrapper = ({ children, componentContext = {} }) => ( - + {children} diff --git a/src/components/MessageList/styling/ScrollToLatestMessageButton.scss b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss index 3c32908c9e..6cc83274cb 100644 --- a/src/components/MessageList/styling/ScrollToLatestMessageButton.scss +++ b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss @@ -9,7 +9,7 @@ ); z-index: 2; border-radius: var(--radius-max); - background-color: var(--background-elevation-elevation-1); + background-color: var(--background-core-elevation-1); // todo - we ned to have the shadows in variables that are supported in light and dark mode too box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), diff --git a/src/components/MessageList/styling/UnreadMessageNotification.scss b/src/components/MessageList/styling/UnreadMessageNotification.scss index d8c4c49926..ff6e405dd0 100644 --- a/src/components/MessageList/styling/UnreadMessageNotification.scss +++ b/src/components/MessageList/styling/UnreadMessageNotification.scss @@ -2,7 +2,7 @@ .str-chat__unread-messages-notification { display: flex; align-items: center; - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); /* shadow/web/light/elevation-2 */ diff --git a/src/components/MessageList/styling/UnreadMessagesSeparator.scss b/src/components/MessageList/styling/UnreadMessagesSeparator.scss index f1306fde7a..97b90cd32c 100644 --- a/src/components/MessageList/styling/UnreadMessagesSeparator.scss +++ b/src/components/MessageList/styling/UnreadMessagesSeparator.scss @@ -8,7 +8,7 @@ align-items: center; width: fit-content; padding: var(--spacing-xxs) var(--spacing-xs); - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); overflow: clip; diff --git a/src/components/Modal/styling/Modal.scss b/src/components/Modal/styling/Modal.scss index a1c29a0032..ada1a62b02 100644 --- a/src/components/Modal/styling/Modal.scss +++ b/src/components/Modal/styling/Modal.scss @@ -8,7 +8,7 @@ --str-chat__modal-color: var(--str-chat__text-color); /* The background color of the content area of the component */ - --str-chat__modal-background-color: var(--background-elevation-elevation-1); + --str-chat__modal-background-color: var(--background-core-elevation-1); /* The overlay color of the component */ --str-chat__modal-overlay-color: var(--background-core-scrim); diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx new file mode 100644 index 0000000000..8f92e274dd --- /dev/null +++ b/src/components/Notifications/Notification.tsx @@ -0,0 +1,152 @@ +import React, { type ComponentType, forwardRef } from 'react'; +import clsx from 'clsx'; +import type { NotificationSeverity } from 'stream-chat'; +import { type Notification as NotificationType } from 'stream-chat'; + +import { + IconArrowRotateRightLeftRepeatRefresh, + IconCheckmark2, + IconCircleInfoTooltip, + IconCrossMedium, + IconExclamationCircle, + IconExclamationTriangle, +} from '../../components/Icons'; +import { useChatContext } from '../../context/ChatContext'; +import { useTranslationContext } from '../../context/TranslationContext'; +import { Button } from '../Button'; + +type NotificationEntryDirection = 'bottom' | 'left' | 'right' | 'top'; +type NotificationTransitionState = 'enter' | 'exit'; + +export type NotificationIconProps = { + notification: NotificationType; +}; + +const IconsBySeverity: Record = { + error: IconExclamationCircle, + info: IconCircleInfoTooltip, + loading: IconArrowRotateRightLeftRepeatRefresh, + success: IconCheckmark2, + warning: IconExclamationTriangle, +}; + +const DefaultNotificationIcon = ({ notification }: NotificationIconProps) => { + if (!notification.severity) return null; + + const Icon = IconsBySeverity[notification.severity] ?? null; + return Icon && ; +}; + +export type NotificationProps = { + /** Notification from client.notifications state */ + notification: NotificationType; + /** Optional class name */ + className?: string; + /** Direction from which the notification enters. */ + entryDirection?: NotificationEntryDirection; + /** Optional custom icon component. */ + Icon?: React.ComponentType; + /** Optional dismiss handler */ + onDismiss?: () => void; + /** Show close button (for persistent notifications) */ + showClose?: boolean; + /** Optional transition state applied by NotificationList. */ + transitionState?: NotificationTransitionState; +}; + +export const Notification = forwardRef( + ( + { + className, + entryDirection, + Icon = DefaultNotificationIcon, + notification, + onDismiss, + showClose = false, + transitionState, + }: NotificationProps, + ref, + ) => { + const { client } = useChatContext(); + const { t } = useTranslationContext(); + + const displayMessage = t('translationBuilderTopic/notification', { + notification, + value: notification.message, + }); + + const handleDismiss = () => { + if (onDismiss) { + onDismiss(); + return; + } + + client.notifications.remove(notification.id); + }; + + const isPersistent = !notification.duration; + + const severity = notification.severity; + + return ( +
+
+ {Icon && ( +
+ +
+ )} +
{displayMessage}
+
+ {notification.actions && notification.actions.length > 0 && ( +
+ {notification.actions.map((action, index) => ( + + ))} +
+ )} + {(showClose || isPersistent) && ( + + )} +
+ ); + }, +); + +Notification.displayName = 'Notification'; diff --git a/src/components/Notifications/NotificationList.tsx b/src/components/Notifications/NotificationList.tsx new file mode 100644 index 0000000000..326ebe54ab --- /dev/null +++ b/src/components/Notifications/NotificationList.tsx @@ -0,0 +1,232 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; +import type { Notification } from 'stream-chat'; + +import { useChatContext } from '../../context'; +import { useNotifications } from './hooks/useNotifications'; +import { Notification as NotificationComponent } from './Notification'; + +import type { NotificationTargetPanel } from './notificationTarget'; + +export type NotificationListFilter = (notification: Notification) => boolean; +export type NotificationListEnterFrom = 'bottom' | 'left' | 'right' | 'top'; +export type NotificationListVerticalAlignment = 'bottom' | 'top'; + +export type NotificationListProps = { + /** Optional class name for the list container */ + className?: string; + /** + * Direction from which the replacement notification enters the single visible slot. + * Travel distance is configured in `ENTER_TRANSLATION` below. + */ + enterFrom?: NotificationListEnterFrom; + /** + * When provided, this list only shows notifications that pass the filter. + * Use to declare which notifications this list consumes (e.g. by origin.emitter, origin.context.channelId, or metadata). + */ + filter?: NotificationListFilter; + /** Panel target consumed by this list. */ + panel?: NotificationTargetPanel; + /** Fallback panel when emitted notifications do not include origin.context.panel. */ + fallbackPanel?: NotificationTargetPanel; + /** Vertical alignment of the single notification slot within its parent. Defaults to `bottom`. */ + verticalAlignment?: NotificationListVerticalAlignment; +}; + +// Entry motion is controlled through CSS variables so the keyframe can stay shared in SCSS. +// Use full-size percentages on the active axis to make a replacement notification slide in +// from outside its slot. If future tuning needs more travel, prefer `calc(100% + gap)` here. +const ENTER_TRANSLATION: Record = { + bottom: { x: '0%', y: '100%' }, + left: { x: '-100%', y: '0%' }, + right: { x: '100%', y: '0%' }, + top: { x: '0%', y: '-100%' }, +}; + +const EXIT_ANIMATION_MS = 340; + +const isEnterFrom = (value: unknown): value is NotificationListEnterFrom => + value === 'bottom' || value === 'left' || value === 'right' || value === 'top'; + +const getNotificationEnterFrom = ( + notification: Notification | null, + fallbackEnterFrom: NotificationListEnterFrom, +) => { + if (!notification) return fallbackEnterFrom; + + const metadataEnterFrom = notification.metadata?.entryDirection; + if (isEnterFrom(metadataEnterFrom)) return metadataEnterFrom; + + const originEnterFrom = notification.origin.context?.entryDirection; + if (isEnterFrom(originEnterFrom)) return originEnterFrom; + + return fallbackEnterFrom; +}; + +export const NotificationList = ({ + className, + enterFrom = 'bottom', + fallbackPanel, + filter, + panel, + verticalAlignment = 'bottom', +}: NotificationListProps) => { + const { client } = useChatContext(); + const exitTimeoutRef = useRef(null); + const latestNotificationRef = useRef(null); + const listRef = useRef(null); + const observedElementRef = useRef(null); + const startedTimeoutIdsRef = useRef | null>(null); + + if (!startedTimeoutIdsRef.current) { + startedTimeoutIdsRef.current = new Set(); + } + + const [displayedNotification, setDisplayedNotification] = useState( + null, + ); + const [transitionState, setTransitionState] = useState<'enter' | 'exit'>('enter'); + const notifications = useNotifications({ fallbackPanel, filter, panel }); + const nextNotification = notifications[0] ?? null; + + const dismiss = useCallback( + (id: string) => { + startedTimeoutIdsRef.current?.delete(id); + client.notifications.remove(id); + }, + [client], + ); + + useEffect(() => { + const notificationIds = new Set(notifications.map(({ id }) => id)); + + startedTimeoutIdsRef.current?.forEach((id) => { + if (!notificationIds.has(id)) { + startedTimeoutIdsRef.current?.delete(id); + } + }); + }, [notifications]); + + useEffect(() => { + latestNotificationRef.current = nextNotification; + }, [nextNotification]); + + useEffect( + () => () => { + if (exitTimeoutRef.current) { + window.clearTimeout(exitTimeoutRef.current); + } + }, + [], + ); + + useEffect(() => { + if (!displayedNotification) { + if (!nextNotification) return; + + setDisplayedNotification(nextNotification); + setTransitionState('enter'); + return; + } + + if (displayedNotification.id === nextNotification?.id) return; + if (transitionState === 'exit') return; + + setTransitionState('exit'); + exitTimeoutRef.current = window.setTimeout(() => { + setDisplayedNotification(latestNotificationRef.current); + setTransitionState('enter'); + exitTimeoutRef.current = null; + }, EXIT_ANIMATION_MS); + }, [displayedNotification, nextNotification, transitionState]); + + const notification = displayedNotification; + const notificationEnterFrom = getNotificationEnterFrom(notification, enterFrom); + + useEffect(() => { + const element = observedElementRef.current; + if (!element || !notification || transitionState === 'exit') return; + + const startTimeout = () => { + if ( + !startedTimeoutIdsRef.current || + startedTimeoutIdsRef.current.has(notification.id) + ) + return; + + startedTimeoutIdsRef.current.add(notification.id); + client.notifications.startTimeout(notification.id); + }; + + if (typeof IntersectionObserver === 'undefined') { + startTimeout(); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const [entry] = entries; + if (!entry?.isIntersecting) return; + + startTimeout(); + observer.disconnect(); + }, + { + root: listRef.current, + threshold: 0.5, + }, + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [client, notification, transitionState]); + + if (!notification) return null; + + return ( +
+
+ dismiss(notification.id)} + ref={(element) => { + observedElementRef.current = element; + }} + showClose={!notification.duration} + transitionState={transitionState} + /> +
+
+ ); +}; diff --git a/src/components/Notifications/__tests__/NotificationList.test.tsx b/src/components/Notifications/__tests__/NotificationList.test.tsx new file mode 100644 index 0000000000..6743b07cd9 --- /dev/null +++ b/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { useChatContext } from '../../../context'; +import { NotificationList } from '../NotificationList'; +import { useNotifications } from '../hooks/useNotifications'; + +import type { Notification } from 'stream-chat'; + +jest.mock('../../../context', () => ({ + useChatContext: jest.fn(), +})); + +jest.mock('../hooks/useNotifications', () => ({ + useNotifications: jest.fn(), +})); + +jest.mock('../Notification', () => { + const MockNotification = React.forwardRef( + ( + { + className, + entryDirection, + notification, + onDismiss, + }: { + className?: string; + entryDirection?: string; + notification: { id: string; message: string }; + onDismiss?: () => void; + }, + ref: React.Ref, + ) => ( +
+ {notification.message} + +
+ ), + ); + MockNotification.displayName = 'Notification'; + return { Notification: MockNotification }; +}); + +const mockedUseChatContext = jest.mocked(useChatContext); +const mockedUseNotifications = jest.mocked(useNotifications); + +const clearTimeout = jest.fn(); +const remove = jest.fn(); +const startTimeout = jest.fn(); + +const notifications = [ + { + createdAt: 1, + duration: 1000, + id: 'n-1', + message: 'First', + origin: { emitter: 'test' }, + severity: 'info', + }, + { + createdAt: 2, + id: 'n-2', + message: 'Second', + origin: { emitter: 'test' }, + severity: 'info', + }, +] as Notification[]; + +type MockObserverEntry = { + callback: IntersectionObserverCallback; + element?: Element; + options?: IntersectionObserverInit; +}; + +const observerEntries: MockObserverEntry[] = []; + +class IntersectionObserverMock { + callback: IntersectionObserverCallback; + options?: IntersectionObserverInit; + element?: Element; + + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + this.callback = callback; + this.options = options; + observerEntries.push({ callback, options }); + } + + disconnect = jest.fn(); + + observe = (element: Element) => { + this.element = element; + observerEntries[observerEntries.length - 1].element = element; + }; + + unobserve = jest.fn(); +} + +describe('NotificationList', () => { + let currentNotifications: Notification[]; + + beforeEach(() => { + jest.useFakeTimers(); + observerEntries.splice(0, observerEntries.length); + currentNotifications = [...notifications]; + mockedUseChatContext.mockReturnValue({ + client: { notifications: { clearTimeout, remove, startTimeout } }, + }); + remove.mockImplementation((id: string) => { + currentNotifications = currentNotifications.filter( + (notification) => notification.id !== id, + ); + }); + mockedUseNotifications.mockImplementation(() => currentNotifications); + window.IntersectionObserver = IntersectionObserverMock; + }); + + afterEach(() => { + jest.useRealTimers(); + clearTimeout.mockReset(); + remove.mockReset(); + startTimeout.mockReset(); + mockedUseChatContext.mockReset(); + mockedUseNotifications.mockReset(); + delete (window as Partial).IntersectionObserver; + }); + + it('starts a timeout only when a notification first intersects the scroll container', () => { + render(); + + expect(startTimeout).not.toHaveBeenCalled(); + expect(observerEntries).toHaveLength(1); + expect(screen.getByTestId('notification-list')).toHaveClass( + 'str-chat__notification-list--position-bottom', + ); + + const listElement = screen.getByTestId('notification-list'); + expect(observerEntries[0].options?.root).toBe(listElement); + + observerEntries[0].callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + observerEntries[0].callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + + expect(startTimeout).toHaveBeenCalledTimes(1); + expect(startTimeout).toHaveBeenCalledWith('n-1'); + }); + + it('starts timeouts immediately when IntersectionObserver is not available', () => { + delete (window as Partial).IntersectionObserver; + + render(); + + expect(startTimeout).toHaveBeenCalledTimes(1); + expect(startTimeout).toHaveBeenNthCalledWith(1, 'n-1'); + }); + + it('clears timeout and removes notification on dismiss', () => { + const { rerender } = render(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0]); + + expect(screen.getByTestId('notification-n-1')).toBeInTheDocument(); + expect(screen.queryByTestId('notification-n-2')).not.toBeInTheDocument(); + + rerender(); + act(() => { + jest.advanceTimersByTime(340); + }); + rerender(); + + expect(remove).toHaveBeenCalledWith('n-1'); + expect(screen.getByTestId('notification-n-2')).toBeInTheDocument(); + }); + + it('supports bottom alignment', () => { + render(); + + expect(screen.getByTestId('notification-list')).toHaveClass( + 'str-chat__notification-list--position-bottom', + ); + }); + + it('prefers per-notification entry direction over the list fallback', () => { + currentNotifications = [ + { + ...notifications[0], + metadata: { entryDirection: 'left' }, + }, + ]; + + render(); + + expect(screen.getByTestId('notification-list')).toHaveClass( + 'str-chat__notification-list--enter-from-left', + ); + expect(screen.getByTestId('notification-n-1')).toHaveAttribute( + 'data-entry-direction', + 'left', + ); + }); +}); diff --git a/src/components/Notifications/__tests__/notificationOrigin.test.ts b/src/components/Notifications/__tests__/notificationOrigin.test.ts new file mode 100644 index 0000000000..eb768d7005 --- /dev/null +++ b/src/components/Notifications/__tests__/notificationOrigin.test.ts @@ -0,0 +1,63 @@ +import { + getNotificationTargetPanel, + isNotificationForPanel, + isNotificationTargetPanel, +} from '../notificationTarget'; + +import type { Notification } from 'stream-chat'; + +const notification = (panel?: unknown) => + ({ + createdAt: Date.now(), + id: 'n1', + message: 'test', + origin: { + context: panel === undefined ? {} : { panel }, + emitter: 'test', + }, + severity: 'info', + }) as Notification; + +const taggedNotification = (tag: string) => + ({ + createdAt: Date.now(), + id: 'n2', + message: 'test', + origin: { + context: {}, + emitter: 'test', + }, + severity: 'info', + tags: [tag], + }) as Notification; + +describe('notificationOrigin helpers', () => { + it('recognizes supported panel values', () => { + expect(isNotificationTargetPanel('channel')).toBe(true); + expect(isNotificationTargetPanel('thread')).toBe(true); + expect(isNotificationTargetPanel('channel-list')).toBe(true); + expect(isNotificationTargetPanel('thread-list')).toBe(true); + expect(isNotificationTargetPanel('unknown')).toBe(false); + }); + + it('extracts panel from notification origin context', () => { + expect(getNotificationTargetPanel(notification('thread-list'))).toBe('thread-list'); + expect(getNotificationTargetPanel(notification('invalid-panel'))).toBeUndefined(); + }); + + it('extracts panel from target tag when present', () => { + expect(getNotificationTargetPanel(taggedNotification('target:channel-list'))).toBe( + 'channel-list', + ); + }); + + it('falls back to channel panel when panel is missing', () => { + expect(isNotificationForPanel(notification(), 'channel')).toBe(true); + expect(isNotificationForPanel(notification(), 'thread')).toBe(false); + }); + + it('matches explicit target panel when present', () => { + expect(isNotificationForPanel(notification('thread'), 'thread')).toBe(true); + expect(isNotificationForPanel(notification('thread'), 'channel')).toBe(false); + }); +}); diff --git a/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.ts b/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.ts new file mode 100644 index 0000000000..dbfedff34d --- /dev/null +++ b/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.ts @@ -0,0 +1,89 @@ +import { renderHook } from '@testing-library/react'; + +import { useChatViewContext } from '../../../ChatView'; +import { useChannelStateContext } from '../../../../context'; +import { useNotificationTarget } from '../useNotificationTarget'; +import { useThreadContext } from '../../../Threads/ThreadContext'; + +jest.mock('../../../ChatView', () => ({ + useChatViewContext: jest.fn(), +})); + +jest.mock('../../../../context', () => ({ + useChannelStateContext: jest.fn(), +})); + +jest.mock('../../../Threads/ThreadContext', () => ({ + useThreadContext: jest.fn(), +})); + +const mockedUseChannelStateContext = jest.mocked(useChannelStateContext); +const mockedUseChatViewContext = jest.mocked(useChatViewContext); +const mockedUseThreadContext = jest.mocked(useThreadContext); + +describe('useNotificationTarget', () => { + beforeEach(() => { + mockedUseChannelStateContext.mockReturnValue({}); + mockedUseChatViewContext.mockReturnValue({ + activeChatView: 'channels', + setActiveChatView: jest.fn(), + }); + mockedUseThreadContext.mockReturnValue(undefined); + }); + + afterEach(() => { + mockedUseChannelStateContext.mockReset(); + mockedUseChatViewContext.mockReset(); + mockedUseThreadContext.mockReset(); + }); + + it('returns channel when channel context exists', () => { + mockedUseChannelStateContext.mockReturnValue({ channel: {} }); + + const { result } = renderHook(() => useNotificationTarget()); + + expect(result.current).toBe('channel'); + }); + + it('returns thread when thread context exists', () => { + mockedUseThreadContext.mockReturnValue({}); + + const { result } = renderHook(() => useNotificationTarget()); + + expect(result.current).toBe('thread'); + }); + + it('returns channel-list for channels view without thread or channel context', () => { + mockedUseChatViewContext.mockReturnValue({ + activeChatView: 'channels', + setActiveChatView: jest.fn(), + }); + + const { result } = renderHook(() => useNotificationTarget()); + + expect(result.current).toBe('channel-list'); + }); + + it('returns thread-list for threads view without thread or channel context', () => { + mockedUseChatViewContext.mockReturnValue({ + activeChatView: 'threads', + setActiveChatView: jest.fn(), + }); + + const { result } = renderHook(() => useNotificationTarget()); + + expect(result.current).toBe('thread-list'); + }); + + it('throws when chat view context is missing', () => { + mockedUseChatViewContext.mockImplementation(() => { + throw new Error( + 'The useChatViewContext hook was called outside of the ChatView provider.', + ); + }); + + expect(() => renderHook(() => useNotificationTarget())).toThrow( + 'The useChatViewContext hook was called outside of the ChatView provider.', + ); + }); +}); diff --git a/src/components/Notifications/hooks/index.ts b/src/components/Notifications/hooks/index.ts index 0435a4c45c..cdd0149777 100644 --- a/src/components/Notifications/hooks/index.ts +++ b/src/components/Notifications/hooks/index.ts @@ -1 +1,2 @@ export * from './useNotifications'; +export * from './useNotificationTarget'; diff --git a/src/components/Notifications/hooks/useNotificationTarget.ts b/src/components/Notifications/hooks/useNotificationTarget.ts new file mode 100644 index 0000000000..c1024e6d44 --- /dev/null +++ b/src/components/Notifications/hooks/useNotificationTarget.ts @@ -0,0 +1,21 @@ +import { useChatViewContext } from '../../ChatView'; +import { useChannelStateContext } from '../../../context'; +import { useThreadContext } from '../../Threads/ThreadContext'; + +import type { NotificationTargetPanel } from '../notificationTarget'; +import { useLegacyThreadContext } from '../../Thread'; + +/** + * Resolves the panel target where notifications emitted by the current component should be displayed. + */ +export const useNotificationTarget = (): NotificationTargetPanel => { + const { activeChatView } = useChatViewContext(); + const { channel } = useChannelStateContext(); + const threadInstance = useThreadContext(); + const { legacyThread } = useLegacyThreadContext(); + + if (threadInstance || legacyThread) return 'thread'; + if (channel) return 'channel'; + if (activeChatView === 'threads') return 'thread-list'; + return 'channel-list'; +}; diff --git a/src/components/Notifications/hooks/useNotifications.ts b/src/components/Notifications/hooks/useNotifications.ts index e281cc9996..62c03b19e1 100644 --- a/src/components/Notifications/hooks/useNotifications.ts +++ b/src/components/Notifications/hooks/useNotifications.ts @@ -1,13 +1,57 @@ +import { useCallback } from 'react'; import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; import type { Notification, NotificationManagerState } from 'stream-chat'; +import { isNotificationForPanel } from '../notificationTarget'; -const selector = (state: NotificationManagerState) => ({ - notifications: state.notifications, -}); +import type { NotificationTargetPanel } from '../notificationTarget'; -export const useNotifications = (): Notification[] => { +export type UseNotificationsFilter = (notification: Notification) => boolean; + +export type UseNotificationsOptions = { + /** + * When provided, only notifications that pass this filter are returned. + * Use to have a given NotificationList consume only a subset of client.notifications + * (e.g. by origin.emitter, origin.context, or metadata). + */ + filter?: UseNotificationsFilter; + /** + * Panel target consumed by a specific notification list. + */ + panel?: NotificationTargetPanel; + /** + * Fallback panel used when origin.context.panel is absent. + * Defaults to `channel`. + */ + fallbackPanel?: NotificationTargetPanel; +}; + +/** + * Subscribes to client.notifications.store and returns the list of notifications. + * Optionally pass a filter so only notifications that match are returned (e.g. for a specific NotificationList). + */ +export const useNotifications = (options?: UseNotificationsOptions): Notification[] => { const { client } = useChatContext(); - const result = useStateStore(client.notifications.store, selector); - return result.notifications; + const selector = useCallback( + (state: NotificationManagerState) => { + const notifications = state.notifications; + const panel = options?.panel; + const byPanel = panel + ? notifications.filter((notification) => + isNotificationForPanel(notification, panel, { + fallbackPanel: options?.fallbackPanel, + }), + ) + : notifications; + + return { + notifications: options?.filter ? byPanel.filter(options.filter) : byPanel, + }; + }, + [options?.fallbackPanel, options?.filter, options?.panel], + ); + + const { notifications } = useStateStore(client.notifications.store, selector); + + return notifications; }; diff --git a/src/components/Notifications/index.ts b/src/components/Notifications/index.ts index 4cc90d02bd..8e859c0f90 100644 --- a/src/components/Notifications/index.ts +++ b/src/components/Notifications/index.ts @@ -1 +1,4 @@ export * from './hooks'; +export * from './Notification'; +export * from './NotificationList'; +export * from './notificationTarget'; diff --git a/src/components/Notifications/notificationOrigin.ts b/src/components/Notifications/notificationOrigin.ts deleted file mode 100644 index beeb652ca3..0000000000 --- a/src/components/Notifications/notificationOrigin.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Panel where the notification was registered (channel vs thread). - * Use in origin.context.panel when publishing so NotificationList can filter by panel. - */ -export type NotificationOriginPanel = 'channel' | 'thread'; diff --git a/src/components/Notifications/notificationTarget.ts b/src/components/Notifications/notificationTarget.ts new file mode 100644 index 0000000000..b5e761b67e --- /dev/null +++ b/src/components/Notifications/notificationTarget.ts @@ -0,0 +1,50 @@ +import type { Notification } from 'stream-chat'; + +const NOTIFICATION_TARGET_PANELS = [ + 'channel', + 'thread', + 'channel-list', + 'thread-list', +] as const; + +/** + * Panel where a notification should be consumed. + * Use in origin.context.panel when publishing so NotificationList can filter by panel. + */ +export type NotificationTargetPanel = (typeof NOTIFICATION_TARGET_PANELS)[number]; + +export const isNotificationTargetPanel = ( + value: unknown, +): value is NotificationTargetPanel => + typeof value === 'string' && + (NOTIFICATION_TARGET_PANELS as readonly string[]).includes(value); + +export const getNotificationTargetPanel = ( + notification: Notification, +): NotificationTargetPanel | undefined => { + const targetTag = notification.tags?.find((tag) => tag.startsWith('target:')); + if (targetTag) { + const candidate = targetTag.slice('target:'.length); + if (isNotificationTargetPanel(candidate)) return candidate; + } + const panel = notification.origin.context?.panel; + return isNotificationTargetPanel(panel) ? panel : undefined; +}; + +export const getNotificationTargetTag = (panel: NotificationTargetPanel) => + `target:${panel}` as const; + +export const addNotificationTargetTag = ( + panel: NotificationTargetPanel, + tags?: string[], +) => Array.from(new Set([getNotificationTargetTag(panel), ...(tags ?? [])])); + +export const isNotificationForPanel = ( + notification: Notification, + panel: NotificationTargetPanel, + options?: { fallbackPanel?: NotificationTargetPanel }, +) => { + const fallbackPanel = options?.fallbackPanel ?? 'channel'; + const resolvedPanel = getNotificationTargetPanel(notification) ?? fallbackPanel; + return resolvedPanel === panel; +}; diff --git a/src/components/Notifications/styling/Notification.scss b/src/components/Notifications/styling/Notification.scss new file mode 100644 index 0000000000..b7d27bae82 --- /dev/null +++ b/src/components/Notifications/styling/Notification.scss @@ -0,0 +1,171 @@ +.str-chat__notification { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + min-height: 48px; + min-width: 200px; + max-width: 100%; + padding: var(--spacing-xs); + position: relative; + pointer-events: visible; + background: var(--str-chat__notification-background, var(--background-core-inverse)); + border-radius: var(--str-chat__notification-border-radius, var(--radius-3xl)); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 4px 8px 0 rgba(0, 0, 0, 0.14), + 0 12px 24px 0 rgba(0, 0, 0, 0.1); + color: var(--str-chat__notification-color, var(--text-inverse)); + + .str-chat__notification-content { + align-items: flex-start; + display: flex; + flex: 1 1 auto; + gap: var(--spacing-xs); + min-width: 0; + + .str-chat__notification-icon { + display: flex; + align-self: center; + height: 100%; + + svg { + block-size: var(--icon-size-sm); + inline-size: var(--icon-size-sm); + } + } + + .str-chat__notification-message { + flex: 1 1 auto; + padding-block: var(--spacing-xxxs); + font: var(--str-chat__caption-default-text); + min-width: 0; + } + } + + .str-chat__notification-actions { + display: flex; + flex-basis: 100%; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-xxs); + } + + .str-chat__notification-close-button { + align-self: center; + padding: var(--spacing-xxs); + + svg { + height: var(--icon-size-sm); + width: var(--icon-size-sm); + } + } +} + +.str-chat__notification--is-entering { + // Keep entry slow enough to hide the slot swap when the current notification is dismissed. + // Duration/easing live here; directional distance is controlled by NotificationList.tsx. + animation: str-chat__notification-list-enter 760ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: opacity, transform; +} + +.str-chat__notification--is-exiting { + animation-duration: 340ms; + animation-fill-mode: forwards; + animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1); + will-change: opacity, transform; +} + +.str-chat__notification--is-exiting.str-chat__notification--enter-from-bottom { + animation-name: str-chat__notification-list-exit-to-bottom; +} + +.str-chat__notification--is-exiting.str-chat__notification--enter-from-left { + animation-name: str-chat__notification-list-exit-to-left; +} + +.str-chat__notification--is-exiting.str-chat__notification--enter-from-right { + animation-name: str-chat__notification-list-exit-to-right; +} + +.str-chat__notification--is-exiting.str-chat__notification--enter-from-top { + animation-name: str-chat__notification-list-exit-to-top; +} + +//// Severity overrides: allow themes to keep colored variants; defaults match Figma (inverse). +//.str-chat__notification--success { +// background: var(--str-chat-success-background, var(--background-core-inverse)); +// color: var(--str-chat-success-color, var(--text-inverse)); +//} +// +//.str-chat__notification--error { +// background: var(--str-chat-error-background, var(--background-core-inverse)); +// color: var(--str-chat-error-color, var(--text-inverse)); +//} +// +//.str-chat__notification--warning { +// background: var(--str-chat-warning-background, var(--background-core-inverse)); +// color: var(--str-chat-warning-color, var(--text-inverse)); +//} +// +//.str-chat__notification--info { +// background: var(--str-chat-info-background, var(--background-core-inverse)); +// color: var(--str-chat-info-color, var(--text-inverse)); +//} + +// Loading state: spin the refresh icon +.str-chat__notification--loading .str-chat__notification-icon { + animation: str-chat__notification-spin 0.8s linear infinite; +} + +@keyframes str-chat__notification-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes str-chat__notification-list-exit-to-bottom { + from { + opacity: 1; + transform: translate(0, 0); + } + to { + opacity: 0; + transform: translate(0, 35%) scale(0.98); + } +} + +@keyframes str-chat__notification-list-exit-to-left { + from { + opacity: 1; + transform: translate(0, 0); + } + to { + opacity: 0; + transform: translate(-35%, 0) scale(0.98); + } +} + +@keyframes str-chat__notification-list-exit-to-right { + from { + opacity: 1; + transform: translate(0, 0); + } + to { + opacity: 0; + transform: translate(35%, 0) scale(0.98); + } +} + +@keyframes str-chat__notification-list-exit-to-top { + from { + opacity: 1; + transform: translate(0, 0); + } + to { + opacity: 0; + transform: translate(0, -35%) scale(0.98); + } +} diff --git a/src/components/Notifications/styling/NotificationList.scss b/src/components/Notifications/styling/NotificationList.scss new file mode 100644 index 0000000000..e840557344 --- /dev/null +++ b/src/components/Notifications/styling/NotificationList.scss @@ -0,0 +1,70 @@ +.str-chat__notification-list { + --str-chat__notification-list-inset: 16px; + --str-chat__notification-list-gap: 8px; + --str-chat__notification-list-max-height: calc( + 100% - (var(--str-chat__notification-list-inset) * 2) + ); + --str-chat__notification-list-width: min( + 100%, + calc(100% - (var(--str-chat__notification-list-inset) * 2)) + ); + + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + inline-size: var(--str-chat__notification-list-width); + max-width: 100%; + min-width: 0; + max-height: var(--str-chat__notification-list-max-height); + padding-inline: var(--spacing-xs); + pointer-events: none; + position: absolute; + inset-inline-end: var(--str-chat__notification-list-inset); + z-index: 2; +} + +.str-chat__notification-list--position-top { + inset-block-start: var(--str-chat__notification-list-inset); +} + +.str-chat__notification-list--position-bottom { + inset-block-end: var(--str-chat__notification-list-inset); +} + +.str-chat__notification-list__edge { + flex-shrink: 0; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + z-index: 1; +} + +.str-chat__notification-list__edge--top { + top: 0; +} + +.str-chat__notification-list__edge--bottom { + bottom: 0; +} + +// Shared entry keyframe for the single-slot notification list. +// The direction-specific travel is injected from NotificationList.tsx through CSS variables: +// `--str-chat__notification-list-enter-x` and `--str-chat__notification-list-enter-y`. +// Adjust timing/easing in Notification.scss and adjust travel distance in NotificationList.tsx. +@keyframes str-chat__notification-list-enter { + from { + opacity: 0.2; + transform: translate( + var(--str-chat__notification-list-enter-x, 0), + var(--str-chat__notification-list-enter-y, 100%) + ); + } + + to { + opacity: 1; + transform: translate(0, 0); + } +} diff --git a/src/components/Notifications/styling/index.scss b/src/components/Notifications/styling/index.scss new file mode 100644 index 0000000000..d967b303c6 --- /dev/null +++ b/src/components/Notifications/styling/index.scss @@ -0,0 +1,2 @@ +@use 'Notification'; +@use 'NotificationList'; diff --git a/src/components/Poll/PollActions/EndPollAlert.tsx b/src/components/Poll/PollActions/EndPollAlert.tsx index 8b2d11a85d..243dc39ee2 100644 --- a/src/components/Poll/PollActions/EndPollAlert.tsx +++ b/src/components/Poll/PollActions/EndPollAlert.tsx @@ -7,12 +7,14 @@ import { useTranslationContext, } from '../../../context'; import { Button } from '../../Button'; +import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications'; export const EndPollAlert = () => { const { client } = useChatContext(); const { t } = useTranslationContext(); const { poll } = usePollContext(); const { close } = useModalContext(); + const panel = useNotificationTarget(); return ( @@ -34,6 +36,7 @@ export const EndPollAlert = () => { client.notifications.addSuccess({ message: t('Poll ended'), options: { + tags: addNotificationTargetTag(panel), type: 'api:poll:end:success', }, origin: { emitter: 'EndPollAlert' }, @@ -42,6 +45,8 @@ export const EndPollAlert = () => { client.notifications.addError({ message: t('Failed to end the poll'), options: { + originalError: e instanceof Error ? e : undefined, + tags: addNotificationTargetTag(panel), type: 'api:poll:end:failed', }, origin: { emitter: 'EndPollAlert' }, diff --git a/src/components/Poll/styling/PollOptionFullList.scss b/src/components/Poll/styling/PollOptionFullList.scss index 74f7a01592..c81fc7dba2 100644 --- a/src/components/Poll/styling/PollOptionFullList.scss +++ b/src/components/Poll/styling/PollOptionFullList.scss @@ -22,7 +22,7 @@ } .str-chat__poll-option--votable { &:hover { - background-color: var(--background-core-hover); + background-color: var(--background-utility-hover); } } } diff --git a/src/components/Reactions/styling/MessageReactionsDetail.scss b/src/components/Reactions/styling/MessageReactionsDetail.scss index 240b09f94f..086b578fb6 100644 --- a/src/components/Reactions/styling/MessageReactionsDetail.scss +++ b/src/components/Reactions/styling/MessageReactionsDetail.scss @@ -10,7 +10,7 @@ .str-chat__message-reactions-detail { border-radius: var(--radius-lg); - background: var(--background-elevation-elevation-2); + background: var(--background-core-elevation-2); max-width: 256px; min-width: min(90vw, 256px); diff --git a/src/components/Reactions/styling/ReactionSelector.scss b/src/components/Reactions/styling/ReactionSelector.scss index 81deeb879f..92a421f977 100644 --- a/src/components/Reactions/styling/ReactionSelector.scss +++ b/src/components/Reactions/styling/ReactionSelector.scss @@ -10,7 +10,7 @@ border-radius: var(--radius-4xl, 32px); border: 1px solid var(--border-core-surface-subtle, #e2e6ea); - background: var(--background-elevation-elevation-2, #fff); + background: var(--background-core-elevation-2, #fff); box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16); @@ -81,17 +81,17 @@ gap: var(--spacing-none, 0); &:not(:disabled):hover { - background-color: var(--background-core-hover); + background-color: var(--background-utility-hover); } &:not(:disabled):active { - background-color: var(--background-core-pressed); + background-color: var(--background-utility-pressed); } &:not(:disabled):focus-visible { outline: 2px solid var(--border-utility-focus); outline-offset: -2px; } &:not(:disabled)[aria-pressed='true'] { - background-color: var(--background-core-selected); + background-color: var(--background-utility-selected); } .str-chat__reaction-icon { diff --git a/src/components/Reactions/styling/common.scss b/src/components/Reactions/styling/common.scss index a91a492008..f8ccaf7ed6 100644 --- a/src/components/Reactions/styling/common.scss +++ b/src/components/Reactions/styling/common.scss @@ -34,13 +34,13 @@ } &:hover::before { - background: var(--background-core-hover); + background: var(--background-utility-hover); } &:active::before { - background: var(--background-core-pressed); + background: var(--background-utility-pressed); } &[aria-pressed='true']::before { - background: var(--background-core-selected); + background: var(--background-utility-selected); } } } diff --git a/src/components/ResizableContainer/styling/ResizableContainer.scss b/src/components/ResizableContainer/styling/ResizableContainer.scss deleted file mode 100644 index b4eec3d472..0000000000 --- a/src/components/ResizableContainer/styling/ResizableContainer.scss +++ /dev/null @@ -1,81 +0,0 @@ -.str-chat__resize-container { - // layout only; panels and handles are children - - .str-chat__resize-container__center { - display: flex; - flex-direction: column; - } -} - -.str-chat__resize-panel { - .str-chat__resize-panel__content { - display: flex; - flex-direction: column; - } - - .str-chat__resize-panel__handle { - position: relative; - z-index: 1; - background: transparent; - transition: background-color 0.15s ease; - - &:hover, - &:focus-visible { - background: var(--str-chat__primary-color-10, rgba(0 0 0 / 0.06)); - } - - &:active { - background: var(--str-chat__primary-color-15, rgba(0 0 0 / 0.09)); - } - } - - .str-chat__resize-panel__handle-line { - display: flex; - align-items: center; - justify-content: center; - width: 1px; - height: 100%; - min-height: 24px; - background: var(--str-chat__border-color, rgba(0 0 0 / 0.12)); - } - - .str-chat__resize-panel__handle-icon { - width: 16px; - height: 16px; - color: var(--str-chat__secondary-color, rgba(0 0 0 / 0.5)); - pointer-events: none; - } - - .str-chat__resize-panel__expand-tab { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-width: 20px; - height: 100%; - min-height: 48px; - padding: 0; - border: none; - background: transparent; - color: var(--str-chat__secondary-color, rgba(0 0 0 / 0.5)); - cursor: col-resize; - transition: - background-color 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--str-chat__primary-color-10, rgba(0 0 0 / 0.06)); - color: var(--str-chat__primary-color, #006cff); - } - - &:focus-visible { - outline: 2px solid var(--str-chat__primary-color, #006cff); - outline-offset: 2px; - } - - svg { - width: 16px; - height: 16px; - } - } -} diff --git a/src/components/Search/styling/Search.scss b/src/components/Search/styling/Search.scss index 17c18c58c4..263d1e8b0e 100644 --- a/src/components/Search/styling/Search.scss +++ b/src/components/Search/styling/Search.scss @@ -17,7 +17,7 @@ align-items: center; gap: var(--spacing-xs); border-radius: var(--radius-max); - border: 1px solid var(--input-border-default); + border: 1px solid var(--border-core-default); color: var(--input-text-placeholder); font: var(--str_chat__heading-xs-text); // FIXME: there's no proper variation so we need to adjust font-weight separately @@ -129,15 +129,15 @@ } } - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); &:not(:disabled):hover { - background: var(--background-core-hover); + background: var(--background-utility-hover); } &:not(:disabled):active { - background: var(--background-core-pressed); + background: var(--background-utility-pressed); } &:not(:disabled)[aria-pressed='true'] { - background: var(--background-core-selected); + background: var(--background-utility-selected); } } } diff --git a/src/components/Thread/__tests__/ThreadHeader.test.js b/src/components/Thread/__tests__/ThreadHeader.test.js new file mode 100644 index 0000000000..fec18193bf --- /dev/null +++ b/src/components/Thread/__tests__/ThreadHeader.test.js @@ -0,0 +1,118 @@ +import '@testing-library/jest-dom'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { ChannelStateProvider } from '../../../context/ChannelStateContext'; +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import { useChannelDisplayName } from '../../ChannelPreview/hooks/useChannelDisplayName'; +import { ThreadHeader } from '../ThreadHeader'; + +jest.mock('../../ChannelPreview/hooks/useChannelDisplayName', () => ({ + useChannelDisplayName: jest.fn(), +})); + +jest.mock('../../Threads', () => ({ + useThreadContext: jest.fn(() => undefined), +})); + +const alice = { id: 'alice', name: 'Alice' }; +const bob = { id: 'bob', name: 'Bob' }; +const createThread = (user) => ({ + id: `${user?.id ?? 'thread'}-message`, + reply_count: 2, + user, +}); + +const createChannel = (overrides = {}) => ({ + data: undefined, + getClient: () => ({ userID: alice.id }), + state: { + members: { + [alice.id]: { user: alice }, + [bob.id]: { user: bob }, + }, + }, + ...overrides, +}); + +const renderComponent = ({ channelOverrides = {}, props = {} } = {}) => { + const client = { off: jest.fn(), on: jest.fn(), userID: alice.id }; + const thread = createThread(alice); + const channel = createChannel(channelOverrides); + + return render( + + + { + if (key === 'Thread') return 'Thread'; + if (key === 'replyCount') return `${options.count} replies`; + if (key === 'Direct message') return 'Direct message'; + if (key === 'aria/Close thread') return 'Close thread'; + + return key; + }, + }} + > + + + + , + ); +}; + +describe('ThreadHeader', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('renders the channel display title in the subtitle', async () => { + useChannelDisplayName.mockReturnValue('Bob'); + + await renderComponent(); + + expect(screen.getByText('Bob · 2 replies')).toBeInTheDocument(); + }); + + it('falls back to the parent message author when the channel has no display title', async () => { + useChannelDisplayName.mockReturnValue(undefined); + + await renderComponent({ + channelOverrides: { + state: { + members: { + [alice.id]: { user: alice }, + }, + }, + }, + props: { + thread: createThread(alice), + }, + }); + + expect(screen.getByText('Alice · 2 replies')).toBeInTheDocument(); + }); + + it('renders only the reply count when no title source is available', async () => { + useChannelDisplayName.mockReturnValue(undefined); + + await renderComponent({ + channelOverrides: { + state: { + members: { + [alice.id]: { user: alice }, + }, + }, + }, + props: { + thread: createThread({ id: 'alice' }), + }, + }); + + expect(screen.getByText('2 replies')).toBeInTheDocument(); + expect(screen.queryByText(/^undefined ·/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Thread/styling/ThreadHeader.scss b/src/components/Thread/styling/ThreadHeader.scss index 23257bd5ed..8c1f5a79cd 100644 --- a/src/components/Thread/styling/ThreadHeader.scss +++ b/src/components/Thread/styling/ThreadHeader.scss @@ -8,7 +8,7 @@ --str-chat__thread-header-color: var(--str-chat__text-color); /* The background color of the thread header */ - --str-chat__thread-header-background-color: var(--background-elevation-elevation-1); + --str-chat__thread-header-background-color: var(--background-core-elevation-1); /* Top border of the thread header */ --str-chat__thread-header-border-block-start: none; diff --git a/src/components/Thread/styling/ThreadHeaderMain.scss b/src/components/Thread/styling/ThreadHeaderMain.scss index 69b016b52b..1c06d66da4 100644 --- a/src/components/Thread/styling/ThreadHeaderMain.scss +++ b/src/components/Thread/styling/ThreadHeaderMain.scss @@ -3,7 +3,7 @@ .str-chat__thread-header--main { @include utils.header-layout; border-bottom: 1px solid var(--border-core-default); - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); .str-chat__thread-header--main__details { @include utils.header-text-layout; diff --git a/src/components/Threads/ThreadContext.tsx b/src/components/Threads/ThreadContext.tsx index e18dae2d41..af11beb563 100644 --- a/src/components/Threads/ThreadContext.tsx +++ b/src/components/Threads/ThreadContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext } from 'react'; -import { Channel } from '../../components'; +import { Channel } from '../Channel'; import type { PropsWithChildren } from 'react'; import type { Thread } from 'stream-chat'; diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index fcc7661ae3..d41547cdd2 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -10,6 +10,7 @@ import { ThreadListEmptyPlaceholder as DefaultThreadListEmptyPlaceholder } from import { ThreadListUnseenThreadsBanner as DefaultThreadListUnseenThreadsBanner } from './ThreadListUnseenThreadsBanner'; import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from './ThreadListLoadingIndicator'; import { LoadingChannels } from '../../Loading'; +import { NotificationList } from '../../Notifications'; import { useChatContext, useComponentContext } from '../../../context'; import { useStateStore } from '../../../store'; import { ThreadListHeader } from './ThreadListHeader'; @@ -86,6 +87,7 @@ const useThreadHighlighting = (threadManager: ThreadManager) => { export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { const { client, navOpen = true } = useChatContext(); const { + NotificationList: NotificationListFromContext = NotificationList, ThreadListEmptyPlaceholder = DefaultThreadListEmptyPlaceholder, ThreadListItem = DefaultThreadListItem, ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator, @@ -142,6 +144,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { // itemsRendered={(items) => console.log({ items })} {...virtuosoProps} /> +
); }; diff --git a/src/components/Threads/ThreadList/styling/ThreadList.scss b/src/components/Threads/ThreadList/styling/ThreadList.scss index 2cd7e4ad69..4335bcd4c3 100644 --- a/src/components/Threads/ThreadList/styling/ThreadList.scss +++ b/src/components/Threads/ThreadList/styling/ThreadList.scss @@ -35,6 +35,7 @@ max-width: 100%; min-width: var(--str-chat__thread-list-min-width); opacity: 1; + position: relative; transform: translateX(0); transition: flex-basis var(--str-chat__thread-list-transition-duration) @@ -133,7 +134,7 @@ } &:not(:disabled):hover { - @include utils.overlay-after(var(--background-core-hover)); + @include utils.overlay-after(var(--background-utility-hover)); } } } diff --git a/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss index 46a0fd4440..b6879f2c49 100644 --- a/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss +++ b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss @@ -20,19 +20,19 @@ border: none; cursor: pointer; text-align: start; - background: var(--base-transparent-0); + background: var(--background-core-elevation-1); border-radius: var(--radius-lg); width: 100%; max-width: 100%; &:not(:disabled):hover { - background: var(--background-core-hover); + background: var(--background-utility-hover); } &:not(:disabled):active { - background: var(--background-core-pressed); + background: var(--background-utility-pressed); } &:not(:disabled)[aria-pressed='true'] { - background: var(--background-core-selected); + background: var(--background-utility-selected); } .str-chat__avatar { diff --git a/src/context/ChannelActionContext.tsx b/src/context/ChannelActionContext.tsx index d53278b4fc..fd668fa7f1 100644 --- a/src/context/ChannelActionContext.tsx +++ b/src/context/ChannelActionContext.tsx @@ -28,7 +28,6 @@ export type MarkReadWrapperOptions = { export type RetrySendMessage = (message: LocalMessage) => Promise; export type ChannelActionContextValue = { - addNotification: (text: string, type: 'success' | 'error') => void; closeThread: (event?: React.BaseSyntheticEvent) => void; deleteMessage: ( message: LocalMessage, diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index d79b6dfcac..c40033034e 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -22,7 +22,6 @@ import { type MessageDeletedProps, type MessageEditedIndicatorProps, type MessageInputProps, - type MessageListNotificationsProps, type MessageProps, type MessageReactionsDetailProps, type MessageRepliesCountButtonProps, @@ -32,6 +31,7 @@ import { type ModalGalleryProps, type ModalProps, type NewMessageNotificationProps, + type NotificationListProps, type PinIndicatorProps, type PollCreationDialogProps, type PollOptionSelectorProps, @@ -141,8 +141,10 @@ export type ComponentContextValue = { /** Custom UI component for a message bubble of a deleted message, defaults to and accepts same props as: [MessageDeletedBubble](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageDeletedBubble.tsx) */ MessageDeletedBubble?: React.ComponentType; MessageListMainPanel?: React.ComponentType; + /** Custom UI component to display notifications rendered by `NotificationList`, defaults to and accepts same props as: [NotificationList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Notifications/NotificationList.tsx) */ + NotificationList?: React.ComponentType; /** Custom UI component that displays message and connection status notifications in the `MessageList`, defaults to and accepts same props as [DefaultMessageListNotifications](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageListNotifications.tsx) */ - MessageListNotifications?: React.ComponentType; + MessageListNotifications?: React.ComponentType; /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [NewMessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/NewMessageNotification.tsx) */ NewMessageNotification?: React.ComponentType; /** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */ diff --git a/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts b/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts index 4a76b5d4c8..0d2430e169 100644 --- a/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts +++ b/src/i18n/TranslationBuilder/notifications/NotificationTranslationTopic.ts @@ -1,32 +1,24 @@ -import { - attachmentUploadBlockedNotificationTranslator, - attachmentUploadFailedNotificationTranslator, - attachmentUploadNotTerminatedTranslator, -} from './attachmentUpload'; import { TranslationTopic } from '../../TranslationBuilder'; import type { Notification } from 'stream-chat'; import type { NotificationTranslatorOptions } from './types'; +import { translatorsByNotificationType } from './translatorsByNotificationType'; import type { TranslationTopicOptions, Translator } from '../../index'; -import { pollCreationFailedNotificationTranslator } from './pollComposition'; -import { pollVoteCountTrespass } from './pollVoteCountTrespass'; -import { browserAudioPlaybackError } from './browserAudioPlaybackError'; -import { - pollEndFailedNotificationTranslator, - pollEndSucceededNotificationTranslator, -} from './pollEnd'; + +const translateByNotificationType: Translator = ({ + options: { notification }, + ...params +}) => { + if (!notification?.type) return null; + const translator = translatorsByNotificationType[notification.type]; + if (!translator) return null; + return translator({ ...params, options: { notification } }); +}; export const defaultNotificationTranslators: Record< string, Translator > = { - 'api:attachment:upload:failed': attachmentUploadFailedNotificationTranslator, - 'api:poll:create:failed': pollCreationFailedNotificationTranslator, - 'api:poll:end:failed': pollEndFailedNotificationTranslator, - 'api:poll:end:success': pollEndSucceededNotificationTranslator, - 'browser:audio:playback:error': browserAudioPlaybackError, - 'validation:attachment:upload:blocked': attachmentUploadBlockedNotificationTranslator, - 'validation:attachment:upload:in-progress': attachmentUploadNotTerminatedTranslator, - 'validation:poll:castVote:limit': pollVoteCountTrespass, + '*': translateByNotificationType, }; export class NotificationTranslationTopic extends TranslationTopic { @@ -42,8 +34,20 @@ export class NotificationTranslationTopic extends TranslationTopic { const { notification } = options; if (!notification) return value; - const translator = notification.type && this.translators.get(notification.type); - if (!translator) return value; - return translator({ key, options, t: this.i18next.t, value }) || value; + const byType = notification.type + ? this.translators.get(notification.type) + : undefined; + if (byType) return byType({ key, options, t: this.i18next.t, value }) || value; + + const byFallback = this.translators.get('*'); + const translated = byFallback?.({ key, options, t: this.i18next.t, value }) ?? null; + if (translated) return translated; + if (!notification.message) return value; + + // Final fallback: attempt to translate message as natural key. + return this.i18next.t(notification.message, { + ...(notification.metadata ?? {}), + value: notification.message, + }); }; } diff --git a/src/i18n/TranslationBuilder/notifications/attachmentUpload.ts b/src/i18n/TranslationBuilder/notifications/attachmentUpload.ts deleted file mode 100644 index ae1febee4a..0000000000 --- a/src/i18n/TranslationBuilder/notifications/attachmentUpload.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { NotificationTranslatorOptions } from './types'; -import type { Translator } from '../TranslationBuilder'; - -export const attachmentUploadBlockedNotificationTranslator: Translator< - NotificationTranslatorOptions -> = ({ options, t }) => { - const { notification } = options; - if (!notification) return null; - if (typeof notification.metadata?.reason !== 'string') { - const reason = t('unknown error'); - return t('Attachment upload blocked due to {{reason}}', { reason }); - } - if (notification.metadata?.reason === 'size_limit') { - const reason = t('size limit'); - return t('Attachment upload blocked due to {{reason}}', { reason }); - } - const reason = t('unsupported file type'); - return t('Attachment upload blocked due to {{reason}}', { reason }); -}; - -export const attachmentUploadFailedNotificationTranslator: Translator< - NotificationTranslatorOptions -> = ({ options, t }) => { - const { notification } = options; - if (!notification) return null; - const { reason: originalReason } = notification.metadata ?? {}; - if (typeof originalReason !== 'string') { - const reason = t('unknown error'); - return t('Attachment upload failed due to {{reason}}', { reason }); - } - let reason = originalReason.toLowerCase(); - if (reason === 'network error') { - reason = t('network error'); - return t('Attachment upload failed due to {{reason}}', { reason }); - } - // custom reason string - return t('Attachment upload failed due to {{reason}}', { reason }); -}; - -export const attachmentUploadNotTerminatedTranslator: Translator< - NotificationTranslatorOptions -> = ({ options: { notification }, t }) => { - if (!notification?.message) return null; - return t('Wait until all attachments have uploaded'); -}; diff --git a/src/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.ts b/src/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.ts deleted file mode 100644 index a94279a33b..0000000000 --- a/src/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Translator } from '../TranslationBuilder'; -import type { NotificationTranslatorOptions } from './types'; - -export const browserAudioPlaybackError: Translator = ({ - options, - t, -}) => options.notification?.message ?? t('Error reproducing the recording'); diff --git a/src/i18n/TranslationBuilder/notifications/pollComposition.ts b/src/i18n/TranslationBuilder/notifications/pollComposition.ts deleted file mode 100644 index 3a9d594d3a..0000000000 --- a/src/i18n/TranslationBuilder/notifications/pollComposition.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Translator } from '../TranslationBuilder'; -import type { NotificationTranslatorOptions } from './types'; - -export const pollCreationFailedNotificationTranslator: Translator< - NotificationTranslatorOptions -> = ({ options: { notification }, t }) => { - if ( - typeof notification?.metadata?.reason === 'string' && - notification.metadata.reason.length - ) { - return t('Failed to create the poll due to {{reason}}', { - reason: notification.metadata.reason.toLowerCase(), - }); - } - return t('Failed to create the poll'); -}; diff --git a/src/i18n/TranslationBuilder/notifications/pollEnd.ts b/src/i18n/TranslationBuilder/notifications/pollEnd.ts deleted file mode 100644 index 03ae627b82..0000000000 --- a/src/i18n/TranslationBuilder/notifications/pollEnd.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Translator } from '../TranslationBuilder'; -import type { NotificationTranslatorOptions } from './types'; - -export const pollEndFailedNotificationTranslator: Translator< - NotificationTranslatorOptions -> = ({ options: { notification }, t }) => { - if ( - typeof notification?.metadata?.reason === 'string' && - notification.metadata.reason.length - ) { - return t('Failed to end the poll due to {{reason}}', { - reason: notification.metadata.reason.toLowerCase(), - }); - } - return t('Failed to end the poll'); -}; - -export const pollEndSucceededNotificationTranslator: Translator< - NotificationTranslatorOptions -> = ({ t }) => t('Poll ended'); diff --git a/src/i18n/TranslationBuilder/notifications/pollVoteCountTrespass.ts b/src/i18n/TranslationBuilder/notifications/pollVoteCountTrespass.ts deleted file mode 100644 index 53023c28a8..0000000000 --- a/src/i18n/TranslationBuilder/notifications/pollVoteCountTrespass.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Translator } from '../TranslationBuilder'; -import type { NotificationTranslatorOptions } from './types'; - -export const pollVoteCountTrespass: Translator = ({ t }) => - t('Reached the vote limit. Remove an existing vote first.'); diff --git a/src/i18n/TranslationBuilder/notifications/translators.ts b/src/i18n/TranslationBuilder/notifications/translators.ts new file mode 100644 index 0000000000..60b8c5372b --- /dev/null +++ b/src/i18n/TranslationBuilder/notifications/translators.ts @@ -0,0 +1,73 @@ +import type { Notification } from 'stream-chat'; + +import type { NotificationTranslatorOptions } from './types'; +import type { TranslationTopicOptions, Translator } from '../../index'; + +const normalizeReason = (notification?: Notification) => { + const reason = notification?.metadata?.reason; + if (typeof reason !== 'string' || !reason.length) return undefined; + return reason.toLowerCase(); +}; + +const withReasonFallback = ({ + fallbackTranslationKey, + notification, + reasonTranslationKey, + t, +}: { + fallbackTranslationKey: string; + notification?: Notification; + reasonTranslationKey: string; + t: TranslationTopicOptions['i18next']['t']; +}) => { + const reason = normalizeReason(notification); + if (!reason) return t(fallbackTranslationKey); + return t(reasonTranslationKey, { reason }); +}; + +export const translateAttachmentUploadBlocked: Translator< + NotificationTranslatorOptions +> = ({ options: { notification }, t }) => { + const rawReason = notification?.metadata?.reason; + let reason = t('unsupported file type'); + if (typeof rawReason !== 'string') reason = t('unknown error'); + if (rawReason === 'size_limit') reason = t('size limit'); + return t('Attachment upload blocked due to {{reason}}', { reason }); +}; + +export const translateAttachmentUploadFailed: Translator< + NotificationTranslatorOptions +> = ({ options: { notification }, t }) => + withReasonFallback({ + fallbackTranslationKey: 'Error uploading attachment', + notification, + reasonTranslationKey: 'Attachment upload failed due to {{reason}}', + t, + }); + +export const translatePollCreateFailed: Translator = ({ + options: { notification }, + t, +}) => + withReasonFallback({ + fallbackTranslationKey: 'Failed to create the poll', + notification, + reasonTranslationKey: 'Failed to create the poll due to {{reason}}', + t, + }); + +export const translatePollEndFailed: Translator = ({ + options: { notification }, + t, +}) => + withReasonFallback({ + fallbackTranslationKey: 'Failed to end the poll', + notification, + reasonTranslationKey: 'Failed to end the poll due to {{reason}}', + t, + }); + +export const translateBrowserAudioPlaybackError: Translator< + NotificationTranslatorOptions +> = ({ options: { notification }, t }) => + notification?.message ? t(notification.message) : t('Error reproducing the recording'); diff --git a/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts new file mode 100644 index 0000000000..e01633e71d --- /dev/null +++ b/src/i18n/TranslationBuilder/notifications/translatorsByNotificationType.ts @@ -0,0 +1,35 @@ +import type { NotificationTranslatorOptions } from './types'; +import { + translateAttachmentUploadBlocked, + translateAttachmentUploadFailed, + translateBrowserAudioPlaybackError, + translatePollCreateFailed, + translatePollEndFailed, +} from './translators'; +import type { Translator } from '../../index'; + +export const translatorsByNotificationType: Record< + string, + Translator +> = { + 'api:attachment:upload:failed': translateAttachmentUploadFailed, + 'api:location:create:failed': ({ t }) => t('Failed to share location'), + 'api:location:share:failed': ({ t }) => t('Failed to share location'), + 'api:poll:create:failed': translatePollCreateFailed, + 'api:poll:end:failed': translatePollEndFailed, + 'api:poll:end:success': ({ t }) => t('Poll ended'), + 'api:reply:search:failed': ({ t }) => t('Thread has not been found'), + 'browser:audio:playback:error': translateBrowserAudioPlaybackError, + 'browser:location:get:failed': ({ t }) => t('Failed to retrieve location'), + 'channel:jumpToFirstUnread:failed': ({ t }) => + t('Failed to jump to the first unread message'), + 'validation:attachment:file:missing': ({ t }) => + t('File is required for upload attachment'), + 'validation:attachment:id:missing': ({ t }) => + t('Local upload attachment missing local id'), + 'validation:attachment:upload:blocked': translateAttachmentUploadBlocked, + 'validation:attachment:upload:in-progress': ({ t }) => + t('Wait until all attachments have uploaded'), + 'validation:poll:castVote:limit': ({ t }) => + t('Reached the vote limit. Remove an existing vote first.'), +}; diff --git a/src/i18n/__tests__/NotificationTranslationBuilder.test.js b/src/i18n/__tests__/NotificationTranslationBuilder.test.js index dfcd88083d..163067b036 100644 --- a/src/i18n/__tests__/NotificationTranslationBuilder.test.js +++ b/src/i18n/__tests__/NotificationTranslationBuilder.test.js @@ -21,7 +21,7 @@ describe('NotificationTranslationTopic', () => { translators, }); expect(builder.translators.size).toBe( - Object.keys(defaultNotificationTranslators).length + 1, + Object.keys(defaultNotificationTranslators).length + 2, ); expect(builder.translators.get('test')).toEqual(translators.test); expect(builder.translators.get('validation:attachment:upload:blocked')).toEqual( @@ -51,4 +51,130 @@ describe('NotificationTranslationTopic', () => { notification = { type: 'api:attachment:upload:failed' }; expect(builder.translate(translatedString, key, { notification })).toBe('failed'); }); + + it('falls back to translating notification.message when type has no translator', () => { + const i18next = { + ...mockI18Next, + t: jest.fn((key) => + key === 'File is required for upload attachment' + ? 'translated/file-required' + : key, + ), + }; + const builder = new NotificationTranslationTopic({ + i18next, + }); + + const output = builder.translate('XXX', '', { + notification: { + message: 'File is required for upload attachment', + type: 'unknown:type', + }, + }); + + expect(output).toBe('translated/file-required'); + expect(i18next.t).toHaveBeenCalledWith('File is required for upload attachment', { + value: 'File is required for upload attachment', + }); + }); + + it('passes notification metadata to i18next for message interpolation fallback', () => { + const i18next = { + ...mockI18Next, + t: jest.fn((key, options) => + key === 'Attachment upload failed due to {{reason}}' + ? `translated/reason:${options.reason}` + : key, + ), + }; + const builder = new NotificationTranslationTopic({ + i18next, + }); + + const output = builder.translate('XXX', '', { + notification: { + message: 'Attachment upload failed due to {{reason}}', + metadata: { reason: 'network error' }, + type: 'unknown:type', + }, + }); + + expect(output).toBe('translated/reason:network error'); + expect(i18next.t).toHaveBeenCalledWith('Attachment upload failed due to {{reason}}', { + reason: 'network error', + value: 'Attachment upload failed due to {{reason}}', + }); + }); + + it.each([ + ['api:location:create:failed', 'Failed to share location'], + ['api:location:share:failed', 'Failed to share location'], + ['api:message:search:not-found', 'Thread has not been found'], + ['api:poll:end:success', 'Poll ended'], + ['browser-api:location:get:failed', 'Failed to retrieve location'], + ['channel:jumpToFirstUnread:failed', 'Failed to jump to the first unread message'], + ['validation:attachment:file:missing', 'File is required for upload attachment'], + ['validation:attachment:id:missing', 'Local upload attachment missing local id'], + [ + 'validation:attachment:upload:in-progress', + 'Wait until all attachments have uploaded', + ], + [ + 'validation:poll:castVote:limit', + 'Reached the vote limit. Remove an existing vote first.', + ], + ])('translates known notification type %s', (type, translationKey) => { + const i18next = { + ...mockI18Next, + t: jest.fn((key) => `translated:${key}`), + }; + const builder = new NotificationTranslationTopic({ i18next }); + + const output = builder.translate('XXX', '', { + notification: { + type, + }, + }); + + expect(output).toBe(`translated:${translationKey}`); + expect(i18next.t).toHaveBeenCalledWith(translationKey); + }); + + it('normalizes reason metadata in poll creation failure translation', () => { + const i18next = { + ...mockI18Next, + t: jest.fn((key, options) => + key === 'Failed to create the poll due to {{reason}}' + ? `translated/reason:${options.reason}` + : key, + ), + }; + const builder = new NotificationTranslationTopic({ i18next }); + + const output = builder.translate('XXX', '', { + notification: { + metadata: { reason: 'NETWORK' }, + type: 'api:poll:create:failed', + }, + }); + + expect(output).toBe('translated/reason:network'); + }); + + it('prefers exact translator over default type-registry fallback', () => { + const customTranslator = jest.fn().mockReturnValue('custom/location-failed'); + const builder = new NotificationTranslationTopic({ + i18next: mockI18Next, + translators: { 'api:location:create:failed': customTranslator }, + }); + + const output = builder.translate('XXX', '', { + notification: { + type: 'api:location:create:failed', + }, + }); + + expect(output).toBe('custom/location-failed'); + expect(customTranslator).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/i18n/de.json b/src/i18n/de.json index a3e0757b1d..0afc0154f5 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -167,6 +167,7 @@ "Error: {{ errorMessage }}": "Fehler: {{ errorMessage }}", "Failed to create the poll": "Fehler beim Erstellen der Umfrage", "Failed to create the poll due to {{reason}}": "Die Umfrage konnte aufgrund von {{reason}} nicht erstellt werden", + "Failed to delete the message": "Nachricht konnte nicht gelöscht werden", "Failed to end the poll": "Umfrage konnte nicht beendet werden", "Failed to end the poll due to {{reason}}": "Umfrage konnte aufgrund von {{reason}} nicht beendet werden", "Failed to jump to the first unread message": "Fehler beim Springen zur ersten ungelesenen Nachricht", @@ -175,6 +176,7 @@ "Failed to retrieve location": "Standort konnte nicht abgerufen werden", "Failed to share location": "Standort konnte nicht geteilt werden", "File": "Datei", + "File is required for upload attachment": "Datei ist für den Anhang-Upload erforderlich", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Datei ist zu groß: {{ size }}, maximale Upload-Größe beträgt {{ limit }}", "File too large": "Datei ist zu groß", "fileCount_one": "1 datei", @@ -255,6 +257,7 @@ "Live location": "Live-Standort", "Live until {{ timestamp }}": "Live bis {{ timestamp }}", "Load more": "Mehr laden", + "Local upload attachment missing local id": "Lokaler Upload-Anhang hat keine lokale ID", "Location": "Standort", "Location sharing ended": "Standortfreigabe beendet", "Mark as unread": "Als ungelesen markieren", diff --git a/src/i18n/en.json b/src/i18n/en.json index 5a064bb13f..d575a26bdd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -167,6 +167,7 @@ "Error: {{ errorMessage }}": "Error: {{ errorMessage }}", "Failed to create the poll": "Failed to create the poll", "Failed to create the poll due to {{reason}}": "Failed to create the poll due to {{reason}}", + "Failed to delete the message": "Failed to delete the message", "Failed to end the poll": "Failed to end the poll", "Failed to end the poll due to {{reason}}": "Failed to end the poll due to {{reason}}", "Failed to jump to the first unread message": "Failed to jump to the first unread message", @@ -175,6 +176,7 @@ "Failed to retrieve location": "Failed to retrieve location", "Failed to share location": "Failed to share location", "File": "File", + "File is required for upload attachment": "File is required for upload attachment", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "File is too large: {{ size }}, maximum upload size is {{ limit }}", "File too large": "File too large", "fileCount_one": "File", @@ -255,6 +257,7 @@ "Live location": "Live location", "Live until {{ timestamp }}": "Live until {{ timestamp }}", "Load more": "Load more", + "Local upload attachment missing local id": "Local upload attachment missing local id", "Location": "Location", "Location sharing ended": "Location sharing ended", "Mark as unread": "Mark as unread", diff --git a/src/i18n/es.json b/src/i18n/es.json index 5c0db6a8f9..6a930a1002 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -174,6 +174,7 @@ "Error: {{ errorMessage }}": "Error: {{ errorMessage }}", "Failed to create the poll": "Error al crear la encuesta", "Failed to create the poll due to {{reason}}": "No se pudo crear la encuesta debido a {{reason}}", + "Failed to delete the message": "No se pudo eliminar el mensaje", "Failed to end the poll": "No se pudo terminar la encuesta", "Failed to end the poll due to {{reason}}": "No se pudo terminar la encuesta debido a {{reason}}", "Failed to jump to the first unread message": "Error al saltar al primer mensaje no leído", @@ -182,6 +183,7 @@ "Failed to retrieve location": "No se pudo obtener la ubicación", "Failed to share location": "No se pudo compartir la ubicación", "File": "Archivo", + "File is required for upload attachment": "Se requiere un archivo para subir el adjunto", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "El archivo es demasiado grande: {{ size }}, el tamaño máximo de carga es de {{ limit }}", "File too large": "Archivo demasiado grande", "fileCount_one": "1 archivo", @@ -265,6 +267,7 @@ "Live location": "Ubicación en vivo", "Live until {{ timestamp }}": "En vivo hasta {{ timestamp }}", "Load more": "Cargar más", + "Local upload attachment missing local id": "El adjunto de subida local no tiene id local", "Location": "Ubicación", "Location sharing ended": "Compartir ubicación terminado", "Mark as unread": "Marcar como no leído", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 9a27adfaca..de078634ed 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -174,6 +174,7 @@ "Error: {{ errorMessage }}": "Erreur : {{ errorMessage }}", "Failed to create the poll": "Échec de la création du sondage", "Failed to create the poll due to {{reason}}": "Échec de la création du sondage en raison de {{reason}}", + "Failed to delete the message": "Échec de la suppression du message", "Failed to end the poll": "Impossible de terminer le sondage", "Failed to end the poll due to {{reason}}": "Impossible de terminer le sondage en raison de {{reason}}", "Failed to jump to the first unread message": "Échec du saut vers le premier message non lu", @@ -182,6 +183,7 @@ "Failed to retrieve location": "Impossible de récupérer l'emplacement", "Failed to share location": "Impossible de partager l'emplacement", "File": "Fichier", + "File is required for upload attachment": "Un fichier est requis pour joindre une pièce", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Le fichier est trop volumineux : {{ size }}, la taille maximale de téléchargement est de {{ limit }}", "File too large": "Fichier trop volumineux", "fileCount_one": "1 fichier", @@ -265,6 +267,7 @@ "Live location": "Emplacement en direct", "Live until {{ timestamp }}": "En direct jusqu'à {{ timestamp }}", "Load more": "Charger plus", + "Local upload attachment missing local id": "Pièce jointe locale sans identifiant local", "Location": "Emplacement", "Location sharing ended": "Partage d'emplacement terminé", "Mark as unread": "Marquer comme non lu", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 834857e8a0..f9b673e087 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -168,6 +168,7 @@ "Error: {{ errorMessage }}": "फेल: {{ errorMessage }}", "Failed to create the poll": "मतदान बनाने में विफल", "Failed to create the poll due to {{reason}}": "मतदान {{reason}} के कारण नहीं बन सका", + "Failed to delete the message": "संदेश हटाने में विफल", "Failed to end the poll": "पोल समाप्त करने में विफल", "Failed to end the poll due to {{reason}}": "{{reason}} के कारण पोल समाप्त करने में विफल", "Failed to jump to the first unread message": "पहले अपठित संदेश पर जाने में विफल", @@ -176,6 +177,7 @@ "Failed to retrieve location": "स्थान प्राप्त करने में विफल", "Failed to share location": "स्थान साझा करने में विफल", "File": "फ़ाइल", + "File is required for upload attachment": "अटैचमेंट अपलोड के लिए फ़ाइल आवश्यक है", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "फ़ाइल बहुत बड़ी है: {{ size }}, अधिकतम अपलोड साइज़ {{ limit }} है", "File too large": "फ़ाइल बहुत बड़ी है", "fileCount_one": "1 फ़ाइल", @@ -256,6 +258,7 @@ "Live location": "लाइव स्थान", "Live until {{ timestamp }}": "{{ timestamp }} तक लाइव", "Load more": "और लोड करें", + "Local upload attachment missing local id": "लोकल अपलोड अटैचमेंट में लोकल आईडी नहीं है", "Location": "स्थान", "Location sharing ended": "स्थान साझा करना समाप्त", "Mark as unread": "अपठित चिह्नित करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index d0afdeecd3..795b7d8c2c 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -174,6 +174,7 @@ "Error: {{ errorMessage }}": "Errore: {{ errorMessage }}", "Failed to create the poll": "Impossibile creare il sondaggio", "Failed to create the poll due to {{reason}}": "Impossibile creare il sondaggio a causa di {{reason}}", + "Failed to delete the message": "Impossibile eliminare il messaggio", "Failed to end the poll": "Impossibile terminare il sondaggio", "Failed to end the poll due to {{reason}}": "Impossibile terminare il sondaggio a causa di {{reason}}", "Failed to jump to the first unread message": "Impossibile passare al primo messaggio non letto", @@ -182,6 +183,7 @@ "Failed to retrieve location": "Impossibile recuperare la posizione", "Failed to share location": "Impossibile condividere la posizione", "File": "File", + "File is required for upload attachment": "È richiesto un file per caricare l'allegato", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Il file è troppo grande: {{ size }}, la dimensione massima di caricamento è {{ limit }}", "File too large": "File troppo grande", "fileCount_one": "1 file", @@ -265,6 +267,7 @@ "Live location": "Posizione live", "Live until {{ timestamp }}": "Live fino a {{ timestamp }}", "Load more": "Carica di più", + "Local upload attachment missing local id": "Allegato di caricamento locale senza id locale", "Location": "Posizione", "Location sharing ended": "Condivisione posizione terminata", "Mark as unread": "Contrassegna come non letto", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index fa42ad4bf4..cfbdac966b 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -166,6 +166,7 @@ "Error: {{ errorMessage }}": "エラー: {{ errorMessage }}", "Failed to create the poll": "投票の作成に失敗しました", "Failed to create the poll due to {{reason}}": "{{reason}} のため投票の作成に失敗しました", + "Failed to delete the message": "メッセージの削除に失敗しました", "Failed to end the poll": "アンケートの終了に失敗しました", "Failed to end the poll due to {{reason}}": "{{reason}}のためアンケートの終了に失敗しました", "Failed to jump to the first unread message": "最初の未読メッセージにジャンプできませんでした", @@ -174,6 +175,7 @@ "Failed to retrieve location": "位置情報の取得に失敗しました", "Failed to share location": "位置情報の共有に失敗しました", "File": "ファイル", + "File is required for upload attachment": "添付ファイルのアップロードにはファイルが必要です", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "ファイルが大きすぎます:{{ size }}、最大アップロードサイズは{{ limit }}です", "File too large": "ファイルが大きすぎます", "fileCount_other": "{{ count }}件のファイル", @@ -251,6 +253,7 @@ "Live location": "ライブ位置情報", "Live until {{ timestamp }}": "{{ timestamp }}までライブ", "Load more": "もっと読み込む", + "Local upload attachment missing local id": "ローカルアップロード添付にローカルIDがありません", "Location": "位置情報", "Location sharing ended": "位置情報の共有が終了しました", "Mark as unread": "未読としてマーク", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 663df53837..1a2df98069 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -166,6 +166,7 @@ "Error: {{ errorMessage }}": "오류: {{ errorMessage }}", "Failed to create the poll": "투표 생성 실패", "Failed to create the poll due to {{reason}}": "{{reason}} 때문에 투표를 생성하지 못했습니다", + "Failed to delete the message": "메시지 삭제에 실패했습니다", "Failed to end the poll": "투표 종료에 실패했습니다", "Failed to end the poll due to {{reason}}": "{{reason}}(으)로 인해 투표 종료에 실패했습니다", "Failed to jump to the first unread message": "첫 번째 읽지 않은 메시지로 이동하지 못했습니다", @@ -174,6 +175,7 @@ "Failed to retrieve location": "위치를 가져오지 못했습니다", "Failed to share location": "위치를 공유하지 못했습니다", "File": "파일", + "File is required for upload attachment": "첨부 파일 업로드에 파일이 필요합니다", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "파일이 너무 큽니다: {{ size }}, 최대 업로드 크기는 {{ limit }}입니다", "File too large": "파일이 너무 큽니다", "fileCount_other": "파일 {{ count }}개", @@ -251,6 +253,7 @@ "Live location": "라이브 위치", "Live until {{ timestamp }}": "{{ timestamp }}까지 라이브", "Load more": "더 불러오기", + "Local upload attachment missing local id": "로컬 업로드 첨부에 로컬 ID가 없습니다", "Location": "위치", "Location sharing ended": "위치 공유가 종료되었습니다", "Mark as unread": "읽지 않음으로 표시", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index f5bb2543da..7a9dbb12ea 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -167,6 +167,7 @@ "Error: {{ errorMessage }}": "Fout: {{ errorMessage }}", "Failed to create the poll": "Fout bij het maken van de peiling", "Failed to create the poll due to {{reason}}": "Peiling kon niet worden aangemaakt vanwege {{reason}}", + "Failed to delete the message": "Bericht verwijderen mislukt", "Failed to end the poll": "Peiling kon niet worden beëindigd", "Failed to end the poll due to {{reason}}": "Peiling kon niet worden beëindigd vanwege {{reason}}", "Failed to jump to the first unread message": "Niet gelukt om naar het eerste ongelezen bericht te springen", @@ -175,6 +176,7 @@ "Failed to retrieve location": "Locatie kon niet worden opgehaald", "Failed to share location": "Locatie kon niet worden gedeeld", "File": "Bestand", + "File is required for upload attachment": "Bestand is vereist voor het uploaden van een bijlage", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Bestand is te groot: {{ size }}, maximale uploadgrootte is {{ limit }}", "File too large": "Bestand is te groot", "fileCount_one": "1 bestand", @@ -255,6 +257,7 @@ "Live location": "Live locatie", "Live until {{ timestamp }}": "Live tot {{ timestamp }}", "Load more": "Meer laden", + "Local upload attachment missing local id": "Lokale uploadbijlage mist lokale id", "Location": "Locatie", "Location sharing ended": "Locatie delen beëindigd", "Mark as unread": "Markeren als ongelezen", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 675e9449f2..516af19f2f 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -174,6 +174,7 @@ "Error: {{ errorMessage }}": "Erro: {{ errorMessage }}", "Failed to create the poll": "Falha ao criar a pesquisa", "Failed to create the poll due to {{reason}}": "Falha ao criar a enquete devido a {{reason}}", + "Failed to delete the message": "Falha ao excluir a mensagem", "Failed to end the poll": "Falha ao encerrar a enquete", "Failed to end the poll due to {{reason}}": "Falha ao encerrar a enquete devido a {{reason}}", "Failed to jump to the first unread message": "Falha ao pular para a primeira mensagem não lida", @@ -182,6 +183,7 @@ "Failed to retrieve location": "Falha ao obter localização", "Failed to share location": "Falha ao compartilhar localização", "File": "Arquivo", + "File is required for upload attachment": "Arquivo é necessário para enviar o anexo", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "O arquivo é muito grande: {{ size }}, o tamanho máximo de upload é {{ limit }}", "File too large": "Arquivo muito grande", "fileCount_one": "1 arquivo", @@ -265,6 +267,7 @@ "Live location": "Localização ao vivo", "Live until {{ timestamp }}": "Ao vivo até {{ timestamp }}", "Load more": "Carregar mais", + "Local upload attachment missing local id": "Anexo de envio local sem id local", "Location": "Localização", "Location sharing ended": "Compartilhamento de localização encerrado", "Mark as unread": "Marcar como não lida", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1c7f1a1493..c94cf845c3 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -182,6 +182,7 @@ "Error: {{ errorMessage }}": "Ошибка: {{ errorMessage }}", "Failed to create the poll": "Не удалось создать опрос", "Failed to create the poll due to {{reason}}": "Не удалось создать опрос из-за {{reason}}", + "Failed to delete the message": "Не удалось удалить сообщение", "Failed to end the poll": "Не удалось завершить опрос", "Failed to end the poll due to {{reason}}": "Не удалось завершить опрос из-за {{reason}}", "Failed to jump to the first unread message": "Не удалось перейти к первому непрочитанному сообщению", @@ -190,6 +191,7 @@ "Failed to retrieve location": "Не удалось получить местоположение", "Failed to share location": "Не удалось поделиться местоположением", "File": "Файл", + "File is required for upload attachment": "Для загрузки вложения требуется файл", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}", "File too large": "Файл слишком большой", "fileCount_four": "{{ count }} файла", @@ -279,6 +281,7 @@ "Live location": "Местоположение в прямом эфире", "Live until {{ timestamp }}": "В прямом эфире до {{ timestamp }}", "Load more": "Загрузить больше", + "Local upload attachment missing local id": "У локального вложения нет локального id", "Location": "Местоположение", "Location sharing ended": "Обмен местоположением завершен", "Mark as unread": "Отметить как непрочитанное", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 65b9d3f3b3..8a68669ba8 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -167,6 +167,7 @@ "Error: {{ errorMessage }}": "Hata: {{ errorMessage }}", "Failed to create the poll": "Anket oluşturulurken hata oluştu", "Failed to create the poll due to {{reason}}": "{{reason}} nedeniyle anket oluşturulamadı", + "Failed to delete the message": "Mesaj silinemedi", "Failed to end the poll": "Anket sonlandırılamadı", "Failed to end the poll due to {{reason}}": "{{reason}} nedeniyle anket sonlandırılamadı", "Failed to jump to the first unread message": "İlk okunmamış mesaja atlamada hata oluştu", @@ -175,6 +176,7 @@ "Failed to retrieve location": "Konum alınamadı", "Failed to share location": "Konum paylaşılamadı", "File": "Dosya", + "File is required for upload attachment": "Ek yüklemek için dosya gerekli", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Dosya çok büyük: {{ size }}, maksimum yükleme boyutu {{ limit }}", "File too large": "Dosya çok büyük", "fileCount_one": "1 dosya", @@ -255,6 +257,7 @@ "Live location": "Canlı konum", "Live until {{ timestamp }}": "{{ timestamp }}'e kadar canlı", "Load more": "Daha fazla yükle", + "Local upload attachment missing local id": "Yerel yükleme ekinde yerel kimlik eksik", "Location": "Konum", "Location sharing ended": "Konum paylaşımı sona erdi", "Mark as unread": "Okunmamış olarak işaretle", diff --git a/src/styling/_palette-variables.scss b/src/styling/_palette-variables.scss deleted file mode 100644 index a36496ff40..0000000000 --- a/src/styling/_palette-variables.scss +++ /dev/null @@ -1,135 +0,0 @@ -.str-chat { - --str-chat__slate900: var(--slate-900); - --str-chat__slate800: var(--slate-800); - --str-chat__slate700: var(--slate-700); - --str-chat__slate600: var(--slate-600); - --str-chat__slate500: var(--slate-500); - --str-chat__slate400: var(--slate-400); - --str-chat__slate300: var(--slate-300); - --str-chat__slate200: var(--slate-200); - --str-chat__slate150: var(--slate-150); - --str-chat__slate100: var(--slate-100); - --str-chat__slate50: var(--slate-50); - - --str-chat__neutral900: var(--neutral-900); - --str-chat__neutral800: var(--neutral-800); - --str-chat__neutral700: var(--neutral-700); - --str-chat__neutral600: var(--neutral-600); - --str-chat__neutral500: var(--neutral-500); - --str-chat__neutral400: var(--neutral-400); - --str-chat__neutral300: var(--neutral-300); - --str-chat__neutral200: var(--neutral-200); - --str-chat__neutral150: var(--neutral-150); - --str-chat__neutral100: var(--neutral-100); - --str-chat__neutral50: var(--neutral-50); - - --str-chat__blue950: var(--blue-900); - --str-chat__blue900: var(--blue-900); - --str-chat__blue800: var(--blue-800); - --str-chat__blue700: var(--blue-700); - --str-chat__blue600: var(--blue-600); - --str-chat__blue500: var(--blue-500); - --str-chat__blue400: var(--blue-400); - --str-chat__blue300: var(--blue-300); - --str-chat__blue200: var(--blue-200); - --str-chat__blue150: var(--blue-150); - --str-chat__blue100: var(--blue-100); - --str-chat__blue50: var(--blue-50); - - --str-chat__grey950: var(--base-black); - --str-chat__grey900: var(--neutral-900); - --str-chat__grey800: var(--neutral-800); - --str-chat__grey700: var(--neutral-700); - --str-chat__grey600: var(--neutral-600); - --str-chat__grey500: var(--neutral-500); - --str-chat__grey400: var(--neutral-400); - --str-chat__grey300: var(--neutral-300); - --str-chat__grey200: var(--neutral-200); - --str-chat__grey150: var(--neutral-150); - --str-chat__grey100: var(--neutral-100); - --str-chat__grey50: var(--base-white); - - --str-chat__red900: var(--red-900); - --str-chat__red800: var(--red-800); - --str-chat__red700: var(--red-700); - --str-chat__red600: var(--red-600); - --str-chat__red500: var(--red-500); - --str-chat__red400: var(--red-400); - --str-chat__red300: var(--red-300); - --str-chat__red200: var(--red-200); - --str-chat__red150: var(--red-150); - --str-chat__red100: var(--red-100); - --str-chat__red50: var(--red-50); - - --str-chat__green900: var(--green-900); - --str-chat__green800: var(--green-800); - --str-chat__green700: var(--green-700); - --str-chat__green600: var(--green-600); - --str-chat__green500: var(--green-500); - --str-chat__green400: var(--green-400); - --str-chat__green300: var(--green-300); - --str-chat__green200: var(--green-200); - --str-chat__green150: var(--green-150); - --str-chat__green100: var(--green-100); - --str-chat__green50: var(--green-50); - - --str-chat__yellow900: var(--yellow-900); - --str-chat__yellow800: var(--yellow-800); - --str-chat__yellow700: var(--yellow-700); - --str-chat__yellow600: var(--yellow-600); - --str-chat__yellow500: var(--yellow-500); - --str-chat__yellow400: var(--yellow-400); - --str-chat__yellow300: var(--yellow-300); - --str-chat__yellow200: var(--yellow-200); - --str-chat__yellow150: var(--yellow-150); - --str-chat__yellow100: var(--yellow-100); - --str-chat__yellow50: var(--yellow-50); - - --str-chat__cyan900: var(--cyan-900); - --str-chat__cyan800: var(--cyan-800); - --str-chat__cyan700: var(--cyan-700); - --str-chat__cyan600: var(--cyan-600); - --str-chat__cyan500: var(--cyan-500); - --str-chat__cyan400: var(--cyan-400); - --str-chat__cyan300: var(--cyan-300); - --str-chat__cyan200: var(--cyan-200); - --str-chat__cyan150: var(--cyan-150); - --str-chat__cyan100: var(--cyan-100); - --str-chat__cyan50: var(--cyan-50); - - --str-chat__purple900: var(--purple-900); - --str-chat__purple800: var(--purple-800); - --str-chat__purple700: var(--purple-700); - --str-chat__purple600: var(--purple-600); - --str-chat__purple500: var(--purple-500); - --str-chat__purple400: var(--purple-400); - --str-chat__purple300: var(--purple-300); - --str-chat__purple200: var(--purple-200); - --str-chat__purple150: var(--purple-150); - --str-chat__purple100: var(--purple-100); - --str-chat__purple50: var(--purple-50); - - --str-chat__violet900: var(--violet-900); - --str-chat__violet800: var(--violet-800); - --str-chat__violet700: var(--violet-700); - --str-chat__violet600: var(--violet-600); - --str-chat__violet500: var(--violet-500); - --str-chat__violet400: var(--violet-400); - --str-chat__violet300: var(--violet-300); - --str-chat__violet200: var(--violet-200); - --str-chat__violet150: var(--violet-150); - --str-chat__violet100: var(--violet-100); - --str-chat__violet50: var(--violet-50); - - --str-chat__lime900: var(--lime-900); - --str-chat__lime800: var(--lime-800); - --str-chat__lime700: var(--lime-700); - --str-chat__lime600: var(--lime-600); - --str-chat__lime500: var(--lime-500); - --str-chat__lime400: var(--lime-400); - --str-chat__lime300: var(--lime-300); - --str-chat__lime200: var(--lime-200); - --str-chat__lime150: var(--lime-150); - --str-chat__lime100: var(--lime-100); - --str-chat__lime50: var(--lime-50); -} diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 4b524a7926..614faed50d 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -81,7 +81,7 @@ @mixin modal { border-radius: var(--radius-xl); - background: var(--background-elevation-elevation-1); + background: var(--background-core-elevation-1); /* shadow/web/light/elevation-4 */ box-shadow: diff --git a/src/styling/index.scss b/src/styling/index.scss index 8b195a4fbc..d54e704328 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -3,7 +3,6 @@ @use 'animations'; @use 'global-layout-variables'; @use 'global-theme-variables'; -@use 'palette-variables'; @use './variables.css'; @use 'base'; @use 'icons'; @@ -16,7 +15,6 @@ // Base components @use '../components/Badge/styling' as Badge; @use '../components/Button/styling' as Button; -@use '../components/ChannelList/styling' as ChannelList; @use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog; @use '../components/Modal/styling' as Modal; @@ -29,6 +27,7 @@ @use '../components/Avatar/styling/GroupAvatar' as GroupAvatar; @use '../components/Channel/styling' as Channel; @use '../components/ChannelHeader/styling' as ChannelHeader; +@use '../components/ChannelList/styling' as ChannelList; @use '../components/ChannelPreview/styling' as ChannelPreview; @use '../components/ChatView/styling' as ChatView; @use '../components/DateSeparator/styling' as DateSeparator; @@ -43,6 +42,7 @@ @use '../components/MessageBounce/styling' as MessageBounce; @use '../components/MessageInput/styling' as MessageComposer; @use '../components/MessageList/styling' as MessageList; +@use '../components/Notifications/styling' as Notifications; @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling' as Reactions; @use '../components/Search/styling' as Search; diff --git a/src/styling/variables-import.css b/src/styling/variables-import.css new file mode 100644 index 0000000000..caf8687d71 --- /dev/null +++ b/src/styling/variables-import.css @@ -0,0 +1,525 @@ +/** + * Do not edit directly, this file was auto-generated. + */ + +.str-chat { + --base-transparent-0: rgba(255, 255, 255, 0); + --base-transparent-white-10: rgba(255, 255, 255, 0.1); + --base-transparent-white-20: rgba(255, 255, 255, 0.2); + --base-transparent-white-30: rgba(255, 255, 255, 0.3); + --base-transparent-white-70: rgba(255, 255, 255, 0.7); + --base-transparent-black-5: rgba(0, 0, 0, 0.05); /** Used for bg in closeButton */ + --base-transparent-black-10: rgba(0, 0, 0, 0.1); /** Used for bg in closeButton */ + --base-transparent-black-70: rgba(0, 0, 0, 0.7); /** Used for bg in closeButton */ + --base-black: #000000; + --base-white: #ffffff; + --slate-50: #f6f8fa; + --slate-100: #ebeef1; + --slate-150: #d5dbe1; + --slate-200: #c0c8d2; + --slate-300: #a3acba; + --slate-400: #87909f; + --slate-500: #687385; + --slate-600: #545969; + --slate-700: #414552; + --slate-800: #30313d; + --slate-900: #1a1b25; + --neutral-50: #f8f8f8; + --neutral-100: #efefef; + --neutral-150: #d8d8d8; + --neutral-200: #c4c4c4; + --neutral-300: #ababab; + --neutral-400: #8f8f8f; + --neutral-500: #6a6a6a; + --neutral-600: #565656; + --neutral-700: #464646; + --neutral-800: #323232; + --neutral-900: #1c1c1c; + --blue-50: #f3f7ff; + --blue-100: #e3edff; + --blue-150: #c3d9ff; + --blue-200: #a5c5ff; + --blue-300: #78a8ff; + --blue-400: #4586ff; + --blue-500: #005fff; + --blue-600: #1b53bd; + --blue-700: #19418d; + --blue-800: #142f63; + --blue-900: #091a3b; + --cyan-50: #f1fbfc; + --cyan-100: #d1f3f6; + --cyan-150: #a9e4ea; + --cyan-200: #72d7e0; + --cyan-300: #45bcc7; + --cyan-400: #1e9ea9; + --cyan-500: #248088; + --cyan-600: #006970; + --cyan-700: #065056; + --cyan-800: #003a3f; + --cyan-900: #002124; + --green-50: #e1ffee; + --green-100: #bdfcdb; + --green-150: #8febbd; + --green-200: #59dea3; + --green-300: #00c384; + --green-400: #00a46e; + --green-500: #277e59; + --green-600: #006643; + --green-700: #004f33; + --green-800: #003a25; + --green-900: #002213; + --purple-50: #f7f8ff; + --purple-100: #ecedff; + --purple-150: #d4d7ff; + --purple-200: #c1c5ff; + --purple-300: #a1a3ff; + --purple-400: #8482fc; + --purple-500: #644af9; + --purple-600: #553bd8; + --purple-700: #4032a1; + --purple-800: #2e2576; + --purple-900: #1a114d; + --yellow-50: #fef9da; + --yellow-100: #fcedb9; + --yellow-150: #fcd579; + --yellow-200: #f6bf57; + --yellow-300: #fa922b; + --yellow-400: #f26d10; + --yellow-500: #c84801; + --yellow-600: #a82c00; + --yellow-700: #842106; + --yellow-800: #5f1a05; + --yellow-900: #331302; + --red-50: #fff5fa; + --red-100: #ffe7f2; + --red-150: #ffccdf; + --red-200: #ffb1cd; + --red-300: #fe87a1; + --red-400: #fc526a; + --red-500: #d90d10; + --red-600: #b3093c; + --red-700: #890d37; + --red-800: #68052b; + --red-900: #3e021a; + --violet-50: #fef4ff; + --violet-100: #fbe8fe; + --violet-150: #f7cffc; + --violet-200: #eeb5f4; + --violet-300: #e68bec; + --violet-400: #d75fe7; + --violet-500: #b716ca; + --violet-600: #9d00ae; + --violet-700: #7c0089; + --violet-800: #5c0066; + --violet-900: #36003d; + --lime-50: #f1fde8; + --lime-100: #d4ffb0; + --lime-150: #b1ee79; + --lime-200: #9cda5d; + --lime-300: #78c100; + --lime-400: #639e11; + --lime-500: #4b7a0a; + --lime-600: #3e6213; + --lime-700: #355315; + --lime-800: #203a00; + --lime-900: #112100; + --size-2: 2px; + --size-4: 4px; + --size-6: 6px; + --size-8: 8px; + --size-12: 12px; + --size-16: 16px; + --size-20: 20px; + --size-24: 24px; + --size-32: 32px; + --size-40: 40px; + --size-48: 48px; + --size-64: 64px; + --size-28: 28px; + --size-80: 80px; + --size-128: 128px; + --size-240: 240px; + --size-320: 320px; + --size-480: 480px; + --size-560: 560px; + --size-640: 640px; + --size-760: 760px; + --size-144: 144px; + --size-208: 208px; + --size-56: 56px; + --radius-0: 0; + --radius-2: 2px; + --radius-4: 4px; + --radius-6: 6px; + --radius-8: 8px; + --radius-12: 12px; + --radius-16: 16px; + --radius-20: 20px; + --radius-24: 24px; + --radius-32: 32px; + --radius-full: 9999px; + --space-0: 0; + --space-2: 2px; + --space-4: 4px; + --space-8: 8px; + --space-12: 12px; + --space-16: 16px; + --space-20: 20px; + --space-24: 24px; + --space-32: 32px; + --space-40: 40px; + --space-48: 48px; + --space-64: 64px; + --space-80: 80px; + --w100: 1; + --w150: 1.5; + --w200: 2; + --w300: 3; + --w400: 4; + --w120: 1.2; + --font-family-geist: "Geist"; /** Primary sans-serif font for web typography. Use Geist as the main typeface. Recommended fallbacks: system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif. */ + --font-family-geist-mono: "Geist Mono"; /** Primary monospace font for web typography. Use Geist Mono for code, timestamps, and technical text. Recommended fallbacks: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace. */ + --font-family-sf-pro: "SF Pro"; /** Primary sans-serif font for iOS typography. Use SF Pro as the main typeface. Recommended fallbacks: -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif. */ + --font-family-sf-mono: "SF Mono"; /** Primary monospace font for iOS typography. Use SF Mono for code, timestamps, and technical text. Recommended fallbacks: SFMono-Regular, Menlo, Monaco, Consolas, monospace. */ + --font-family-roboto: "Roboto"; /** Primary sans-serif font for Android typography. Use Roboto as the main typeface, aligned with Material and system defaults. Recommended fallbacks: Roboto, “Noto Sans”, system-ui, sans-serif. */ + --font-family-roboto-mono: "Roboto Mono"; /** Primary monospace font for Android typography. Use Roboto Mono for code, timestamps, and technical text. Recommended fallbacks: Roboto Mono, “Noto Sans Mono”, monospace. */ + --font-weight-w400: 400; + --font-weight-w500: 500; + --font-weight-w600: 600; + --font-weight-w700: 700; + --font-size-size-8: 8px; + --font-size-size-10: 10px; + --font-size-size-11: 11px; + --font-size-size-12: 12px; + --font-size-size-13: 13px; + --font-size-size-14: 14px; + --font-size-size-16: 16px; + --font-size-size-15: 15px; + --font-size-size-17: 17px; + --font-size-size-18: 18px; + --font-size-size-20: 20px; + --font-size-size-22: 22px; + --font-size-size-24: 24px; + --font-size-size-28: 28px; + --font-size-size-32: 32px; + --font-size-size-40: 40px; + --font-size-size-48: 48px; + --font-size-size-64: 64px; + --line-height-line-height-8: 8px; + --line-height-line-height-10: 10px; + --line-height-line-height-12: 12px; + --line-height-line-height-13: 13px; + --line-height-line-height-14: 14px; + --line-height-line-height-15: 15px; + --line-height-line-height-16: 16px; + --line-height-line-height-17: 17px; + --line-height-line-height-18: 18px; + --line-height-line-height-20: 20px; + --line-height-line-height-24: 24px; + --line-height-line-height-28: 28px; + --line-height-line-height-32: 32px; + --line-height-line-height-40: 40px; + --line-height-line-height-48: 48px; + --typography-font-family-sans: "Geist"; + --typography-font-family-mono: "Geist Mono"; + --typography-font-weight-regular: 400; + --typography-font-weight-medium: 500; + --typography-font-weight-semi-bold: 600; + --typography-font-weight-bold: 700; + --typography-font-size-xxs: 10px; /** Micro text such as timestamps or subtle metadata. */ + --typography-font-size-xs: 12px; /** Compact secondary text, small UI labels. */ + --typography-font-size-sm: 14px; /** Default mobile body size, small controls. */ + --typography-font-size-md: 16px; /** Default desktop body size, main text. */ + --typography-font-size-lg: 18px; /** Medium emphasis, section labels. */ + --typography-font-size-xl: 20px; /** Small headings. */ + --typography-font-size-2xl: 24px; /** Section titles, important headings. */ + --typography-font-size-micro: 8px; /** Micro text such as timestamps or subtle metadata. */ + --typography-line-height-tight: 16px; /** Compact text, headers, UI labels. */ + --typography-line-height-normal: 20px; /** Default reading line-height for sizes 14–16px. */ + --typography-line-height-relaxed: 24px; /** For larger text sizes or multiline descriptions. */ + --light-elevation-1: 0 0 0 1px rgba(0,0,0,0.05), 0 1px 2px 0 rgba(0,0,0,0.1), 0 4px 8px 0 rgba(0,0,0,0.06); /** Low elevation level for subtle separation. */ + --light-elevation-2: 0 0 0 1px rgba(0,0,0,0.05), 0 2px 4px 0 rgba(0,0,0,0.12), 0 6px 16px 0 rgba(0,0,0,0.06); + --light-elevation-3: 0 0 0 1px rgba(0,0,0,0.05), 0 4px 8px 0 rgba(0,0,0,0.14), 0 12px 24px 0 rgba(0,0,0,0.1); + --light-elevation-4: 0 0 0 1px rgba(0,0,0,0.05), 0 6px 12px 0 rgba(0,0,0,0.16), 0 20px 32px 0 rgba(0,0,0,0.12); + --dark-elevation-1: 0 0 0 1px rgba(255,255,255,0.15), 0 1px 2px 0 rgba(0,0,0,0.2), 0 4px 8px 0 rgba(0,0,0,0.1); + --dark-elevation-2: 0 0 0 1px rgba(255,255,255,0.15), 0 2px 4px 0 rgba(0,0,0,0.22), 0 6px 16px 0 rgba(0,0,0,0.12); + --dark-elevation-3: 0 0 0 1px rgba(255,255,255,0.15), 0 4px 8px 0 rgba(0,0,0,0.24), 0 12px 24px 0 rgba(0,0,0,0.14); + --dark-elevation-4: 0 0 0 1px rgba(255,255,255,0.15), 0 6px 12px 0 rgba(0,0,0,0.28), 0 20px 32px 0 rgba(0,0,0,0.16); + --radius-none: 0; + --radius-xxs: 2px; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 20px; + --radius-max: 9999px; + --radius-3xl: 24px; + --radius-4xl: 32px; + --spacing-none: 0; /** No spacing. Used for tight component joins. */ + --spacing-xxs: 4px; /** Base unit. Minimal padding, tight gaps. */ + --spacing-xs: 8px; /** Small padding and default vertical gaps. */ + --spacing-sm: 12px; /** Common internal spacing in inputs and buttons. */ + --spacing-md: 16px; /** Default large padding for sections and cards. */ + --spacing-xl: 24px; /** Comfortable spacing for chat bubbles and list items. */ + --spacing-2xl: 32px; /** Larger spacing for panels, modals, and gutters. */ + --spacing-3xl: 40px; /** Used for wide layout spacing and breathing room. */ + --spacing-lg: 20px; /** Medium spacing for grouping elements and section breaks. */ + --spacing-xxxs: 2px; + --device-radius: 8px; + --device-safe-area-bottom: 0; + --device-safe-area-top: 0; + --message-bubble-radius-group-top: 20px; + --message-bubble-radius-group-middle: 20px; + --message-bubble-radius-group-bottom: 20px; + --message-bubble-radius-tail: 0; + --message-bubble-radius-attachment: 12px; + --message-bubble-radius-attachment-inline: 8px; + --composer-radius-fixed: 24px; + --composer-radius-floating: 24px; + --button-radius-lg: 9999px; + --button-radius-md: 9999px; + --button-radius-sm: 9999px; + --button-radius-full: 9999px; + --button-visual-height-sm: 32px; + --button-visual-height-md: 40px; + --button-visual-height-lg: 48px; + --button-visual-height-xs: 24px; + /** + * Minimum interactive hit target size. + * + * iOS / Android: enforce minimum touch target. + * Web: do not apply a min-width or min-height; size to content. + * + * Note: Web uses a placeholder value in Figma due to variable mode constraints. + */ + --button-hit-target-min-height: 48px; + /** + * Minimum interactive hit target size. + * + * iOS / Android: enforce minimum touch target. + * Web: do not apply a min-width or min-height; size to content. + * + * Note: Web uses a placeholder value in Figma due to variable mode constraints. + */ + --button-hit-target-min-width: 48px; + --button-padding-y-lg: 14px; + --button-padding-y-md: 10px; + --button-padding-y-sm: 6px; + --button-padding-y-xs: 4px; + --button-padding-x-icon-only-lg: 14px; + --button-padding-x-icon-only-md: 10px; + --button-padding-x-icon-only-sm: 6px; + --button-padding-x-icon-only-xs: 4px; + --button-padding-x-with-label-lg: 16px; + --button-padding-x-with-label-md: 16px; + --button-padding-x-with-label-sm: 16px; + --button-padding-x-with-label-xs: 12px; + --button-primary-bg: #005fff; + --button-primary-bg-liquid-glass: rgba(255, 255, 255, 0); + --button-primary-text: #005fff; + --button-primary-text-on-accent: #ffffff; + --button-primary-text-inverted: #ffffff; + --button-primary-border: #a5c5ff; + --button-primary-border-inverted: #ffffff; + --button-secondary-bg: #ebeef1; + --button-secondary-bg-liquid-glass: #ffffff; + --button-secondary-text: #1a1b25; + --button-secondary-text-on-accent: #1a1b25; + --button-secondary-text-inverted: #ffffff; + --button-secondary-border: #d5dbe1; + --button-secondary-border-inverted: #ffffff; + --button-destructive-bg: #d90d10; + --button-destructive-bg-liquid-glass: #ffffff; + --button-destructive-text: #d90d10; + --button-destructive-text-on-accent: #ffffff; + --button-destructive-text-inverted: #ffffff; + --button-destructive-border: #d90d10; + --button-destructive-border-inverted: #ffffff; + --icon-size-xs: 12px; + --icon-size-sm: 16px; + --icon-size-md: 20px; + --icon-size-lg: 32px; + --icon-stroke-subtle: 1.2; + --icon-stroke-default: 1.5; + --icon-stroke-emphasis: 2; + --emoji-sm: 16px; + --emoji-md: 24px; + --emoji-lg: 32px; + --emoji-xl: 48px; + --emoji-2xl: 64px; + --background-core-elevation-0: #ffffff; /** Flat surfaces. */ + --background-core-elevation-1: #ffffff; /** Slightly elevated surfaces. */ + --background-core-elevation-2: #ffffff; /** Card-like elements. */ + --background-core-elevation-3: #ffffff; /** Popovers. */ + --background-core-elevation-4: #ffffff; /** Dialogs, modals. */ + --background-core-surface: #ebeef1; /** Standard section background. */ + --background-core-surface-subtle: #f6f8fa; /** Very light section background. */ + --background-core-surface-strong: #d5dbe1; /** Stronger section background. */ + --background-core-surface-card: #f6f8fa; + --background-core-inverse: #1a1b25; /** Inverse background used for elevated, transient, or high-attention UI surfaces that sit on top of the default app background. */ + --background-core-on-accent: #ffffff; /** Use for surfaces that must remain white across themes (e.g., media controls over video). Don’t use for general UI surfaces. */ + --background-core-highlight: #fef9da; + --background-core-overlay-light: rgba(255, 255, 255, 0.75); /** Selected overlay. */ + --background-core-overlay-dark: rgba(26, 27, 37, 0.25); /** Selected overlay. */ + --background-core-scrim: rgba(26, 27, 37, 0.5); /** Dimmed overlay for modals. */ + --background-core-app: #ffffff; /** Global application background. */ + --background-utility-hover: rgba(26, 27, 37, 0.1); /** Hover feedback overlay. */ + --background-utility-pressed: rgba(26, 27, 37, 0.15); /** Pressed feedback overlay. */ + --background-utility-selected: rgba(26, 27, 37, 0.2); /** Selected overlay. */ + --background-utility-disabled: #ebeef1; /** Disabled backgrounds. */ + --border-utility-hover: rgba(26, 27, 37, 0.1); /** Hover feedback overlay. */ + --border-utility-pressed: rgba(26, 27, 37, 0.2); /** Pressed feedback overlay. */ + --border-utility-selected: rgba(26, 27, 37, 0.15); /** Selected overlay. */ + --border-utility-focused: #c3d9ff; /** Focus ring or focus border. */ + --border-utility-active: #005fff; /** Focus ring or focus border. */ + --border-utility-success: #00a46e; /** Success borders. */ + --border-utility-warning: #f26d10; /** Warning borders. */ + --border-utility-error: #d90d10; /** Error state. */ + --border-utility-disabled: #ebeef1; /** Optional disabled background for inputs, buttons, or chips. */ + --border-core-default: #d5dbe1; /** Standard surface border. */ + --border-core-subtle: #ebeef1; /** Very light separators. */ + --border-core-strong: #a3acba; /** Stronger surface border. */ + --border-core-inverse: #ffffff; /** Used on dark backgrounds. */ + --border-core-on-accent: #ffffff; /** Borders on accent backgrounds. */ + --border-core-on-surface: #c0c8d2; + --border-core-opacity-subtle: rgba(26, 27, 37, 0.1); /** Image frame border treatment. */ + --border-core-opacity-strong: rgba(26, 27, 37, 0.25); /** Image frame border treatment. */ + --chat-bg-incoming: #ebeef1; /** Incoming bubble background. */ + --chat-bg-outgoing: #e3edff; /** Outgoing bubble background. */ + --chat-bg-attachment-incoming: #d5dbe1; /** Attachment card in incoming bubble. */ + --chat-bg-attachment-outgoing: #c3d9ff; /** Attachment card in outgoing bubble. */ + --chat-text-incoming: #1a1b25; /** Message body text. */ + --chat-text-outgoing: #091a3b; /** Message body text. */ + --chat-text-username: #414552; /** Username label. */ + --chat-text-timestamp: #687385; /** Time labels. */ + --chat-text-typing-indicator: #1a1b25; /** Typing indicator chip. */ + --chat-text-read: #005fff; + --chat-text-mention: #005fff; /** Mention styling. */ + --chat-text-link: #005fff; /** Links inside message bubbles. */ + --chat-text-reaction: #414552; /** Reaction count text. */ + --chat-text-system: #414552; /** System messages like date separators. */ + --chat-border-incoming: #ebeef1; + --chat-border-outgoing: #e3edff; + --chat-border-on-chat-incoming: #a3acba; + --chat-border-on-chat-outgoing: #78a8ff; + --chat-reply-indicator-incoming: #87909f; /** Reply indicator shading for incoming. */ + --chat-reply-indicator-outgoing: #4586ff; /** Reply indicator shading for outgoing. */ + --chat-waveform-bar: rgba(26, 27, 37, 0.25); + --chat-waveform-bar-playing: #005fff; + --chat-poll-progress-track-incoming: #d5dbe1; + --chat-poll-progress-track-outgoing: #a5c5ff; + --chat-poll-progress-fill-incoming: #687385; + --chat-poll-progress-fill-outgoing: #005fff; + --chat-thread-connector-incoming: #d5dbe1; + --chat-thread-connector-outgoing: #c3d9ff; + --input-text-default: #1a1b25; /** Main text inside the chat input. */ + --input-text-placeholder: #687385; /** Placeholder text for the input. Lower emphasis than main text. */ + --input-text-disabled: #a3acba; /** Placeholder text for the input. Lower emphasis than main text. */ + --input-text-icon: #687385; /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ + --input-send-icon: #005fff; /** Default send icon color in the input. Uses the brand accent. */ + --input-send-icon-disabled: #a3acba; /** Send icon when disabled (e.g. empty input). */ + --reaction-bg: #ffffff; /** Reaction bar background. */ + --reaction-border: #d5dbe1; /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */ + --reaction-text: #1a1b25; /** Count label next to the emoji inside the reaction chip. Uses secondary text so it does not compete with message text. */ + --reaction-emoji: #1a1b25; /** Emoji color inside reaction chips. Uses primary text color so the emoji stays clearly legible. */ + --presence-bg-online: #00a46e; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --presence-border: #ffffff; /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ + --presence-bg-offline: #687385; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --system-text: #000000; + --system-bg-blur: rgba(255, 255, 255, 0.01); + --system-caret: #005fff; + --system-scrollbar: rgba(0, 0, 0, 0.5); + --badge-bg-default: #ffffff; + --badge-bg-primary: #005fff; + --badge-bg-neutral: #687385; + --badge-bg-error: #d90d10; + --badge-bg-overlay: rgba(0, 0, 0, 0.75); + --badge-text: #1a1b25; + --badge-text-on-accent: #ffffff; + --badge-border: #ffffff; + --control-remove-control-bg: #1a1b25; + --control-remove-control-icon: #ffffff; + --control-remove-control-border: #ffffff; + --control-progress-bar-fill: #687385; + --control-progress-bar-track: #d5dbe1; + --control-toggle-switch-bg: #687385; + --control-toggle-switch-bg-selected: #005fff; + --control-toggle-switch-bg-disabled: #ebeef1; + --control-toggle-switch-knob: #ffffff; + --control-playback-toggle-text: #1a1b25; + --control-playback-toggle-border: #d5dbe1; + --control-checkbox-bg: rgba(255, 255, 255, 0); + --control-checkbox-border: #d5dbe1; + --control-checkbox-bg-selected: #005fff; + --control-checkbox-icon: #ffffff; + --control-play-button-bg: #000000; + --control-play-button-icon: #ffffff; + --control-playback-thumb-bg-default: #ffffff; + --control-playback-thumb-border-default: rgba(26, 27, 37, 0.25); + --control-playback-thumb-bg-active: #005fff; + --control-playback-thumb-border-active: #ffffff; + --control-radio-button-bg: rgba(255, 255, 255, 0); + --control-radio-button-border: #d5dbe1; + --control-radio-button-bg-selected: #005fff; + --control-radio-button-indicator: #ffffff; + --control-radio-check-bg: rgba(255, 255, 255, 0); + --control-radio-check-border: #d5dbe1; + --control-radio-check-bg-selected: #005fff; + --control-radio-check-icon: #ffffff; + --text-primary: #1a1b25; /** Main text color. */ + --text-secondary: #414552; /** Secondary metadata text. */ + --text-tertiary: #687385; /** Lowest priority text. */ + --text-inverse: #ffffff; /** Text on dark or accent backgrounds. */ + --text-on-accent: #ffffff; /** Text on dark or accent backgrounds. */ + --text-disabled: #a3acba; /** Disabled text. */ + --text-link: #005fff; /** Hyperlinks and inline actions. */ + --avatar-palette-bg-1: #c3d9ff; + --avatar-palette-bg-2: #a9e4ea; + --avatar-palette-bg-3: #8febbd; + --avatar-palette-bg-4: #d4d7ff; + --avatar-palette-bg-5: #fcd579; + --avatar-palette-text-1: #091a3b; + --avatar-palette-text-2: #002124; + --avatar-palette-text-3: #002213; + --avatar-palette-text-4: #1a114d; + --avatar-palette-text-5: #331302; + --avatar-bg-default: #c3d9ff; + --avatar-bg-placeholder: #d5dbe1; + --avatar-text-default: #091a3b; + --avatar-text-placeholder: #687385; + --avatar-presence-bg-online: #00a46e; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --avatar-presence-bg-offline: #687385; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --avatar-presence-border: #ffffff; /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ + --accent-primary: #005fff; /** Main brand accent for interactive elements. */ + --accent-success: #00a46e; /** For success states and positive actions. */ + --accent-warning: #f26d10; /** Warning or caution messages. */ + --accent-error: #d90d10; /** Destructive actions and error states. */ + --accent-neutral: #687385; /** Neutral accent for low-priority badges. */ + --brand-50: #f3f7ff; + --brand-100: #e3edff; + --brand-150: #c3d9ff; + --brand-200: #a5c5ff; + --brand-300: #78a8ff; + --brand-400: #4586ff; + --brand-500: #005fff; + --brand-600: #1b53bd; + --brand-700: #19418d; + --brand-800: #142f63; + --brand-900: #091a3b; + --skeleton-loading-base: rgba(255, 255, 255, 0); /** Base color for the default skeleton loading gradient. Used as the background tone for placeholder surfaces. */ + --skeleton-loading-highlight: #ffffff; /** Highlight color for the default skeleton loading gradient. Used for the moving shimmer to indicate loading activity. */ + --chrome-0: #ffffff; /** Neutral accent for low-priority badges. */ + --chrome-50: #f6f8fa; + --chrome-100: #ebeef1; + --chrome-150: #d5dbe1; + --chrome-200: #c0c8d2; + --chrome-300: #a3acba; + --chrome-400: #87909f; + --chrome-500: #687385; + --chrome-600: #545969; + --chrome-700: #414552; + --chrome-800: #30313d; + --chrome-900: #1a1b25; + --chrome-1000: #000000; /** Neutral accent for low-priority badges. */ +} diff --git a/src/styling/variables.css b/src/styling/variables.css index e9116ef61a..58110f720d 100644 --- a/src/styling/variables.css +++ b/src/styling/variables.css @@ -1,8 +1,11 @@ /** - * Do not edit directly, this file was auto-generated. + * Source https://www.figma.com/design/Us73erK1xFNcB5EH3hyq6Y/Chat-SDK-Design-System?node-id=164-2815&p=f&view=variables&var-set-id=41-278&m=dev */ -.str-chat { +.str-chat, +.str-chat__theme-dark .str-chat__theme-inverse { + /* ─── Palette (color primitives) ─── */ + /* Base */ --base-transparent-0: rgba(255, 255, 255, 0); --base-transparent-white-10: rgba(255, 255, 255, 0.1); --base-transparent-white-20: rgba(255, 255, 255, 0.2); @@ -13,6 +16,7 @@ --base-transparent-black-70: rgba(0, 0, 0, 0.7); /** Used for bg in closeButton */ --base-black: #000000; --base-white: #ffffff; + /* Slate */ --slate-50: #f6f8fa; --slate-100: #ebeef1; --slate-150: #d5dbe1; @@ -24,6 +28,7 @@ --slate-700: #414552; --slate-800: #30313d; --slate-900: #1a1b25; + /* Neutral */ --neutral-50: #f8f8f8; --neutral-100: #efefef; --neutral-150: #d8d8d8; @@ -35,6 +40,7 @@ --neutral-700: #464646; --neutral-800: #323232; --neutral-900: #1c1c1c; + /* Blue */ --blue-50: #f3f7ff; --blue-100: #e3edff; --blue-150: #c3d9ff; @@ -46,6 +52,7 @@ --blue-700: #19418d; --blue-800: #142f63; --blue-900: #091a3b; + /* Cyan */ --cyan-50: #f1fbfc; --cyan-100: #d1f3f6; --cyan-150: #a9e4ea; @@ -57,6 +64,7 @@ --cyan-700: #065056; --cyan-800: #003a3f; --cyan-900: #002124; + /* Green */ --green-50: #e1ffee; --green-100: #bdfcdb; --green-150: #8febbd; @@ -68,6 +76,7 @@ --green-700: #004f33; --green-800: #003a25; --green-900: #002213; + /* Purple */ --purple-50: #f7f8ff; --purple-100: #ecedff; --purple-150: #d4d7ff; @@ -79,6 +88,7 @@ --purple-700: #4032a1; --purple-800: #2e2576; --purple-900: #1a114d; + /* Yellow */ --yellow-50: #fef9da; --yellow-100: #fcedb9; --yellow-150: #fcd579; @@ -90,6 +100,7 @@ --yellow-700: #842106; --yellow-800: #5f1a05; --yellow-900: #331302; + /* Red */ --red-50: #fff5fa; --red-100: #ffe7f2; --red-150: #ffccdf; @@ -101,6 +112,7 @@ --red-700: #890d37; --red-800: #68052b; --red-900: #3e021a; + /* Violet */ --violet-50: #fef4ff; --violet-100: #fbe8fe; --violet-150: #f7cffc; @@ -112,6 +124,7 @@ --violet-700: #7c0089; --violet-800: #5c0066; --violet-900: #36003d; + /* Lime */ --lime-50: #f1fde8; --lime-100: #d4ffb0; --lime-150: #b1ee79; @@ -123,6 +136,22 @@ --lime-700: #355315; --lime-800: #203a00; --lime-900: #112100; + /* Chrome */ + --chrome-0: #ffffff; /** Neutral accent for low-priority badges. */ + --chrome-50: #f6f8fa; + --chrome-100: #ebeef1; + --chrome-150: #d5dbe1; + --chrome-200: #c0c8d2; + --chrome-300: #a3acba; + --chrome-400: #87909f; + --chrome-500: #687385; + --chrome-600: #545969; + --chrome-700: #414552; + --chrome-800: #30313d; + --chrome-900: #1a1b25; + --chrome-1000: #000000; /** Neutral accent for low-priority badges. */ + + /* ─── Size ─── */ --size-2: 2px; --size-4: 4px; --size-6: 6px; @@ -147,6 +176,8 @@ --size-144: 144px; --size-208: 208px; --size-56: 56px; + + /* ─── Radius ─── */ --radius-0: 0; --radius-2: 2px; --radius-4: 4px; @@ -158,6 +189,8 @@ --radius-24: 24px; --radius-32: 32px; --radius-full: 9999px; + + /* ─── Space ─── */ --space-0: 0; --space-2: 2px; --space-4: 4px; @@ -171,22 +204,29 @@ --space-48: 48px; --space-64: 64px; --space-80: 80px; + + /* ─── Width (stroke/weight) ─── */ --w100: 1; --w150: 1.5; --w200: 2; --w300: 3; --w400: 4; --w120: 1.2; + + /* ─── Font family ─── */ --font-family-geist: 'Geist'; /** Primary sans-serif font for web typography. Use Geist as the main typeface. Recommended fallbacks: system-ui, -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif. */ --font-family-geist-mono: 'Geist Mono'; /** Primary monospace font for web typography. Use Geist Mono for code, timestamps, and technical text. Recommended fallbacks: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace. */ --font-family-sf-pro: 'SF Pro'; /** Primary sans-serif font for iOS typography. Use SF Pro as the main typeface. Recommended fallbacks: -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, sans-serif. */ --font-family-sf-mono: 'SF Mono'; /** Primary monospace font for iOS typography. Use SF Mono for code, timestamps, and technical text. Recommended fallbacks: SFMono-Regular, Menlo, Monaco, Consolas, monospace. */ --font-family-roboto: 'Roboto'; /** Primary sans-serif font for Android typography. Use Roboto as the main typeface, aligned with Material and system defaults. Recommended fallbacks: Roboto, “Noto Sans”, system-ui, sans-serif. */ --font-family-roboto-mono: 'Roboto Mono'; /** Primary monospace font for Android typography. Use Roboto Mono for code, timestamps, and technical text. Recommended fallbacks: Roboto Mono, “Noto Sans Mono”, monospace. */ + /* ─── Font weight ─── */ --font-weight-w400: 400; --font-weight-w500: 500; --font-weight-w600: 600; --font-weight-w700: 700; + + /* ─── Font size ─── */ --font-size-size-8: 8px; --font-size-size-10: 10px; --font-size-size-11: 11px; @@ -205,6 +245,8 @@ --font-size-size-40: 40px; --font-size-size-48: 48px; --font-size-size-64: 64px; + + /* ─── Line height ─── */ --line-height-line-height-8: 8px; --line-height-line-height-10: 10px; --line-height-line-height-12: 12px; @@ -220,34 +262,24 @@ --line-height-line-height-32: 32px; --line-height-line-height-40: 40px; --line-height-line-height-48: 48px; + + /* ─── Typography (semantic) ─── */ --typography-font-weight-regular: 400; --typography-font-weight-medium: 500; --typography-font-weight-semi-bold: 600; --typography-font-weight-bold: 700; - --light-elevation-1: - 0 0 0 1px rgba(0, 0, 0, 0.05), 0 1px 2px 0 rgba(0, 0, 0, 0.1), - 0 4px 8px 0 rgba(0, 0, 0, 0.06); /** Low elevation level for subtle separation. */ - --light-elevation-2: - 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px 0 rgba(0, 0, 0, 0.12), - 0 6px 16px 0 rgba(0, 0, 0, 0.06); - --light-elevation-3: - 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 8px 0 rgba(0, 0, 0, 0.14), - 0 12px 24px 0 rgba(0, 0, 0, 0.1); - --light-elevation-4: - 0 0 0 1px rgba(0, 0, 0, 0.05), 0 6px 12px 0 rgba(0, 0, 0, 0.16), - 0 20px 32px 0 rgba(0, 0, 0, 0.12); - --dark-elevation-1: - 0 0 0 1px rgba(255, 255, 255, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.2), - 0 4px 8px 0 rgba(0, 0, 0, 0.1); - --dark-elevation-2: - 0 0 0 1px rgba(255, 255, 255, 0.15), 0 2px 4px 0 rgba(0, 0, 0, 0.22), - 0 6px 16px 0 rgba(0, 0, 0, 0.12); - --dark-elevation-3: - 0 0 0 1px rgba(255, 255, 255, 0.15), 0 4px 8px 0 rgba(0, 0, 0, 0.24), - 0 12px 24px 0 rgba(0, 0, 0, 0.14); - --dark-elevation-4: - 0 0 0 1px rgba(255, 255, 255, 0.15), 0 6px 12px 0 rgba(0, 0, 0, 0.28), - 0 20px 32px 0 rgba(0, 0, 0, 0.16); + + /* ─── Elevation (shadows) ─── */ + --light-elevation-1: 0 0 0 1px rgba(0,0,0,0.05), 0 1px 2px 0 rgba(0,0,0,0.1), 0 4px 8px 0 rgba(0,0,0,0.06); /** Low elevation level for subtle separation. */ + --light-elevation-2: 0 0 0 1px rgba(0,0,0,0.05), 0 2px 4px 0 rgba(0,0,0,0.12), 0 6px 16px 0 rgba(0,0,0,0.06); + --light-elevation-3: 0 0 0 1px rgba(0,0,0,0.05), 0 4px 8px 0 rgba(0,0,0,0.14), 0 12px 24px 0 rgba(0,0,0,0.1); + --light-elevation-4: 0 0 0 1px rgba(0,0,0,0.05), 0 6px 12px 0 rgba(0,0,0,0.16), 0 20px 32px 0 rgba(0,0,0,0.12); + --dark-elevation-1: 0 0 0 1px rgba(255,255,255,0.15), 0 1px 2px 0 rgba(0,0,0,0.2), 0 4px 8px 0 rgba(0,0,0,0.1); + --dark-elevation-2: 0 0 0 1px rgba(255,255,255,0.15), 0 2px 4px 0 rgba(0,0,0,0.22), 0 6px 16px 0 rgba(0,0,0,0.12); + --dark-elevation-3: 0 0 0 1px rgba(255,255,255,0.15), 0 4px 8px 0 rgba(0,0,0,0.24), 0 12px 24px 0 rgba(0,0,0,0.14); + --dark-elevation-4: 0 0 0 1px rgba(255,255,255,0.15), 0 6px 12px 0 rgba(0,0,0,0.28), 0 20px 32px 0 rgba(0,0,0,0.16); + + /* ─── Button (layout: padding, radius, height, hit target) ─── */ --button-padding-y-lg: 14px; --button-padding-y-md: 10px; --button-padding-y-sm: 6px; @@ -260,87 +292,68 @@ --button-padding-x-with-label-md: 16px; --button-padding-x-with-label-sm: 16px; --button-padding-x-with-label-xs: 12px; - --background-core-hover: rgba(30, 37, 43, 0.05); /** Hover feedback overlay. */ - --background-core-pressed: rgba(30, 37, 43, 0.1); /** Pressed feedback overlay. */ - --background-core-selected: rgba(30, 37, 43, 0.15); /** Selected overlay. */ - --background-core-scrim: rgba(0, 0, 0, 0.25); /** Dimmed overlay for modals. */ - --background-core-overlay: rgba(255, 255, 255, 0.75); /** Selected overlay. */ + + /* ─── Background (core overlays, system) ─── */ + --background-core-scrim: rgba(26, 27, 37, 0.5); /** Dimmed overlay for modals. */ --background-core-overlay-light: rgba(255, 255, 255, 0.75); /** Selected overlay. */ - --background-core-overlay-dark: rgba(0, 0, 0, 0.25); /** Selected overlay. */ - --border-utility-focus: rgba(120, 168, 255, 0.5); /** Focus ring or focus border. */ - --border-core-opacity-10: rgba(0, 0, 0, 0.1); /** Image frame border treatment. */ - --border-core-opacity-25: rgba(0, 0, 0, 0.25); /** Image frame border treatment. */ + --background-core-overlay-dark: rgba(26, 27, 37, 0.25); /** Selected overlay. */ --system-bg-blur: rgba(255, 255, 255, 0.01); --system-scrollbar: rgba(0, 0, 0, 0.5); --badge-bg-overlay: rgba(0, 0, 0, 0.75); - --typography-font-family-sans: var(--font-family-geist); - --typography-font-family-mono: var(--font-family-geist-mono); - --typography-font-size-xxs: var( - --font-size-size-10 - ); /** Micro text such as timestamps or subtle metadata. */ - --typography-font-size-xs: var( - --font-size-size-12 - ); /** Compact secondary text, small UI labels. */ - --typography-font-size-sm: var( - --font-size-size-14 - ); /** Default mobile body size, small controls. */ - --typography-font-size-md: var( - --font-size-size-16 - ); /** Default desktop body size, main text. */ - --typography-font-size-lg: var( - --font-size-size-18 - ); /** Medium emphasis, section labels. */ - --typography-font-size-xl: var(--font-size-size-20); /** Small headings. */ - --typography-font-size-2xl: var( - --font-size-size-24 - ); /** Section titles, important headings. */ - --typography-font-size-micro: var( - --font-size-size-8 - ); /** Micro text such as timestamps or subtle metadata. */ - --typography-line-height-tight: var( - --line-height-line-height-16 - ); /** Compact text, headers, UI labels. */ - --typography-line-height-normal: var( - --line-height-line-height-20 - ); /** Default reading line-height for sizes 14–16px. */ - --typography-line-height-relaxed: var( - --line-height-line-height-24 - ); /** For larger text sizes or multiline descriptions. */ - --radius-none: var(--radius-0); - --radius-xxs: var(--radius-2); - --radius-xs: var(--radius-4); - --radius-sm: var(--radius-6); - --radius-md: var(--radius-8); - --radius-lg: var(--radius-12); - --radius-xl: var(--radius-16); - --radius-2xl: var(--radius-20); - --radius-max: var(--radius-full); - --radius-3xl: var(--radius-24); - --radius-4xl: var(--radius-32); - --spacing-none: var(--space-0); /** No spacing. Used for tight component joins. */ - --spacing-xxs: var(--space-4); /** Base unit. Minimal padding, tight gaps. */ - --spacing-xs: var(--space-8); /** Small padding and default vertical gaps. */ - --spacing-sm: var(--space-12); /** Common internal spacing in inputs and buttons. */ - --spacing-md: var(--space-16); /** Default large padding for sections and cards. */ - --spacing-xl: var( - --space-24 - ); /** Comfortable spacing for chat bubbles and list items. */ - --spacing-2xl: var(--space-32); /** Larger spacing for panels, modals, and gutters. */ - --spacing-3xl: var(--space-40); /** Used for wide layout spacing and breathing room. */ - --spacing-lg: var( - --space-20 - ); /** Medium spacing for grouping elements and section breaks. */ - --spacing-xxxs: var(--space-2); - --device-safe-area-bottom: var(--space-0); - --device-safe-area-top: var(--space-0); - --button-radius-lg: var(--radius-full); - --button-radius-md: var(--radius-full); - --button-radius-sm: var(--radius-full); - --button-radius-full: var(--radius-full); - --button-visual-height-sm: var(--size-32); - --button-visual-height-md: var(--size-40); - --button-visual-height-lg: var(--size-48); - --button-visual-height-xs: var(--size-24); + + /* ─── Typography (family, size scale, line-height) ─── */ + --typography-font-family-sans: "Geist"; + --typography-font-family-mono: "Geist Mono"; + --typography-font-size-xxs: 10px; /** Micro text such as timestamps or subtle metadata. */ + --typography-font-size-xs: 12px; /** Compact secondary text, small UI labels. */ + --typography-font-size-sm: 14px; /** Default mobile body size, small controls. */ + --typography-font-size-md: 16px; /** Default desktop body size, main text. */ + --typography-font-size-lg: 18px; /** Medium emphasis, section labels. */ + --typography-font-size-xl: 20px; /** Small headings. */ + --typography-font-size-2xl: 24px; /** Section titles, important headings. */ + --typography-font-size-micro: 8px; /** Micro text such as timestamps or subtle metadata. */ + --typography-line-height-tight: 16px; /** Compact text, headers, UI labels. */ + --typography-line-height-normal: 20px; /** Default reading line-height for sizes 14–16px. */ + --typography-line-height-relaxed: 24px; /** For larger text sizes or multiline descriptions. */ + + /* ─── Radius (semantic) ─── */ + --radius-none: 0; + --radius-xxs: 2px; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 20px; + --radius-max: 9999px; + --radius-3xl: 24px; + --radius-4xl: 32px; + + /* ─── Spacing ─── */ + --spacing-none: 0; /** No spacing. Used for tight component joins. */ + --spacing-xxs: 4px; /** Base unit. Minimal padding, tight gaps. */ + --spacing-xs: 8px; /** Small padding and default vertical gaps. */ + --spacing-sm: 12px; /** Common internal spacing in inputs and buttons. */ + --spacing-md: 16px; /** Default large padding for sections and cards. */ + --spacing-xl: 24px; /** Comfortable spacing for chat bubbles and list items. */ + --spacing-2xl: 32px; /** Larger spacing for panels, modals, and gutters. */ + --spacing-3xl: 40px; /** Used for wide layout spacing and breathing room. */ + --spacing-lg: 20px; /** Medium spacing for grouping elements and section breaks. */ + --spacing-xxxs: 2px; + + /* ─── Device ─── */ + --device-safe-area-bottom: 0; + --device-safe-area-top: 0; + + /* ─── Button (radius, visual height, hit target, liquid glass) ─── */ + --button-radius-lg: 9999px; + --button-radius-md: 9999px; + --button-radius-sm: 9999px; + --button-radius-full: 9999px; + --button-visual-height-sm: 32px; + --button-visual-height-md: 40px; + --button-visual-height-lg: 48px; + --button-visual-height-xs: 24px; /** * Minimum interactive hit target size. * @@ -349,7 +362,7 @@ * * Note: Web uses a placeholder value in Figma due to variable mode constraints. */ - --button-hit-target-min-height: var(--size-48); + --button-hit-target-min-height: 48px; /** * Minimum interactive hit target size. * @@ -358,293 +371,297 @@ * * Note: Web uses a placeholder value in Figma due to variable mode constraints. */ - --button-hit-target-min-width: var(--size-48); - --button-primary-bg-liquid-glass: var(--base-transparent-0); - --icon-size-xs: var(--size-12); - --icon-size-sm: var(--size-16); - --icon-size-md: var(--size-20); - --icon-size-lg: var(--size-32); - --icon-stroke-subtle: var(--w120); - --icon-stroke-default: var(--w150); - --icon-stroke-emphasis: var(--w200); - --emoji-md: var(--font-size-size-24); - --emoji-lg: var(--font-size-size-32); - --emoji-xl: var(--font-size-size-48); - --emoji-2xl: var(--font-size-size-64); - --background-core-disabled: var( - --slate-100 - ); /** Optional disabled background for inputs, buttons, or chips. */ - --background-core-surface: var(--slate-100); /** Standard section background. */ - --background-core-surface-subtle: var(--slate-50); /** Very light section background. */ - --background-core-surface-strong: var(--slate-150); /** Stronger section background. */ - --background-core-inverse: var( - --slate-900 - ); /** Inverse background used for elevated, transient, or high-attention UI surfaces that sit on top of the default app background. */ - --background-core-highlight: var(--yellow-50); - --background-core-surface-card: var(--slate-50); - --background-elevation-elevation-0: var(--base-white); /** Flat surfaces. */ - --background-elevation-elevation-1: var( - --base-white - ); /** Slightly elevated surfaces. */ - --background-elevation-elevation-2: var(--base-white); /** Card-like elements. */ - --background-elevation-elevation-3: var(--base-white); /** Popovers. */ - --background-elevation-elevation-4: var(--base-white); /** Dialogs, modals. */ - --border-utility-disabled: var( - --slate-100 - ); /** Optional disabled background for inputs, buttons, or chips. */ - --border-core-default: var(--slate-150); /** Standard surface border. */ - --border-core-subtle: var(--slate-100); /** Very light separators. */ - --border-core-strong: var(--slate-300); /** Stronger surface border. */ - --border-core-on-dark: var(--base-white); /** Used on dark backgrounds. */ - --border-core-on-accent: var(--base-white); /** Borders on accent backgrounds. */ - --border-core-on-surface: var(--slate-200); - --chat-reply-indicator-incoming: var( - --slate-400 - ); /** Reply indicator shading for incoming. */ - --chat-waveform-bar: var(--border-core-opacity-25); - --system-text: var(--base-black); - --control-radiocheck-bg: var(--base-transparent-0); - --control-checkbox-bg: var(--base-transparent-0); - --text-primary: var(--slate-900); /** Main text color. */ - --text-secondary: var(--slate-700); /** Secondary metadata text. */ - --text-tertiary: var(--slate-500); /** Lowest priority text. */ - --text-inverse: var(--base-white); /** Text on dark or accent backgrounds. */ - --text-disabled: var(--slate-300); /** Disabled text. */ - --text-on-accent: var(--base-white); /** Text on dark or accent backgrounds. */ - --text-on-dark: var(--base-white); /** Text on dark or accent backgrounds. */ - --avatar-palette-bg-1: var(--blue-150); - --avatar-palette-bg-2: var(--cyan-150); - --avatar-palette-bg-3: var(--green-150); - --avatar-palette-bg-4: var(--purple-150); - --avatar-palette-bg-5: var(--yellow-150); - --avatar-palette-text-1: var(--blue-900); - --avatar-palette-text-2: var(--cyan-900); - --avatar-palette-text-3: var(--green-900); - --avatar-palette-text-4: var(--purple-900); - --avatar-palette-text-5: var(--yellow-900); - --avatar-bg-placeholder: var(--slate-100); - --avatar-text-placeholder: var(--slate-500); - --accent-success: var(--green-400); /** For success states and positive actions. */ - --accent-warning: var(--yellow-400); /** Warning or caution messages. */ - --accent-error: var(--red-500); /** Destructive actions and error states. */ - --accent-neutral: var(--slate-400); /** Neutral accent for low-priority badges. */ - --accent-black: var(--base-black); /** Neutral accent for low-priority badges. */ - --brand-50: var(--blue-50); - --brand-100: var(--blue-100); - --brand-150: var(--blue-150); - --brand-200: var(--blue-200); - --brand-300: var(--blue-300); - --brand-400: var(--blue-400); - --brand-500: var(--blue-500); - --brand-600: var(--blue-600); - --brand-700: var(--blue-700); - --brand-800: var(--blue-800); - --brand-900: var(--blue-900); - --skeleton-loading-base: var( - --base-transparent-0 - ); /** Base color for the default skeleton loading gradient. Used as the background tone for placeholder surfaces. */ - --skeleton-loading-highlight: var( - --base-white - ); /** Highlight color for the default skeleton loading gradient. Used for the moving shimmer to indicate loading activity. */ - --device-radius: var(--radius-md); - --message-bubble-radius-group-top: var(--radius-2xl); - --message-bubble-radius-group-middle: var(--radius-2xl); - --message-bubble-radius-group-bottom: var(--radius-2xl); - --message-bubble-radius-tail: var(--radius-none); - --message-bubble-radius-attachment: var(--radius-lg); - --message-bubble-radius-attachment-inline: var(--radius-md); - --composer-radius-fixed: var(--radius-3xl); - --composer-radius-floating: var(--radius-3xl); - --composer-bg: var( - --background-elevation-elevation-1 - ); /** Composer container background. */ - --button-primary-text-on-accent: var(--text-on-accent); - --button-primary-border: var(--brand-200); - --button-primary-text-on-dark: var(--text-on-dark); - --button-primary-border-on-dark: var(--border-core-on-dark); + --button-hit-target-min-width: 48px; + --button-primary-bg-liquid-glass: rgba(255, 255, 255, 0); + + /* ─── Icon & emoji ─── */ + --icon-size-xs: 12px; + --icon-size-sm: 16px; + --icon-size-md: 20px; + --icon-size-lg: 32px; + --icon-stroke-subtle: 1.2; + --icon-stroke-default: 1.5; + --icon-stroke-emphasis: 2; + --emoji-md: 24px; + --emoji-lg: 32px; + --emoji-xl: 48px; + --emoji-2xl: 64px; + + /* ─── Background (core: elevation, surface, utility) ─── */ + --background-core-elevation-0: #ffffff; /** Flat surfaces. */ + --background-core-elevation-1: #ffffff; /** Slightly elevated surfaces. */ + --background-core-elevation-2: #ffffff; /** Card-like elements. */ + --background-core-elevation-3: #ffffff; /** Popovers. */ + --background-core-elevation-4: #ffffff; /** Dialogs, modals. */ + --background-core-surface: #ebeef1; /** Standard section background. */ + --background-core-surface-subtle: #f6f8fa; /** Very light section background. */ + --background-core-surface-strong: #d5dbe1; /** Stronger section background. */ + --background-core-inverse: #1a1b25; /** Inverse background used for elevated, transient, or high-attention UI surfaces that sit on top of the default app background. */ + --background-core-on-accent: #ffffff; /** Use for surfaces that must remain white across themes (e.g., media controls over video). Don’t use for general UI surfaces. */ + --background-core-highlight: #fef9da; + --background-core-surface-card: #f6f8fa; + --background-utility-hover: rgba(26, 27, 37, 0.1); /** Hover feedback overlay. */ + --background-utility-pressed: rgba(26, 27, 37, 0.15); /** Pressed feedback overlay. */ + --background-utility-selected: rgba(26, 27, 37, 0.2); /** Selected overlay. */ + --background-utility-disabled: #ebeef1; /** Disabled backgrounds. */ + + /* ─── Border (utility, core) ─── */ + --border-utility-disabled: #ebeef1; /** Optional disabled background for inputs, buttons, or chips. */ + --border-utility-hover: rgba(26, 27, 37, 0.1); /** Hover feedback overlay. */ + --border-utility-pressed: rgba(26, 27, 37, 0.2); /** Pressed feedback overlay. */ + --border-core-default: #d5dbe1; /** Standard surface border. */ + --border-core-subtle: #ebeef1; /** Very light separators. */ + --border-core-strong: #a3acba; /** Stronger surface border. */ + --border-core-on-accent: #ffffff; /** Borders on accent backgrounds. */ + --border-core-on-surface: #c0c8d2; + --border-core-inverse: #ffffff; /** Border on inverse (dark) surface, e.g. presence ring. */ + --border-utility-focused: #c3d9ff; /** Focus ring or focus border. */ + --border-utility-active: var(--accent-primary); /** Focus ring or focus border. */ + --border-utility-selected: rgba(26, 27, 37, 0.15); /** Focus ring or focus border. */ + --border-core-opacity-subtle: rgba(26, 27, 37, 0.1); /** Image frame border treatment. */ + --border-core-opacity-strong: rgba(26, 27, 37, 0.25); /** Image frame border treatment. */ + + /* ─── Chat (reply, waveform) ─── */ + --chat-reply-indicator-incoming: #87909f; /** Reply indicator shading for incoming. */ + --chat-waveform-bar: rgba(26, 27, 37, 0.25); + + /* ─── System & control ─── */ + --system-text: #000000; + --system-caret: var(--accent-primary); + --control-checkbox-bg: rgba(255, 255, 255, 0); + + /* ─── Text ─── */ + --text-primary: #1a1b25; /** Main text color. */ + --text-secondary: #414552; /** Secondary metadata text. */ + --text-tertiary: #687385; /** Lowest priority text. */ + --text-inverse: #ffffff; /** Text on dark or accent backgrounds. */ + --text-disabled: #a3acba; /** Disabled text. */ + --text-on-accent: #ffffff; /** Text on dark or accent backgrounds. */ + --text-link: var(--accent-primary); /** Hyperlinks and inline actions. */ + + /* ─── Avatar (palette, placeholder) ─── */ + --avatar-palette-bg-1: #c3d9ff; + --avatar-palette-bg-2: #a9e4ea; + --avatar-palette-bg-3: #8febbd; + --avatar-palette-bg-4: #d4d7ff; + --avatar-palette-bg-5: #fcd579; + --avatar-palette-text-1: #091a3b; + --avatar-palette-text-2: #002124; + --avatar-palette-text-3: #002213; + --avatar-palette-text-4: #1a114d; + --avatar-palette-text-5: #331302; + --avatar-bg-placeholder: #d5dbe1; + --avatar-text-placeholder: #687385; + + /* ─── Accent & brand ─── */ + --accent-success: #00a46e; /** For success states and positive actions. */ + --accent-warning: #f26d10; /** Warning or caution messages. */ + --accent-error: #d90d10; /** Destructive actions and error states. */ + --accent-neutral: #687385; /** Neutral accent for low-priority badges. */ + --brand-50: #f3f7ff; + --brand-100: #e3edff; + --brand-150: #c3d9ff; + --brand-200: #a5c5ff; + --brand-300: #78a8ff; + --brand-400: #4586ff; + --brand-500: #005fff; + --brand-600: #1b53bd; + --brand-700: #19418d; + --brand-800: #142f63; + --brand-900: #091a3b; + --accent-primary: #005fff; /** Main brand accent for interactive elements. */ + + /* ─── Skeleton ─── */ + --skeleton-loading-base: rgba(255, 255, 255, 0); /** Base color for the default skeleton loading gradient. Used as the background tone for placeholder surfaces. */ + --skeleton-loading-highlight: #ffffff; /** Highlight color for the default skeleton loading gradient. Used for the moving shimmer to indicate loading activity. */ + + /* ─── Device & message/composer radius ─── */ + --device-radius: 8px; + --message-bubble-radius-group-top: 20px; + --message-bubble-radius-group-middle: 20px; + --message-bubble-radius-group-bottom: 20px; + --message-bubble-radius-tail: 0; + --message-bubble-radius-attachment: 12px; + --message-bubble-radius-attachment-inline: 8px; + --composer-radius-fixed: 24px; + --composer-radius-floating: 24px; + + /* ─── Button (semantic colors: primary, secondary, destructive) ─── */ + --button-primary-bg: var(--accent-primary); + --button-primary-text: var(--accent-primary); + --button-primary-text-on-accent: #ffffff; + --button-primary-border: #a5c5ff; + --button-primary-text-inverse: #ffffff; + --button-primary-border-inverse: #ffffff; --button-secondary-text: var(--text-primary); - --button-secondary-bg-liquid-glass: var(--background-elevation-elevation-0); - --button-secondary-border: var(--border-core-default); - --button-secondary-bg: var(--background-core-surface); + --button-secondary-bg-liquid-glass: #ffffff; + --button-secondary-border: #d5dbe1; + --button-secondary-bg: #ebeef1; --button-secondary-text-on-accent: var(--text-primary); - --button-secondary-text-on-dark: var(--text-on-dark); - --button-secondary-border-on-dark: var(--border-core-on-dark); - --button-destructive-text: var(--accent-error); - --button-destructive-bg: var(--accent-error); - --button-destructive-text-on-accent: var(--text-on-accent); - --button-destructive-bg-liquid-glass: var(--background-elevation-elevation-0); - --button-destructive-border: var(--accent-error); - --button-destructive-text-on-dark: var(--text-on-dark); - --button-destructive-border-on-dark: var(--text-on-dark); - --emoji-sm: var(--typography-font-size-md); - --background-core-app: var( - --background-elevation-elevation-0 - ); /** Global application background. */ - --border-utility-error: var(--accent-error); /** Error state. */ - --border-utility-warning: var(--accent-warning); /** Warning borders. */ - --border-utility-success: var(--accent-success); /** Success borders. */ - --chat-bg-incoming: var(--background-core-surface); /** Incoming bubble background. */ - --chat-bg-outgoing: var(--brand-100); /** Outgoing bubble background. */ - --chat-bg-attachment-incoming: var( - --background-core-surface-strong - ); /** Attachment card in incoming bubble. */ - --chat-bg-attachment-outgoing: var( - --brand-150 - ); /** Attachment card in outgoing bubble. */ - --chat-bg-typing-indicator: var(--accent-neutral); /** Typing indicator chip. */ - --chat-text-timestamp: var(--text-tertiary); /** Time labels. */ - --chat-text-username: var(--text-secondary); /** Username label. */ - --chat-text-reaction: var(--text-secondary); /** Reaction count text. */ - --chat-text-system: var(--text-secondary); /** System messages like date separators. */ + --button-secondary-text-inverse: #ffffff; + --button-secondary-border-inverse: #ffffff; + --button-destructive-text: #d90d10; + --button-destructive-bg: #d90d10; + --button-destructive-text-on-accent: #ffffff; + --button-destructive-bg-liquid-glass: #ffffff; + --button-destructive-border: #d90d10; + --button-destructive-text-inverse: #ffffff; + --button-destructive-border-inverse: #ffffff; + --emoji-sm: 16px; + + /* ─── Background (core app), border (utility state), chat (bg, text, border) ─── */ + --background-core-app: #ffffff; /** Global application background. */ + --border-utility-error: #d90d10; /** Error state. */ + --border-utility-warning: #f26d10; /** Warning borders. */ + --border-utility-success: #00a46e; /** Success borders. */ + --chat-bg-incoming: #ebeef1; /** Incoming bubble background. */ + --chat-bg-outgoing: #e3edff; /** Outgoing bubble background. */ + --chat-bg-attachment-incoming: #d5dbe1; /** Attachment card in incoming bubble. */ + --chat-bg-attachment-outgoing: #c3d9ff; /** Attachment card in outgoing bubble. */ + --chat-text-timestamp: #687385; /** Time labels. */ + --chat-text-username: #414552; /** Username label. */ + --chat-text-reaction: #414552; /** Reaction count text. */ + --chat-text-system: #414552; /** System messages like date separators. */ --chat-text-incoming: var(--text-primary); /** Message body text. */ - --chat-text-outgoing: var(--brand-900); /** Message body text. */ - --chat-border-outgoing: var(--brand-100); - --chat-border-incoming: var(--border-core-subtle); - --chat-border-on-chat-outgoing: var(--brand-300); - --chat-border-on-chat-incoming: var(--border-core-strong); - --chat-reply-indicator-outgoing: var( - --brand-400 - ); /** Reply indicator shading for outgoing. */ - --chat-poll-progress-track-outgoing: var(--brand-200); - --chat-thread-connector-incoming: var(--border-core-default); - --chat-thread-connector-outgoing: var(--brand-150); - --input-border-default: var( - --border-core-default - ); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */ - --input-border-hover: var( - --border-core-strong - ); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */ - --input-text-default: var(--text-primary); /** Main text inside the chat input. */ - --input-text-placeholder: var( - --text-tertiary - ); /** Placeholder text for the input. Lower emphasis than main text. */ - --input-text-icon: var( - --text-tertiary - ); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ - --input-text-disabled: var( - --text-disabled - ); /** Placeholder text for the input. Lower emphasis than main text. */ - --input-send-icon-disabled: var( - --text-disabled - ); /** Send icon when disabled (e.g. empty input). */ - --input-option-card-bg: var(--background-core-surface-subtle); - --reaction-bg: var(--background-elevation-elevation-3); /** Reaction bar background. */ - --reaction-border: var( - --border-core-default - ); /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */ - --reaction-text: var( - --text-primary - ); /** Count label next to the emoji inside the reaction chip. Uses secondary text so it does not compete with message text. */ - --reaction-emoji: var( - --text-primary - ); /** Emoji color inside reaction chips. Uses primary text color so the emoji stays clearly legible. */ - --presence-bg-online: var( - --accent-success - ); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ - --presence-border: var( - --border-core-on-dark - ); /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ - --presence-bg-offline: var( - --accent-neutral - ); /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ - --badge-border: var(--border-core-on-dark); - --badge-bg-error: var(--accent-error); - --badge-bg-neutral: var(--accent-neutral); - --badge-text: var(--text-primary); - --badge-text-inverse: var(--text-on-dark); - --badge-bg-default: var(--background-elevation-elevation-3); - --badge-bg-inverse: var(--background-core-inverse); - --badge-text-on-accent: var(--text-on-accent); - --control-radiocheck-border: var(--border-core-default); - --control-radiocheck-icon-selected: var(--text-on-accent); - --control-remove-control-bg: var(--background-core-inverse); - --control-remove-control-icon: var(--text-on-dark); - --control-remove-control-border: var(--border-core-on-dark); - --control-progress-bar-track: var(--background-core-surface-strong); - --control-progress-bar-fill: var(--accent-neutral); - --control-play-control-bg: var(--accent-black); - --control-play-control-icon: var(--text-on-accent); - --control-toggle-switch-knob: var(--background-elevation-elevation-4); - --control-toggle-switch-bg: var(--accent-neutral); - --control-toggle-switch-bg-disabled: var(--background-core-disabled); - --control-playback-toggle-border: var(--border-core-default); - --control-playback-toggle-text: var(--text-primary); - --control-checkbox-border: var(--border-core-default); - --control-checkbox-icon-selected: var(--text-on-accent); - --avatar-bg-default: var(--avatar-palette-bg-1); - --avatar-text-default: var(--avatar-palette-text-1); - --accent-primary: var(--brand-500); /** Main brand accent for interactive elements. */ - --chip-bg: var(--brand-100); - --chip-text: var(--brand-900); - --button-primary-bg: var(--accent-primary); - --button-primary-text: var(--accent-primary); - --border-utility-selected: var(--accent-primary); /** Focus ring or focus border. */ + --chat-text-outgoing: #091a3b; /** Message body text. */ + --chat-border-outgoing: #e3edff; + --chat-border-incoming: #ebeef1; + --chat-border-on-chat-outgoing: #78a8ff; + --chat-border-on-chat-incoming: #a3acba; + --chat-reply-indicator-outgoing: #4586ff; /** Reply indicator shading for outgoing. */ + --chat-poll-progress-track-outgoing: #a5c5ff; + --chat-thread-connector-incoming: #d5dbe1; + --chat-thread-connector-outgoing: #c3d9ff; --chat-text-read: var(--accent-primary); - --chat-text-typing-indicator: var(--chat-text-incoming); /** Typing indicator chip. */ + --chat-text-typing-indicator: var(--text-primary); /** Typing indicator chip. */ --chat-waveform-bar-playing: var(--accent-primary); - --chat-poll-progress-track-incoming: var(--control-progress-bar-track); - --chat-poll-progress-fill-incoming: var(--control-progress-bar-fill); + --chat-poll-progress-track-incoming: #d5dbe1; + --chat-poll-progress-fill-incoming: #687385; --chat-poll-progress-fill-outgoing: var(--accent-primary); - --input-send-icon: var( - --accent-primary - ); /** Default send icon color in the input. Uses the brand accent. */ - --system-caret: var(--accent-primary); + --chat-text-mention: var(--accent-primary); /** Mention styling. */ + --chat-text-link: var(--accent-primary); /** Links inside message bubbles. */ + + /* ─── Input ─── */ + --input-text-default: var(--text-primary); /** Main text inside the chat input. */ + --input-text-placeholder: #687385; /** Placeholder text for the input. Lower emphasis than main text. */ + --input-text-icon: #687385; /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ + --input-text-disabled: #a3acba; /** Placeholder text for the input. Lower emphasis than main text. */ + --input-send-icon: var(--accent-primary); /** Default send icon color in the input. Uses the brand accent. */ + --input-send-icon-disabled: #a3acba; /** Send icon when disabled (e.g. empty input). */ + + /* ─── Reaction ─── */ + --reaction-bg: #ffffff; /** Reaction bar background. */ + --reaction-border: #d5dbe1; /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */ + --reaction-text: var(--text-primary); /** Count label next to the emoji inside the reaction chip. Uses secondary text so it does not compete with message text. */ + --reaction-emoji: var(--text-primary); /** Emoji color inside reaction chips. Uses primary text color so the emoji stays clearly legible. */ + + /* ─── Presence ─── */ + --presence-bg-online: #00a46e; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --presence-border: #ffffff; /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ + --presence-bg-offline: #687385; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + + /* ─── Badge ─── */ + --badge-border: #ffffff; --badge-bg-primary: var(--accent-primary); - --control-radiocheck-bg-selected: var(--accent-primary); + --badge-bg-error: #d90d10; + --badge-bg-neutral: #687385; + --badge-text: var(--text-primary); + --badge-bg-default: #ffffff; + --badge-text-on-accent: #ffffff; + + /* ─── Control (remove, progress bar, toggle, playback, checkbox, play button, thumb, radio) ─── */ + --control-remove-control-bg: #1a1b25; + --control-remove-control-icon: #ffffff; + --control-remove-control-border: #ffffff; + --control-progress-bar-track: #d5dbe1; + --control-progress-bar-fill: #687385; + --control-toggle-switch-knob: #ffffff; + --control-toggle-switch-bg: #687385; --control-toggle-switch-bg-selected: var(--accent-primary); + --control-toggle-switch-bg-disabled: #ebeef1; + --control-playback-toggle-border: #d5dbe1; + --control-playback-toggle-text: var(--text-primary); + --control-checkbox-border: #d5dbe1; --control-checkbox-bg-selected: var(--accent-primary); - --text-link: var(--accent-primary); /** Hyperlinks and inline actions. */ - --chat-text-mention: var(--text-link); /** Mention styling. */ - --chat-text-link: var(--text-link); /** Links inside message bubbles. */ - --input-border-focus: var( - --border-utility-selected - ); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ - --input-border-selected: var( - --border-utility-selected - ); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ + --control-checkbox-icon: #ffffff; + --control-play-button-bg: #000000; + --control-play-button-icon: #ffffff; + --control-playback-thumb-bg-default: #ffffff; + --control-playback-thumb-border-default: rgba(26, 27, 37, 0.25); + --control-playback-thumb-bg-active: var(--accent-primary); + --control-playback-thumb-border-active: #ffffff; + --control-radio-button-bg: rgba(255, 255, 255, 0); + --control-radio-button-border: #d5dbe1; + --control-radio-button-bg-selected: var(--accent-primary); + --control-radio-button-indicator: #ffffff; + --control-radio-check-bg: rgba(255, 255, 255, 0); + --control-radio-check-border: #d5dbe1; + --control-radio-check-bg-selected: var(--accent-primary); + --control-radio-check-icon: #ffffff; + + /* ─── Avatar (default, presence) ─── */ + --avatar-bg-default: #c3d9ff; + --avatar-text-default: #091a3b; + --avatar-presence-bg-online: #00a46e; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --avatar-presence-bg-offline: #687385; /** The green online indicator. Uses success accent in normal themes. In high-contrast, color is dropped and replaced with strong black for maximum clarity. */ + --avatar-presence-border: #ffffff; /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ } -.str-chat__theme-dark { - --background-core-hover: rgba(255, 255, 255, 0.1); - --background-core-pressed: rgba(255, 255, 255, 0.15); - --background-core-selected: rgba(255, 255, 255, 0.2); +.str-chat__theme-dark, +.str-chat:not(.str-chat__theme-dark) *:not(.str-chat__theme-dark) .str-chat__theme-inverse { + /* Background (Figma dark semantics) */ --background-core-scrim: rgba(0, 0, 0, 0.75); - --background-core-overlay: rgba(0, 0, 0, 0.75); --background-core-overlay-light: rgba(0, 0, 0, 0.75); --background-core-overlay-dark: rgba(0, 0, 0, 0.5); - --border-utility-focus: rgba(120, 168, 255, 0.25); - --border-core-opacity-10: rgba(255, 255, 255, 0.2); - --border-core-opacity-25: rgba(255, 255, 255, 0.25); - --system-bg-blur: rgba(0, 0, 0, 0.01); - --system-scrollbar: rgba(255, 255, 255, 0.5); - --background-core-disabled: var(--neutral-800); --background-core-surface: var(--neutral-800); --background-core-surface-subtle: var(--neutral-900); --background-core-surface-strong: var(--neutral-700); --background-core-inverse: var(--neutral-50); --background-core-highlight: var(--yellow-800); --background-core-surface-card: var(--neutral-800); - --background-elevation-elevation-0: var(--base-black); - --background-elevation-elevation-1: var(--neutral-900); - --background-elevation-elevation-2: var(--neutral-800); - --background-elevation-elevation-3: var(--neutral-700); - --background-elevation-elevation-4: var(--neutral-600); + --background-core-elevation-0: var(--neutral-800); + --background-core-elevation-1: var(--neutral-900); + --background-core-elevation-2: var(--neutral-700); + --background-core-elevation-3: var(--neutral-600); + --background-core-elevation-4: var(--neutral-500); + --background-core-on-accent: var(--base-black); + --background-core-app: var(--background-core-elevation-0); + --background-utility-hover: rgba(255, 255, 255, 0.15); + --background-utility-pressed: var(--base-transparent-white-20); + --background-utility-selected: rgba(255, 255, 255, 0.25); + --background-utility-disabled: var(--neutral-700); + /* Border */ --border-utility-disabled: var(--neutral-700); + --border-utility-focused: var(--brand-150); + --border-utility-focus: rgba(120, 168, 255, 0.25); + --border-utility-active: var(--accent-primary); + --border-utility-hover: var(--base-transparent-white-10); + --border-utility-pressed: var(--base-transparent-white-20); + --border-utility-selected: rgba(255, 255, 255, 0.15); + --border-utility-error: var(--accent-error); + --border-utility-warning: var(--accent-warning); + --border-utility-success: var(--accent-success); --border-core-default: var(--neutral-600); --border-core-subtle: var(--neutral-800); --border-core-strong: var(--neutral-400); - --border-core-on-dark: var(--neutral-900); --border-core-on-surface: var(--neutral-500); --border-core-inverse: var(--neutral-900); - --chat-reply-indicator-incoming: var(--neutral-500); - --chat-poll-progress-fill-outgoing: var(--base-white); + --border-core-on-accent: var(--base-black); + --border-core-opacity-subtle: var(--base-transparent-white-20); + --border-core-opacity-strong: rgba(255, 255, 255, 0.25); + /* System */ + --system-bg-blur: rgba(0, 0, 0, 0.01); + --system-scrollbar: rgba(255, 255, 255, 0.5); --system-text: var(--base-white); + /* Text */ --text-primary: var(--base-white); --text-secondary: var(--neutral-100); --text-tertiary: var(--neutral-300); --text-inverse: var(--neutral-900); + --text-on-accent: var(--base-white); --text-disabled: var(--neutral-500); - --text-on-dark: var(--neutral-900); + --text-link: var(--brand-600); + /* Avatar */ --avatar-palette-bg-1: var(--blue-600); --avatar-palette-bg-2: var(--cyan-600); --avatar-palette-bg-3: var(--green-600); @@ -657,10 +674,12 @@ --avatar-palette-text-5: var(--yellow-100); --avatar-bg-placeholder: var(--neutral-700); --avatar-text-placeholder: var(--neutral-400); + /* Accent & brand */ --accent-success: var(--green-300); --accent-warning: var(--yellow-300); --accent-error: var(--red-400); --accent-neutral: var(--neutral-500); + --accent-primary: var(--brand-400); --brand-50: var(--blue-900); --brand-100: var(--blue-800); --brand-150: var(--blue-700); @@ -671,11 +690,125 @@ --brand-700: var(--blue-150); --brand-800: var(--blue-100); --brand-900: var(--blue-50); + /* Skeleton */ + --skeleton-loading-base: var(--base-transparent-0); --skeleton-loading-highlight: var(--base-transparent-white-20); - --button-primary-border-on-dark: var(--text-inverse); + /* Chrome (dark semantic scale) */ + --chrome-0: var(--base-black); + --chrome-50: var(--neutral-900); + --chrome-100: var(--neutral-800); + --chrome-150: var(--neutral-700); + --chrome-200: var(--neutral-600); + --chrome-300: var(--neutral-500); + --chrome-400: var(--neutral-400); + --chrome-500: var(--neutral-300); + --chrome-600: var(--neutral-200); + --chrome-700: var(--neutral-150); + --chrome-800: var(--neutral-100); + --chrome-900: var(--neutral-50); + --chrome-1000: var(--base-white); + /* Button (dark mode – aligned with Figma / design-system-tokens dark semantics) */ + --button-primary-bg: var(--accent-primary); + --button-primary-bg-liquid-glass: var(--base-transparent-0); + --button-primary-text: var(--text-link); + --button-primary-text-on-accent: var(--text-on-accent); + --button-primary-text-inverse: var(--text-inverse); + --button-primary-border: var(--brand-200); + --button-primary-border-inverse: var(--text-inverse); + --button-secondary-bg: var(--background-core-surface); + --button-secondary-bg-liquid-glass: var(--background-core-elevation-0); + --button-secondary-text: var(--text-primary); + --button-secondary-text-on-accent: var(--text-primary); + --button-secondary-text-inverse: var(--text-inverse); + --button-secondary-border: var(--border-core-default); + --button-secondary-border-inverse: var(--border-core-inverse); + --button-destructive-bg: var(--accent-error); + --button-destructive-bg-liquid-glass: var(--background-core-elevation-0); + --button-destructive-text: var(--accent-error); + --button-destructive-text-on-accent: var(--text-on-accent); + --button-destructive-text-inverse: var(--text-inverse); + --button-destructive-border: var(--accent-error); + --button-destructive-border-inverse: var(--text-inverse); + /* Chat (semantic overrides so --chat-bg-* etc resolve in dark) */ + --chat-bg-incoming: var(--background-core-surface); + --chat-bg-outgoing: var(--brand-100); + --chat-bg-attachment-incoming: var(--background-core-surface-strong); + --chat-bg-attachment-outgoing: var(--brand-150); + --chat-reply-indicator-incoming: var(--neutral-500); --chat-reply-indicator-outgoing: var(--brand-700); - --input-option-card-bg: var(--background-core-surface); + --chat-poll-progress-fill-outgoing: var(--base-white); + --chat-text-username: var(--text-secondary); + --chat-text-timestamp: var(--text-tertiary); + --chat-text-read: var(--accent-primary); + --chat-text-mention: var(--text-link); + --chat-text-link: var(--text-link); + --chat-text-reaction: var(--text-secondary); + --chat-text-system: var(--text-secondary); + --chat-border-incoming: var(--border-core-subtle); + --chat-border-on-chat-incoming: var(--border-core-strong); + --chat-waveform-bar: var(--border-core-opacity-strong); + --chat-waveform-bar-playing: var(--accent-primary); + --chat-thread-connector-incoming: var(--border-core-default); + --chat-text-typing-indicator: var(--chat-text-incoming); + --chat-poll-progress-track-incoming: var(--control-progress-bar-track); + --chat-poll-progress-fill-incoming: var(--control-progress-bar-fill); + /* Input */ + --input-text-default: var(--text-primary); + --input-text-placeholder: var(--text-tertiary); + --input-text-icon: var(--text-tertiary); + --input-text-disabled: var(--text-disabled); + --input-send-icon: var(--accent-primary); + --input-send-icon-disabled: var(--text-disabled); + /* Reaction */ + --reaction-bg: var(--background-core-elevation-3); + --reaction-border: var(--border-core-default); + --reaction-text: var(--text-primary); + --reaction-emoji: var(--text-primary); + /* Presence */ + --presence-border: var(--border-core-inverse); + --presence-bg-offline: var(--accent-neutral); + /* System */ + --system-caret: var(--accent-primary); + /* Badge (-on-dark in tokens = *-inverse in react) */ + --badge-bg-overlay: rgba(0, 0, 0, 0.75); + --badge-bg-default: var(--background-core-elevation-3); + --badge-bg-primary: var(--accent-primary); + --badge-bg-neutral: var(--accent-neutral); + --badge-text: var(--text-primary); + --badge-text-on-accent: var(--text-on-accent); + --badge-border: var(--border-core-inverse); + --badge-bg-inverse: var(--chrome-1000); + --badge-text-inverse: var(--text-inverse); + /* Control */ + --control-checkbox-bg: var(--base-transparent-0); + --control-playback-thumb-border-default: var(--border-core-opacity-strong); + --control-radio-button-bg: var(--base-transparent-0); + --control-radio-check-bg: var(--base-transparent-0); + --control-remove-control-bg: var(--background-core-inverse); + --control-remove-control-icon: var(--text-inverse); + --control-remove-control-border: var(--border-core-inverse); + --control-progress-bar-track: var(--background-core-surface-strong); + --control-progress-bar-fill: var(--accent-neutral); + --control-toggle-switch-bg: var(--accent-neutral); + --control-toggle-switch-bg-disabled: var(--background-utility-disabled); + --control-toggle-switch-knob: var(--background-core-elevation-4); + --control-playback-toggle-text: var(--text-primary); + --control-playback-toggle-border: var(--border-core-default); + --control-checkbox-border: var(--border-core-default); + --control-checkbox-bg-selected: var(--accent-primary); + --control-checkbox-icon: var(--text-on-accent); + --control-play-button-bg: var(--chrome-1000); + --control-play-button-icon: var(--text-on-accent); --control-playback-thumb-bg-default: var(--background-core-inverse); - --accent-primary: var(--brand-400); - --chip-bg: var(--brand-200); -} + --control-playback-thumb-border-active: var(--border-core-on-accent); + --control-playback-thumb-bg-active: var(--accent-primary); + --control-radio-button-border: var(--border-core-default); + --control-radio-button-bg-selected: var(--accent-primary); + --control-radio-button-indicator: var(--text-on-accent); + --control-radio-check-border: var(--border-core-default); + --control-radio-check-bg-selected: var(--accent-primary); + --control-radio-check-icon: var(--text-on-accent); + /* Avatar */ + --avatar-presence-bg-offline: var(--accent-neutral); + --avatar-presence-border: var(--border-core-inverse); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 26113f43f8..6c559cf6ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11919,10 +11919,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.37.0: - version "9.37.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.37.0.tgz#6a791197114fc4d30379740659a9f6efcc8f1794" - integrity sha512-AeFgc5jp2DvZtLwv+VIZ7qbPGaZL79eS15Vd5RJPDhLNov93NmXu+Nvdl6lE2qYdRw1Fur/2dB+WA015hj/l6A== +stream-chat@^9.38.0: + version "9.38.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.38.0.tgz#5c13eb8bbc2fa4adb774687b0c9c51f129d1b458" + integrity sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"