Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/gist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
removeInboxMessage,
} from './managers/inbox-message-manager';
import { isInboxEnabled, initializeInboxFromCache } from './managers/inbox-config-manager';
import { destroyInbox } from './managers/inbox-component-manager';
import type { GistConfig, GistMessage, DisplaySettings, ColorScheme } from './types';
import type { InboxMessage } from './managers/inbox-message-manager';

Expand Down Expand Up @@ -151,6 +152,7 @@ export default class Gist {
static async clearUserToken(): Promise<void> {
if (this.config.isPreviewSession) return;
clearUserToken();
destroyInbox();
if (this.config.useAnonymousSession) {
useGuestSession();
}
Expand Down
139 changes: 138 additions & 1 deletion src/managers/inbox-component-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ vi.mock('./inbox-message-manager', () => ({
updateInboxMessageOpenState: vi.fn(() => Promise.resolve()),
removeInboxMessage: vi.fn(() => Promise.resolve()),
}));
vi.mock('./message-component-manager', () => ({
resolveRendererColorScheme: vi.fn(() => 'light'),
}));
vi.mock('@customerio/jist', () => ({
default: class MockJistTemplateElement extends HTMLElement {},
}));
Expand All @@ -52,10 +55,14 @@ import {
updateInboxMessageOpenState,
removeInboxMessage,
} from './inbox-message-manager';
import { resolveRendererColorScheme } from './message-component-manager';
import type { InboxMessage } from './inbox-message-manager';
import type { Branding, InboxPattern } from '../types';

function makeBranding(overrides: Partial<InboxPattern> = {}): Branding {
function makeBranding(
overrides: Partial<InboxPattern> = {},
darkOverrides?: Partial<InboxPattern>
): Branding {
return {
theme: { heading: {} },
patterns: {
Expand All @@ -81,6 +88,7 @@ function makeBranding(overrides: Partial<InboxPattern> = {}): Branding {
},
...overrides,
},
...(darkOverrides ? { modes: { dark: { inbox: darkOverrides } } } : {}),
},
};
}
Expand All @@ -95,6 +103,16 @@ function makeInboxMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
};
}

function mockMatchMedia(matches = false) {
return vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}

describe('inbox-component-manager', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -103,6 +121,7 @@ describe('inbox-component-manager', () => {
vi.mocked(getBranding).mockReturnValue(null);
vi.mocked(getTemplates).mockReturnValue(null);
vi.mocked(getInboxMessagesFromLocalStore).mockResolvedValue([]);
window.matchMedia = mockMatchMedia();
});

describe('initializeInboxComponent', () => {
Expand Down Expand Up @@ -404,4 +423,122 @@ describe('inbox-component-manager', () => {
expect(document.getElementById('gist-inbox-panel')).toBeNull();
});
});

describe('color scheme integration', () => {
async function openPanelAndGetJist(
brandingOverrides?: Partial<InboxPattern>,
darkOverrides?: Partial<InboxPattern>
): Promise<HTMLElement> {
vi.mocked(getBranding).mockReturnValue(makeBranding(brandingOverrides, darkOverrides));
const messages = [makeInboxMessage()];
vi.mocked(getInboxMessagesFromLocalStore).mockResolvedValue(messages);
await updateInbox(messages);
document.getElementById('gist-inbox-button')!.click();
await vi.waitFor(() => {
expect(document.getElementById('gist-inbox-panel')).not.toBeNull();
});
return document.querySelector('jist-template')! as HTMLElement;
}

it('sets jist mode to light when colorScheme resolves to light', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('light');
const jistEl = await openPanelAndGetJist();
expect((jistEl as unknown as { mode: string }).mode).toBe('light');
});

it('sets jist mode to dark when colorScheme resolves to dark', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('dark');
const jistEl = await openPanelAndGetJist();
expect((jistEl as unknown as { mode: string }).mode).toBe('dark');
});

it('sets jist mode to auto when colorScheme resolves to undefined', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue(undefined);
const jistEl = await openPanelAndGetJist();
expect((jistEl as unknown as { mode: string }).mode).toBe('auto');
});

it('registers colorSchemeChanged event listener on init', () => {
initializeInboxComponent();
expect(Gist.events.on).toHaveBeenCalledWith('colorSchemeChanged', expect.any(Function));
});

it('applies dark overrides to button when scheme is dark', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('dark');
vi.mocked(getBranding).mockReturnValue(
makeBranding({}, { floatingIcon: { background: '#ffffff', color: '#000000', svg: '' } })
);

await updateInbox([makeInboxMessage()]);

const button = document.getElementById('gist-inbox-button')!;
expect(button.style.background).toBe('rgb(255, 255, 255)');
expect(button.style.color).toBe('rgb(0, 0, 0)');
});

