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
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 @@ -10,7 +10,7 @@
//@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/ChannelList/ChannelList-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';
@use 'stream-chat-react/dist/scss/v2/common/CTAButton/CTAButton-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 @@ -10,7 +10,7 @@
//@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/ChannelList/ChannelList-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';
//@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-theme';
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
"prettier-fix": "yarn prettier --write",
"fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1",
"start": "tsc -p tsconfig.lib.json -w",
"start:css": "sass --watch src/styling:dist/css",
"start:css": "node scripts/watch-styling.mjs",
"prepare": "husky install",
"preversion": "yarn install",
"test": "jest",
Expand Down
229 changes: 229 additions & 0 deletions scripts/watch-styling.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { watch } from 'node:fs';
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { compileAsync } from 'sass';

const SRC_DIR = path.resolve('src');
const ENTRY_FILE = path.join(SRC_DIR, 'styling/index.scss');
const OUTPUT_FILE = path.resolve('dist/css/index.css');
const SCSS_EXTENSION = '.scss';
const BUILD_DELAY_MS = 150;
const SCAN_INTERVAL_MS = 500;

let activeBuild = false;
let buildQueued = false;
let buildQueuedTrigger = 'queued changes';
let buildTimer;
let lastTrigger = 'initial startup';
let knownScssState = new Map();
let pollingFallbackActive = false;
let scanInProgress = false;
let stopWatching = () => undefined;

const log = (message) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
console.log(`[watch-styling ${time}] ${message}`);
};

const isScssFile = (filename) => filename.endsWith(SCSS_EXTENSION);

const toOutputRelativePath = (source) =>
path
.relative(path.dirname(OUTPUT_FILE), fileURLToPath(source))
.split(path.sep)
.join('/');

const collectScssState = async () => {
const scssState = new Map();
const entries = await readdir(SRC_DIR, { recursive: true, withFileTypes: true });

for (const entry of entries) {
if (!entry.isFile() || !isScssFile(entry.name)) continue;

const filePath = path.resolve(
path.join(entry.parentPath ?? entry.path ?? SRC_DIR, entry.name),
);

try {
const { mtimeMs } = await stat(filePath);
scssState.set(filePath, mtimeMs);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}

return scssState;
};

const findChangedFile = (previousState, nextState) => {
for (const [filePath, mtimeMs] of nextState) {
if (!previousState.has(filePath)) {
return `added ${path.relative(process.cwd(), filePath)}`;
}

if (previousState.get(filePath) !== mtimeMs) {
return `changed ${path.relative(process.cwd(), filePath)}`;
}
}

for (const filePath of previousState.keys()) {
if (!nextState.has(filePath)) {
return `removed ${path.relative(process.cwd(), filePath)}`;
}
}

return null;
};

const flushQueuedBuild = () => {
if (!buildQueued) return;

const trigger = buildQueuedTrigger;
buildQueued = false;
buildQueuedTrigger = 'queued changes';
void runBuild(trigger);
};

const buildStyling = async () => {
const { css, sourceMap } = await compileAsync(ENTRY_FILE, {
sourceMap: true,
style: 'expanded',
});
const sourceMapFile = `${path.basename(OUTPUT_FILE)}.map`;
const normalizedSourceMap = {
...sourceMap,
file: path.basename(OUTPUT_FILE),
sources: sourceMap.sources.map((source) =>
source.startsWith('file://') ? toOutputRelativePath(source) : source,
),
};

await mkdir(path.dirname(OUTPUT_FILE), { recursive: true });
await writeFile(OUTPUT_FILE, `${css}\n\n/*# sourceMappingURL=${sourceMapFile} */\n`);
await writeFile(`${OUTPUT_FILE}.map`, JSON.stringify(normalizedSourceMap));
};

const runBuild = async (trigger) => {
if (activeBuild) {
buildQueued = true;
buildQueuedTrigger = trigger;
return;
}

activeBuild = true;
log(`running build-styling (${trigger})`);

try {
await buildStyling();
log('build-styling completed');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`build-styling failed: ${message}`);
} finally {
activeBuild = false;
flushQueuedBuild();
}
};

const scheduleBuild = (trigger) => {
lastTrigger = trigger;
clearTimeout(buildTimer);
buildTimer = setTimeout(() => {
void runBuild(lastTrigger);
}, BUILD_DELAY_MS);
};

