From 8f407d0217068f84b181b6dc147d185600dda2be Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 21 Jan 2026 21:45:19 +0100 Subject: [PATCH 01/11] feat: add new CSS variables --- src/styling/variables.css | 213 +++++++++++++++++++++++++------------- 1 file changed, 143 insertions(+), 70 deletions(-) diff --git a/src/styling/variables.css b/src/styling/variables.css index d98c7e512..9abf8239e 100644 --- a/src/styling/variables.css +++ b/src/styling/variables.css @@ -1,12 +1,17 @@ /** - Populate with px unit all the variables in @stream-chat-react/src/styling/variables.css - where it makes sense according to the variable name. This file has be copied in from another repository. - If a value is 0, do not append px. Line height is also unit-less. + * Do not edit directly, this file was auto-generated. */ + .str-chat { - --base-transparent: rgba(255, 255, 255, 0); --base-black: #000000; --base-white: #ffffff; + --base-transparent-0: rgba(255, 255, 255, 0); + --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-white-70: rgba(255, 255, 255, 0.7); + --base-transparent-white-10: rgba(255, 255, 255, 0.1); + --base-transparent-white-20: rgba(255, 255, 255, 0.2); + --base-transparent-black-70: rgba(0, 0, 0, 0.7); /** Used for bg in closeButton */ --slate-50: #fafbfc; --slate-100: #f2f4f6; --slate-200: #e2e6ea; @@ -143,7 +148,7 @@ --w150: 1.5; --w200: 2; --w300: 3; - --w400: 400; + --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. */ @@ -170,33 +175,43 @@ --font-size-size-48: 48px; --font-size-size-13: 13px; --font-size-size-8: 8px; - --line-height-line-height-10: 10; - --line-height-line-height-12: 12; - --line-height-line-height-14: 14; - --line-height-line-height-15: 15; - --line-height-line-height-16: 16; - --line-height-line-height-17: 17; - --line-height-line-height-18: 18; - --line-height-line-height-20: 20; - --line-height-line-height-24: 24; - --line-height-line-height-28: 28; - --line-height-line-height-32: 32; - --line-height-line-height-40: 40; - --line-height-line-height-48: 48; + --line-height-line-height-8: 8px; + --line-height-line-height-10: 10px; + --line-height-line-height-12: 12px; + --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-weight-regular: 400; + --typography-font-weight-medium: 500; + --typography-font-weight-semi-bold: 600; + --typography-font-weight-bold: 700; --light-elevation-0: 0 0 0 0 rgba(0,0,0,0); /** Base elevation level. */ - --light-elevation-1: 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 2px 4px 0 rgba(0,0,0,0.12), 0 6px 16px 0 rgba(0,0,0,0.06); - --light-elevation-3: 0 4px 8px 0 rgba(0,0,0,0.14), 0 12px 24px 0 rgba(0,0,0,0.1); - --light-elevation-4: 0 6px 12px 0 rgba(0,0,0,0.16), 0 20px 32px 0 rgba(0,0,0,0.12); + --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-0: 0 0 0 0 rgba(0,0,0,0); - --dark-elevation-1: 0 1px 2px 0 rgba(0,0,0,0.2), 0 4px 8px 0 rgba(0,0,0,0.1); - --dark-elevation-2: 0 2px 4px 0 rgba(0,0,0,0.22), 0 6px 16px 0 rgba(0,0,0,0.12); - --dark-elevation-3: 0 4px 8px 0 rgba(0,0,0,0.24), 0 12px 24px 0 rgba(0,0,0,0.14); - --dark-elevation-4: 0 6px 12px 0 rgba(0,0,0,0.28), inset 0 20px 32px 0 rgba(0,0,0,0.16); - --w500: 500; - --w600: 600; - --w700: 700; - --device-radius: 0.5rem; + --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-padding-y-lg: 14px; + --button-padding-y-md: 10px; + --button-padding-y-sm: 6px; + --button-padding-x-icon-only-lg: 14px; + --button-padding-x-icon-only-md: 10px; + --button-padding-x-icon-only-sm: 6px; + --button-padding-x-with-label-lg: 16px; + --button-padding-x-with-label-md: 16px; + --button-padding-x-with-label-sm: 16px; --state-hover: rgba(0, 0, 0, 0.05); /** Hover feedback overlay. */ --state-pressed: rgba(0, 0, 0, 0.1); /** Pressed feedback overlay. */ --state-selected: rgba(0, 0, 0, 0.1); /** Selected overlay. */ @@ -206,6 +221,10 @@ --system-bg-blur: rgba(255, 255, 255, 0.01); --system-scrollbar: rgba(0, 0, 0, 0.5); --background-core-overlay: rgba(0, 0, 0, 0.1); /** Dimmed overlay for modals. */ + --color-state-hover: rgba(30, 37, 43, 0.05); /** Hover feedback overlay. */ + --color-state-pressed: rgba(30, 37, 43, 0.1); /** Pressed feedback overlay. */ + --color-state-selected: rgba(30, 37, 43, 0.1); /** Selected overlay. */ + --color-state-bg-overlay: rgba(30, 37, 43, 0.5); --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. */ @@ -239,6 +258,7 @@ --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); @@ -248,19 +268,37 @@ --button-visual-height-sm: var(--size-32); --button-visual-height-md: var(--size-40); --button-visual-height-lg: var(--size-48); - --button-hit-target-min-height: var(--size-48); /* manually adjusted */ - --button-hit-target-min-width: var(--size-48); /* manually adjusted */ - --button-type-secondary-bg: var(--base-transparent); + /** + * 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: var(--size-48); + /** + * 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: var(--size-48); + --button-type-secondary-bg: var(--base-transparent-0); --button-type-destructive-text: var(--base-white); - --button-style-ghost-bg: var(--base-transparent); - --button-style-ghost-border: var(--base-transparent); - --button-style-outline-bg: var(--base-transparent); + --button-style-ghost-bg: var(--base-transparent-0); + --button-style-ghost-border: var(--base-transparent-0); + --button-style-outline-bg: var(--base-transparent-0); --button-style-outline-border-on-chat-outgoing: var(--blue-300); --button-style-liquid-glass-text-primary: var(--base-white); --button-style-liquid-glass-text-destructive: var(--base-white); --button-style-liquid-glass-bg-secondary: var(--base-white); - --button-style-liquid-glass-bg-primary: var(--base-transparent); - --button-style-liquid-glass-bg-destructive: var(--base-transparent); + --button-style-liquid-glass-bg-primary: var(--base-transparent-0); + --button-style-liquid-glass-bg-destructive: var(--base-transparent-0); + --button-secondary-bg: var(--base-transparent-0); + --button-destructive-text: var(--base-white); --icon-size-xs: var(--size-12); --icon-size-sm: var(--size-16); --icon-size-md: var(--size-20); @@ -275,7 +313,6 @@ --accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ --state-text-disabled: var(--slate-400); /** Disabled text and icon color. Matches foundation disabled colors. */ --state-bg-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */ - --border-utility-focus: var(--blue-300); /** Focus ring or focus border. */ --border-utility-border: var(--slate-100); /** Disabled state border. */ --border-utility-error: var(--red-500); /** Error state. */ --border-utility-warning: var(--yellow-500); /** Warning borders. */ @@ -288,21 +325,18 @@ --border-core-primary: var(--blue-600); /** Selected or active state border. */ --border-core-subtle: var(--slate-100); /** Light outlines. */ --chat-bg-incoming: var(--slate-100); /** Incoming bubble background. */ - --chat-bg-outgoing: var(--blue-100); /** Outgoing bubble background. */ --chat-bg-attachment-incoming: var(--slate-200); /** Attachment card in incoming bubble. */ - --chat-bg-attachment-outgoing: var(--blue-200); /** Attachment card in outgoing bubble. */ --chat-bg-typing-indicator: var(--base-black); /** Typing indicator chip. */ - --chat-border-outgoing: var(--base-transparent); - --chat-border-incoming: var(--base-transparent); + --chat-border-outgoing: var(--base-transparent-0); + --chat-border-incoming: var(--base-transparent-0); + --chat-border-on-chat-incoming: var(--slate-400); --chat-reply-indicator-incoming: var(--slate-400); /** Reply indicator shading for incoming. */ - --chat-reply-indicator-outgoing: var(--blue-400); /** Reply indicator shading for outgoing. */ --chat-waveform-bar: var(--border-core-opacity-25); --chat-poll-progress-track-incoming: var(--slate-600); --chat-poll-progress-fill-incoming: var(--slate-300); - --chat-poll-progress-fill-outgoing: var(--blue-200); --chat-thread-connector-incoming: var(--slate-200); - --input-bg-default: var(--base-transparent); /** Background of the chat input field. Slightly elevated over the app background in light and dark, solid white in high-contrast. */ - --input-bg-hover: var(--state-hover); /** Hover state for the input surface. Implemented as a hover overlay on top of chat-input-bg, not as a separate base color. No overlay in high-contrast. */ + --input-bg-default: var(--base-transparent-0); /** Background of the chat input field. Slightly elevated over the app background in light and dark, solid white in high-contrast. */ + --input-bg-hover: var(--color-state-hover); /** Hover state for the input surface. Implemented as a hover overlay on top of chat-input-bg, not as a separate base color. No overlay in high-contrast. */ --input-text-default: var(--slate-900); /** Main text inside the chat input. */ --input-text-placeholder: var(--slate-600); /** Placeholder text for the input. Lower emphasis than main text. */ --input-text-icon: var(--slate-700); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ @@ -313,12 +347,14 @@ --badge-text: var(--base-white); --badge-text-inverse: var(--slate-900); --badge-bg-inverse: var(--base-white); - --control-radiocheck-bg: var(--base-transparent); - --control-radiocheck-bg-disabled: var(--base-transparent); + --control-radiocheck-bg: var(--base-transparent-0); + --control-radiocheck-bg-disabled: var(--base-transparent-0); --control-remove-bg: var(--slate-900); --control-remove-icon: var(--base-white); --control-progress-bar-track: var(--slate-500); --control-progress-bar-fill: var(--slate-100); + --control-remove-control-bg: var(--slate-900); + --control-remove-control-icon: var(--base-white); --background-core-app: var(--base-white); /** Global application background. */ --background-core-surface: var(--slate-50); /** Surface background for cards, panels. */ --background-core-surface-subtle: var(--slate-100); /** Subtle section background. */ @@ -333,7 +369,6 @@ --text-tertiary: var(--slate-600); /** Lowest priority text. */ --text-inverse: var(--base-white); /** Text on dark or accent backgrounds. */ --text-disabled: var(--slate-400); /** Disabled text. */ - --text-link: var(--blue-500); /** Hyperlinks and inline actions. */ --text-on-accent: var(--base-white); /** Text on dark or accent backgrounds. */ --avatar-palette-bg-1: var(--blue-100); --avatar-palette-bg-2: var(--cyan-100); @@ -345,6 +380,24 @@ --avatar-palette-text-3: var(--green-800); --avatar-palette-text-4: var(--purple-800); --avatar-palette-text-5: var(--yellow-800); + --color-accent-success: var(--green-500); /** For success states and positive actions. */ + --color-accent-warning: var(--yellow-500); /** Warning or caution messages. */ + --color-accent-error: var(--red-500); /** Destructive actions and error states. */ + --color-accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ + --color-state-text-disabled: var(--slate-400); /** Disabled text and icon color. Matches foundation disabled colors. */ + --color-state-bg-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */ + --color-brand-50: var(--blue-50); + --color-brand-100: var(--blue-100); + --color-brand-200: var(--blue-200); + --color-brand-300: var(--blue-300); + --color-brand-400: var(--blue-400); + --color-brand-500: var(--blue-500); + --color-brand-600: var(--blue-600); + --color-brand-700: var(--blue-700); + --color-brand-800: var(--blue-800); + --color-brand-900: var(--blue-900); + --color-brand-950: var(--blue-950); + --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); @@ -362,47 +415,67 @@ --button-type-destructive-bg: var(--accent-error); --button-type-destructive-text-inverse: var(--accent-error); --button-type-destructive-border: var(--accent-error); - --button-style-ghost-text-primary: var(--accent-primary); --button-style-ghost-text-secondary: var(--text-primary); --button-style-outline-border: var(--border-core-surface-subtle); --button-style-outline-text: var(--text-primary); --button-style-outline-border-on-chat-incoming: var(--border-core-surface); --button-style-liquid-glass-text-secondary: var(--text-primary); - --border-utility-selected: var(--accent-primary); /** Focus ring or focus border. */ + --button-primary-text: var(--text-on-accent); + --button-primary-border: var(--color-brand-600); + --button-secondary-text: var(--text-primary); + --button-secondary-border: var(--border-core-surface-subtle); + --button-destructive-bg: var(--color-accent-error); + --button-destructive-border: var(--color-accent-error); + --button-destructive-text-inverse: var(--color-accent-error); + --border-utility-focus: var(--color-brand-300); /** Focus ring or focus border. */ + --border-utility-selected: var(--color-brand-500); /** Focus ring or focus border. */ + --chat-bg-outgoing: var(--color-brand-100); /** Outgoing bubble background. */ + --chat-bg-attachment-outgoing: var(--color-brand-200); /** Attachment card in outgoing bubble. */ --chat-text-message: var(--text-primary); /** Message body text. */ --chat-text-timestamp: var(--text-tertiary); /** Time labels. */ --chat-text-username: var(--text-secondary); /** Username label. */ - --chat-text-mention: var(--text-link); /** Mention styling. */ - --chat-text-link: var(--text-link); /** Links inside message bubbles. */ --chat-text-reaction: var(--text-secondary); /** Reaction count text. */ --chat-text-system: var(--text-secondary); /** System messages like date separators. */ - --chat-waveform-bar-playing: var(--accent-primary); - --chat-poll-progress-track-outgoing: var(--accent-primary); - --chat-thread-connector-outgoing: var(--chat-bg-outgoing); - --input-bg-bg-disabled: var(--state-bg-disabled); /** Disabled input background using your shared disabled surface. White in high-contrast, with the disabled border and text carrying most of the signal. */ + --chat-border-on-chat-outgoing: var(--color-brand-300); + --chat-reply-indicator-outgoing: var(--color-brand-400); /** Reply indicator shading for outgoing. */ + --chat-poll-progress-fill-outgoing: var(--color-brand-200); + --input-bg-bg-disabled: var(--color-state-bg-disabled); /** Disabled input background using your shared disabled surface. White in high-contrast, with the disabled border and text carrying most of the signal. */ --input-border-default: var(--border-core-surface-subtle); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */ --input-border-hover: var(--border-core-surface); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */ - --input-border-focus: var(--border-utility-focus); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ --input-border-border-disabled: var(--border-core-subtle); /** Disabled input border. Low contrast in normal modes, black outline in high-contrast. */ - --input-send-icon: var(--accent-primary); /** Default send icon color in the input. Uses the brand accent. */ - --input-send-icon-disabled: var(--state-text-disabled); /** Send icon when disabled (e.g. empty input). */ + --input-send-icon-disabled: var(--color-state-text-disabled); /** Send icon when disabled (e.g. empty input). */ --reaction-bg: var(--background-elevation-elevation-1); /** Reaction bar background. */ --reaction-border: var(--border-core-surface-subtle); /** 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-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-bg-primary: var(--accent-primary); - --badge-bg-error: var(--accent-error); - --badge-bg-neutral: var(--accent-neutral); + --presence-bg-online: var(--color-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-bg-offline: var(--color-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-bg-error: var(--color-accent-error); + --badge-bg-neutral: var(--color-accent-neutral); --control-radiocheck-border: var(--border-core-surface-subtle); - --control-radiocheck-bg-selected: var(--accent-primary); - --control-radiocheck-border-selected: var(--border-core-primary); --control-radiocheck-icon-selected: var(--text-inverse); --control-radiocheck-border-disabled: var(--border-utility-border); - --control-radiocheck-bg-selected-disabled: var(--state-bg-disabled); - --control-radiocheck-icon-selected-disabled: var(--state-text-disabled); + --control-radiocheck-bg-selected-disabled: var(--color-state-bg-disabled); + --control-radiocheck-icon-selected-disabled: var(--color-state-text-disabled); --control-remove-border: var(--border-core-on-dark); + --control-remove-control-border: var(--border-core-on-dark); + --control-play-control-bg: var(--background-elevation-elevation-1); + --control-play-control-icon: var(--text-primary); + --control-play-control-border: var(--border-core-surface-subtle); --avatar-bg-default: var(--avatar-palette-bg-1); --avatar-text-default: var(--avatar-palette-text-1); + --color-accent-primary: var(--color-brand-500); /** Main brand accent for interactive elements. */ + --button-style-ghost-text-primary: var(--color-accent-primary); + --button-primary-bg: var(--color-accent-primary); + --chat-waveform-bar-playing: var(--color-accent-primary); + --chat-poll-progress-track-outgoing: var(--color-accent-primary); + --chat-thread-connector-outgoing: var(--chat-bg-outgoing); + --input-border-focus: var(--border-utility-focus); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ + --input-send-icon: var(--color-accent-primary); /** Default send icon color in the input. Uses the brand accent. */ + --badge-bg-primary: var(--color-accent-primary); + --control-radiocheck-bg-selected: var(--color-accent-primary); + --control-radiocheck-border-selected: var(--border-utility-selected); + --text-link: var(--color-accent-primary); /** Hyperlinks and inline actions. */ + --chat-text-mention: var(--text-link); /** Mention styling. */ + --chat-text-link: var(--text-link); /** Links inside message bubbles. */ } From 5e4846b42c438d2bc45808fa060c53cd8ed77baa Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Jan 2026 10:11:14 +0100 Subject: [PATCH 02/11] feat: reflect latest design updates in Button.scss --- src/components/Button/styling/Button.scss | 120 ++++++++++++---------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index 4c4f8a7b8..2bda3152b 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -12,100 +12,71 @@ align-items: center; justify-content: center; gap: var(--spacing-xs); - //padding-inline: var(--spacing-md); - //padding-block: var(--spacing-sm); - /* - min-width / min-height on buttons: - - enforce a minimum tappable area - - follow accessibility & platform guidelines - - don’t affect layout unless the button would be too small - - are about usability, not design polish - */ - min-height: var(--button-visual-height-lg); - max-height: var(--button-visual-height-lg); line-height: var(--typography-line-height-normal); - border-radius: var(--button-radius-lg); - - font-weight: var(--font-weight-w600); + font-weight: var(--typography-font-weight-semibold); &.str-chat__button--solid { &.str-chat__button--primary { - background-color: var(--button-type-primary-bg); - color: var(--button-type-primary-text); - border-color: var(--button-type-primary-border); + background-color: var(--button-primary-bg); + color: var(--button-primary-text-on-accent); } &.str-chat__button--secondary { - background-color: var(--button-type-secondary-bg); - color: var(--button-type-secondary-text); - border-color: var(--button-type-secondary-border); + background-color: var(--button-secondary-bg); + color: var(--button-secondary-text-on-accent); } &.str-chat__button--destructive { - background-color: var(--button-type-destructive-bg); - color: var(--button-type-destructive-text); - border-color: var(--button-type-destructive-border); + background-color: var(--button-destructive-bg); + color: var(--button-destructive-text-on-accent); } &:disabled { - background-color: var(--state-bg-disabled); + background-color: var(--background-core-disabled); } } &.str-chat__button--ghost { - border-color: var(--button-style-ghost-border); - background-color: var(--button-style-ghost-bg); - &.str-chat__button--primary { - color: var(--button-style-ghost-text-primary); + color: var(--button-primary-text); } &.str-chat__button--secondary { - color: var(--button-style-ghost-text-secondary); + color: var(--button-secondary-text); } &.str-chat__button--destructive { - color: var(--button-type-destructive-text-inverse); + color: var(--button-destructive-text); } } + // todo: shouldn't we specify also background color? &.str-chat__button--outline { - // todo: designs not available - //&.str-chat__button--primary { - // background-color: var(--button-type-primary-bg); - // color: var(--button-type-primary-text); - // border-color: var(--button-type-primary-border); - //} + &.str-chat__button--primary { + color: var(--button-primary-text); + border-color: var(--button-primary-border); + } &.str-chat__button--secondary { - background-color: var(--button-style-outline-bg); - color: var(--button-style-outline-text); - border-color: var(--button-style-outline-border); + color: var(--button-secondary-text); + border-color: var(--button-secondary-border); } - // todo: designs not available &.str-chat__button--destructive { - //background-color: var(--button-type-destructive-bg); - color: var(--button-type-destructive-text-inverse); - //border-color: var(--button-type-destructive-border); + color: var(--button-destructive-text); + border-color: var(--button-destructive-border); } } - &.str-chat__button--solid, - &.str-chat__button--ghost { - &:disabled { - border: none; - } + &.str-chat__button--outline { + border-width: 1px; + border-style: solid; } &.str-chat__button--solid, &.str-chat__button--outline, &.str-chat__button--ghost { - border-width: 1px; - border-style: solid; - - &:not(:disabled):hover { @include utils.overlay-after(0.05); } @@ -115,17 +86,56 @@ } &:not(:disabled):focus-visible { // focused - outline: 2px solid var(--border-utility-focus); + @include utils.focusable; } &:disabled { - color: var(--state-text-disabled); + color: var(--text-disabled); cursor: default; } } + &.str-chat__button--outline:disabled { + border-color: var(--border-utility-disabled); + } + &.str-chat__button--floating { - box-shadow: var(--light-elevation-3); + box-shadow: var(--light-elevation-2); + + } + + &.str-chat__button--size-lg { + padding-block: var(--button-padding-y-lg); + padding-inline: var(--button-padding-x-with-label-lg); + border-radius: var(--button-radius-lg); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-lg); + } + } + + &.str-chat__button--size-md { + padding-block: var(--button-padding-y-md); + padding-inline: var(--button-padding-x-with-label-md); + border-radius: var(--button-radius-md); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-md); + } + } + + &.str-chat__button--size-sm { + padding-block: var(--button-padding-y-sm); + padding-inline: var(--button-padding-x-with-label-sm); + border-radius: var(--button-radius-md); + + &.str-chat__button--circular { + padding-inline: var(--button-padding-x-icon-only-sm); + } + } + + &.str-chat__button--circular { + border-radius: var(--button-radius-full); } } } From 8811160186299a97d1788cbf13baf91a42235a09 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Jan 2026 10:11:36 +0100 Subject: [PATCH 03/11] feat: reflect latest design updates in variables.css --- src/styling/variables.css | 405 ++++++++++++++++++-------------------- 1 file changed, 195 insertions(+), 210 deletions(-) diff --git a/src/styling/variables.css b/src/styling/variables.css index 9abf8239e..4d357e4ae 100644 --- a/src/styling/variables.css +++ b/src/styling/variables.css @@ -3,8 +3,6 @@ */ .str-chat { - --base-black: #000000; - --base-white: #ffffff; --base-transparent-0: rgba(255, 255, 255, 0); --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 */ @@ -12,94 +10,118 @@ --base-transparent-white-10: rgba(255, 255, 255, 0.1); --base-transparent-white-20: rgba(255, 255, 255, 0.2); --base-transparent-black-70: rgba(0, 0, 0, 0.7); /** Used for bg in closeButton */ - --slate-50: #fafbfc; - --slate-100: #f2f4f6; - --slate-200: #e2e6ea; - --slate-300: #d0d5da; - --slate-400: #b8bec4; - --slate-500: #9ea4aa; - --slate-600: #838990; - --slate-700: #6a7077; - --slate-800: #50565d; - --slate-900: #384047; - --slate-950: #1e252b; - --neutral-50: #f7f7f7; - --neutral-100: #ededed; - --neutral-200: #d9d9d9; - --neutral-300: #c1c1c1; - --neutral-400: #a3a3a3; - --neutral-500: #7f7f7f; - --neutral-600: #636363; - --neutral-700: #4a4a4a; - --neutral-800: #383838; - --neutral-900: #262626; - --neutral-950: #151515; - --blue-50: #ebf3ff; - --blue-100: #d2e3ff; - --blue-200: #a6c4ff; - --blue-300: #7aa7ff; - --blue-400: #4e8bff; + --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: #0052ce; - --blue-700: #0042a3; - --blue-800: #003179; - --blue-900: #001f4f; - --blue-950: #001025; - --cyan-50: #f0fcfe; - --cyan-100: #d7f7fb; - --cyan-200: #bdf1f8; - --cyan-300: #a3ecf4; - --cyan-400: #89e6f1; - --cyan-500: #69e5f6; - --cyan-600: #3ec9d9; - --cyan-700: #28a8b5; - --cyan-800: #1c8791; - --cyan-900: #125f66; - --cyan-950: #0b3d44; - --green-50: #e8fff5; - --green-100: #c9fce7; - --green-200: #a9f8d9; - --green-300: #88f2ca; - --green-400: #59e9b5; - --green-500: #00e2a1; - --green-600: #00b681; - --green-700: #008d64; - --green-800: #006548; - --green-900: #003d2b; - --green-950: #002319; - --purple-50: #f5effe; - --purple-100: #ebdefd; - --purple-200: #d8bffc; - --purple-300: #c79ffc; - --purple-400: #b98af9; - --purple-500: #b38af8; - --purple-600: #996ce3; - --purple-700: #7f55c7; - --purple-800: #6640ab; - --purple-900: #4d2c8f; - --purple-950: #351c6b; - --yellow-50: #fff9e5; - --yellow-100: #fff1c2; - --yellow-200: #ffe8a0; - --yellow-300: #ffde7d; - --yellow-400: #ffd65a; - --yellow-500: #ffd233; - --yellow-600: #e6b400; - --yellow-700: #c59600; - --yellow-800: #9f7700; - --yellow-900: #7a5a00; - --yellow-950: #4f3900; - --red-50: #fcebea; - --red-100: #f8cfcd; - --red-200: #f3b3b0; - --red-300: #ed958f; - --red-400: #e6756c; - --red-500: #d92f26; - --red-600: #b9261f; - --red-700: #98201a; - --red-800: #761915; - --red-900: #54120f; - --red-950: #360b09; + --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; @@ -212,19 +234,15 @@ --button-padding-x-with-label-lg: 16px; --button-padding-x-with-label-md: 16px; --button-padding-x-with-label-sm: 16px; - --state-hover: rgba(0, 0, 0, 0.05); /** Hover feedback overlay. */ - --state-pressed: rgba(0, 0, 0, 0.1); /** Pressed feedback overlay. */ - --state-selected: rgba(0, 0, 0, 0.1); /** Selected overlay. */ - --state-bg-overlay: rgba(0, 0, 0, 0.5); - --border-core-image: rgba(0, 0, 0, 0.1); /** Image frame border treatment. */ - --border-core-opacity-25: rgba(0, 0, 0, 0.25); /** Utility border for overlays. */ + --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. */ + --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. */ --system-bg-blur: rgba(255, 255, 255, 0.01); --system-scrollbar: rgba(0, 0, 0, 0.5); - --background-core-overlay: rgba(0, 0, 0, 0.1); /** Dimmed overlay for modals. */ - --color-state-hover: rgba(30, 37, 43, 0.05); /** Hover feedback overlay. */ - --color-state-pressed: rgba(30, 37, 43, 0.1); /** Pressed feedback overlay. */ - --color-state-selected: rgba(30, 37, 43, 0.1); /** Selected overlay. */ - --color-state-bg-overlay: rgba(30, 37, 43, 0.5); --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. */ @@ -286,19 +304,7 @@ * Note: Web uses a placeholder value in Figma due to variable mode constraints. */ --button-hit-target-min-width: var(--size-48); - --button-type-secondary-bg: var(--base-transparent-0); - --button-type-destructive-text: var(--base-white); - --button-style-ghost-bg: var(--base-transparent-0); - --button-style-ghost-border: var(--base-transparent-0); - --button-style-outline-bg: var(--base-transparent-0); - --button-style-outline-border-on-chat-outgoing: var(--blue-300); - --button-style-liquid-glass-text-primary: var(--base-white); - --button-style-liquid-glass-text-destructive: var(--base-white); - --button-style-liquid-glass-bg-secondary: var(--base-white); - --button-style-liquid-glass-bg-primary: var(--base-transparent-0); - --button-style-liquid-glass-bg-destructive: var(--base-transparent-0); - --button-secondary-bg: var(--base-transparent-0); - --button-destructive-text: var(--base-white); + --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); @@ -306,27 +312,39 @@ --icon-stroke-subtle: var(--w120); --icon-stroke-default: var(--w150); --icon-stroke-emphasis: var(--w200); - --accent-primary: var(--blue-500); /** Main brand accent for interactive elements. */ - --accent-success: var(--green-500); /** For success states and positive actions. */ - --accent-warning: var(--yellow-500); /** Warning or caution messages. */ - --accent-error: var(--red-500); /** Destructive actions and error states. */ - --accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ - --state-text-disabled: var(--slate-400); /** Disabled text and icon color. Matches foundation disabled colors. */ - --state-bg-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */ - --border-utility-border: var(--slate-100); /** Disabled state border. */ - --border-utility-error: var(--red-500); /** Error state. */ - --border-utility-warning: var(--yellow-500); /** Warning borders. */ - --border-utility-success: var(--green-500); /** Success borders. */ - --border-core-surface: var(--slate-400); /** Standard surface border. */ - --border-core-surface-subtle: var(--slate-200); /** Very light separators. */ - --border-core-surface-strong: var(--slate-600); /** Stronger surface border. */ + --color-accent-success: var(--green-300); /** For success states and positive actions. */ + --color-accent-warning: var(--yellow-400); /** Warning or caution messages. */ + --color-accent-error: var(--red-500); /** Destructive actions and error states. */ + --color-accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ + --color-accent-black: var(--base-black); /** Neutral accent for low-priority badges. */ + --color-brand-50: var(--blue-50); + --color-brand-100: var(--blue-100); + --color-brand-150: var(--blue-150); + --color-brand-200: var(--blue-200); + --color-brand-300: var(--blue-300); + --color-brand-400: var(--blue-400); + --color-brand-500: var(--blue-500); + --color-brand-600: var(--blue-600); + --color-brand-700: var(--blue-700); + --color-brand-800: var(--blue-800); + --color-brand-900: var(--blue-900); + --background-core-disabled: var(--slate-100); /** Optional disabled background for inputs, buttons, or chips. */ + --background-core-surface: var(--slate-200); /** Standard section background. */ + --background-core-surface-subtle: var(--slate-100); /** Very light section background. */ + --background-core-surface-strong: var(--slate-300); /** Stronger section background. */ + --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-200); /** 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-200); /** 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-primary: var(--blue-600); /** Selected or active state border. */ - --border-core-subtle: var(--slate-100); /** Light outlines. */ --chat-bg-incoming: var(--slate-100); /** Incoming bubble background. */ - --chat-bg-attachment-incoming: var(--slate-200); /** Attachment card in incoming bubble. */ - --chat-bg-typing-indicator: var(--base-black); /** Typing indicator chip. */ + --chat-bg-attachment-incoming: var(--slate-150); /** Attachment card in incoming bubble. */ --chat-border-outgoing: var(--base-transparent-0); --chat-border-incoming: var(--base-transparent-0); --chat-border-on-chat-incoming: var(--slate-400); @@ -335,35 +353,10 @@ --chat-poll-progress-track-incoming: var(--slate-600); --chat-poll-progress-fill-incoming: var(--slate-300); --chat-thread-connector-incoming: var(--slate-200); - --input-bg-default: var(--base-transparent-0); /** Background of the chat input field. Slightly elevated over the app background in light and dark, solid white in high-contrast. */ - --input-bg-hover: var(--color-state-hover); /** Hover state for the input surface. Implemented as a hover overlay on top of chat-input-bg, not as a separate base color. No overlay in high-contrast. */ - --input-text-default: var(--slate-900); /** Main text inside the chat input. */ - --input-text-placeholder: var(--slate-600); /** Placeholder text for the input. Lower emphasis than main text. */ - --input-text-icon: var(--slate-700); /** Icons inside the input area (attach, emoji, camera, send when idle). Matches secondary text strength. */ - --input-text-disabled: var(--slate-400); /** Placeholder text for the input. Lower emphasis than main text. */ - --presence-border: var(--base-white); /** The thin outline around the presence dot. Matches the local surface behind the avatar. In high-contrast it uses the base surface. */ --system-text: var(--base-black); - --badge-border: var(--base-white); - --badge-text: var(--base-white); - --badge-text-inverse: var(--slate-900); - --badge-bg-inverse: var(--base-white); --control-radiocheck-bg: var(--base-transparent-0); - --control-radiocheck-bg-disabled: var(--base-transparent-0); - --control-remove-bg: var(--slate-900); - --control-remove-icon: var(--base-white); --control-progress-bar-track: var(--slate-500); - --control-progress-bar-fill: var(--slate-100); - --control-remove-control-bg: var(--slate-900); - --control-remove-control-icon: var(--base-white); - --background-core-app: var(--base-white); /** Global application background. */ - --background-core-surface: var(--slate-50); /** Surface background for cards, panels. */ - --background-core-surface-subtle: var(--slate-100); /** Subtle section background. */ - --background-core-surface-strong: var(--slate-200); /** Higher contrast surface. */ - --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. */ + --control-progress-bar-fill: var(--slate-200); --text-primary: var(--slate-900); /** Main text color. */ --text-secondary: var(--slate-700); /** Secondary metadata text. */ --text-tertiary: var(--slate-600); /** Lowest priority text. */ @@ -373,30 +366,13 @@ --avatar-palette-bg-1: var(--blue-100); --avatar-palette-bg-2: var(--cyan-100); --avatar-palette-bg-3: var(--green-100); - --avatar-palette-bg-4: var(--purple-200); - --avatar-palette-bg-5: var(--yellow-200); + --avatar-palette-bg-4: var(--purple-100); + --avatar-palette-bg-5: var(--yellow-100); --avatar-palette-text-1: var(--blue-800); --avatar-palette-text-2: var(--cyan-800); --avatar-palette-text-3: var(--green-800); --avatar-palette-text-4: var(--purple-800); --avatar-palette-text-5: var(--yellow-800); - --color-accent-success: var(--green-500); /** For success states and positive actions. */ - --color-accent-warning: var(--yellow-500); /** Warning or caution messages. */ - --color-accent-error: var(--red-500); /** Destructive actions and error states. */ - --color-accent-neutral: var(--slate-500); /** Neutral accent for low-priority badges. */ - --color-state-text-disabled: var(--slate-400); /** Disabled text and icon color. Matches foundation disabled colors. */ - --color-state-bg-disabled: var(--slate-200); /** Optional disabled background for inputs, buttons, or chips. */ - --color-brand-50: var(--blue-50); - --color-brand-100: var(--blue-100); - --color-brand-200: var(--blue-200); - --color-brand-300: var(--blue-300); - --color-brand-400: var(--blue-400); - --color-brand-500: var(--blue-500); - --color-brand-600: var(--blue-600); - --color-brand-700: var(--blue-700); - --color-brand-800: var(--blue-800); - --color-brand-900: var(--blue-900); - --color-brand-950: var(--blue-950); --device-radius: var(--radius-md); --message-bubble-radius-group-top: var(--radius-2xl); --message-bubble-radius-group-middle: var(--radius-2xl); @@ -407,30 +383,27 @@ --composer-radius-fixed: var(--radius-3xl); --composer-radius-floating: var(--radius-3xl); --composer-bg: var(--background-elevation-elevation-1); /** Composer container background. */ - --button-type-primary-bg: var(--accent-primary); - --button-type-primary-text: var(--text-on-accent); - --button-type-primary-border: var(--border-core-primary); - --button-type-secondary-text: var(--text-primary); - --button-type-secondary-border: var(--border-core-surface-subtle); - --button-type-destructive-bg: var(--accent-error); - --button-type-destructive-text-inverse: var(--accent-error); - --button-type-destructive-border: var(--accent-error); - --button-style-ghost-text-secondary: var(--text-primary); - --button-style-outline-border: var(--border-core-surface-subtle); - --button-style-outline-text: var(--text-primary); - --button-style-outline-border-on-chat-incoming: var(--border-core-surface); - --button-style-liquid-glass-text-secondary: var(--text-primary); - --button-primary-text: var(--text-on-accent); - --button-primary-border: var(--color-brand-600); + --button-primary-text-on-accent: var(--text-on-accent); + --button-primary-border: var(--color-brand-200); --button-secondary-text: var(--text-primary); - --button-secondary-border: var(--border-core-surface-subtle); + --button-secondary-bg-liquid-glass: var(--background-elevation-elevation-0); + --button-secondary-border: var(--border-core-default); + --button-secondary-bg: var(--background-core-surface-subtle); + --button-secondary-text-on-accent: var(--text-primary); + --button-destructive-text: var(--color-accent-error); --button-destructive-bg: var(--color-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(--color-accent-error); - --button-destructive-text-inverse: var(--color-accent-error); + --color-accent-primary: var(--color-brand-500); /** Main brand accent for interactive elements. */ + --background-core-app: var(--background-elevation-elevation-0); /** Global application background. */ --border-utility-focus: var(--color-brand-300); /** Focus ring or focus border. */ - --border-utility-selected: var(--color-brand-500); /** Focus ring or focus border. */ + --border-utility-error: var(--color-accent-error); /** Error state. */ + --border-utility-warning: var(--color-accent-warning); /** Warning borders. */ + --border-utility-success: var(--color-accent-success); /** Success borders. */ --chat-bg-outgoing: var(--color-brand-100); /** Outgoing bubble background. */ - --chat-bg-attachment-outgoing: var(--color-brand-200); /** Attachment card in outgoing bubble. */ + --chat-bg-attachment-outgoing: var(--color-brand-150); /** Attachment card in outgoing bubble. */ + --chat-bg-typing-indicator: var(--color-accent-neutral); /** Typing indicator chip. */ --chat-text-message: var(--text-primary); /** Message body text. */ --chat-text-timestamp: var(--text-tertiary); /** Time labels. */ --chat-text-username: var(--text-secondary); /** Username label. */ @@ -439,42 +412,54 @@ --chat-border-on-chat-outgoing: var(--color-brand-300); --chat-reply-indicator-outgoing: var(--color-brand-400); /** Reply indicator shading for outgoing. */ --chat-poll-progress-fill-outgoing: var(--color-brand-200); - --input-bg-bg-disabled: var(--color-state-bg-disabled); /** Disabled input background using your shared disabled surface. White in high-contrast, with the disabled border and text carrying most of the signal. */ - --input-border-default: var(--border-core-surface-subtle); /** Default border of the chat input. Uses the standard border role from foundations. In high-contrast always black. */ - --input-border-hover: var(--border-core-surface); /** Optional hover border when the input is hovered or highlighted. Slightly stronger than default. */ - --input-border-border-disabled: var(--border-core-subtle); /** Disabled input border. Low contrast in normal modes, black outline in high-contrast. */ - --input-send-icon-disabled: var(--color-state-text-disabled); /** Send icon when disabled (e.g. empty input). */ + --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). */ --reaction-bg: var(--background-elevation-elevation-1); /** Reaction bar background. */ - --reaction-border: var(--border-core-surface-subtle); /** Border around unselected reaction chips. Subtle in normal modes, strong in high-contrast for visibility. */ + --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(--color-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(--color-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(--color-accent-error); --badge-bg-neutral: var(--color-accent-neutral); - --control-radiocheck-border: var(--border-core-surface-subtle); + --badge-text: var(--text-on-accent); + --badge-text-inverse: var(--text-primary); + --badge-bg-default: var(--background-elevation-elevation-1); + --badge-bg-inverse: var(--color-accent-black); + --control-radiocheck-border: var(--border-core-default); --control-radiocheck-icon-selected: var(--text-inverse); - --control-radiocheck-border-disabled: var(--border-utility-border); - --control-radiocheck-bg-selected-disabled: var(--color-state-bg-disabled); - --control-radiocheck-icon-selected-disabled: var(--color-state-text-disabled); - --control-remove-border: var(--border-core-on-dark); + --control-remove-control-bg: var(--color-accent-black); + --control-remove-control-icon: var(--text-on-accent); --control-remove-control-border: var(--border-core-on-dark); --control-play-control-bg: var(--background-elevation-elevation-1); --control-play-control-icon: var(--text-primary); - --control-play-control-border: var(--border-core-surface-subtle); + --control-play-control-border: var(--border-core-default); + --control-play-control-bg-inverse: var(--color-accent-black); + --control-play-control-icon-inverse: var(--text-on-accent); + --control-toggle-switch-knob: var(--background-elevation-elevation-4); + --control-toggle-switch-bg: var(--background-core-surface-strong); + --control-toggle-switch-bg-disabled: var(--background-core-disabled); --avatar-bg-default: var(--avatar-palette-bg-1); --avatar-text-default: var(--avatar-palette-text-1); - --color-accent-primary: var(--color-brand-500); /** Main brand accent for interactive elements. */ - --button-style-ghost-text-primary: var(--color-accent-primary); --button-primary-bg: var(--color-accent-primary); + --button-primary-text: var(--color-accent-primary); + --border-utility-selected: var(--color-accent-primary); /** Focus ring or focus border. */ --chat-waveform-bar-playing: var(--color-accent-primary); --chat-poll-progress-track-outgoing: var(--color-accent-primary); --chat-thread-connector-outgoing: var(--chat-bg-outgoing); --input-border-focus: var(--border-utility-focus); /** Focus border when the input is focused. Uses the shared focus state token (brand in normal modes, black in high-contrast). */ --input-send-icon: var(--color-accent-primary); /** Default send icon color in the input. Uses the brand accent. */ + --system-caret: var(--color-accent-primary); --badge-bg-primary: var(--color-accent-primary); --control-radiocheck-bg-selected: var(--color-accent-primary); - --control-radiocheck-border-selected: var(--border-utility-selected); + --control-toggle-switch-bg-selected: var(--color-accent-primary); --text-link: var(--color-accent-primary); /** Hyperlinks and inline actions. */ --chat-text-mention: var(--text-link); /** Mention styling. */ --chat-text-link: var(--text-link); /** Links inside message bubbles. */ From 4cc512bd92547517c0472f697d9db11d5fb99c65 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Jan 2026 14:48:46 +0100 Subject: [PATCH 04/11] feat: add more Button updates --- src/components/Button/Button.tsx | 4 +- src/components/Button/index.ts | 1 + src/components/Button/styling/Button.scss | 4 +- src/styling/base.scss | 1 + src/styling/utils.scss | 84 ++++++++++++++++++++++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index a0c82d2ce..262d32395 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,6 +1,8 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; -export const Button = ({ className, ...props }: ComponentProps<'button'>) => ( +export type ButtonProps = ComponentProps<'button'>; + +export const Button = ({ className, ...props }: ButtonProps) => ( ); }; From ac2ac3d652f607fa02c236172c7ee4d584957dbb Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 26 Jan 2026 12:10:04 +0100 Subject: [PATCH 06/11] feat: add styles for MessageComposer --- package.json | 6 +- src/components/Attachment/Attachment.tsx | 3 +- src/components/Attachment/Audio.tsx | 3 +- src/components/Attachment/Card.tsx | 3 +- src/components/Attachment/FileAttachment.tsx | 2 +- .../Attachment/UnsupportedAttachment.tsx | 2 +- src/components/Attachment/VoiceRecording.tsx | 14 +- .../Attachment/components/PlayButton.tsx | 18 - src/components/Attachment/components/index.ts | 3 +- src/components/Attachment/utils.tsx | 11 +- .../components/DurationDisplay.tsx | 57 ++ .../components/WaveProgressBar.tsx | 4 +- .../AudioPlayback/components/index.ts | 2 + src/components/AudioPlayback/index.ts | 1 + .../styling/DurationDisplay.scss | 15 + .../styling/WaveProgressBar.scss | 37 ++ .../AudioPlayback/styling/index.scss | 2 + src/components/Button/PlayButton.tsx | 25 + src/components/Button/styling/Button.scss | 1 + src/components/Channel/Channel.tsx | 3 - src/components/Dialog/DialogMenu.tsx | 16 - .../Dialog/__tests__/DialogsManager.test.js | 2 +- src/components/Dialog/base/ContextMenu.tsx | 6 + .../ContextMenuButton.tsx} | 71 +- src/components/Dialog/base/index.ts | 2 + src/components/Dialog/hooks/useDialog.ts | 5 +- src/components/Dialog/index.ts | 6 +- .../Dialog/{ => service}/DialogAnchor.tsx | 6 +- .../Dialog/{ => service}/DialogManager.ts | 0 .../Dialog/{ => service}/DialogPortal.tsx | 6 +- src/components/Dialog/service/index.ts | 3 + .../Dialog/styling/ContextMenu.scss | 41 ++ src/components/Dialog/styling/Dialog.scss | 7 + src/components/Dialog/styling/index.scss | 2 + src/components/FileIcon/FileIcon.tsx | 49 ++ src/components/FileIcon/FileIconSet.tsx | 226 +++++++ .../FileIcon/iconMap.ts | 30 +- .../FileIcon/index.ts | 0 src/components/FileIcon/mimeTypes.ts | 340 ++++++++++ src/components/FileIcon/styling/FileIcon.scss | 16 + src/components/Gallery/Image.tsx | 5 +- src/components/Icons/BaseIcon.tsx | 10 + .../Icons/IconArrowRotateClockwise.tsx | 6 +- src/components/Icons/IconCamera.tsx | 15 +- src/components/Icons/IconChainLink.tsx | 20 + src/components/Icons/IconChevronRight.tsx | 13 + src/components/Icons/IconClose.tsx | 6 +- src/components/Icons/IconCommand.tsx | 13 + .../Icons/IconExclamationCircle.tsx | 6 +- .../Icons/IconExclamationTriangle.tsx | 6 +- src/components/Icons/IconFile.tsx | 13 + src/components/Icons/IconLocationPin.tsx | 14 + src/components/Icons/IconMicrophone.tsx | 7 +- src/components/Icons/IconPaperPlane.tsx | 7 +- src/components/Icons/IconPause.tsx | 6 +- src/components/Icons/IconPlaySolid.tsx | 6 +- src/components/Icons/IconPlus.tsx | 7 +- src/components/Icons/IconPoll.tsx | 13 + src/components/Icons/IconVideoCamera.tsx | 17 + .../Icons/IconVideoCameraOutline.tsx | 17 + src/components/Icons/index.ts | 8 + src/components/Icons/styling/IconCamera.scss | 12 + .../Icons/styling/IconChainLink.scss | 17 + .../Icons/styling/IconChevronRight.scss | 13 + src/components/Icons/styling/IconCommand.scss | 13 + src/components/Icons/styling/IconFile.scss | 11 + .../Icons/styling/IconLocationPin.scss | 11 + .../Icons/styling/IconMicrophone.scss | 2 + .../Icons/styling/IconPaperPlane.scss | 1 + src/components/Icons/styling/IconPlus.scss | 1 + src/components/Icons/styling/IconPoll.scss | 13 + .../Icons/styling/IconVideoCameraOutline.scss | 11 + src/components/Icons/styling/index.scss | 10 +- .../AudioRecorder/AudioRecorder.tsx | 7 +- .../AudioRecordingButtonWithNotification.tsx | 60 ++ .../AudioRecorder/AudioRecordingButtons.tsx | 38 +- .../RecordingPermissionDeniedNotification.tsx | 14 +- .../MessageActions/RemindMeSubmenu.tsx | 8 +- .../AttachmentPreviewList.tsx | 190 +++--- .../AudioAttachmentPreview.tsx | 163 +++++ .../FileAttachmentPreview.tsx | 123 ++-- .../GeolocationPreview.tsx | 6 +- .../ImageAttachmentPreview.tsx | 67 -- .../MediaAttachmentPreview.tsx | 138 ++++ .../UnsupportedAttachmentPreview.tsx | 74 +-- .../VoiceRecordingPreview.tsx | 87 --- .../AttachmentPreviewList/index.ts | 2 + .../utils/AttachmentPreviewRoot.tsx | 113 ++++ .../AttachmentSelector.tsx | 165 +++-- .../MessageInput/AttachmentSelector/index.ts | 1 + src/components/MessageInput/CooldownTimer.tsx | 1 - .../MessageInput/LinkPreviewList.tsx | 63 +- .../MessageInput/MessageComposerActions.tsx | 71 ++ src/components/MessageInput/MessageInput.tsx | 7 +- .../MessageInput/MessageInputFlat.tsx | 199 +++--- .../MessageInput/QuotedMessageIndicator.tsx | 9 + .../MessageInput/QuotedMessagePreview.tsx | 385 +++++++++-- .../RemoveAttachmentPreviewButton.tsx | 34 + .../MessageInput/WithDragAndDropUpload.tsx | 9 +- .../__tests__/AttachmentPreviewList.test.js | 2 +- .../hooks/__tests__/useCooldownTimer.test.js | 6 +- src/components/MessageInput/hooks/index.ts | 3 +- .../hooks/useCooldownRemaining.tsx | 22 + .../MessageInput/hooks/useCooldownTimer.tsx | 65 -- .../hooks/useCreateMessageInputContext.ts | 8 - .../hooks/useMessageCompositionIsEmpty.ts | 11 + src/components/MessageInput/icons.tsx | 93 +-- .../styling/AttachmentPreview.scss | 209 ++++++ .../styling/AttachmentPreviewThumbnail.scss | 8 + .../styling/AttachmentSelector.scss | 26 + .../MessageInput/styling/LinkPreviewList.scss | 82 +++ .../MessageInput/styling/MessageComposer.scss | 404 ++++++++++++ .../styling/QuotedMessageIndicator.scss | 10 + .../styling/QuotedMessagePreview.scss | 87 +++ .../RemoveAttachmentPreviewButton.scss | 11 + .../MessageInput/styling/index.scss | 8 + src/components/Poll/Poll.tsx | 1 + src/components/Poll/QuotedPoll.tsx | 1 + .../ReactFileUtilities/FileIcon/FileIcon.tsx | 45 -- .../FileIcon/FileIconSet.tsx | 618 ------------------ .../ReactFileUtilities/FileIcon/mimeTypes.ts | 163 ----- .../ReactFileUtilities/UploadButton.tsx | 5 +- src/components/ReactFileUtilities/index.ts | 1 - .../TextareaComposer/TextareaComposer.tsx | 27 +- src/components/VideoPlayer/VideoPlayer.tsx | 27 + src/components/VideoPlayer/index.ts | 1 + src/components/index.ts | 5 +- src/context/ComponentContext.tsx | 10 +- src/context/DialogManagerContext.tsx | 4 +- src/context/MessageInputContext.tsx | 5 +- src/i18n/de.json | 24 +- src/i18n/en.json | 22 +- src/i18n/es.json | 23 +- src/i18n/fr.json | 23 +- src/i18n/hi.json | 18 + src/i18n/it.json | 25 +- src/i18n/ja.json | 15 + src/i18n/ko.json | 15 + src/i18n/nl.json | 22 +- src/i18n/pt.json | 23 +- src/i18n/ru.json | 24 + src/i18n/tr.json | 18 + src/plugins/Emojis/EmojiPicker.tsx | 17 +- src/plugins/Emojis/icons.tsx | 31 +- src/plugins/Emojis/styling/icons.scss | 7 + src/plugins/Emojis/styling/index.scss | 1 + src/styling/{utils.scss => _utils.scss} | 35 +- src/styling/_variables.scss | 2 + src/styling/accessibility.scss | 6 + src/styling/animations.scss | 31 + src/styling/assets/EmojiOneColor.woff2 | Bin 0 -> 3345244 bytes src/styling/assets/NotoColorEmoji-flags.woff2 | Bin 0 -> 770104 bytes .../assets/icons/stream-chat-icons.eot | Bin 0 -> 8808 bytes .../assets/icons/stream-chat-icons.svg | 50 ++ .../assets/icons/stream-chat-icons.ttf | Bin 0 -> 8604 bytes .../assets/icons/stream-chat-icons.woff | Bin 0 -> 4992 bytes .../assets/icons/stream-chat-icons.woff2 | Bin 0 -> 4144 bytes src/styling/base.scss | 5 + src/styling/icons.scss | 21 + src/styling/index.scss | 15 +- 160 files changed, 3914 insertions(+), 1937 deletions(-) delete mode 100644 src/components/Attachment/components/PlayButton.tsx create mode 100644 src/components/AudioPlayback/components/DurationDisplay.tsx rename src/components/{Attachment => AudioPlayback}/components/WaveProgressBar.tsx (97%) create mode 100644 src/components/AudioPlayback/components/index.ts create mode 100644 src/components/AudioPlayback/styling/DurationDisplay.scss create mode 100644 src/components/AudioPlayback/styling/WaveProgressBar.scss create mode 100644 src/components/AudioPlayback/styling/index.scss create mode 100644 src/components/Button/PlayButton.tsx delete mode 100644 src/components/Dialog/DialogMenu.tsx create mode 100644 src/components/Dialog/base/ContextMenu.tsx rename src/components/Dialog/{ButtonWithSubmenu.tsx => base/ContextMenuButton.tsx} (69%) create mode 100644 src/components/Dialog/base/index.ts rename src/components/Dialog/{ => service}/DialogAnchor.tsx (94%) rename src/components/Dialog/{ => service}/DialogManager.ts (100%) rename src/components/Dialog/{ => service}/DialogPortal.tsx (94%) create mode 100644 src/components/Dialog/service/index.ts create mode 100644 src/components/Dialog/styling/ContextMenu.scss create mode 100644 src/components/Dialog/styling/Dialog.scss create mode 100644 src/components/Dialog/styling/index.scss create mode 100644 src/components/FileIcon/FileIcon.tsx create mode 100644 src/components/FileIcon/FileIconSet.tsx rename src/components/{ReactFileUtilities => }/FileIcon/iconMap.ts (72%) rename src/components/{ReactFileUtilities => }/FileIcon/index.ts (100%) create mode 100644 src/components/FileIcon/mimeTypes.ts create mode 100644 src/components/FileIcon/styling/FileIcon.scss create mode 100644 src/components/Icons/BaseIcon.tsx create mode 100644 src/components/Icons/IconChainLink.tsx create mode 100644 src/components/Icons/IconChevronRight.tsx create mode 100644 src/components/Icons/IconCommand.tsx create mode 100644 src/components/Icons/IconFile.tsx create mode 100644 src/components/Icons/IconLocationPin.tsx create mode 100644 src/components/Icons/IconPoll.tsx create mode 100644 src/components/Icons/IconVideoCamera.tsx create mode 100644 src/components/Icons/IconVideoCameraOutline.tsx create mode 100644 src/components/Icons/styling/IconCamera.scss create mode 100644 src/components/Icons/styling/IconChainLink.scss create mode 100644 src/components/Icons/styling/IconChevronRight.scss create mode 100644 src/components/Icons/styling/IconCommand.scss create mode 100644 src/components/Icons/styling/IconFile.scss create mode 100644 src/components/Icons/styling/IconLocationPin.scss create mode 100644 src/components/Icons/styling/IconPoll.scss create mode 100644 src/components/Icons/styling/IconVideoCameraOutline.scss create mode 100644 src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx create mode 100644 src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx create mode 100644 src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx create mode 100644 src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx rename src/components/MessageInput/{ => AttachmentSelector}/AttachmentSelector.tsx (70%) create mode 100644 src/components/MessageInput/AttachmentSelector/index.ts create mode 100644 src/components/MessageInput/MessageComposerActions.tsx create mode 100644 src/components/MessageInput/QuotedMessageIndicator.tsx create mode 100644 src/components/MessageInput/RemoveAttachmentPreviewButton.tsx create mode 100644 src/components/MessageInput/hooks/useCooldownRemaining.tsx delete mode 100644 src/components/MessageInput/hooks/useCooldownTimer.tsx create mode 100644 src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts create mode 100644 src/components/MessageInput/styling/AttachmentPreview.scss create mode 100644 src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss create mode 100644 src/components/MessageInput/styling/AttachmentSelector.scss create mode 100644 src/components/MessageInput/styling/LinkPreviewList.scss create mode 100644 src/components/MessageInput/styling/MessageComposer.scss create mode 100644 src/components/MessageInput/styling/QuotedMessageIndicator.scss create mode 100644 src/components/MessageInput/styling/QuotedMessagePreview.scss create mode 100644 src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss create mode 100644 src/components/MessageInput/styling/index.scss delete mode 100644 src/components/ReactFileUtilities/FileIcon/FileIcon.tsx delete mode 100644 src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx delete mode 100644 src/components/ReactFileUtilities/FileIcon/mimeTypes.ts create mode 100644 src/components/VideoPlayer/VideoPlayer.tsx create mode 100644 src/components/VideoPlayer/index.ts create mode 100644 src/plugins/Emojis/styling/icons.scss create mode 100644 src/plugins/Emojis/styling/index.scss rename src/styling/{utils.scss => _utils.scss} (51%) create mode 100644 src/styling/_variables.scss create mode 100644 src/styling/accessibility.scss create mode 100644 src/styling/animations.scss create mode 100644 src/styling/assets/EmojiOneColor.woff2 create mode 100644 src/styling/assets/NotoColorEmoji-flags.woff2 create mode 100644 src/styling/assets/icons/stream-chat-icons.eot create mode 100644 src/styling/assets/icons/stream-chat-icons.svg create mode 100644 src/styling/assets/icons/stream-chat-icons.ttf create mode 100644 src/styling/assets/icons/stream-chat-icons.woff create mode 100644 src/styling/assets/icons/stream-chat-icons.woff2 create mode 100644 src/styling/icons.scss diff --git a/package.json b/package.json index 884c1978d..ba3e9bd12 100644 --- a/package.json +++ b/package.json @@ -224,7 +224,7 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && concurrently './scripts/copy-css.sh' 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'", - "build-styling": "sass src/styling/index.scss dist/css/index.css", + "build-styling": "sass src/styling/index.scss dist/css/index.css && sass src/plugins/Emojis/styling/index.scss dist/css/emojis.css", "build-translations": "i18next-cli extract", "coverage": "jest --collectCoverage && codecov", "lint": "yarn prettier --list-different && yarn eslint && yarn validate-translations", @@ -234,8 +234,8 @@ "prettier": "prettier '**/*.{js,mjs,ts,mts,jsx,tsx,md,json,yml}'", "prettier-fix": "yarn prettier --write", "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", - "start": "tsc --watch --sourceMap", - "start:css": "sass --watch src/styling/index.scss dist/css/index.css", + "start": "tsc -p tsconfig.lib.json -w", + "start:css": "sass --watch src/styling:dist/css src/plugins/Emojis/styling:dist/css", "prepare": "husky install", "preversion": "yarn install", "test": "jest", diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 0457228d2..11617ca60 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -37,6 +37,7 @@ import type { GeolocationProps } from './Geolocation'; const CONTAINER_MAP = { audio: AudioContainer, + // todo: rename to linkPreview card: CardContainer, file: FileContainer, media: MediaContainer, @@ -177,7 +178,7 @@ const renderGroupedAttachments = ({ return containers; }; -const getAttachmentType = ( +export const getAttachmentType = ( attachment: AttachmentProps['attachments'][number], ): keyof typeof CONTAINER_MAP => { if (isScrapedContent(attachment)) { diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 686cf70a4..d8189340b 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -1,11 +1,12 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { DownloadButton, FileSizeIndicator, PlayButton, ProgressBar } from './components'; +import { DownloadButton, FileSizeIndicator, ProgressBar } from './components'; import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; +import { PlayButton } from '../Button/PlayButton'; type AudioAttachmentUIProps = { audioPlayer: AudioPlayer; diff --git a/src/components/Attachment/Card.tsx b/src/components/Attachment/Card.tsx index 2d0125bb1..542ed5012 100644 --- a/src/components/Attachment/Card.tsx +++ b/src/components/Attachment/Card.tsx @@ -5,7 +5,7 @@ import ReactPlayer from 'react-player'; import type { AudioProps } from './Audio'; import { ImageComponent } from '../Gallery'; import { SafeAnchor } from '../SafeAnchor'; -import { PlayButton, ProgressBar } from './components'; +import { ProgressBar } from './components'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; @@ -15,6 +15,7 @@ import type { Dimensions } from '../../types/types'; import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback'; import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; +import { PlayButton } from '../Button'; const getHostFromURL = (url?: string | null) => { if (url !== undefined && url !== null) { diff --git a/src/components/Attachment/FileAttachment.tsx b/src/components/Attachment/FileAttachment.tsx index 2e8537efd..639d812de 100644 --- a/src/components/Attachment/FileAttachment.tsx +++ b/src/components/Attachment/FileAttachment.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import type { Attachment } from 'stream-chat'; import { DownloadButton, FileSizeIndicator } from './components'; diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index 60883bd62..df7c7976d 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import { useTranslationContext } from '../../context'; export type UnsupportedAttachmentProps = { diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 2ce46ee31..c713cb951 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -1,18 +1,14 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { - FileSizeIndicator, - PlaybackRateButton, - PlayButton, - WaveProgressBar, -} from './components'; +import { FileSizeIndicator, PlaybackRateButton, WaveProgressBar } from './components'; import { displayDuration } from './utils'; -import { FileIcon } from '../ReactFileUtilities'; +import { FileIcon } from '../FileIcon'; import { useMessageContext, useTranslationContext } from '../../context'; import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback/'; import { useStateStore } from '../../store'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; +import { PlayButton } from '../Button'; const rootClassName = 'str-chat__message-attachment__voice-recording-widget'; @@ -73,7 +69,7 @@ const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {playbackRate?.toFixed(1)}x ) : ( - + )} @@ -156,7 +152,7 @@ export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) - + ); }; diff --git a/src/components/Attachment/components/PlayButton.tsx b/src/components/Attachment/components/PlayButton.tsx deleted file mode 100644 index 67ea75ff7..000000000 --- a/src/components/Attachment/components/PlayButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { PauseIcon, PlayTriangleIcon } from '../icons'; - -type PlayButtonProps = { - isPlaying: boolean; - onClick: () => void; -}; - -export const PlayButton = ({ isPlaying, onClick }: PlayButtonProps) => ( - -); diff --git a/src/components/Attachment/components/index.ts b/src/components/Attachment/components/index.ts index bba0e369d..f22bba5ae 100644 --- a/src/components/Attachment/components/index.ts +++ b/src/components/Attachment/components/index.ts @@ -2,5 +2,4 @@ export * from './DownloadButton'; export * from './FileSizeIndicator'; export * from './ProgressBar'; export * from './PlaybackRateButton'; -export * from './PlayButton'; -export * from './WaveProgressBar'; +export * from '../../AudioPlayback/components/WaveProgressBar'; diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx index a6ec70b1e..230fe3b62 100644 --- a/src/components/Attachment/utils.tsx +++ b/src/components/Attachment/utils.tsx @@ -53,11 +53,10 @@ export const displayDuration = (totalSeconds?: number) => { const [hours, hoursLeftover] = divMod(totalSeconds, 3600); const [minutes, seconds] = divMod(hoursLeftover, 60); const roundedSeconds = Math.ceil(seconds); + const prependHrsZero = String(hours).padStart(2, '0'); + const prependMinZero = String(minutes).padStart(2, '0'); + const prependSecZero = String(roundedSeconds).padStart(2, '0'); + const minSec = `${prependMinZero}:${prependSecZero}`; - const prependHrsZero = hours.toString().length === 1 ? '0' : ''; - const prependMinZero = minutes.toString().length === 1 ? '0' : ''; - const prependSecZero = roundedSeconds.toString().length === 1 ? '0' : ''; - const minSec = `${prependMinZero}${minutes}:${prependSecZero}${roundedSeconds}`; - - return hours ? `${prependHrsZero}${hours}:` + minSec : minSec; + return hours ? `${prependHrsZero}:` + minSec : minSec; }; diff --git a/src/components/AudioPlayback/components/DurationDisplay.tsx b/src/components/AudioPlayback/components/DurationDisplay.tsx new file mode 100644 index 000000000..72e49cbed --- /dev/null +++ b/src/components/AudioPlayback/components/DurationDisplay.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import clsx from 'clsx'; + +type DurationDisplayProps = { + /** Whether audio is currently playing */ + isPlaying: boolean; + /** Optional className for styling */ + className?: string; + /** Total duration in seconds */ + duration?: number; + /** Elapsed time in seconds */ + secondsElapsed?: number; +}; + +function formatTime(totalSeconds?: number) { + if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) { + return null; + } + const s = Math.floor(totalSeconds); + const minutes = Math.floor(s / 60); + const seconds = s % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +export function DurationDisplay({ + className, + duration, + isPlaying, + secondsElapsed, +}: DurationDisplayProps) { + const formattedDuration = formatTime(duration); + const formattedSecondsElapsed = formatTime(secondsElapsed); + + return ( +
0 && secondsElapsed < (duration || 0), + 'str-chat__duration-display--isPlaying': isPlaying, + }, + className, + )} + > + {isPlaying && ( + + {formattedSecondsElapsed} + + )} + {!!(formattedDuration && formattedSecondsElapsed) && <> / } + {formattedDuration && ( + {formattedDuration} + )} +
+ ); +} diff --git a/src/components/Attachment/components/WaveProgressBar.tsx b/src/components/AudioPlayback/components/WaveProgressBar.tsx similarity index 97% rename from src/components/Attachment/components/WaveProgressBar.tsx rename to src/components/AudioPlayback/components/WaveProgressBar.tsx index e727afae9..1ea3c162a 100644 --- a/src/components/Attachment/components/WaveProgressBar.tsx +++ b/src/components/AudioPlayback/components/WaveProgressBar.tsx @@ -9,8 +9,8 @@ import React, { useState, } from 'react'; import clsx from 'clsx'; -import { resampleWaveformData } from '../audioSampling'; -import type { SeekFn } from '../hooks/useAudioController'; +import { resampleWaveformData } from '../../Attachment/audioSampling'; +import type { SeekFn } from '../../Attachment/hooks/useAudioController'; type WaveProgressBarProps = { /** Function that allows to change the track progress */ diff --git a/src/components/AudioPlayback/components/index.ts b/src/components/AudioPlayback/components/index.ts new file mode 100644 index 000000000..600786bcc --- /dev/null +++ b/src/components/AudioPlayback/components/index.ts @@ -0,0 +1,2 @@ +export * from './DurationDisplay'; +export * from './WaveProgressBar'; diff --git a/src/components/AudioPlayback/index.ts b/src/components/AudioPlayback/index.ts index 566e397fb..5bffc7665 100644 --- a/src/components/AudioPlayback/index.ts +++ b/src/components/AudioPlayback/index.ts @@ -1,4 +1,5 @@ export * from './AudioPlayer'; +export * from './components'; export * from './plugins'; export { useActiveAudioPlayer, diff --git a/src/components/AudioPlayback/styling/DurationDisplay.scss b/src/components/AudioPlayback/styling/DurationDisplay.scss new file mode 100644 index 000000000..5f47adf65 --- /dev/null +++ b/src/components/AudioPlayback/styling/DurationDisplay.scss @@ -0,0 +1,15 @@ +.str-chat { + .str-chat__duration-display { + font-size: var(--typography-font-size-xs); + line-height: var(typography-line-height-tight); + letter-spacing: 0; + min-width: 35px; + width: 35px; + } + + &.str-chat__duration-display--hasProgress { + .str-chat__duration-display__time-elapsed { + color: var(--color-accent-primary); + } + } +} \ No newline at end of file diff --git a/src/components/AudioPlayback/styling/WaveProgressBar.scss b/src/components/AudioPlayback/styling/WaveProgressBar.scss new file mode 100644 index 000000000..afac3c161 --- /dev/null +++ b/src/components/AudioPlayback/styling/WaveProgressBar.scss @@ -0,0 +1,37 @@ +.str-chat { + .str-chat__wave-progress-bar__track { + $min_amplitude_height: 2px; + position: relative; + flex: 1; + width: 160px; + height: 20px; + display: flex; + align-items: center; + gap: var(--str-chat__voice-recording-amplitude-bar-gap-width); + + .str-chat__wave-progress-bar__amplitude-bar { + width: 2px; + min-width: 2px; + height: calc( + var(--str-chat__wave-progress-bar__amplitude-bar-height) + $min_amplitude_height + ); // variable set dynamically on element + background: var(--chat-waveform-bar); + border-radius: var(--radius-max); + } + + .str-chat__wave-progress-bar__amplitude-bar--active { + background: var(--chat-waveform-bar-playing); + } + + .str-chat__wave-progress-bar__progress-indicator { + position: absolute; + left: 0; + // todo: CSS use semantic variable instead of --base-white + border: 2px solid var(--base-white); + box-shadow: var(--light-elevation-3); + background: var(--color-accent-primary); + height: 14px; + width: 14px; + } + } +} \ No newline at end of file diff --git a/src/components/AudioPlayback/styling/index.scss b/src/components/AudioPlayback/styling/index.scss new file mode 100644 index 000000000..8558688dd --- /dev/null +++ b/src/components/AudioPlayback/styling/index.scss @@ -0,0 +1,2 @@ +@use "DurationDisplay"; +@use "WaveProgressBar"; \ No newline at end of file diff --git a/src/components/Button/PlayButton.tsx b/src/components/Button/PlayButton.tsx new file mode 100644 index 000000000..459f79014 --- /dev/null +++ b/src/components/Button/PlayButton.tsx @@ -0,0 +1,25 @@ +import { Button } from './Button'; +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { IconPause, IconPlaySolid } from '../Icons'; + +export type PlayButtonProps = ComponentProps<'button'> & { + isPlaying: boolean; +}; + +export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => ( + +); diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index ea14aa66b..7e606e04c 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -81,6 +81,7 @@ @include utils.overlay-after(var(--background-core-hover)); } + &[aria-expanded="true"], &:not(:disabled):active { // pressed @include utils.overlay-after(var(--background-core-pressed)); } diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 32fc8fe0f..3c02eb46f 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -112,7 +112,6 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'EmojiPicker' | 'emojiSearchIndex' | 'EmptyStateIndicator' - | 'FileUploadIcon' | 'GiphyPreviewMessage' | 'HeaderComponent' | 'Input' @@ -1223,7 +1222,6 @@ const ChannelInner = ( EmojiPicker: props.EmojiPicker, emojiSearchIndex: props.emojiSearchIndex, EmptyStateIndicator: props.EmptyStateIndicator, - FileUploadIcon: props.FileUploadIcon, GiphyPreviewMessage: props.GiphyPreviewMessage, HeaderComponent: props.HeaderComponent, Input: props.Input, @@ -1293,7 +1291,6 @@ const ChannelInner = ( props.EmojiPicker, props.emojiSearchIndex, props.EmptyStateIndicator, - props.FileUploadIcon, props.GiphyPreviewMessage, props.HeaderComponent, props.Input, diff --git a/src/components/Dialog/DialogMenu.tsx b/src/components/Dialog/DialogMenu.tsx deleted file mode 100644 index 54e2527ce..000000000 --- a/src/components/Dialog/DialogMenu.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { ComponentProps } from 'react'; -import React from 'react'; -import clsx from 'clsx'; - -export type DialogMenuButtonProps = ComponentProps<'button'>; - -export const DialogMenuButton = ({ - children, - className, - ...props -}: DialogMenuButtonProps) => ( - -); diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index 64099c156..f1cc21bd4 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -1,4 +1,4 @@ -import { DialogManager } from '../DialogManager'; +import { DialogManager } from '../service/DialogManager'; import * as nanoid from 'nanoid'; const dialogId = 'dialogId'; diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/base/ContextMenu.tsx new file mode 100644 index 000000000..907626010 --- /dev/null +++ b/src/components/Dialog/base/ContextMenu.tsx @@ -0,0 +1,6 @@ +import React, { type ComponentProps } from 'react'; +import clsx from 'clsx'; + +export const ContextMenu = ({ className, ...props }: ComponentProps<'div'>) => ( +
+); diff --git a/src/components/Dialog/ButtonWithSubmenu.tsx b/src/components/Dialog/base/ContextMenuButton.tsx similarity index 69% rename from src/components/Dialog/ButtonWithSubmenu.tsx rename to src/components/Dialog/base/ContextMenuButton.tsx index 74e18ba23..93d912cf1 100644 --- a/src/components/Dialog/ButtonWithSubmenu.tsx +++ b/src/components/Dialog/base/ContextMenuButton.tsx @@ -1,24 +1,56 @@ import clsx from 'clsx'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDialogIsOpen, useDialogOnNearestManager } from './hooks'; -import { useDialogAnchor } from './DialogAnchor'; import type { ComponentProps, ComponentType } from 'react'; -import type { PopperLikePlacement } from './hooks'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PopperLikePlacement } from '../hooks'; +import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; +import { useDialogAnchor } from '../service'; +import { IconChevronRight } from '../../Icons'; + +export type BaseContextMenuButtonProps = { + hasSubMenu?: boolean; + Icon?: ComponentType>; + SubmenuIcon?: ComponentType>; +} & ComponentProps<'button'>; + +export const BaseContextMenuButton = ({ + children, + className, + hasSubMenu, + Icon, + SubmenuIcon = IconChevronRight, + ...props +}: BaseContextMenuButtonProps) => ( + +); -type ButtonWithSubmenu = ComponentProps<'button'> & { - children: React.ReactNode; - placement: PopperLikePlacement; +type ButtonWithSubmenuProps = { Submenu: ComponentType; submenuContainerProps?: ComponentProps<'div'>; + submenuPlacement?: PopperLikePlacement; }; -export const ButtonWithSubmenu = ({ + +const ContextMenuButtonWithSubmenu = ({ children, className, - placement, Submenu, submenuContainerProps, + submenuPlacement = 'right-start', ...buttonProps -}: ButtonWithSubmenu) => { +}: BaseContextMenuButtonProps & ButtonWithSubmenuProps) => { const buttonRef = useRef(null); const [dialogContainer, setDialogContainer] = useState(null); const keepSubmenuOpen = useRef(false); @@ -28,7 +60,7 @@ export const ButtonWithSubmenu = ({ const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); const { setPopperElement, styles } = useDialogAnchor({ open: dialogIsOpen, - placement, + placement: submenuPlacement, referenceElement: buttonRef.current, }); @@ -75,11 +107,12 @@ export const ButtonWithSubmenu = ({ return ( <> - + {dialogIsOpen && (
{ @@ -134,3 +167,13 @@ export const ButtonWithSubmenu = ({ ); }; + +type ContextMenuButtonProps = BaseContextMenuButtonProps & + Partial; + +export const ContextMenuButton = (props: ContextMenuButtonProps) => + props.Submenu ? ( + + ) : ( + + ); diff --git a/src/components/Dialog/base/index.ts b/src/components/Dialog/base/index.ts new file mode 100644 index 000000000..99070ecd1 --- /dev/null +++ b/src/components/Dialog/base/index.ts @@ -0,0 +1,2 @@ +export * from './ContextMenuButton'; +export * from './ContextMenu'; diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 3c12ab758..62cd70031 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -6,7 +6,10 @@ import { } from '../../../context'; import { useStateStore } from '../../../store'; -import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager'; +import type { + DialogManagerState, + GetOrCreateDialogParams, +} from '../service/DialogManager'; export type UseDialogParams = GetOrCreateDialogParams & { dialogManagerId?: string; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts index 7e3fd2bf0..8a992d2dd 100644 --- a/src/components/Dialog/index.ts +++ b/src/components/Dialog/index.ts @@ -1,5 +1,3 @@ -export * from './ButtonWithSubmenu'; -export * from './DialogAnchor'; -export * from './DialogManager'; -export * from './DialogPortal'; +export * from './base'; export * from './hooks'; +export * from './service'; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx similarity index 94% rename from src/components/Dialog/DialogAnchor.tsx rename to src/components/Dialog/service/DialogAnchor.tsx index ac8a3e1ed..0f7268b96 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/service/DialogAnchor.tsx @@ -3,9 +3,9 @@ import type { ComponentProps, PropsWithChildren } from 'react'; import React, { useEffect, useState } from 'react'; import { FocusScope } from '@react-aria/focus'; import { DialogPortalEntry } from './DialogPortal'; -import { useDialog, useDialogIsOpen } from './hooks'; -import { usePopoverPosition } from './hooks/usePopoverPosition'; -import type { PopperLikePlacement } from './hooks'; +import { useDialog, useDialogIsOpen } from '../hooks'; +import { usePopoverPosition } from '../hooks/usePopoverPosition'; +import type { PopperLikePlacement } from '../hooks'; export interface DialogAnchorOptions { open: boolean; diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts similarity index 100% rename from src/components/Dialog/DialogManager.ts rename to src/components/Dialog/service/DialogManager.ts diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/service/DialogPortal.tsx similarity index 94% rename from src/components/Dialog/DialogPortal.tsx rename to src/components/Dialog/service/DialogPortal.tsx index 8274f2726..546998b50 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/service/DialogPortal.tsx @@ -1,8 +1,8 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; -import { useDialogIsOpen, useOpenedDialogCount } from './hooks'; -import { Portal } from '../Portal/Portal'; -import { useDialogManager, useNearestDialogManagerContext } from '../../context'; +import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; +import { Portal } from '../../Portal/Portal'; +import { useDialogManager, useNearestDialogManagerContext } from '../../../context'; export const DialogPortalDestination = () => { const { dialogManager } = useNearestDialogManagerContext() ?? {}; diff --git a/src/components/Dialog/service/index.ts b/src/components/Dialog/service/index.ts new file mode 100644 index 000000000..4f1a8c99d --- /dev/null +++ b/src/components/Dialog/service/index.ts @@ -0,0 +1,3 @@ +export * from './DialogAnchor'; +export * from './DialogManager'; +export * from './DialogPortal'; diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss new file mode 100644 index 000000000..05837f2c5 --- /dev/null +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -0,0 +1,41 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__context-menu { + display: flex; + flex-direction: column; + gap: var(--spacing-xxxs); + padding: var(--spacing-xxs); + background-color: var(--background-elevation-elevation-2); + box-shadow: var(--light-elevation-3); + border-radius: var(--radius-lg); + + .str-chat__context-menu__button { + @include utils.button-reset; + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + cursor: pointer; + border-radius: var(--radius-md); + font-size: var(--typography-font-size-xs); + font-weight: var(--typography-font-weight-semi-bold); + line-height: var(--typography-line-height-tight); + + &:hover { + background-color: var(--background-core-hover); + } + + svg { + height: var(--icon-size-sm); + width: var(--icon-size-sm); + color: var(--text-secondary); + } + + .str-chat__context-menu__button__text { + flex: 1; + text-align: left; + } + } + } +} \ No newline at end of file diff --git a/src/components/Dialog/styling/Dialog.scss b/src/components/Dialog/styling/Dialog.scss new file mode 100644 index 000000000..1f833f674 --- /dev/null +++ b/src/components/Dialog/styling/Dialog.scss @@ -0,0 +1,7 @@ +.str-chat { + .str-chat__dialog-contents { + //background-color: var(--background-elevation-elevation-4); + //box-shadow: var(--light-elevation-3); + //border-radius: var(--radius-lg); + } +} \ No newline at end of file diff --git a/src/components/Dialog/styling/index.scss b/src/components/Dialog/styling/index.scss new file mode 100644 index 000000000..d21468a5a --- /dev/null +++ b/src/components/Dialog/styling/index.scss @@ -0,0 +1,2 @@ +@use "ContextMenu"; +@use "Dialog"; \ No newline at end of file diff --git a/src/components/FileIcon/FileIcon.tsx b/src/components/FileIcon/FileIcon.tsx new file mode 100644 index 000000000..0bd7be835 --- /dev/null +++ b/src/components/FileIcon/FileIcon.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { iconMap } from './iconMap'; +import { mimeTypeToExtensionMap } from './mimeTypes'; + +export type FileIconProps = { + className?: string; + fileName?: string; + mimeType?: string; +}; + +export function mimeTypeToIcon(mimeType?: string) { + const theMap = iconMap['standard']; + + if (!mimeType) return theMap.fallback; + + const icon = theMap[mimeType]; + if (icon) return icon; + + if (mimeType.startsWith('audio/')) return theMap['audio/']; + if (mimeType.startsWith('video/')) return theMap['video/']; + if (mimeType.startsWith('image/')) return theMap['image/']; + if (mimeType.startsWith('text/')) return theMap['text/']; + + return theMap.fallback; +} + +const labelFromMimeType = ({ + fileName, + mimeType, +}: Pick) => { + let label; + + if (mimeType) { + label = mimeTypeToExtensionMap[mimeType]; + } + + if (!label && fileName) { + label = fileName.split('.').slice(-1)[0]; + } + return label; +}; + +export const FileIcon = (props: FileIconProps) => { + const { fileName, mimeType, ...rest } = props; + + const Icon = mimeTypeToIcon(mimeType); + const label = labelFromMimeType({ fileName, mimeType }); + return ; +}; diff --git a/src/components/FileIcon/FileIconSet.tsx b/src/components/FileIcon/FileIconSet.tsx new file mode 100644 index 000000000..77c6a06c8 --- /dev/null +++ b/src/components/FileIcon/FileIconSet.tsx @@ -0,0 +1,226 @@ +import type { ComponentProps, ComponentPropsWithoutRef } from 'react'; +import React from 'react'; +import clsx from 'clsx'; + +export type IconProps = { + label?: string; +} & ComponentPropsWithoutRef<'svg'>; + +const Svg = ({ className, ...props }: ComponentProps<'svg'>) => ( + +); + +type FileIconLabelProps = { label?: string }; + +const FileIconLabel = ({ label }: FileIconLabelProps) => ( + + {label} + +); + +export const FilePdfIcon = ({ className, ...props }: Omit) => ( + + + + + + + + + + + + + +); + +export const FileWordIcon = ({ className, label = 'doc', ...props }: IconProps) => ( + + + + + + + + +); + +export const FilePowerPointIcon = ({ className, label = 'ppt', ...props }: IconProps) => ( + + + + + + +); + +export const FileExcelIcon = ({ className = '', label = 'xls', ...props }: IconProps) => ( + + + + + + +); + +export const FileArchiveIcon = ({ className = '', label = '', ...props }: IconProps) => ( + + + + + + +); + +export const FileCodeIcon = ({ className = '', label = 'code', ...props }: IconProps) => ( + + + + + + +); + +export const FileAudioIcon = ({ + className = '', + label = 'audio', + ...props +}: IconProps) => ( + + + + + + +); + +// todo: can we remove this type of icon? missing design +export const FileVideoIcon = ({ className = '', ...props }: IconProps) => ( + + + + + + + +); + +export const FileFallbackIcon = ({ className = '', ...props }: IconProps) => ( + + + + + + + +); diff --git a/src/components/ReactFileUtilities/FileIcon/iconMap.ts b/src/components/FileIcon/iconMap.ts similarity index 72% rename from src/components/ReactFileUtilities/FileIcon/iconMap.ts rename to src/components/FileIcon/iconMap.ts index e11a7cac1..4764745ed 100644 --- a/src/components/ReactFileUtilities/FileIcon/iconMap.ts +++ b/src/components/FileIcon/iconMap.ts @@ -18,11 +18,7 @@ type MimeTypeMappedComponent = | 'FileArchiveIcon' | 'FileCodeIcon'; -type GeneralContentTypeComponent = - | 'FileImageIcon' - | 'FileAudioIcon' - | 'FileVideoIcon' - | 'FileAltIcon'; +type GeneralContentTypeComponent = 'FileAudioIcon' | 'FileVideoIcon' | 'FileAltIcon'; type IconComponents = { FileAltIcon: ComponentType; @@ -31,7 +27,6 @@ type IconComponents = { FileCodeIcon: ComponentType; FileExcelIcon: ComponentType; FileFallbackIcon: ComponentType; - FileImageIcon: ComponentType; FilePdfIcon: ComponentType; FilePowerPointIcon: ComponentType; FileVideoIcon: ComponentType; @@ -79,45 +74,23 @@ function generateMimeTypeToIconMap({ function generateGeneralTypeToIconMap({ FileAltIcon, FileAudioIcon, - FileImageIcon, FileVideoIcon, }: Pick, GeneralContentTypeComponent>) { return { 'audio/': FileAudioIcon, - 'image/': FileImageIcon, 'text/': FileAltIcon, 'video/': FileVideoIcon, }; } -export type IconType = 'standard' | 'alt'; - type IconMap = { standard: Record< SupportedMimeType | GeneralType | 'fallback', ComponentType >; - alt?: Record>; }; export const iconMap: IconMap = { - alt: { - ...generateMimeTypeToIconMap({ - FileArchiveIcon: fileIconSet.FileArchiveIconAlt, - FileCodeIcon: fileIconSet.FileCodeIconAlt, - FileExcelIcon: fileIconSet.FileExcelIconAlt, - FilePdfIcon: fileIconSet.FilePdfIcon, - FilePowerPointIcon: fileIconSet.FilePowerPointIconAlt, - FileWordIcon: fileIconSet.FileWordIconAlt, - }), - ...generateGeneralTypeToIconMap({ - FileAltIcon: fileIconSet.FileFallbackIcon, - FileAudioIcon: fileIconSet.FileAudioIconAlt, - FileImageIcon: fileIconSet.FileImageIcon, - FileVideoIcon: fileIconSet.FileVideoIconAlt, - }), - fallback: fileIconSet.FileFallbackIcon, - }, standard: { ...generateMimeTypeToIconMap({ FileArchiveIcon: fileIconSet.FileArchiveIcon, @@ -130,7 +103,6 @@ export const iconMap: IconMap = { ...generateGeneralTypeToIconMap({ FileAltIcon: fileIconSet.FileFallbackIcon, FileAudioIcon: fileIconSet.FileAudioIcon, - FileImageIcon: fileIconSet.FileImageIcon, FileVideoIcon: fileIconSet.FileVideoIcon, }), fallback: fileIconSet.FileFallbackIcon, diff --git a/src/components/ReactFileUtilities/FileIcon/index.ts b/src/components/FileIcon/index.ts similarity index 100% rename from src/components/ReactFileUtilities/FileIcon/index.ts rename to src/components/FileIcon/index.ts diff --git a/src/components/FileIcon/mimeTypes.ts b/src/components/FileIcon/mimeTypes.ts new file mode 100644 index 000000000..717f40150 --- /dev/null +++ b/src/components/FileIcon/mimeTypes.ts @@ -0,0 +1,340 @@ +export type GeneralType = 'audio/' | 'video/' | 'image/' | 'text/'; + +export type SupportedMimeType = + | (typeof wordMimeTypes)[number] + | (typeof excelMimeTypes)[number] + | (typeof powerpointMimeTypes)[number] + | (typeof archiveFileTypes)[number] + | (typeof codeFileTypes)[number]; + +export const wordMimeTypes = [ + // Microsoft Word + // .doc .dot + 'application/msword', + // .doc .dot + 'application/msword-template', + // .docx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + // .dotx (no test) + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + // .docm + 'application/vnd.ms-word.document.macroEnabled.12', + // .dotm (no test) + 'application/vnd.ms-word.template.macroEnabled.12', + + // LibreOffice/OpenOffice Writer + // .odt + 'application/vnd.oasis.opendocument.text', + // .ott + 'application/vnd.oasis.opendocument.text-template', + // .fodt + 'application/vnd.oasis.opendocument.text-flat-xml', + // .uot + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const excelMimeTypes = [ + // .csv + 'text/csv', + // TODO: maybe more data files + + // Microsoft Excel + // .xls .xlt .xla (no test for .xla) + 'application/vnd.ms-excel', + // .xlsx + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + // .xltx (no test) + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + // .xlsm + 'application/vnd.ms-excel.sheet.macroEnabled.12', + // .xltm (no test) + 'application/vnd.ms-excel.template.macroEnabled.12', + // .xlam (no test) + 'application/vnd.ms-excel.addin.macroEnabled.12', + // .xlsb (no test) + 'application/vnd.ms-excel.addin.macroEnabled.12', + + // LibreOffice/OpenOffice Calc + // .ods + 'application/vnd.oasis.opendocument.spreadsheet', + // .ots + 'application/vnd.oasis.opendocument.spreadsheet-template', + // .fods + 'application/vnd.oasis.opendocument.spreadsheet-flat-xml', + // .uos + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const powerpointMimeTypes = [ + // Microsoft Word + // .ppt .pot .pps .ppa (no test for .ppa) + 'application/vnd.ms-powerpoint', + // .pptx + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // .potx (no test) + 'application/vnd.openxmlformats-officedocument.presentationml.template', + // .ppsx + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + // .ppam + 'application/vnd.ms-powerpoint.addin.macroEnabled.12', + // .pptm + 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', + // .potm + 'application/vnd.ms-powerpoint.template.macroEnabled.12', + // .ppsm + 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', + + // LibreOffice/OpenOffice Writer + // .odp + 'application/vnd.oasis.opendocument.presentation', + // .otp + 'application/vnd.oasis.opendocument.presentation-template', + // .fodp + 'application/vnd.oasis.opendocument.presentation-flat-xml', + // .uop + // NOTE: firefox doesn't know mimetype so maybe ignore +]; + +export const archiveFileTypes = [ + // .zip + 'application/zip', + // .z7 + 'application/x-7z-compressed', + // .ar + 'application/x-archive', + // .tar + 'application/x-tar', + // .tar.gz + 'application/gzip', + // .tar.Z + 'application/x-compress', + // .tar.bz2 + 'application/x-bzip', + // .tar.lz + 'application/x-lzip', + // .tar.lz4 + 'application/x-lz4', + // .tar.lzma + 'application/x-lzma', + // .tar.lzo (no test) + 'application/x-lzop', + // .tar.xz + 'application/x-xz', + // .war + 'application/x-webarchive', + // .rar + 'application/vnd.rar', +]; + +export const codeFileTypes = [ + // .html .htm + 'text/html', + // .css + 'text/css', + // .js + 'application/x-javascript', + 'text/javascript', + // .json + 'application/json', + // .py + 'text/x-python', + // .go + 'text/x-go', + // .c + 'text/x-csrc', + // .cpp + 'text/x-c++src', + // .rb + 'application/x-ruby', + // .rust + 'text/rust', + // .java + 'text/x-java', + // .php + 'application/x-php', + // .cs + 'text/x-csharp', + // .scala + 'text/x-scala', + // .erl + 'text/x-erlang', + // .sh + 'application/x-shellscript', +]; + +export const mimeTypeToExtensionMap: Record = { + // Application types (sorted alphabetically) + 'application/epub+zip': 'epub', + 'application/gzip': 'gz', + 'application/java-archive': 'jar', + 'application/json': 'json', + 'application/ld+json': 'jsonld', + 'application/msword': 'doc', + 'application/msword-template': 'dot', + 'application/octet-stream': 'bin', + 'application/ogg': 'ogx', + 'application/pdf': 'pdf', + 'application/postscript': 'ps', + 'application/rtf': 'rtf', + 'application/vnd.amazon.ebook': 'azw', + 'application/vnd.apple.installer+xml': 'mpkg', + 'application/vnd.mozilla.xul+xml': 'xul', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.ms-excel.addin.macroEnabled.12': 'xlam', + 'application/vnd.ms-excel.sheet.macroEnabled.12': 'xlsm', + 'application/vnd.ms-excel.template.macroEnabled.12': 'xltm', + 'application/vnd.ms-fontobject': 'eot', + 'application/vnd.ms-powerpoint': 'ppt', + 'application/vnd.ms-powerpoint.addin.macroEnabled.12': 'ppam', + 'application/vnd.ms-powerpoint.presentation.macroEnabled.12': 'pptm', + 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12': 'ppsm', + 'application/vnd.ms-powerpoint.template.macroEnabled.12': 'potm', + 'application/vnd.ms-word.document.macroEnabled.12': 'docm', + 'application/vnd.ms-word.template.macroEnabled.12': 'dotm', + 'application/vnd.oasis.opendocument.presentation': 'odp', + 'application/vnd.oasis.opendocument.presentation-flat-xml': 'fodp', + 'application/vnd.oasis.opendocument.presentation-template': 'otp', + 'application/vnd.oasis.opendocument.spreadsheet': 'ods', + 'application/vnd.oasis.opendocument.spreadsheet-flat-xml': 'fods', + 'application/vnd.oasis.opendocument.spreadsheet-template': 'ots', + 'application/vnd.oasis.opendocument.text': 'odt', + 'application/vnd.oasis.opendocument.text-flat-xml': 'fodt', + 'application/vnd.oasis.opendocument.text-template': 'ott', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.slideshow': 'ppsx', + 'application/vnd.openxmlformats-officedocument.presentationml.template': 'potx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': 'xltx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': 'dotx', + 'application/vnd.rar': 'rar', + 'application/vnd.visio': 'vsd', + 'application/wasm': 'wasm', + 'application/x-7z-compressed': '7z', + 'application/x-abiword': 'abw', + 'application/x-archive': 'ar', + 'application/x-bzip': 'bz', + 'application/x-bzip2': 'bz2', + 'application/x-cdf': 'cda', + 'application/x-compress': 'Z', + 'application/x-csh': 'csh', + 'application/x-dosexec': 'exe', + 'application/x-freearc': 'arc', + 'application/x-httpd-php': 'php', + 'application/x-iso9660-image': 'iso', + 'application/x-javascript': 'js', + 'application/x-lz4': 'lz4', + 'application/x-lzip': 'lz', + 'application/x-lzma': 'lzma', + 'application/x-lzop': 'lzo', + 'application/x-mobipocket-ebook': 'mobi', + 'application/x-msdownload': 'exe', + 'application/x-perl': 'pl', + 'application/x-php': 'php', + 'application/x-rar-compressed': 'rar', + 'application/x-ruby': 'rb', + 'application/x-sh': 'sh', + 'application/x-shellscript': 'sh', + 'application/x-shockwave-flash': 'swf', + 'application/x-sql': 'sql', + 'application/x-stuffit': 'sit', + 'application/x-tar': 'tar', + 'application/x-webarchive': 'war', + 'application/x-xz': 'xz', + 'application/x-yaml': 'yaml', + 'application/xhtml+xml': 'xhtml', + 'application/xml': 'xml', + 'application/zip': 'zip', + + // Audio types + 'audio/aac': 'aac', + 'audio/flac': 'flac', + 'audio/midi': 'midi', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mp3', + 'audio/ogg': 'oga', + 'audio/opus': 'opus', + 'audio/wav': 'wav', + 'audio/webm': 'weba', + 'audio/x-aiff': 'aiff', + 'audio/x-m4a': 'm4a', + 'audio/x-midi': 'midi', + 'audio/x-ms-wma': 'wma', + 'audio/x-wav': 'wav', + + // Font types + 'font/otf': 'otf', + 'font/ttf': 'ttf', + 'font/woff': 'woff', + 'font/woff2': 'woff2', + + // Image types + 'image/apng': 'apng', + 'image/avif': 'avif', + 'image/bmp': 'bmp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/svg+xml': 'svg', + 'image/tiff': 'tiff', + 'image/vnd.microsoft.icon': 'ico', + 'image/webp': 'webp', + 'image/x-icon': 'ico', + + // Text types + 'text/calendar': 'ics', + 'text/css': 'css', + 'text/csv': 'csv', + 'text/html': 'html', + 'text/javascript': 'js', + 'text/markdown': 'md', + 'text/plain': 'txt', + 'text/rtf': 'rtf', + 'text/rust': 'rs', + 'text/tab-separated-values': 'tsv', + 'text/vcard': 'vcf', + 'text/x-c': 'c', + 'text/x-c++src': 'cpp', + 'text/x-csharp': 'cs', + 'text/x-csrc': 'c', + 'text/x-diff': 'diff', + 'text/x-erlang': 'erl', + 'text/x-go': 'go', + 'text/x-java': 'java', + 'text/x-java-source': 'java', + 'text/x-kotlin': 'kt', + 'text/x-lua': 'lua', + 'text/x-markdown': 'md', + 'text/x-objectivec': 'm', + 'text/x-pascal': 'pas', + 'text/x-perl': 'pl', + 'text/x-python': 'py', + 'text/x-ruby': 'rb', + 'text/x-rust': 'rs', + 'text/x-scala': 'scala', + 'text/x-sh': 'sh', + 'text/x-shellscript': 'sh', + 'text/x-sql': 'sql', + 'text/x-swift': 'swift', + 'text/x-typescript': 'ts', + 'text/x-yaml': 'yaml', + 'text/xml': 'xml', + 'text/yaml': 'yaml', + + // Video types + 'video/3gpp': '3gp', + 'video/3gpp2': '3g2', + 'video/mp2t': 'ts', + 'video/mp4': 'mp4', + 'video/mpeg': 'mpeg', + 'video/ogg': 'ogv', + 'video/quicktime': 'mov', + 'video/webm': 'webm', + 'video/x-flv': 'flv', + 'video/x-m4v': 'm4v', + 'video/x-matroska': 'mkv', + 'video/x-ms-wmv': 'wmv', + 'video/x-msvideo': 'avi', +}; diff --git a/src/components/FileIcon/styling/FileIcon.scss b/src/components/FileIcon/styling/FileIcon.scss new file mode 100644 index 000000000..cc6037900 --- /dev/null +++ b/src/components/FileIcon/styling/FileIcon.scss @@ -0,0 +1,16 @@ +.str-chat { + .str-chat__file-icon { + fill: none; + width: 32px; + height: 40px; + + .str-chat__file-icon__label { + fill: white; + //font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.1px; + text-anchor: middle; + } + } +} \ No newline at end of file diff --git a/src/components/Gallery/Image.tsx b/src/components/Gallery/Image.tsx index 097a30825..d8eaf28a9 100644 --- a/src/components/Gallery/Image.tsx +++ b/src/components/Gallery/Image.tsx @@ -10,8 +10,10 @@ import { useComponentContext } from '../../context'; import type { Attachment } from 'stream-chat'; import type { Dimensions } from '../../types/types'; +import clsx from 'clsx'; export type ImageProps = { + className?: string; dimensions?: Dimensions; innerRef?: MutableRefObject; previewUrl?: string; @@ -33,6 +35,7 @@ export type ImageProps = { */ export const ImageComponent = (props: ImageProps) => { const { + className, dimensions = {}, fallback, image_url, @@ -62,7 +65,7 @@ export const ImageComponent = (props: ImageProps) => { <> ) => ( + +); diff --git a/src/components/Icons/IconArrowRotateClockwise.tsx b/src/components/Icons/IconArrowRotateClockwise.tsx index f0571665a..5de62debb 100644 --- a/src/components/Icons/IconArrowRotateClockwise.tsx +++ b/src/components/Icons/IconArrowRotateClockwise.tsx @@ -1,16 +1,16 @@ import clsx from 'clsx'; import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; export const IconArrowRotateClockwise = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconCamera.tsx b/src/components/Icons/IconCamera.tsx index 855314f85..65e7cc7e2 100644 --- a/src/components/Icons/IconCamera.tsx +++ b/src/components/Icons/IconCamera.tsx @@ -1,17 +1,14 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconCamera = ({ className, ...props }: ComponentProps<'svg'>) => ( - - - + + + ); diff --git a/src/components/Icons/IconChainLink.tsx b/src/components/Icons/IconChainLink.tsx new file mode 100644 index 000000000..f66b9d20f --- /dev/null +++ b/src/components/Icons/IconChainLink.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconChainLink = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + + + + + + + +); diff --git a/src/components/Icons/IconChevronRight.tsx b/src/components/Icons/IconChevronRight.tsx new file mode 100644 index 000000000..bc4750af1 --- /dev/null +++ b/src/components/Icons/IconChevronRight.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconChevronRight = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconClose.tsx b/src/components/Icons/IconClose.tsx index f9a0b20a7..77e40ad34 100644 --- a/src/components/Icons/IconClose.tsx +++ b/src/components/Icons/IconClose.tsx @@ -1,13 +1,13 @@ import { type ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconClose = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconCommand.tsx b/src/components/Icons/IconCommand.tsx new file mode 100644 index 000000000..05c74bb46 --- /dev/null +++ b/src/components/Icons/IconCommand.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconCommand = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconExclamationCircle.tsx b/src/components/Icons/IconExclamationCircle.tsx index 6c6c02111..c39e1bcfd 100644 --- a/src/components/Icons/IconExclamationCircle.tsx +++ b/src/components/Icons/IconExclamationCircle.tsx @@ -1,17 +1,17 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconExclamationCircle = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconExclamationTriangle.tsx b/src/components/Icons/IconExclamationTriangle.tsx index f12978d0f..372d53e7a 100644 --- a/src/components/Icons/IconExclamationTriangle.tsx +++ b/src/components/Icons/IconExclamationTriangle.tsx @@ -1,20 +1,20 @@ import clsx from 'clsx'; import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; export const IconExclamationTriangle = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconFile.tsx b/src/components/Icons/IconFile.tsx new file mode 100644 index 000000000..39cd5cb98 --- /dev/null +++ b/src/components/Icons/IconFile.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconFile = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconLocationPin.tsx b/src/components/Icons/IconLocationPin.tsx new file mode 100644 index 000000000..ca3232a1a --- /dev/null +++ b/src/components/Icons/IconLocationPin.tsx @@ -0,0 +1,14 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconLocationPin = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/IconMicrophone.tsx b/src/components/Icons/IconMicrophone.tsx index 6a029d93f..8ea5193f1 100644 --- a/src/components/Icons/IconMicrophone.tsx +++ b/src/components/Icons/IconMicrophone.tsx @@ -1,17 +1,16 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconMicrophone = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPaperPlane.tsx b/src/components/Icons/IconPaperPlane.tsx index 8392c9821..7d48e3114 100644 --- a/src/components/Icons/IconPaperPlane.tsx +++ b/src/components/Icons/IconPaperPlane.tsx @@ -1,17 +1,16 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconPaperPlane = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPause.tsx b/src/components/Icons/IconPause.tsx index f90341ba1..cc98cd96c 100644 --- a/src/components/Icons/IconPause.tsx +++ b/src/components/Icons/IconPause.tsx @@ -1,14 +1,14 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconPause = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPlaySolid.tsx b/src/components/Icons/IconPlaySolid.tsx index ad0b04e2e..5eebd8f42 100644 --- a/src/components/Icons/IconPlaySolid.tsx +++ b/src/components/Icons/IconPlaySolid.tsx @@ -1,13 +1,13 @@ import clsx from 'clsx'; import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; export const IconPlaySolid = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPlus.tsx b/src/components/Icons/IconPlus.tsx index ace6cffa4..9d0aefcb9 100644 --- a/src/components/Icons/IconPlus.tsx +++ b/src/components/Icons/IconPlus.tsx @@ -1,14 +1,13 @@ import type { ComponentProps } from 'react'; import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; export const IconPlus = ({ className, ...props }: ComponentProps<'svg'>) => ( - - + ); diff --git a/src/components/Icons/IconPoll.tsx b/src/components/Icons/IconPoll.tsx new file mode 100644 index 000000000..e630ed3f6 --- /dev/null +++ b/src/components/Icons/IconPoll.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; + +export const IconPoll = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconVideoCamera.tsx b/src/components/Icons/IconVideoCamera.tsx new file mode 100644 index 000000000..591238fda --- /dev/null +++ b/src/components/Icons/IconVideoCamera.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconVideoCamera = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconVideoCameraOutline.tsx b/src/components/Icons/IconVideoCameraOutline.tsx new file mode 100644 index 000000000..c45dd9e38 --- /dev/null +++ b/src/components/Icons/IconVideoCameraOutline.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; + +export const IconVideoCameraOutline = ({ + className, + ...props +}: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 9cf994a91..0fa4bfd28 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -1,10 +1,18 @@ export * from './IconArrowRotateClockwise'; export * from './IconCamera'; +export * from './IconChainLink'; +export * from './IconChevronRight'; export * from './IconClose'; +export * from './IconCommand'; export * from './IconExclamationCircle'; export * from './IconExclamationTriangle'; +export * from './IconFile'; +export * from './IconLocationPin'; export * from './IconMicrophone'; export * from './IconPaperPlane'; export * from './IconPause'; export * from './IconPlaySolid'; export * from './IconPlus'; +export * from './IconPoll'; +export * from './IconVideoCamera'; +export * from './IconVideoCameraOutline'; diff --git a/src/components/Icons/styling/IconCamera.scss b/src/components/Icons/styling/IconCamera.scss new file mode 100644 index 000000000..8426bfb27 --- /dev/null +++ b/src/components/Icons/styling/IconCamera.scss @@ -0,0 +1,12 @@ +.str-chat__icon--camera { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconChainLink.scss b/src/components/Icons/styling/IconChainLink.scss new file mode 100644 index 000000000..4d9675d84 --- /dev/null +++ b/src/components/Icons/styling/IconChainLink.scss @@ -0,0 +1,17 @@ +.str-chat__icon--chain-link { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.2; + } + + #clip-path rect { + fill: var(--base-white); + height: 12px; + width: 12px; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconChevronRight.scss b/src/components/Icons/styling/IconChevronRight.scss new file mode 100644 index 000000000..cef40bad3 --- /dev/null +++ b/src/components/Icons/styling/IconChevronRight.scss @@ -0,0 +1,13 @@ +.str-chat__icon--chevron-right { + fill: none; + height: 16px; + width: 16px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconCommand.scss b/src/components/Icons/styling/IconCommand.scss new file mode 100644 index 000000000..0a66be4e0 --- /dev/null +++ b/src/components/Icons/styling/IconCommand.scss @@ -0,0 +1,13 @@ +.str-chat__icon--command { + fill: none; + height: 16px; + width: 16px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.5; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconFile.scss b/src/components/Icons/styling/IconFile.scss new file mode 100644 index 000000000..375cb5606 --- /dev/null +++ b/src/components/Icons/styling/IconFile.scss @@ -0,0 +1,11 @@ +.str-chat__icon--file { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconLocationPin.scss b/src/components/Icons/styling/IconLocationPin.scss new file mode 100644 index 000000000..45a395fcd --- /dev/null +++ b/src/components/Icons/styling/IconLocationPin.scss @@ -0,0 +1,11 @@ +.str-chat__icon--location-pin { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconMicrophone.scss b/src/components/Icons/styling/IconMicrophone.scss index 9994a1ec7..81d7149fb 100644 --- a/src/components/Icons/styling/IconMicrophone.scss +++ b/src/components/Icons/styling/IconMicrophone.scss @@ -1,5 +1,7 @@ .str-chat { .str-chat__icon--microphone { + fill: none; + stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; diff --git a/src/components/Icons/styling/IconPaperPlane.scss b/src/components/Icons/styling/IconPaperPlane.scss index ad6449b71..e846cbafd 100644 --- a/src/components/Icons/styling/IconPaperPlane.scss +++ b/src/components/Icons/styling/IconPaperPlane.scss @@ -1,6 +1,7 @@ .str-chat { .str-chat__icon--paper-plane { stroke-width: 1.5; + stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; } diff --git a/src/components/Icons/styling/IconPlus.scss b/src/components/Icons/styling/IconPlus.scss index 6e4e4ab62..d33e894b7 100644 --- a/src/components/Icons/styling/IconPlus.scss +++ b/src/components/Icons/styling/IconPlus.scss @@ -1,6 +1,7 @@ .str-chat { .str-chat__icon--plus { stroke-width: 1.5; + stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; } diff --git a/src/components/Icons/styling/IconPoll.scss b/src/components/Icons/styling/IconPoll.scss new file mode 100644 index 000000000..98da1d1d0 --- /dev/null +++ b/src/components/Icons/styling/IconPoll.scss @@ -0,0 +1,13 @@ +.str-chat__icon--poll { + fill: none; + height: 10px; + width: 10px; + + path { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconVideoCameraOutline.scss b/src/components/Icons/styling/IconVideoCameraOutline.scss new file mode 100644 index 000000000..55166bc30 --- /dev/null +++ b/src/components/Icons/styling/IconVideoCameraOutline.scss @@ -0,0 +1,11 @@ +.str-chat__icon--video-camera-outline { + fill: none; + height: 12px; + width: 12px; + + path { + stroke: currentColor; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/index.scss b/src/components/Icons/styling/index.scss index 911d3e5c2..6b73e2b3e 100644 --- a/src/components/Icons/styling/index.scss +++ b/src/components/Icons/styling/index.scss @@ -1,9 +1,17 @@ @use 'IconArrowRotateClockwise'; +@use 'IconCamera'; +@use 'IconChainLink'; +@use 'IconChevronRight'; @use 'IconClose'; +@use 'IconCommand'; @use 'IconExclamationCircle'; @use 'IconExclamationTriangle'; +@use 'IconFile'; +@use 'IconLocationPin'; @use 'IconMicrophone'; @use 'IconPaperPlane'; @use 'IconPause'; @use 'IconPlaySolid'; -@use 'IconPlus'; \ No newline at end of file +@use 'IconPlus'; +@use 'IconPoll'; +@use 'IconVideoCameraOutline'; \ No newline at end of file diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx index 545468dd2..117461973 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx @@ -6,11 +6,10 @@ import { BinIcon, CheckSignIcon, LoadingIndicatorIcon, - MicIcon, PauseIcon, - SendIcon, } from '../../MessageInput'; import { useMessageInputContext } from '../../../context/MessageInputContext'; +import { IconMicrophone, IconPaperPlane } from '../../Icons'; export const AudioRecorder = () => { const messageInputContext = useMessageInputContext(); @@ -59,7 +58,7 @@ export const AudioRecorder = () => { className='str-chat__audio_recorder__resume-recording-button' onClick={recorder.resume} > - + )} {state.recording && ( @@ -78,7 +77,7 @@ export const AudioRecorder = () => { disabled={isUploadingFile} onClick={completeRecording} > - {isUploadingFile ? : } + {isUploadingFile ? : } ) : ( -); +export const StartRecordingAudioButton = forwardRef< + HTMLButtonElement, + StartRecordingAudioButtonProps +>(function StartRecordingAudioButton(props, ref) { + return ( + + ); +}); diff --git a/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx b/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx index fde46039b..8b5079162 100644 --- a/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx +++ b/src/components/MediaRecorder/RecordingPermissionDeniedNotification.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { useTranslationContext } from '../../context'; - import type { RecordingPermission } from './classes/BrowserPermission'; +import { Button } from '../Button'; +import clsx from 'clsx'; export type RecordingPermissionDeniedNotificationProps = { onClose: () => void; @@ -33,12 +34,17 @@ export const RecordingPermissionDeniedNotification = ({ {permissionTranslations.body[permissionName]}

- +
); diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index 21d978f32..f205c1be7 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { ButtonWithSubmenu } from '../Dialog'; +import { ContextMenuButton } from '../Dialog'; import type { ComponentProps } from 'react'; export const RemindMeActionButton = ({ @@ -10,14 +10,14 @@ export const RemindMeActionButton = ({ const { t } = useTranslationContext(); return ( - {t('Remind Me')} - + ); }; diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx index d6cebcf31..c45050c99 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -9,38 +9,48 @@ import { isLocalVoiceRecordingAttachment, isScrapedContent, } from 'stream-chat'; -import type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; -import { UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview } from './UnsupportedAttachmentPreview'; -import type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; -import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; -import type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; -import DefaultFilePreview from './FileAttachmentPreview'; -import type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; -import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; +import { + UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview, + type UnsupportedAttachmentPreviewProps, +} from './UnsupportedAttachmentPreview'; +import { type VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; +import { + FileAttachmentPreview as DefaultFileAttachmentPreview, + type FileAttachmentPreviewProps, +} from './FileAttachmentPreview'; +import { + type AudioAttachmentPreviewProps, + AudioAttachmentPreview as DefaultAudioAttachmentPreview, +} from './AudioAttachmentPreview'; +import { type ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; import { useAttachmentsForPreview, useMessageComposer } from '../hooks'; import { GeolocationPreview as DefaultGeolocationPreview, type GeolocationPreviewProps, } from './GeolocationPreview'; +import { + MediaAttachmentPreview, + type MediaAttachmentPreviewProps, +} from './MediaAttachmentPreview'; export type AttachmentPreviewListProps = { - AudioAttachmentPreview?: ComponentType; + AudioAttachmentPreview?: ComponentType; FileAttachmentPreview?: ComponentType; GeolocationPreview?: ComponentType; ImageAttachmentPreview?: ComponentType; UnsupportedAttachmentPreview?: ComponentType; - VideoAttachmentPreview?: ComponentType; + VideoAttachmentPreview?: ComponentType; VoiceRecordingPreview?: ComponentType; }; export const AttachmentPreviewList = ({ - AudioAttachmentPreview = DefaultFilePreview, - FileAttachmentPreview = DefaultFilePreview, + AudioAttachmentPreview = DefaultAudioAttachmentPreview, + FileAttachmentPreview = DefaultFileAttachmentPreview, GeolocationPreview = DefaultGeolocationPreview, - ImageAttachmentPreview = DefaultImagePreview, + ImageAttachmentPreview = MediaAttachmentPreview, UnsupportedAttachmentPreview = DefaultUnknownAttachmentPreview, - VideoAttachmentPreview = DefaultFilePreview, - VoiceRecordingPreview = DefaultVoiceRecordingPreview, + VideoAttachmentPreview = MediaAttachmentPreview, + VoiceRecordingPreview = DefaultAudioAttachmentPreview, }: AttachmentPreviewListProps) => { const messageComposer = useMessageComposer(); @@ -51,82 +61,82 @@ export const AttachmentPreviewList = ({ return (
-
- {location && ( - - )} - {attachments.map((attachment) => { - if (isScrapedContent(attachment)) return null; - if (isLocalVoiceRecordingAttachment(attachment)) { - return ( - - ); - } else if (isLocalAudioAttachment(attachment)) { - return ( - - ); - } else if (isLocalVideoAttachment(attachment)) { - return ( - - ); - } else if (isLocalImageAttachment(attachment)) { - return ( - - ); - } else if (isLocalFileAttachment(attachment)) { - return ( - - ); - } else if (isLocalAttachment(attachment)) { - return ( - - ); + {/**/} + {location && ( + + /> + )} + {attachments.map((attachment) => { + if (isScrapedContent(attachment)) return null; + if (isLocalVoiceRecordingAttachment(attachment)) { + return ( + + ); + } else if (isLocalAudioAttachment(attachment)) { + return ( + + ); + } else if (isLocalVideoAttachment(attachment)) { + return ( + + ); + } else if (isLocalImageAttachment(attachment)) { + return ( + + ); + } else if (isLocalFileAttachment(attachment)) { + return ( + + ); + } else if (isLocalAttachment(attachment)) { + return ( + + ); + } + return null; + })} + {/*
*/}
); }; diff --git a/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx new file mode 100644 index 000000000..e064a6eaa --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -0,0 +1,163 @@ +import type { UploadAttachmentPreviewProps } from './types'; +import type { LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; +import { useTranslationContext } from '../../../context'; +import React, { useEffect } from 'react'; +import clsx from 'clsx'; +import { LoadingIndicatorIcon } from '../icons'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; +import { FileSizeIndicator, WaveProgressBar } from '../../Attachment'; +import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; +import { PlayButton } from '../../Button'; +import { + type AudioPlayerState, + DurationDisplay, + useAudioPlayer, +} from '../../AudioPlayback'; +import { useStateStore } from '../../../store'; + +export type AudioAttachmentPreviewProps> = + UploadAttachmentPreviewProps< + | LocalAudioAttachment + | LocalVoiceRecordingAttachment + >; + +const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + isPlaying: state.isPlaying, + progressPercent: state.progressPercent, + secondsElapsed: state.secondsElapsed, +}); + +export const AudioAttachmentPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: AudioAttachmentPreviewProps) => { + const { t } = useTranslationContext(); + const { id, previewUri, uploadPermissionCheck, uploadState } = + attachment.localMetadata ?? {}; + const url = attachment.asset_url || previewUri; + + const audioPlayer = useAudioPlayer({ + fileSize: attachment.localMetadata.file.size, + mimeType: attachment.localMetadata.file.type, + requester: attachment.localMetadata.id, + src: url, + title: attachment.title, + waveformData: attachment.waveform_data, + }); + + useEffect(() => { + audioPlayer?.cancelScheduledRemoval(); + return () => { + audioPlayer?.scheduleRemoval(); + }; + }, [audioPlayer]); + + const { isPlaying, progressPercent, secondsElapsed } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + const hasWaveform = !!audioPlayer?.waveformData?.length; + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasError = hasRetriableError || hasFatalError; + + const showProgressControls = !hasError || (hasError && isPlaying); + + return ( + + { + audioPlayer?.togglePlay(); + }} + /> + +
+
+ {attachment.title} +
+
+ {uploadState === 'uploading' && } + {showProgressControls ? ( + <> + {!attachment.duration && !progressPercent && !isPlaying && ( + + )} + {hasWaveform ? ( + <> + + + + ) : ( + + )} + + ) : hasFatalError ? ( +
+ + + {hasSizeLimitError + ? t('File too large') + : uploadState === 'blocked' + ? t('Upload blocked') + : t('Upload failed')} + +
+ ) : ( +
+ + {t('Upload error')} + +
+ )} +
+
+ { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx index 4e03fe9d2..9f60f2c7b 100644 --- a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,88 +1,87 @@ import React from 'react'; import { useTranslationContext } from '../../../context'; -import { FileIcon } from '../../ReactFileUtilities'; -import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import { FileIcon } from '../../FileIcon'; +import { LoadingIndicatorIcon } from '../icons'; -import type { - LocalAudioAttachment, - LocalFileAttachment, - LocalVideoAttachment, -} from 'stream-chat'; +import type { LocalFileAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; +import { FileSizeIndicator } from '../../Attachment'; +import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons'; export type FileAttachmentPreviewProps = - UploadAttachmentPreviewProps< - | LocalFileAttachment - | LocalAudioAttachment - | LocalVideoAttachment - >; + UploadAttachmentPreviewProps>; -const FileAttachmentPreview = ({ +export const FileAttachmentPreview = ({ attachment, handleRetry, removeAttachments, }: FileAttachmentPreviewProps) => { const { t } = useTranslationContext('FilePreview'); - const uploadState = attachment.localMetadata?.uploadState; + const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasError = hasRetriableError || hasFatalError; return ( -
-
- +
+
- - - {['blocked', 'failed'].includes(uploadState) && !!handleRetry && ( - - )} - -
+
{attachment.title}
- {/* undefined if loaded from a draft */} - {(typeof uploadState === 'undefined' || uploadState === 'finished') && - !!attachment.asset_url && ( - - - +
+ {uploadState === 'uploading' && } + {!hasError && } + {hasFatalError && ( +
+ + + {hasSizeLimitError + ? t('File too large') + : uploadState === 'blocked' + ? t('Upload blocked') + : t('Upload failed')} + +
)} - {uploadState === 'uploading' && } + {hasRetriableError && ( +
+ + {t('Upload error')} + +
+ )} +
-
+ + { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> + ); }; -export default FileAttachmentPreview; diff --git a/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx index 8faa174ef..b91b839aa 100644 --- a/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx @@ -1,9 +1,9 @@ import type { LiveLocationPreview, StaticLocationPreview } from 'stream-chat'; -import { CloseIcon } from '../icons'; import type { ComponentType } from 'react'; import React from 'react'; import { useTranslationContext } from '../../../context'; import { GeolocationIcon } from '../../Attachment/icons'; +import { IconClose } from '../../Icons'; type GeolocationPreviewImageProps = { location: StaticLocationPreview | LiveLocationPreview; @@ -33,12 +33,12 @@ export const GeolocationPreview = ({ {remove && ( )} diff --git a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx index 91008df19..7481338f6 100644 --- a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx @@ -1,72 +1,5 @@ -import clsx from 'clsx'; -import React, { useCallback, useState } from 'react'; -import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { BaseImage as DefaultBaseImage } from '../../Gallery'; -import { useComponentContext, useTranslationContext } from '../../../context'; import type { LocalImageAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; export type ImageAttachmentPreviewProps> = UploadAttachmentPreviewProps>; - -export const ImageAttachmentPreview = ({ - attachment, - handleRetry, - removeAttachments, -}: ImageAttachmentPreviewProps) => { - const { t } = useTranslationContext('ImagePreviewItem'); - const { BaseImage = DefaultBaseImage } = useComponentContext('ImagePreview'); - const [previewError, setPreviewError] = useState(false); - - const { id, uploadState } = attachment.localMetadata ?? {}; - - const handleLoadError = useCallback(() => setPreviewError(true), []); - const assetUrl = attachment.image_url || attachment.localMetadata.previewUri; - - return ( -
- - - {['blocked', 'failed'].includes(uploadState) && ( - - )} - - {uploadState === 'uploading' && ( -
- -
- )} - - {assetUrl && ( - - )} -
- ); -}; diff --git a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx new file mode 100644 index 000000000..ac102d29f --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -0,0 +1,138 @@ +import type { UploadAttachmentPreviewProps } from './types'; +import { + isVideoAttachment, + type LocalImageAttachment, + type LocalVideoAttachment, +} from 'stream-chat'; +import { useComponentContext, useTranslationContext } from '../../../context'; +import { BaseImage as DefaultBaseImage } from '../../Gallery'; +import React, { + type KeyboardEvent, + type MouseEvent, + useCallback, + useMemo, + useState, +} from 'react'; +import clsx from 'clsx'; +import { LoadingIndicatorIcon } from '../icons'; +import { + IconArrowRotateClockwise, + IconExclamationCircle, + IconVideoCamera, +} from '../../Icons'; +import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; +import { Button } from '../../Button'; +import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; + +export type MediaAttachmentPreviewProps> = + UploadAttachmentPreviewProps< + LocalVideoAttachment | LocalImageAttachment + >; + +export const MediaAttachmentPreview = ({ + attachment, + handleRetry, + removeAttachments, +}: MediaAttachmentPreviewProps) => { + const { t } = useTranslationContext(); + const { BaseImage = DefaultBaseImage, LoadingIndicator = LoadingIndicatorIcon } = + useComponentContext(); + const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false); + + const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {}; + + const isUploading = uploadState === 'uploading'; + const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []); + const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit'; + const hasFatalError = uploadState === 'blocked' || hasSizeLimitError; + const hasRetriableError = uploadState === 'failed' && !!handleRetry; + const hasUploadError = hasRetriableError || hasFatalError; + + const retry = (e: MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + handleRetry(attachment); + return false; + }; + + const thumbnail = useMemo( + () => + isVideoAttachment(attachment) + ? { + alt: attachment.title, + title: attachment.title, + url: attachment.thumb_url, + } + : { + alt: attachment.fallback, + title: attachment.fallback, + url: attachment.image_url || attachment.localMetadata.previewUri, + }, + [attachment], + ); + + return ( + +
+ {thumbnail.url && ( + + )} + +
+ {isUploading && } + + {isVideoAttachment(attachment) && + !hasUploadError && + uploadState !== 'uploading' && ( +
+ + {attachment.duration &&
{attachment.duration}
} +
+ )} + + {hasFatalError && } + + {hasRetriableError && ( + + )} +
+
+ + { + if (id) removeAttachments([id]); + }} + uploadState={uploadState} + /> +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx index e706bfe98..ab7563d92 100644 --- a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { isLocalUploadAttachment } from 'stream-chat'; -import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { FileIcon } from '../../ReactFileUtilities'; -import { useTranslationContext } from '../../../context'; import type { AnyLocalAttachment, LocalUploadAttachment } from 'stream-chat'; +import { type LocalFileAttachment } from 'stream-chat'; +import { FileAttachmentPreview } from './FileAttachmentPreview'; export type UnsupportedAttachmentPreviewProps< CustomLocalMetadata = Record, @@ -19,64 +17,10 @@ export const UnsupportedAttachmentPreview = ({ attachment, handleRetry, removeAttachments, -}: UnsupportedAttachmentPreviewProps) => { - const { t } = useTranslationContext('UnsupportedAttachmentPreview'); - const title = attachment.title ?? t('Unsupported attachment'); - return ( -
-
- -
- - - - {isLocalUploadAttachment(attachment) && - ['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && - !!handleRetry && ( - - )} - -
-
- {title} -
- {attachment.localMetadata?.uploadState === 'finished' && - !!attachment.asset_url && ( - - - - )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - - )} -
-
- ); -}; +}: UnsupportedAttachmentPreviewProps) => ( + +); diff --git a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx index 19b302f94..87a94a1f7 100644 --- a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx @@ -1,92 +1,5 @@ -import React, { useEffect } from 'react'; -import { PlayButton } from '../../Attachment'; -import { RecordingTimer } from '../../MediaRecorder'; -import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; -import { FileIcon } from '../../ReactFileUtilities'; import type { LocalVoiceRecordingAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; -import { useTranslationContext } from '../../../context'; -import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; -import { useStateStore } from '../../../store'; - -const audioPlayerStateSelector = (state: AudioPlayerState) => ({ - isPlaying: state.isPlaying, - secondsElapsed: state.secondsElapsed, -}); export type VoiceRecordingPreviewProps> = UploadAttachmentPreviewProps>; - -export const VoiceRecordingPreview = ({ - attachment, - handleRetry, - removeAttachments, -}: VoiceRecordingPreviewProps) => { - const { t } = useTranslationContext(); - - const audioPlayer = useAudioPlayer({ - mimeType: attachment.mime_type, - src: attachment.asset_url, - }); - - const { isPlaying, secondsElapsed } = - useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; - - useEffect(() => { - audioPlayer?.cancelScheduledRemoval(); - return () => { - audioPlayer?.scheduleRemoval(); - }; - }, [audioPlayer]); - - if (!audioPlayer) return null; - - return ( -
- - - - - {['blocked', 'failed'].includes(attachment.localMetadata?.uploadState) && - !!handleRetry && ( - - )} - -
-
- {attachment.title} -
- {typeof attachment.duration !== 'undefined' && ( - - )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - - )} -
-
- -
-
- ); -}; diff --git a/src/components/MessageInput/AttachmentPreviewList/index.ts b/src/components/MessageInput/AttachmentPreviewList/index.ts index f61151a21..3d7db62a3 100644 --- a/src/components/MessageInput/AttachmentPreviewList/index.ts +++ b/src/components/MessageInput/AttachmentPreviewList/index.ts @@ -1,7 +1,9 @@ export * from './AttachmentPreviewList'; +export type { AudioAttachmentPreviewProps } from './AudioAttachmentPreview'; export type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; export type { GeolocationPreviewProps } from './GeolocationPreview'; export type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; export type { UploadAttachmentPreviewProps as AttachmentPreviewProps } from './types'; export type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; +export type { MediaAttachmentPreviewProps } from './MediaAttachmentPreview'; export type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; diff --git a/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx new file mode 100644 index 000000000..22b22556a --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/utils/AttachmentPreviewRoot.tsx @@ -0,0 +1,113 @@ +import React, { + type ComponentProps, + type KeyboardEvent, + type MouseEvent, + useState, +} from 'react'; +import { useComponentContext, useTranslationContext } from '../../../../context'; +import { + isImageAttachment, + isVideoAttachment, + type LocalUploadAttachment, +} from 'stream-chat'; +import { GlobalModal } from '../../../Modal'; +import { ModalGallery } from '../../../Gallery'; +import { VideoPlayer } from '../../../VideoPlayer'; + +type AttachmentPreviewRootProps = Omit, 'onClick' | 'onKeyDown'> & { + attachment: LocalUploadAttachment; + /** + * Returns boolean value to signal whether the event handling should be terminated immediately (return false) + * or default logic can be executed next (return true) + */ + onPressed?: (e: MouseEvent | KeyboardEvent) => boolean; +}; + +const INTERACTIVE_SELECTOR = + 'button, a, input, textarea, select, [role="button"], [role="link"], [data-interactive="true"]'; + +function hasInteractiveAncestorBeforeRoot( + target: EventTarget | null, + root: HTMLElement | null, +): boolean { + if (!(target instanceof Element) || !root) return false; + + let el: Element | null = target; + while (el && el !== root) { + if (el.matches(INTERACTIVE_SELECTOR)) return true; + el = el.parentElement; + } + return false; +} + +// todo: use this component for all the attachment previews +export const AttachmentPreviewRoot = ({ + attachment, + onPressed, + tabIndex = 0, + ...props +}: AttachmentPreviewRootProps) => { + const { t } = useTranslationContext('FilePreview'); + const { Modal = GlobalModal } = useComponentContext(); + const [showPreview, setShowPreview] = useState(false); + const [root, setRoot] = useState(null); + const url = + attachment.asset_url || attachment.image_url || attachment.localMetadata.previewUri; + + const canDownloadAttachment = !!url; + + const canPreviewAttachment = + (!!url && isImageAttachment(attachment)) || isVideoAttachment(attachment); + + const handlePressed = (e: MouseEvent | KeyboardEvent) => { + if (e.defaultPrevented) return; + + if (hasInteractiveAncestorBeforeRoot(e.target as Element, root)) return; + + if (onPressed) { + const shouldContinue = onPressed(e); + if (!shouldContinue) return; + } + + if (canPreviewAttachment) { + setShowPreview(true); + return; + } + + if (canDownloadAttachment) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + + return ( +
{ + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + handlePressed(e); + }} + ref={setRoot} + role={showPreview ? 'button' : canDownloadAttachment ? 'link' : props.role} + tabIndex={showPreview || canDownloadAttachment ? tabIndex : -1} + > + {props.children} + { + e.stopPropagation(); + setShowPreview(false); + }} + open={showPreview && canPreviewAttachment} + > + {isImageAttachment(attachment) ? ( + + ) : isVideoAttachment(attachment) && url ? ( + + ) : null} + +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx similarity index 70% rename from src/components/MessageInput/AttachmentSelector.tsx rename to src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index b12585680..295d26f2a 100644 --- a/src/components/MessageInput/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -1,83 +1,99 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { UploadIcon as DefaultUploadIcon } from './icons'; -import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; -import { CHANNEL_CONTAINER_ID } from '../Channel/constants'; -import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; -import { DialogMenuButton } from '../Dialog/DialogMenu'; -import { Modal as DefaultModal } from '../Modal'; -import { ShareLocationDialog as DefaultLocationDialog } from '../Location'; -import { PollCreationDialog as DefaultPollCreationDialog } from '../Poll'; -import { Portal } from '../Portal/Portal'; -import { UploadFileInput } from '../ReactFileUtilities'; +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { useAttachmentManagerState } from '../hooks'; +import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; +import { + ContextMenu, + ContextMenuButton, + DialogAnchor, + useDialogIsOpen, + useDialogOnNearestManager, +} from '../../Dialog'; +import { Modal as DefaultModal } from '../../Modal'; +import { ShareLocationDialog as DefaultLocationDialog } from '../../Location'; +import { PollCreationDialog as DefaultPollCreationDialog } from '../../Poll'; +import { Portal } from '../../Portal/Portal'; +import { UploadFileInput } from '../../ReactFileUtilities'; import { useChannelStateContext, useComponentContext, useTranslationContext, -} from '../../context'; +} from '../../../context'; import { AttachmentSelectorContextProvider, useAttachmentSelectorContext, -} from '../../context/AttachmentSelectorContext'; -import { useStableId } from '../UtilityComponents/useStableId'; +} from '../../../context/AttachmentSelectorContext'; +import { useStableId } from '../../UtilityComponents/useStableId'; import clsx from 'clsx'; -import { useMessageComposer } from './hooks'; +import { useCooldownRemaining, useMessageComposer } from '../hooks'; +import { Button, type ButtonProps } from '../../Button'; +import { IconCommand, IconFile, IconLocationPin, IconPlus, IconPoll } from '../../Icons'; + +const AttachmentSelectorMenuInitButtonIcon = () => { + const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); + + if (AttachmentSelectorInitiationButtonContents) { + return ; + } + + return ; +}; + +export const AttachmentSelectorButton = forwardRef( + function AttachmentSelectorButton({ className, ...props }, ref) { + return ( + + ); + }, +); export const SimpleAttachmentSelector = () => { - const { - AttachmentSelectorInitiationButtonContents, - FileUploadIcon = DefaultUploadIcon, - } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const inputRef = useRef(null); - const [labelElement, setLabelElement] = useState(null); + const [buttonElement, setButtonElement] = useState(null); const id = useStableId(); + const cooldownRemaining = useCooldownRemaining(); useEffect(() => { - if (!labelElement) return; + if (!buttonElement) return; const handleKeyUp = (event: KeyboardEvent) => { if (![' ', 'Enter'].includes(event.key) || !inputRef.current) return; event.preventDefault(); inputRef.current.click(); }; - labelElement.addEventListener('keyup', handleKeyUp); + buttonElement.addEventListener('keyup', handleKeyUp); return () => { - labelElement.removeEventListener('keyup', handleKeyUp); + buttonElement.removeEventListener('keyup', handleKeyUp); }; - }, [labelElement]); + }, [buttonElement]); if (!channelCapabilities['upload-file']) return null; return ( -
+
+ inputRef.current?.click()} + ref={setButtonElement} + /> -
); }; -const AttachmentSelectorMenuInitButtonIcon = () => { - const { AttachmentSelectorInitiationButtonContents, FileUploadIcon } = - useComponentContext('SimpleAttachmentSelector'); - if (AttachmentSelectorInitiationButtonContents) { - return ; - } - if (FileUploadIcon) { - return ; - } - return
; -}; - export type AttachmentSelectorModalContentProps = { close: () => void; }; @@ -89,59 +105,78 @@ export type AttachmentSelectorActionProps = { export type AttachmentSelectorAction = { ActionButton: React.ComponentType; - type: 'uploadFile' | 'createPoll' | 'addLocation' | (string & {}); + type: 'uploadFile' | 'createPoll' | 'addLocation' | 'selectCommand' | (string & {}); ModalContent?: React.ComponentType; }; export const DefaultAttachmentSelectorComponents = { + // todo: we do not know how the submenu should look like + Command() { + const { t } = useTranslationContext(); + return ( + null} + > + {t('Commands')} + + ); + }, File({ closeMenu }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); const { fileInput } = useAttachmentSelectorContext(); const { isUploadEnabled } = useAttachmentManagerState(); return ( - { if (fileInput) fileInput.click(); closeMenu(); }} > {t('File')} - + ); }, Location({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( - { openModalForAction('addLocation'); closeMenu(); }} > {t('Location')} - + ); }, Poll({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( - { openModalForAction('createPoll'); closeMenu(); }} > {t('Poll')} - + ); }, }; +/** + * Order of AttachmentSelectorAction objects defines the order in the context menu width index 0 being at the top. + */ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ { ActionButton: DefaultAttachmentSelectorComponents.File, type: 'uploadFile' }, { @@ -152,6 +187,7 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ ActionButton: DefaultAttachmentSelectorComponents.Location, type: 'addLocation', }, + { ActionButton: DefaultAttachmentSelectorComponents.Command, type: 'selectCommand' }, ]; export type AttachmentSelectorProps = { @@ -204,6 +240,7 @@ export const AttachmentSelector = ({ const { Modal = DefaultModal } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const messageComposer = useMessageComposer(); + const cooldownRemaining = useCooldownRemaining(); const actions = useAttachmentSelectorActionsFiltered(attachmentSelectorActionSet); @@ -245,17 +282,17 @@ export const AttachmentSelector = ({
{channelCapabilities['upload-file'] && } - + /> -
@@ -275,7 +312,7 @@ export const AttachmentSelector = ({ openModalForAction={openModal} /> ))} -
+
>; }; export const CooldownTimer = ({ cooldownInterval }: CooldownTimerProps) => { const secondsLeft = useTimer({ startFrom: cooldownInterval }); diff --git a/src/components/MessageInput/LinkPreviewList.tsx b/src/components/MessageInput/LinkPreviewList.tsx index 00b7c75d6..c43cc2535 100644 --- a/src/components/MessageInput/LinkPreviewList.tsx +++ b/src/components/MessageInput/LinkPreviewList.tsx @@ -1,16 +1,18 @@ import clsx from 'clsx'; import React, { useState } from 'react'; -import type { - LinkPreview, - LinkPreviewsManagerState, - MessageComposerState, -} from 'stream-chat'; +import type { LinkPreview, LinkPreviewsManagerState } from 'stream-chat'; import { LinkPreviewsManager } from 'stream-chat'; import { useStateStore } from '../../store'; import { PopperTooltip } from '../Tooltip'; import { useEnterLeaveHandlers } from '../Tooltip/hooks'; import { useMessageComposer } from './hooks'; -import { CloseIcon, LinkIcon } from './icons'; +import { ImageComponent } from '../Gallery'; +import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; +import { IconChainLink } from '../Icons'; + +export type LinkPreviewListProps = { + displayLinkCount?: number; +}; const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ linkPreviews: Array.from(state.previews.values()).filter( @@ -20,29 +22,19 @@ const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ ), }); -const messageComposerStateSelector = (state: MessageComposerState) => ({ - quotedMessage: state.quotedMessage, -}); - -export const LinkPreviewList = () => { +export const LinkPreviewList = ({ displayLinkCount = 1 }: LinkPreviewListProps) => { const messageComposer = useMessageComposer(); const { linkPreviewsManager } = messageComposer; - const { quotedMessage } = useStateStore( - messageComposer.state, - messageComposerStateSelector, - ); const { linkPreviews } = useStateStore( linkPreviewsManager.state, linkPreviewsManagerStateSelector, ); - const showLinkPreviews = linkPreviews.length > 0 && !quotedMessage; - - if (!showLinkPreviews) return null; + if (linkPreviews.length === 0) return null; return (
- {linkPreviews.map((linkPreview) => ( + {linkPreviews.slice(0, displayLinkCount).map((linkPreview) => ( ))}
@@ -58,6 +50,7 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); const [referenceElement, setReferenceElement] = useState(null); + const { image_url, thumb_url, title } = linkPreview; if ( !LinkPreviewsManager.previewIsLoaded(linkPreview) && @@ -72,6 +65,9 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { LinkPreviewsManager.previewIsLoading(linkPreview), })} data-testid='link-preview-card' + onMouseEnter={handleEnter} + onMouseLeave={handleLeave} + ref={setReferenceElement} > { > {linkPreview.og_scrape_url} -
- -
+ + {(image_url || thumb_url) && ( + + )}
{linkPreview.title} @@ -95,15 +92,17 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => {
{linkPreview.text}
+
+ + {linkPreview.og_scrape_url} +
- + />
); }; diff --git a/src/components/MessageInput/MessageComposerActions.tsx b/src/components/MessageInput/MessageComposerActions.tsx new file mode 100644 index 000000000..098ddbef4 --- /dev/null +++ b/src/components/MessageInput/MessageComposerActions.tsx @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; +import { StopAIGenerationButton as DefaultStopAIGenerationButton } from './StopAIGenerationButton'; +import { CooldownTimer as DefaultCooldownTimer } from './CooldownTimer'; +import { SendButton as DefaultSendButton } from './SendButton'; +import { + useChannelStateContext, + useComponentContext, + useMessageInputContext, +} from '../../context'; +import { AIStates, useAIState } from '../AIStateIndicator'; +import { useCooldownRemaining, useMessageCompositionIsEmpty } from './hooks'; +import { AudioRecordingButtonWithNotification } from '../MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification'; + +export const MessageComposerActions = () => { + const { channel } = useChannelStateContext(); + const { hideSendButton } = useMessageInputContext(); + + const { + CooldownTimer = DefaultCooldownTimer, + SendButton = DefaultSendButton, + StopAIGenerationButton: StopAIGenerationButtonOverride, + } = useComponentContext(); + + const compositionIsEmpty = useMessageCompositionIsEmpty(); + /** + * This bit here is needed to make sure that we can get rid of the default behaviour + * if need be. Essentially, this allows us to pass StopAIGenerationButton={null} and + * completely circumvent the default logic if it's not what we want. We need it as a + * prop because there is no other trivial way to override the SendMessage button otherwise. + */ + const StopAIGenerationButton = + StopAIGenerationButtonOverride === undefined + ? DefaultStopAIGenerationButton + : StopAIGenerationButtonOverride; + + const { handleSubmit, recordingController } = useMessageInputContext(); + const cooldownRemaining = useCooldownRemaining(); + + const { aiState } = useAIState(channel); + const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + const shouldDisplayStopAIGeneration = + [AIStates.Thinking, AIStates.Generating].includes(aiState) && + !!StopAIGenerationButton; + + const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 + + let content = ; + + if (shouldDisplayStopAIGeneration) { + content = ; + } else if (hideSendButton) return null; + + if (cooldownRemaining) { + content = ; + } else if (compositionIsEmpty && recordingEnabled) { + content = ; + } + + return
{content}
; +}; + +export const AdditionalMessageComposerActions = () => { + const { EmojiPicker } = useComponentContext(); + const cooldownRemaining = useCooldownRemaining(); + + return ( +
+ {!cooldownRemaining && EmojiPicker ? : null} +
+ ); +}; diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index aa3b89b71..51df19f63 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -3,7 +3,6 @@ import React, { useEffect } from 'react'; import { MessageInputFlat } from './MessageInputFlat'; import { useMessageComposer } from './hooks'; -import { useCooldownTimer } from './hooks/useCooldownTimer'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; import { useMessageInputControls } from './hooks/useMessageInputControls'; import type { ComponentContextValue } from '../../context/ComponentContext'; @@ -56,7 +55,8 @@ export type MessageInputProps = { emojiSearchIndex?: ComponentContextValue['emojiSearchIndex']; /** If true, focuses the text input on component mount */ focus?: boolean; - /** Allows to hide MessageInput's send button. */ + // todo: what sense does hideSendButton prop make, when we have message composer actions (recording, send msg). Can we remove it? + // /** Allows to hide MessageInput's send button. */ hideSendButton?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ Input?: React.ComponentType; @@ -77,7 +77,6 @@ export type MessageInputProps = { }) => Promise | void; /** When replying in a thread, the parent message object */ parent?: LocalMessage; - /** If true, will use an optional dependency to support transliteration in the input for mentions, default is false. See: https://github.com/getstream/transliterate */ /** * Currently, `Enter` is the default submission key and `Shift`+`Enter` is the default combination for the new line. * If specified, this function overrides the default behavior specified previously. @@ -91,12 +90,10 @@ export type MessageInputProps = { }; const MessageInputProvider = (props: PropsWithChildren) => { - const cooldownTimerState = useCooldownTimer(); const messageInputUiApi = useMessageInputControls(props); const { emojiSearchIndex } = useComponentContext('MessageInput'); const messageInputContextValue = useCreateMessageInputContext({ - ...cooldownTimerState, ...messageInputUiApi, ...props, emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 8f1209a0f..725846bb3 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -1,152 +1,109 @@ -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { AttachmentSelector as DefaultAttachmentSelector, SimpleAttachmentSelector, -} from './AttachmentSelector'; +} from './AttachmentSelector/AttachmentSelector'; import { AttachmentPreviewList as DefaultAttachmentPreviewList } from './AttachmentPreviewList'; -import { CooldownTimer as DefaultCooldownTimer } from './CooldownTimer'; -import { SendButton as DefaultSendButton } from './SendButton'; -import { StopAIGenerationButton as DefaultStopAIGenerationButton } from './StopAIGenerationButton'; -import { - AudioRecorder as DefaultAudioRecorder, - RecordingPermissionDeniedNotification as DefaultRecordingPermissionDeniedNotification, - StartRecordingAudioButton as DefaultStartRecordingAudioButton, - RecordingPermission, -} from '../MediaRecorder'; -import { - QuotedMessagePreview as DefaultQuotedMessagePreview, - QuotedMessagePreviewHeader, -} from './QuotedMessagePreview'; +import { AudioRecorder as DefaultAudioRecorder } from '../MediaRecorder'; +import { QuotedMessagePreview as DefaultQuotedMessagePreview } from './QuotedMessagePreview'; import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList'; import { SendToChannelCheckbox as DefaultSendToChannelCheckbox } from './SendToChannelCheckbox'; import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer'; -import { AIStates, useAIState } from '../AIStateIndicator'; -import { RecordingAttachmentType } from '../MediaRecorder/classes'; - -import { useChatContext } from '../../context/ChatContext'; import { useMessageInputContext } from '../../context/MessageInputContext'; import { useComponentContext } from '../../context/ComponentContext'; -import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; import { useMessageContext } from '../../context'; import { WithDragAndDropUpload } from './WithDragAndDropUpload'; +import { + AdditionalMessageComposerActions as DefaultAdditionalMessageComposerActions, + MessageComposerActions, +} from './MessageComposerActions'; +import { useMessageComposer } from './hooks'; +import { useStateStore } from '../../store'; +import { + type AttachmentManagerState, + LinkPreviewsManager, + type LinkPreviewsManagerState, + type MessageComposerState, +} from 'stream-chat'; -export const MessageInputFlat = () => { - const { message } = useMessageContext(); - const { - asyncMessagesMultiSendEnabled, - cooldownRemaining, - handleSubmit, - hideSendButton, - recordingController, - setCooldownRemaining, - } = useMessageInputContext('MessageInputFlat'); +const messageComposerStateSelector = ({ quotedMessage }: MessageComposerState) => ({ + quotedMessage, +}); + +const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); +const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ + linkPreviews: Array.from(state.previews.values()).filter( + (preview) => + LinkPreviewsManager.previewIsLoaded(preview) || + LinkPreviewsManager.previewIsLoading(preview), + ), +}); + +const MessageComposerPreviews = () => { const { AttachmentPreviewList = DefaultAttachmentPreviewList, - AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector, - AudioRecorder = DefaultAudioRecorder, - CooldownTimer = DefaultCooldownTimer, - EmojiPicker, LinkPreviewList = DefaultLinkPreviewList, QuotedMessagePreview = DefaultQuotedMessagePreview, - RecordingPermissionDeniedNotification = DefaultRecordingPermissionDeniedNotification, - SendButton = DefaultSendButton, - SendToChannelCheckbox = DefaultSendToChannelCheckbox, - StartRecordingAudioButton = DefaultStartRecordingAudioButton, - StopAIGenerationButton: StopAIGenerationButtonOverride, - TextareaComposer = DefaultTextareaComposer, } = useComponentContext(); - const { channel } = useChatContext('MessageInputFlat'); - const { aiState } = useAIState(channel); - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - - const [ - showRecordingPermissionDeniedNotification, - setShowRecordingPermissionDeniedNotification, - ] = useState(false); - const closePermissionDeniedNotification = useCallback(() => { - setShowRecordingPermissionDeniedNotification(false); - }, []); - - const { attachments } = useAttachmentManagerState(); + const messageComposer = useMessageComposer(); + const { quotedMessage } = useStateStore( + messageComposer.state, + messageComposerStateSelector, + ); - if (recordingController.recordingState) return ; + const { attachments } = useStateStore( + messageComposer.attachmentManager.state, + attachmentManagerStateSelector, + ); - const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 - const isRecording = !!recordingController.recordingState; + const { linkPreviewsManager } = messageComposer; + const { linkPreviews } = useStateStore( + linkPreviewsManager.state, + linkPreviewsManagerStateSelector, + ); - /** - * This bit here is needed to make sure that we can get rid of the default behaviour - * if need be. Essentially, this allows us to pass StopAIGenerationButton={null} and - * completely circumvent the default logic if it's not what we want. We need it as a - * prop because there is no other trivial way to override the SendMessage button otherwise. - */ - const StopAIGenerationButton = - StopAIGenerationButtonOverride === undefined - ? DefaultStopAIGenerationButton - : StopAIGenerationButtonOverride; - const shouldDisplayStopAIGeneration = - [AIStates.Thinking, AIStates.Generating].includes(aiState) && - !!StopAIGenerationButton; + if (!quotedMessage && attachments.length === 0 && linkPreviews.length === 0) + return null; + // todo: pass the entity arrays from here so that the preview lists do not have to subscribe to the composer state changes too? return ( - - {recordingEnabled && - recordingController.permissionState === 'denied' && - showRecordingPermissionDeniedNotification && ( - - )} +
+ + - +
+ ); +}; -
- -
- - -
- - {EmojiPicker && } -
+export const MessageInputFlat = () => { + const { message } = useMessageContext(); + const { recordingController } = useMessageInputContext(); + + const { + AdditionalMessageComposerActions = DefaultAdditionalMessageComposerActions, + AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector, + AudioRecorder = DefaultAudioRecorder, + SendToChannelCheckbox = DefaultSendToChannelCheckbox, + TextareaComposer = DefaultTextareaComposer, + } = useComponentContext(); + + if (recordingController.recordingState) return ; + + return ( + + +
+ +
+ + +
- {shouldDisplayStopAIGeneration ? ( - - ) : ( - !hideSendButton && ( - <> - {cooldownRemaining ? ( - - ) : ( - <> - - {recordingEnabled && ( - a.type === RecordingAttachmentType.VOICE_RECORDING, - )) - } - onClick={() => { - recordingController.recorder?.start(); - setShowRecordingPermissionDeniedNotification(true); - }} - /> - )} - - )} - - ) - )}
diff --git a/src/components/MessageInput/QuotedMessageIndicator.tsx b/src/components/MessageInput/QuotedMessageIndicator.tsx new file mode 100644 index 000000000..54f1e3892 --- /dev/null +++ b/src/components/MessageInput/QuotedMessageIndicator.tsx @@ -0,0 +1,9 @@ +import clsx from 'clsx'; + +export const QuotedMessageIndicator = ({ isOwnMessage }: { isOwnMessage?: boolean }) => ( +
+); diff --git a/src/components/MessageInput/QuotedMessagePreview.tsx b/src/components/MessageInput/QuotedMessagePreview.tsx index 19aa99213..e54651651 100644 --- a/src/components/MessageInput/QuotedMessagePreview.tsx +++ b/src/components/MessageInput/QuotedMessagePreview.tsx @@ -1,61 +1,250 @@ -import React, { useMemo } from 'react'; +import React, { + type ComponentType, + type ReactElement, + type ReactNode, + useMemo, +} from 'react'; -import { CloseIcon } from './icons'; -import { Attachment as DefaultAttachment } from '../Attachment'; -import { Avatar as DefaultAvatar } from '../Avatar'; -import { Poll } from '../Poll'; +import { displayDuration, SUPPORTED_VIDEO_FORMATS } from '../Attachment'; import { useChatContext } from '../../context/ChatContext'; -import { useComponentContext } from '../../context/ComponentContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { useStateStore } from '../../store'; import { useMessageComposer } from './hooks'; -import { renderText as defaultRenderText } from '../Message/renderText'; -import type { MessageComposerState, TranslationLanguages } from 'stream-chat'; +import { + isAudioAttachment, + isFileAttachment, + isScrapedContent, + isVideoAttachment, + isVoiceRecordingAttachment, + type PollResponse, +} from 'stream-chat'; +import { + type Attachment, + isImageAttachment, + type LocalMessage, + type MessageComposerState, + type SharedLocationResponse, + type TranslationLanguages, +} from 'stream-chat'; import type { MessageContextValue } from '../../context'; +import { RemoveAttachmentPreviewButton } from './RemoveAttachmentPreviewButton'; +import { + IconCamera, + IconChainLink, + IconFile, + IconLocationPin, + IconMicrophone, + IconPlaySolid, + IconPoll, + IconVideoCameraOutline, +} from '../Icons'; +import clsx from 'clsx'; +import { ImageComponent } from '../Gallery'; +import { FileIcon } from '../FileIcon'; +import { QuotedMessageIndicator } from './QuotedMessageIndicator'; const messageComposerStateStoreSelector = (state: MessageComposerState) => ({ quotedMessage: state.quotedMessage, }); -export const QuotedMessagePreviewHeader = () => { - const { t } = useTranslationContext('QuotedMessagePreview'); - const messageComposer = useMessageComposer(); - const { quotedMessage } = useStateStore( - messageComposer.state, - messageComposerStateStoreSelector, - ); +export type QuotedMessagePreviewProps = { + getQuotedMessageAuthor?: (message: LocalMessage) => string; + renderText?: MessageContextValue['renderText']; +}; - if (!quotedMessage) return null; +const NullAttachmentIcon = () => null; - return ( -
-
- {t('Reply to Message')} -
- -
+type AttachmentType = 'documents' | 'images' | 'links' | 'videos' | 'voiceRecordings'; + +const getAttachmentType = (attachment: Attachment) => { + if (isScrapedContent(attachment)) { + return 'link'; + } else if (isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { + return 'video'; + } else if (isImageAttachment(attachment)) { + return 'image'; + } else if (isAudioAttachment(attachment)) { + return 'audio'; + } else if (isVoiceRecordingAttachment(attachment)) { + return 'voiceRecording'; + } else if (isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) { + return 'file'; + } + + return 'unsupported'; +}; + +type GroupedAttachments = Record & { + locations: SharedLocationResponse[]; + polls: PollResponse[]; + total: number; +}; + +const getGroupedAttachments = (quotedMessage: LocalMessage | null) => { + const groupedAttachments = { + documents: [], + images: [], + links: [], + locations: [], + polls: [], + total: 0, + videos: [], + voiceRecordings: [], + }; + + if (!quotedMessage || !quotedMessage.attachments) return groupedAttachments; + + const result = quotedMessage.attachments.reduce( + (count, attachment) => { + switch (getAttachmentType(attachment)) { + case 'link': + count.links.push(attachment); + count.total += 1; + break; + case 'video': + count.videos.push(attachment); + count.total += 1; + break; + case 'voiceRecording': + count.voiceRecordings.push(attachment); + count.total += 1; + break; + case 'audio': + case 'file': + count.documents.push(attachment); + count.total += 1; + break; + default: + if (isImageAttachment(attachment)) { + count.images.push(attachment); + count.total += 1; + } + } + + return count; + }, + groupedAttachments, ); + if (quotedMessage.shared_location) { + result.locations.push(quotedMessage.shared_location); + result.total += 1; + } else if (quotedMessage.poll) { + result.polls.push(quotedMessage.poll); + result.total += 1; + } + + return result; }; -export type QuotedMessagePreviewProps = { - renderText?: MessageContextValue['renderText']; +type PreviewType = + | 'voice' + | 'file' + | 'image' + | 'link' + | 'location' + | 'poll' + | 'video' + | 'mixed'; + +const getAttachmentIconWithType = ( + quotedMessage: LocalMessage | null, +): { + groupedAttachments: GroupedAttachments; + Icon: ComponentType; + PreviewImage: ReactElement | null; + previewType: PreviewType | null; +} => { + const groupedAttachments = getGroupedAttachments(quotedMessage); + const result = { + groupedAttachments, + Icon: NullAttachmentIcon, + PreviewImage: null, + previewType: null, + }; + if (!groupedAttachments.total) return result; + if (groupedAttachments.polls.length > 0) + return { ...result, Icon: IconPoll, previewType: 'poll' }; + if (groupedAttachments.locations.length > 0) + // todo: we do not generate the location preview image + return { ...result, Icon: IconLocationPin, previewType: 'location' }; + if ( + groupedAttachments.documents.length === groupedAttachments.total && + groupedAttachments.documents.length === 1 + ) { + const fileAttachment = groupedAttachments.documents[0] as Attachment; + return { + ...result, + Icon: IconFile, + PreviewImage: ( + + ), + previewType: 'file', + }; + } + if (groupedAttachments.links.length === groupedAttachments.total) { + const linkAttachment = groupedAttachments.links[0]; + return { + ...result, + Icon: IconChainLink, + PreviewImage: ( + + ), + previewType: 'link', + }; + } + if (groupedAttachments.videos.length === groupedAttachments.total) { + const videoAttachment = groupedAttachments.videos[0]; + return { + ...result, + Icon: IconVideoCameraOutline, + PreviewImage: ( + <> + +
+ +
+ + ), + previewType: 'video', + }; + } + if (groupedAttachments.images.length === groupedAttachments.total) { + const imageAttachment = groupedAttachments.images[0]; + return { + ...result, + Icon: IconCamera, + PreviewImage: ( + + ), + previewType: 'image', + }; + } + if (groupedAttachments.voiceRecordings.length === groupedAttachments.total) + return { ...result, Icon: IconMicrophone, previewType: 'voice' }; + + return { ...result, Icon: IconFile, previewType: 'mixed' }; }; export const QuotedMessagePreview = ({ - renderText = defaultRenderText, + getQuotedMessageAuthor, + renderText, }: QuotedMessagePreviewProps) => { const { client } = useChatContext(); - const { Attachment = DefaultAttachment, Avatar = DefaultAvatar } = - useComponentContext('QuotedMessagePreview'); - const { userLanguage } = useTranslationContext('QuotedMessagePreview'); + const { t, userLanguage } = useTranslationContext(); const messageComposer = useMessageComposer(); const { quotedMessage } = useStateStore( messageComposer.state, @@ -69,51 +258,105 @@ export const QuotedMessagePreview = ({ [quotedMessage?.i18n, quotedMessage?.text, userLanguage], ); - const renderedText = useMemo( - () => renderText(quotedMessageText, quotedMessage?.mentioned_users), - [quotedMessage, quotedMessageText, renderText], - ); + const { AttachmentIcon, PreviewImage, renderedText } = useMemo(() => { + if (!quotedMessage) return { AttachmentIcon: NullAttachmentIcon, renderedText: null }; - const quotedMessageAttachments = useMemo( - () => - quotedMessage?.attachments?.length ? quotedMessage.attachments.slice(0, 1) : [], - [quotedMessage], - ); + const { + groupedAttachments, + Icon: AttachmentIcon, + PreviewImage, + previewType, + } = getAttachmentIconWithType(quotedMessage); - const poll = quotedMessage?.poll_id && client.polls.fromState(quotedMessage.poll_id); + let renderedText: ReactNode | undefined; - if (!quotedMessageText && !quotedMessageAttachments.length && !poll) return null; + if (!quotedMessageText) { + if (previewType === 'poll') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + renderedText = quotedMessage.poll!.name; + } else if (previewType === 'location') { + renderedText = t('Live location'); + } else if (previewType === 'voice') { + { + const voiceRecording = groupedAttachments.voiceRecordings[0]; + renderedText = t('Voice message {{ duration }}', { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + duration: displayDuration(voiceRecording!.duration), + }); + } + } else if (previewType === 'link') { + renderedText = groupedAttachments.links[0].title; + } else if (previewType === 'mixed') { + renderedText = t('{{ count }} files', { count: groupedAttachments.total }); + } else if (previewType === 'video') { + renderedText = + groupedAttachments.videos.length === 1 + ? t('Video') + : t('{{ count }} videos', { + count: groupedAttachments.videos.length, + }); + } else if (previewType === 'file') { + renderedText = groupedAttachments.documents[0].title; + } else if (previewType === 'image') { + renderedText = + groupedAttachments.images.length === 1 + ? t('Photo') + : t('{{ count }} photos', { + count: groupedAttachments.images.length, + }); + } + } else if (renderText) { + renderedText = renderText(quotedMessageText, quotedMessage?.mentioned_users); + } else { + renderedText = quotedMessageText; + } + return { + AttachmentIcon, + PreviewImage, + renderedText, + }; + }, [quotedMessage, quotedMessageText, renderText, t]); + + const isOwnMessage = client.user?.id === quotedMessage?.user?.id; + + if (!quotedMessage || (!renderedText && !AttachmentIcon && !PreviewImage)) return null; + + const authorName = getQuotedMessageAuthor?.(quotedMessage) ?? quotedMessage.user?.name; return (
- {quotedMessage?.user && ( - - )} -
- {poll ? ( - - ) : ( - <> - {!!quotedMessageAttachments.length && ( - - )} -
- {renderedText} -
- - )} + +
+
+ {isOwnMessage + ? t('You') + : authorName + ? t('Reply to {{ authorName }}', { authorName }) + : t('Reply')} +
+ +
+ + {renderedText} +
+ {PreviewImage && ( +
{PreviewImage}
+ )} + + messageComposer.setQuotedMessage(null)} + />
); }; diff --git a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx new file mode 100644 index 000000000..f6dfa409d --- /dev/null +++ b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx'; +import { IconClose } from '../Icons'; +import { Button } from '../Button'; +import React, { type ComponentProps } from 'react'; +import { useTranslationContext } from '../../context'; +import type { AttachmentLoadingState } from 'stream-chat'; + +export const RemoveAttachmentPreviewButton = ({ + className, + uploadState, + ...props +}: ComponentProps<'button'> & { + uploadState?: AttachmentLoadingState; +}) => { + const { t } = useTranslationContext(); + return ( + + ); +}; diff --git a/src/components/MessageInput/WithDragAndDropUpload.tsx b/src/components/MessageInput/WithDragAndDropUpload.tsx index 00b2b6a34..59b0b956e 100644 --- a/src/components/MessageInput/WithDragAndDropUpload.tsx +++ b/src/components/MessageInput/WithDragAndDropUpload.tsx @@ -5,7 +5,11 @@ import clsx from 'clsx'; import type { MessageComposerConfig } from 'stream-chat'; import { useMessageInputContext, useTranslationContext } from '../../context'; -import { useAttachmentManagerState, useMessageComposer } from './hooks'; +import { + useAttachmentManagerState, + useCooldownRemaining, + useMessageComposer, +} from './hooks'; import { useStateStore } from '../../store'; const DragAndDropUploadContext = React.createContext<{ @@ -81,6 +85,7 @@ export const WithDragAndDropUpload = ({ messageComposer.configState, attachmentManagerConfigStateSelector, ); + const cooldownRemaining = useCooldownRemaining(); // if message input context is available, there's no need to use the queue const isWithinMessageInputContext = Object.keys(messageInputContext).length > 0; @@ -110,7 +115,7 @@ export const WithDragAndDropUpload = ({ // apply `disabled` rules if available, otherwise allow anything and // let the `uploadNewFiles` handle the limitations internally disabled: isWithinMessageInputContext - ? !isUploadEnabled || (messageInputContext.cooldownRemaining ?? 0) > 0 + ? !isUploadEnabled || (cooldownRemaining ?? 0) > 0 : false, multiple: multipleUploads, noClick: true, diff --git a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js index 27ed1f76a..1ec031a24 100644 --- a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js +++ b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js @@ -263,7 +263,7 @@ describe('AttachmentPreviewList', () => { file: 'FileAttachmentPreview', image: 'ImageAttachmentPreview', unsupported: 'UnsupportedAttachmentPreview', - video: 'VideoAttachmentPreview', + video: 'MediaAttachmentPreview', voiceRecording: 'VoiceRecordingPreview', }; const title = `${type}-attachment`; diff --git a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js index 90526a4e9..c5ddb59e6 100644 --- a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js +++ b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; -import { useCooldownTimer } from '../useCooldownTimer'; +import { useCooldownRemaining } from '../useCooldownRemaining'; import { ChannelStateProvider, ChatProvider } from '../../../../context'; import { getTestClient } from '../../../../mock-builders'; @@ -17,12 +17,12 @@ async function renderUseCooldownTimerHook({ channel, chatContext }) { {children} ); - return renderHook(useCooldownTimer, { wrapper }); + return renderHook(useCooldownRemaining, { wrapper }); } const cid = 'cid'; const cooldown = 30; -describe('useCooldownTimer', () => { +describe('useCooldownRemaining', () => { it('should set remaining cooldown time to 0 if no channel.cooldown', async () => { const channel = { cid }; const chatContext = { latestMessageDatesByChannels: {} }; diff --git a/src/components/MessageInput/hooks/index.ts b/src/components/MessageInput/hooks/index.ts index 0c8fd01bd..ef36b94c0 100644 --- a/src/components/MessageInput/hooks/index.ts +++ b/src/components/MessageInput/hooks/index.ts @@ -1,7 +1,8 @@ export * from './useAttachmentManagerState'; export * from './useAttachmentsForPreview'; export * from './useCanCreatePoll'; -export * from './useCooldownTimer'; +export * from './useCooldownRemaining'; export * from './useMessageInputControls'; export * from './useMessageComposer'; export * from './useMessageComposerHasSendableData'; +export * from './useMessageCompositionIsEmpty'; diff --git a/src/components/MessageInput/hooks/useCooldownRemaining.tsx b/src/components/MessageInput/hooks/useCooldownRemaining.tsx new file mode 100644 index 000000000..5de5eb8e5 --- /dev/null +++ b/src/components/MessageInput/hooks/useCooldownRemaining.tsx @@ -0,0 +1,22 @@ +import { type CooldownTimerState } from 'stream-chat'; + +import { useChannelStateContext } from '../../../context'; +import { useStateStore } from '../../../store'; + +const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ + cooldownRemaining: state.cooldownRemaining, +}); + +/** + * Provides and initial value of cooldown, from which the countdown should start, e.g.: + * + * The value of channel.data.cooldown is 100s but 30s has already elapsed, user reloads the page, + * the initial value is now 70s from which the countdown will continue using useTimer() hook. + */ +export const useCooldownRemaining = (): number => { + const { channel } = useChannelStateContext(); + return ( + useStateStore(channel.cooldownTimer.state, cooldownTimerStateSelector) + .cooldownRemaining ?? 0 + ); +}; diff --git a/src/components/MessageInput/hooks/useCooldownTimer.tsx b/src/components/MessageInput/hooks/useCooldownTimer.tsx deleted file mode 100644 index 5a36d86be..000000000 --- a/src/components/MessageInput/hooks/useCooldownTimer.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import type { ChannelResponse } from 'stream-chat'; - -import { useChannelStateContext, useChatContext } from '../../../context'; - -export type CooldownTimerState = { - cooldownInterval: number; - setCooldownRemaining: React.Dispatch>; - cooldownRemaining?: number; -}; - -export const useCooldownTimer = (): CooldownTimerState => { - const { client, latestMessageDatesByChannels } = useChatContext('useCooldownTimer'); - const { channel, messages = [] } = useChannelStateContext('useCooldownTimer'); - const [cooldownRemaining, setCooldownRemaining] = useState(); - - const { cooldown: cooldownInterval = 0, own_capabilities } = (channel.data || - {}) as ChannelResponse; - - const skipCooldown = own_capabilities?.includes('skip-slow-mode'); - - const ownLatestMessageDate = useMemo( - () => - latestMessageDatesByChannels[channel.cid] ?? - [...messages] - .sort( - (a, b) => (b.created_at as Date)?.getTime() - (a.created_at as Date)?.getTime(), - ) - .find((v) => v.user?.id === client.user?.id)?.created_at, - [messages, client.user?.id, latestMessageDatesByChannels, channel.cid], - ) as Date; - - useEffect(() => { - const timeSinceOwnLastMessage = ownLatestMessageDate - ? // prevent negative values - Math.max(0, (new Date().getTime() - ownLatestMessageDate.getTime()) / 1000) - : undefined; - - const remaining = - !skipCooldown && - typeof timeSinceOwnLastMessage !== 'undefined' && - cooldownInterval > timeSinceOwnLastMessage - ? Math.round(cooldownInterval - timeSinceOwnLastMessage) - : 0; - - setCooldownRemaining(remaining); - - if (!remaining) return; - - const timeout = setTimeout(() => { - setCooldownRemaining(0); - }, remaining * 1000); - - return () => { - clearTimeout(timeout); - }; - }, [cooldownInterval, ownLatestMessageDate, skipCooldown]); - - return { - cooldownInterval, - cooldownRemaining, - setCooldownRemaining, - }; -}; diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index e5f4e2513..03d8617d2 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -8,8 +8,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => asyncMessagesMultiSendEnabled, audioRecordingEnabled, clearEditingState, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, focus, handleSubmit, @@ -20,7 +18,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => onPaste, parent, recordingController, - setCooldownRemaining, shouldSubmit, textareaRef, } = value; @@ -33,8 +30,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => asyncMessagesMultiSendEnabled, audioRecordingEnabled, clearEditingState, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, focus, handleSubmit, @@ -45,7 +40,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => onPaste, parent, recordingController, - setCooldownRemaining, shouldSubmit, textareaRef, }), @@ -53,8 +47,6 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) => [ asyncMessagesMultiSendEnabled, audioRecordingEnabled, - cooldownInterval, - cooldownRemaining, emojiSearchIndex, handleSubmit, hideSendButton, diff --git a/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts b/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts new file mode 100644 index 000000000..2f2562b6d --- /dev/null +++ b/src/components/MessageInput/hooks/useMessageCompositionIsEmpty.ts @@ -0,0 +1,11 @@ +import type { EditingAuditState } from 'stream-chat'; +import { useMessageComposer } from './useMessageComposer'; +import { useStateStore } from '../../../store'; + +const editingAuditStateStateSelector = (state: EditingAuditState) => state; + +export const useMessageCompositionIsEmpty = () => { + const messageComposer = useMessageComposer(); + useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); + return messageComposer.compositionIsEmpty; +}; diff --git a/src/components/MessageInput/icons.tsx b/src/components/MessageInput/icons.tsx index 20829b4a6..94b8de703 100644 --- a/src/components/MessageInput/icons.tsx +++ b/src/components/MessageInput/icons.tsx @@ -3,16 +3,14 @@ import { nanoid } from 'nanoid'; import { useTranslationContext } from '../../context/TranslationContext'; -export const LoadingIndicatorIcon = ({ size = 20 }: { size?: number }) => { +export const LoadingIndicatorIcon = () => { const id = useMemo(() => nanoid(), []); return (
@@ -64,95 +62,6 @@ export const UploadIcon = () => { ); }; -export const CloseIcon = () => ( - - - -); - -export const RetryIcon = () => ( - - - -); - -export const DownloadIcon = () => ( - - - -); - -export const LinkIcon = () => ( - - - -); - -export const SendIcon = () => { - const { t } = useTranslationContext('SendButton'); - return ( - - {t('Send')} - - - ); -}; - -export const MicIcon = () => ( - - - - -); - export const BinIcon = () => ( diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss new file mode 100644 index 000000000..eca51ced1 --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -0,0 +1,209 @@ +@use '../../../styling/utils'; + +.str-chat { + .str-chat__attachment-preview-list { + padding: var(--spacing-xxs); + display: flex; + align-items: center; + //justify-content: center; + justify-content: flex-start; + width: 100%; + + min-width: 0; + max-width: 100%; + overflow-x: auto; + flex: 1 1 auto; + overflow-y: hidden; + gap: var(--spacing-md); + + //.str-chat__attachment-list-scroll-container { + // display: flex; + // gap: var(--spacing-md); + // align-items: center; + // justify-content: flex-start; + // width: 100%; + // min-width: 0; + // + // overflow-x: auto; + // overflow-y: hidden; + // padding-block: var(--spacing-md); + //} + } + .str-chat__attachment-preview-audio, + .str-chat__attachment-preview-file, + .str-chat__attachment-preview-voice-recording, + .str-chat__attachment-preview-unsupported { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + padding-right: var(--spacing-sm); + min-width: 224px; + max-width: 280px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-core-default); + } + + .str-chat__attachment-preview-audio, + .str-chat__attachment-preview-file, + .str-chat__attachment-preview-voice-recording, + .str-chat__attachment-preview-unsupported, + .str-chat__attachment-preview-media { + position: relative; + &:focus-visible { + @include utils.focusable; + } + } + + .str-chat__attachment-preview-media { + width: 72px; + height: 72px; + cursor: pointer; + border: 1px solid var(--border-core-default); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__attachment-preview-media__thumbnail-wrapper { + border-radius: var(--message-bubble-radius-attachment); + overflow: hidden; + height: 100%; + width: 100%; + + img { + width: 72px; + height: 72px; + object-fit: cover; + } + } + + .str-chat__attachment-preview-media__video-indicator { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + position: absolute; + bottom: var(--spacing-xxs); + left: var(--spacing-xxs); + padding-inline: var(--button-padding-x-icon-only-sm); + padding-block: var(--button-padding-y-sm); + border-radius: var(--radius-max); + // todo: change to --badge-bg when the variable is available + background-color: var(--chat-bg-typing-indicator); + color: var(--badge-text); + font-size: var(--typography-font-size-xxs); + font-weight: var(--typography-font-weight-bold); + line-height: var(--line-height-line-height-10); + + .str-chat__icon--video-camera { + width: 10px; + height: 8px; + fill: currentColor; + } + } + } + + .str-chat__attachment-preview-media__overlay { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + border-radius: var(--message-bubble-radius-attachment); + + &:hover { + @include utils.overlay-after(var(--background-core-hover)); + background-color: var(--background-core-hover); + } + + &:active { + @include utils.overlay-after(var(--background-core-pressed)); + background-color: var(--background-core-pressed); + } + + .str-chat__loading-indicator, + .str-chat__icon--exclamation-circle { + width: 14px; + height: 14px; + position: absolute; + left: var(--spacing-xxs); + bottom: var(--spacing-xxs); + border: 2px solid var(--control-remove-control-border); + border-radius: var(--radius-max); + } + } + + .str-chat__attachment-preview-media--upload-error { + .str-chat__attachment-preview-media__overlay { + background-color: var(--background-core-overlay); + } + } + + .str-chat__attachment-preview-media--uploading { + .str-chat__attachment-preview-media__overlay { + background: linear-gradient(180deg, var(--base-white) 0%, var(--slate-100) 100%); + } + } + + .str-chat__attachment-preview-file__icon { + display: flex; + align-items: center; + } + + .str-chat__attachment-preview-file__info { + @include utils.ellipsis-text-parent; + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: var(--spacing-xxs); + + .str-chat__attachment-preview-file-name { + @include utils.ellipsis-text; + max-width: 100%; + font-weight: var(--typography-font-weight-semi-bold); + font-size: var(--typography-font-size-sm); + line-height: var(--typography-line-height-tight); + } + + .str-chat__attachment-preview-file__data { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + color: var(--text-secondary); + font-weight: var(--typography-font-weight-regular); + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + + .str-chat__loading-indicator { + width: var(--size-12); + height: var(--size-12); + } + + .str-chat__attachment-preview-file__fatal-error { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + color: var(--color-accent-error); + } + + .str-chat__attachment-preview-file__retriable-error { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + white-space: nowrap; + + .str-chat__attachment-preview-file__retry-upload-button { + @include utils.button-reset; + color: var(--color-accent-primary); + cursor: pointer; + } + } + } + } + + .str-chat__button-play { + height: var(--button-visual-height-md); + width: var(--button-visual-height-md); + border: 1px solid var(--control-play-control-border); + background-color: var(--control-play-control-bg); + } +} diff --git a/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss b/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss new file mode 100644 index 000000000..cb0b4a089 --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentPreviewThumbnail.scss @@ -0,0 +1,8 @@ +// todo: should we have img dimensions determined by semantic variables? +.str-chat__attachment-preview__thumbnail { + border-radius: var(--radius-md); + overflow: hidden; + width: 40px; + height: 40px; + object-fit: cover; +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/AttachmentSelector.scss b/src/components/MessageInput/styling/AttachmentSelector.scss new file mode 100644 index 000000000..20aa25998 --- /dev/null +++ b/src/components/MessageInput/styling/AttachmentSelector.scss @@ -0,0 +1,26 @@ +.str-chat { + .str-chat__attachment-selector { + .str-chat__attachment-selector__menu-button { + .str-chat__attachment-selector__menu-button__icon { + width: var(--icon-size-md); + color: var(--button-style-outline-text); + } + } + } + + .str-chat__file-input { + display: none; + } + + .str-chat__attachment-selector-actions-menu { + min-width: 200px; + } + + .str-chat__message-composer--floating { + .str-chat__attachment-selector__menu-button { + background-color: var(--background-elevation-elevation-1); + // todo: variable exists only in Figma, not added to tokens repo + box-shadow: var(--shadow-web-light-elevation-2); + } + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/LinkPreviewList.scss b/src/components/MessageInput/styling/LinkPreviewList.scss new file mode 100644 index 000000000..307f34e47 --- /dev/null +++ b/src/components/MessageInput/styling/LinkPreviewList.scss @@ -0,0 +1,82 @@ +@use '../../../styling/utils'; + +.str-chat__link-preview-list { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: var(--spacing-xxs); +} + +.str-chat__link-preview-card { + position: relative; + width: 100%; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-around; + gap: var(--spacing-xs); + padding-inline: var(--spacing-xs) var(--spacing-sm); + padding-block: var(--spacing-xs); + background-color: var(--chat-bg-outgoing); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__tooltip { + @include utils.ellipsis-text(); + display: block; + max-width: calc(var(--str-chat__spacing-px) * 250); + padding-inline: 0.5rem; + } + + .str-chat__link-preview-card__icon-container { + display: flex; + align-items: center; + } + + .str-chat__link-preview-card__content { + min-width: 0; + flex: 1; + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + + .str-chat__link-preview-card__content-title, + .str-chat__link-preview-card__content-description, + .str-chat__link-preview-card__content__url { + @include utils.ellipsis-text(); + } + + .str-chat__link-preview-card__content-title { + font-weight: var(--typography-font-weight-semi-bold); + } + + .str-chat__link-preview-card__content__url { + display: flex; + align-items: center; + gap: var(--spacing-xxs); + } + } + + .str-chat__link-preview-card__dismiss-button { + @include utils.button-reset; + cursor: pointer; + } +} + +//.str-chat__link-preview-card--loading { +// .str-chat__link-preview-card__content { +// display: flex; +// flex-direction: column; +// gap: 0.25rem; +// +// .str-chat__link-preview-card__content-title { +// height: calc(var(--str-chat__spacing-px) * 16); +// width: 100% +// } +// +// .str-chat__link-preview-card__content-description { +// height: calc(var(--str-chat__spacing-px) * 12); +// width: 100%; +// } +// } +//} diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss new file mode 100644 index 000000000..e8d5174a0 --- /dev/null +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -0,0 +1,404 @@ +@use '../../../styling/utils'; + +.str-chat { + /* + Styles for floating like composer + */ + .str-chat__message-composer--floating { + position: fixed; + bottom: 0; + background-color: var(--base-transparent-0); + // todo: variable exists only in Figma, not added to tokens repo + box-shadow: var(--shadow-web-light-elevation-2); + } + + .str-chat__message-composer { + display: flex; + align-items: end; + width: 100%; + max-width: 768px; + padding: var(--spacing-xs); + gap: var(--spacing-xs); + min-width: 0; + } + + .str-chat__message-composer-compose-area { + display: flex; + flex-direction: column; + width: 100%; + padding-inline: var(--spacing-xs); + padding-block: var(--spacing-sm); + border: 1px solid var(--border-core-default); + border-radius: var(--radius-3xl); + color: var(--input-text-default); + background-color: var(--composer-bg); + min-width: 0; + } + + .str-chat__message-composer-previews { + display: flex; + flex-direction: column; + width: 100%; + padding-bottom: var(--spacing-xs); + gap: var(--spacing-xxs); + + min-width: 0; + } + + .str-chat__message-composer-controls { + display: flex; + align-items: end; + width: 100%; + gap: var(--spacing-xs); + + $controls-containers-min-height: 26px; + + .str-chat__message-composer__actions, + .str-chat__message-composer__additional-actions { + height: $controls-containers-min-height; + display: flex; + align-items: center; + } + + .str-chat__textarea { + flex: 1; + position: relative; + display: flex; + align-items: center; + margin-inline: var(--spacing-xxs) var(--spacing-xs); + min-height: $controls-containers-min-height; // align with the attachment button + + textarea { + resize: none; + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + width: 100%; + color: var(--input-text-default); + font-size: var(--typography-font-size-md); + } + } + + .str-chat__emoji-picker-button { + display: flex; + cursor: pointer; + + .str-chat__icon--emoji { + width: var(--icon-size-md); + } + } + + .str-chat__start-recording-audio-button { + .str-chat__icon--microphone { + width: var(--icon-size-md); + } + } + + .str-chat__send-button { + .str-chat__icon--paper-plane { + width: var(--icon-size-md); + fill: none; + } + } + + // todo: we need designs for this - I am hard-coding the dimensions + .str-chat__stop-ai-generation-button { + width: 30px; + height: 28px; + cursor: pointer; + background-image: var(--str-chat__circle-stop-icon); + background-color: transparent; + border-width: 0; + } + + .str-chat__message-input-cooldown { + display: flex; + align-items: center; + justify-content: center; + height: var(--button-visual-height-md); + width: var(--button-visual-height-md); + border-radius: var(--button-radius-full); + background-color: var(--background-core-disabled); + color: var(--text-disabled); + } + + [dir='rtl'] .str-chat__send-button, + [dir='rtl'] .str-chat__start-recording-audio-button { + svg { + transform: scale(-1, 1); + } + } + + + } + + // todo: need designs? what kind of action buttons to use on modals? + .str-chat__recording-permission-denied-notification { + max-width: 100%; + padding: 1rem; + margin-inline: 0.5rem; + border-radius: var(--radius-2xl); + + .str-chat__recording-permission-denied-notification__dismiss-button-container { + display: flex; + justify-content: flex-end; + } + + .str-chat__recording-permission-denied-notification__heading, + .str-chat__recording-permission-denied-notification__dismiss-button { + font: var(--str-chat__subtitle2-medium-text); + } + + .str-chat__recording-permission-denied-notification__message { + font: var(--str-chat__subtitle-text); + } + + //.str-chat__recording-permission-denied-notification__dismiss-button { + // text-transform: uppercase; + //} + } + + // todo: need designs? + .str-chat__send-to-channel-checkbox__container { + width: 100%; + display: flex; + padding: 0.5rem 0.75rem; + + .str-chat__send-to-channel-checkbox__field { + display: flex; + align-items: center; + + * { + cursor: pointer; + } + + label { + padding-inline: 0.5rem; + color: var(--str-chat__text-low-emphasis-color); + font: var(--str-chat__body-text); + } + + input { + margin: 0; + } + } + } +} + +// +// +//// theme +// +//.str-chat { +// /* The border radius of the component */ +// --str-chat__message-input-border-radius: 0; +// +// /* The text/icon color of the component */ +// --str-chat__message-input-color: var(--str-chat__text-color); +// +// /* The background color of the component */ +// --str-chat__message-input-background-color: var(--str-chat__secondary-background-color); +// +// /* Top border of the component */ +// --str-chat__message-input-border-block-start: none; +// +// /* Bottom border of the component */ +// --str-chat__message-input-border-block-end: none; +// +// /* Left (right in RTL layout) border of the component */ +// --str-chat__message-input-border-inline-start: none; +// +// /* Right (left in RTL layout) border of the component */ +// --str-chat__message-input-border-inline-end: none; +// +// /* Box shadow applied to the component */ +// --str-chat__message-input-box-shadow: none; +// +// /* The border radius used for the borders of the textarea */ +// --str-chat__message-textarea-border-radius: var(--str-chat__border-radius-md); +// +// /* The text/icon color of the textarea */ +// --str-chat__message-textarea-color: var(--str-chat__text-color); +// +// /* The background color of the textarea */ +// --str-chat__message-textarea-background-color: transparent; +// +// /* Top border of the textarea */ +// --str-chat__message-textarea-border-block-start: 1px solid var(--str-chat__surface-color); +// +// /* Bottom border of the textarea */ +// --str-chat__message-textarea-border-block-end: 1px solid var(--str-chat__surface-color); +// +// /* Left (right in RTL layout) border of the textarea */ +// --str-chat__message-textarea-border-inline-start: 1px solid var(--str-chat__surface-color); +// +// /* Right (left in RTL layout) border of the textarea */ +// --str-chat__message-textarea-border-inline-end: 1px solid var(--str-chat__surface-color); +// +// /* Box shadow applied to the textarea */ +// --str-chat__message-textarea-box-shadow: none; +// +// /* The border radius used for the borders of the send button */ +// --str-chat__message-send-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the send button */ +// --str-chat__message-send-color: var(--str-chat__primary-color); +// +// /* The background color of the send button */ +// --str-chat__message-send-background-color: transparent; +// +// /* Top border of the send button */ +// --str-chat__message-send-border-block-start: 0; +// +// /* Bottom border of the send button */ +// --str-chat__message-send-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the send button */ +// --str-chat__message-send-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the send button */ +// --str-chat__message-send-border-inline-end: 0; +// +// /* Box shadow applied to the send button */ +// --str-chat__message-send-box-shadow: none; +// +// /* The color of the send button in disabled state */ +// --str-chat__message-send-disabled-color: var(--str-chat__disabled-color); +// +// /* The background color of the send button in disabled state */ +// --str-chat__message-send-disabled-background-color: var(--str-chat__disabled-color); +// +// /* The border radius used for the borders of the audio recording button */ +// --str-chat__start-recording-audio-button-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the audio recording button */ +// --str-chat__start-recording-audio-button-color: var(--str-chat__text-low-emphasis-color); +// +// /* The background color of the audio recording button */ +// --str-chat__start-recording-audio-button-background-color: transparent; +// +// /* Top border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-block-start: 0; +// +// /* Bottom border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the audio recording button */ +// --str-chat__start-recording-audio-button-border-inline-end: 0; +// +// /* Box shadow applied to the audio recording button */ +// --str-chat__start-recording-audio-button-box-shadow: none; +// +// /* The color of the audio recording button in disabled state */ +// --str-chat__start-recording-audio-button-disabled-color: var(--str-chat__disabled-color); +// +// /* The background color of the audio recording button in disabled state */ +// --str-chat__start-recording-audio-button-disabled-background-color: transparent; +// +// /* The border radius used for the borders of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-radius: var(--str-chat__border-radius-circle); +// +// /* The text/icon color of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-color: var(--str-chat__text-low-emphasis-color); +// +// /* The background color of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-background-color: transparent; +// +// /* Top border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-block-start: 0; +// +// /* Bottom border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-block-end: 0; +// +// /* Left (right in RTL layout) border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-inline-start: 0; +// +// /* Right (left in RTL layout) border of the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-border-inline-end: 0; +// +// /* Box shadow applied to the tool buttons of the message input (such as attachment upload button) */ +// --str-chat__message-input-tools-box-shadow: none; +// + +// +// /* Color applied to an icon in a button that opens attachment selector */ +// --str-chat__attachment-selector-button-icon-color: var(--str-chat__text-low-emphasis-color); +// +// /* Color applied to an icon in a button that opens attachment selector when hovered over */ +// --str-chat__attachment-selector-button-icon-color-hover: var(--str-chat__primary-color); +// +// /* Color applied to an attachment selector menu item icon when hovered over */ +// --str-chat__attachment-selector-actions-menu-button-icon-color: var(--str-chat__primary-color); +// +// /* Color applied to an attachment selector menu item icon when hovered over or focused */ +// --str-chat__attachment-selector-actions-menu-button-icon-color-active: var( +// --str-chat__primary-color +// ); +//} +// +//.str-chat__message-input { +// @include utils.component-layer-overrides('message-input'); +// +// .str-chat__file-input-container { +// --str-chat-icon-color: var(--str-chat__message-input-tools-color); +// @include utils.component-layer-overrides('message-input-tools'); +// +// svg path { +// fill: var(--str-chat__message-input-tools-color); +// } +// } +// +// .str-chat__attachment-preview-image-error { +// svg path { +// fill: var(--str-chat__primary-color); +// } +// } +// +// +// +//.str-chat__attachment-selector-actions-menu { +// .str-chat__attachment-selector-actions-menu__button { +// color: var(--str-chat__text-low-emphasis-color); +// +// .str-chat__context-menu__button-icon { +// background-color: var(--str-chat__attachment-selector-actions-menu-button-icon-color); +// } +// +// &:hover, +// &:focus { +// color: var(--str-chat__text-color); +// +// .str-chat__context-menu__button-icon { +// background-color: var( +// --str-chat__attachment-selector-actions-menu-button-icon-color-active +// ); +// } +// } +// } +// +// .str-chat__attachment-selector-actions-menu__upload-file-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__folder-icon) no-repeat center / contain; +// mask: var(--str-chat__folder-icon) no-repeat center / contain; +// } +// } +// +// .str-chat__attachment-selector-actions-menu__create-poll-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__poll-icon) no-repeat center / contain; +// mask: var(--str-chat__poll-icon) no-repeat center / contain; +// } +// } +// +// .str-chat__attachment-selector-actions-menu__add-location-button { +// .str-chat__context-menu__button-icon { +// -webkit-mask: var(--str-chat__location-icon) no-repeat center / contain; +// mask: var(--str-chat__location-icon) no-repeat center / contain; +// } +// } +//} +// \ No newline at end of file diff --git a/src/components/MessageInput/styling/QuotedMessageIndicator.scss b/src/components/MessageInput/styling/QuotedMessageIndicator.scss new file mode 100644 index 000000000..2ed272722 --- /dev/null +++ b/src/components/MessageInput/styling/QuotedMessageIndicator.scss @@ -0,0 +1,10 @@ +.str-chat__quoted-message-indicator { + background-color: var(--chat-reply-indicator-incoming); + border-radius: var(--radius-max); + height: 100%; + width: 2px; +} + +.str-chat__quoted-message-indicator--own-message { + background-color: var(--chat-reply-indicator-outgoing); +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/QuotedMessagePreview.scss b/src/components/MessageInput/styling/QuotedMessagePreview.scss new file mode 100644 index 000000000..79a98f5c5 --- /dev/null +++ b/src/components/MessageInput/styling/QuotedMessagePreview.scss @@ -0,0 +1,87 @@ +@use '../../../styling/utils'; + +.str-chat { + + .str-chat__quoted-message-preview { + display: flex; + align-items: center; + position: relative; + background-color: var(--chat-bg-incoming); + padding: var(--spacing-xs); + border-radius: var(--message-bubble-radius-attachment); + + .str-chat__quoted-message-indicator { + height: 36px; + } + + .str-chat__quoted-message-preview__content { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + min-width: 0; + font-size: var(--typography-font-size-xs); + line-height: var(--typography-line-height-tight); + height: 40px; // to keep the same height even though the image preview is missing (it has 40px height) + + .str-chat__quoted-message-preview__author { + @include utils.ellipsis-text(); + overflow-x: hidden; // force ellipsis to show + font-weight: var(--typography-font-weight-semi-bold); + } + + .str-chat__quoted-message-preview__message { + //@include utils.ellipsis-text-parent; + display: flex; + align-items: center; + gap: var(--spacing-xxs); + + svg { + height: var(--typography-font-size-xs); + width: var(--typography-font-size-xs); + } + + .str-chat__icon--microphone path { + stroke-width: 2; + } + + span { + @include utils.ellipsis-text(); + min-width: 0; + flex: 1 1; + } + } + } + + .str-chat__quoted-message-preview__image { + display: flex; + position: relative; + + .str-chat__attachment-preview__thumbnail__play-indicator { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + width: 20px; + position: absolute; + left: 10px; + top: 10px; + border-radius: var(--radius-max); + background-color: var(--control-play-control-bg-inverse); + + .str-chat__icon--play-solid { + height: 12px; + width: 12px; + + path { + fill: var(--control-play-control-icon-inverse); + } + } + } + } + } + + .str-chat__quoted-message-preview--own { + background-color: var(--chat-bg-outgoing); + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss new file mode 100644 index 000000000..1fccae20e --- /dev/null +++ b/src/components/MessageInput/styling/RemoveAttachmentPreviewButton.scss @@ -0,0 +1,11 @@ +.str-chat__button.str-chat__attachment-preview__remove-button { + position: absolute; + z-index: 1; + // todo: do we need semantic variable here? + top: -6px; + right: -6px; + // todo: replace --base-black with semantic variable + background-color: var(--base-black); + border: 3px solid var(--control-remove-control-border); + border-radius: var(--radius-max); +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/index.scss b/src/components/MessageInput/styling/index.scss new file mode 100644 index 000000000..eeed997fb --- /dev/null +++ b/src/components/MessageInput/styling/index.scss @@ -0,0 +1,8 @@ +@use "AttachmentPreview"; +@use "AttachmentPreviewThumbnail"; +@use "AttachmentSelector"; +@use "LinkPreviewList"; +@use "MessageComposer"; +@use "QuotedMessageIndicator"; +@use "QuotedMessagePreview"; +@use "RemoveAttachmentPreviewButton"; \ No newline at end of file diff --git a/src/components/Poll/Poll.tsx b/src/components/Poll/Poll.tsx index ce140280d..702f29539 100644 --- a/src/components/Poll/Poll.tsx +++ b/src/components/Poll/Poll.tsx @@ -4,6 +4,7 @@ import { QuotedPoll as DefaultQuotedPoll } from './QuotedPoll'; import { PollProvider, useComponentContext } from '../../context'; import type { Poll as PollClass } from 'stream-chat'; +// todo: remove QuotedPoll component references export const Poll = ({ isQuoted, poll }: { poll: PollClass; isQuoted?: boolean }) => { const { PollContent = DefaultPollContent, QuotedPoll = DefaultQuotedPoll } = useComponentContext(); diff --git a/src/components/Poll/QuotedPoll.tsx b/src/components/Poll/QuotedPoll.tsx index 62979197f..ec70e08ad 100644 --- a/src/components/Poll/QuotedPoll.tsx +++ b/src/components/Poll/QuotedPoll.tsx @@ -15,6 +15,7 @@ const pollStateSelectorQuotedPoll = ( name: nextValue.name, }); +// todo: export const QuotedPoll = () => { const { poll } = usePollContext(); const { is_closed, name } = useStateStore(poll.state, pollStateSelectorQuotedPoll); diff --git a/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx b/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx deleted file mode 100644 index e8be8a2f5..000000000 --- a/src/components/ReactFileUtilities/FileIcon/FileIcon.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import type { IconType } from './iconMap'; -import { iconMap } from './iconMap'; - -export type FileIconProps = { - big?: boolean; - className?: string; - filename?: string; - mimeType?: string; - size?: number; // big icon on sent attachment - sizeSmall?: number; // small icon on file upload preview - type?: IconType; -}; - -export function mimeTypeToIcon(type: IconType = 'standard', mimeType?: string) { - const theMap = iconMap[type] || iconMap['standard']; - - if (!mimeType) return theMap.fallback; - - const icon = theMap[mimeType]; - if (icon) return icon; - - if (mimeType.startsWith('audio/')) return theMap['audio/']; - if (mimeType.startsWith('video/')) return theMap['video/']; - if (mimeType.startsWith('image/')) return theMap['image/']; - if (mimeType.startsWith('text/')) return theMap['text/']; - - return theMap.fallback; -} - -export const FileIcon = (props: FileIconProps) => { - const { - big = false, - mimeType, - size = 50, - sizeSmall = 20, - type = 'standard', - ...rest - } = props; - - const Icon = mimeTypeToIcon(type, mimeType); - - return ; -}; diff --git a/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx b/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx deleted file mode 100644 index 44c245117..000000000 --- a/src/components/ReactFileUtilities/FileIcon/FileIconSet.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import type { ComponentPropsWithoutRef } from 'react'; -import React from 'react'; -import clsx from 'clsx'; - -export type IconTypeV2 = 'standard' | 'alt'; - -export type IconProps = { - mimeType?: string; - size?: number; - type?: IconTypeV2; -} & ComponentPropsWithoutRef<'svg'>; - -const DEFAULT_SIZE = 40; - -export const FilePdfIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileWordIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - -); - -export const FileWordIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - - - -); - -export const FilePowerPointIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FilePowerPointIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileExcelIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileExcelIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileArchiveIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileArchiveIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileCodeIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - -); - -export const FileCodeIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileAudioIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileAudioIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileVideoIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - -); - -export const FileVideoIconAlt = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - -); - -export const FileFallbackIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - - - - - - - - - - - -); - -// v1 icon without possibility to specify size via props -export const FileImageIcon = ({ - className = '', - size = DEFAULT_SIZE, - ...props -}: IconProps) => ( - - - -); diff --git a/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts b/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts deleted file mode 100644 index 2a9623ee5..000000000 --- a/src/components/ReactFileUtilities/FileIcon/mimeTypes.ts +++ /dev/null @@ -1,163 +0,0 @@ -export type GeneralType = 'audio/' | 'video/' | 'image/' | 'text/'; - -export type SupportedMimeType = - | (typeof wordMimeTypes)[number] - | (typeof excelMimeTypes)[number] - | (typeof powerpointMimeTypes)[number] - | (typeof archiveFileTypes)[number] - | (typeof codeFileTypes)[number]; - -export const wordMimeTypes = [ - // Microsoft Word - // .doc .dot - 'application/msword', - // .doc .dot - 'application/msword-template', - // .docx - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - // .dotx (no test) - 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', - // .docm - 'application/vnd.ms-word.document.macroEnabled.12', - // .dotm (no test) - 'application/vnd.ms-word.template.macroEnabled.12', - - // LibreOffice/OpenOffice Writer - // .odt - 'application/vnd.oasis.opendocument.text', - // .ott - 'application/vnd.oasis.opendocument.text-template', - // .fodt - 'application/vnd.oasis.opendocument.text-flat-xml', - // .uot - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const excelMimeTypes = [ - // .csv - 'text/csv', - // TODO: maybe more data files - - // Microsoft Excel - // .xls .xlt .xla (no test for .xla) - 'application/vnd.ms-excel', - // .xlsx - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - // .xltx (no test) - 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', - // .xlsm - 'application/vnd.ms-excel.sheet.macroEnabled.12', - // .xltm (no test) - 'application/vnd.ms-excel.template.macroEnabled.12', - // .xlam (no test) - 'application/vnd.ms-excel.addin.macroEnabled.12', - // .xlsb (no test) - 'application/vnd.ms-excel.addin.macroEnabled.12', - - // LibreOffice/OpenOffice Calc - // .ods - 'application/vnd.oasis.opendocument.spreadsheet', - // .ots - 'application/vnd.oasis.opendocument.spreadsheet-template', - // .fods - 'application/vnd.oasis.opendocument.spreadsheet-flat-xml', - // .uos - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const powerpointMimeTypes = [ - // Microsoft Word - // .ppt .pot .pps .ppa (no test for .ppa) - 'application/vnd.ms-powerpoint', - // .pptx - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - // .potx (no test) - 'application/vnd.openxmlformats-officedocument.presentationml.template', - // .ppsx - 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - // .ppam - 'application/vnd.ms-powerpoint.addin.macroEnabled.12', - // .pptm - 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', - // .potm - 'application/vnd.ms-powerpoint.template.macroEnabled.12', - // .ppsm - 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', - - // LibreOffice/OpenOffice Writer - // .odp - 'application/vnd.oasis.opendocument.presentation', - // .otp - 'application/vnd.oasis.opendocument.presentation-template', - // .fodp - 'application/vnd.oasis.opendocument.presentation-flat-xml', - // .uop - // NOTE: firefox doesn't know mimetype so maybe ignore -]; - -export const archiveFileTypes = [ - // .zip - 'application/zip', - // .z7 - 'application/x-7z-compressed', - // .ar - 'application/x-archive', - // .tar - 'application/x-tar', - // .tar.gz - 'application/gzip', - // .tar.Z - 'application/x-compress', - // .tar.bz2 - 'application/x-bzip', - // .tar.lz - 'application/x-lzip', - // .tar.lz4 - 'application/x-lz4', - // .tar.lzma - 'application/x-lzma', - // .tar.lzo (no test) - 'application/x-lzop', - // .tar.xz - 'application/x-xz', - // .war - 'application/x-webarchive', - // .rar - 'application/vnd.rar', -]; - -export const codeFileTypes = [ - // .html .htm - 'text/html', - // .css - 'text/css', - // .js - 'application/x-javascript', - 'text/javascript', - // .json - 'application/json', - // .py - 'text/x-python', - // .go - 'text/x-go', - // .c - 'text/x-csrc', - // .cpp - 'text/x-c++src', - // .rb - 'application/x-ruby', - // .rust - 'text/rust', - // .java - 'text/x-java', - // .php - 'application/x-php', - // .cs - 'text/x-csharp', - // .scala - 'text/x-scala', - // .erl - 'text/x-erlang', - // .sh - 'application/x-shellscript', -]; diff --git a/src/components/ReactFileUtilities/UploadButton.tsx b/src/components/ReactFileUtilities/UploadButton.tsx index a4e4f78b0..1d651f592 100644 --- a/src/components/ReactFileUtilities/UploadButton.tsx +++ b/src/components/ReactFileUtilities/UploadButton.tsx @@ -5,7 +5,7 @@ import React, { forwardRef, useCallback, useMemo } from 'react'; import { useHandleFileChangeWrapper } from './utils'; import { useMessageInputContext, useTranslationContext } from '../../context'; -import { useMessageComposer } from '../MessageInput'; +import { useCooldownRemaining, useMessageComposer } from '../MessageInput'; import { useAttachmentManagerState } from '../MessageInput/hooks/useAttachmentManagerState'; import { useStateStore } from '../../store'; import type { MessageComposerConfig } from 'stream-chat'; @@ -50,7 +50,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( ref: React.ForwardedRef, ) { const { t } = useTranslationContext('UploadFileInput'); - const { cooldownRemaining, textareaRef } = useMessageInputContext(); + const { textareaRef } = useMessageInputContext(); const messageComposer = useMessageComposer(); const { attachmentManager } = messageComposer; const { isUploadEnabled } = useAttachmentManagerState(); @@ -58,6 +58,7 @@ export const UploadFileInput = forwardRef(function UploadFileInput( messageComposer.configState, attachmentManagerConfigStateSelector, ); + const cooldownRemaining = useCooldownRemaining(); const id = useMemo(() => nanoid(), []); const onFileChange = useCallback( diff --git a/src/components/ReactFileUtilities/index.ts b/src/components/ReactFileUtilities/index.ts index 667a49056..a3a8c7251 100644 --- a/src/components/ReactFileUtilities/index.ts +++ b/src/components/ReactFileUtilities/index.ts @@ -1,4 +1,3 @@ -export * from './FileIcon'; export * from './LoadingIndicator'; export * from './UploadButton'; export * from './types'; diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 7b19c2c95..b9a719518 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -7,7 +7,7 @@ import type { } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import Textarea from 'react-textarea-autosize'; -import { useMessageComposer } from '../MessageInput'; +import { useCooldownRemaining, useMessageComposer } from '../MessageInput'; import type { AttachmentManagerState, MessageComposerConfig, @@ -22,6 +22,7 @@ import { } from '../../context'; import { useStateStore } from '../../store'; import { SuggestionList as DefaultSuggestionList } from './SuggestionList'; +import { useTimer } from '../MessageInput/hooks/useTimer'; const textComposerStateSelector = (state: TextComposerState) => ({ selection: state.selection, @@ -87,7 +88,6 @@ export const TextareaComposer = ({ const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext(); const { additionalTextareaProps, - cooldownRemaining, focus, handleSubmit, maxRows: maxRowsContext, @@ -96,9 +96,15 @@ export const TextareaComposer = ({ shouldSubmit: shouldSubmitContext, textareaRef, } = useMessageInputContext(); + const cooldownRemaining = useCooldownRemaining(); + const cooldownRemainingLeft = useTimer({ startFrom: cooldownRemaining }); + const placeholder = cooldownRemainingLeft + ? t('Slow mode, wait {{ seconds }}s...', { seconds: cooldownRemainingLeft }) + : (placeholderProp ?? additionalTextareaProps?.placeholder ?? t('Type your message')); + const maxRows = maxRowsProp ?? maxRowsContext ?? 1; const minRows = minRowsProp ?? minRowsContext; - const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder; + const shouldSubmit = shouldSubmitProp ?? shouldSubmitContext ?? defaultShouldSubmit; const messageComposer = useMessageComposer(); @@ -282,19 +288,14 @@ export const TextareaComposer = ({ return (