9 | ) => {
10 | const withContext = (
11 | Component: React.ComponentType
& { defaultProps?: DP }
12 | ) => {
13 | return class WithContextHOC extends React.Component<
14 | Omit
& Partial
15 | > {
16 | public render() {
17 | return {this.renderComponent};
18 | }
19 |
20 | private renderComponent = (ctx: Context) => {
21 | const newProps = { ...ctx, ...this.props };
22 | return ;
23 | };
24 | };
25 | };
26 |
27 | return withContext;
28 | };
29 |
30 | export default createContextHOC;
31 |
--------------------------------------------------------------------------------
/src/utils/locales.ts:
--------------------------------------------------------------------------------
1 | // Originally taken from https://github.com/trucknet-io/react-targem/blob/develop/src/utils/locale.ts
2 |
3 | import { Locale } from "src/config/locales";
4 |
5 | export function findLocale(
6 | supportedLocales: Locale[],
7 | locale: string
8 | ): Locale | undefined {
9 | if (supportedLocales.includes(locale as Locale)) {
10 | return locale as Locale;
11 | }
12 | for (const localeToMatch of supportedLocales) {
13 | if (localeToMatch.includes(locale.split("-")[0])) {
14 | return localeToMatch;
15 | }
16 | }
17 | return undefined;
18 | }
19 |
20 | export function getBrowserLocale(
21 | supportedLocales: Locale[],
22 | fallbackLocale: Locale
23 | ): Locale {
24 | let browserLocale: Locale | undefined;
25 | if (typeof window !== "undefined" && window.navigator) {
26 | const lang = window.navigator.language;
27 | if (lang) {
28 | browserLocale = findLocale(supportedLocales, lang);
29 | }
30 | }
31 |
32 | return browserLocale || fallbackLocale;
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui-kit/NavBar/NavBar.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | width: 100%;
4 | height: 3.4rem;
5 | background-color: var(--color-primary);
6 | }
7 |
8 | .item {
9 | min-width: 100px;
10 | padding: 1rem 0.6rem;
11 | color: var(--color-text-contrast);
12 | text-align: center;
13 | text-decoration: none;
14 | transition: background-color 0.5s ease;
15 | }
16 |
17 | .item .text {
18 | opacity: 0.8;
19 | }
20 |
21 | .item-dark {
22 | background-color: var(--color-primary-darker);
23 | }
24 |
25 | .item-active {
26 | border-bottom: 4px solid var(--color-primary-darker);
27 | }
28 |
29 | .item-active .text {
30 | opacity: 1;
31 | }
32 |
33 | .badge {
34 | position: relative;
35 | top: -3px;
36 | display: inline-block;
37 | width: 22px;
38 | margin-right: 0.5rem;
39 | margin-left: 0.5rem;
40 | background-color: var(--color-danger);
41 | border-radius: 50%;
42 | color: var(--color-text-contrast);
43 | font-size: 9px;
44 | line-height: 22px;
45 | vertical-align: middle;
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/ui-kit/NavBar/NavBar.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavBar, NavBarItem } from "./NavBar";
3 |
4 | export default { title: "components/ui-kit/NavBar", component: NavBar };
5 |
6 | export const withDefaultView = (): React.ReactNode => (
7 |
8 |
9 |
10 |
11 | );
12 |
13 | export const withBadge = (): React.ReactNode => (
14 |
15 |
16 |
17 |
18 | );
19 |
20 | export const withBigNumberBadge = (): React.ReactNode => (
21 |
22 |
23 |
24 |
25 | );
26 |
27 | export const withBlinking = (): React.ReactNode => (
28 |
29 |
30 |
31 |
32 | );
33 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Typography/Typography.css:
--------------------------------------------------------------------------------
1 | .common {
2 | margin-top: 0;
3 | margin-bottom: 0;
4 | color: var(--color-text);
5 | letter-spacing: var(--letter-spacing-base);
6 | line-height: var(--line-height-base);
7 | }
8 |
9 | .common.gutter-bottom {
10 | margin-bottom: 0.7rem;
11 | }
12 |
13 | .common a {
14 | color: var(--color-text-link);
15 | }
16 |
17 | .common a:visited {
18 | color: var(--color-text-link-visited);
19 | }
20 |
21 | .common a:active {
22 | color: var(--color-text-link-active);
23 | }
24 |
25 | .color-muted {
26 | color: var(--color-text-muted);
27 | }
28 |
29 | .color-contrast {
30 | color: var(--color-text-contrast);
31 | }
32 |
33 | .color-danger {
34 | color: var(--color-danger);
35 | }
36 |
37 | .p {
38 | font-family: var(--font-family-primary);
39 | font-size: var(--font-size-base);
40 | }
41 |
42 | .h1,
43 | .h2,
44 | .h3 {
45 | font-family: var(--font-family-secondary);
46 | }
47 |
48 | .h1 {
49 | font-size: 2.8rem;
50 | }
51 |
52 | .h2 {
53 | font-size: 2.4rem;
54 | }
55 |
56 | .h3 {
57 | font-size: 2rem;
58 | }
59 |
--------------------------------------------------------------------------------
/src/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 |
16 |
17 |
18 | <%= htmlWebpackPlugin.options.title %>
19 |
20 |
24 |
25 |
26 |
27 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/wrappers/BodyWrapper/BodyWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./BodyWrapper.css";
3 | import cn from "clsx";
4 | import { NavBar, NavBarItem } from "src/components/ui-kit/NavBar";
5 | import { T } from "react-targem";
6 | import { routes } from "src/config/routes";
7 | import { withChat, WithChat } from "src/contexts/ChatContext";
8 |
9 | interface BodyWrapperProps extends WithChat {
10 | children: React.ReactNode;
11 | }
12 |
13 | class BodyWrapper extends PureComponent {
14 | render(): React.ReactNode {
15 | const { chatMessagesUnreadCount } = this.props;
16 | return (
17 |
18 |
19 | }
21 | to={routes.home}
22 | badge={chatMessagesUnreadCount}
23 | isBlinking={chatMessagesUnreadCount > 0}
24 | />
25 | } to={routes.settings} />
26 |
27 | {this.props.children}
28 |
29 | );
30 | }
31 | }
32 |
33 | export default withChat(BodyWrapper);
34 |
--------------------------------------------------------------------------------
/src/pages/Settings/SettingsPage.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import SettingsPage from "./SettingsPage";
3 | import { action } from "@storybook/addon-actions";
4 |
5 | export default { title: "pages/Settings", component: SettingsPage };
6 |
7 | const handleResetDefaultClick = action("onResetDefaultClick");
8 | const handleUsernameChange = action("onUsernameChange");
9 | const handleLocaleChange = action("onLocaleChange");
10 | const handleThemeChange = action("onThemeChange");
11 | const handleIs12hoursChange = action("onIs12hoursChange");
12 | const handleIsCtrlEnterToSend = action("onIsCtrlEnterToSend");
13 |
14 | const defaultProps = {
15 | onResetDefaultClick: handleResetDefaultClick,
16 | onUsernameChange: handleUsernameChange,
17 | onLocaleChange: handleLocaleChange,
18 | onThemeChange: handleThemeChange,
19 | onIs12hoursChange: handleIs12hoursChange,
20 | onIsCtrlEnterToSend: handleIsCtrlEnterToSend,
21 | locale: "en-GB",
22 | username: "goooseman",
23 | theme: "default",
24 | is12hours: true,
25 | isCtrlEnterToSend: false,
26 | } as const;
27 |
28 | export const withDefaultView = (): React.ReactNode => (
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/__utils__/render.tsx:
--------------------------------------------------------------------------------
1 | import { render, RenderOptions, RenderResult } from "@testing-library/react";
2 | import React from "react";
3 | import { TargemStatefulProvider } from "react-targem";
4 | import "@testing-library/jest-dom";
5 |
6 | export const BlankWrapper: React.StatelessComponent<{}> = (props: {
7 | children?: React.ReactNode;
8 | }) => <>{props.children}>;
9 |
10 | const AllTheProviders = (Wrapper: React.ComponentType = BlankWrapper) => ({
11 | children,
12 | }: {
13 | children?: React.ReactNode;
14 | }) => {
15 | return (
16 |
17 |
18 | {/** StrictMode is useful for `this.setState` functions to be called twice and to prevent side-effects */}
19 | {children}
20 |
21 |
22 | );
23 | };
24 |
25 | const customRender = (
26 | ui: React.ReactElement,
27 | options?: RenderOptions
28 | ): RenderResult => {
29 | return render(ui, { ...options, wrapper: AllTheProviders(options?.wrapper) });
30 | };
31 |
32 | // re-export everything
33 | export * from "@testing-library/react";
34 |
35 | // override render method
36 | export { customRender as render };
37 |
--------------------------------------------------------------------------------
/src/components/ui-kit/NavBar/NavBar.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavBar, NavBarItem } from "./NavBar";
3 | import { render } from "__utils__/renderWithRouter";
4 |
5 | it("should blink", () => {
6 | jest.useFakeTimers();
7 | const { getByText } = render(
8 |
9 |
10 |
11 | );
12 | const navBar = getByText("Foo").parentElement;
13 | jest.advanceTimersByTime(1000);
14 | expect(navBar).toHaveClass("itemDark");
15 | jest.advanceTimersByTime(1000);
16 | expect(navBar).not.toHaveClass("itemDark");
17 | jest.advanceTimersByTime(1000);
18 | expect(navBar).toHaveClass("itemDark");
19 | });
20 |
21 | it("should become light after blinking stop", () => {
22 | jest.useFakeTimers();
23 | const { getByText, rerender } = render(
24 |
25 |
26 |
27 | );
28 | const navBar = getByText("Foo").parentElement;
29 | jest.advanceTimersByTime(1000);
30 | expect(navBar).toHaveClass("itemDark");
31 | rerender(
32 |
33 |
34 |
35 | );
36 | expect(navBar).not.toHaveClass("itemDark");
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Button/Button.css:
--------------------------------------------------------------------------------
1 | .button {
2 | min-width: 3rem;
3 | padding: 0.5rem 1rem;
4 | border: 0;
5 | background-color: var(--color-primary);
6 | color: var(--color-text-contrast);
7 | cursor: pointer;
8 | outline: 0;
9 | text-transform: uppercase;
10 | transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
11 | box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
12 | border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
13 | }
14 |
15 | .border-radius {
16 | border-radius: var(--border-radius);
17 | }
18 |
19 | .button[disabled] {
20 | background-color: var(--color-primary-lighter);
21 | pointer-events: none;
22 | }
23 |
24 | .button:hover,
25 | .button:active,
26 | .button:focus {
27 | background-color: var(--color-primary-darker);
28 | }
29 |
30 | .button-secondary {
31 | background-color: var(--color-secondary);
32 | }
33 |
34 | .button-secondary[disabled] {
35 | background-color: var(--color-secondary-lighter);
36 | }
37 |
38 | .button-secondary:hover,
39 | .button-secondary:active,
40 | .button-secondary:focus {
41 | background-color: var(--color-secondary-darker);
42 | }
43 |
44 | .button-lg {
45 | padding: 1rem 1.5rem;
46 | }
47 |
48 | .button-sm {
49 | min-width: 0;
50 | padding: 0.5rem 0.5rem;
51 | }
52 |
--------------------------------------------------------------------------------
/config/webpack/config.webpack.storybook.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const path = require("path");
3 |
4 | // This file does not exports a webpack configuration file, but exports a function, which customizes the configuration
5 |
6 | module.exports = function (config) {
7 | // https://github.com/storybookjs/storybook/tree/master/addons/storysource#parser
8 |
9 | config.module.rules.push({
10 | test: /\.stories\.tsx?$/,
11 | exclude: [/node_modules/],
12 | loaders: [
13 | {
14 | loader: require.resolve("@storybook/source-loader"),
15 | options: { parser: "typescript" },
16 | },
17 | ],
18 | enforce: "pre",
19 | });
20 |
21 | // https://www.npmjs.com/package/react-docgen-typescript-loader
22 | config.module.rules.push({
23 | test: /\.tsx?$/,
24 | exclude: [/node_modules/],
25 | use: [
26 | {
27 | loader: require.resolve("react-docgen-typescript-loader"),
28 | options: {
29 | // Provide the path to your tsconfig.json so that your stories can
30 | // display types from outside each individual story.
31 | tsconfigPath: path.resolve(__dirname, "../../tsconfig.json"),
32 | },
33 | },
34 | ],
35 | });
36 |
37 | return config;
38 | };
39 |
--------------------------------------------------------------------------------
/src/pages/Chat/ChatPage.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | display: flex;
4 | width: 100%;
5 |
6 | /* A hardcoded NavBar height, can be exported as variable, but better to be fixed in a different way */
7 | height: calc(100vh - 3.4rem);
8 | flex-direction: column;
9 | }
10 |
11 | .messages-container {
12 | flex-grow: 1;
13 | overflow-y: auto;
14 | }
15 |
16 | .search-container {
17 | display: flex;
18 | align-items: center;
19 | justify-content: space-between;
20 | padding: 1rem 1rem;
21 | background-color: var(--color-secondary-darker);
22 | }
23 |
24 | .search-input {
25 | max-width: 40rem;
26 | }
27 |
28 | .search-button {
29 | position: absolute;
30 | right: 1rem;
31 | bottom: calc(100% + 0.8rem);
32 | }
33 |
34 | .search-navigator {
35 | display: flex;
36 | align-items: center;
37 | margin-left: 1rem;
38 | }
39 |
40 | .search-navigator p {
41 | margin-right: 0.5rem;
42 | margin-left: 0.5rem;
43 | white-space: nowrap;
44 | }
45 |
46 | .error-container {
47 | display: flex;
48 | align-items: center;
49 | justify-content: space-between;
50 | margin: 0 -0.5rem;
51 | }
52 |
53 | .error-container > * {
54 | margin: 0 0.5rem;
55 | }
56 |
57 | [dir="rtl"] .search-button {
58 | right: auto;
59 | left: 1rem;
60 | }
61 |
62 | [dir="rtl"] .search-navigator {
63 | margin-right: 1rem;
64 | margin-left: 0;
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/ui-kit/RadioGroup/RadioGroup.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RadioGroup from "./RadioGroup";
3 | import { render, fireEvent } from "__utils__/render";
4 |
5 | const defaultProps = {
6 | labelledWith: "Foo?",
7 | id: "story",
8 | options: [
9 | {
10 | value: "foo",
11 | text: "Foo!",
12 | },
13 | {
14 | value: "bar",
15 | text: "Bar!",
16 | },
17 | ],
18 | value: "foo",
19 | };
20 |
21 | it("should fire onChange after option being clicked", () => {
22 | const onChangeSpy = jest.fn();
23 | const { getByLabelText } = render(
24 |
25 | );
26 | const radioButton = getByLabelText("Bar!");
27 |
28 | fireEvent.click(radioButton);
29 |
30 | expect(onChangeSpy).toBeCalledTimes(1);
31 | expect(onChangeSpy).toBeCalledWith("bar");
32 | });
33 |
34 | it("should work with boolean values", () => {
35 | const onChangeSpy = jest.fn();
36 | const { getByLabelText } = render(
37 |
43 | );
44 | const radioButton = getByLabelText("Foo");
45 |
46 | fireEvent.click(radioButton);
47 |
48 | expect(onChangeSpy).toBeCalledTimes(1);
49 | expect(onChangeSpy).toBeCalledWith(true);
50 | });
51 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./Button.css";
3 | import cn from "clsx";
4 |
5 | interface ButtonProps
6 | extends React.DetailedHTMLProps<
7 | React.ButtonHTMLAttributes,
8 | HTMLButtonElement
9 | > {
10 | size: "sm" | "md" | "lg";
11 | color: "primary" | "secondary";
12 | hasBorderRadius: boolean;
13 | }
14 |
15 | /**
16 | * Custom `Button` component to be used as a drop-in replacement for ``.
17 | */
18 | class Button extends PureComponent {
19 | public static defaultProps = {
20 | size: "md",
21 | hasBorderRadius: true,
22 | color: "primary",
23 | };
24 |
25 | render(): React.ReactNode {
26 | const {
27 | children,
28 | className,
29 | size,
30 | hasBorderRadius,
31 | color,
32 | ...otherProps
33 | } = this.props;
34 |
35 | return (
36 |
47 | );
48 | }
49 | }
50 |
51 | export default Button;
52 |
--------------------------------------------------------------------------------
/src/styles/themes/default.css:
--------------------------------------------------------------------------------
1 | /* This a default theme, so no extra css selectors are needed */
2 |
3 | /* Colors: https://www.materialui.co/colors */
4 |
5 | /* This vars will be taken by postcss-custom-properties to create fallback css properties */
6 | :root {
7 | /* colors */
8 | --color-primary-lighter: #ffe0b2; /* 100 */
9 | --color-primary: #ff9800; /* 500 */
10 | --color-primary-darker: #d84315; /* 800 */
11 | --color-secondary-lighter: #b3e5fc; /* 100 */
12 | --color-secondary: #03a9f4; /* 500 */
13 | --color-secondary-darker: #0277bd; /* 800 */
14 | --color-text: #000;
15 | --color-text-muted: #808080;
16 | --color-text-contrast: #fff;
17 | --color-text-link: #00e;
18 | --color-text-link-visited: #551a8b;
19 | --color-text-link-active: #f00;
20 | --color-border: #000;
21 | --color-background: #fff;
22 | --color-danger: #b71c1c;
23 |
24 | /* fonts */
25 | --font-family-primary: helvetica neue, helvetica, arial, sans-serif;
26 | --font-family-secondary: georgia, times new roman, times, serif;
27 | --font-size-base: 16px;
28 | --letter-spacing-base: 0;
29 | --line-height-base: 1.3;
30 |
31 | /* sizes */
32 | --border-width-regular: 0.1rem;
33 | --border-radius: 4px;
34 |
35 | /* z-indexes */
36 | --z-index-0: 0;
37 | --z-index-1: 1;
38 | --z-index-2: 10;
39 | --z-index-3: 100;
40 | --z-index-4: 1000;
41 | --z-index-5: 10000;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/TimeDisplay/TimeDisplay.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 |
3 | export interface TimeDisplayProps {
4 | date: Date;
5 | locale: string;
6 | is12hours: boolean;
7 | }
8 |
9 | /** A component which diplays time, if the date is today or date and time if not */
10 | class TimeDisplay extends PureComponent {
11 | render(): React.ReactNode {
12 | const { locale, is12hours, date } = this.props;
13 |
14 | const isDateHidden = this.isToday(date);
15 |
16 | const options: Intl.DateTimeFormatOptions = {
17 | year: this.isThisYear(date) ? undefined : "numeric",
18 | month: isDateHidden ? undefined : "short",
19 | day: isDateHidden ? undefined : "2-digit",
20 | hour: "2-digit",
21 | minute: "2-digit",
22 | hour12: is12hours,
23 | } as const;
24 |
25 | return new Intl.DateTimeFormat(locale, options).format(date);
26 | }
27 |
28 | private isToday = (someDate: Date) => {
29 | const today = new Date();
30 | return (
31 | someDate.getDate() == today.getDate() &&
32 | someDate.getMonth() == today.getMonth() &&
33 | someDate.getFullYear() == today.getFullYear()
34 | );
35 | };
36 |
37 | private isThisYear = (someDate: Date) => {
38 | const today = new Date();
39 | return someDate.getFullYear() == today.getFullYear();
40 | };
41 | }
42 |
43 | export default TimeDisplay;
44 |
--------------------------------------------------------------------------------
/__utils__/renderWithRouter.tsx:
--------------------------------------------------------------------------------
1 | import { createMemoryHistory, MemoryHistory } from "history";
2 | import React from "react";
3 | import { Router } from "react-router-dom";
4 | import { render, RenderResult, RenderOptions, BlankWrapper } from "./render";
5 |
6 | interface RenderWithRouterReturn extends RenderResult {
7 | history: MemoryHistory;
8 | }
9 |
10 | const renderWithRouter = (
11 | ui: React.ReactElement,
12 | routerOptions: {
13 | route: string;
14 | } = {
15 | route: "/",
16 | },
17 | options?: RenderOptions
18 | ): RenderWithRouterReturn => {
19 | const history = createMemoryHistory({
20 | initialEntries: [routerOptions.route],
21 | });
22 | const RouterProvider = (Wrapper: React.ComponentType = BlankWrapper) => ({
23 | children,
24 | }: {
25 | children?: React.ReactNode;
26 | }) => {
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | };
33 |
34 | return {
35 | ...render(ui, {
36 | ...options,
37 | wrapper: RouterProvider(options?.wrapper),
38 | }),
39 | // adding `history` to the returned utilities to allow us
40 | // to reference it in our tests (just try to avoid using
41 | // this to test implementation details).
42 | history,
43 | };
44 | };
45 |
46 | export * from "@testing-library/react";
47 |
48 | export { renderWithRouter as render };
49 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Input/Input.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: relative;
3 | display: inline-flex;
4 | width: 100%;
5 | flex-direction: column;
6 | }
7 |
8 | .common {
9 | width: 100%;
10 | padding: 0.5rem 0.5rem;
11 | border: 1px solid var(--color-border);
12 | background-color: var(--color-background);
13 | border-radius: var(--border-radius);
14 | color: var(--color-text);
15 | font-family: var(--font-family-primary);
16 | font-size: var(--font-size-base);
17 | letter-spacing: var(--letter-spacing-base);
18 | line-height: var(--line-height-base);
19 | outline-color: var(--color-primary);
20 | }
21 |
22 | .common::placeholder {
23 | color: var(--color-text-muted);
24 | opacity: 1;
25 | }
26 |
27 | select.common {
28 | appearance: none;
29 | }
30 |
31 | textarea.common {
32 | height: 100%;
33 | }
34 |
35 | .container-inline {
36 | display: flex;
37 | flex-direction: row;
38 | align-items: center;
39 | }
40 |
41 | .container-inline .common {
42 | width: auto;
43 | order: 0;
44 | margin-right: 0.4rem;
45 | margin-left: 0.4rem;
46 | }
47 |
48 | .container-inline label.label {
49 | order: 1;
50 | margin-bottom: 0;
51 | }
52 |
53 | .input-container {
54 | position: relative;
55 | }
56 |
57 | .input-addon-right {
58 | position: absolute;
59 | top: 0;
60 | right: 1rem;
61 | bottom: 0;
62 | height: 20px;
63 | margin: auto 0;
64 | }
65 |
66 | [dir="rtl"] .input-addon-right {
67 | right: auto;
68 | left: 1rem;
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/ui-kit/RadioGroup/RadioGroup.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RadioGroup from "./RadioGroup";
3 | import { action } from "@storybook/addon-actions";
4 |
5 | export default { title: "components/ui-kit/RadioGroup", component: RadioGroup };
6 |
7 | const handleChange = action("onClick");
8 |
9 | const defaultProps = {
10 | labelledWith: "Foo?",
11 | id: "story",
12 | options: [
13 | {
14 | value: "foo",
15 | text: "Foo!",
16 | },
17 | {
18 | value: "bar",
19 | text: "Bar!",
20 | },
21 | ],
22 | onChange: handleChange,
23 | value: "foo",
24 | };
25 |
26 | export const withTwoOptions = (): React.ReactNode => (
27 |
28 | );
29 |
30 | const fourOptions = [
31 | {
32 | value: "foo",
33 | text: "Foo!",
34 | },
35 | {
36 | value: "bar",
37 | text: "Bar!",
38 | },
39 | {
40 | value: "fuu",
41 | text: "Fuu!",
42 | },
43 | {
44 | value: "ber",
45 | text: "Ber!",
46 | },
47 | ];
48 |
49 | export const withFourOptions = (): React.ReactNode => (
50 |
51 | );
52 |
53 | const booleanOptions = [
54 | {
55 | value: false,
56 | text: "False",
57 | },
58 | {
59 | value: true,
60 | text: "True",
61 | },
62 | ];
63 |
64 | export const withBooleanOptions = (): React.ReactNode => (
65 |
66 | );
67 |
--------------------------------------------------------------------------------
/src/components/TimeDisplay/TimeDisplay.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TimeDisplay from "./TimeDisplay";
3 |
4 | export default { title: "components/TimeDisplay", component: TimeDisplay };
5 |
6 | const now = new Date();
7 |
8 | const defaultProps = {
9 | locale: "en-US",
10 | is12hours: true,
11 | date: now,
12 | };
13 |
14 | export const withEnUsAnd12h = (): React.ReactNode => (
15 |
16 | );
17 |
18 | export const withEnUsAnd24h = (): React.ReactNode => (
19 |
20 | );
21 |
22 | const anotherDay = new Date(now.getFullYear(), 3, 29);
23 |
24 | export const withDateAnotherDay = (): React.ReactNode => (
25 |
26 | );
27 |
28 | export const withDateAnotherDayIn24h = (): React.ReactNode => (
29 |
30 | );
31 |
32 | export const withDateAnotherDayAndFrenchLanguage = (): React.ReactNode => (
33 |
39 | );
40 |
41 | export const withDateAnotherDayAndFrenchLanguageIn12hours = (): React.ReactNode => (
42 |
43 | );
44 |
45 | const anotherYear = new Date(1976, 3, 11);
46 |
47 | export const withDateAnotherYear = (): React.ReactNode => (
48 |
49 | );
50 |
--------------------------------------------------------------------------------
/src/pages/Settings/SettingsPage.container.tsx:
--------------------------------------------------------------------------------
1 | import { RouteComponentProps } from "react-router-dom";
2 | import React, { PureComponent } from "react";
3 | import SettingsPage from "./SettingsPage";
4 | import { withSettings, WithSettings } from "src/contexts/SettingsContext";
5 |
6 | interface SettingsPageContainerProps
7 | extends RouteComponentProps,
8 | WithSettings {}
9 |
10 | class SettingsPageContainer extends PureComponent {
11 | render(): React.ReactNode {
12 | const { is12hours, isCtrlEnterToSend, username, lang, theme } = this.props;
13 |
14 | return (
15 |
28 | );
29 | }
30 |
31 | private handleFieldChange = (field: string) => (value: unknown) => {
32 | this.props.setSettings({ [field]: value });
33 | };
34 |
35 | private handleResetDefaultClick = () => {
36 | this.props.resetSettings();
37 | };
38 | }
39 |
40 | export default withSettings(SettingsPageContainer);
41 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser
3 | parserOptions: {
4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
5 | sourceType: "module", // Allows for the use of imports
6 | ecmaFeatures: {
7 | jsx: true, // Allows for the parsing of JSX
8 | },
9 | },
10 | settings: {
11 | react: {
12 | version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use
13 | },
14 | },
15 | extends: [
16 | "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
17 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
18 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
19 | "plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
20 | "plugin:jest-dom/recommended",
21 | ],
22 | rules: {
23 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
24 | // e.g. "@typescript-eslint/explicit-function-return-type": "off",
25 | "no-console": "error",
26 | "@typescript-eslint/ban-types": "off",
27 | "@typescript-eslint/no-empty-interface": "off",
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/.vscode/components.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "React Component": {
3 | "prefix": "reactComponent",
4 | "body": [
5 | "import React, { PureComponent } from \"react\";",
6 | "import classes from \"./${1:${TM_FILENAME_BASE}}.css\";",
7 | "import cn from \"clsx\";",
8 | "",
9 | "interface ${1:${TM_FILENAME_BASE}}Props {",
10 | "\t",
11 | "}",
12 | "",
13 | "class ${1:${TM_FILENAME_BASE}} extends PureComponent<${1:${TM_FILENAME_BASE}}Props> {",
14 | "",
15 | "\trender(): React.ReactNode {",
16 | "\t\treturn (",
17 | "\t\t\t",
18 | "\t\t\t\t$0",
19 | "\t\t\t
",
20 | "\t\t)",
21 | "\t}",
22 | "}",
23 | "",
24 | "export default ${1:${TM_FILENAME_BASE}};"
25 | ],
26 | "description": "Creates a React component class with ES7 module system and TypeScript interfaces"
27 | },
28 | "React Component Index": {
29 | "prefix": "reactComponentIndex",
30 | "body": ["import ${1} from \"./${1}\";", "export default ${1};"]
31 | },
32 | "React Component Storybook": {
33 | "prefix": "reactComponentStorybook",
34 | "body": [
35 | "import React from \"react\";",
36 | "import ${TM_FILENAME_BASE/.stories//} from \"./${TM_FILENAME_BASE/.stories//}\";",
37 | "",
38 | "export default { title: \"${TM_FILENAME_BASE/.stories//}\", component: ${TM_FILENAME_BASE/.stories//} };",
39 | "",
40 | "export const withSmth = (): React.ReactNode => (",
41 | "\t$0",
42 | ");"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | const webpackDevelopment = require("../config/webpack/config.webpack.development");
2 | const webpackProduction = require("../config/webpack/config.webpack.production");
3 | const webpackStorybook = require("../config/webpack/config.webpack.storybook");
4 | const HtmlWebpackPlugin = require("html-webpack-plugin");
5 |
6 | module.exports = {
7 | addons: [
8 | "@storybook/addon-actions/register",
9 | "@storybook/addon-storysource",
10 | "@storybook/addon-docs",
11 | "@storybook/addon-viewport/register",
12 | "@storybook/addon-a11y/register",
13 | "@storybook/addon-contexts/register",
14 | ],
15 | stories: ["../src/**/*.stories.(tsx|mdx)"],
16 | webpackFinal: async (config, { configType }) => {
17 | // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
18 | // You can change the configuration based on that.
19 | // 'PRODUCTION' is used when building the static version of storybook.
20 |
21 | const customConfig =
22 | configType === "DEVELOPMENT" ? webpackDevelopment : webpackProduction;
23 |
24 | // https://storybook.js.org/docs/configurations/custom-webpack-config/#examples
25 |
26 | const safeCustomPlugins = customConfig.plugins.filter((p) => {
27 | return !(p instanceof HtmlWebpackPlugin);
28 | });
29 |
30 | const finalConfig = webpackStorybook({
31 | ...customConfig,
32 | entry: config.entry,
33 | output: config.output,
34 | plugins: [...config.plugins, ...safeCustomPlugins], // https://github.com/storybookjs/storybook/issues/6020
35 | });
36 |
37 | return finalConfig;
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/src/pages/NotFound/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./NotFound.css";
3 | import cn from "clsx";
4 | import { RouteComponentProps, Link } from "react-router-dom";
5 | import Typography from "src/components/ui-kit/Typography";
6 | import { routes } from "src/config/routes";
7 | import { T } from "react-targem";
8 |
9 | // Images
10 |
11 | import Image404Dog from "./404-dog.jpg";
12 |
13 | interface NotFoundProps extends RouteComponentProps {}
14 |
15 | class NotFound extends PureComponent {
16 | render(): React.ReactNode {
17 | return (
18 |
19 |
20 | {/* "error page" by eoshea is licensed under CC BY-NC-SA 2.0 */}
21 | {/* https://ccsearch.creativecommons.org/photos/3803cd7d-a9a2-413b-a01c-86182d316197 */}
22 |

23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 | }
41 |
42 | export default NotFound;
43 |
--------------------------------------------------------------------------------
/.storybook/contexts.js:
--------------------------------------------------------------------------------
1 | // https://github.com/storybookjs/storybook/tree/master/addons/contexts
2 |
3 | import { SettingsContextProvider } from "../src/contexts/SettingsContext";
4 | import { themes } from "../src/config/themes";
5 | import { locales } from "../src/config/locales";
6 | import { TargemProvider } from "react-targem";
7 | import translationsJson from "src/i18n/translations.json";
8 |
9 | const localeParams = locales.map((l, i) => {
10 | return {
11 | name: l.internationalName,
12 | props: { locale: l.key, translations: translationsJson },
13 | default: i === 0,
14 | };
15 | });
16 |
17 | export const contexts = [
18 | {
19 | icon: "wrench",
20 | title: "Settings",
21 | components: [SettingsContextProvider],
22 | params: [
23 | {
24 | name: "Default theme / 12 hours / CTRL + ENTER disabled",
25 | props: { is12hours: true, theme: "default", isCtrlEnterToSend: false },
26 | },
27 | {
28 | name: "Dark theme / 12 hours / CTRL + ENTER disabled",
29 | props: { is12hours: true, theme: "dark", isCtrlEnterToSend: false },
30 | },
31 | {
32 | name: "Default theme / 24 hours / CTRL + ENTER disabled",
33 | props: { is12hours: false, theme: "default", isCtrlEnterToSend: false },
34 | },
35 | {
36 | name: "Default theme / 12 hours / CTRL + ENTER enabled",
37 | props: { is12hours: true, theme: "default", isCtrlEnterToSend: true },
38 | },
39 | ],
40 | },
41 | {
42 | icon: "globe",
43 | title: "Locale",
44 | components: [TargemProvider],
45 | params: localeParams,
46 | },
47 | ];
48 |
--------------------------------------------------------------------------------
/src/utils/gstate/gstate.ts:
--------------------------------------------------------------------------------
1 | export interface EmptyEvent {
2 | type: E;
3 | payload?: unknown;
4 | }
5 |
6 | export interface GMachine<
7 | T extends string,
8 | E extends string,
9 | Ev extends EmptyEvent
10 | > {
11 | value: T;
12 | transition: (currentState: T, event: Ev) => T;
13 | }
14 |
15 | export type MachineOptions<
16 | T extends string,
17 | E extends string,
18 | Ev extends EmptyEvent
19 | > = {
20 | [stateName in T]: {
21 | actions?: {
22 | onEnter?: (event: Ev) => void;
23 | onExit?: (event: Ev) => void;
24 | };
25 | transitions: Partial<
26 | {
27 | [eventName in E]: {
28 | target: T;
29 | action?: (event: Ev) => void;
30 | };
31 | }
32 | >;
33 | };
34 | } & {
35 | initialState: T;
36 | };
37 |
38 | export const createMachine = <
39 | T extends string,
40 | E extends string,
41 | Ev extends EmptyEvent
42 | >(
43 | definition: MachineOptions
44 | ): GMachine => {
45 | const machine = {
46 | value: definition.initialState,
47 | transition: (currentState: T, event: Ev) => {
48 | const currStateDefinition = definition[currentState];
49 | const destTransition = currStateDefinition.transitions[event.type];
50 | if (!destTransition) {
51 | return machine.value;
52 | }
53 | const destState = destTransition.target;
54 | machine.value = destState;
55 | destTransition.action?.(event);
56 | currStateDefinition.actions?.onExit?.(event);
57 | definition[destState].actions?.onEnter?.(event);
58 | return machine.value;
59 | },
60 | };
61 | return machine;
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Typography/Typography.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Typography from "./Typography";
3 | import { Link } from "react-router-dom";
4 |
5 | export default { title: "components/ui-kit/Typography", component: Typography };
6 |
7 | export const withP = (): React.ReactNode => (
8 | Hello, world!
9 | );
10 |
11 | export const withPWithoutGutterBottom = (): React.ReactNode => (
12 | Hello, world!
13 | );
14 |
15 | export const withSmallSize = (): React.ReactNode => (
16 | Hello, world!
17 | );
18 |
19 | export const withH1 = (): React.ReactNode => (
20 | Hello, world!
21 | );
22 |
23 | export const withH2 = (): React.ReactNode => (
24 | Hello, world!
25 | );
26 |
27 | export const withH3 = (): React.ReactNode => (
28 | Hello, world!
29 | );
30 |
31 | export const withStylesPassed = (): React.ReactNode => (
32 | Hello, world!
33 | );
34 |
35 | export const withMutedColor = (): React.ReactNode => (
36 | Hello, world!
37 | );
38 |
39 | export const withContrastColor = (): React.ReactNode => (
40 |
41 | Hello, world!
42 |
43 | );
44 |
45 | export const withDangerColor = (): React.ReactNode => (
46 | Hello, world!
47 | );
48 |
49 | export const withLink = (): React.ReactNode => (
50 |
51 | Hello, world!
52 |
53 | );
54 |
--------------------------------------------------------------------------------
/src/wrappers/AppWrapper/AppWrapper.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | SettingsContextProvider,
4 | withSettings,
5 | WithSettings,
6 | } from "src/contexts/SettingsContext";
7 | import { ChatContextProvider } from "src/contexts/ChatContext";
8 | import StorybookSharedWrapper from "src/wrappers/StorybookSharedWrapper";
9 | import { TargemProvider } from "react-targem";
10 |
11 | // translation.json file is autogenerated and ignored
12 | // so we use require() to prevent tsc compile time errors before webpack is first run
13 | // eslint-disable-next-line @typescript-eslint/no-var-requires
14 | const translationsJson = require("src/i18n/translations.json");
15 |
16 | interface AppWrapperProps {
17 | children: React.ReactChild;
18 | }
19 |
20 | interface AppWrapperInternalProps extends WithSettings, AppWrapperProps {}
21 |
22 | class AppWrapperInternal extends React.PureComponent {
23 | render(): React.ReactNode {
24 | const { lang, username, userId } = this.props;
25 |
26 | return (
27 |
32 |
33 | {this.props.children}
34 |
35 |
36 | );
37 | }
38 | }
39 |
40 | const AppWrapperInternalWithSettings = withSettings(AppWrapperInternal);
41 |
42 | class AppWrapper extends React.PureComponent {
43 | render(): React.ReactNode {
44 | return (
45 |
46 |
47 | {this.props.children}
48 |
49 |
50 | );
51 | }
52 | }
53 |
54 | export default AppWrapper;
55 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Typography/Typography.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./Typography.css";
3 | import cn from "clsx";
4 |
5 | interface TypographyProps {
6 | variant: "p" | "h1" | "h2" | "h3" | "label"; // Inlined to be displayed in Storybook Docs
7 | gutterBottom: boolean;
8 | size: "small" | "normal";
9 | color: "normal" | "muted" | "contrast" | "danger";
10 | children: React.ReactNode;
11 | className?: string;
12 | style?: React.CSSProperties;
13 | htmlFor?: string;
14 | id?: string;
15 | }
16 |
17 | /** A component to be used as a drop-in replacement for ``, ``, ``, `` */
18 | class Typography extends PureComponent {
19 | public static defaultProps = {
20 | variant: "p",
21 | color: "normal",
22 | gutterBottom: true,
23 | size: "normal",
24 | };
25 |
26 | render(): React.ReactNode {
27 | const {
28 | variant,
29 | children,
30 | style,
31 | size,
32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
33 | gutterBottom,
34 | ...otherProps
35 | } = this.props;
36 | const Component = variant;
37 |
38 | return (
39 |
40 | {size === "small" ? {children} : children}
41 |
42 | );
43 | }
44 |
45 | private getClassName = (): string => {
46 | const { variant, className, color, gutterBottom } = this.props;
47 | return cn(className, classes.common, {
48 | [classes.p]: variant === "p",
49 | [classes.h1]: variant === "h1",
50 | [classes.h2]: variant === "h2",
51 | [classes.h3]: variant === "h3",
52 | [classes.colorMuted]: color === "muted",
53 | [classes.colorContrast]: color === "contrast",
54 | [classes.colorDanger]: color === "danger",
55 | [classes.gutterBottom]: gutterBottom,
56 | });
57 | };
58 | }
59 |
60 | export default Typography;
61 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatInput/ChatInput.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ChatInput from "./ChatInput";
3 | import { render, fireEvent } from "__utils__/render";
4 |
5 | const changeEvent = {
6 | target: {
7 | value: "Foooo",
8 | },
9 | };
10 |
11 | const ctrlEnterEvent = {
12 | ctrlKey: true,
13 | keyCode: 13,
14 | };
15 |
16 | it("should fire onSubmit after send button being clicked", () => {
17 | const onSubmitSpy = jest.fn();
18 | const { getByLabelText, getByTitle } = render(
19 |
20 | );
21 | const placeholder = getByLabelText("Message contents");
22 | fireEvent.change(placeholder, changeEvent);
23 | const button = getByTitle("Send!");
24 | fireEvent.click(button);
25 | expect(onSubmitSpy).toBeCalledTimes(1);
26 | expect(onSubmitSpy).toBeCalledWith("Foooo");
27 | });
28 |
29 | it("should fire onSubmit after CTRL + ENTER being pressed", async () => {
30 | const onSubmitSpy = jest.fn();
31 | const { getByLabelText } = render(
32 |
33 | );
34 | const placeholder = getByLabelText("Message contents");
35 | fireEvent.change(placeholder, changeEvent);
36 |
37 | fireEvent.keyPress(document, ctrlEnterEvent);
38 | expect(onSubmitSpy).toBeCalledTimes(1);
39 | expect(onSubmitSpy).toBeCalledWith("Foooo");
40 | });
41 |
42 | /**
43 | * There was a bug in Chrome, https://bugs.chromium.org/p/chromium/issues/detail?id=79407
44 | */
45 | it("should fire onSubmit after CTRL + ENTER being pressed (old chrome)", async () => {
46 | const onSubmitSpy = jest.fn();
47 | const { getByLabelText } = render(
48 |
49 | );
50 | const placeholder = getByLabelText("Message contents");
51 | fireEvent.change(placeholder, changeEvent);
52 |
53 | fireEvent.keyPress(document, {
54 | ...ctrlEnterEvent,
55 | keyCode: 10,
56 | });
57 | expect(onSubmitSpy).toBeCalledTimes(1);
58 | expect(onSubmitSpy).toBeCalledWith("Foooo");
59 | });
60 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatMessage/ChatMessage.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | width: 100%;
4 | flex-direction: column;
5 | padding: 0.4rem 0.8rem;
6 |
7 | --triangle-size: 14px;
8 | --background-color: var(--color-primary-lighter);
9 | }
10 |
11 | .is-current-search.container {
12 | --background-color: var(--color-secondary-lighter);
13 | }
14 |
15 | .bubble-container {
16 | display: flex;
17 | width: 100%;
18 | }
19 |
20 | .inbox .bubble-container {
21 | flex-direction: row;
22 | }
23 |
24 | .outbox .bubble-container {
25 | flex-direction: row-reverse;
26 | }
27 |
28 | .username {
29 | padding: 0.2rem var(--triangle-size);
30 | }
31 |
32 | .outbox .username {
33 | align-self: flex-end;
34 | }
35 |
36 | .bubble {
37 | max-width: 60%;
38 | background-color: var(--background-color);
39 | word-break: break-word;
40 | }
41 |
42 | .bubble-content {
43 | padding: 0.8rem 1.6rem;
44 | }
45 |
46 | .triangle {
47 | border-top: var(--triangle-size) solid var(--background-color);
48 | }
49 |
50 | [dir="ltr"] .inbox .bubble,
51 | [dir="rtl"] .outbox .bubble {
52 | border-radius: 0 var(--border-radius) var(--border-radius)
53 | var(--border-radius);
54 | }
55 |
56 | [dir="ltr"] .outbox .bubble,
57 | [dir="rtl"] .inbox .bubble {
58 | border-radius: var(--border-radius) 0 var(--border-radius)
59 | var(--border-radius);
60 | }
61 |
62 | [dir="ltr"] .inbox .triangle,
63 | [dir="rtl"] .outbox .triangle {
64 | border-left: 10px solid transparent;
65 | border-top-left-radius: 2px;
66 | }
67 |
68 | [dir="ltr"] .outbox .triangle,
69 | [dir="rtl"] .inbox .triangle {
70 | border-right: 10px solid transparent;
71 | border-top-right-radius: 2px;
72 | }
73 |
74 | .date {
75 | align-self: flex-end;
76 | margin-right: 1rem;
77 | margin-left: 1rem;
78 | }
79 |
80 | .image {
81 | width: 100%;
82 | max-width: 100%;
83 | max-height: 200px;
84 | object-fit: cover;
85 | object-position: 50% 0;
86 | }
87 |
88 | .youtube-container {
89 | position: relative;
90 | height: 0;
91 | padding-top: 25px;
92 | padding-bottom: 56.25% /* 16:9 */;
93 | }
94 |
95 | .youtube {
96 | position: absolute;
97 | top: 0;
98 | left: 0;
99 | width: 100%;
100 | height: 100%;
101 | }
102 |
--------------------------------------------------------------------------------
/src/services/ChatService/README.md:
--------------------------------------------------------------------------------
1 | # Chat Service
2 |
3 | > Library agnostic Chat Service.
4 |
5 | ### Specification
6 |
7 | - Each message contains a randomly generated `userId`
8 | - If there is no internet connection, sent messages are displayed (without a checkmark) and sent after the connection is reinitialized.
9 | - User is able to change the username, but old messages will not be updated
10 | - Chat has optimistic UI updates
11 | - `userId` can only be changed by resetting the settings or cleaning local storage
12 |
13 | ### Frontend
14 |
15 | This module export `ChatService` which can be used with any frontend library: React, Vue, AngularJS and etc.
16 |
17 | In a React application, it can be connected to a React _smart_ container, which should store chat messages in its' state. Or it can be connected to a global context if data is needed in several places across the application.
18 |
19 | `ChatAdapter` is responsible for the connection to a backend and transforming messages to our schema. By separating this responsibility we will have an ability to replace this backend socket implementation to any other.
20 |
21 | `ChatService` is responsible for this application's business logic layer and storing full messages list in-memory.
22 |
23 | ### Backend
24 |
25 | A server has the following API.
26 |
27 | ##### Send message
28 |
29 | ```typescript
30 | const content: {
31 | id: string;
32 | userId: string;
33 | text: string;
34 | username: string;
35 | createdAt: string; // ISO 8601
36 | status: "none";
37 | } = { ... };
38 | await chatIO.emit("message", content, cb);
39 | ```
40 |
41 | ##### List messages
42 |
43 | ```typescript
44 | const Content: {
45 | id: string;
46 | userId: string;
47 | text: string;
48 | username: string;
49 | createdAt: string; // ISO 8601
50 | status: "none";
51 | } = { ... };
52 | await chatIO.emit("listMessages", (err?: Error, data: { items: Content[]} ) => { ... });
53 | ```
54 |
55 | ##### Recieve message
56 |
57 | ```typescript
58 | const Content: {
59 | id: string;
60 | userId: string;
61 | text: string;
62 | username: string;
63 | createdAt: string; // ISO 8601
64 | status: "none";
65 | } = { ... };
66 | chatIO.on("message", (data: Content) => { ... });
67 | ```
68 |
69 | ### TODO
70 |
71 | - [ ] Pagination
72 | - [ ] Authorization
73 | - [ ] Seen marks
74 |
--------------------------------------------------------------------------------
/src/pages/Settings/SettingsPage.container.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import SettingsPageContainer from "./SettingsPage.container";
3 | import { render, fireEvent } from "__utils__/renderWithRouter";
4 | import { withRouter } from "react-router-dom";
5 |
6 | const Container = withRouter(SettingsPageContainer);
7 |
8 | const resetDefaultsButtonText = "Reset to defaults";
9 |
10 | it("should call reset settings method when 'Reset to defaults' is clicked", () => {
11 | const resetSettingsSpy = jest.fn();
12 | const { getByText } = render();
13 | const button = getByText(resetDefaultsButtonText);
14 | fireEvent.click(button);
15 |
16 | expect(resetSettingsSpy).toBeCalledTimes(1);
17 | });
18 |
19 | it("should call set settings method when username field is changed", () => {
20 | const setSettingsSpy = jest.fn();
21 | const { getByLabelText } = render();
22 | const input = getByLabelText("Username");
23 | fireEvent.change(input, {
24 | target: {
25 | value: "foo",
26 | },
27 | });
28 |
29 | expect(setSettingsSpy).toBeCalledWith({ username: "foo" });
30 | });
31 |
32 | it("should change language when language dropdown is changed", () => {
33 | const setSettingsSpy = jest.fn();
34 | const { getByLabelText } = render();
35 |
36 | const select = getByLabelText("Language");
37 | fireEvent.change(select, {
38 | target: {
39 | value: "ru",
40 | },
41 | });
42 |
43 | expect(setSettingsSpy).toBeCalledWith({ lang: "ru" });
44 | });
45 |
46 | it("should change theme when theme radio is changed", () => {
47 | const setSettingsSpy = jest.fn();
48 | const { getByLabelText } = render();
49 |
50 | const radio = getByLabelText("Dark");
51 | fireEvent.click(radio);
52 |
53 | expect(setSettingsSpy).toBeCalledWith({ theme: "dark" });
54 | });
55 |
56 | it("should change clock settings when clock settings radio is changed", () => {
57 | const setSettingsSpy = jest.fn();
58 | const { getByLabelText } = render();
59 |
60 | const radio = getByLabelText("12 hours");
61 | fireEvent.click(radio);
62 |
63 | expect(setSettingsSpy).toBeCalledWith({ is12hours: true });
64 | });
65 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Input/Input.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Input from "./Input";
3 | import { action } from "@storybook/addon-actions";
4 |
5 | export default { title: "components/ui-kit/Input", component: Input };
6 |
7 | const handleChange = action("onClick");
8 |
9 | const defaultProps = {
10 | id: "story",
11 | type: "text",
12 | component: "input",
13 | onChange: handleChange,
14 | } as const;
15 |
16 | export const withInput = (): React.ReactNode => ;
17 |
18 | export const withInputAddonRight = (): React.ReactNode => (
19 | ...
} />
20 | );
21 |
22 | export const withInputNumber = (): React.ReactNode => (
23 |
24 | );
25 |
26 | export const withInputRadio = (): React.ReactNode => (
27 |
28 | );
29 |
30 | export const withInputCheckbox = (): React.ReactNode => (
31 |
32 | );
33 |
34 | export const withLabelledInput = (): React.ReactNode => (
35 |
36 | );
37 |
38 | export const withInputAndPlaceholder = (): React.ReactNode => (
39 |
40 | );
41 |
42 | const defaultTextAreaProps = {
43 | id: "story",
44 | component: "textarea",
45 | onChange: handleChange,
46 | } as const;
47 |
48 | export const withTextarea = (): React.ReactNode => (
49 |
50 | );
51 |
52 | export const withLabelledTextarea = (): React.ReactNode => (
53 |
54 | );
55 |
56 | export const withTextareaAndPlaceholder = (): React.ReactNode => (
57 |
58 | );
59 |
60 | const defaultSelectProps = {
61 | id: "story",
62 | component: "select",
63 | onChange: handleChange,
64 | } as const;
65 |
66 | export const withSelect = (): React.ReactNode => (
67 |
68 |
69 |
70 |
71 | );
72 |
73 | export const withLabelledSelect = (): React.ReactNode => (
74 |
75 |
76 |
77 |
78 | );
79 |
--------------------------------------------------------------------------------
/src/components/ui-kit/RadioGroup/RadioGroup.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./RadioGroup.css";
3 | import cn from "clsx";
4 | import Typography from "src/components/ui-kit/Typography";
5 | import Input from "src/components/ui-kit/Input";
6 |
7 | interface RadioGroupProps {
8 | labelledWith: React.ReactNode;
9 | options: {
10 | value: P;
11 | text: React.ReactNode;
12 | }[];
13 | onChange: (value: P) => void;
14 | value: P;
15 | id: string;
16 | className?: string;
17 | }
18 |
19 | interface StringLike {
20 | toString: () => string;
21 | }
22 |
23 | class RadioGroup
extends PureComponent<
24 | RadioGroupProps
25 | > {
26 | render(): React.ReactNode {
27 | const {
28 | className,
29 | id,
30 | labelledWith,
31 | options,
32 | onChange,
33 | value,
34 | } = this.props;
35 |
36 | const titleId = `${id.toString()}-title`;
37 |
38 | return (
39 |
44 | {labelledWith ? (
45 |
{labelledWith}
46 | ) : null}
47 |
48 | {options.map((o) => (
49 |
57 | ))}
58 |
59 |
60 | );
61 | }
62 | }
63 |
64 | interface RadioGroupItemProps {
65 | value: P;
66 | text: React.ReactNode;
67 | activeValue: P;
68 | onChange: (value: P) => void;
69 | id: string;
70 | }
71 |
72 | class RadioGroupItem
extends PureComponent<
73 | RadioGroupItemProps
74 | > {
75 | render(): React.ReactNode {
76 | const { activeValue, id, value, text } = this.props;
77 |
78 | return (
79 |
89 | );
90 | }
91 |
92 | private handleChange = (): void => {
93 | this.props.onChange(this.props.value);
94 | };
95 | }
96 |
97 | export default RadioGroup;
98 |
--------------------------------------------------------------------------------
/src/components/ui-kit/NavBar/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./NavBar.css";
3 | import cn from "clsx";
4 | import { NavLink } from "react-router-dom";
5 | import Typography from "../Typography";
6 |
7 | interface NavBarProps {
8 | children: React.ReactNode;
9 | }
10 |
11 | export class NavBar extends PureComponent {
12 | render(): React.ReactNode {
13 | return {this.props.children}
;
14 | }
15 | }
16 |
17 | interface NavBarItemProps {
18 | text: React.ReactNode;
19 | to: string;
20 | badge?: number;
21 | isBlinking: boolean;
22 | }
23 |
24 | interface NavBarItemState {
25 | isDark: boolean;
26 | }
27 |
28 | export class NavBarItem extends PureComponent<
29 | NavBarItemProps,
30 | NavBarItemState
31 | > {
32 | static defaultProps: Partial = {
33 | isBlinking: false,
34 | };
35 |
36 | public blinkingInverval?: NodeJS.Timeout;
37 |
38 | public state: NavBarItemState = {
39 | isDark: false,
40 | };
41 |
42 | public componentDidMount(): void {
43 | if (this.props.isBlinking) {
44 | this.startBlinking();
45 | }
46 | }
47 |
48 | public componentDidUpdate(prevProps: NavBarItemProps): void {
49 | if (prevProps.isBlinking === this.props.isBlinking) {
50 | return;
51 | }
52 | if (this.props.isBlinking) {
53 | this.startBlinking();
54 | return;
55 | }
56 | this.stopBlinking();
57 | }
58 |
59 | public componentWillUnmount(): void {
60 | this.stopBlinking();
61 | }
62 |
63 | render(): React.ReactNode {
64 | const { badge, text, to } = this.props;
65 | const { isDark } = this.state;
66 |
67 | return (
68 |
76 | {text}
77 | {badge ? (
78 |
79 | {" "}
80 | {badge}
81 |
82 | ) : null}
83 |
84 | );
85 | }
86 |
87 | private startBlinking = () => {
88 | this.blinkingInverval = setInterval(() => {
89 | this.setState((state: NavBarItemState) => ({
90 | isDark: !state.isDark,
91 | }));
92 | }, 1000);
93 | };
94 |
95 | private stopBlinking = () => {
96 | if (this.blinkingInverval) {
97 | clearInterval(this.blinkingInverval);
98 | }
99 | this.setState({
100 | isDark: false,
101 | });
102 | };
103 | }
104 |
--------------------------------------------------------------------------------
/src/utils/gstate/gstate.spec.ts:
--------------------------------------------------------------------------------
1 | import { createMachine, GMachine, EmptyEvent } from "./gstate";
2 |
3 | type MachineStates = "off" | "on";
4 | type MachineEvents = "switch";
5 |
6 | interface SwitchEvent extends EmptyEvent {
7 | type: "switch";
8 | }
9 |
10 | let machine: GMachine;
11 |
12 | let transitionActionSpy: jest.Mock;
13 | let onEnterSpy: jest.Mock;
14 | let onExitSpy: jest.Mock;
15 |
16 | beforeEach(() => {
17 | transitionActionSpy = jest.fn();
18 | onEnterSpy = jest.fn();
19 | onExitSpy = jest.fn();
20 | machine = createMachine({
21 | off: {
22 | transitions: {
23 | switch: {
24 | target: "on",
25 | action: transitionActionSpy,
26 | },
27 | },
28 | actions: {
29 | onEnter: onEnterSpy,
30 | onExit: onExitSpy,
31 | },
32 | },
33 | on: {
34 | transitions: {
35 | switch: {
36 | target: "off",
37 | action: transitionActionSpy,
38 | },
39 | },
40 | actions: {
41 | onEnter: onEnterSpy,
42 | onExit: onExitSpy,
43 | },
44 | },
45 | initialState: "off",
46 | });
47 | });
48 |
49 | it("should use initialState as default value", () => {
50 | expect(machine.value).toBe("off");
51 | });
52 |
53 | it("should contain transiton function which returns value", () => {
54 | expect(machine.transition("off", { type: "switch" })).toBe("on");
55 | });
56 |
57 | it("should switch the state", () => {
58 | let state = machine.value;
59 | state = machine.transition(state, { type: "switch" });
60 | expect(state).toBe("on");
61 | state = machine.transition(state, { type: "switch" });
62 | expect(state).toBe("off");
63 | });
64 |
65 | it("should fire action", () => {
66 | let state = machine.value;
67 | state = machine.transition(state, { type: "switch" });
68 | expect(transitionActionSpy).toBeCalledTimes(1);
69 | expect(transitionActionSpy).toBeCalledWith({ type: "switch" });
70 | machine.transition(state, { type: "switch" });
71 | expect(transitionActionSpy).toBeCalledTimes(2);
72 | });
73 |
74 | it("should fire onEnter", () => {
75 | let state = machine.value;
76 | state = machine.transition(state, { type: "switch" });
77 | expect(onEnterSpy).toBeCalledTimes(1);
78 | expect(onEnterSpy).toBeCalledWith({ type: "switch" });
79 | machine.transition(state, { type: "switch" });
80 | expect(onEnterSpy).toBeCalledTimes(2);
81 | });
82 |
83 | it("should fire onExit", () => {
84 | let state = machine.value;
85 | state = machine.transition(state, { type: "switch" });
86 | expect(onExitSpy).toBeCalledTimes(1);
87 | expect(onExitSpy).toBeCalledWith({ type: "switch" });
88 | machine.transition(state, { type: "switch" });
89 | expect(onExitSpy).toBeCalledTimes(2);
90 | });
91 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatInput/ChatInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./ChatInput.css";
3 | import cn from "clsx";
4 | import Button from "src/components/ui-kit/Button";
5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6 | import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
7 | import { withLocale, WithLocale } from "react-targem";
8 | import Input from "src/components/ui-kit/Input";
9 |
10 | export interface ChatInputPureProps {
11 | onSubmit: (message: string) => void;
12 | }
13 |
14 | interface ChatInputProps extends ChatInputPureProps, WithLocale {
15 | isCtrlEnterToSend: boolean;
16 | }
17 |
18 | interface ChatInputState {
19 | message: string;
20 | }
21 |
22 | /**
23 | * Custom `Input` component to be used as a drop-in replacement for ` and ``.
24 | */
25 | class ChatInput extends PureComponent {
26 | public state: ChatInputState = {
27 | message: "",
28 | };
29 |
30 | public componentDidMount() {
31 | if (this.props.isCtrlEnterToSend) {
32 | document.addEventListener("keypress", this.handleKeyDown);
33 | }
34 | }
35 |
36 | public componentWillUnmount() {
37 | /** Props could change while component was mounted, so it is a good idea to remove event listener anyway */
38 | document.removeEventListener("keypress", this.handleKeyDown);
39 | }
40 |
41 | render(): React.ReactNode {
42 | const { t } = this.props;
43 | const { message } = this.state;
44 |
45 | return (
46 |
47 |
56 |
59 |
60 | );
61 | }
62 |
63 | private handleSubmit = () => {
64 | this.props.onSubmit(this.state.message);
65 | this.setState((s: ChatInputState) => ({
66 | ...s,
67 | message: "",
68 | }));
69 | };
70 |
71 | /** This is a View-related business logic, which should remain in the View component */
72 | private handleInputChange = (
73 | event: React.ChangeEvent
74 | ) => {
75 | this.setState({
76 | message: event.target.value,
77 | });
78 | };
79 |
80 | private handleKeyDown = (event: KeyboardEvent): void => {
81 | if (event.keyCode === 13 && event.ctrlKey) {
82 | this.handleSubmit();
83 | return;
84 | }
85 | if (event.keyCode === 10 && event.ctrlKey) {
86 | this.handleSubmit();
87 | return;
88 | }
89 | return;
90 | };
91 | }
92 |
93 | export default withLocale(ChatInput);
94 |
--------------------------------------------------------------------------------
/src/contexts/SettingsContext/SettingsContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import createContextHOC from "../createContextHOC";
3 | import { defaultThemeName, ThemeName } from "src/config/themes";
4 | import { Locale, locales } from "src/config/locales";
5 | import { getBrowserLocale } from "src/utils/locales";
6 | import { v4 as uuidv4 } from "uuid";
7 |
8 | interface SettingsContextProviderState {
9 | username?: string;
10 | lang: Locale;
11 | theme: ThemeName;
12 | is12hours: boolean;
13 | isCtrlEnterToSend: boolean;
14 | userId: string;
15 | }
16 |
17 | export interface WithSettings extends SettingsContextProviderState {
18 | setSettings: (state: Partial) => void;
19 | resetSettings: () => void;
20 | }
21 |
22 | const getInitialValues = (): SettingsContextProviderState => ({
23 | theme: defaultThemeName,
24 | is12hours: false,
25 | isCtrlEnterToSend: true,
26 | lang: getBrowserLocale(
27 | locales.map((l) => l.key),
28 | locales[0].key
29 | ),
30 | userId: uuidv4(),
31 | username: "",
32 | });
33 |
34 | const { Provider, Consumer } = React.createContext({
35 | ...getInitialValues(),
36 | // eslint-disable-next-line @typescript-eslint/no-empty-function
37 | setSettings: () => {},
38 | // eslint-disable-next-line @typescript-eslint/no-empty-function
39 | resetSettings: () => {},
40 | });
41 |
42 | const localStorageKey = "sample-chat:settings";
43 |
44 | export class SettingsContextProvider extends React.PureComponent<
45 | Partial,
46 | SettingsContextProviderState
47 | > {
48 | constructor(props: Partial) {
49 | super(props);
50 | this.state = {
51 | ...getInitialValues(),
52 | ...this.getSettingsFromLocalStorage(),
53 | };
54 | }
55 |
56 | public render(): React.ReactNode {
57 | const { state, props } = this;
58 |
59 | const providerValue = {
60 | ...state,
61 | ...props,
62 | setSettings: this.setSettings,
63 | resetSettings: this.resetSettings,
64 | };
65 |
66 | return {props.children};
67 | }
68 |
69 | private setSettings = (settings: Partial) => {
70 | this.setState((state: SettingsContextProviderState) => {
71 | const newSettings = {
72 | ...state,
73 | ...settings,
74 | };
75 | window.localStorage.setItem(localStorageKey, JSON.stringify(newSettings));
76 | return newSettings;
77 | });
78 | };
79 |
80 | private getSettingsFromLocalStorage = (): Partial<
81 | SettingsContextProviderState
82 | > => {
83 | const json = window.localStorage.getItem(localStorageKey);
84 |
85 | if (!json) {
86 | return {};
87 | }
88 |
89 | return JSON.parse(json);
90 | };
91 |
92 | private resetSettings = () => {
93 | this.setState(getInitialValues());
94 | };
95 | }
96 |
97 | export const withSettings = createContextHOC(Consumer);
98 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatMessage/ChatMessage.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ChatMessage from "./ChatMessage";
3 | import { action } from "@storybook/addon-actions";
4 |
5 | export default {
6 | title: "pages/Chat/components/ChatMessage",
7 | component: ChatMessage,
8 | };
9 |
10 | const defaultMessage = {
11 | text: "Yo!",
12 | type: "inbox",
13 | username: "goooseman",
14 | createdAt: new Date(),
15 | status: "none",
16 | onLoad: action("onLoad"),
17 | isCurrentSearch: false,
18 | } as const;
19 |
20 | export const withRecievedMessage = (): React.ReactNode => (
21 |
22 | );
23 |
24 | export const withCurrentSearch = (): React.ReactNode => (
25 |
26 | );
27 |
28 | export const withALink = (): React.ReactNode => (
29 |
33 | Hi! How are you doing?{" "}
34 | https://example.com
35 | >
36 | }
37 | />
38 | );
39 |
40 | export const withHorizontalImage = (): React.ReactNode => (
41 |
45 | Hi! How are you doing?{" "}
46 |
47 | https://source.unsplash.com/600x300?girl
48 |
49 | >
50 | }
51 | imageSrc="https://source.unsplash.com/600x300?girl"
52 | />
53 | );
54 |
55 | export const withVerticalImage = (): React.ReactNode => (
56 |
60 | Hi! How are you doing?{" "}
61 |
62 | https://source.unsplash.com/400x800?girl
63 |
64 | >
65 | }
66 | imageSrc="https://source.unsplash.com/400x800?girl"
67 | />
68 | );
69 |
70 | export const withSentMessage = (): React.ReactNode => (
71 |
72 | );
73 |
74 | export const withSentMessageInStatusRecieved = (): React.ReactNode => (
75 |
76 | );
77 |
78 | export const withRecievedMessageInStatusRecieved = (): React.ReactNode => (
79 |
80 | );
81 |
82 | const yesterday = new Date(Date.now() - 60 * 60 * 24 * 1000);
83 |
84 | export const withRecievedYesterdayMessage = (): React.ReactNode => (
85 |
86 | );
87 |
88 | export const withYoutubeVideo = (): React.ReactNode => (
89 |
93 | Check out this video:{" "}
94 |
95 | https://www.youtube.com/watch?v=BMUiFMZr7vk
96 |
97 | >
98 | }
99 | youtubeId="BMUiFMZr7vk"
100 | />
101 | );
102 |
--------------------------------------------------------------------------------
/src/contexts/SettingsContext/SettingContext.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | import React from "react";
3 | import {
4 | SettingsContextProvider,
5 | WithSettings,
6 | withSettings,
7 | } from "./SettingsContext";
8 | import { render, fireEvent } from "__utils__/render";
9 |
10 | class WithSettingsTester extends React.PureComponent {
11 | public render(): React.ReactNode {
12 | const { username, userId } = this.props;
13 |
14 | return (
15 | <>
16 |
19 |
20 | Username: {username}
21 | {}} />
22 | >
23 | );
24 | }
25 |
26 | private handleUsernameChangeClick = () => {
27 | this.props.setSettings({
28 | username: "foo",
29 | });
30 | };
31 |
32 | private handleResetClick = () => {
33 | this.props.resetSettings();
34 | };
35 | }
36 |
37 | const WithSettingsTesterHocd = withSettings(WithSettingsTester);
38 |
39 | describe("withSettings", () => {
40 | let originalLocalStorage: Storage;
41 |
42 | beforeEach(() => {
43 | originalLocalStorage = window.localStorage;
44 | Object.defineProperty(window, "localStorage", {
45 | value: {
46 | getItem: jest.fn(),
47 | setItem: jest.fn(),
48 | },
49 | writable: true,
50 | });
51 | });
52 |
53 | afterEach(() => {
54 | Object.defineProperty(window, "localStorage", {
55 | value: originalLocalStorage,
56 | writable: true,
57 | });
58 | });
59 |
60 | const TestContainer = (
61 |
62 |
63 |
64 | );
65 | it("should change username", () => {
66 | const { getByText } = render(TestContainer);
67 | fireEvent.click(getByText("Change username!"));
68 | expect(getByText("Username: foo")).toBeInTheDocument();
69 | });
70 |
71 | it("should save settings to localStorage", () => {
72 | const { getByText } = render(TestContainer);
73 | fireEvent.click(getByText("Change username!"));
74 | expect(window.localStorage.setItem).toHaveBeenCalledWith(
75 | "sample-chat:settings",
76 | expect.stringContaining('"username":"foo"')
77 | );
78 | });
79 |
80 | it("should read settings from localStorage", () => {
81 | (window.localStorage.getItem as jest.Mock).mockImplementation(() => {
82 | return '{"username": "bar-bar"}';
83 | });
84 | const { getByText } = render(TestContainer);
85 |
86 | expect(window.localStorage.getItem).toHaveBeenCalledWith(
87 | "sample-chat:settings"
88 | );
89 | expect(getByText("Username: bar-bar")).toBeInTheDocument();
90 | });
91 |
92 | it("should generate new User ID if settings are reset", () => {
93 | const { getByTitle, getByText } = render(TestContainer);
94 | const userId = (getByTitle("User ID") as HTMLInputElement).value;
95 | fireEvent.click(getByText("Reset!"));
96 | expect(getByTitle("User ID")).not.toHaveValue(userId);
97 | });
98 | });
99 |
--------------------------------------------------------------------------------
/config/webpack/config.webpack.common.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
3 | const HtmlWebpackPlugin = require("html-webpack-plugin");
4 | const CopyPlugin = require("copy-webpack-plugin");
5 | const path = require("path");
6 | const WebpackShellPluginNext = require("webpack-shell-plugin-next");
7 | const Dotenv = require("dotenv-webpack");
8 |
9 | module.exports = {
10 | entry: path.resolve(__dirname, "../..", "src", "index.tsx"),
11 | output: {
12 | filename: "main.js",
13 | path: path.resolve(__dirname, "../..", "dist"),
14 | },
15 | devServer: {
16 | historyApiFallback: true,
17 | },
18 | module: {
19 | rules: [
20 | {
21 | test: /\.(png|svg|jpg|gif)$/,
22 | use: ["file-loader"],
23 | },
24 | {
25 | test: /\.(woff|woff2|eot|ttf|otf)$/,
26 | use: ["file-loader"],
27 | },
28 | {
29 | test: /\.js$/,
30 | // Transpiling node_modules is bad for the perfomance reasons
31 | // But some libraries can contain non-es5 code
32 | // So es5-check is used after production build to make sure no new libraries contain non-es5 code
33 | exclude: [
34 | new RegExp(
35 | "node_modules\\" +
36 | path.sep +
37 | "(?!await-lock|react-youtube|socket.io-client)"
38 | ),
39 | ],
40 | use: {
41 | loader: "babel-loader",
42 | options: {
43 | presets: ["@babel/preset-env"],
44 | },
45 | },
46 | },
47 | {
48 | test: /\.tsx?$/,
49 | use: [
50 | {
51 | loader: "ts-loader",
52 | options: {
53 | transpileOnly: true,
54 | },
55 | },
56 | ],
57 | },
58 | {
59 | test: /\.css$/i,
60 | use: [
61 | {
62 | loader: MiniCssExtractPlugin.loader,
63 | options: {
64 | // only enable hot in development
65 | hmr: process.env.NODE_ENV === "development",
66 | // if hmr does not work, this is a forceful method.
67 | reloadAll: true,
68 | },
69 | },
70 | {
71 | loader: "css-loader",
72 | options: {
73 | importLoaders: 1,
74 | modules: {
75 | localIdentName: "[name]__[local]--[hash:base64:5]",
76 | },
77 | localsConvention: "camelCaseOnly",
78 | },
79 | },
80 | "postcss-loader",
81 | ],
82 | },
83 | ],
84 | },
85 | plugins: [
86 | new HtmlWebpackPlugin({
87 | title: "Application",
88 | }),
89 | new MiniCssExtractPlugin(),
90 | new CopyPlugin({
91 | patterns: [{ from: "public" }],
92 | }),
93 | new WebpackShellPluginNext({
94 | onBuildStart: {
95 | scripts: ["npm run intl:generate-json"],
96 | blocking: true,
97 | parallel: false,
98 | },
99 | }),
100 | new Dotenv({
101 | path: process.env.DOTENV || "./.env",
102 | }),
103 | ],
104 | resolve: {
105 | extensions: [".js", ".ts", ".tsx", ".json"],
106 | alias: {
107 | src: path.resolve(__dirname, "../../src/"),
108 | debug: "debug-es5",
109 | },
110 | },
111 | };
112 |
--------------------------------------------------------------------------------
/src/components/ui-kit/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./Input.css";
3 | import cn from "clsx";
4 | import Typography from "src/components/ui-kit/Typography";
5 |
6 | interface CommonInputProps {
7 | labelledWith?: React.ReactNode;
8 | addonRight?: React.ReactNode;
9 | containerClassName?: string;
10 | }
11 |
12 | interface TextAreaProps
13 | extends React.DetailedHTMLProps<
14 | React.TextareaHTMLAttributes,
15 | HTMLTextAreaElement
16 | > {
17 | id: string;
18 | component: "textarea";
19 | }
20 |
21 | interface InpurProps
22 | extends React.DetailedHTMLProps<
23 | React.InputHTMLAttributes,
24 | HTMLInputElement
25 | > {
26 | id: string;
27 | component: "input";
28 | }
29 |
30 | interface SelectProps
31 | extends React.DetailedHTMLProps<
32 | React.SelectHTMLAttributes,
33 | HTMLSelectElement
34 | > {
35 | id: string;
36 | component: "select";
37 | }
38 |
39 | export type InputElementProps = TextAreaProps | InpurProps | SelectProps;
40 |
41 | /**
42 | * Custom `Input` component to be used as a drop-in replacement for ``, ``, ``.
43 | */
44 | class Input extends PureComponent {
45 | render(): React.ReactNode {
46 | const { labelledWith, addonRight, containerClassName } = this.props;
47 |
48 | return (
49 |
54 | {labelledWith ? (
55 |
60 | {labelledWith}
61 |
62 | ) : null}
63 |
64 | {this.getInputElement()}
65 |
{addonRight}
66 |
67 |
68 | );
69 | }
70 |
71 | private isInline = (): boolean => {
72 | return (
73 | this.props.component === "input" &&
74 | (this.props.type === "radio" || this.props.type === "checkbox")
75 | );
76 | };
77 |
78 | private getInputElement = (): React.ReactNode => {
79 | /* eslint-disable @typescript-eslint/no-unused-vars */
80 | const {
81 | component,
82 | className,
83 | labelledWith,
84 | addonRight,
85 | containerClassName,
86 | ...inputProps
87 | } = this.props;
88 | /* eslint-enable @typescript-eslint/no-unused-vars */
89 |
90 | switch (component) {
91 | case "textarea":
92 | return (
93 |
97 | );
98 |
99 | case "input":
100 | return (
101 |
105 | );
106 | case "select":
107 | return (
108 |
112 | );
113 | default:
114 | return null;
115 | }
116 | };
117 | }
118 |
119 | export default Input;
120 |
--------------------------------------------------------------------------------
/src/services/ChatService/ChatAdapter.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from "socket.io-client";
2 | import { ChatMessage } from "./ChatService";
3 | import { v4 as uuidv4 } from "uuid";
4 |
5 | export interface ChatAdapterMessage {
6 | id: string;
7 | userId: string;
8 | text: string;
9 | username: string;
10 | createdAt: string;
11 | status: "none" | "receivedByServer";
12 | }
13 |
14 | type ChatEmitNames = "message" | "listMessages";
15 |
16 | type SocketResponse =
17 | | {
18 | err: Error;
19 | res: undefined;
20 | }
21 | | {
22 | err: undefined;
23 | res: R;
24 | };
25 |
26 | type OnMessageCb = (data: ChatMessage) => void;
27 |
28 | class ChatAdapter {
29 | private socket: typeof Socket;
30 | private userId: string;
31 | private onMessageCb?: OnMessageCb;
32 |
33 | constructor(socket: typeof Socket, userId: string) {
34 | this.socket = socket;
35 | this.userId = userId;
36 | this.socket.on("disconnect", this.handleDisconnected);
37 | this.socket.on("message", (message: ChatAdapterMessage) => {
38 | this.onMessageCb &&
39 | this.onMessageCb(this.transformChatAdapterMessage(message));
40 | });
41 | }
42 |
43 | public connect(): void {
44 | this.socket.open();
45 | }
46 |
47 | public disconnect(): void {
48 | this.socket.close();
49 | }
50 |
51 | public onMessage(cb: (data: ChatMessage) => void): void {
52 | this.onMessageCb = cb;
53 | }
54 |
55 | public async emitMessage(message: {
56 | text: string;
57 | username: string;
58 | }): Promise {
59 | const messageToSend = {
60 | ...message,
61 | userId: this.userId,
62 | status: "none",
63 | id: uuidv4(),
64 | } as const;
65 |
66 | this.onMessageCb &&
67 | this.onMessageCb({
68 | ...messageToSend,
69 | type: "outbox",
70 | createdAt: new Date(),
71 | });
72 | await this.emitAsync("message", messageToSend);
73 | return;
74 | }
75 |
76 | public async emitListMessages(): Promise<{
77 | items: ChatMessage[];
78 | }> {
79 | const response = await this.emitAsync<{
80 | items: ChatAdapterMessage[];
81 | }>("listMessages");
82 |
83 | return {
84 | ...response,
85 | items: response.items.map(this.transformChatAdapterMessage).reverse(),
86 | };
87 | }
88 |
89 | private emitAsync(
90 | eventName: ChatEmitNames,
91 | ...args: unknown[]
92 | ): Promise {
93 | return new Promise((resolve, reject) => {
94 | this.socket.emit(eventName, ...args, (response: SocketResponse) => {
95 | if (response.err) {
96 | reject(response.err);
97 | }
98 | resolve(response.res);
99 | });
100 | });
101 | }
102 |
103 | private transformChatAdapterMessage = (
104 | message: ChatAdapterMessage
105 | ): ChatMessage => {
106 | return {
107 | id: message.id,
108 | username: message.username,
109 | type: this.userId === message.userId ? "outbox" : "inbox",
110 | text: message.text,
111 | createdAt: new Date(message.createdAt),
112 | status: message.status,
113 | };
114 | };
115 |
116 | private handleDisconnected = (reason: string) => {
117 | if (reason === "io server disconnect") {
118 | // the disconnection was initiated by the server, you need to reconnect manually
119 | this.socket.connect();
120 | }
121 | // else the socket will automatically try to reconnect
122 | };
123 | }
124 |
125 | export default ChatAdapter;
126 |
--------------------------------------------------------------------------------
/src/services/ChatService/ChatService.ts:
--------------------------------------------------------------------------------
1 | import ChatAdapter from "./ChatAdapter";
2 |
3 | export interface ChatMessage {
4 | id: string;
5 | text: string;
6 | type: "inbox" | "outbox";
7 | username: string;
8 | createdAt: Date;
9 | status: "none" | "receivedByServer";
10 | }
11 |
12 | interface MessagesStore {
13 | [id: string]: ChatMessage;
14 | }
15 |
16 | interface MessagesListInternal {
17 | /** Store in an object to have O(1) time complexity to find a message */
18 | messages: MessagesStore;
19 | count: number;
20 | unreadCount: number;
21 | }
22 |
23 | interface MessagesList {
24 | messages: ChatMessage[];
25 | count: number;
26 | unreadCount: number;
27 | }
28 |
29 | const initialMessagesList = { messages: {}, count: 0, unreadCount: 0 };
30 |
31 | export interface SearchResult {
32 | id: string;
33 | matches: string[];
34 | }
35 |
36 | class ChatService {
37 | private onMessagesListChangeCb?: (messagesList: MessagesList) => void;
38 |
39 | constructor(chatAdapter: ChatAdapter) {
40 | this.adapter = chatAdapter;
41 | this.adapter.onMessage(this.handleMessage);
42 | }
43 |
44 | public async connect(): Promise {
45 | this.adapter.connect();
46 | const messagesList = await this.adapter.emitListMessages();
47 | this.messagesList.messages = messagesList.items.reduce(
48 | (obj: MessagesStore, m: ChatMessage) => {
49 | obj[m.id] = m;
50 | return obj;
51 | },
52 | {}
53 | );
54 | this.messagesList.count = messagesList.items.length;
55 | this.messagesList.unreadCount = 0;
56 | this.handleMessagesUpdate();
57 | }
58 |
59 | public async disconnect(): Promise {
60 | return this.adapter.disconnect();
61 | }
62 |
63 | public async search(query: string): Promise {
64 | if (query == "") {
65 | return [];
66 | }
67 | const result = [];
68 | for (const message of Object.values(this.messagesList.messages)) {
69 | const matches = message.text.match(new RegExp(query, "gi"));
70 | if (!matches || matches.length === 0) {
71 | continue;
72 | }
73 | result.push({
74 | id: message.id,
75 | matches: matches,
76 | });
77 | }
78 | return result;
79 | }
80 |
81 | public async sendMessage(message: {
82 | text: string;
83 | username: string;
84 | }): Promise {
85 | await this.adapter.emitMessage(message);
86 | }
87 |
88 | public onMessagesListChange(cb: (messagesList: MessagesList) => void): void {
89 | this.onMessagesListChangeCb = cb;
90 | }
91 |
92 | public markAllAsRead(): void {
93 | this.messagesList.unreadCount = 0;
94 | this.handleMessagesUpdate();
95 | }
96 |
97 | private handleMessage = (message: ChatMessage) => {
98 | if (!this.messagesList.messages[message.id]) {
99 | this.messagesList.unreadCount += 1;
100 | }
101 | this.messagesList.messages[message.id] = message;
102 | // In a real chat should come from server
103 | this.messagesList.count = Object.keys(this.messagesList.messages).length;
104 |
105 | this.handleMessagesUpdate();
106 | };
107 |
108 | private handleMessagesUpdate = () => {
109 | this.onMessagesListChangeCb &&
110 | this.onMessagesListChangeCb({
111 | messages: Object.values(this.messagesList.messages),
112 | count: this.messagesList.count,
113 | unreadCount: this.messagesList.unreadCount,
114 | });
115 | };
116 |
117 | private messagesList: MessagesListInternal = initialMessagesList;
118 | private adapter: ChatAdapter;
119 | }
120 |
121 | export default ChatService;
122 |
--------------------------------------------------------------------------------
/src/pages/Chat/ChatPage.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ChatPage from "./ChatPage";
3 | import { action } from "@storybook/addon-actions";
4 |
5 | export default { title: "pages/Chat", component: ChatPage };
6 |
7 | const defaultRecievedChatMessage = {
8 | type: "inbox" as const,
9 | username: "PimpMasta",
10 | createdAt: new Date(2020, 0, 1),
11 | status: "none" as const,
12 | };
13 |
14 | const defaulSentChatMessage = {
15 | type: "outbox" as const,
16 | username: "HolyGrandma",
17 | createdAt: new Date(2020, 0, 1),
18 | status: "receivedByServer" as const,
19 | };
20 |
21 | const getChatMessages = (idPrefix: string) => [
22 | {
23 | ...defaultRecievedChatMessage,
24 | id: `${idPrefix}1`,
25 | text: "Want to bang tonight?",
26 | createdAt: new Date(2020, 0, 1, 1, 10),
27 | },
28 | {
29 | ...defaultRecievedChatMessage,
30 | id: `${idPrefix}2`,
31 | text: "I meant hang",
32 | createdAt: new Date(2020, 0, 1, 1, 11),
33 | },
34 | {
35 | ...defaultRecievedChatMessage,
36 | id: `${idPrefix}3`,
37 | text: "Duck, auto-cucumber",
38 | createdAt: new Date(2020, 0, 1, 1, 13),
39 | },
40 | {
41 | ...defaulSentChatMessage,
42 | id: `${idPrefix}4`,
43 | text: "What?",
44 | createdAt: new Date(2020, 0, 1, 1, 23),
45 | },
46 | {
47 | id: `${idPrefix}5`,
48 | text: "God donut.",
49 | ...defaultRecievedChatMessage,
50 | createdAt: new Date(2020, 0, 1, 1, 25),
51 | },
52 | {
53 | ...defaultRecievedChatMessage,
54 | id: `${idPrefix}6`,
55 | text: "How the duck do I turn this off?",
56 | createdAt: new Date(2020, 0, 1, 1, 26),
57 | },
58 | {
59 | ...defaulSentChatMessage,
60 | id: `${idPrefix}7`,
61 | text: ":))",
62 | createdAt: new Date(Date.now() - 1000 * 60 * 20),
63 | },
64 | ];
65 |
66 | const commonProps: React.ComponentProps = {
67 | searchState: "chat",
68 | onSubmit: action("onSubmit"),
69 | onSearchButtonClick: action("onSearchButtonClick"),
70 | onSearchInput: action("onSearchInput"),
71 | onChangeCurrentSearchClick: () => action("onChangeCurrentSearchClick"),
72 | onRetryButtonClick: action("onRetryButtonClick"),
73 | chatMessages: getChatMessages("1"),
74 | currentSearchResult: 0,
75 | searchQuery: "",
76 | style: {
77 | paddingTop: "60px",
78 | },
79 | };
80 |
81 | export const withDefaultChat = (): React.ReactNode => (
82 |
83 | );
84 |
85 | export const withTonsOfMessage = (): React.ReactNode => (
86 |
95 | );
96 |
97 | export const withSearchOpened = (): React.ReactNode => (
98 |
99 | );
100 |
101 | export const withSearchLoading = (): React.ReactNode => (
102 |
103 | );
104 |
105 | export const withSearchResults = (): React.ReactNode => (
106 |
122 | );
123 |
124 | export const withSearchFailed = (): React.ReactNode => (
125 |
126 | );
127 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./ChatMessage.css";
3 | import cn from "clsx";
4 | import Typography from "src/components/ui-kit/Typography";
5 | import TimeDisplay from "src/components/TimeDisplay";
6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7 | import { faCheck } from "@fortawesome/free-solid-svg-icons";
8 | import { WithLocale, withLocale } from "react-targem";
9 | import YouTube from "react-youtube";
10 |
11 | export interface ChatMessageProps {
12 | text: React.ReactNode;
13 | type: "inbox" | "outbox";
14 | username: string;
15 | createdAt: Date;
16 | status: "none" | "receivedByServer";
17 | imageSrc?: string;
18 | youtubeId?: string;
19 | onLoad: () => void;
20 | isCurrentSearch: boolean;
21 | messageRef?: React.RefObject;
22 | }
23 |
24 | class ChatMessage extends PureComponent {
25 | render(): React.ReactNode {
26 | const {
27 | createdAt,
28 | type,
29 | username,
30 | text,
31 | imageSrc,
32 | t,
33 | onLoad,
34 | youtubeId,
35 | isCurrentSearch,
36 | messageRef,
37 | } = this.props;
38 |
39 | return (
40 |
48 | {type === "inbox" ? (
49 |
55 | {username}
56 |
57 | ) : null}
58 |
59 |
60 |
61 |
62 |
63 | {text}
64 |
65 | {imageSrc ? (
66 |
72 |
78 |
79 | ) : null}
80 | {youtubeId ? (
81 |
85 |
86 |
87 | ) : null}
88 |
89 |
90 |
96 | <>
97 | {this.getStatusIcon()}
98 | >
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | private getStatusIcon = () => {
106 | const { status, type } = this.props;
107 | if (status === "receivedByServer" && type === "outbox") {
108 | return ;
109 | }
110 | return null;
111 | };
112 | }
113 |
114 | export default withLocale(ChatMessage);
115 |
--------------------------------------------------------------------------------
/src/contexts/ChatContext/ChatContext.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 | import React from "react";
3 | import { ChatContextProvider } from "./ChatContext";
4 | // @ts-ignore
5 | import { getChatService, mockedService } from "src/services/ChatService";
6 | import { render, fireEvent } from "__utils__/render";
7 |
8 | jest.mock("src/services/ChatService");
9 |
10 | beforeEach(jest.clearAllMocks);
11 |
12 | describe("ChatContextProvider", () => {
13 | class ChatContextTester extends React.PureComponent {
14 | public state = {
15 | username: "foo",
16 | userId: "bar",
17 | };
18 |
19 | public render(): React.ReactNode {
20 | const { userId, username } = this.state;
21 |
22 | return (
23 | <>
24 |
25 |
29 | >
30 | );
31 | }
32 |
33 | private handleChangeUserIdClick = () => {
34 | this.setState({
35 | userId: "new-id",
36 | });
37 | };
38 | }
39 |
40 | it("should initialize ChatService with provided userId", () => {
41 | render();
42 | expect(getChatService).toBeCalledTimes(1);
43 | expect(getChatService).toBeCalledWith("bar");
44 | expect(mockedService.connect).toBeCalledTimes(1);
45 | expect(mockedService.onMessagesListChange).toBeCalledTimes(1);
46 | });
47 |
48 | it("should disconnect old ChatService and initialize new one after userId is changed", () => {
49 | const { getByText } = render();
50 | fireEvent.click(getByText("Change userId"));
51 |
52 | expect(getChatService).toBeCalledTimes(2);
53 | expect(getChatService).toHaveBeenLastCalledWith("new-id");
54 | expect(mockedService.connect).toBeCalledTimes(2);
55 | expect(mockedService.onMessagesListChange).toBeCalledTimes(2);
56 | expect(mockedService.disconnect).toBeCalledTimes(1);
57 | });
58 |
59 | it("should fire disconnect after component is unmounted", () => {
60 | const { unmount } = render();
61 | unmount();
62 |
63 | expect(mockedService.disconnect).toBeCalledTimes(1);
64 | });
65 |
66 | it("should change browser's title if there is an unread message", () => {
67 | render();
68 | const changeMessageList = (mockedService.onMessagesListChange as jest.Mock)
69 | .mock.calls[0][0];
70 |
71 | changeMessageList({
72 | messages: 0,
73 | count: 0,
74 | unreadCount: 1,
75 | });
76 |
77 | expect(document.title).toBe("New message is received!");
78 | });
79 |
80 | it("should pluralize title correctly", () => {
81 | render();
82 | const changeMessageList = (mockedService.onMessagesListChange as jest.Mock)
83 | .mock.calls[0][0];
84 |
85 | changeMessageList({
86 | messages: 0,
87 | count: 0,
88 | unreadCount: 2,
89 | });
90 |
91 | expect(document.title).toBe("2 new messages are received!");
92 | });
93 |
94 | it("should rollback original title if no unread messages are left", () => {
95 | document.title = "some-title";
96 | render();
97 | const changeMessageList = (mockedService.onMessagesListChange as jest.Mock)
98 | .mock.calls[0][0];
99 |
100 | changeMessageList({
101 | messages: 0,
102 | count: 0,
103 | unreadCount: 1,
104 | });
105 |
106 | expect(document.title).toBe("New message is received!");
107 |
108 | changeMessageList({
109 | messages: 0,
110 | count: 0,
111 | unreadCount: 0,
112 | });
113 |
114 | expect(document.title).toBe("some-title");
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/src/i18n/template.pot:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: sample-chat-frontend 1.0.6\n"
4 | "Content-Type: text/plain; charset=utf-8\n"
5 | "POT-Creation-Date: Sat Mar 06 2021 22:08:50 GMT+0200 (Israel Standard Time)\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
8 |
9 | #: src/components/ui-kit/Loading/Loading.tsx:
10 | msgid "Loading"
11 | msgstr ""
12 |
13 | #: src/contexts/ChatContext/ChatContext.tsx:
14 | msgid "New message is received!"
15 | msgid_plural "{{ count }} new messages are received!"
16 | msgstr[0] ""
17 | msgstr[1] ""
18 |
19 | #: src/pages/Chat/ChatPage.container.tsx:
20 | msgid "Please, specify username to use chat"
21 | msgstr ""
22 |
23 | #: src/pages/Chat/ChatPage.tsx:
24 | msgid "Search..."
25 | msgstr ""
26 |
27 | #: src/pages/Chat/ChatPage.tsx:
28 | msgid "Ooops..."
29 | msgstr ""
30 |
31 | #: src/pages/Chat/ChatPage.tsx:
32 | msgid "Retry"
33 | msgstr ""
34 |
35 | #: src/pages/Chat/ChatPage.tsx:
36 | msgid "Previous result"
37 | msgstr ""
38 |
39 | #: src/pages/Chat/ChatPage.tsx:
40 | msgid "{{ from }} of {{ to }}"
41 | msgstr ""
42 |
43 | #: src/pages/Chat/ChatPage.tsx:
44 | msgid "Next result"
45 | msgstr ""
46 |
47 | #: src/pages/Chat/ChatPage.tsx:
48 | msgid "Open search"
49 | msgstr ""
50 |
51 | #: src/pages/Chat/ChatPage.tsx:
52 | msgid "Close search"
53 | msgstr ""
54 |
55 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
56 | msgid "Message contents"
57 | msgstr ""
58 |
59 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
60 | msgid "Type a message..."
61 | msgstr ""
62 |
63 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
64 | msgid "Send!"
65 | msgstr ""
66 |
67 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
68 | msgid "Open image in a new tab"
69 | msgstr ""
70 |
71 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
72 | msgid "Image from the message"
73 | msgstr ""
74 |
75 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
76 | msgid "Youtube player"
77 | msgstr ""
78 |
79 | #: src/pages/NotFound/NotFound.tsx:
80 | msgid "Ooops... Page not found..."
81 | msgstr ""
82 |
83 | #: src/pages/NotFound/NotFound.tsx:
84 | msgid ""
85 | "The page you are looking for might have been removed, had its name changed, or "
86 | "is temporarily unavailable."
87 | msgstr ""
88 |
89 | #: src/pages/NotFound/NotFound.tsx:
90 | msgid "Go home"
91 | msgstr ""
92 |
93 | #: src/pages/Settings/SettingsPage.tsx:
94 | msgid "Username"
95 | msgstr ""
96 |
97 | #: src/pages/Settings/SettingsPage.tsx:
98 | msgid "Theme"
99 | msgstr ""
100 |
101 | #: src/pages/Settings/SettingsPage.tsx:
102 | msgid "Light"
103 | msgstr ""
104 |
105 | #: src/pages/Settings/SettingsPage.tsx:
106 | msgid "Dark"
107 | msgstr ""
108 |
109 | #: src/pages/Settings/SettingsPage.tsx:
110 | msgid "Clock display"
111 | msgstr ""
112 |
113 | #: src/pages/Settings/SettingsPage.tsx:
114 | msgid "12 hours"
115 | msgstr ""
116 |
117 | #: src/pages/Settings/SettingsPage.tsx:
118 | msgid "24 hours"
119 | msgstr ""
120 |
121 | #: src/pages/Settings/SettingsPage.tsx:
122 | msgid ""
123 | "Just for educational purposes. In a real-world application time format should "
124 | "be taken from the browser's locale."
125 | msgstr ""
126 |
127 | #: src/pages/Settings/SettingsPage.tsx:
128 | msgid "Send messages on CTRL + ENTER"
129 | msgstr ""
130 |
131 | #: src/pages/Settings/SettingsPage.tsx:
132 | msgid "On"
133 | msgstr ""
134 |
135 | #: src/pages/Settings/SettingsPage.tsx:
136 | msgid "Off"
137 | msgstr ""
138 |
139 | #: src/pages/Settings/SettingsPage.tsx:
140 | msgid "Language"
141 | msgstr ""
142 |
143 | #: src/pages/Settings/SettingsPage.tsx:
144 | msgid "Reset to defaults"
145 | msgstr ""
146 |
147 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
148 | msgid "Chat"
149 | msgstr ""
150 |
151 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
152 | msgid "Settings"
153 | msgstr ""
--------------------------------------------------------------------------------
/src/contexts/ChatContext/ChatContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | ChatMessage,
4 | getChatService,
5 | ChatService,
6 | SearchResult,
7 | } from "src/services/ChatService";
8 | import createContextHOC from "../createContextHOC";
9 | import { WithLocale, withLocale } from "react-targem";
10 |
11 | interface ChatContextProviderState {
12 | chatMessages: ChatMessage[];
13 | chatMessagesCount: number;
14 | chatMessagesUnreadCount: number;
15 | }
16 |
17 | interface ChatContextProviderProps extends WithLocale {
18 | username?: string;
19 | userId: string;
20 | }
21 |
22 | export interface WithChat extends ChatContextProviderState {
23 | sendMessage: (text: string) => void;
24 | searchMessage: (query: string) => Promise;
25 | markAllAsRead: () => void;
26 | }
27 |
28 | const defaults: ChatContextProviderState = {
29 | chatMessages: [],
30 | chatMessagesCount: 0,
31 | chatMessagesUnreadCount: 0,
32 | };
33 |
34 | const { Provider, Consumer } = React.createContext({
35 | ...defaults,
36 | /* eslint-disable @typescript-eslint/no-empty-function */
37 | sendMessage: () => {},
38 | markAllAsRead: () => {},
39 | searchMessage: () => Promise.resolve([]),
40 | /* eslint-enable @typescript-eslint/no-empty-function */
41 | });
42 |
43 | class ChatContextProviderPure extends React.PureComponent<
44 | ChatContextProviderProps,
45 | ChatContextProviderState
46 | > {
47 | private chatService?: ChatService;
48 | private originalHtmlTitle?: string;
49 |
50 | constructor(props: ChatContextProviderProps) {
51 | super(props);
52 | this.state = defaults;
53 | }
54 |
55 | public componentDidMount(): void {
56 | this.initChatService();
57 | }
58 |
59 | public componentDidUpdate(prevProps: ChatContextProviderProps): void {
60 | if (prevProps.userId !== this.props.userId) {
61 | this.chatService?.disconnect();
62 | this.initChatService();
63 | }
64 | }
65 |
66 | public componentWillUnmount(): void {
67 | this.chatService?.disconnect();
68 | }
69 |
70 | public render(): React.ReactNode {
71 | const { state, props } = this;
72 |
73 | const providerValue = {
74 | ...state,
75 | sendMessage: this.sendMessage,
76 | markAllAsRead: this.markAllAsRead,
77 | searchMessage: this.searchMessage,
78 | };
79 |
80 | return {props.children};
81 | }
82 |
83 | private searchMessage = (query: string) => {
84 | if (!this.chatService) {
85 | return Promise.resolve([]);
86 | }
87 | return this.chatService.search(query);
88 | };
89 |
90 | private handleMessagesListChange = (messagesList: {
91 | messages: ChatMessage[];
92 | count: number;
93 | unreadCount: number;
94 | }) => {
95 | this.setState({
96 | chatMessages: messagesList.messages,
97 | chatMessagesCount: messagesList.count,
98 | chatMessagesUnreadCount: messagesList.unreadCount,
99 | });
100 |
101 | if (messagesList.unreadCount > 0) {
102 | if (!this.originalHtmlTitle) {
103 | this.originalHtmlTitle = document.title;
104 | }
105 | document.title = this.props.tn(
106 | "New message is received!",
107 | "{{ count }} new messages are received!",
108 | messagesList.unreadCount
109 | );
110 | return;
111 | }
112 |
113 | if (this.originalHtmlTitle) {
114 | document.title = this.originalHtmlTitle;
115 | this.originalHtmlTitle = undefined;
116 | }
117 | };
118 |
119 | private sendMessage = (text: string) => {
120 | const { username } = this.props;
121 | if (!username || username === "") {
122 | throw new Error("No username is specified");
123 | }
124 | if (!this.chatService) {
125 | throw new Error("Chat Service is not initialized");
126 | }
127 | this.chatService.sendMessage({ text, username });
128 | };
129 |
130 | private markAllAsRead = () => {
131 | this.chatService?.markAllAsRead();
132 | };
133 |
134 | private initChatService = () => {
135 | this.chatService = getChatService(this.props.userId);
136 | this.chatService.connect();
137 | this.chatService.onMessagesListChange(this.handleMessagesListChange);
138 | };
139 | }
140 |
141 | export const ChatContextProvider = withLocale(ChatContextProviderPure);
142 |
143 | export const withChat = createContextHOC(Consumer);
144 | export type { SearchResult };
145 |
--------------------------------------------------------------------------------
/src/i18n/he.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: sample-chat-frontend 1.0.6\n"
4 | "Content-Type: text/plain; charset=utf-8\n"
5 | "POT-Creation-Date: Wed Jun 10 2020 08:42:40 GMT+0300 (Israel Daylight Time)\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : n==2 ? 1 : n>10 && n%10==0 ? 2 : "
8 | "3);\n"
9 | "PO-Revision-Date: \n"
10 | "Language-Team: \n"
11 | "MIME-Version: 1.0\n"
12 | "X-Generator: Poedit 2.4.2\n"
13 | "Last-Translator: \n"
14 | "Language: he\n"
15 |
16 | #: src/components/ui-kit/Loading/Loading.tsx:
17 | msgid "Loading"
18 | msgstr "טוען"
19 |
20 | #: src/contexts/ChatContext/ChatContext.tsx:
21 | msgid "New message is received!"
22 | msgid_plural "{{ count }} new messages are received!"
23 | msgstr[0] "הודעה חדשה מתקבלת!"
24 | msgstr[1] "שתי הודעות מתקבלות!"
25 | msgstr[2] "מתקבלות {{ count }} הודעות!"
26 | msgstr[3] "מתקבלות {{ count }} הודעו!"
27 |
28 | #: src/pages/Chat/ChatPage.container.tsx:
29 | msgid "Please, specify username to use chat"
30 | msgstr "אנא, ציין את שם המשתמש לשימוש בצ’אט"
31 |
32 | #: src/pages/Chat/ChatPage.tsx:
33 | msgid "Search..."
34 | msgstr "חיפוש..."
35 |
36 | #: src/pages/Chat/ChatPage.tsx:
37 | msgid "Ooops..."
38 | msgstr "אופס..."
39 |
40 | #: src/pages/Chat/ChatPage.tsx:
41 | msgid "Retry"
42 | msgstr "לנסות שוב"
43 |
44 | #: src/pages/Chat/ChatPage.tsx:
45 | msgid "Previous result"
46 | msgstr "התוצאה הקודמת"
47 |
48 | #: src/pages/Chat/ChatPage.tsx:
49 | msgid "{{ from }} of {{ to }}"
50 | msgstr "{{ from }} מתוך {{ to }}"
51 |
52 | #: src/pages/Chat/ChatPage.tsx:
53 | msgid "Next result"
54 | msgstr "התוצאה הבאה"
55 |
56 | #: src/pages/Chat/ChatPage.tsx:
57 | msgid "Open search"
58 | msgstr "פתח חיפוש"
59 |
60 | #: src/pages/Chat/ChatPage.tsx:
61 | msgid "Close search"
62 | msgstr "סגירת החיפוש"
63 |
64 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
65 | msgid "Message contents"
66 | msgstr "תוכן ההודעה"
67 |
68 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
69 | msgid "Type a message..."
70 | msgstr "הקלד הודעה…"
71 |
72 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
73 | msgid "Send!"
74 | msgstr "שלח!"
75 |
76 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
77 | msgid "Open image in a new tab"
78 | msgstr "פתח תמונה בכרטיסייה חדשה"
79 |
80 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
81 | msgid "Image from the message"
82 | msgstr "תמונה מההודעה"
83 |
84 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
85 | msgid "Youtube player"
86 | msgstr "נגן YouTube"
87 |
88 | #: src/pages/NotFound/NotFound.tsx:
89 | msgid "Ooops... Page not found..."
90 | msgstr "אופס… הדף לא נמצא…"
91 |
92 | #: src/pages/NotFound/NotFound.tsx:
93 | msgid ""
94 | "The page you are looking for might have been removed, had its name changed, or "
95 | "is temporarily unavailable."
96 | msgstr "ייתכן שהדף שאתה מחפש הוסר, אם שמו השתנה או שהוא אינו זמין באופן זמני."
97 |
98 | #: src/pages/NotFound/NotFound.tsx:
99 | msgid "Go home"
100 | msgstr "עבור לאינדקס"
101 |
102 | #: src/pages/Settings/SettingsPage.tsx:
103 | msgid "Username"
104 | msgstr "שם משתמש"
105 |
106 | #: src/pages/Settings/SettingsPage.tsx:
107 | msgid "Theme"
108 | msgstr "נושא"
109 |
110 | #: src/pages/Settings/SettingsPage.tsx:
111 | msgid "Light"
112 | msgstr "קל"
113 |
114 | #: src/pages/Settings/SettingsPage.tsx:
115 | msgid "Dark"
116 | msgstr "אפל"
117 |
118 | #: src/pages/Settings/SettingsPage.tsx:
119 | msgid "Clock display"
120 | msgstr "תצוגת שעון"
121 |
122 | #: src/pages/Settings/SettingsPage.tsx:
123 | msgid "12 hours"
124 | msgstr "12 שעות"
125 |
126 | #: src/pages/Settings/SettingsPage.tsx:
127 | msgid "24 hours"
128 | msgstr "24 שעות"
129 |
130 | #: src/pages/Settings/SettingsPage.tsx:
131 | msgid ""
132 | "Just for educational purposes. In a real-world application time format should "
133 | "be taken from the browser's locale."
134 | msgstr "רק למטרות חינוכיות. במתכונת זמן יישום בעולם האמיתי יש לקחת ממקום הדפדפן."
135 |
136 | #: src/pages/Settings/SettingsPage.tsx:
137 | msgid "Send messages on CTRL + ENTER"
138 | msgstr "שלח הודעות ב- CTRL + ENTER"
139 |
140 | #: src/pages/Settings/SettingsPage.tsx:
141 | msgid "On"
142 | msgstr "על"
143 |
144 | #: src/pages/Settings/SettingsPage.tsx:
145 | msgid "Off"
146 | msgstr "כבוי"
147 |
148 | #: src/pages/Settings/SettingsPage.tsx:
149 | msgid "Language"
150 | msgstr "שפה"
151 |
152 | #: src/pages/Settings/SettingsPage.tsx:
153 | msgid "Reset to defaults"
154 | msgstr "איפוס לברירות מחדל"
155 |
156 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
157 | msgid "Chat"
158 | msgstr "צ’אט"
159 |
160 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
161 | msgid "Settings"
162 | msgstr "הגדרות"
163 |
--------------------------------------------------------------------------------
/src/i18n/en-GB.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: sample-chat-frontend 1.0.6\n"
4 | "Content-Type: text/plain; charset=utf-8\n"
5 | "POT-Creation-Date: Wed Jun 10 2020 08:42:40 GMT+0300 (Israel Daylight Time)\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
8 | "PO-Revision-Date: \n"
9 | "Language-Team: \n"
10 | "MIME-Version: 1.0\n"
11 | "X-Generator: Poedit 2.3.1\n"
12 | "Last-Translator: \n"
13 | "Language: en\n"
14 |
15 | #: src/components/ui-kit/Loading/Loading.tsx:
16 | msgid "Loading"
17 | msgstr "Loading"
18 |
19 | #: src/contexts/ChatContext/ChatContext.tsx:
20 | msgid "New message is received!"
21 | msgid_plural "{{ count }} new messages are received!"
22 | msgstr[0] "New message is received!"
23 | msgstr[1] "{{ count }} new messages are received!"
24 |
25 | #: src/pages/Chat/ChatPage.container.tsx:
26 | msgid "Please, specify username to use chat"
27 | msgstr "Please, specify username to use chat"
28 |
29 | #: src/pages/Chat/ChatPage.tsx:
30 | msgid "Search..."
31 | msgstr "Search..."
32 |
33 | #: src/pages/Chat/ChatPage.tsx:
34 | msgid "Ooops..."
35 | msgstr "Ooops..."
36 |
37 | #: src/pages/Chat/ChatPage.tsx:
38 | msgid "Retry"
39 | msgstr "Retry"
40 |
41 | #: src/pages/Chat/ChatPage.tsx:
42 | msgid "Previous result"
43 | msgstr "Previous result"
44 |
45 | #: src/pages/Chat/ChatPage.tsx:
46 | msgid "{{ from }} of {{ to }}"
47 | msgstr "{{ from }} of {{ to }}"
48 |
49 | #: src/pages/Chat/ChatPage.tsx:
50 | msgid "Next result"
51 | msgstr "Next result"
52 |
53 | #: src/pages/Chat/ChatPage.tsx:
54 | msgid "Open search"
55 | msgstr "Open search"
56 |
57 | #: src/pages/Chat/ChatPage.tsx:
58 | msgid "Close search"
59 | msgstr "Close search"
60 |
61 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
62 | msgid "Message contents"
63 | msgstr "Message contents"
64 |
65 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
66 | msgid "Type a message..."
67 | msgstr "Type a message..."
68 |
69 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
70 | msgid "Send!"
71 | msgstr "Send!"
72 |
73 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
74 | msgid "Open image in a new tab"
75 | msgstr "Open image in a new tab"
76 |
77 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
78 | msgid "Image from the message"
79 | msgstr "Image from the message"
80 |
81 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
82 | msgid "Youtube player"
83 | msgstr "Youtube player"
84 |
85 | #: src/pages/NotFound/NotFound.tsx:
86 | msgid "Ooops... Page not found..."
87 | msgstr "Ooops... Page not found..."
88 |
89 | #: src/pages/NotFound/NotFound.tsx:
90 | msgid ""
91 | "The page you are looking for might have been removed, had its name changed, or "
92 | "is temporarily unavailable."
93 | msgstr ""
94 | "The page you are looking for might have been removed, had its name changed, or "
95 | "is temporarily unavailable."
96 |
97 | #: src/pages/NotFound/NotFound.tsx:
98 | msgid "Go home"
99 | msgstr "Go home"
100 |
101 | #: src/pages/Settings/SettingsPage.tsx:
102 | msgid "Username"
103 | msgstr "Username"
104 |
105 | #: src/pages/Settings/SettingsPage.tsx:
106 | msgid "Theme"
107 | msgstr "Theme"
108 |
109 | #: src/pages/Settings/SettingsPage.tsx:
110 | msgid "Light"
111 | msgstr "Light"
112 |
113 | #: src/pages/Settings/SettingsPage.tsx:
114 | msgid "Dark"
115 | msgstr "Dark"
116 |
117 | #: src/pages/Settings/SettingsPage.tsx:
118 | msgid "Clock display"
119 | msgstr "Clock display"
120 |
121 | #: src/pages/Settings/SettingsPage.tsx:
122 | msgid "12 hours"
123 | msgstr "12 hours"
124 |
125 | #: src/pages/Settings/SettingsPage.tsx:
126 | msgid "24 hours"
127 | msgstr "24 hours"
128 |
129 | #: src/pages/Settings/SettingsPage.tsx:
130 | msgid ""
131 | "Just for educational purposes. In a real-world application time format should "
132 | "be taken from the browser's locale."
133 | msgstr ""
134 | "Just for educational purposes. In a real-world application time format should "
135 | "be taken from the browser's locale."
136 |
137 | #: src/pages/Settings/SettingsPage.tsx:
138 | msgid "Send messages on CTRL + ENTER"
139 | msgstr "Send messages on CTRL + ENTER"
140 |
141 | #: src/pages/Settings/SettingsPage.tsx:
142 | msgid "On"
143 | msgstr "On"
144 |
145 | #: src/pages/Settings/SettingsPage.tsx:
146 | msgid "Off"
147 | msgstr "Off"
148 |
149 | #: src/pages/Settings/SettingsPage.tsx:
150 | msgid "Language"
151 | msgstr "Language"
152 |
153 | #: src/pages/Settings/SettingsPage.tsx:
154 | msgid "Reset to defaults"
155 | msgstr "Reset to defaults"
156 |
157 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
158 | msgid "Chat"
159 | msgstr "Chat"
160 |
161 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
162 | msgid "Settings"
163 | msgstr "Settings"
--------------------------------------------------------------------------------
/src/services/ChatService/ChatAdapter.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-function */
2 | import { EventEmitter } from "events";
3 | import { Socket } from "socket.io-client";
4 | import {
5 | fakeIncomingMessage,
6 | fakeTransformedMessage,
7 | userId,
8 | } from "./__fixtures__";
9 | import ChatAdapter from "./ChatAdapter";
10 |
11 | interface FakeSocketOptions {
12 | isOffline: boolean;
13 | emitResponse: Object;
14 | }
15 |
16 | const defaultFakeSocketOptions = {
17 | isOffline: false,
18 | emitResponse: {},
19 | };
20 |
21 | class FakeSocket extends EventEmitter {
22 | private options: FakeSocketOptions;
23 |
24 | public constructor(options: Partial = {}) {
25 | super();
26 | this.options = { ...defaultFakeSocketOptions, ...options };
27 | }
28 |
29 | public connect() {}
30 | public disconnect() {}
31 | public open() {}
32 | public close() {}
33 | public emit(eventName: string, ...args: unknown[]) {
34 | const cb = args.splice(-1)[0] as (response?: Object) => void;
35 | cb({
36 | res: this.options.emitResponse,
37 | });
38 | if (this.options.isOffline) {
39 | return true;
40 | }
41 | super.emit(eventName, ...args);
42 | return true;
43 | }
44 | }
45 |
46 | const createFakeAdapter = async (options?: Partial) => {
47 | const socketServer = (new FakeSocket(options) as unknown) as typeof Socket;
48 | const adapter = new ChatAdapter(socketServer, userId);
49 | await adapter.connect();
50 | return { socketServer, adapter };
51 | };
52 |
53 | it("should transform a message received from service to our schema", async () => {
54 | const { socketServer, adapter } = await createFakeAdapter();
55 | const onMessageSpy = jest.fn();
56 | adapter.onMessage(onMessageSpy);
57 |
58 | socketServer.emit("message", fakeIncomingMessage, () => {});
59 | expect(onMessageSpy).toBeCalledTimes(1);
60 | expect(onMessageSpy).toBeCalledWith(fakeTransformedMessage);
61 | });
62 |
63 | it("should transform a message send by me with type: outbox", async () => {
64 | const { socketServer, adapter } = await createFakeAdapter();
65 | const onMessageSpy = jest.fn();
66 | adapter.onMessage(onMessageSpy);
67 |
68 | socketServer.emit("message", { ...fakeIncomingMessage, userId }, () => {});
69 | expect(onMessageSpy).toBeCalledTimes(1);
70 | expect(onMessageSpy).toBeCalledWith({
71 | ...fakeTransformedMessage,
72 | type: "outbox",
73 | });
74 | });
75 |
76 | it("should send a chat Message", async () => {
77 | const { socketServer, adapter } = await createFakeAdapter();
78 | const onServerMessageSpy = jest.fn();
79 | socketServer.on("message", onServerMessageSpy);
80 |
81 | await adapter.emitMessage({
82 | text: "foo",
83 | username: "bar",
84 | });
85 | expect(onServerMessageSpy).toBeCalledTimes(1);
86 | expect(onServerMessageSpy).toBeCalledWith({
87 | id: expect.any(String),
88 | text: "foo",
89 | username: "bar",
90 | userId,
91 | status: "none",
92 | });
93 | });
94 |
95 | it("should send two messages with different ids", async () => {
96 | const { socketServer, adapter } = await createFakeAdapter();
97 | const onServerMessageSpy = jest.fn();
98 | socketServer.on("message", onServerMessageSpy);
99 |
100 | await adapter.emitMessage({
101 | text: "foo",
102 | username: "bar",
103 | });
104 | await adapter.emitMessage({
105 | text: "foo",
106 | username: "bar",
107 | });
108 | expect(onServerMessageSpy).toBeCalledTimes(2);
109 | expect(onServerMessageSpy).toHaveBeenNthCalledWith(
110 | 2,
111 | expect.objectContaining({
112 | id: expect.not.stringMatching(onServerMessageSpy.mock.calls[0][0].id),
113 | })
114 | );
115 | });
116 |
117 | it("should have an optimistic UI update", async () => {
118 | const { adapter } = await createFakeAdapter({ isOffline: true });
119 | const onMessageSpy = jest.fn();
120 | adapter.onMessage(onMessageSpy);
121 |
122 | adapter.emitMessage({
123 | text: "foo",
124 | username: "bar",
125 | });
126 | expect(onMessageSpy).toBeCalledTimes(1);
127 | expect(onMessageSpy).toBeCalledWith({
128 | id: expect.any(String),
129 | createdAt: expect.any(Date),
130 | type: "outbox",
131 | text: "foo",
132 | username: "bar",
133 | userId,
134 | status: "none",
135 | });
136 | });
137 |
138 | it("should listMessages and transform them", async () => {
139 | const { adapter } = await createFakeAdapter({
140 | emitResponse: {
141 | items: [fakeIncomingMessage],
142 | },
143 | });
144 |
145 | const messages = await adapter.emitListMessages();
146 |
147 | expect(messages.items).toEqual([fakeTransformedMessage]);
148 | });
149 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatMessage/ChatMessage.container.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import ChatMessage, { ChatMessageProps } from "./ChatMessage";
3 | import AwaitLock from "await-lock";
4 |
5 | interface ChatMessageContainerProps extends ChatMessageProps {
6 | text: string;
7 | }
8 |
9 | interface ChatMessageContainerState {
10 | imageSrc?: string;
11 | youtubeId?: string;
12 | }
13 |
14 | const linkRegexp = /(https?:\/\/[\w-\.\/\:\?\=\&]+)/gi;
15 | const imageContentTypeRegexp = /^image\//;
16 | // https://gist.github.com/afeld/1254889
17 | const youtubeIdRegexp = /(youtu\.be\/|youtube\.com\/(watch\?(.*&)?v=|(embed|v)\/))([^\?&"'>]+)/;
18 |
19 | class ChatMessageContainer extends PureComponent<
20 | ChatMessageContainerProps,
21 | ChatMessageContainerState
22 | > {
23 | public state: ChatMessageContainerState = {};
24 | public isImageLinkCache: { [link: string]: boolean } = {};
25 | public isImageLinkLock: AwaitLock = new AwaitLock();
26 | public isUnmounted = false;
27 |
28 | public componentDidMount(): void {
29 | const links = this.getLinks(this.props.text);
30 |
31 | void this.checkLinksForImages(links);
32 | void this.checkLinksForYoutubeVideos(links);
33 | }
34 |
35 | public componentWillUnmount(): void {
36 | this.isUnmounted = true;
37 | }
38 |
39 | render(): React.ReactNode {
40 | return (
41 |
47 | );
48 | }
49 |
50 | private getText = (): React.ReactNode => {
51 | const { text } = this.props;
52 | const links: string[] = [];
53 | // https://stackoverflow.com/a/33238464
54 | const parts: (string | JSX.Element)[] = text.split(linkRegexp);
55 | for (let i = 1; i < parts.length; i += 2) {
56 | links.push(parts[i] as string);
57 | parts[i] = (
58 |
59 | {parts[i]}
60 |
61 | );
62 | }
63 |
64 | return parts;
65 | };
66 |
67 | private getLinks = (text: string): string[] => {
68 | const links: string[] = [];
69 | // https://stackoverflow.com/a/33238464
70 | const parts: (string | JSX.Element)[] = text.split(linkRegexp);
71 | for (let i = 1; i < parts.length; i += 2) {
72 | links.push(parts[i] as string);
73 | }
74 |
75 | return links;
76 | };
77 |
78 | private checkLinksForYoutubeVideos = (links: string[]): void => {
79 | for (const link of links) {
80 | if (this.isUnmounted) {
81 | return;
82 | }
83 | const youtubeId = this.getYoutubeId(link);
84 | if (youtubeId) {
85 | this.setState({
86 | youtubeId: youtubeId,
87 | });
88 | return;
89 | }
90 | }
91 | };
92 |
93 | private getYoutubeId = (link: string): string | undefined => {
94 | const match = link.match(youtubeIdRegexp);
95 | return match ? match[5] : undefined;
96 | };
97 |
98 | private checkLinksForImages = async (links: string[]): Promise => {
99 | for (const link of links) {
100 | if (this.isUnmounted) {
101 | return;
102 | }
103 | const isImageLink = await this.isImageLink(link);
104 | if (isImageLink) {
105 | this.setState({
106 | imageSrc: link,
107 | });
108 | return;
109 | }
110 | }
111 | };
112 |
113 | private async isImageLink(link: string): Promise {
114 | await this.isImageLinkLock.acquireAsync();
115 | try {
116 | if (this.isImageLinkCache[link]) {
117 | return this.isImageLinkCache[link];
118 | }
119 |
120 | const headResponse: Response = await window.fetch(link, {
121 | method: "HEAD",
122 | });
123 | if (headResponse.status === 301 || headResponse.status === 302) {
124 | const location = headResponse.headers.get("Location");
125 | if (location) {
126 | const result = await this.isImageLink(location);
127 | this.isImageLinkCache[link] = result;
128 | return result;
129 | }
130 | return false;
131 | }
132 |
133 | const contentType = headResponse.headers.get("Content-Type");
134 | const result = imageContentTypeRegexp.test(contentType || "");
135 |
136 | this.isImageLinkCache[link] = result;
137 | return result;
138 | } catch {
139 | // Ideally we need to analyze the exception and in some cases (e.g. Network) we should not cache it
140 | // And in some cases (e.g. CORS error) we should cache it
141 | this.isImageLinkCache[link] = false;
142 | return false;
143 | } finally {
144 | this.isImageLinkLock.release();
145 | }
146 | }
147 | }
148 |
149 | export default ChatMessageContainer;
150 |
--------------------------------------------------------------------------------
/src/pages/Settings/SettingsPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./SettingsPage.css";
3 | import cn from "clsx";
4 | import Button from "src/components/ui-kit/Button";
5 | import Input from "src/components/ui-kit/Input";
6 | import { T } from "react-targem";
7 | import { Locale, locales } from "src/config/locales";
8 | import RadioGroup from "src/components/ui-kit/RadioGroup";
9 | import { ThemeName } from "src/config/themes";
10 | import Typography from "src/components/ui-kit/Typography";
11 |
12 | interface SettingsPageProps {
13 | username?: string;
14 | locale: Locale;
15 | theme: ThemeName;
16 | is12hours: boolean;
17 | isCtrlEnterToSend: boolean;
18 | onResetDefaultClick: () => void;
19 | onUsernameChange: (username: string) => void;
20 | onLocaleChange: (locale: Locale) => void;
21 | onThemeChange: (theme: ThemeName) => void;
22 | onIs12hoursChange: (is12hours: boolean) => void;
23 | onIsCtrlEnterToSend: (isCtrlEnterToSend: boolean) => void;
24 | }
25 |
26 | class SettingsPage extends PureComponent {
27 | render(): React.ReactNode {
28 | const { username, locale } = this.props;
29 |
30 | return (
31 |
32 |
33 | }
36 | id="username"
37 | component="input"
38 | type="text"
39 | onChange={this.handleUsernameChange}
40 | value={username}
41 | />
42 | }
46 | onChange={this.props.onThemeChange}
47 | value={this.props.theme}
48 | options={[
49 | {
50 | text: ,
51 | value: "default",
52 | },
53 | {
54 | text: ,
55 | value: "dark",
56 | },
57 | ]}
58 | />
59 | }
63 | onChange={this.props.onIs12hoursChange}
64 | value={this.props.is12hours}
65 | options={[
66 | {
67 | text: ,
68 | value: true,
69 | },
70 | {
71 | text: ,
72 | value: false,
73 | },
74 | ]}
75 | />
76 |
77 |
78 |
79 | }
83 | onChange={this.props.onIsCtrlEnterToSend}
84 | value={this.props.isCtrlEnterToSend}
85 | options={[
86 | {
87 | text: ,
88 | value: true,
89 | },
90 | {
91 | text: ,
92 | value: false,
93 | },
94 | ]}
95 | />
96 | }
99 | id="language"
100 | component="select"
101 | onChange={this.handleLocaleChange}
102 | value={locale}
103 | >
104 | {locales.map((l) => (
105 |
111 | ))}
112 |
113 |
114 |
121 |
122 | );
123 | }
124 |
125 | private handleUsernameChange = (
126 | event: React.ChangeEvent
127 | ) => {
128 | this.props.onUsernameChange(event.target.value);
129 | };
130 |
131 | private handleLocaleChange = (
132 | event: React.ChangeEvent
133 | ) => {
134 | this.props.onLocaleChange(event.target.value as Locale);
135 | };
136 | }
137 |
138 | export default SettingsPage;
139 |
--------------------------------------------------------------------------------
/src/i18n/ru.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: sample-chat-frontend 1.0.6\n"
4 | "Content-Type: text/plain; charset=utf-8\n"
5 | "POT-Creation-Date: Wed Jun 10 2020 08:42:40 GMT+0300 (Israel Daylight Time)\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
8 | "&& (n%100<12 || n%100>14) ? 1 : 2);\n"
9 | "PO-Revision-Date: \n"
10 | "Language-Team: \n"
11 | "MIME-Version: 1.0\n"
12 | "X-Generator: Poedit 2.4.2\n"
13 | "Last-Translator: \n"
14 | "Language: ru\n"
15 |
16 | #: src/components/ui-kit/Loading/Loading.tsx:
17 | msgid "Loading"
18 | msgstr "Загрузка"
19 |
20 | #: src/contexts/ChatContext/ChatContext.tsx:
21 | msgid "New message is received!"
22 | msgid_plural "{{ count }} new messages are received!"
23 | msgstr[0] "Получено новое сообщение!"
24 | msgstr[1] "Получено {{ count }} новых сообщения!"
25 | msgstr[2] "Получено {{ count }} новых сообщений!"
26 |
27 | #: src/pages/Chat/ChatPage.container.tsx:
28 | msgid "Please, specify username to use chat"
29 | msgstr "Пожалуйста, укажите имя пользователя, чтобы пользоваться чатом"
30 |
31 | #: src/pages/Chat/ChatPage.tsx:
32 | msgid "Search..."
33 | msgstr "Поиск…"
34 |
35 | #: src/pages/Chat/ChatPage.tsx:
36 | msgid "Ooops..."
37 | msgstr "Ууупс…"
38 |
39 | #: src/pages/Chat/ChatPage.tsx:
40 | msgid "Retry"
41 | msgstr "Ещё раз"
42 |
43 | #: src/pages/Chat/ChatPage.tsx:
44 | msgid "Previous result"
45 | msgstr "Предыдущий результат"
46 |
47 | #: src/pages/Chat/ChatPage.tsx:
48 | msgid "{{ from }} of {{ to }}"
49 | msgstr "{{ from }} из {{ to }}"
50 |
51 | #: src/pages/Chat/ChatPage.tsx:
52 | msgid "Next result"
53 | msgstr "Следующий результат"
54 |
55 | #: src/pages/Chat/ChatPage.tsx:
56 | msgid "Open search"
57 | msgstr "Открыть поиск"
58 |
59 | #: src/pages/Chat/ChatPage.tsx:
60 | msgid "Close search"
61 | msgstr "Закрыть поиск"
62 |
63 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
64 | msgid "Message contents"
65 | msgstr "Тело письма"
66 |
67 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
68 | msgid "Type a message..."
69 | msgstr "Введите сообщениe…"
70 |
71 | #: src/pages/Chat/components/ChatInput/ChatInput.tsx:
72 | msgid "Send!"
73 | msgstr "Отправить!"
74 |
75 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
76 | msgid "Open image in a new tab"
77 | msgstr "Открыть изображение в новой вкладке"
78 |
79 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
80 | msgid "Image from the message"
81 | msgstr "Изображение из сообщения"
82 |
83 | #: src/pages/Chat/components/ChatMessage/ChatMessage.tsx:
84 | msgid "Youtube player"
85 | msgstr "Плеер Youtube"
86 |
87 | #: src/pages/NotFound/NotFound.tsx:
88 | msgid "Ooops... Page not found..."
89 | msgstr "Уууупс… Страница не найдена..."
90 |
91 | #: src/pages/NotFound/NotFound.tsx:
92 | msgid ""
93 | "The page you are looking for might have been removed, had its name changed, or "
94 | "is temporarily unavailable."
95 | msgstr ""
96 | "Возможно, страница, которую вы открыли, была удалена, переименована, или она "
97 | "временно недоступна."
98 |
99 | #: src/pages/NotFound/NotFound.tsx:
100 | msgid "Go home"
101 | msgstr "Вернуться домой"
102 |
103 | #: src/pages/Settings/SettingsPage.tsx:
104 | msgid "Username"
105 | msgstr "Имя пользователя"
106 |
107 | #: src/pages/Settings/SettingsPage.tsx:
108 | msgid "Theme"
109 | msgstr "Тема"
110 |
111 | #: src/pages/Settings/SettingsPage.tsx:
112 | msgid "Light"
113 | msgstr "Светлая"
114 |
115 | #: src/pages/Settings/SettingsPage.tsx:
116 | msgid "Dark"
117 | msgstr "Тёмная"
118 |
119 | #: src/pages/Settings/SettingsPage.tsx:
120 | msgid "Clock display"
121 | msgstr "Отображение времени"
122 |
123 | #: src/pages/Settings/SettingsPage.tsx:
124 | msgid "12 hours"
125 | msgstr "12 часов"
126 |
127 | #: src/pages/Settings/SettingsPage.tsx:
128 | msgid "24 hours"
129 | msgstr "24 часа"
130 |
131 | #: src/pages/Settings/SettingsPage.tsx:
132 | msgid ""
133 | "Just for educational purposes. In a real-world application time format should "
134 | "be taken from the browser's locale."
135 | msgstr ""
136 | "Только в образовательных целях. В настоящем приложении часовой формат должен "
137 | "быть взять автоматически из региона браузера."
138 |
139 | #: src/pages/Settings/SettingsPage.tsx:
140 | msgid "Send messages on CTRL + ENTER"
141 | msgstr "Отправить сообщения по CTRL + ENTER"
142 |
143 | #: src/pages/Settings/SettingsPage.tsx:
144 | msgid "On"
145 | msgstr "Вкл"
146 |
147 | #: src/pages/Settings/SettingsPage.tsx:
148 | msgid "Off"
149 | msgstr "Выкл"
150 |
151 | #: src/pages/Settings/SettingsPage.tsx:
152 | msgid "Language"
153 | msgstr "Язык"
154 |
155 | #: src/pages/Settings/SettingsPage.tsx:
156 | msgid "Reset to defaults"
157 | msgstr "Сбросить настройки"
158 |
159 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
160 | msgid "Chat"
161 | msgstr ""
162 |
163 | #: src/wrappers/BodyWrapper/BodyWrapper.tsx:
164 | msgid "Settings"
165 | msgstr ""
166 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sample-chat-frontend",
3 | "version": "1.1.0",
4 | "description": "A sample real-time chat built on React",
5 | "private": true,
6 | "engines": {
7 | "npm": ">=6.14.0"
8 | },
9 | "scripts": {
10 | "start": "webpack-dev-server --config config/webpack/config.webpack.development.js",
11 | "start:local": "cross-env DOTENV=.env.local webpack-dev-server --config config/webpack/config.webpack.development.js",
12 | "storybook": "start-storybook",
13 | "build:dev": "webpack --config config/webpack/config.webpack.development.js && es-check es5 './dist/**/*.js'",
14 | "build:prod": "webpack --config config/webpack/config.webpack.production.js && es-check es5 './dist/**/*.js'",
15 | "build:storybook": "build-storybook",
16 | "serve:app": "ws -d dist/ --spa index.html --https",
17 | "serve:storybook": "ws -d storybook-static/ --spa index.html --https",
18 | "test": "jest",
19 | "lint:check": "npm run lint:prettier:check . && npm run lint:tsc:check && npm run lint:eslint:check . && npm run lint:stylelint:check .",
20 | "lint:write": "npm run lint:prettier:write . && npm run lint:eslint:write . && npm run lint:stylelint:write .",
21 | "lint:prettier:check": "prettier --check --ignore-path .gitignore",
22 | "lint:prettier:write": "prettier --write --ignore-path .gitignore",
23 | "lint:tsc:check": "tsc --noEmit",
24 | "lint:eslint:check": "eslint --ext .ts,.tsx --ignore-path .gitignore",
25 | "lint:eslint:write": "eslint --ext .ts,.tsx --ignore-path .gitignore --fix",
26 | "lint:stylelint:check": "stylelint --ignore-path .gitignore",
27 | "lint:stylelint:write": "stylelint --fix --ignore-path .gitignore",
28 | "intl:update-po": "gettext-utils export-strings --default-locale=en-GB && git add src/i18n/*.po*",
29 | "intl:generate-json": "gettext-utils import-strings"
30 | },
31 | "repository": {
32 | "type": "git",
33 | "url": "git+ssh://git@github.com/goooseman/sample-chat-frontend.git"
34 | },
35 | "keywords": [
36 | "react",
37 | "chat",
38 | "socket.io",
39 | "cdd",
40 | "tdd"
41 | ],
42 | "author": "goooseman",
43 | "license": "ISC",
44 | "bugs": {
45 | "url": "https://github.com/goooseman/sample-chat-frontend/issues"
46 | },
47 | "homepage": "https://github.com/goooseman/sample-chat-frontend#readme",
48 | "dependencies": {
49 | "@babel/core": "^7.10.2",
50 | "@babel/preset-env": "^7.10.2",
51 | "@fortawesome/fontawesome-svg-core": "^1.2.28",
52 | "@fortawesome/free-solid-svg-icons": "^5.13.0",
53 | "@fortawesome/react-fontawesome": "^0.1.10",
54 | "autoprefixer": "^9.8.0",
55 | "await-lock": "^2.0.1",
56 | "babel-loader": "^8.1.0",
57 | "clsx": "^1.1.1",
58 | "copy-webpack-plugin": "^6.0.2",
59 | "css-loader": "^3.6.0",
60 | "cssnano": "^4.1.10",
61 | "debug-es5": "^4.1.0",
62 | "dotenv-webpack": "^1.8.0",
63 | "es-check": "^5.1.0",
64 | "file-loader": "^6.0.0",
65 | "gettext-utils": "^2.2.0",
66 | "html-webpack-plugin": "^4.3.0",
67 | "mini-css-extract-plugin": "^0.9.0",
68 | "postcss-custom-media": "^7.0.8",
69 | "postcss-custom-properties": "^9.1.1",
70 | "postcss-loader": "^3.0.0",
71 | "postcss-normalize": "^9.0.0",
72 | "react": "^16.13.1",
73 | "react-dom": "^16.13.1",
74 | "react-router-dom": "^5.2.0",
75 | "react-targem": "^1.7.1",
76 | "react-youtube": "~7.10.0",
77 | "socket.io-client": "^2.3.0",
78 | "ts-loader": "^7.0.5",
79 | "typescript": "^3.9.5",
80 | "uuid": "^8.1.0",
81 | "webpack": "^4.43.0",
82 | "webpack-cli": "^3.3.11",
83 | "webpack-shell-plugin-next": "^1.1.9"
84 | },
85 | "devDependencies": {
86 | "@commitlint/cli": "^8.3.5",
87 | "@commitlint/config-conventional": "^8.3.4",
88 | "@storybook/addon-a11y": "^5.3.19",
89 | "@storybook/addon-actions": "^5.3.19",
90 | "@storybook/addon-console": "^1.2.1",
91 | "@storybook/addon-contexts": "^5.3.19",
92 | "@storybook/addon-docs": "^5.3.19",
93 | "@storybook/addon-storysource": "^5.3.19",
94 | "@storybook/addon-viewport": "^5.3.19",
95 | "@storybook/react": "^5.3.19",
96 | "@testing-library/jest-dom": "^5.10.1",
97 | "@testing-library/react": "^10.2.1",
98 | "@testing-library/user-event": "^12.8.1",
99 | "@types/jest": "^26.0.0",
100 | "@types/react": "^16.9.36",
101 | "@types/react-dom": "^16.9.8",
102 | "@types/react-router-dom": "^5.1.5",
103 | "@types/socket.io-client": "^1.4.33",
104 | "@types/uuid": "^8.0.0",
105 | "@types/youtube": "0.0.38",
106 | "@typescript-eslint/eslint-plugin": "^3.3.0",
107 | "@typescript-eslint/parser": "^3.3.0",
108 | "canvas": "^2.6.1",
109 | "cross-env": "^7.0.2",
110 | "eslint": "^7.2.0",
111 | "eslint-config-prettier": "^6.11.0",
112 | "eslint-plugin-jest-dom": "^3.0.0",
113 | "eslint-plugin-prettier": "^3.1.4",
114 | "eslint-plugin-react": "^7.20.0",
115 | "husky": "^4.2.5",
116 | "identity-obj-proxy": "^3.0.0",
117 | "jest": "^26.0.1",
118 | "lint-staged": "^10.2.10",
119 | "local-web-server": "^4.2.1",
120 | "nock": "^12.0.3",
121 | "node-fetch": "^2.6.0",
122 | "prettier": "^2.0.5",
123 | "react-docgen-typescript-loader": "^3.7.2",
124 | "stylelint": "^13.6.0",
125 | "stylelint-config-css-modules": "^2.2.0",
126 | "stylelint-config-idiomatic-order": "^8.1.0",
127 | "stylelint-config-prettier": "^8.0.1",
128 | "stylelint-config-standard": "^20.0.0",
129 | "ts-jest": "^26.1.0",
130 | "webpack-dev-server": "^3.11.0"
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/pages/Chat/ChatPage.container.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ChatPageContainer from "./ChatPage.container";
3 | import { render, screen } from "__utils__/renderWithRouter";
4 | import { withRouter } from "react-router-dom";
5 | import userEvent from "@testing-library/user-event";
6 | import { fakeTransformedMessage } from "src/services/ChatService/__fixtures__";
7 |
8 | const Container = withRouter(ChatPageContainer);
9 |
10 | let originalAlert: (message?: unknown) => void;
11 | beforeEach(() => {
12 | originalAlert = window.alert;
13 | Object.defineProperty(window, "alert", {
14 | value: jest.fn(),
15 | writable: true,
16 | });
17 | });
18 |
19 | afterEach(() => {
20 | Object.defineProperty(window, "alert", {
21 | value: originalAlert,
22 | writable: true,
23 | });
24 | });
25 |
26 | it("should not redirect to /settings", () => {
27 | const { history } = render();
28 | expect(history.location.pathname).toBe("/");
29 | });
30 |
31 | it("should redirect to /settings if no username is provided", () => {
32 | const { history } = render();
33 | expect(history.location.pathname).toBe("/settings");
34 | });
35 |
36 | it("should redirect to /settings if username is empty", () => {
37 | const { history } = render();
38 | expect(history.location.pathname).toBe("/settings");
39 | });
40 |
41 | it("should call markAllAdRead when mounted", () => {
42 | const markAllAsReadSpy = jest.fn();
43 | render();
44 | expect(markAllAsReadSpy).toBeCalledTimes(1);
45 | });
46 |
47 | it("should show error alert if username is empty", () => {
48 | render();
49 | expect(window.alert).toBeCalledTimes(1);
50 | });
51 |
52 | it("should open search when search icon is clicked", () => {
53 | render();
54 | userEvent.click(screen.getByLabelText("Open search"));
55 | expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
56 | userEvent.click(screen.getByLabelText("Close search"));
57 | expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument();
58 | });
59 |
60 | it("should show loading indicator while searching", () => {
61 | render();
62 | userEvent.click(screen.getByLabelText("Open search"));
63 | userEvent.type(screen.getByPlaceholderText("Search..."), "foo");
64 | expect(screen.getByLabelText("Loading")).toBeInTheDocument();
65 | });
66 |
67 | const searchMessages = [
68 | { ...fakeTransformedMessage, text: "One two three", id: "1" },
69 | { ...fakeTransformedMessage, text: "Three two one", id: "2" },
70 | ];
71 | const searchString = "two";
72 | const searchResults = [
73 | {
74 | id: "1",
75 | matches: ["two"],
76 | },
77 | {
78 | id: "2",
79 | matches: ["two"],
80 | },
81 | ];
82 |
83 | it("should show 1 of 2 when search is completed", async () => {
84 | const searchMessageSpy = jest.fn().mockImplementation(() => {
85 | return Promise.resolve(searchResults);
86 | });
87 | render(
88 |
93 | );
94 | userEvent.click(screen.getByLabelText("Open search"));
95 | userEvent.type(screen.getByPlaceholderText("Search..."), searchString);
96 | expect(searchMessageSpy).toBeCalled();
97 | expect(await screen.findByText("1 of 2")).toBeInTheDocument();
98 | expect(screen.getByLabelText("Previous result")).toBeDisabled();
99 | userEvent.click(screen.getByLabelText("Next result"));
100 | expect(await screen.findByText("2 of 2")).toBeInTheDocument();
101 | expect(screen.getByLabelText("Next result")).toBeDisabled();
102 | });
103 |
104 | it("should show 0 of 0 when search is empty", async () => {
105 | const searchMessageSpy = jest.fn().mockImplementation(() => {
106 | return Promise.resolve([]);
107 | });
108 | render(
109 |
114 | );
115 | userEvent.click(screen.getByLabelText("Open search"));
116 | userEvent.type(screen.getByPlaceholderText("Search..."), searchString);
117 | expect(searchMessageSpy).toBeCalled();
118 | expect(await screen.findByText("0 of 0")).toBeInTheDocument();
119 | expect(screen.getByLabelText("Previous result")).toBeDisabled();
120 | expect(screen.getByLabelText("Next result")).toBeDisabled();
121 | });
122 |
123 | it("should show a retry button if fails", async () => {
124 | const searchMessageSpy = jest.fn().mockImplementation(() => {
125 | return Promise.reject(new Error("Powerfull search engine is down"));
126 | });
127 | render(
128 |
133 | );
134 | userEvent.click(screen.getByLabelText("Open search"));
135 | userEvent.type(screen.getByPlaceholderText("Search..."), searchString);
136 | expect(await screen.findByText("Ooops...")).toBeInTheDocument();
137 | searchMessageSpy.mockImplementation(() => {
138 | return Promise.resolve(searchResults);
139 | });
140 | userEvent.click(screen.getByLabelText("Retry"));
141 | expect(await screen.findByText("1 of 2")).toBeInTheDocument();
142 | });
143 |
144 | it("should empty searchQuery when closed", async () => {
145 | render();
146 | userEvent.click(screen.getByLabelText("Open search"));
147 | userEvent.type(screen.getByPlaceholderText("Search..."), searchString);
148 | userEvent.click(screen.getByLabelText("Close search"));
149 | userEvent.click(screen.getByLabelText("Open search"));
150 | expect(screen.getByPlaceholderText("Search...")).toHaveValue("");
151 | });
152 |
--------------------------------------------------------------------------------
/src/services/ChatService/ChatService.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 | import ChatService, { ChatMessage } from "./ChatService";
3 | import ChatAdapter from "./ChatAdapter";
4 | import { fakeTransformedMessage } from "./__fixtures__";
5 |
6 | const setupService = (
7 | options: {
8 | emitListMessagesRes?: unknown;
9 | } = {}
10 | ) => {
11 | const onMessagesListChangeSpy = jest.fn();
12 |
13 | // @ts-ignore
14 | const fakeAdapter: ChatAdapter = {
15 | connect: jest.fn(),
16 | disconnect: jest.fn(),
17 | onMessage: jest.fn(),
18 | emitMessage: jest.fn(),
19 | emitListMessages: jest.fn().mockImplementation(() => {
20 | return Promise.resolve({
21 | items: options.emitListMessagesRes || [],
22 | });
23 | }),
24 | };
25 | const chatService = new ChatService(fakeAdapter);
26 | chatService.onMessagesListChange(onMessagesListChangeSpy);
27 | // @ts-ignore
28 | const emitMessage = fakeAdapter.onMessage.mock.calls[0][0] as (
29 | message: ChatMessage
30 | ) => void;
31 |
32 | return { chatService, emitMessage, fakeAdapter, onMessagesListChangeSpy };
33 | };
34 |
35 | it("should connect", async () => {
36 | const { chatService, fakeAdapter } = setupService();
37 | await chatService.connect();
38 | expect(fakeAdapter.connect).toBeCalledTimes(1);
39 | expect(fakeAdapter.emitListMessages).toBeCalledTimes(1);
40 | });
41 |
42 | it("should disconnect", async () => {
43 | const { chatService, fakeAdapter } = setupService();
44 | await chatService.disconnect();
45 | expect(fakeAdapter.disconnect).toBeCalledTimes(1);
46 | });
47 |
48 | it("should proceed initial messages", async () => {
49 | const { chatService, onMessagesListChangeSpy } = setupService({
50 | emitListMessagesRes: [fakeTransformedMessage],
51 | });
52 | await chatService.connect();
53 |
54 | expect(onMessagesListChangeSpy).toBeCalledTimes(1);
55 | expect(onMessagesListChangeSpy).toHaveBeenLastCalledWith({
56 | messages: [fakeTransformedMessage],
57 | count: 1,
58 | unreadCount: 0,
59 | });
60 | });
61 |
62 | it("should send chat message", async () => {
63 | const { chatService, fakeAdapter } = setupService();
64 | const message = { text: "foo", username: "bar" };
65 | await chatService.sendMessage(message);
66 | expect(fakeAdapter.onMessage).toBeCalledTimes(1);
67 | expect(fakeAdapter.emitMessage).toBeCalledWith(message);
68 | });
69 |
70 | it("should call cb after new message is recieved", () => {
71 | const { onMessagesListChangeSpy, emitMessage } = setupService();
72 |
73 | emitMessage(fakeTransformedMessage);
74 |
75 | expect(onMessagesListChangeSpy).toBeCalledWith(
76 | expect.objectContaining({
77 | messages: [fakeTransformedMessage],
78 | count: 1,
79 | })
80 | );
81 | });
82 |
83 | it("should not add two messages twice (usually used for Optimistic UI updates)", () => {
84 | const { emitMessage, onMessagesListChangeSpy } = setupService();
85 |
86 | emitMessage({ ...fakeTransformedMessage, status: "none" });
87 | emitMessage(fakeTransformedMessage);
88 |
89 | expect(onMessagesListChangeSpy).toBeCalledTimes(2);
90 | expect(onMessagesListChangeSpy).toHaveBeenLastCalledWith(
91 | expect.objectContaining({
92 | messages: [fakeTransformedMessage],
93 | count: 1,
94 | })
95 | );
96 | });
97 |
98 | it("should return a new array every time (not to mutate original one)", () => {
99 | const { onMessagesListChangeSpy, emitMessage } = setupService();
100 |
101 | emitMessage(fakeTransformedMessage);
102 |
103 | const array1 = onMessagesListChangeSpy.mock.calls[0][0];
104 |
105 | emitMessage({ ...fakeTransformedMessage, id: "other" });
106 |
107 | expect(onMessagesListChangeSpy).toBeCalledTimes(2);
108 | expect(onMessagesListChangeSpy.mock.calls[1][0]).not.toBe(array1);
109 | });
110 |
111 | it("should clear unreadCount when markAllAsRead is used", () => {
112 | const { onMessagesListChangeSpy, emitMessage, chatService } = setupService();
113 |
114 | emitMessage(fakeTransformedMessage);
115 | chatService.markAllAsRead();
116 |
117 | expect(onMessagesListChangeSpy).toHaveBeenLastCalledWith(
118 | expect.objectContaining({
119 | unreadCount: 0,
120 | })
121 | );
122 | });
123 |
124 | describe("search", () => {
125 | it("should return found messages", async () => {
126 | const { chatService } = setupService({
127 | emitListMessagesRes: [
128 | { ...fakeTransformedMessage, text: "A very very informative message!" },
129 | {
130 | ...fakeTransformedMessage,
131 | text: "Vary informative message",
132 | id: "2",
133 | },
134 | ],
135 | });
136 | await chatService.connect();
137 |
138 | const searchResult = await chatService.search("very");
139 | expect(searchResult).toEqual([{ id: "1", matches: ["very", "very"] }]);
140 | });
141 |
142 | it("should return empty array in case of no matches", async () => {
143 | const { chatService } = setupService({
144 | emitListMessagesRes: [
145 | { ...fakeTransformedMessage, text: "A very very informative message!" },
146 | {
147 | ...fakeTransformedMessage,
148 | text: "Vary informative message",
149 | id: "2",
150 | },
151 | ],
152 | });
153 | await chatService.connect();
154 |
155 | const searchResult = await chatService.search("foo");
156 | expect(searchResult).toEqual([]);
157 | });
158 |
159 | it("should search case insensitive", async () => {
160 | const { chatService } = setupService({
161 | emitListMessagesRes: [
162 | { ...fakeTransformedMessage, text: "A very very informative message!" },
163 | {
164 | ...fakeTransformedMessage,
165 | text: "Vary informative message",
166 | id: "2",
167 | },
168 | ],
169 | });
170 | await chatService.connect();
171 |
172 | const searchResult = await chatService.search("Very");
173 | expect(searchResult).toEqual([{ id: "1", matches: ["very", "very"] }]);
174 | });
175 |
176 | it("should return nothing for empty query", async () => {
177 | const { chatService } = setupService({
178 | emitListMessagesRes: [
179 | { ...fakeTransformedMessage, text: "A very very informative message!" },
180 | {
181 | ...fakeTransformedMessage,
182 | text: "Vary informative message",
183 | id: "2",
184 | },
185 | ],
186 | });
187 | await chatService.connect();
188 |
189 | const searchResult = await chatService.search("");
190 | expect(searchResult).toEqual([]);
191 | });
192 | });
193 |
--------------------------------------------------------------------------------
/src/pages/Chat/components/ChatMessage/ChatMessage.container.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ChatMessageContainer from "./ChatMessage.container";
3 | import { render } from "__utils__/render";
4 | import nock from "nock";
5 |
6 | const defaultMessage = {
7 | text: "Yo!",
8 | type: "inbox",
9 | username: "goooseman",
10 | createdAt: new Date(),
11 | status: "none",
12 | onLoad: jest.fn(),
13 | isCurrentSearch: false,
14 | } as const;
15 |
16 | jest.setTimeout(10 * 1000);
17 |
18 | it("should find links automatically", () => {
19 | const textWithLink = "Find it out on Google: https://google.com!";
20 | const { getByText } = render(
21 |
22 | );
23 | expect(getByText("https://google.com")).toHaveAttribute(
24 | "href",
25 | "https://google.com"
26 | );
27 | });
28 |
29 | it("should find links in brackets automatically", () => {
30 | const textWithLink = "Find it out on Google: (https://google.com!)";
31 | const { getByText } = render(
32 |
33 | );
34 | expect(getByText("https://google.com")).toHaveAttribute(
35 | "href",
36 | "https://google.com"
37 | );
38 | });
39 |
40 | it("should find links with three subdomains automatically", () => {
41 | const textWithLink = "Find it out on Google: https://docs.google.com!";
42 | const { getByText } = render(
43 |
44 | );
45 | expect(getByText("https://docs.google.com")).toHaveAttribute(
46 | "href",
47 | "https://docs.google.com"
48 | );
49 | });
50 |
51 | it("should find links with path automatically", () => {
52 | const textWithLink =
53 | "Find it out on Google: https://google.com/page?foo=bar&time=am!";
54 | const { getByText } = render(
55 |
56 | );
57 | expect(getByText("https://google.com/page?foo=bar&time=am")).toHaveAttribute(
58 | "href",
59 | "https://google.com/page?foo=bar&time=am"
60 | );
61 | });
62 |
63 | it("should find links with port number automatically", () => {
64 | const textWithLink = "Find it out on Google: http://google.com:80!";
65 | const { getByText } = render(
66 |
67 | );
68 | expect(getByText("http://google.com:80")).toHaveAttribute(
69 | "href",
70 | "http://google.com:80"
71 | );
72 | });
73 |
74 | it("should find links with http proto automatically", () => {
75 | const textWithLink = "Find it out on Google: http://google.com!";
76 | const { getByText } = render(
77 |
78 | );
79 | expect(getByText("http://google.com")).toHaveAttribute(
80 | "href",
81 | "http://google.com"
82 | );
83 | });
84 |
85 | describe("Images", () => {
86 | afterEach(nock.restore);
87 |
88 | it("should find first image in the text automatically", async () => {
89 | nock("https://images.unsplash.com")
90 | .head("/photo-1542692810-396766644d8c")
91 | .reply(200, {}, { "Content-type": "image/jpeg" });
92 |
93 | const textWithLink =
94 | "Here is a picture of a beautiful car: https://images.unsplash.com/photo-1542692810-396766644d8c";
95 | const { findByAltText } = render(
96 |
97 | );
98 | expect(await findByAltText("Image from the message")).toBeInTheDocument();
99 | expect(await findByAltText("Image from the message")).toHaveAttribute(
100 | "src",
101 | "https://images.unsplash.com/photo-1542692810-396766644d8c"
102 | );
103 | });
104 |
105 | it("should follow redirects to find an image", async () => {
106 | nock("https://source.unsplash.com")
107 | .get("/600x300?girl")
108 | .reply(302, undefined, {
109 | Location: "https://images.unsplash.com/photo-1542692810-396766644d8c",
110 | });
111 |
112 | nock("https://images.unsplash.com")
113 | .head("/photo-1542692810-396766644d8c")
114 | .reply(200, {}, { "Content-type": "image/jpeg" });
115 |
116 | const textWithLink =
117 | "Here is a picture of a beautiful car: https://source.unsplash.com/600x300?girl";
118 | const { findByAltText } = render(
119 |
120 | );
121 | expect(
122 | await findByAltText("Image from the message", {}, { timeout: 10 * 1000 })
123 | ).toBeInTheDocument();
124 | expect(await findByAltText("Image from the message")).toHaveAttribute(
125 | "src",
126 | "https://source.unsplash.com/600x300?girl"
127 | );
128 | });
129 |
130 | it("should find second image in the text automatically", async () => {
131 | nock("https://google.com")
132 | .head("/")
133 | .reply(200, {}, { "Content-type": "text/html" });
134 | nock("https://images.unsplash.com")
135 | .head("/photo-1542692810-396766644d8c")
136 | .reply(200, {}, { "Content-type": "image/jpeg" });
137 |
138 | const textWithLink =
139 | "I've searched through google (https://google.com). Here is a picture of a beautiful car: https://images.unsplash.com/photo-1542692810-396766644d8c";
140 | const { findByAltText } = render(
141 |
142 | );
143 | expect(
144 | await findByAltText("Image from the message", {}, { timeout: 10 * 1000 })
145 | ).toBeInTheDocument();
146 | expect(await findByAltText("Image from the message")).toHaveAttribute(
147 | "src",
148 | "https://images.unsplash.com/photo-1542692810-396766644d8c"
149 | );
150 | });
151 | });
152 |
153 | describe("Youtube", () => {
154 | it("should show youtube player for https://www.youtube.com/watch?v=BMUiFMZr7vk", async () => {
155 | const textWithYoutube =
156 | "Watch it: https://www.youtube.com/watch?v=BMUiFMZr7vk!";
157 | const { findByLabelText } = render(
158 |
159 | );
160 | expect(await findByLabelText("Youtube player")).toBeInTheDocument();
161 | });
162 |
163 | it("should show youtube video for two messages in a row", async () => {
164 | const textWithYoutube =
165 | "Watch it: https://www.youtube.com/watch?v=BMUiFMZr7vk!";
166 | const { findAllByLabelText } = render(
167 | <>
168 |
169 |
170 | >
171 | );
172 | expect(await findAllByLabelText("Youtube player")).toHaveLength(2);
173 | });
174 | });
175 |
--------------------------------------------------------------------------------
/src/pages/Chat/ChatPage.container.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import { RouteComponentProps, Redirect } from "react-router-dom";
3 | import ChatPage, { SearchState } from "./ChatPage";
4 | import { WithSettings, withSettings } from "src/contexts/SettingsContext";
5 | import { Route } from "src/config/routes";
6 | import { WithChat, withChat, SearchResult } from "src/contexts/ChatContext";
7 | import { withLocale, WithLocale } from "react-targem";
8 | import { createMachine, EmptyEvent } from "src/utils/gstate";
9 |
10 | type SearchEventNames =
11 | | "SWITCH_SEARCH"
12 | | "SEARCH"
13 | | "SEARCH_SUCCESS"
14 | | "SEARCH_FAILURE";
15 |
16 | interface SwitchSearchEvent extends EmptyEvent {
17 | type: "SWITCH_SEARCH";
18 | }
19 | interface SearchEvent extends EmptyEvent {
20 | type: "SEARCH";
21 | payload: string;
22 | }
23 |
24 | interface SearchSuccessEvent extends EmptyEvent {
25 | type: "SEARCH_SUCCESS";
26 | payload: SearchResult[];
27 | }
28 |
29 | interface SearchFailureEvent extends EmptyEvent {
30 | type: "SEARCH_FAILURE";
31 | }
32 |
33 | type SearchEvents =
34 | | SwitchSearchEvent
35 | | SearchEvent
36 | | SearchSuccessEvent
37 | | SearchFailureEvent;
38 |
39 | interface ChatPageContainerProps
40 | extends RouteComponentProps,
41 | WithSettings,
42 | WithChat,
43 | WithLocale {}
44 |
45 | interface ChatPageContainerState {
46 | redirectTo?: Route;
47 | searchState: SearchState;
48 | searchQuery: string;
49 | searchResults?: SearchResult[];
50 | currentSearchResult: number;
51 | }
52 |
53 | class ChatPageContainer extends PureComponent<
54 | ChatPageContainerProps,
55 | ChatPageContainerState
56 | > {
57 | public searchMachine = createMachine<
58 | SearchState,
59 | SearchEventNames,
60 | SearchEvents
61 | >({
62 | chat: {
63 | transitions: {
64 | SWITCH_SEARCH: {
65 | target: "search",
66 | },
67 | },
68 | },
69 | search: {
70 | actions: {
71 | onEnter: () => {
72 | this.setState({
73 | searchQuery: "",
74 | });
75 | },
76 | },
77 | transitions: {
78 | SEARCH: {
79 | target: "searchLoading",
80 | },
81 | SWITCH_SEARCH: {
82 | target: "chat",
83 | },
84 | },
85 | },
86 | searchLoading: {
87 | actions: {
88 | onEnter: async (event) => {
89 | if (event.type !== "SEARCH") {
90 | return;
91 | }
92 | try {
93 | const results = await this.props.searchMessage(event.payload);
94 | this.performSearchTransition({
95 | type: "SEARCH_SUCCESS",
96 | payload: results,
97 | });
98 | } catch (e) {
99 | this.performSearchTransition({
100 | type: "SEARCH_FAILURE",
101 | });
102 | }
103 | },
104 | },
105 | transitions: {
106 | SEARCH_SUCCESS: {
107 | target: "searchFound",
108 | },
109 | SEARCH_FAILURE: {
110 | target: "searchNotFound",
111 | },
112 | SWITCH_SEARCH: {
113 | target: "chat",
114 | },
115 | },
116 | },
117 | searchNotFound: {
118 | transitions: {
119 | SWITCH_SEARCH: {
120 | target: "chat",
121 | },
122 | SEARCH: {
123 | target: "searchLoading",
124 | },
125 | },
126 | },
127 | searchFound: {
128 | actions: {
129 | onEnter: (event) => {
130 | if (event.type !== "SEARCH_SUCCESS") {
131 | return;
132 | }
133 | this.setState({ searchResults: event.payload });
134 | },
135 | },
136 | transitions: {
137 | SWITCH_SEARCH: {
138 | target: "chat",
139 | },
140 | SEARCH: {
141 | target: "searchLoading",
142 | },
143 | },
144 | },
145 | initialState: "chat",
146 | });
147 |
148 | public state: ChatPageContainerState = {
149 | searchState: this.searchMachine.value,
150 | searchQuery: "",
151 | currentSearchResult: 0,
152 | };
153 |
154 | public componentDidMount() {
155 | this.setRedirectIfUsernameIsEmpty();
156 | this.props.markAllAsRead();
157 | }
158 |
159 | public componentDidUpdate(prevProps: ChatPageContainerProps) {
160 | if (this.props.username !== prevProps.username) {
161 | this.setRedirectIfUsernameIsEmpty();
162 | }
163 |
164 | if (this.props.chatMessagesUnreadCount > 0) {
165 | this.props.markAllAsRead();
166 | }
167 | }
168 |
169 | public render(): React.ReactNode {
170 | if (this.state.redirectTo) {
171 | return ;
172 | }
173 | return (
174 |
186 | );
187 | }
188 |
189 | private performSearchTransition = (event: SearchEvents) => {
190 | this.setState((s) => ({
191 | searchState: this.searchMachine.transition(s.searchState, event),
192 | }));
193 | };
194 |
195 | private handleSearchButtonClick = () => {
196 | this.performSearchTransition({ type: "SWITCH_SEARCH" });
197 | };
198 |
199 | private handleRetryButtonClick = () => {
200 | this.performSearchTransition({
201 | type: "SEARCH",
202 | payload: this.state.searchQuery,
203 | });
204 | };
205 |
206 | private handleChangeCurrentSearchClick = (dir: "next" | "prev") => () => {
207 | let modifier: number;
208 | switch (dir) {
209 | case "next":
210 | modifier = 1;
211 | break;
212 | case "prev":
213 | modifier = -1;
214 | break;
215 | }
216 | this.setState((s) => ({
217 | currentSearchResult: s.currentSearchResult + modifier,
218 | }));
219 | };
220 |
221 | private handleSearchInput = (event: React.MouseEvent) => {
222 | const query = event.currentTarget.value;
223 | this.setState({
224 | searchQuery: query,
225 | });
226 | this.performSearchTransition({ type: "SEARCH", payload: query });
227 | };
228 |
229 | private setRedirectIfUsernameIsEmpty() {
230 | const { username, t } = this.props;
231 | if (!username || username === "") {
232 | window.alert(t("Please, specify username to use chat"));
233 | this.setState({ redirectTo: "/settings" });
234 | }
235 | }
236 | }
237 |
238 | export default withLocale(withChat(withSettings(ChatPageContainer)));
239 |
--------------------------------------------------------------------------------
/src/pages/Chat/ChatPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import classes from "./ChatPage.css";
3 | import cn from "clsx";
4 | import ChatMessage from "./components/ChatMessage";
5 | import ChatInput from "./components/ChatInput";
6 | import {
7 | ChatMessage as ChatMessageType,
8 | SearchResult,
9 | } from "src/services/ChatService";
10 | import Button from "src/components/ui-kit/Button";
11 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
12 | import {
13 | faSearch,
14 | faTimes,
15 | faChevronDown,
16 | faChevronUp,
17 | faRedo,
18 | } from "@fortawesome/free-solid-svg-icons";
19 | import { WithLocale, withLocale, T } from "react-targem";
20 | import Input from "src/components/ui-kit/Input";
21 | import Loading from "src/components/ui-kit/Loading";
22 | import Typography from "src/components/ui-kit/Typography";
23 |
24 | export type SearchState =
25 | | "chat"
26 | | "search"
27 | | "searchLoading"
28 | | "searchFound"
29 | | "searchNotFound";
30 |
31 | interface ChatPageProps extends WithLocale {
32 | chatMessages: ChatMessageType[];
33 | onSubmit: (message: string) => void;
34 | searchState: SearchState;
35 | searchQuery: string;
36 | searchResults?: SearchResult[];
37 | currentSearchResult: number;
38 | onSearchButtonClick: (event: React.MouseEvent) => void;
39 | onSearchInput: (event: React.MouseEvent) => void;
40 | onChangeCurrentSearchClick: (
41 | direction: "prev" | "next"
42 | ) => (event: React.MouseEvent) => void;
43 | onRetryButtonClick: (event: React.MouseEvent) => void;
44 | style?: React.CSSProperties;
45 | }
46 |
47 | class ChatPage extends PureComponent {
48 | public chatRef = React.createRef();
49 | public currentSearchedMessageRef = React.createRef();
50 |
51 | public componentDidMount(): void {
52 | this.scrollChatToBottom();
53 | this.scrollToSearchedMessage();
54 | }
55 |
56 | public componentDidUpdate(prevProps: ChatPageProps): void {
57 | const chat = this.chatRef.current;
58 | if (chat && chat.scrollHeight - chat.scrollTop !== chat.clientHeight) {
59 | this.scrollChatToBottom();
60 | }
61 | if (
62 | prevProps.currentSearchResult !== this.props.currentSearchResult ||
63 | prevProps.searchResults !== this.props.searchResults
64 | ) {
65 | this.scrollToSearchedMessage();
66 | }
67 | }
68 |
69 | render(): React.ReactNode {
70 | const {
71 | chatMessages,
72 | onSubmit,
73 | t,
74 | searchState,
75 | onSearchButtonClick,
76 | onSearchInput,
77 | searchQuery,
78 | currentSearchResult,
79 | searchResults,
80 | onChangeCurrentSearchClick,
81 | onRetryButtonClick,
82 | style,
83 | } = this.props;
84 |
85 | return (
86 |
87 | {searchState !== "chat" ? (
88 |
89 |
}
97 | />
98 |
99 | {searchState === "searchNotFound" ? (
100 |
101 |
102 |
103 |
104 |
111 |
112 | ) : null}
113 | {searchState === "searchFound" && searchResults ? (
114 |
115 |
123 |
124 | 0 ? currentSearchResult + 1 : 0,
129 | to: searchResults.length || 0,
130 | }}
131 | />
132 |
133 |
141 |
142 | ) : null}
143 |
144 | ) : null}
145 |
159 |
160 | {chatMessages.map((c) => {
161 | let isCurrentSearch = searchResults
162 | ? searchResults[currentSearchResult]?.id === c.id
163 | : false;
164 | if (searchState === "chat") {
165 | isCurrentSearch = false;
166 | }
167 | return (
168 |
177 | );
178 | })}
179 |
180 |
181 |
182 | );
183 | }
184 |
185 | private handleImageLoad = () => {
186 | const chat = this.chatRef.current;
187 | if (chat && chat.scrollHeight - chat.scrollTop !== chat.clientHeight) {
188 | this.scrollChatToBottom();
189 | }
190 | };
191 |
192 | private scrollChatToBottom() {
193 | const chat = this.chatRef.current;
194 | if (chat) {
195 | chat.scrollTop = chat.scrollHeight - chat.clientHeight;
196 | }
197 | }
198 |
199 | private scrollToSearchedMessage() {
200 | const { searchResults } = this.props;
201 | const chat = this.chatRef.current;
202 | const currentSearchedMessage = this.currentSearchedMessageRef.current;
203 | if (!chat || !currentSearchedMessage || !searchResults) {
204 | return;
205 | }
206 |
207 | chat.scrollTop =
208 | currentSearchedMessage.offsetTop +
209 | currentSearchedMessage.clientHeight -
210 | chat.clientHeight;
211 | }
212 | }
213 |
214 | export default withLocale(ChatPage);
215 |
--------------------------------------------------------------------------------