it('applies dark overrides to panel when scheme is dark', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('dark');
await openPanelAndGetJist({}, { background: '#1a1a1a', borderColor: '#666666' });

const panel = document.getElementById('gist-inbox-panel')!;
expect(panel.style.background).toBe('rgb(26, 26, 26)');
expect(panel.style.border).toBe('1px solid rgb(102, 102, 102)');
});

it('uses base pattern when scheme is light even with dark overrides', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('light');
vi.mocked(getBranding).mockReturnValue(makeBranding({}, { background: '#1a1a1a' }));

await updateInbox([makeInboxMessage()]);

const button = document.getElementById('gist-inbox-button')!;
expect(button.style.background).toBe('rgb(1, 1, 1)');
});

it('deep merges nested dark overrides preserving unoverridden values', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('dark');
vi.mocked(getBranding).mockReturnValue(
makeBranding(
{},
{ unreadIndicator: { background: '#ff9900' } as InboxPattern['unreadIndicator'] }
)
);

await updateInbox([makeInboxMessage()]);

const badge = document.getElementById('gist-inbox-badge')!;
expect(badge.style.background).toBe('rgb(255, 153, 0)');
expect(badge.style.color).toBe('rgb(255, 255, 255)');
});

it('re-renders inbox on colorSchemeChanged event', async () => {
vi.mocked(resolveRendererColorScheme).mockReturnValue('light');
vi.mocked(getBranding).mockReturnValue(
makeBranding({}, { floatingIcon: { background: '#ffffff', color: '#000000', svg: '' } })
);
const messages = [makeInboxMessage()];
vi.mocked(getInboxMessagesFromLocalStore).mockResolvedValue(messages);

initializeInboxComponent();
await vi.waitFor(() => {
expect(document.getElementById('gist-inbox-button')).not.toBeNull();
});

expect(document.getElementById('gist-inbox-button')!.style.background).toBe('rgb(1, 1, 1)');

vi.mocked(resolveRendererColorScheme).mockReturnValue('dark');

const onCall = vi
.mocked(Gist.events.on)
.mock.calls.find(([event]) => event === 'colorSchemeChanged');
const handler = onCall![1] as () => void;
handler();

await vi.waitFor(() => {
expect(document.getElementById('gist-inbox-button')!.style.background).toBe(
'rgb(255, 255, 255)'
);
});
});
});
});
80 changes: 75 additions & 5 deletions src/managers/inbox-component-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ import {
removeInboxMessage,
} from './inbox-message-manager';
import { getUserLocale } from './locale-manager';
import { resolveRendererColorScheme } from './message-component-manager';
import { INBOX_CSS } from './inbox-component-styles';
import type { InboxMessage } from './inbox-message-manager';
import type { InboxPattern, InboxActionConfig, InboxActionBehavior } from '../types';
import type {
InboxPattern,
InboxActionConfig,
InboxActionBehavior,
Branding,
DeepPartial,
} from '../types';
import JistTemplateElement from '@customerio/jist';
import type { JistMode } from '@customerio/jist';

const INBOX_STYLE_ID = 'gist-inbox-styles';
const BUTTON_ID = 'gist-inbox-button';
Expand All @@ -24,11 +32,58 @@ const MESSAGES_CONTAINER_ID = 'gist-inbox-messages';
let initialized = false;
let panelOpen = false;
const pendingOpenStateUpdates = new Set<string>();
let systemSchemeQuery: MediaQueryList | null = null;
let systemSchemeHandler: (() => void) | null = null;

function deepMerge<T extends object>(base: T, overrides: DeepPartial<T>): T {
const result = { ...base } as Record<string, unknown>;
const over = overrides as Record<string, unknown>;
for (const key of Object.keys(over)) {
const baseVal = result[key];
const overrideVal = over[key];
if (
baseVal != null &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal) &&
overrideVal != null &&
typeof overrideVal === 'object' &&
!Array.isArray(overrideVal)
) {
result[key] = deepMerge(
baseVal as Record<string, unknown>,
overrideVal as Record<string, unknown>
);
} else if (overrideVal !== undefined) {
result[key] = overrideVal;
}
}
return result as T;
}

function resolveInboxPattern(
branding: Branding | null,
rendererScheme: 'light' | 'dark' | undefined
): InboxPattern | null {
const base = branding?.patterns?.inbox;
if (!base) return null;
const darkOverrides = branding?.patterns?.modes?.dark?.inbox;
const isDark =
rendererScheme === 'dark' || (rendererScheme == null && (systemSchemeQuery?.matches ?? false));
if (isDark && darkOverrides) {
return deepMerge(base, darkOverrides);
}
return base;
}

