Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 36 additions & 10 deletions src/ui/components/TabBar/WorkspaceTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { DragEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import type {
DragEvent,
FocusEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
} from 'react';
import { useContext, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';

Expand All @@ -22,6 +28,7 @@ import {
Tab,
TabBadge,
UnreadDot,
UnreadDotBadge,
} from './styles';
import type { TabOrientation } from './styles';

Expand Down Expand Up @@ -93,6 +100,7 @@ const WorkspaceTab = ({
const mentionCount =
typeof badge === 'number' && badge > 0 ? badge : undefined;
const displayCount = formatMentionCount(mentionCount);
const hasUnreadMessages = badge !== undefined && typeof badge !== 'number';

const shortcutSuffix =
shortcutNumber && Number(shortcutNumber) >= 1 && Number(shortcutNumber) <= 9
Expand All @@ -104,7 +112,7 @@ const WorkspaceTab = ({
return ` — ${t('tabBar.unreadMessage', { count: mentionCount })}`;
}

if (badge === '•') {
if (hasUnreadMessages) {
return ` — ${t('tabBar.unreadMessages')}`;
}

Expand Down Expand Up @@ -164,13 +172,27 @@ const WorkspaceTab = ({
const isVertical = orientation === 'vertical';
const showLabel = !compact && !isVertical;

const badges = (
<>
{displayCount && <TabBadge variant='ghost'>{displayCount}</TabBadge>}
{!displayCount && badge === '•' && <UnreadDot variant='ghost' />}
{!userLoggedIn && <TabBadge variant='warning'>!</TabBadge>}
</>
);
// Exactly one badge at a time: a logged-out server's unread state is stale,
// so the login warning wins; otherwise a mention count beats the plain
// unread dot. The unread indicator itself depends on the layout: the
// sidebar (vertical) gets the badge with the drawn dot, the tab strip
// keeps the plain 8px ball.
const badgeElement = ((): ReactNode => {
if (!userLoggedIn) {
return <TabBadge variant='warning'>!</TabBadge>;
}
if (displayCount) {
return <TabBadge variant='ghost'>{displayCount}</TabBadge>;
}
if (hasUnreadMessages) {
return isVertical ? (
<UnreadDotBadge variant='ghost' />
) : (
<UnreadDot variant='ghost' />
);
}
return null;
})();

return (
<>
Expand Down Expand Up @@ -212,7 +234,11 @@ const WorkspaceTab = ({
{showLabel && isShortcutVisible && shortcutNumber && (
<ShortcutChip>{shortcutNumber}</ShortcutChip>
)}
{isVertical ? <BadgeWrapper>{badges}</BadgeWrapper> : badges}
{isVertical ? (
<BadgeWrapper>{badgeElement}</BadgeWrapper>
) : (
badgeElement
)}
</Tab>
<Divider orientation={orientation}></Divider>
</>
Expand Down
129 changes: 126 additions & 3 deletions src/ui/components/TabBar/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ describe('TabBar', () => {
renderTabBar(<TabBar />, {
preloadedState: buildState({
servers: [
{ url: 'https://a.rocket.chat/', title: 'Server A', badge: 150 },
{
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: 150,
userLoggedIn: true,
},
],
}),
});
Expand All @@ -178,6 +183,70 @@ describe('TabBar', () => {
expect(badges[0]).toHaveTextContent('');
});

it('renders the unread indicator as the 8px ball in horizontal mode and the full badge in vertical mode', () => {
const server = {
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: '•' as const,
userLoggedIn: true,
};

const horizontal = renderTabBar(<TabBar />, {
preloadedState: buildState({ servers: [server] }),
});
const horizontalBadge = horizontal.container.querySelector('.rcx-badge');
expect(horizontalBadge).not.toBeNull();
expect(getComputedStyle(horizontalBadge as Element).width).toBe('8px');
horizontal.unmount();

const vertical = renderTabBar(<TabBar orientation='vertical' />, {
preloadedState: buildState({ servers: [server] }),
});
const verticalBadge = vertical.container.querySelector('.rcx-badge');
expect(verticalBadge).not.toBeNull();
// The vertical indicator keeps the standard badge box (Fuselage's own
// 1rem minimum, not loaded in this environment) instead of the 8px ball.
expect(getComputedStyle(verticalBadge as Element).width).not.toBe('8px');
});

it('shows only the login warning when a logged-out server also has unread messages', () => {
const { container } = renderTabBar(<TabBar />, {
preloadedState: buildState({
servers: [
{
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: '•',
userLoggedIn: false,
},
],
}),
});

const badges = container.querySelectorAll('.rcx-badge');
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveTextContent('!');
});

it('shows the mention count instead of the unread dot when the badge is a number', () => {
const { container } = renderTabBar(<TabBar />, {
preloadedState: buildState({
servers: [
{
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: 3,
userLoggedIn: true,
},
],
}),
});

const badges = container.querySelectorAll('.rcx-badge');
expect(badges).toHaveLength(1);
expect(badges[0]).toHaveTextContent('3');
});

it('renders initials as a fallback when there is no favicon', () => {
renderTabBar(<TabBar />, {
preloadedState: buildState({
Expand Down Expand Up @@ -339,7 +408,12 @@ describe('TabBar', () => {
renderTabBar(<TabBar />, {
preloadedState: buildState({
servers: [
{ url: 'https://a.rocket.chat/', title: 'Server A', badge: 97 },
{
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: 97,
userLoggedIn: true,
},
],
}),
});
Expand Down Expand Up @@ -384,12 +458,61 @@ describe('TabBar', () => {
renderTabBar(<TabBar orientation='vertical' />, {
preloadedState: buildState({
servers: [
{ url: 'https://a.rocket.chat/', title: 'Server A', badge: 5 },
{
url: 'https://a.rocket.chat/',
title: 'Server A',
badge: 5,
userLoggedIn: true,
},
],
}),
});

expect(screen.getByText('5')).toBeInTheDocument();
});

// Every badge — the unread '•' dot, the '!' warning, a wider mention
// count — must share one centre point so size and variant never change
// where the badge appears. An edge-anchored wrapper would let width leak
// into placement and shift narrower badges off the others' centre.
it('centres every badge on the same point regardless of size or variant', () => {
const getWrapperOffsets = (badgeProps: Record<string, unknown>) => {
const { unmount } = renderTabBar(<TabBar orientation='vertical' />, {
preloadedState: buildState({
servers: [
{
url: 'https://a.rocket.chat/',
title: 'Server A',
...badgeProps,
},
],
}),
});

const tab = screen.getByRole('tab');
const wrapper = tab.querySelector('div');
const style = wrapper ? getComputedStyle(wrapper) : null;
const offsets = {
top: style?.top,
left: style?.left,
};

unmount();
return offsets;
};

const dot = getWrapperOffsets({ badge: '•', userLoggedIn: true });
const warning = getWrapperOffsets({ userLoggedIn: false });
const mentionCount = getWrapperOffsets({ badge: 42, userLoggedIn: true });

// The anchor is declared in px and paired with translate(-50%, -50%), so
// an identical top/left across variants means an identical centre. (jsdom
// resolves the percentage transform against each badge's measured box, so
// the declared anchor — not the computed matrix — is what to compare.)
expect(dot.top).toBe('4px');
expect(dot.left).toBe('28px');
expect(warning).toEqual(dot);
expect(mentionCount).toEqual(dot);
});
});
});
67 changes: 55 additions & 12 deletions src/ui/components/TabBar/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,31 +364,74 @@ export const ShortcutChip = styled.span`
opacity: 0.7;
`;

/* Colors come from the Fuselage Badge variant tokens. The ghost variant is
deliberate: its background (stroke-dark) flips to a light gray in the dark
palette, keeping the badge visible on the dark tab strip, where the
server's secondary/level-1 badge color would blend in. Overriding the text
color here once broke the badge by referencing a token that does not
exist. */
export const TabBadge = styled(Badge)`
flex-shrink: 0;
color: var(--rcx-color-font-white);
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2);
`;

/* Unread-without-mentions indicator for vertical (sidebar) tabs: same
footprint as the count badge, with a drawn dot instead of a text glyph.
The dot matches the macOS dock badge (dock.setBadge('•') renders its
bullet at ~1/8 of the badge diameter → 2px inside the 16px badge) and
uses currentColor so it follows the variant's font token. */
export const UnreadDotBadge = styled(TabBadge)`
align-items: center;

&::before {
content: '';
display: block;
width: 2px;
height: 2px;
border-radius: 50%;
background: currentColor;
}
`;

/* Unread-without-mentions indicator for horizontal tabs: a plain 8px ball.
Badge ships min-width/min-height of 1rem plus 2px/4px padding; without
resetting the padding and the fixed box, an 8px dot renders as a 16x8
pill. */
export const UnreadDot = styled(TabBadge)`
width: 8px;
min-width: 8px;
height: 8px;
min-height: 8px;
padding: 0;
`;

/* Floats the mention/warning badges over the top-right corner of a vertical
tab, matching the sidebar's ServerButton badge placement. */
tab, matching the sidebar's ServerButton badge placement.

Anchors the badge's CENTRE to a fixed point on the tab, so a badge of any
size or variant lands in the same visual spot. The anchor is the centre of
the standard 16px badge sitting on the corner, which is the reference
placement; a wider mention count then grows outward from that same centre
rather than shifting position.

Anchoring an edge instead (e.g. right: 0) pins the wrapper's right edge and
lets content grow leftward, so badge width leaks into placement and a
narrower badge lands off-centre from a wider one. */
const BADGE_ANCHOR_X = '28px';
const BADGE_ANCHOR_Y = '4px';

export const BadgeWrapper = styled.div`
position: absolute;
top: 0;
right: 0;
top: ${BADGE_ANCHOR_Y};
left: ${BADGE_ANCHOR_X};
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
transform: translate(30%, -30%);
transform: translate(-50%, -50%);
pointer-events: none;
`;

/* Small unread indicator for tabs that have unread messages but no mention
count (badge === '•'), in both horizontal and vertical layouts. */
export const UnreadDot = styled(TabBadge)`
min-width: 8px;
min-height: 8px;
`;

export const WindowControlsGroup = styled.div`
display: flex;
flex-direction: row;
Expand Down
5 changes: 4 additions & 1 deletion src/ui/components/TopBar/ServerSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ const Trigger = styled.button<{ hasNotification: boolean }>`
--rcx-badge-colors-ghost-background-color,
var(--rcx-color-stroke-dark, var(--rcx-color-neutral-700))
);
color: var(--rcx-color-font-white);
color: var(
--rcx-badge-colors-ghost-color,
var(--rcx-color-font-pure-white, #ffffff)
);
`};
}
`;
Expand Down
Loading