Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
04dfe29
feat: show username in metadata only for 3+ members
MartinCupela Feb 20, 2026
2476745
feat: show "Pinned by You" for messages pinned by own user
MartinCupela Feb 20, 2026
4db82a2
feat: set max message width to 400px
MartinCupela Feb 20, 2026
5260f32
style: fix linter issues
MartinCupela Feb 20, 2026
a51b47f
docs: explain how Attachment prop attachmentActionsDefaultFocus shoul…
MartinCupela Feb 20, 2026
f37116c
feat: redesign ReminderNotification
MartinCupela Feb 20, 2026
bddb9e9
feat: add MessageTranslationIndicator
MartinCupela Feb 20, 2026
bc5bc43
Merge origin/master into feat/channel-header-figma
MartinCupela Feb 23, 2026
f728d14
feat(ChannelHeader): implement Figma design with sidebar collapse and…
MartinCupela Feb 24, 2026
172ed3d
feat: render
MartinCupela Feb 24, 2026
67827f8
chore: update dev_patterns skill
MartinCupela Feb 24, 2026
ed3a06b
feat: redesign UnreadMessagesNotification and UnreadMessagesSeparator
MartinCupela Feb 25, 2026
0341869
fix: display the UnreadMessageNotification reliably in MessageList
MartinCupela Feb 25, 2026
4d8ce8d
Merge branch 'master' into feat/message-view-design-refresh
MartinCupela Feb 25, 2026
80b6989
feat(Button): add variant, appearance, circular, and size props
MartinCupela Feb 25, 2026
6c0c0ea
refactor(Button): use variant, appearance, size, circular props in co…
MartinCupela Feb 25, 2026
3f8c19b
feat: introduce button variant props
MartinCupela Feb 25, 2026
514e85e
Merge branch 'master' into feat/button-variant-props
MartinCupela Feb 25, 2026
fb298b0
Merge branch 'master' into feat/channel-header-figma
MartinCupela Feb 25, 2026
9ae7a54
Merge branch 'feat/button-variant-props' into feat/channel-header-figma
MartinCupela Feb 25, 2026
ea0800e
feat: redesign ChannelHeader
MartinCupela Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout';
//@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout';
//@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout';
@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout';
//@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout';
@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout';
@use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-layout';
@use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-layout';
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme';
//@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme';
//@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss';
@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme';
//@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme';
@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme';
@use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-theme';
@use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-theme';
Expand Down
4 changes: 2 additions & 2 deletions src/components/Attachment/AttachmentActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,9 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => {
<span>{text}</span>
{actions.map((action, index) => (
<Button
appearance='ghost'
className={clsx(
`str-chat__message-attachment-actions-button str-chat__message-attachment-actions-button--${action.style}`,
'str-chat__button--ghost',
'str-chat__button--secondary',
)}
data-testid={`${action.name}`}
data-value={action.value}
Expand All @@ -82,6 +81,7 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => {
ref={(element) => {
buttonRefs.current[index] = element;
}}
variant='secondary'
>
{action.text ? (knownActionText[action.text] ?? t(action.text)) : null}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export const PlaybackRateButton = ({ children, onClick }: PlaybackRateButtonProp
className={clsx('str-chat__message_attachment__playback-rate-button')}
data-testid='playback-rate-button'
onClick={onClick}
type='button'
>
{children}
</Button>
Expand Down
44 changes: 41 additions & 3 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,56 @@ import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
import clsx from 'clsx';

export type ButtonProps = ComponentProps<'button'>;
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
export type ButtonAppearance = 'solid' | 'outline' | 'ghost';
export type ButtonSize = 'lg' | 'md' | 'sm';

export type ButtonProps = ComponentProps<'button'> & {
/** Semantic variant: primary, secondary, or danger (maps to destructive in styles). */
variant?: ButtonVariant;
/** Visual style: solid, outline, or ghost. */
appearance?: ButtonAppearance;
/** When true, uses full border-radius for icon-only/pill shape. */
circular?: boolean;
/** Size: lg, md, or sm. */
size?: ButtonSize;
};

const variantToClass: Record<ButtonVariant, string> = {
danger: 'str-chat__button--destructive',
primary: 'str-chat__button--primary',
secondary: 'str-chat__button--secondary',
};

const appearanceToClass: Record<ButtonAppearance, string> = {
ghost: 'str-chat__button--ghost',
outline: 'str-chat__button--outline',
solid: 'str-chat__button--solid',
};

const sizeToClass: Record<ButtonSize, string> = {
lg: 'str-chat__button--size-lg',
md: 'str-chat__button--size-md',
sm: 'str-chat__button--size-sm',
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, ...props },
{ appearance, circular, className, size, variant, ...props },
ref,
) {
return (
<button
ref={ref}
type='button'
{...props}
className={clsx('str-chat__button', className)}
className={clsx(
'str-chat__button',
variant != null && variantToClass[variant],
appearance != null && appearanceToClass[appearance],
circular && 'str-chat__button--circular',
size != null && sizeToClass[size],
className,
)}
/>
);
});
15 changes: 6 additions & 9 deletions src/components/Button/PlayButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@ export type PlayButtonProps = ComponentProps<'button'> & {

export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => (
<Button
{...props}
className={clsx(
'str-chat__button-play',
'str-chat__button--secondary',
'str-chat__button--outline',
'str-chat__button--size-sm',
'str-chat__button--circular',
className,
)}
appearance='outline'
circular
className={clsx('str-chat__button-play', className)}
data-testid={isPlaying ? 'pause-audio' : 'play-audio'}
size='sm'
variant='secondary'
{...props}
>
{isPlaying ? <IconPause /> : <IconPlaySolid />}
</Button>
Expand Down
66 changes: 32 additions & 34 deletions src/components/ChannelHeader/ChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import React from 'react';

import { MenuIcon as DefaultMenuIcon } from './icons';
import { IconLayoutAlignLeft } from '../Icons/icons';
import { Avatar as DefaultAvatar } from '../Avatar';
import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus';
import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo';
import { useChannelStateContext } from '../../context/ChannelStateContext';
import { useChatContext } from '../../context/ChatContext';
import { useTranslationContext } from '../../context/TranslationContext';
import type { ChannelAvatarProps } from '../Avatar';
import { Button } from '../Button';
import clsx from 'clsx';

export type ChannelHeaderProps = {
/** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */
Avatar?: React.ComponentType<ChannelAvatarProps>;
/** Manually set the image to render, defaults to the Channel image */
image?: string;
/** Show a little indicator that the Channel is live right now */
live?: boolean;
/** UI component to display menu icon, defaults to [MenuIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelHeader/ChannelHeader.tsx)*/
MenuIcon?: React.ComponentType;
/** When true, shows IconLayoutAlignLeft instead of MenuIcon for sidebar expansion */
sidebarCollapsed?: boolean;
/** Set title manually */
title?: string;
};
Expand All @@ -28,58 +31,53 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
const {
Avatar = DefaultAvatar,
image: overrideImage,
live,
MenuIcon = DefaultMenuIcon,
MenuIcon = IconLayoutAlignLeft,
sidebarCollapsed = true,
title: overrideTitle,
} = props;

const { channel, watcher_count } = useChannelStateContext('ChannelHeader');
const { channel } = useChannelStateContext();
const { openMobileNav } = useChatContext('ChannelHeader');
const { t } = useTranslationContext('ChannelHeader');
const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({
channel,
overrideImage,
overrideTitle,
});

const { member_count, subtitle } = channel?.data || {};
const onlineStatusText = useChannelHeaderOnlineStatus();

return (
<div className='str-chat__channel-header'>
<button
aria-label={t('aria/Menu')}
className='str-chat__header-hamburger'
<div
className={clsx('str-chat__channel-header', {
'str-chat__channel-header--sidebar-collapsed': sidebarCollapsed,
})}
>
<Button
appearance='ghost'
aria-label={sidebarCollapsed ? t('aria/Expand sidebar') : t('aria/Menu')}
circular
className='str-chat__header-sidebar-toggle'
onClick={openMobileNav}
size='md'
variant='secondary'
>
<MenuIcon />
</button>
{sidebarCollapsed && <MenuIcon />}
</Button>
<div className='str-chat__channel-header__data'>
<div className='str-chat__channel-header__data__title'>{displayTitle}</div>
{onlineStatusText != null && (
<div className='str-chat__channel-header__data__subtitle'>
{onlineStatusText}
</div>
)}
</div>
<Avatar
className='str-chat__avatar--channel-header'
groupChannelDisplayInfo={groupChannelDisplayInfo}
imageUrl={displayImage}
size='lg'
userName={displayTitle}
/>
<div className='str-chat__channel-header-end'>
<p className='str-chat__channel-header-title'>
{displayTitle}{' '}
{live && (
<span className='str-chat__header-livestream-livelabel'>{t('live')}</span>
)}
</p>
{subtitle && <p className='str-chat__channel-header-subtitle'>{subtitle}</p>}
<p className='str-chat__channel-header-info'>
{!live && !!member_count && member_count > 0 && (
<>
{t('{{ memberCount }} members', {
memberCount: member_count,
})}
,{' '}
</>
)}
{t('{{ watcherCount }} online', { watcherCount: watcher_count })}
</p>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import type { ChannelState } from 'stream-chat';

import { useChannelStateContext } from '../../../context/ChannelStateContext';
import { useChatContext } from '../../../context/ChatContext';
import { useTranslationContext } from '../../../context/TranslationContext';

/**
* Returns the channel header online status text (e.g. "Online", "Offline", or "X members, Y online").
* Returns null when the channel has no members (nothing to show).
*/
export function useChannelHeaderOnlineStatus(): string | null {
const { t } = useTranslationContext();
const { client } = useChatContext();
const { channel, watcherCount = 0 } = useChannelStateContext();
const { member_count: memberCount = 0 } = channel?.data || {};

// todo: we need reactive state for watchers in LLC
const [watchers, setWatchers] = useState<ChannelState['watchers']>(() =>
Object.assign({}, channel?.state?.watchers ?? {}),
);

useEffect(() => {
if (!channel) return;
const subscription = channel.on('user.watching.start', (event) => {
setWatchers((prev) => {
if (!event.user?.id) return prev;
if (prev[event.user.id]) return prev;
return Object.assign({ [event.user.id]: event.user }, prev);
});
});
return () => subscription.unsubscribe();
}, [channel]);

if (!memberCount) return null;

const isDmChannel =
memberCount === 1 ||
(memberCount === 2 &&
Object.values(channel?.state?.members ?? {}).some(
({ user }) => user?.id === client.user?.id,
));

if (isDmChannel) {
const hasWatchers = Object.keys(watchers).length > 0;
return hasWatchers ? t('Online') : t('Offline');
}

return `${t('{{ memberCount }} members', { memberCount })}, ${t('{{ watcherCount }} online', { watcherCount })}`;
}
17 changes: 0 additions & 17 deletions src/components/ChannelHeader/icons.tsx

This file was deleted.

Loading
Loading