export function resetInboxComponentState(): void {
initialized = false;
panelOpen = false;
pendingOpenStateUpdates.clear();
if (systemSchemeQuery && systemSchemeHandler) {
systemSchemeQuery.removeEventListener('change', systemSchemeHandler);
}
systemSchemeQuery = null;
systemSchemeHandler = null;
}

export function initializeInboxComponent(): void {
Expand All @@ -45,6 +100,14 @@ export function initializeInboxComponent(): void {
void updateInbox(messages as InboxMessage[]);
});

Gist.events.on('colorSchemeChanged', () => {
void updateInbox();
});

systemSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemSchemeHandler = () => void updateInbox();
systemSchemeQuery.addEventListener('change', systemSchemeHandler);
Comment thread
cursor[bot] marked this conversation as resolved.

void updateInbox();
}

Expand All @@ -65,7 +128,8 @@ export async function updateInbox(messages?: InboxMessage[]): Promise<void> {
branding = getBranding();
}

const inboxPattern = branding?.patterns?.inbox;
const rendererScheme = resolveRendererColorScheme(Gist.config?.colorScheme);
const inboxPattern = resolveInboxPattern(branding, rendererScheme);
if (!inboxPattern) {
destroyInbox();
return;
Expand All @@ -74,7 +138,7 @@ export async function updateInbox(messages?: InboxMessage[]): Promise<void> {
renderButton(inboxPattern, inboxMessages);

if (panelOpen) {
renderPanel(inboxPattern, inboxMessages);
renderPanel(inboxPattern, inboxMessages, branding, rendererScheme ?? 'auto');

for (const message of inboxMessages) {
const queueId = message.queueId;
Expand Down Expand Up @@ -162,7 +226,12 @@ function closePanel(): void {
document.getElementById(PANEL_ID)?.classList.remove('gist-inbox-panel--open');
}

function renderPanel(pattern: InboxPattern, messages: InboxMessage[]): void {
function renderPanel(
pattern: InboxPattern,
messages: InboxMessage[],
branding: Branding | null,
jistMode: JistMode
): void {
let panel = document.getElementById(PANEL_ID);

const isNew = !panel;
Expand All @@ -187,7 +256,6 @@ function renderPanel(pattern: InboxPattern, messages: InboxMessage[]): void {

container.innerHTML = '';

const branding = getBranding();
const templates = getTemplates() as Record<string, unknown> | null;

messages.forEach((message, index) => {
Expand All @@ -211,6 +279,7 @@ function renderPanel(pattern: InboxPattern, messages: InboxMessage[]): void {
templates: Record<string, unknown>;
data: Record<string, unknown>;
theme: Record<string, unknown> | null;
mode: JistMode;
formatDate: ((isoString: string, name: string) => string) | null;
onAction:
| ((event: { name: string; data: unknown; meta: Record<string, unknown> | null }) => void)
Expand All @@ -234,6 +303,7 @@ function renderPanel(pattern: InboxPattern, messages: InboxMessage[]): void {
jist.theme = branding.theme as Record<string, unknown>;
}

jist.mode = jistMode;
jist.formatDate = formatRelativeDate;
jist.data = (message.properties ?? {}) as unknown as Record<string, unknown>;

Expand Down
10 changes: 10 additions & 0 deletions src/managers/inbox-component-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const INBOX_CSS = `
width: 24px;
height: 24px;
}
#gist-inbox-button svg [fill]:not([fill="none"]) {
fill: currentColor;
}
#gist-inbox-badge {
position: absolute;
top: -4px;
Expand Down Expand Up @@ -65,4 +68,11 @@ export const INBOX_CSS = `
.gist-inbox-message-row {
transition: background-color 0.15s ease;
}
@media (max-width: 424px) {
#gist-inbox-panel {
width: auto !important;
left: 12px !important;
right: 12px !important;
}
}
`;
3 changes: 2 additions & 1 deletion src/managers/message-component-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function resolveColorSchemeCss(colorScheme: ColorScheme | undefined): string {
return 'light only';
}

function resolveRendererColorScheme(
export function resolveRendererColorScheme(
colorScheme: ColorScheme | undefined
): 'light' | 'dark' | undefined {
if (!colorScheme || colorScheme === 'default') return 'light';
Expand Down Expand Up @@ -111,6 +111,7 @@ function updateColorSchemeOnLiveIframes(scheme: 'light' | 'dark' | undefined): v
}
}
}
Gist.events?.dispatch('colorSchemeChanged', scheme);
}

interface MessageOptions {
Expand Down
Loading
Loading