const formatNativeWatchTrigger = (filename) => {
if (!filename) return 'filesystem event';

const normalizedFilename = String(filename);
if (!isScssFile(normalizedFilename)) return null;

return `changed ${path.join(path.relative(process.cwd(), SRC_DIR), normalizedFilename)}`;
};

const scanForChanges = async () => {
if (scanInProgress) return;
scanInProgress = true;

try {
const nextState = await collectScssState();
const trigger = findChangedFile(knownScssState, nextState);
knownScssState = nextState;

if (trigger) {
scheduleBuild(trigger);
}
} finally {
scanInProgress = false;
}
};

const startPollingWatcher = async () => {
if (pollingFallbackActive) return;

pollingFallbackActive = true;
stopWatching();
knownScssState = await collectScssState();

const scanInterval = setInterval(() => {
void scanForChanges();
}, SCAN_INTERVAL_MS);

stopWatching = () => clearInterval(scanInterval);
log(
`watching ${path.relative(process.cwd(), SRC_DIR)}/**/*.scss for changes (polling fallback)`,
);
};

const startNativeWatcher = () => {
try {
const watcher = watch(SRC_DIR, { recursive: true }, (_eventType, filename) => {
const trigger = formatNativeWatchTrigger(filename);
if (!trigger) return;

scheduleBuild(trigger);
});

watcher.on('error', (error) => {
if (pollingFallbackActive) return;

log(`native watcher failed (${error.message}), falling back to polling`);
watcher.close();
void startPollingWatcher();
});

stopWatching = () => watcher.close();
log(
`watching ${path.relative(process.cwd(), SRC_DIR)}/**/*.scss for changes (native recursive watch)`,
);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`native recursive watch unavailable (${message}), falling back to polling`);
return false;
}
};

const shutdown = () => {
clearTimeout(buildTimer);
stopWatching();
};

process.on('SIGINT', () => {
shutdown();
process.exit(0);
});

process.on('SIGTERM', () => {
shutdown();
process.exit(0);
});

await runBuild('initial startup');

if (!startNativeWatcher()) {
await startPollingWatcher();
}
3 changes: 1 addition & 2 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
import React, { type ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';

export type ButtonVariant = 'primary' | 'secondary' | 'danger';
Expand Down
24 changes: 18 additions & 6 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
LoadingErrorIndicator as DefaultLoadingErrorIndicator,
LoadingChannel as DefaultLoadingIndicator,
} from '../Loading';
import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';

import type {
ChannelActionContextValue,
Expand Down Expand Up @@ -125,8 +126,8 @@ export type ChannelProps = {
updatedMessage: LocalMessage | MessageResponse,
options?: UpdateMessageOptions,
) => ReturnType<StreamChat['updateMessage']>;
/** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */
EmptyPlaceholder?: React.ReactElement;
/** Custom UI component to be shown if no active channel is set, defaults to the message empty state indicator. Pass `null` to suppress rendering. */
EmptyPlaceholder?: React.ReactElement | null;
/** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */
giphyVersion?: GiphyVersions;
/** A custom function to provide size configuration for image attachments */
Expand Down Expand Up @@ -169,13 +170,20 @@ const ChannelContainer = ({
};

const UnMemoizedChannel = (props: PropsWithChildren<ChannelProps>) => {
const { channel: propsChannel, EmptyPlaceholder = null } = props;
const { LoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator } =
useComponentContext();
const { channel: propsChannel, EmptyPlaceholder } = props;
const {
EmptyStateIndicator = DefaultEmptyStateIndicator,
LoadingErrorIndicator,
LoadingIndicator = DefaultLoadingIndicator,
} = useComponentContext('Channel');

const { channel: contextChannel, channelsQueryState } = useChatContext('Channel');

const channel = propsChannel || contextChannel;
const emptyPlaceholder =
'EmptyPlaceholder' in props
? EmptyPlaceholder
: EmptyStateIndicator && <EmptyStateIndicator listType='message' />;

if (channelsQueryState.queryInProgress === 'reload' && LoadingIndicator) {
return (
Expand All @@ -193,8 +201,12 @@ const UnMemoizedChannel = (props: PropsWithChildren<ChannelProps>) => {
);
}

if (channelsQueryState.error) {
return <ChannelContainer />;
}

if (!channel?.cid) {
return <ChannelContainer>{EmptyPlaceholder}</ChannelContainer>;
return <ChannelContainer>{emptyPlaceholder}</ChannelContainer>;
}

return <ChannelInner {...props} channel={channel} key={channel.cid} />;
Expand Down
Loading
Loading