Skip to content

Commit

Permalink
feat(notifications): use room unread count to process notifications (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
domw30 authored Dec 4, 2024
1 parent 8bac1b8 commit ad24c08
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 122 deletions.
19 changes: 16 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { getMainBackgroundClass, getMainBackgroundVideoSrc } from './utils';
import { AppBar } from './components/app-bar/container';
import { DialogManager } from './components/dialog-manager/container';
import { ThemeEngine } from './components/theme-engine';
import { denormalizeConversations } from './store/channels-list';
import { DefaultRoomLabels } from './store/channels';

export const App = () => {
const { isAuthenticated, mainClassName, videoBackgroundSrc, wrapperClassName } = useAppMain();
const { isAuthenticated, mainClassName, videoBackgroundSrc, wrapperClassName, hasUnreadNotifications } = useAppMain();

return (
// See: ZOS-115
Expand All @@ -23,7 +25,7 @@ export const App = () => {
{videoBackgroundSrc && <VideoBackground src={videoBackgroundSrc} />}
<div className={wrapperClassName}>
<DialogManager />
<AppBar />
<AppBar hasUnreadNotifications={hasUnreadNotifications} />
<AppRouter />
</div>
</>
Expand All @@ -37,8 +39,18 @@ export const App = () => {
const useAppMain = () => {
const isAuthenticated = useSelector((state: RootState) => !!state.authentication.user?.data);
const background = useSelector((state: RootState) => state.background.selectedMainBackground);
const videoBackgroundSrc = getMainBackgroundVideoSrc(background);

const hasUnreadNotifications = useSelector((state: RootState) => {
const conversations = denormalizeConversations(state);
return conversations.some(
(channel) =>
channel.unreadCount > 0 &&
!channel.labels?.includes(DefaultRoomLabels.ARCHIVED) &&
!channel.labels?.includes(DefaultRoomLabels.MUTE)
);
});

const videoBackgroundSrc = getMainBackgroundVideoSrc(background);
const mainClassName = classNames('main', 'messenger-full-screen', getMainBackgroundClass(background), {
'sidekick-panel-open': isAuthenticated,
background: isAuthenticated,
Expand All @@ -51,6 +63,7 @@ const useAppMain = () => {
mainClassName,
videoBackgroundSrc,
wrapperClassName,
hasUnreadNotifications,
};
};

Expand Down
4 changes: 2 additions & 2 deletions src/components/app-bar/container.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useRouteMatch } from 'react-router-dom';
import { AppBar as AppBarComponent } from './';

export const AppBar = () => {
export const AppBar = ({ hasUnreadNotifications }: { hasUnreadNotifications: boolean }) => {
const match = useRouteMatch('/:app');

return <AppBarComponent activeApp={match?.params?.app ?? ''} />;
return <AppBarComponent activeApp={match?.params?.app ?? ''} hasUnreadNotifications={hasUnreadNotifications} />;
};
2 changes: 1 addition & 1 deletion src/components/app-bar/container.vitest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ vi.mock('./', () => ({
const renderComponent = (route: string | undefined = '/') => {
render(
<MemoryRouter initialEntries={[route]}>
<AppBar />
<AppBar hasUnreadNotifications={false} />
</MemoryRouter>
);
};
Expand Down
19 changes: 18 additions & 1 deletion src/components/app-bar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const cn = bemClassName('app-bar');

export interface Properties {
activeApp: string | undefined;
hasUnreadNotifications: boolean;
}

interface State {
Expand All @@ -27,6 +28,17 @@ export class AppBar extends React.Component<Properties, State> {
openModal = () => this.setState({ isModalOpen: true });
closeModal = () => this.setState({ isModalOpen: false });

renderNotificationIcon = () => {
const { hasUnreadNotifications } = this.props;

return (
<div {...cn('notification-icon-wrapper')}>
<IconBell1 size={24} />
{hasUnreadNotifications && <div {...cn('notification-dot')} />}
</div>
);
};

render() {
const { activeApp } = this.props;
const isActive = checkActive(activeApp);
Expand All @@ -37,7 +49,12 @@ export class AppBar extends React.Component<Properties, State> {
<AppLink Icon={IconMessageSquare2} isActive={isActive('conversation')} label='Messenger' to='/conversation' />
<AppLink Icon={IconGlobe3} isActive={isActive('explorer')} label='Explorer' to='/explorer' />
{featureFlags.enableNotificationsApp && (
<AppLink Icon={IconBell1} isActive={isActive('notifications')} label='Notifications' to='/notifications' />
<AppLink
Icon={this.renderNotificationIcon}
isActive={isActive('notifications')}
label='Notifications'
to='/notifications'
/>
)}
<WorldPanelItem Icon={IconDotsGrid} label='More Apps' isActive={false} onClick={this.openModal} />
</div>
Expand Down
1 change: 1 addition & 0 deletions src/components/app-bar/index.vitest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock('./world-panel-item', () => ({

const DEFAULT_PROPS: Properties = {
activeApp: undefined,
hasUnreadNotifications: false,
};

const renderComponent = (props: Partial<Properties>) => {
Expand Down
14 changes: 14 additions & 0 deletions src/components/app-bar/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@
pointer-events: all;

@include main-background;

&__notification-icon-wrapper {
position: relative;
}

&__notification-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: theme.$color-secondary-11;
}
}
87 changes: 45 additions & 42 deletions src/components/notifications-feed/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Container, Properties } from './index';
import { Container } from './index';
import { NotificationItem } from './notification-item';
import { Header } from '../header';
import { Channel } from '../../store/channels';

describe('NotificationsFeed', () => {
const mockNotifications = [
const mockConversations: Channel[] = [
{
id: '1',
content: { body: 'notification 1' },
sender: { userId: 'user-1', firstName: 'Test' },
createdAt: 1678861267433,
roomId: 'room-1',
type: 'reply' as any,
id: 'channel-1',
unreadCount: 3,
name: 'Conversation 1',
lastMessage: { content: 'Hello' },
},
{
id: '2',
content: { body: 'notification 2' },
sender: { userId: 'user-2', firstName: 'User' },
createdAt: 1678861267434,
roomId: 'room-2',
type: 'reply' as any,
id: 'channel-2',
unreadCount: 1,
name: 'Conversation 2',
lastMessage: { content: 'Hi there' },
},
];
] as any;

const subject = (props: Partial<Properties> = {}) => {
const subject = (
props: Partial<{
conversations: Channel[];
isConversationsLoaded: boolean;
openNotificationConversation: (roomId: string) => void;
}> = {}
) => {
const allProps = {
notifications: [],
loading: false,
error: null,
fetchNotifications: jest.fn(),
conversations: [],
isConversationsLoaded: false,
openNotificationConversation: jest.fn(),
markNotificationsAsRead: jest.fn(),

...props,
};

Expand All @@ -47,38 +48,40 @@ describe('NotificationsFeed', () => {
expect(header.prop('icon')).toBeTruthy();
});

it('renders notifications when they exist', () => {
const wrapper = subject({ notifications: mockNotifications } as any);
const notificationItems = wrapper.find(NotificationItem);
it('renders notifications when conversations exist', () => {
const wrapper = subject({
conversations: mockConversations,
isConversationsLoaded: true,
});

const notificationItems = wrapper.find(NotificationItem);
expect(notificationItems).toHaveLength(2);
expect(notificationItems.at(0).prop('notification')).toEqual(mockNotifications[0]);
expect(notificationItems.at(1).prop('notification')).toEqual(mockNotifications[1]);
expect(notificationItems.at(0).prop('conversation')).toEqual(mockConversations[0]);
expect(notificationItems.at(1).prop('conversation')).toEqual(mockConversations[1]);
});

it('calls fetchNotifications on mount', () => {
const fetchNotifications = jest.fn();
subject({ fetchNotifications });
it('shows loading state when conversations are not loaded', () => {
const wrapper = subject({ isConversationsLoaded: false });
expect(wrapper.find('Spinner').exists()).toBe(true);
});

expect(fetchNotifications).toHaveBeenCalled();
it('shows empty state when no conversations and loaded', () => {
const wrapper = subject({
conversations: [],
isConversationsLoaded: true,
});
expect(wrapper.text()).toContain('No new notifications');
});

it('calls openNotificationConversation when notification is clicked', () => {
const openNotificationConversation = jest.fn();
const wrapper = subject({
notifications: mockNotifications,
conversations: mockConversations,
isConversationsLoaded: true,
openNotificationConversation,
} as any);

wrapper.find(NotificationItem).first().prop('onClick')('room-1');

expect(openNotificationConversation).toHaveBeenCalledWith('room-1');
});

it('gets correct oldest timestamp from notifications', () => {
const wrapper = subject({ notifications: mockNotifications } as any);
const instance = wrapper.instance() as Container;
});

expect(instance.getOldestTimestamp(mockNotifications)).toBe(1678861267433);
wrapper.find(NotificationItem).first().prop('onClick')('channel-1');
expect(openNotificationConversation).toHaveBeenCalledWith('channel-1');
});
});
70 changes: 27 additions & 43 deletions src/components/notifications-feed/index.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,54 @@
import React from 'react';
import { RootState } from '../../store/reducer';
import { connectContainer } from '../../store/redux-container';
import { Notification } from '../../store/notifications';
import { fetchNotifications, openNotificationConversation, markNotificationsAsRead } from '../../store/notifications';

import { Channel, DefaultRoomLabels } from '../../store/channels';
import { denormalizeConversations } from '../../store/channels-list';
import { Header } from '../header';
import { IconBell1 } from '@zero-tech/zui/icons';
import { NotificationItem } from './notification-item';
import { Spinner } from '@zero-tech/zui/components/LoadingIndicator';
import { featureFlags } from '../../lib/feature-flags';
import { openNotificationConversation } from '../../store/notifications';

import styles from './styles.module.scss';

export interface PublicProperties {}

export interface Properties extends PublicProperties {
notifications: Notification[];
loading: boolean;
error: string | null;
conversations: Channel[];
isConversationsLoaded: boolean;

fetchNotifications: () => void;
openNotificationConversation: (roomId: string) => void;
markNotificationsAsRead: (roomId: string) => void;
}

export class Container extends React.Component<Properties> {
static mapState(state: RootState): Partial<Properties> {
const {
notifications: { items, loading, error },
chat: { isConversationsLoaded },
} = state;

const conversations = denormalizeConversations(state).filter(
(conversation) =>
conversation.unreadCount > 0 &&
!conversation.labels?.includes(DefaultRoomLabels.ARCHIVED) &&
!conversation.labels?.includes(DefaultRoomLabels.MUTE)
);

return {
notifications: items,
loading,
error,
conversations,
isConversationsLoaded,
};
}

static mapActions(): Partial<Properties> {
return {
fetchNotifications,
openNotificationConversation,
markNotificationsAsRead,
};
}

componentDidMount() {
this.props.fetchNotifications();
}

onNotificationClick = (roomId: string) => {
if (featureFlags.enableNotificationsReadStatus) {
this.props.markNotificationsAsRead(roomId);
}
this.props.openNotificationConversation(roomId);
};

getOldestTimestamp(notifications: Notification[] = []): number {
return notifications.reduce((previousTimestamp, notification: any) => {
return notification.createdAt < previousTimestamp ? notification.createdAt : previousTimestamp;
}, Date.now());
}

renderHeaderIcon() {
return <IconBell1 className={styles.HeaderIcon} size={18} isFilled />;
}
Expand All @@ -71,17 +58,17 @@ export class Container extends React.Component<Properties> {
}

renderNotifications() {
const { notifications } = this.props;
const { conversations } = this.props;

return notifications.map((notification) => (
<li key={notification.id}>
<NotificationItem notification={notification} onClick={this.onNotificationClick} />
return conversations.map((conversation) => (
<li key={conversation.id}>
<NotificationItem conversation={conversation} onClick={this.onNotificationClick} />
</li>
));
}

renderNoNotifications() {
return <div className={styles.NoNotifications}>No notifications</div>;
return <div className={styles.NoNotifications}>No new notifications</div>;
}

renderLoading() {
Expand All @@ -92,13 +79,11 @@ export class Container extends React.Component<Properties> {
);
}

renderError() {
const { error } = this.props;
return error ? <div className={styles.Error}>{error}</div> : null;
}

render() {
const { notifications, loading, error } = this.props;
const { conversations, isConversationsLoaded } = this.props;

const isEmptyState = conversations.length === 0 && isConversationsLoaded;
const isLoadingState = !isConversationsLoaded;

return (
<div className={styles.NotificationsFeed}>
Expand All @@ -108,11 +93,10 @@ export class Container extends React.Component<Properties> {
</div>

<div className={styles.Body}>
<ol className={styles.Notifications}>{notifications.length > 0 && this.renderNotifications()}</ol>
<ol className={styles.Notifications}>{!isEmptyState && this.renderNotifications()}</ol>

{notifications.length === 0 && !loading && !error && this.renderNoNotifications()}
{loading && this.renderLoading()}
{error && this.renderError()}
{isEmptyState && this.renderNoNotifications()}
{isLoadingState && this.renderLoading()}
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit ad24c08

Please sign in to comment.