= (intercepter: T) => void;
+
+ export interface CommonWrapper> {
+ /**
+ * Returns a new wrapper with only the nodes of the current wrapper that, when passed into the provided predicate function, return true.
+ */
+ filterWhere(predicate: (wrapper: this) => boolean): this;
+
+ /**
+ * Returns whether or not the current wrapper has a node anywhere in it's render tree that looks like the one passed in.
+ */
+ contains(node: ReactElement | ReactElement[] | string): boolean;
+
+ /**
+ * Returns whether or not a given react element exists in the shallow render tree.
+ */
+ containsMatchingElement(node: ReactElement | ReactElement[]): boolean;
+
+ /**
+ * Returns whether or not all the given react elements exists in the shallow render tree
+ */
+ containsAllMatchingElements(nodes: ReactElement[] | ReactElement[][]): boolean;
+
+ /**
+ * Returns whether or not one of the given react elements exists in the shallow render tree.
+ */
+ containsAnyMatchingElements(nodes: ReactElement[] | ReactElement[][]): boolean;
+
+ /**
+ * Returns whether or not the current render tree is equal to the given node, based on the expected value.
+ */
+ equals(node: ReactElement): boolean;
+
+ /**
+ * Returns whether or not a given react element matches the shallow render tree.
+ */
+ matchesElement(node: ReactElement): boolean;
+
+ /**
+ * Returns whether or not the current node has a className prop including the passed in class name.
+ */
+ hasClass(className: string | RegExp): boolean;
+
+ /**
+ * Invokes a function prop.
+ * @param invokePropName The function prop to call.
+ * @param ...args The argments to the invokePropName function
+ * @returns The value of the function.
+ */
+ invoke<
+ K extends NonNullable<{ [K in keyof P]: P[K] extends ((...arg: any[]) => void) | undefined ? K : never }[keyof P]>
+ >(
+ invokePropName: K
+ ): P[K];
+
+ /**
+ * Returns whether or not the current node matches a provided selector.
+ */
+ is(selector: EnzymeSelector): boolean;
+
+ /**
+ * Returns whether or not the current node is empty.
+ * @deprecated Use .exists() instead.
+ */
+ isEmpty(): boolean;
+
+ /**
+ * Returns whether or not the current node exists.
+ */
+ exists(selector?: EnzymeSelector): boolean;
+
+ /**
+ * Returns a new wrapper with only the nodes of the current wrapper that don't match the provided selector.
+ * This method is effectively the negation or inverse of filter.
+ */
+ not(selector: EnzymeSelector): this;
+
+ /**
+ * Returns a string of the rendered text of the current render tree. This function should be looked at with
+ * skepticism if being used to test what the actual HTML output of the component will be. If that is what you
+ * would like to test, use enzyme's render function instead.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ text(): string;
+
+ /**
+ * Returns a string of the rendered HTML markup of the current render tree.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ html(): string;
+
+ /**
+ * Returns the node at a given index of the current wrapper.
+ */
+ get(index: number): ReactElement;
+
+ /**
+ * Returns the wrapper's underlying node.
+ */
+ getNode(): ReactElement;
+
+ /**
+ * Returns the wrapper's underlying nodes.
+ */
+ getNodes(): ReactElement[];
+
+ /**
+ * Returns the wrapper's underlying node.
+ */
+ getElement(): ReactElement;
+
+ /**
+ * Returns the wrapper's underlying node.
+ */
+ getElements(): ReactElement[];
+
+ /**
+ * Returns the outer most DOMComponent of the current wrapper.
+ */
+ getDOMNode(): T;
+
+ /**
+ * Returns a wrapper around the node at a given index of the current wrapper.
+ */
+ at(index: number): this;
+
+ /**
+ * Reduce the set of matched nodes to the first in the set.
+ */
+ first(): this;
+
+ /**
+ * Reduce the set of matched nodes to the last in the set.
+ */
+ last(): this;
+
+ /**
+ * Returns a new wrapper with a subset of the nodes of the original wrapper, according to the rules of `Array#slice`.
+ */
+ slice(begin?: number, end?: number): this;
+
+ /**
+ * Taps into the wrapper method chain. Helpful for debugging.
+ */
+ tap(intercepter: Intercepter): this;
+
+ /**
+ * Returns the state hash for the root node of the wrapper. Optionally pass in a prop name and it will return just that value.
+ */
+ state(): S;
+ state(key: K): S[K];
+ state(key: string): T;
+
+ /**
+ * Returns the context hash for the root node of the wrapper. Optionally pass in a prop name and it will return just that value.
+ */
+ context(): any;
+ context(key: string): T;
+
+ /**
+ * Returns the props hash for the current node of the wrapper.
+ *
+ * NOTE: can only be called on a wrapper of a single node.
+ */
+ props(): P;
+
+ /**
+ * Returns the prop value for the node of the current wrapper with the provided key.
+ *
+ * NOTE: can only be called on a wrapper of a single node.
+ */
+ prop(key: K): P[K];
+ prop(key: string): T;
+
+ /**
+ * Returns the key value for the node of the current wrapper.
+ * NOTE: can only be called on a wrapper of a single node.
+ */
+ key(): string;
+
+ /**
+ * Simulate events.
+ * Returns itself.
+ * @param args?
+ */
+ simulate(event: string, ...args: any[]): this;
+
+ /**
+ * Used to simulate throwing a rendering error. Pass an error to throw.
+ * Returns itself.
+ * @param error
+ */
+ simulateError(error: any): this;
+
+ /**
+ * A method to invoke setState() on the root component instance similar to how you might in the definition of
+ * the component, and re-renders. This method is useful for testing your component in hard to achieve states,
+ * however should be used sparingly. If possible, you should utilize your component's external API in order to
+ * get it into whatever state you want to test, in order to be as accurate of a test as possible. This is not
+ * always practical, however.
+ * Returns itself.
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ setState(state: Pick, callback?: () => void): this;
+
+ /**
+ * A method that sets the props of the root component, and re-renders. Useful for when you are wanting to test
+ * how the component behaves over time with changing props. Calling this, for instance, will call the
+ * componentWillReceiveProps lifecycle method.
+ *
+ * Similar to setState, this method accepts a props object and will merge it in with the already existing props.
+ * Returns itself.
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ setProps(props: Pick, callback?: () => void): this;
+
+ /**
+ * A method that sets the context of the root component, and re-renders. Useful for when you are wanting to
+ * test how the component behaves over time with changing contexts.
+ * Returns itself.
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ setContext(context: any): this;
+
+ /**
+ * Gets the instance of the component being rendered as the root node passed into shallow().
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ instance(): C;
+
+ /**
+ * Forces a re-render. Useful to run before checking the render output if something external may be updating
+ * the state of the component somewhere.
+ * Returns itself.
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ update(): this;
+
+ /**
+ * Returns an html-like string of the wrapper for debugging purposes. Useful to print out to the console when
+ * tests are not passing when you expect them to.
+ */
+ debug(): string;
+
+ /**
+ * Returns the name of the current node of the wrapper.
+ */
+ name(): string;
+
+ /**
+ * Iterates through each node of the current wrapper and executes the provided function with a wrapper around
+ * the corresponding node passed in as the first argument.
+ *
+ * Returns itself.
+ * @param fn A callback to be run for every node in the collection. Should expect a ShallowWrapper as the first
+ * argument, and will be run with a context of the original instance.
+ */
+ forEach(fn: (wrapper: this, index: number) => any): this;
+
+ /**
+ * Maps the current array of nodes to another array. Each node is passed in as a ShallowWrapper to the map
+ * function.
+ * Returns an array of the returned values from the mapping function..
+ * @param fn A mapping function to be run for every node in the collection, the results of which will be mapped
+ * to the returned array. Should expect a ShallowWrapper as the first argument, and will be run
+ * with a context of the original instance.
+ */
+ map(fn: (wrapper: this, index: number) => V): V[];
+
+ /**
+ * Applies the provided reducing function to every node in the wrapper to reduce to a single value. Each node
+ * is passed in as a ShallowWrapper, and is processed from left to right.
+ */
+ reduce(fn: (prevVal: R, wrapper: this, index: number) => R, initialValue?: R): R;
+
+ /**
+ * Applies the provided reducing function to every node in the wrapper to reduce to a single value.
+ * Each node is passed in as a ShallowWrapper, and is processed from right to left.
+ */
+ reduceRight(fn: (prevVal: R, wrapper: this, index: number) => R, initialValue?: R): R;
+
+ /**
+ * Returns whether or not any of the nodes in the wrapper match the provided selector.
+ */
+ some(selector: EnzymeSelector): boolean;
+
+ /**
+ * Returns whether or not any of the nodes in the wrapper pass the provided predicate function.
+ */
+ someWhere(fn: (wrapper: this) => boolean): boolean;
+
+ /**
+ * Returns whether or not all of the nodes in the wrapper match the provided selector.
+ */
+ every(selector: EnzymeSelector): boolean;
+
+ /**
+ * Returns whether or not all of the nodes in the wrapper pass the provided predicate function.
+ */
+ everyWhere(fn: (wrapper: this) => boolean): boolean;
+
+ /**
+ * Returns true if renderer returned null
+ */
+ isEmptyRender(): boolean;
+
+ /**
+ * Renders the component to static markup and returns a Cheerio wrapper around the result.
+ */
+ render(): Cheerio;
+
+ /**
+ * Returns the type of the current node of this wrapper. If it's a composite component, this will be the
+ * component constructor. If it's native DOM node, it will be a string of the tag name.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ type(): string | ComponentClass | StatelessComponent
;
+
+ length: number;
+ }
+
+ export type Parameters = T extends (...args: infer A) => any ? A : never;
+
+ // tslint:disable-next-line no-empty-interface
+ export interface ShallowWrapper extends CommonWrapper
{}
+ export class ShallowWrapper
{
+ constructor(nodes: JSX.Element[] | JSX.Element, root?: ShallowWrapper, options?: ShallowRendererProps);
+ shallow(options?: ShallowRendererProps): ShallowWrapper;
+ unmount(): this;
+
+ /**
+ * Find every node in the render tree that matches the provided selector.
+ * @param selector The selector to match.
+ */
+ find(statelessComponent: StatelessComponent): ShallowWrapper;
+ find(component: ComponentType): ShallowWrapper;
+ find(props: EnzymePropSelector): ShallowWrapper;
+ find(selector: string): ShallowWrapper;
+
+ /**
+ * Removes nodes in the current wrapper that do not match the provided selector.
+ * @param selector The selector to match.
+ */
+ filter(statelessComponent: StatelessComponent): ShallowWrapper;
+ filter(component: ComponentType): ShallowWrapper;
+ filter(props: EnzymePropSelector | string): ShallowWrapper;
+
+ /**
+ * Finds every node in the render tree that returns true for the provided predicate function.
+ */
+ findWhere(predicate: (wrapper: ShallowWrapper) => boolean): ShallowWrapper;
+
+ /**
+ * Returns a new wrapper with all of the children of the node(s) in the current wrapper. Optionally, a selector
+ * can be provided and it will filter the children by this selector.
+ */
+ children(statelessComponent: StatelessComponent): ShallowWrapper;
+ children(component: ComponentType): ShallowWrapper;
+ children(selector: string): ShallowWrapper;
+ children(props?: EnzymePropSelector): ShallowWrapper;
+
+ /**
+ * Returns a new wrapper with child at the specified index.
+ */
+ childAt(index: number): ShallowWrapper;
+ childAt(index: number): ShallowWrapper;
+
+ /**
+ * Shallow render the one non-DOM child of the current wrapper, and return a wrapper around the result.
+ * NOTE: can only be called on wrapper of a single non-DOM component element node.
+ */
+ dive(
+ options?: ShallowRendererProps
+ ): ShallowWrapper;
+ dive(options?: ShallowRendererProps): ShallowWrapper;
+ dive(options?: ShallowRendererProps): ShallowWrapper;
+
+ /**
+ * Strips out all the not host-nodes from the list of nodes
+ *
+ * This method is useful if you want to check for the presence of host nodes
+ * (actually rendered HTML elements) ignoring the React nodes.
+ */
+ hostNodes(): ShallowWrapper;
+
+ /**
+ * Returns a wrapper around all of the parents/ancestors of the wrapper. Does not include the node in the
+ * current wrapper. Optionally, a selector can be provided and it will filter the parents by this selector.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ parents(statelessComponent: StatelessComponent): ShallowWrapper;
+ parents(component: ComponentType): ShallowWrapper;
+ parents(selector: string): ShallowWrapper;
+ parents(props?: EnzymePropSelector): ShallowWrapper;
+
+ /**
+ * Returns a wrapper of the first element that matches the selector by traversing up through the current node's
+ * ancestors in the tree, starting with itself.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ closest(statelessComponent: StatelessComponent): ShallowWrapper;
+ closest(component: ComponentType): ShallowWrapper;
+ closest(props: EnzymePropSelector): ShallowWrapper;
+ closest(selector: string): ShallowWrapper;
+
+ /**
+ * Returns a wrapper with the direct parent of the node in the current wrapper.
+ */
+ parent(): ShallowWrapper;
+
+ /**
+ * Returns a wrapper of the node rendered by the provided render prop.
+ */
+ renderProp(
+ prop: PropName
+ ): (...params: Parameters) => ShallowWrapper;
+
+ /**
+ * If a wrappingComponent was passed in options,
+ * this methods returns a ShallowWrapper around the rendered wrappingComponent.
+ * This ShallowWrapper can be used to update the wrappingComponent's props and state
+ */
+ getWrappingComponent: () => ShallowWrapper;
+ }
+
+ // tslint:disable-next-line no-empty-interface
+ export interface ReactWrapper extends CommonWrapper
{}
+ export class ReactWrapper
{
+ constructor(nodes: JSX.Element | JSX.Element[], root?: ReactWrapper, options?: MountRendererProps);
+
+ unmount(): this;
+ mount(): this;
+
+ /**
+ * Returns a wrapper of the node that matches the provided reference name.
+ *
+ * NOTE: can only be called on a wrapper instance that is also the root instance.
+ */
+ ref(refName: string): ReactWrapper;
+ ref(refName: string): ReactWrapper;
+
+ /**
+ * Detaches the react tree from the DOM. Runs ReactDOM.unmountComponentAtNode() under the hood.
+ *
+ * This method will most commonly be used as a "cleanup" method if you decide to use the attachTo option in mount(node, options).
+ *
+ * The method is intentionally not "fluent" (in that it doesn't return this) because you should not be doing anything with this wrapper after this method is called.
+ *
+ * Using the attachTo is not generally recommended unless it is absolutely necessary to test something.
+ * It is your responsibility to clean up after yourself at the end of the test if you do decide to use it, though.
+ */
+ detach(): void;
+
+ /**
+ * Strips out all the not host-nodes from the list of nodes
+ *
+ * This method is useful if you want to check for the presence of host nodes
+ * (actually rendered HTML elements) ignoring the React nodes.
+ */
+ hostNodes(): ReactWrapper;
+
+ /**
+ * Find every node in the render tree that matches the provided selector.
+ * @param selector The selector to match.
+ */
+ find(statelessComponent: StatelessComponent): ReactWrapper;
+ find(component: ComponentType): ReactWrapper;
+ find(props: EnzymePropSelector): ReactWrapper;
+ find(selector: string): ReactWrapper;
+
+ /**
+ * Finds every node in the render tree that returns true for the provided predicate function.
+ */
+ findWhere(predicate: (wrapper: ReactWrapper) => boolean): ReactWrapper;
+
+ /**
+ * Removes nodes in the current wrapper that do not match the provided selector.
+ * @param selector The selector to match.
+ */
+ filter(statelessComponent: StatelessComponent): ReactWrapper;
+ filter(component: ComponentType): ReactWrapper;
+ filter(props: EnzymePropSelector | string): ReactWrapper;
+
+ /**
+ * Returns a new wrapper with all of the children of the node(s) in the current wrapper. Optionally, a selector
+ * can be provided and it will filter the children by this selector.
+ */
+ children(statelessComponent: StatelessComponent): ReactWrapper;
+ children(component: ComponentType): ReactWrapper;
+ children(selector: string): ReactWrapper;
+ children(props?: EnzymePropSelector): ReactWrapper;
+
+ /**
+ * Returns a new wrapper with child at the specified index.
+ */
+ childAt(index: number): ReactWrapper;
+ childAt(index: number): ReactWrapper;
+
+ /**
+ * Returns a wrapper around all of the parents/ancestors of the wrapper. Does not include the node in the
+ * current wrapper. Optionally, a selector can be provided and it will filter the parents by this selector.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ parents(statelessComponent: StatelessComponent): ReactWrapper;
+ parents(component: ComponentType): ReactWrapper;
+ parents(selector: string): ReactWrapper;
+ parents(props?: EnzymePropSelector): ReactWrapper;
+
+ /**
+ * Returns a wrapper of the first element that matches the selector by traversing up through the current node's
+ * ancestors in the tree, starting with itself.
+ *
+ * Note: can only be called on a wrapper of a single node.
+ */
+ closest(statelessComponent: StatelessComponent): ReactWrapper;
+ closest(component: ComponentType): ReactWrapper;
+ closest(props: EnzymePropSelector): ReactWrapper;
+ closest(selector: string): ReactWrapper;
+
+ /**
+ * Returns a wrapper with the direct parent of the node in the current wrapper.
+ */
+ parent(): ReactWrapper;
+ }
+
+ export interface Lifecycles {
+ componentDidUpdate?: {
+ onSetState: boolean;
+ prevContext: boolean;
+ };
+ getDerivedStateFromProps?: { hasShouldComponentUpdateBug: boolean } | boolean;
+ getChildContext?: {
+ calledByRenderer: boolean;
+ [key: string]: any;
+ };
+ setState?: any;
+ // TODO Maybe some life cycle are missing
+ [lifecycleName: string]: any;
+ }
+
+ export interface ShallowRendererProps {
+ // See https://github.com/airbnb/enzyme/blob/enzyme@3.10.0/docs/api/shallow.md#arguments
+ /**
+ * If set to true, componentDidMount is not called on the component, and componentDidUpdate is not called after
+ * setProps and setContext. Default to false.
+ */
+ disableLifecycleMethods?: boolean;
+ /**
+ * Enable experimental support for full react lifecycle methods
+ */
+ lifecycleExperimental?: boolean;
+ /**
+ * Context to be passed into the component
+ */
+ context?: any;
+ /**
+ * The legacy enableComponentDidUpdateOnSetState option should be matched by
+ * `lifecycles: { componentDidUpdate: { onSetState: true } }`, for compatibility
+ */
+ enableComponentDidUpdateOnSetState?: boolean;
+ /**
+ * the legacy supportPrevContextArgumentOfComponentDidUpdate option should be matched by
+ * `lifecycles: { componentDidUpdate: { prevContext: true } }`, for compatibility
+ */
+ supportPrevContextArgumentOfComponentDidUpdate?: boolean;
+ lifecycles?: Lifecycles;
+ /**
+ * A component that will render as a parent of the node.
+ * It can be used to provide context to the node, among other things.
+ * See https://airbnb.io/enzyme/docs/api/ShallowWrapper/getWrappingComponent.html
+ * Note: wrappingComponent must render its children.
+ */
+ wrappingComponent?: ComponentType;
+ /**
+ * Initial props to pass to the wrappingComponent if it is specified.
+ */
+ wrappingComponentProps?: any;
+ /**
+ * If set to true, when rendering Suspense enzyme will replace all the lazy components in children
+ * with fallback element prop. Otherwise it won't handle fallback of lazy component.
+ * Default to true. Note: not supported in React < 16.6.
+ */
+ suspenseFallback?: boolean;
+ adapter?: EnzymeAdapter;
+ /* TODO what are these doing??? */
+ attachTo?: any;
+ hydrateIn?: any;
+ PROVIDER_VALUES?: any;
+ }
+
+ export interface MountRendererProps {
+ /**
+ * Context to be passed into the component
+ */
+ context?: {};
+ /**
+ * DOM Element to attach the component to
+ */
+ attachTo?: HTMLElement | null;
+ /**
+ * Merged contextTypes for all children of the wrapper
+ */
+ childContextTypes?: {};
+ }
+
+ /**
+ * Shallow rendering is useful to constrain yourself to testing a component as a unit, and to ensure that
+ * your tests aren't indirectly asserting on behavior of child components.
+ */
+ export function shallow(
+ node: ReactElement,
+ options?: ShallowRendererProps
+ ): ShallowWrapper
;
+ export function shallow
(node: ReactElement
, options?: ShallowRendererProps): ShallowWrapper
;
+ export function shallow
(node: ReactElement
, options?: ShallowRendererProps): ShallowWrapper
;
+
+ /**
+ * Mounts and renders a react component into the document and provides a testing wrapper around it.
+ */
+ export function mount(
+ node: ReactElement,
+ options?: MountRendererProps
+ ): ReactWrapper
;
+ export function mount
(node: ReactElement
, options?: MountRendererProps): ReactWrapper
;
+ export function mount
(node: ReactElement
, options?: MountRendererProps): ReactWrapper
;
+
+ /**
+ * Render react components to static HTML and analyze the resulting HTML structure.
+ */
+ export function render
(node: ReactElement
, options?: any): Cheerio;
+
+ // See https://github.com/airbnb/enzyme/blob/v3.10.0/packages/enzyme/src/EnzymeAdapter.js
+ export class EnzymeAdapter {
+ wrapWithWrappingComponent?: (node: ReactElement, options?: ShallowRendererProps) => any;
+ }
+
+ /**
+ * Configure enzyme to use the correct adapter for the react version
+ * This is enabling the Enzyme configuration with adapters in TS
+ */
+ export function configure(options: {
+ adapter: EnzymeAdapter;
+ // See https://github.com/airbnb/enzyme/blob/enzyme@3.10.0/docs/guides/migration-from-2-to-3.md#lifecycle-methods
+ // Actually, `{adapter:} & Pick` is more precise. However,
+ // in that case jsdoc won't be shown
+ /**
+ * If set to true, componentDidMount is not called on the component, and componentDidUpdate is not called after
+ * setProps and setContext. Default to false.
+ */
+ disableLifecycleMethods?: boolean;
+ }): void;
+}
diff --git a/frontend/app/common/__mocks__/constants.ts b/frontend/app/common/__mocks__/constants.ts
new file mode 100644
index 00000000..3fb7aef8
--- /dev/null
+++ b/frontend/app/common/__mocks__/constants.ts
@@ -0,0 +1,7 @@
+// @ts-ignore
+const mock: typeof import('@app/common/constants') = {
+ ...jest.requireActual('@app/common/constants'),
+ BASE_URL: 'https://demo.remark42.com/',
+};
+
+module.exports = mock;
diff --git a/frontend/app/common/__mocks__/settings.ts b/frontend/app/common/__mocks__/settings.ts
new file mode 100644
index 00000000..adcb398c
--- /dev/null
+++ b/frontend/app/common/__mocks__/settings.ts
@@ -0,0 +1,20 @@
+// @ts-ignore
+const mock: typeof import('@app/common/settings') = {
+ ...jest.requireActual('@app/common/settings'),
+ siteId: 'remark',
+ pageTitle: 'remark test',
+ url: 'https://remark42.com/test',
+ maxShownComments: 20,
+ token: 'abcd',
+ theme: 'light',
+ querySettings: {
+ site_id: 'remark',
+ page_title: 'remark test',
+ url: 'https://remark42.com/test',
+ max_shown_comments: 20,
+ token: 'abcd',
+ theme: 'light',
+ },
+};
+
+module.exports = mock;
diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts
index 490bb2f6..a2e62a28 100644
--- a/frontend/app/common/api.ts
+++ b/frontend/app/common/api.ts
@@ -12,8 +12,27 @@ const __loginAnonymously = (username: string): Promise => {
return fetcher.get({ url, withCredentials: true, overriddenApiBase: '' });
};
+const __loginViaEmail = (token: string): Promise => {
+ const url = `/auth/email/login?token=${token}`;
+ return fetcher.get({ url, withCredentials: true, overriddenApiBase: '' });
+};
+
+/**
+ * First step of two of `email` authorization
+ *
+ * @param username userrname
+ * @param address email address
+ */
+export const sendEmailVerificationRequest = (username: string, address: string): Promise => {
+ const url = `/auth/email/login?id=${siteId}&user=${encodeURIComponent(username)}&address=${encodeURIComponent(
+ address
+ )}`;
+ return fetcher.get({ url, withCredentials: true, overriddenApiBase: '' });
+};
+
export const logIn = (provider: AuthProvider): Promise => {
if (provider.name === 'anonymous') return __loginAnonymously(provider.username);
+ if (provider.name === 'email') return __loginViaEmail(provider.token);
return new Promise((resolve, reject) => {
const url = `${BASE_URL}/auth/${provider.name}/login?from=${encodeURIComponent(
diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts
index 217cbd27..6e22940f 100644
--- a/frontend/app/common/constants.ts
+++ b/frontend/app/common/constants.ts
@@ -22,6 +22,7 @@ export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = {
yandex: 'Yandex',
dev: 'Dev',
anonymous: 'Anonymous',
+ email: 'Email',
};
/** locastorage key for collapsed comments */
diff --git a/frontend/app/common/fetcher.test.ts b/frontend/app/common/fetcher.test.ts
index 8d1e54f2..d45be047 100644
--- a/frontend/app/common/fetcher.test.ts
+++ b/frontend/app/common/fetcher.test.ts
@@ -1,23 +1,13 @@
import fetcher from './fetcher';
+import { mockHeaders } from '@app/testUtils/mockHeaders';
describe('fetcher', () => {
- let originalHeaders = (window as any).Headers;
-
beforeAll(() => {
- originalHeaders = (window as any).Headers;
- (window as any).Headers = class {
- append() {}
- has() {
- return false;
- }
- get() {
- return null;
- }
- };
+ mockHeaders.mock();
});
afterAll(() => {
- (window as any).Headers = originalHeaders;
+ mockHeaders.restore();
});
afterEach(() => {
diff --git a/frontend/app/common/polyfills.ts b/frontend/app/common/polyfills.ts
index 84e4c1c9..79f9a86c 100644
--- a/frontend/app/common/polyfills.ts
+++ b/frontend/app/common/polyfills.ts
@@ -1,3 +1,4 @@
+import 'intersection-observer';
import 'core-js/es/promise';
import 'focus-visible';
import '@webcomponents/custom-elements';
diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts
index 14095f29..debc98d5 100644
--- a/frontend/app/common/types.ts
+++ b/frontend/app/common/types.ts
@@ -65,6 +65,14 @@ export interface Comment {
delete?: boolean;
/** post title */
title?: string;
+ /**
+ * @ClientOnly defines whether comments was hidden (deleted)
+ *
+ * Situatuon may occure for example if user decided to hide someone,
+ * in this case we don't use `delete` field because comment with `delete`
+ * still renders, and comment with `hidden` flag completely removed from DOM
+ */
+ hidden?: boolean;
}
export interface CommentsResponse {
@@ -119,7 +127,8 @@ export type AuthProvider =
| { name: 'github' }
| { name: 'yandex' }
| { name: 'dev' }
- | { name: 'anonymous'; username: string };
+ | { name: 'anonymous'; username: string }
+ | { name: 'email'; token: string };
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m';
diff --git a/frontend/app/components/auth-panel/__anonymous-login-form/index.ts b/frontend/app/components/auth-panel/__anonymous-login-form/index.ts
index c3cc53f0..134b7c86 100644
--- a/frontend/app/components/auth-panel/__anonymous-login-form/index.ts
+++ b/frontend/app/components/auth-panel/__anonymous-login-form/index.ts
@@ -1,3 +1,3 @@
-export { AnonymousLoginForm } from './auth-panel__anonymous-login-form';
+import './auth-panel__anonymous-login-form.scss';
-require('./auth-panel__anonymous-login-form.scss');
+export { AnonymousLoginForm } from './auth-panel__anonymous-login-form';
diff --git a/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss b/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss
new file mode 100644
index 00000000..599ec6af
--- /dev/null
+++ b/frontend/app/components/auth-panel/__dropdown-provider/auth-panel__dropdown-provider.scss
@@ -0,0 +1,3 @@
+.auth-panel__dropdown-provider {
+ padding: 0.2rem 0.4rem;
+}
diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss
new file mode 100644
index 00000000..6ff96ca5
--- /dev/null
+++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss
@@ -0,0 +1,52 @@
+.auth-panel-email-login-form {
+ padding: 0.35em 0.55em;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+}
+
+.auth-panel-email-login-form__input,
+.auth-panel-email-login-form__token-input {
+ width: 12rem;
+ margin: 0.15rem;
+}
+
+.auth-panel-email-login-form__token-input {
+ resize: vertical;
+ font: inherit;
+ font-weight: normal;
+ font-size: 0.8em;
+}
+
+.auth-panel-email-login-form__submit {
+ background: none;
+ border: none;
+ padding: 0;
+ padding: 0.1em;
+ margin-top: 0.2em;
+ color: currentColor;
+ font: inherit;
+ cursor: pointer;
+}
+
+.auth-panel-email-login-form__submit:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.auth-panel-email-login-form__back-button {
+ color: #259c9a;
+ cursor: pointer;
+ margin-left: 0.1rem;
+ margin-bottom: 0.5rem;
+
+ &:hover {
+ opacity: 0.8;
+ }
+}
+
+.auth-panel-email-login-form__error {
+ color: #9a0000;
+ text-align: center;
+ margin-top: 1em;
+}
diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx
new file mode 100644
index 00000000..55c9926a
--- /dev/null
+++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx
@@ -0,0 +1,50 @@
+/** @jsx h */
+import { h } from 'preact';
+import { mount } from 'enzyme';
+import { EmailLoginForm, Props, State } from './auth-panel__email-login-form';
+import { User } from '@app/common/types';
+import { sleep } from '@app/utils/sleep';
+
+describe('EmailLoginForm', () => {
+ it('works', async () => {
+ const testUser = ({} as any) as User;
+ const sendEmailVerification = jest.fn(async () => {});
+ const onSignIn = jest.fn(async () => testUser);
+ const onSuccess = jest.fn(async () => {});
+ const el = mount(
+
+ );
+ await new Promise(resolve =>
+ el.setState(
+ {
+ usernameValue: 'someone',
+ addressValue: 'someone@example.com',
+ } as State,
+ resolve
+ )
+ );
+ el.find('form').simulate('submit');
+ await sleep(100);
+ expect(sendEmailVerification).toBeCalledWith('someone', 'someone@example.com');
+ expect(el.state().verificationSent).toBe(true);
+
+ await new Promise(resolve =>
+ el.setState(
+ {
+ tokenValue: 'abcd',
+ } as State,
+ resolve
+ )
+ );
+
+ el.find('form').simulate('submit');
+ await sleep(100);
+ expect(onSignIn).toBeCalledWith('abcd');
+ expect(onSuccess).toBeCalledWith(testUser);
+ });
+});
diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx
new file mode 100644
index 00000000..544e4b3a
--- /dev/null
+++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx
@@ -0,0 +1,235 @@
+/** @jsx h */
+import { h, Component, RenderableProps } from 'preact';
+import b from 'bem-react-helper';
+import { Theme, User } from '@app/common/types';
+import { sendEmailVerificationRequest } from '@app/common/api';
+import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
+import { connect } from 'preact-redux';
+import { getHandleClickProps } from '@app/common/accessibility';
+import { sleep } from '@app/utils/sleep';
+import TextareaAutosize from '@app/components/input/textarea-autosize';
+
+const mapStateToProps = () => ({
+ sendEmailVerification: sendEmailVerificationRequest,
+});
+
+export type Props = {
+ onSignIn(token: string): Promise;
+ onSuccess?(user: User): Promise;
+ theme: Theme;
+ className?: string;
+} & ReturnType;
+
+export interface State {
+ usernameValue: string;
+ addressValue: string;
+ tokenValue: string;
+ verificationSent: boolean;
+ loading: boolean;
+ error: string | null;
+}
+
+export class EmailLoginForm extends Component {
+ static usernameRegex = /^[a-zA-Z][\w ]+$/;
+ static emailRegex = /[^@]+@[^.]+\..+/;
+
+ inputRef?: HTMLInputElement;
+ tokenRef?: TextareaAutosize;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ usernameValue: '',
+ addressValue: '',
+ tokenValue: '',
+ verificationSent: false,
+ loading: false,
+ error: null,
+ };
+
+ this.focus = this.focus.bind(this);
+ this.onVerificationSubmit = this.onVerificationSubmit.bind(this);
+ this.onSubmit = this.onSubmit.bind(this);
+ this.onUsernameChange = this.onUsernameChange.bind(this);
+ this.onAddressChange = this.onAddressChange.bind(this);
+ this.onTokenChange = this.onTokenChange.bind(this);
+ this.goBack = this.goBack.bind(this);
+ }
+
+ async focus() {
+ await sleep(100);
+ if (this.inputRef) {
+ this.inputRef.focus();
+ return;
+ }
+ this.tokenRef && this.tokenRef.textareaRef && this.tokenRef.textareaRef.select();
+ }
+
+ async onVerificationSubmit(e: Event) {
+ e.preventDefault();
+ this.setState({ loading: true });
+ try {
+ await this.props.sendEmailVerification(this.state.usernameValue, this.state.addressValue);
+ this.setState({ verificationSent: true });
+ setTimeout(() => {
+ this.tokenRef && this.tokenRef.focus();
+ }, 100);
+ } catch (e) {
+ this.setState({ error: extractErrorMessageFromResponse(e) });
+ } finally {
+ this.setState({ loading: false });
+ }
+ }
+
+ async onSubmit(e: Event) {
+ e.preventDefault();
+ try {
+ this.setState({ loading: true });
+ const user = await this.props.onSignIn(this.state.tokenValue);
+ if (!user) {
+ this.setState({ error: 'No user was found' });
+ return;
+ }
+ this.setState({ verificationSent: false, tokenValue: '' });
+ this.props.onSuccess && this.props.onSuccess(user);
+ } catch (e) {
+ this.setState({ error: extractErrorMessageFromResponse(e) });
+ } finally {
+ this.setState({ loading: false });
+ }
+ }
+
+ onUsernameChange(e: Event) {
+ this.setState({ error: null, usernameValue: (e.target as HTMLInputElement).value });
+ }
+
+ onAddressChange(e: Event) {
+ this.setState({ error: null, addressValue: (e.target as HTMLInputElement).value });
+ }
+
+ onTokenChange(e: Event) {
+ this.setState({ error: null, tokenValue: (e.target as HTMLInputElement).value });
+ }
+
+ goBack() {
+ this.setState({
+ tokenValue: '',
+ error: null,
+ verificationSent: false,
+ });
+ setTimeout(() => {
+ this.inputRef && this.inputRef.focus();
+ }, 100);
+ }
+
+ getForm1InvalidReason(): string | null {
+ if (this.state.loading) return 'Loading...';
+ const username = this.state.usernameValue;
+ if (username.length < 3) return 'Username must be at least 3 characters long';
+ if (!EmailLoginForm.usernameRegex.test(username))
+ return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
+ if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return 'Address should be valid email address';
+ return null;
+ }
+
+ getForm2InvalidReason(): string | null {
+ if (this.state.loading) return 'Loading...';
+ if (this.state.tokenValue.length === 0) return 'Token field must not be empty';
+ return null;
+ }
+
+ componentDidMount() {
+ setTimeout(() => {
+ this.inputRef && this.inputRef.focus();
+ }, 100);
+ }
+
+ render(props: RenderableProps) {
+ // TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
+ let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
+ if (props.className) {
+ className += ' ' + b('auth-panel-email-login-form', {}, { theme: props.theme });
+ }
+
+ const form1InvalidReason = this.getForm1InvalidReason();
+
+ if (!this.state.verificationSent)
+ return (
+
+ );
+
+ const form2InvalidReason = this.getForm2InvalidReason();
+
+ return (
+
+ );
+ }
+}
+
+export const EmailLoginFormConnected = connect(
+ mapStateToProps,
+ null,
+ null,
+ { withRef: true }
+)(EmailLoginForm);
diff --git a/frontend/app/components/auth-panel/__email-login-form/index.ts b/frontend/app/components/auth-panel/__email-login-form/index.ts
new file mode 100644
index 00000000..eade0573
--- /dev/null
+++ b/frontend/app/components/auth-panel/__email-login-form/index.ts
@@ -0,0 +1,3 @@
+import './auth-panel__email-login-form.scss';
+
+export { EmailLoginForm, EmailLoginFormConnected } from './auth-panel__email-login-form';
diff --git a/frontend/app/components/auth-panel/__select-label-value/auth-panel__select-label-value.scss b/frontend/app/components/auth-panel/__select-label-value/auth-panel__select-label-value.scss
new file mode 100644
index 00000000..1aa311c7
--- /dev/null
+++ b/frontend/app/components/auth-panel/__select-label-value/auth-panel__select-label-value.scss
@@ -0,0 +1,9 @@
+.auth-panel__select-label-value_focused {
+ outline: 1px dotted;
+ outline-color: inherit;
+
+ @supports (outline-color: -webkit-focus-ring-color) {
+ outline-color: -webkit-focus-ring-color;
+ outline-style: auto;
+ }
+}
diff --git a/frontend/app/components/auth-panel/__user-id/index.ts b/frontend/app/components/auth-panel/__user-id/index.ts
index e11d552b..0a741abd 100644
--- a/frontend/app/components/auth-panel/__user-id/index.ts
+++ b/frontend/app/components/auth-panel/__user-id/index.ts
@@ -1,3 +1,3 @@
-export { UserID } from './auth-panel__user-id';
+import './auth-panel__user-id.scss';
-require('./auth-panel__user-id.scss');
+export { UserID } from './auth-panel__user-id';
diff --git a/frontend/app/components/auth-panel/auth-panel.scss b/frontend/app/components/auth-panel/auth-panel.scss
index 03c6138a..9988a8a7 100644
--- a/frontend/app/components/auth-panel/auth-panel.scss
+++ b/frontend/app/components/auth-panel/auth-panel.scss
@@ -3,4 +3,5 @@
justify-content: space-between;
font-size: 14px;
line-height: 16px;
+ align-items: baseline;
}
diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx
index 92e4f53d..07c0de00 100644
--- a/frontend/app/components/auth-panel/auth-panel.test.tsx
+++ b/frontend/app/components/auth-panel/auth-panel.test.tsx
@@ -1,12 +1,13 @@
/** @jsx h */
-import { h, render } from 'preact';
+import { h } from 'preact';
+import { mount } from 'enzyme';
import { Props, AuthPanel } from './auth-panel';
-import { createDomContainer } from '../../testUtils';
import { User, PostInfo } from '../../common/types';
const DefaultProps: Partial = {
sort: '-score',
providers: ['google', 'github'],
+ provider: { name: null },
postInfo: {
read_only: false,
url: 'https://example.com',
@@ -17,125 +18,139 @@ const DefaultProps: Partial = {
describe(' ', () => {
describe('For not authorized user', () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('should render login form with google and github provider', () => {
- const element = ;
+ const element = mount( );
- render(element, container);
-
- const authPanelColumn = container.querySelectorAll('.auth-panel__column');
+ const authPanelColumn = element.find('.auth-panel__column');
expect(authPanelColumn.length).toEqual(2);
- const authForm = authPanelColumn[0];
+ const authForm = authPanelColumn.first();
- expect(authForm.textContent).toEqual(expect.stringContaining('Sign in to comment using'));
+ expect(authForm.text()).toEqual(expect.stringContaining('Sign in to comment using'));
- const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link');
+ const providerLinks = authForm.find('.auth-panel__pseudo-link');
- expect(providerLinks[0].textContent).toEqual('Google');
- expect(providerLinks[1].textContent).toEqual('GitHub');
+ expect(providerLinks.at(0).text()).toEqual('Google');
+ expect(providerLinks.at(1).text()).toEqual('GitHub');
+ });
+
+ describe('sorting', () => {
+ it('should place selected provider first', () => {
+ const element = mount(
+
+ );
+
+ const providerLinks = element
+ .find('.auth-panel__column')
+ .first()
+ .find('.auth-panel__pseudo-link');
+
+ expect(providerLinks.at(0).text()).toEqual('GitHub');
+ expect(providerLinks.at(1).text()).toEqual('Google');
+ expect(providerLinks.at(2).text()).toEqual('Yandex');
+ });
+
+ it('should do nothing if provider not found', () => {
+ const element = mount(
+
+ );
+
+ const providerLinks = element
+ .find('.auth-panel__column')
+ .first()
+ .find('.auth-panel__pseudo-link');
+
+ expect(providerLinks.at(0).text()).toEqual('Google');
+ expect(providerLinks.at(1).text()).toEqual('GitHub');
+ expect(providerLinks.at(2).text()).toEqual('Yandex');
+ });
});
it('should render login form with google and github provider for read-only post', () => {
- const element = (
+ const element = mount(
);
- render(element, container);
-
- const authPanelColumn = container.querySelectorAll('.auth-panel__column');
+ const authPanelColumn = element.find('.auth-panel__column');
expect(authPanelColumn.length).toEqual(2);
- const authForm = authPanelColumn[0];
+ const authForm = authPanelColumn.first();
- expect(authForm.textContent).toEqual(expect.stringContaining('Sign in using Google or GitHub'));
+ expect(authForm.text()).toEqual(expect.stringContaining('Sign in using Google or GitHub'));
- const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link');
+ const providerLinks = authForm.find('.auth-panel__pseudo-link');
- expect(providerLinks[0].textContent).toEqual('Google');
- expect(providerLinks[1].textContent).toEqual('GitHub');
+ expect(providerLinks.at(0).text()).toEqual('Google');
+ expect(providerLinks.at(1).text()).toEqual('GitHub');
});
it('should not render settings if there is no hidden users', () => {
- const element = (
+ const element = mount(
);
- render(element, container);
+ const adminAction = element.find('.auth-panel__admin-action');
- const adminAction = container.querySelector('.auth-panel__admin-action')!;
-
- expect(adminAction).toBe(null);
+ expect(adminAction.exists()).toBe(false);
});
it('should render settings if there is some hidden users', () => {
- const element = (
+ const element = mount(
);
- render(element, container);
+ const adminAction = element.find('.auth-panel__admin-action');
- const adminAction = container.querySelector('.auth-panel__admin-action')!;
-
- expect(adminAction.textContent).toEqual('Show settings');
+ expect(adminAction.text()).toEqual('Show settings');
});
});
describe('For authorized user', () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('should render info about current user', () => {
- const element = ;
+ const element = mount( );
- render(element, container);
-
- const authPanelColumn = container.querySelectorAll('.auth-panel__column');
+ const authPanelColumn = element.find('.auth-panel__column');
expect(authPanelColumn.length).toEqual(2);
- const userInfo = authPanelColumn[0];
+ const userInfo = authPanelColumn.first();
- expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John'));
+ expect(userInfo.text()).toEqual(expect.stringContaining('You signed in as John'));
});
});
describe('For admin user', () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('should render admin action', () => {
- const element = ;
+ const element = mount(
+
+ );
- render(element, container);
+ const adminAction = element.find('.auth-panel__admin-action').first();
- const adminAction = container.querySelector('.auth-panel__admin-action')!;
-
- expect(adminAction.textContent).toEqual('Show settings');
+ expect(adminAction.text()).toEqual('Show settings');
});
});
});
diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx
index 0e8b1e39..b77fd3c1 100644
--- a/frontend/app/components/auth-panel/auth-panel.tsx
+++ b/frontend/app/components/auth-panel/auth-panel.tsx
@@ -11,16 +11,20 @@ import Dropdown, { DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { UserID } from './__user-id';
import { AnonymousLoginForm } from './__anonymous-login-form';
+import { EmailLoginForm, EmailLoginFormConnected } from './__email-login-form';
import { StoreState } from '@app/store';
+import { ProviderState } from '@app/store/provider/reducers';
+import debounce from '@app/utils/debounce';
export interface Props {
user: User | null;
hiddenUsers: StoreState['hiddenUsers'];
- providers: (AuthProvider['name'])[];
sort: Sorting;
isCommentsDisabled: boolean;
theme: Theme;
postInfo: PostInfo;
+ providers: (AuthProvider['name'])[];
+ provider: ProviderState;
onSortChange(s: Sorting): Promise;
onSignIn(p: AuthProvider): Promise;
@@ -34,24 +38,53 @@ export interface Props {
interface State {
isBlockedVisible: boolean;
anonymousUsernameInputValue: string;
+ threshold: number;
+ sortSelectFocused: boolean;
}
export class AuthPanel extends Component {
+ emailLoginRef?: EmailLoginForm;
+
constructor(props: Props) {
super(props);
this.state = {
isBlockedVisible: false,
anonymousUsernameInputValue: 'anon',
+ threshold: 3,
+ sortSelectFocused: false,
};
this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this);
this.toggleCommentsAvailability = this.toggleCommentsAvailability.bind(this);
this.onSortChange = this.onSortChange.bind(this);
this.onSignIn = this.onSignIn.bind(this);
+ this.onEmailSignIn = this.onEmailSignIn.bind(this);
this.handleAnonymousLoginFormSubmut = this.handleAnonymousLoginFormSubmut.bind(this);
this.handleOAuthLogin = this.handleOAuthLogin.bind(this);
this.toggleUserInfoVisibility = this.toggleUserInfoVisibility.bind(this);
+ this.onEmailTitleClick = this.onEmailTitleClick.bind(this);
+ }
+
+ componentWillMount() {
+ this.resizeHandler();
+ window.addEventListener('resize', this.resizeHandler);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.resizeHandler);
+ }
+
+ singInMessageAndSortWidth = 255;
+
+ resizeHandler = debounce(() => {
+ this.setState({
+ threshold: Math.max(3, Math.round((window.innerWidth - this.singInMessageAndSortWidth) / 80)),
+ });
+ }, 100);
+
+ onEmailTitleClick() {
+ this.emailLoginRef && this.emailLoginRef.focus();
}
onSortChange(e: Event) {
@@ -60,6 +93,16 @@ export class AuthPanel extends Component {
}
}
+ onSortFocus = () => {
+ this.setState({ sortSelectFocused: true });
+ };
+
+ onSortBlur = (e: Event) => {
+ this.setState({ sortSelectFocused: false });
+
+ this.onSortChange(e);
+ };
+
toggleBlockedVisibility() {
if (!this.state.isBlockedVisible) {
if (this.props.onBlockedUsersShow) this.props.onBlockedUsersShow();
@@ -94,6 +137,10 @@ export class AuthPanel extends Component {
this.props.onSignIn(provider);
}
+ onEmailSignIn(token: string) {
+ return this.props.onSignIn({ name: 'email', token });
+ }
+
async handleAnonymousLoginFormSubmut(username: string) {
this.onSignIn({ name: 'anonymous', username });
}
@@ -104,152 +151,256 @@ export class AuthPanel extends Component {
this.onSignIn({ name: p } as AuthProvider);
}
- render(props: RenderableProps, { isBlockedVisible }: State) {
- const { user, providers = [], sort, isCommentsDisabled } = props;
- const sortArray = getSortArray(sort);
- const loggedIn = !!user;
- const signInMessage = props.postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using ';
+ renderAuthorized = () => {
+ const { user, onSignOut } = this.props;
+ if (!user) return null;
+
const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_';
- const isSettingsLabelVisible =
- Object.keys(this.props.hiddenUsers).length > 0 || (user && user.admin) || this.state.isBlockedVisible;
return (
-
- {user && (
-
- You signed in as{' '}
-
-
-
-
+
+ You signed in as{' '}
+
+
+
+
- {!isUserAnonymous && (
-
- requestDeletion().then(() => props.onSignOut())}
- >
- Request my data removal
-
-
- )}
- {' '}
- props.onSignOut()}
- >
- Sign out?
-
-
- )}
-
- {IS_STORAGE_AVAILABLE && !loggedIn && (
-
- {signInMessage}
- {providers.map((provider, i) => {
- const comma = i === 0 ? '' : i === providers.length - 1 ? ' or ' : ', ';
-
- if (provider === 'anonymous') {
- return (
-
- {comma}{' '}
-
-
-
-
-
-
- );
- }
-
- return (
-
- {comma}
-
- {PROVIDER_NAMES[provider]}
-
-
- );
- })}
-
- )}
-
- {!IS_STORAGE_AVAILABLE && IS_THIRD_PARTY && (
-
- Disable third-party cookies blocking to sign in or open comments in{' '}
-
- new page
-
-
- )}
-
- {!IS_STORAGE_AVAILABLE && !IS_THIRD_PARTY && (
- Allow cookies to sign in and comment
- )}
-
-
- {isSettingsLabelVisible && (
- this.toggleBlockedVisibility())}
- role="link"
- >
- {isBlockedVisible ? 'Hide' : 'Show'} settings
-
+ {!isUserAnonymous && (
+
+ requestDeletion().then(onSignOut)}>
+ Request my data removal
+
+
)}
+ {' '}
+
+ Sign out?
+
+
+ );
+ };
+
+ renderProvider = (provider: AuthProvider['name'], dropdown: boolean = false) => {
+ if (provider === 'anonymous') {
+ return (
+
+
+
+
+
+ );
+ }
+ if (provider === 'email') {
+ return (
+
+
+ (this.emailLoginRef = ref ? ref.getWrappedInstance() : null)}
+ onSignIn={this.onEmailSignIn}
+ theme={this.props.theme}
+ className="auth-panel__email-login-form"
+ />
+
+
+ );
+ }
+
+ return (
+
+ {PROVIDER_NAMES[provider]}
+
+ );
+ };
+
+ renderOther = (providers: (AuthProvider['name'])[]) => {
+ return (
+
+ {providers.map(provider => (
+ {this.renderProvider(provider, true)}
+ ))}
+
+ );
+ };
+
+ renderUnauthorized = () => {
+ const { user, providers = [], postInfo } = this.props;
+ const { threshold } = this.state;
+ if (user || !IS_STORAGE_AVAILABLE) return null;
+
+ const signInMessage = postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using ';
+ const sortedProviders = ((): typeof providers => {
+ if (!this.props.provider.name) return providers;
+ const lastProviderIndex = providers.indexOf(this.props.provider.name as typeof providers[0]);
+ if (lastProviderIndex < 1) return providers;
+ return [
+ this.props.provider.name as typeof providers[0],
+ ...providers.slice(0, lastProviderIndex),
+ ...providers.slice(lastProviderIndex + 1),
+ ];
+ })();
+
+ const isAboveThreshold = sortedProviders.length > threshold;
+
+ return (
+
+ {signInMessage}
+ {!isAboveThreshold &&
+ sortedProviders.map((provider, i) => {
+ const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ' or ' : ', ';
+
+ return (
+
+ {comma}
+ {this.renderProvider(provider)}
+
+ );
+ })}
+ {isAboveThreshold &&
+ sortedProviders.slice(0, threshold - 1).map((provider, i) => {
+ const comma = i === 0 ? '' : ', ';
+
+ return (
+
+ {comma}
+ {this.renderProvider(provider)}
+
+ );
+ })}
+ {isAboveThreshold && (
+
+ {' or '}
+ {this.renderOther(sortedProviders.slice(threshold - 1))}
+
+ )}
+
+ );
+ };
+
+ renderThirdPartyWarning = () => {
+ if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null;
+ return (
+
+ Disable third-party cookies blocking to sign in or open comments in{' '}
+
+ new page
+
+
+ );
+ };
+
+ renderCookiesWarning = () => {
+ if (IS_STORAGE_AVAILABLE || IS_THIRD_PARTY) return null;
+ return Allow cookies to sign in and comment
;
+ };
+
+ renderSettingsLabel = () => {
+ return (
+ this.toggleBlockedVisibility())}
+ role="link"
+ >
+ {this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
+
+ );
+ };
+
+ renderReadOnlySwitch = () => {
+ const { isCommentsDisabled } = this.props;
+ return (
+ this.toggleCommentsAvailability())}
+ role="link"
+ >
+ {isCommentsDisabled ? 'Enable' : 'Disable'} comments
+
+ );
+ };
+
+ renderSort = () => {
+ const { sort } = this.props;
+ const { sortSelectFocused } = this.state;
+ const sortArray = getSortArray(sort);
+ return (
+
+ Sort by{' '}
+
+
+ {sortArray.find(x => 'selected' in x && x.selected!)!.label}
+
+
+ {sortArray.map(sort => (
+
+ {sort.label}
+
+ ))}
+
+
+
+ );
+ };
+
+ render(props: RenderableProps, { isBlockedVisible }: State) {
+ const {
+ user,
+ postInfo: { read_only },
+ theme,
+ } = props;
+ const isAdmin = user && user.admin;
+ const isSettingsLabelVisible = Object.keys(this.props.hiddenUsers).length > 0 || isAdmin || isBlockedVisible;
+
+ return (
+
+ {this.renderAuthorized()}
+ {this.renderUnauthorized()}
+ {this.renderThirdPartyWarning()}
+ {this.renderCookiesWarning()}
+
+ {isSettingsLabelVisible && this.renderSettingsLabel()}
{isSettingsLabelVisible && ' • '}
- {user && user.admin && (
- this.toggleCommentsAvailability())}
- role="link"
- >
- {isCommentsDisabled ? 'Enable' : 'Disable'} comments
-
- )}
+ {isAdmin && this.renderReadOnlySwitch()}
- {user && user.admin && ' • '}
+ {isAdmin && ' • '}
- {!(user && user.admin) && props.postInfo.read_only && (
- Read-only
- )}
+ {!isAdmin && read_only && Read-only }
-
- Sort by{' '}
-
- {sortArray.find(x => 'selected' in x && x.selected!)!.label}
-
- {sortArray.map(sort => (
-
- {sort.label}
-
- ))}
-
-
-
+ {this.renderSort()}
);
diff --git a/frontend/app/components/auth-panel/index.ts b/frontend/app/components/auth-panel/index.ts
index e3fb738d..7e03ca7f 100644
--- a/frontend/app/components/auth-panel/index.ts
+++ b/frontend/app/components/auth-panel/index.ts
@@ -1,19 +1,22 @@
+import './auth-panel.scss';
+
+import './__readonly-label/auth-panel__readonly-label.scss';
+
+import './__column/auth-panel__column.scss';
+import './__pseudo-link/auth-panel__pseudo-link.scss';
+import './__select/auth-panel__select.scss';
+import './__select-label/auth-panel__select-label.scss';
+import './__select-label-value/auth-panel__select-label-value.scss';
+import './__sort/auth-panel__sort.scss';
+
+import './__user-id/auth-panel__user-id.scss';
+import './__sign-out/auth-panel__sign-out.scss';
+
+import './_theme/_dark/auth-panel_theme_dark.scss';
+import './_theme/_light/auth-panel_theme_light.scss';
+
+import './_logged-in/auth-panel_logged-in.scss';
+
+import './__dropdown-provider/auth-panel__dropdown-provider.scss';
+
export { AuthPanel } from './auth-panel';
-
-require('./auth-panel.scss');
-
-require('./__readonly-label/auth-panel__readonly-label.scss');
-
-require('./__column/auth-panel__column.scss');
-require('./__pseudo-link/auth-panel__pseudo-link.scss');
-require('./__select/auth-panel__select.scss');
-require('./__select-label/auth-panel__select-label.scss');
-require('./__sort/auth-panel__sort.scss');
-
-require('./__user-id/auth-panel__user-id.scss');
-require('./__sign-out/auth-panel__sign-out.scss');
-
-require('./_theme/_dark/auth-panel_theme_dark.scss');
-require('./_theme/_light/auth-panel_theme_light.scss');
-
-require('./_logged-in/auth-panel_logged-in.scss');
diff --git a/frontend/app/components/avatar-icon/index.ts b/frontend/app/components/avatar-icon/index.ts
index bf1046e9..dc67c600 100644
--- a/frontend/app/components/avatar-icon/index.ts
+++ b/frontend/app/components/avatar-icon/index.ts
@@ -1,4 +1,4 @@
-export { AvatarIcon } from './avatar-icon';
+import './avatar-icon.scss';
+import './_default/avatar-icon_default.scss';
-require('./avatar-icon.scss');
-require('./_default/avatar-icon_default.scss');
+export { AvatarIcon } from './avatar-icon';
diff --git a/frontend/app/components/button/index.ts b/frontend/app/components/button/index.ts
index 056a4772..bbd5ea59 100644
--- a/frontend/app/components/button/index.ts
+++ b/frontend/app/components/button/index.ts
@@ -1,8 +1,8 @@
+import './button.scss';
+
+import './_kind/_link/button_kind_link.scss';
+import './_kind/_text/button_kind_text.scss';
+
+import './_focused/button_focused.scss';
+
export { Button } from './button';
-
-require('./button.scss');
-
-require('./_kind/_link/button_kind_link.scss');
-require('./_kind/_text/button_kind_text.scss');
-
-require('./_focused/button_focused.scss');
diff --git a/frontend/app/components/comment/__controls/comment__controls.scss b/frontend/app/components/comment/__controls/comment__controls.scss
index 056a60ad..c01d01bd 100644
--- a/frontend/app/components/comment/__controls/comment__controls.scss
+++ b/frontend/app/components/comment/__controls/comment__controls.scss
@@ -4,10 +4,13 @@
user-select: none;
font-size: 14px;
font-weight: 700;
- opacity: 0;
- &:hover,
- &:focus-within {
- opacity: 1;
+ @media (hover: hover) {
+ opacity: 0;
+
+ &:hover,
+ &:focus-within {
+ opacity: 1;
+ }
}
}
diff --git a/frontend/app/components/comment/__vote/_type/_down/comment__vote_type_down.scss b/frontend/app/components/comment/__vote/_type/_down/comment__vote_type_down.scss
index 5caf0646..4c8db682 100644
--- a/frontend/app/components/comment/__vote/_type/_down/comment__vote_type_down.scss
+++ b/frontend/app/components/comment/__vote/_type/_down/comment__vote_type_down.scss
@@ -2,7 +2,8 @@
transform: scale(1, -1);
margin-left: 4px;
- &.comment__vote_selected, &:hover {
+ &.comment__vote_selected,
+ &:hover {
background-image: url('comment__vote_type_down.svg');
}
}
diff --git a/frontend/app/components/comment/__vote/_type/_up/comment__vote_type_up.scss b/frontend/app/components/comment/__vote/_type/_up/comment__vote_type_up.scss
index c3a78603..5fb20825 100644
--- a/frontend/app/components/comment/__vote/_type/_up/comment__vote_type_up.scss
+++ b/frontend/app/components/comment/__vote/_type/_up/comment__vote_type_up.scss
@@ -1,7 +1,8 @@
.comment__vote_type_up {
margin-right: 4px;
- &.comment__vote_selected, &:hover {
+ &.comment__vote_selected,
+ &:hover {
background-image: url('comment__vote_type_up.svg');
}
}
diff --git a/frontend/app/components/comment/comment.test.tsx b/frontend/app/components/comment/comment.test.tsx
index 14a26ebd..7f86b714 100644
--- a/frontend/app/components/comment/comment.test.tsx
+++ b/frontend/app/components/comment/comment.test.tsx
@@ -1,9 +1,9 @@
/** @jsx h */
-import { h, render } from 'preact';
+import { h } from 'preact';
+import { mount } from 'enzyme';
import { Props, Comment } from './comment';
-import { createDomContainer } from '../../testUtils';
import { User, Comment as CommentType, PostInfo } from '@app/common/types';
-import { delay } from '@app/store/comments/utils';
+import { sleep } from '@app/utils/sleep';
const DefaultProps: Partial = {
post_info: {
@@ -30,193 +30,196 @@ const DefaultProps: Partial = {
describe(' ', () => {
describe('voting', () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('disabled on user info widget', () => {
- const element = ;
- render(element, container);
+ const element = mount( );
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- for (const b of voteButtons as any) {
- expect(b.getAttribute('aria-disabled')).toStrictEqual('true');
- expect(b.getAttribute('title')).toStrictEqual("Voting allowed only on post's page");
- }
+ voteButtons.forEach(b => {
+ expect(b.getDOMNode().getAttribute('aria-disabled')).toStrictEqual('true');
+ expect(b.getDOMNode().getAttribute('title')).toStrictEqual("Voting allowed only on post's page");
+ });
});
it('disabled on read only post', () => {
- const element = (
-
+ const element = mount(
+
);
- render(element, container);
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- for (const b of voteButtons as any) {
- expect(b.getAttribute('aria-disabled')).toStrictEqual('true');
- expect(b.getAttribute('title')).toStrictEqual("Can't vote on read-only topics");
- }
+ voteButtons.forEach(b => {
+ expect(b.getDOMNode().getAttribute('aria-disabled')).toStrictEqual('true');
+ expect(b.getDOMNode().getAttribute('title')).toStrictEqual("Can't vote on read-only topics");
+ });
});
it('disabled for deleted comment', () => {
- const element = (
+ const element = mount(
// ahem
-
+
);
- render(element, container);
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- for (const b of voteButtons as any) {
- expect(b.getAttribute('aria-disabled')).toStrictEqual('true');
- expect(b.getAttribute('title')).toStrictEqual("Can't vote for deleted comment");
- }
+ voteButtons.forEach(b => {
+ expect(b.getDOMNode().getAttribute('aria-disabled')).toStrictEqual('true');
+ expect(b.getDOMNode().getAttribute('title')).toStrictEqual("Can't vote for deleted comment");
+ });
});
it('disabled for guest', () => {
- const element = (
+ const element = mount(
);
- render(element, container);
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- for (const b of voteButtons as any) {
- expect(b.getAttribute('aria-disabled')).toStrictEqual('true');
- expect(b.getAttribute('title')).toStrictEqual("Can't vote for your own comment");
- }
+ voteButtons.forEach(b => {
+ expect(b.getDOMNode().getAttribute('aria-disabled')).toStrictEqual('true');
+ expect(b.getDOMNode().getAttribute('title')).toStrictEqual("Can't vote for your own comment");
+ });
});
it('disabled for own comment', () => {
- const element = ;
- render(element, container);
+ const element = mount( );
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- for (const b of voteButtons as any) {
- expect(b.getAttribute('aria-disabled')).toStrictEqual('true');
- expect(b.getAttribute('title')).toStrictEqual('Sign in to vote');
- }
+ voteButtons.forEach(b => {
+ expect(b.getDOMNode().getAttribute('aria-disabled')).toStrictEqual('true');
+ expect(b.getDOMNode().getAttribute('title')).toStrictEqual('Sign in to vote');
+ });
});
it('disabled for already upvoted comment', async () => {
const voteSpy = jest.fn(async () => {});
- const element = (
+ const element = mount(
);
- render(element, container);
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- expect(voteButtons[0].getAttribute('aria-disabled')).toStrictEqual('true');
- voteButtons[0].click();
- await delay(100);
+ expect(
+ voteButtons
+ .at(0)
+ .getDOMNode()
+ .getAttribute('aria-disabled')
+ ).toStrictEqual('true');
+ voteButtons.at(0).simulate('click');
+ await sleep(100);
expect(voteSpy).not.toBeCalled();
- expect(voteButtons[1].getAttribute('aria-disabled')).toStrictEqual('false');
- voteButtons[1].click();
- await delay(100);
+ expect(
+ voteButtons
+ .at(1)
+ .getDOMNode()
+ .getAttribute('aria-disabled')
+ ).toStrictEqual('false');
+ voteButtons.at(1).simulate('click');
+ await sleep(100);
expect(voteSpy).toBeCalled();
}, 30000);
it('disabled for already downvoted comment', async () => {
const voteSpy = jest.fn(async () => {});
- const element = (
+ const element = mount(
);
- render(element, container);
- const voteButtons = container.querySelectorAll('.comment__vote');
+ const voteButtons = element.find('.comment__vote');
expect(voteButtons.length).toStrictEqual(2);
- expect(voteButtons[1].getAttribute('aria-disabled')).toStrictEqual('true');
- voteButtons[1].click();
- await delay(100);
+ expect(
+ voteButtons
+ .at(1)
+ .getDOMNode()
+ .getAttribute('aria-disabled')
+ ).toStrictEqual('true');
+ voteButtons.at(1).simulate('click');
+ await sleep(100);
expect(voteSpy).not.toBeCalled();
- expect(voteButtons[0].getAttribute('aria-disabled')).toStrictEqual('false');
- voteButtons[0].click();
- await delay(100);
+ expect(
+ voteButtons
+ .at(0)
+ .getDOMNode()
+ .getAttribute('aria-disabled')
+ ).toStrictEqual('false');
+ voteButtons.at(0).simulate('click');
+ await sleep(100);
expect(voteSpy).toBeCalled();
}, 30000);
});
describe('admin controls', () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('for admin if shows admin controls', () => {
- const element = ;
- render(element, container);
+ const element = mount(
+
+ );
- const controls = container.querySelectorAll('.comment__controls > span');
- expect(controls!.length).toBe(5);
- expect(controls![0].textContent).toBe('Copy');
- expect(controls![1].textContent).toBe('Pin');
- expect(controls![2].textContent).toBe('Hide');
- expect(controls![3].childNodes[0].textContent).toBe('Block');
- expect(controls![4].textContent).toBe('Delete');
+ const controls = element.find('.comment__controls > span');
+ expect(controls.length).toBe(5);
+ expect(controls.at(0).text()).toEqual('Copy');
+ expect(controls.at(1).text()).toEqual('Pin');
+ expect(controls.at(2).text()).toEqual('Hide');
+ expect(controls.at(3).getDOMNode().childNodes[0].textContent).toEqual('Block');
+ expect(controls.at(4).text()).toEqual('Delete');
});
it('for regular user it shows only "hide"', () => {
- const element = ;
- render(element, container);
+ const element = mount(
+
+ );
- const controls = container.querySelectorAll('.comment__controls > span');
- expect(controls!.length).toBe(1);
- expect(controls![0].textContent).toBe('Hide');
+ const controls = element.find('.comment__controls > span');
+ expect(controls.length).toBe(1);
+ expect(controls.at(0).text()).toEqual('Hide');
});
it('verification badge clickable for admin', () => {
- const element = ;
- render(element, container);
+ const element = mount(
+
+ );
- const controls = container.querySelector('.comment__verification')!;
- expect(controls.classList.contains('comment__verification_clickable')).toBe(true);
+ const controls = element.find('.comment__verification').first();
+ expect(controls.hasClass('comment__verification_clickable')).toEqual(true);
});
it('verification badge not clickable for regular user', () => {
- const element = (
+ const element = mount(
);
- render(element, container);
- const controls = container.querySelector('.comment__verification')!;
- expect(controls.classList.contains('comment__verification_clickable')).toBe(false);
+ const controls = element.find('.comment__verification').first();
+ expect(controls.hasClass('comment__verification_clickable')).toEqual(false);
});
});
});
diff --git a/frontend/app/components/comment/comment.tsx b/frontend/app/components/comment/comment.tsx
index cac52d67..ed80a559 100644
--- a/frontend/app/components/comment/comment.tsx
+++ b/frontend/app/components/comment/comment.tsx
@@ -42,6 +42,7 @@ export type Props = {
disabled?: boolean;
collapsed?: boolean;
theme: Theme;
+ inView?: boolean;
level?: number;
mix?: string;
getPreview?: typeof getPreview;
@@ -64,6 +65,7 @@ export interface State {
* without server response
*/
cachedScore: number;
+ initial: boolean;
}
export class Comment extends Component {
@@ -80,6 +82,7 @@ export class Comment extends Component {
voteErrorMessage: null,
scoreDelta: 0,
cachedScore: props.data.score,
+ initial: true,
};
this.votingPromise = Promise.resolve();
@@ -91,10 +94,20 @@ export class Comment extends Component {
this.blockUser = debounce(this.blockUser, 100).bind(this);
}
+ // getHandleClickProps = (handler?: (e: KeyboardEvent | MouseEvent) => void) => {
+ // if (this.state.initial) return null;
+ // if (this.props.inView === false) return null;
+ // return getHandleClickProps(handler);
+ // };
+
componentWillReceiveProps(nextProps: Props) {
this.updateState(nextProps);
}
+ componentDidMount() {
+ this.setState({ initial: false });
+ }
+
updateState = (props: Props) => {
this.setState({
scoreDelta: props.data.vote,
@@ -454,15 +467,12 @@ export class Comment extends Component {
const o = {
...props.data,
controversyText: `Controversy: ${(props.data.controversy || 0).toFixed(2)}`,
- text: props.data.text.length
- ? props.view === 'preview'
+ text:
+ props.view === 'preview'
? getTextSnippet(props.data.text)
- : props.data.text
- : this.props.isUserBanned
- ? 'This user was blocked'
- : props.data.delete
- ? 'This comment was deleted'
- : props.data.text,
+ : props.data.delete
+ ? 'This comment was deleted'
+ : props.data.text,
time: formatTime(new Date(props.data.time)),
orig: isEditing
? props.data.orig &&
@@ -532,6 +542,19 @@ export class Comment extends Component {
);
}
+ if (this.props.inView === false) {
+ const [width, height] = this.base ? [this.base.scrollWidth, this.base.scrollHeight] : [100, 100];
+ return (
+
+ );
+ }
+
return (
{
)}
- {isAdmin && props.isUserBanned && props.view !== 'user' && Blocked }
+ {props.isUserBanned && props.view !== 'user' && Blocked }
{isAdmin && !props.isUserBanned && props.data.delete && Deleted }
diff --git a/frontend/app/components/comment/connected-comment.ts b/frontend/app/components/comment/connected-comment.ts
index 4e5dc89b..994288b3 100644
--- a/frontend/app/components/comment/connected-comment.ts
+++ b/frontend/app/components/comment/connected-comment.ts
@@ -3,6 +3,8 @@
* and should be importded explicitly
*/
+import './styles';
+
import { Comment as CommentType } from '@app/common/types';
import { connect } from 'preact-redux';
@@ -40,7 +42,7 @@ const mapStateToProps = (state: StoreState, cprops: { data: CommentType }) => {
> = {
editMode: getCommentMode(state, cprops.data.id),
user: state.user,
- isUserBanned: state.bannedUsers.find(u => u.id === cprops.data.user.id) !== undefined,
+ isUserBanned: cprops.data.user.block || state.bannedUsers.find(u => u.id === cprops.data.user.id) !== undefined,
post_info: state.info,
isCommentsDisabled: state.info.read_only || false,
theme: state.theme,
diff --git a/frontend/app/components/comment/styles.ts b/frontend/app/components/comment/styles.ts
index 4acbc281..2a9c9791 100644
--- a/frontend/app/components/comment/styles.ts
+++ b/frontend/app/components/comment/styles.ts
@@ -1,51 +1,51 @@
+import './comment.scss';
+
+import './__action/comment__action.scss';
+import './__action/_type/_collapse/comment__action_type_collapse.scss';
+import './__action/_type/_edit/comment__action_type_edit.scss';
+import './__action/_type/_delete/comment__action_type_delete.scss';
+
+import './__edit-timer/comment__edit-timer.scss';
+
+import './__body/comment__body.scss';
+
+import './__control/comment__control.scss';
+import './__control/_select/comment__control_select.scss';
+import './__control/_select-label/comment__control_select-label.scss';
+import './__control/_view/_inactive/comment__control_view_inactive.scss';
+
+import './__controls/comment__controls.scss';
+import './__info/comment__info.scss';
+import './__input/comment__input.scss';
+import './__link-to-parent/comment__link-to-parent.scss';
+import './__score/comment__score.scss';
+import './__score-value/comment__score-value.scss';
+import './__status/comment__status.scss';
+import './__text/comment__text.scss';
+import './__time/comment__time.scss';
+import './__user-id/comment__user-id.scss';
+import './__username/comment__username.scss';
+
+import './__verification/comment__verification.scss';
+import './__verification/_active/comment__verification_active.scss';
+import './__verification/_clickable/comment__verification_clickable.scss';
+
+import './__vote/comment__vote.scss';
+import './__vote/_disabled/comment__vote_disabled.scss';
+import './__vote/_selected/comment__vote_selected.scss';
+import './__vote/_type/_down/comment__vote_type_down.scss';
+import './__vote/_type/_up/comment__vote_type_up.scss';
+
+import './_collapsed/comment_collapsed.scss';
+import './_editing/comment_editing.scss';
+import './_replying/comment_replying.scss';
+import './_useless/comment_useless.scss';
+
+import './_view/_admin/comment_view_admin.scss';
+import './_view/_preview/comment_view_preview.scss';
+import './_view/_user/comment_view_user.scss';
+
+import './_theme/_dark/comment_theme_dark.scss';
+import './_theme/_light/comment_theme_light.scss';
+
import '@app/components/raw-content';
-
-require('./comment.scss');
-
-require('./__action/comment__action.scss');
-require('./__action/_type/_collapse/comment__action_type_collapse.scss');
-require('./__action/_type/_edit/comment__action_type_edit.scss');
-require('./__action/_type/_delete/comment__action_type_delete.scss');
-
-require('./__edit-timer/comment__edit-timer.scss');
-
-require('./__body/comment__body.scss');
-
-require('./__control/comment__control.scss');
-require('./__control/_select/comment__control_select.scss');
-require('./__control/_select-label/comment__control_select-label.scss');
-require('./__control/_view/_inactive/comment__control_view_inactive.scss');
-
-require('./__controls/comment__controls.scss');
-require('./__info/comment__info.scss');
-require('./__input/comment__input.scss');
-require('./__link-to-parent/comment__link-to-parent.scss');
-require('./__score/comment__score.scss');
-require('./__score-value/comment__score-value.scss');
-require('./__status/comment__status.scss');
-require('./__text/comment__text.scss');
-require('./__time/comment__time.scss');
-require('./__user-id/comment__user-id.scss');
-require('./__username/comment__username.scss');
-
-require('./__verification/comment__verification.scss');
-require('./__verification/_active/comment__verification_active.scss');
-require('./__verification/_clickable/comment__verification_clickable.scss');
-
-require('./__vote/comment__vote.scss');
-require('./__vote/_disabled/comment__vote_disabled.scss');
-require('./__vote/_selected/comment__vote_selected.scss');
-require('./__vote/_type/_down/comment__vote_type_down.scss');
-require('./__vote/_type/_up/comment__vote_type_up.scss');
-
-require('./_collapsed/comment_collapsed.scss');
-require('./_editing/comment_editing.scss');
-require('./_replying/comment_replying.scss');
-require('./_useless/comment_useless.scss');
-
-require('./_view/_admin/comment_view_admin.scss');
-require('./_view/_preview/comment_view_preview.scss');
-require('./_view/_user/comment_view_user.scss');
-
-require('./_theme/_dark/comment_theme_dark.scss');
-require('./_theme/_light/comment_theme_light.scss');
diff --git a/frontend/app/components/countdown/index.tsx b/frontend/app/components/countdown/index.tsx
index be17bda9..518f385f 100644
--- a/frontend/app/components/countdown/index.tsx
+++ b/frontend/app/components/countdown/index.tsx
@@ -32,6 +32,9 @@ export default class Countdown extends Component {
});
this.start();
}
+ componentWillUnmount() {
+ window.clearInterval(this.intervalID);
+ }
shouldComponentUpdate() {
return false;
}
diff --git a/frontend/app/components/dropdown/__item/dropdown__item.scss b/frontend/app/components/dropdown/__item/dropdown__item.scss
index 7ad17863..1f1a37ba 100644
--- a/frontend/app/components/dropdown/__item/dropdown__item.scss
+++ b/frontend/app/components/dropdown/__item/dropdown__item.scss
@@ -1,6 +1,6 @@
.dropdown__item {
- a,
- button {
+ & > a,
+ & > button {
display: block;
width: 100%;
text-align: left;
diff --git a/frontend/app/components/dropdown/_active/dropdown_active.scss b/frontend/app/components/dropdown/_active/dropdown_active.scss
index 5a3e604b..3316d0eb 100644
--- a/frontend/app/components/dropdown/_active/dropdown_active.scss
+++ b/frontend/app/components/dropdown/_active/dropdown_active.scss
@@ -1,5 +1,5 @@
.dropdown_active {
- .dropdown__content {
+ & > .dropdown__content {
display: block;
}
}
diff --git a/frontend/app/components/dropdown/dropdown.tsx b/frontend/app/components/dropdown/dropdown.tsx
index 82359008..b4ffdda0 100644
--- a/frontend/app/components/dropdown/dropdown.tsx
+++ b/frontend/app/components/dropdown/dropdown.tsx
@@ -4,6 +4,7 @@ import b from 'bem-react-helper';
import { Button } from '@app/components/button';
import { Theme } from '@app/common/types';
+import { sleep } from '@app/utils/sleep';
interface Props {
title: string;
@@ -13,10 +14,13 @@ interface Props {
onTitleClick?: () => void;
mix?: string;
theme: Theme;
+ onOpen?: (root: HTMLDivElement) => unknown;
+ onClose?: (root: HTMLDivElement) => unknown;
}
interface State {
isActive: boolean;
+ contentTranslateX: number;
}
export default class Dropdown extends Component {
@@ -27,53 +31,149 @@ export default class Dropdown extends Component {
this.state = {
isActive: props.isActive || false,
+ contentTranslateX: 0,
};
+
+ this.onOutsideClick = this.onOutsideClick.bind(this);
+ this.receiveMessage = this.receiveMessage.bind(this);
+ this.__onOpen = this.__onOpen.bind(this);
+ this.__onClose = this.__onClose.bind(this);
}
onTitleClick() {
- this.setState({
- isActive: !this.state.isActive,
- });
+ const isActive = !this.state.isActive;
+ const contentTranslateX = isActive ? this.state.contentTranslateX : 0;
+ this.setState(
+ {
+ contentTranslateX,
+ isActive,
+ },
+ async () => {
+ await this.__adjustDropDownContent();
+ if (isActive) {
+ this.__onOpen();
+ this.props.onOpen && this.props.onOpen(this.rootNode!);
+ } else {
+ this.__onClose();
+ this.props.onClose && this.props.onClose(this.rootNode!);
+ }
- if (this.props.onTitleClick) {
- this.props.onTitleClick();
+ if (this.props.onTitleClick) {
+ this.props.onTitleClick();
+ }
+ }
+ );
+ }
+
+ storedDocumentHeight: string | null = null;
+ storedDocumentHeightSet: boolean = false;
+ checkInterval: number | undefined = undefined;
+
+ __onOpen() {
+ const isChildOfDropDown = (() => {
+ if (!this.rootNode) return false;
+ let parent = this.rootNode.parentElement!;
+ while (parent !== document.body) {
+ if (parent.classList.contains('dropdown')) return true;
+ parent = parent.parentElement!;
+ }
+ return false;
+ })();
+ if (isChildOfDropDown) return;
+
+ this.storedDocumentHeight = document.body.style.minHeight;
+ this.storedDocumentHeightSet = true;
+
+ let prevDcBottom: number | null = null;
+
+ this.checkInterval = window.setInterval(() => {
+ if (!this.rootNode || !this.state.isActive) return;
+ const windowHeight = window.innerHeight;
+ const dcBottom = (() => {
+ const dc = Array.from(this.rootNode.children).find(c => c.classList.contains('dropdown__content'));
+ if (!dc) return 0;
+ const rect = dc.getBoundingClientRect();
+ return window.scrollY + Math.abs(rect.top) + dc.scrollHeight + 10;
+ })();
+ if (prevDcBottom === null && dcBottom <= windowHeight) return;
+ if (dcBottom !== prevDcBottom) {
+ prevDcBottom = dcBottom;
+ document.body.style.minHeight = dcBottom + 'px';
+ }
+ }, 100);
+ }
+
+ __onClose() {
+ window.clearInterval(this.checkInterval);
+ if (this.storedDocumentHeightSet) {
+ document.body.style.minHeight = this.storedDocumentHeight;
}
}
+ async __adjustDropDownContent() {
+ if (!this.rootNode) return;
+ const dc = this.rootNode.querySelector('.dropdown__content');
+ if (!dc) return;
+ await sleep(10);
+ const rect = dc.getBoundingClientRect();
+ if (rect.left > 0) {
+ const wWindow = window.innerWidth;
+ if (rect.right <= wWindow) return;
+ const delta = rect.right - wWindow;
+ const max = Math.min(rect.left, delta);
+ this.setState({
+ contentTranslateX: -max,
+ });
+ return;
+ }
+ this.setState({
+ contentTranslateX: -rect.left,
+ });
+ }
+
receiveMessage(e: { data: string | object }) {
try {
const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
- if (data.clickOutside) {
- if (this.state.isActive) {
- this.setState({
- isActive: false,
- });
+ if (!data.clickOutside) return;
+ if (!this.state.isActive) return;
+ this.setState(
+ {
+ contentTranslateX: 0,
+ isActive: false,
+ },
+ () => {
+ this.__onClose();
+ this.props.onClose && this.props.onClose(this.rootNode!);
}
- }
+ );
} catch (e) {}
}
onOutsideClick(e: MouseEvent) {
- if (this.rootNode && !this.rootNode.contains(e.target as Node)) {
- if (this.state.isActive) {
- this.setState({
- isActive: false,
- });
+ if (!this.rootNode || this.rootNode.contains(e.target as Node) || !this.state.isActive) return;
+ this.setState(
+ {
+ contentTranslateX: 0,
+ isActive: false,
+ },
+ () => {
+ this.__onClose();
+ this.props.onClose && this.props.onClose(this.rootNode!);
}
- }
+ );
}
componentDidMount() {
- document.addEventListener('click', e => this.onOutsideClick(e));
+ document.addEventListener('click', this.onOutsideClick);
- window.addEventListener('message', e => this.receiveMessage(e));
+ window.addEventListener('message', this.receiveMessage);
}
componentWillUnmount() {
- document.removeEventListener('click', e => this.onOutsideClick(e));
+ document.removeEventListener('click', this.onOutsideClick);
- window.removeEventListener('message', e => this.receiveMessage(e));
+ window.removeEventListener('message', this.receiveMessage);
}
render(props: RenderableProps, { isActive }: State) {
@@ -93,7 +193,12 @@ export default class Dropdown extends Component {
{title}
-
+
{heading &&
{heading}
}
{children}
diff --git a/frontend/app/components/dropdown/index.ts b/frontend/app/components/dropdown/index.ts
index ff172454..c079805c 100644
--- a/frontend/app/components/dropdown/index.ts
+++ b/frontend/app/components/dropdown/index.ts
@@ -1,16 +1,16 @@
+import './dropdown.scss';
+import './_active/dropdown_active.scss';
+
+import './__item/dropdown__item.scss';
+import './__items/dropdown__items.scss';
+import './__title/dropdown__title.scss';
+import './__content/dropdown__content.scss';
+
+import './_theme/_dark/dropdown_theme_dark.scss';
+import './_theme/_light/dropdown_theme_light.scss';
+
import Dropdown from './dropdown';
export default Dropdown;
export { default as DropdownItem } from './__item';
-
-require('./dropdown.scss');
-require('./_active/dropdown_active.scss');
-
-require('./__item/dropdown__item.scss');
-require('./__items/dropdown__items.scss');
-require('./__title/dropdown__title.scss');
-require('./__content/dropdown__content.scss');
-
-require('./_theme/_dark/dropdown_theme_dark.scss');
-require('./_theme/_light/dropdown_theme_light.scss');
diff --git a/frontend/app/components/input/__markdown-toolbar/input__markdown-toolbar.scss b/frontend/app/components/input/__markdown-toolbar/input__markdown-toolbar.scss
index 1122ca4e..fd58dca5 100644
--- a/frontend/app/components/input/__markdown-toolbar/input__markdown-toolbar.scss
+++ b/frontend/app/components/input/__markdown-toolbar/input__markdown-toolbar.scss
@@ -3,6 +3,14 @@
float: right;
}
+.input__toolbar-file-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+}
+
.input__toolbar-item {
background: none;
border: 0;
diff --git a/frontend/app/components/input/input.tsx b/frontend/app/components/input/input.tsx
index 158982e8..8478e8a9 100644
--- a/frontend/app/components/input/input.tsx
+++ b/frontend/app/components/input/input.tsx
@@ -58,7 +58,7 @@ interface State {
const Labels = {
main: 'Send',
- edit: 'Edit',
+ edit: 'Save',
reply: 'Reply',
};
@@ -92,6 +92,7 @@ export class Input extends Component
{
this.appendError = this.appendError.bind(this);
this.uploadImage = this.uploadImage.bind(this);
this.uploadImages = this.uploadImages.bind(this);
+ this.onPaste = this.onPaste.bind(this);
}
componentWillReceiveProps(nextProps: Props) {
@@ -135,6 +136,15 @@ export class Input extends Component {
});
}
+ async onPaste(e: ClipboardEvent) {
+ if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
+ return;
+ }
+ e.preventDefault();
+ const files = Array.from(e.clipboardData.files);
+ await this.uploadImages(files);
+ }
+
send(e: Event) {
const text = this.state.text;
const props = this.props;
@@ -354,11 +364,16 @@ export class Input extends Component {
onDrop={this.onDrop}
>
-
+
(this.textAreaRef = ref)}
className="input__field"
placeholder="Your comment here"
@@ -368,6 +383,7 @@ export class Input extends Component {
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!props.autofocus}
+ spellcheck={true}
/>
{charactersLeft < 100 && {charactersLeft} }
diff --git a/frontend/app/components/input/markdown-toolbar-icons/image-icon.tsx b/frontend/app/components/input/markdown-toolbar-icons/image-icon.tsx
new file mode 100644
index 00000000..d1766ab1
--- /dev/null
+++ b/frontend/app/components/input/markdown-toolbar-icons/image-icon.tsx
@@ -0,0 +1,13 @@
+/** @jsx h */
+import { h } from 'preact';
+
+export default function ImageIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/components/input/markdown-toolbar.tsx b/frontend/app/components/input/markdown-toolbar.tsx
index a50c9ba5..9c29ebe7 100644
--- a/frontend/app/components/input/markdown-toolbar.tsx
+++ b/frontend/app/components/input/markdown-toolbar.tsx
@@ -7,11 +7,23 @@ import ItalicIcon from './markdown-toolbar-icons/italic-icon';
import QuoteIcon from './markdown-toolbar-icons/quote-icon';
import CodeIcon from './markdown-toolbar-icons/code-icon';
import LinkIcon from './markdown-toolbar-icons/link-icon';
+import ImageIcon from './markdown-toolbar-icons/image-icon';
import UnorderedListIcon from './markdown-toolbar-icons/unordered-list-icon';
import OrderedListIcon from './markdown-toolbar-icons/ordered-list-icon';
interface Props {
textareaId: string;
+ uploadImages: (files: File[]) => Promise;
+ allowUpload: boolean;
+}
+
+interface FileEventTarget extends EventTarget {
+ readonly files: FileList | null;
+ value: string | null;
+}
+
+interface FileInputEvent extends Event {
+ readonly currentTarget: FileEventTarget | null;
}
const boldLabel = 'Add bold text ';
@@ -22,8 +34,20 @@ const codeLabel = 'Insert a code';
const linkLabel = 'Add a link ';
const unorderedListLabel = 'Add a bulleted list';
const orderedListLabel = 'Add a numbered list';
+const attachImageLabel = 'Attach the image, drag & drop or paste from clipboard';
export default class MarkdownToolbar extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.uploadImages = this.uploadImages.bind(this);
+ }
+ async uploadImages(e: Event) {
+ const currentTarget = (e as FileInputEvent).currentTarget;
+ if (!(this.props.allowUpload && currentTarget && currentTarget.files && currentTarget.files.length !== 0)) return;
+ const files = Array.from(currentTarget.files);
+ await this.props.uploadImages(files);
+ currentTarget.value = null;
+ }
render(props: RenderableProps) {
return (
@@ -48,6 +72,12 @@ export default class MarkdownToolbar extends Component {
+ {this.props.allowUpload ? (
+
+
+
+
+ ) : null}
diff --git a/frontend/app/components/input/styles.ts b/frontend/app/components/input/styles.ts
index 450bcf82..c75b61dc 100644
--- a/frontend/app/components/input/styles.ts
+++ b/frontend/app/components/input/styles.ts
@@ -1,25 +1,25 @@
+import './input.scss';
+
+import './__actions/input__actions.scss';
+
+import './__button/input__button.scss';
+import './__button/_type/_preview/input__button_type_preview.scss';
+import './__button/_type/_send/input__button_type_send.scss';
+
+import './__control-panel/input__control-panel.scss';
+import './__counter/input__counter.scss';
+import './__error/input__error.scss';
+import './__field/input__field.scss';
+import './__field-wrapper/input__field-wrapper.scss';
+import './__preview/input__preview.scss';
+import './__preview-wrapper/input__preview-wrapper.scss';
+import './__rss/input__rss.scss';
+import './__rss-link/input__rss-link.scss';
+import './__markdown/input__markdown.scss';
+import './__markdown-link/input__markdown-link.scss';
+import './__markdown-toolbar/input__markdown-toolbar.scss';
+
+import './_theme/_dark/input_theme_dark.scss';
+import './_theme/_light/input_theme_light.scss';
+
import '@app/components/raw-content';
-
-require('./input.scss');
-
-require('./__actions/input__actions.scss');
-
-require('./__button/input__button.scss');
-require('./__button/_type/_preview/input__button_type_preview.scss');
-require('./__button/_type/_send/input__button_type_send.scss');
-
-require('./__control-panel/input__control-panel.scss');
-require('./__counter/input__counter.scss');
-require('./__error/input__error.scss');
-require('./__field/input__field.scss');
-require('./__field-wrapper/input__field-wrapper.scss');
-require('./__preview/input__preview.scss');
-require('./__preview-wrapper/input__preview-wrapper.scss');
-require('./__rss/input__rss.scss');
-require('./__rss-link/input__rss-link.scss');
-require('./__markdown/input__markdown.scss');
-require('./__markdown-link/input__markdown-link.scss');
-require('./__markdown-toolbar/input__markdown-toolbar.scss');
-
-require('./_theme/_dark/input_theme_dark.scss');
-require('./_theme/_light/input_theme_light.scss');
diff --git a/frontend/app/components/list-comments/index.ts b/frontend/app/components/list-comments/index.ts
index 7815fcc0..6a0df3e1 100644
--- a/frontend/app/components/list-comments/index.ts
+++ b/frontend/app/components/list-comments/index.ts
@@ -1,5 +1,5 @@
+import './list-comments.scss';
+
+import './__item/list-comments__item.scss';
+
export { ListComments } from './list-comments';
-
-require('./list-comments.scss');
-
-require('./__item/list-comments__item.scss');
diff --git a/frontend/app/components/preloader/preloader.test.tsx b/frontend/app/components/preloader/preloader.test.tsx
index 68c2b7c0..9386067e 100644
--- a/frontend/app/components/preloader/preloader.test.tsx
+++ b/frontend/app/components/preloader/preloader.test.tsx
@@ -1,18 +1,12 @@
/** @jsx h */
-import { h, render } from 'preact';
+import { h } from 'preact';
+import { mount } from 'enzyme';
import Preloader from './preloader';
-import { createDomContainer } from '@app/testUtils';
describe(` `, () => {
- let container: HTMLElement;
-
- createDomContainer(domContainer => {
- container = domContainer;
- });
-
it('should render Preloader', () => {
- render( , container);
+ const element = mount( );
- expect(container.children[0].className).toEqual('preloader root__preloader');
+ expect(element.childAt(0).hasClass('preloader root__preloader')).toEqual(true);
});
});
diff --git a/frontend/app/components/raw-content/index.ts b/frontend/app/components/raw-content/index.ts
index b66e8692..c5a58809 100644
--- a/frontend/app/components/raw-content/index.ts
+++ b/frontend/app/components/raw-content/index.ts
@@ -1,4 +1,4 @@
-require('./raw-content.scss');
+import './raw-content.scss';
-require('./_theme/_dark/raw-content_theme_dark.scss');
-require('./_theme/_light/raw-content_theme_light.scss');
+import './_theme/_dark/raw-content_theme_dark.scss';
+import './_theme/_light/raw-content_theme_light.scss';
diff --git a/frontend/app/components/root/in-view/in-view.tsx b/frontend/app/components/root/in-view/in-view.tsx
new file mode 100644
index 00000000..ff5d85ca
--- /dev/null
+++ b/frontend/app/components/root/in-view/in-view.tsx
@@ -0,0 +1,72 @@
+import { Component } from 'preact';
+import { sleep } from '@app/utils/sleep';
+
+interface Props {
+ children: (props: { inView: boolean; ref: (ref: Component) => Component }) => JSX.Element;
+}
+
+interface State {
+ inView: boolean;
+ ref: Element | undefined;
+}
+
+const instance_map: Map> = new Map();
+
+const observer = new IntersectionObserver(
+ entries => {
+ entries.forEach(e => {
+ const instance = instance_map.get(e.target);
+ if (!instance) return;
+ instance.setState({
+ inView: e.isIntersecting,
+ });
+ });
+ },
+ {
+ rootMargin: '50px',
+ }
+);
+
+export class InView extends Component {
+ state: State = {
+ inView: false,
+ ref: undefined,
+ };
+
+ componentWillUpdate(_nextProps: Props, nextState: State) {
+ if (this.state.ref === nextState.ref) return;
+
+ if (this.state.ref instanceof Element) {
+ observer.unobserve(this.state.ref);
+ instance_map.delete(this.state.ref);
+ }
+
+ if (nextState.ref instanceof Element) {
+ observer.observe(nextState.ref);
+ instance_map.set(nextState.ref, this);
+ }
+ }
+
+ refSetter = async (ref: Component | null) => {
+ await sleep(1);
+ const el = ref ? ref.base : undefined;
+ if (el === this.state.ref) return;
+ this.setState({
+ ref: ref ? ref.base : undefined,
+ });
+ };
+
+ componentWillUnmount() {
+ if (!(this.state.ref instanceof Element)) return;
+
+ observer.unobserve(this.state.ref);
+ instance_map.delete(this.state.ref);
+ }
+
+ render() {
+ const props = { inView: this.state.inView, ref: this.refSetter };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const r = (this.props.children as any)[0](props);
+ return r;
+ }
+}
diff --git a/frontend/app/components/root/index.ts b/frontend/app/components/root/index.ts
index 2db6192e..17b01efa 100644
--- a/frontend/app/components/root/index.ts
+++ b/frontend/app/components/root/index.ts
@@ -1,15 +1,15 @@
+import './root.scss';
+
+import './__copyright/root__copyright.scss';
+import './__input/root__input.scss';
+import './__preloader/root__preloader.scss';
+import './__pinned-comment/root__pinned-comment.scss';
+import './__pinned-comments/root__pinned-comments.scss';
+import './__show-more/root__show-more.scss';
+import './__thread/root__thread.scss';
+import './__threads/root__threads.scss';
+
+import './_theme/_dark/root_theme_dark.scss';
+import './_theme/_light/root_theme_light.scss';
+
export { Root, ConnectedRoot } from './root';
-
-require('./root.scss');
-
-require('./__copyright/root__copyright.scss');
-require('./__input/root__input.scss');
-require('./__preloader/root__preloader.scss');
-require('./__pinned-comment/root__pinned-comment.scss');
-require('./__pinned-comments/root__pinned-comments.scss');
-require('./__show-more/root__show-more.scss');
-require('./__thread/root__thread.scss');
-require('./__threads/root__threads.scss');
-
-require('./_theme/_dark/root_theme_dark.scss');
-require('./_theme/_light/root_theme_light.scss');
diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx
index ce5ba242..2b226aad 100644
--- a/frontend/app/components/root/root.tsx
+++ b/frontend/app/components/root/root.tsx
@@ -3,16 +3,7 @@ import { h, Component, RenderableProps } from 'preact';
import { connect } from 'preact-redux';
import b from 'bem-react-helper';
-import {
- User,
- Node,
- PostInfo,
- BlockedUser,
- Comment as CommentType,
- Sorting,
- Theme,
- AuthProvider,
-} from '@app/common/types';
+import { User, Sorting, AuthProvider } from '@app/common/types';
import {
NODE_ID,
COMMENT_NODE_CLASSNAME_PREFIX,
@@ -31,7 +22,7 @@ import {
blockUser,
unblockUser,
fetchBlockedUsers,
- setSettingsVisibleState,
+ setSettingsVisibility,
hideUser,
unhideUser,
} from '@app/store/user/actions';
@@ -51,11 +42,26 @@ import { uploadImage, getPreview } from '@app/common/api';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { bindActions } from '@app/utils/actionBinder';
+const mapStateToProps = (state: StoreState) => ({
+ user: state.user,
+ sort: state.sort,
+ isSettingsVisible: state.isSettingsVisible,
+ topComments: state.topComments,
+ pinnedComments: state.pinnedComments.map(id => state.comments[id]).filter(c => !c.hidden),
+ provider: state.provider,
+ theme: state.theme,
+ info: state.info,
+ hiddenUsers: state.hiddenUsers,
+ blockedUsers: state.bannedUsers,
+ getPreview,
+ uploadImage,
+});
+
const boundActions = bindActions({
fetchComments,
fetchUser,
fetchBlockedUsers,
- setSettingsVisible: setSettingsVisibleState,
+ setSettingsVisibility,
logIn,
logOut: logout,
setTheme,
@@ -70,19 +76,7 @@ const boundActions = bindActions({
updateComment,
});
-type Props = {
- user: User | null;
- sort: Sorting;
- comments: Node[];
- pinnedComments: CommentType[];
- theme: Theme;
- info: PostInfo;
- hiddenUsers: StoreState['hiddenUsers'];
- blockedUsers: BlockedUser[];
- isSettingsVisible: boolean;
- getPreview: typeof getPreview;
- uploadImage: typeof uploadImage;
-} & typeof boundActions;
+type Props = ReturnType & typeof boundActions;
interface State {
isLoaded: boolean;
@@ -168,7 +162,7 @@ export class Root extends Component {
if (this.props.user && this.props.user.admin) {
await this.props.fetchBlockedUsers();
}
- this.props.setSettingsVisible(true);
+ this.props.setSettingsVisibility(true);
}
async onBlockedUsersHide() {
@@ -176,7 +170,7 @@ export class Root extends Component {
if (this.state.wasSomeoneUnblocked) {
this.props.fetchComments(this.props.sort);
}
- this.props.setSettingsVisible(false);
+ this.props.setSettingsVisibility(false);
this.setState({
wasSomeoneUnblocked: false,
});
@@ -229,9 +223,10 @@ export class Root extends Component {
user={this.props.user}
hiddenUsers={this.props.hiddenUsers}
sort={this.props.sort}
- providers={StaticStore.config.auth_providers}
isCommentsDisabled={isCommentsDisabled}
postInfo={this.props.info}
+ providers={StaticStore.config.auth_providers}
+ provider={this.props.provider}
onSignIn={this.logIn}
onSignOut={this.logOut}
onBlockedUsersShow={this.onBlockedUsersShow}
@@ -258,24 +253,34 @@ export class Root extends Component {
{this.props.pinnedComments.length > 0 && (
{this.props.pinnedComments.map(comment => (
-
+
))}
)}
- {!!this.props.comments.length && !isCommentsListLoading && (
+ {!!this.props.topComments.length && !isCommentsListLoading && (
- {(IS_MOBILE ? this.props.comments.slice(0, commentsShown) : this.props.comments).map(thread => (
+ {(IS_MOBILE && commentsShown < this.props.topComments.length
+ ? this.props.topComments.slice(0, commentsShown)
+ : this.props.topComments
+ ).map(id => (
))}
- {commentsShown < this.props.comments.length && IS_MOBILE && (
+ {commentsShown < this.props.topComments.length && IS_MOBILE && (
Show more
@@ -320,18 +325,6 @@ export class Root extends Component
{
/** Root component connected to redux */
export const ConnectedRoot = connect(
- (state: StoreState) => ({
- user: state.user,
- sort: state.sort,
- isSettingsVisible: state.isSettingsVisible,
- comments: state.comments,
- pinnedComments: state.pinnedComments,
- theme: state.theme,
- info: state.info,
- hiddenUsers: state.hiddenUsers,
- blockedUsers: state.bannedUsers,
- getPreview,
- uploadImage,
- }),
+ mapStateToProps,
boundActions
)(Root);
diff --git a/frontend/app/components/settings/index.ts b/frontend/app/components/settings/index.ts
index 085058aa..ba3d40d6 100644
--- a/frontend/app/components/settings/index.ts
+++ b/frontend/app/components/settings/index.ts
@@ -1,16 +1,16 @@
+import './settings.scss';
+
+import './__action/settings__action.scss';
+import './__section/settings__section.scss';
+import './__list/settings__list.scss';
+import './__invisible/settings__invisible.scss';
+import './__dimmed/settings__dimmed.scss';
+import './__username/settings__username.scss';
+import './__user-id/settings__user-id.scss';
+import './_theme/_dark/settings_theme_dark.scss';
+import './_theme/_light/settings_theme_light.scss';
+
import withTheme from '../../components/with-theme';
import Settings from './settings';
export default withTheme(Settings);
-
-require('./settings.scss');
-
-require('./__action/settings__action.scss');
-require('./__section/settings__section.scss');
-require('./__list/settings__list.scss');
-require('./__invisible/settings__invisible.scss');
-require('./__dimmed/settings__dimmed.scss');
-require('./__username/settings__username.scss');
-require('./__user-id/settings__user-id.scss');
-require('./_theme/_dark/settings_theme_dark.scss');
-require('./_theme/_light/settings_theme_light.scss');
diff --git a/frontend/app/components/settings/settings.tsx b/frontend/app/components/settings/settings.tsx
index 1ced460a..9666f176 100644
--- a/frontend/app/components/settings/settings.tsx
+++ b/frontend/app/components/settings/settings.tsx
@@ -135,7 +135,7 @@ export default class BlockedUsers extends Component {
{formatTime(new Date(user.time))}
{isUserUnblocked && (
- this.block(user))} className="blocked-users__action">
+ this.block(user))} className="settings__action">
block
)}
diff --git a/frontend/app/components/thread/index.ts b/frontend/app/components/thread/index.ts
index e4069c8f..6fd13765 100644
--- a/frontend/app/components/thread/index.ts
+++ b/frontend/app/components/thread/index.ts
@@ -1,4 +1,4 @@
-export { ConnectedThread as Thread } from './thread';
+import './thread.scss';
+import './_theme_dark/thread_theme_dark.scss';
-require('./thread.scss');
-require('./_theme_dark/thread_theme_dark.scss');
+export { ConnectedThread as Thread } from './thread';
diff --git a/frontend/app/components/thread/thread.tsx b/frontend/app/components/thread/thread.tsx
index d51ebf25..d228c06f 100644
--- a/frontend/app/components/thread/thread.tsx
+++ b/frontend/app/components/thread/thread.tsx
@@ -4,55 +4,67 @@ import { connect } from 'preact-redux';
import b from 'bem-react-helper';
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
-import { Node, Theme } from '@app/common/types';
+import { Comment as CommentInterface } from '@app/common/types';
import { getThreadIsCollapsed } from '@app/store/thread/getters';
import { StoreState } from '@app/store';
+import { InView } from '../root/in-view/in-view';
-interface Props {
- collapsed: boolean;
- data: Node;
- isCommentsDisabled: boolean;
+const mapStateToProps = (state: StoreState, props: { id: CommentInterface['id'] }) => {
+ const comment = state.comments[props.id];
+ return {
+ comment,
+ childs: state.childComments[props.id],
+ collapsed: getThreadIsCollapsed(state, comment),
+ isCommentsDisabled: !!state.info.read_only,
+ theme: state.theme,
+ };
+};
+
+type Props = {
+ id: CommentInterface['id'];
+ childs?: (CommentInterface['id'])[];
level: number;
- theme: Theme;
mix?: string;
getPreview(text: string): Promise;
-}
+} & ReturnType;
function Thread(props: RenderableProps) {
- const {
- collapsed,
- data: { comment, replies = [] },
- level,
- theme,
- } = props;
+ const { collapsed, comment, childs, level, theme } = props;
+
+ if (comment.hidden) return null;
const indented = level > 0;
+ const repliesCount = childs ? childs.length : 0;
return (
-
+
+ {inviewProps => (
+ inviewProps.ref(ref)}
+ key={`comment-${props.id}`}
+ view="main"
+ data={comment}
+ repliesCount={repliesCount}
+ level={level}
+ inView={inviewProps.inView}
+ />
+ )}
+
{!collapsed &&
- !!replies.length &&
- replies.map(thread => (
-
+ childs &&
+ !!childs.length &&
+ childs.map(id => (
+
))}
);
}
-export const ConnectedThread = connect((state: StoreState, props: { data: Node }) => ({
- collapsed: getThreadIsCollapsed(state, props.data.comment),
- isCommentsDisabled: !!state.info.read_only,
- theme: state.theme,
-}))(Thread);
+export const ConnectedThread = connect(mapStateToProps)(Thread);
diff --git a/frontend/app/components/user-info/index.ts b/frontend/app/components/user-info/index.ts
index 067cda79..2fe6e07f 100644
--- a/frontend/app/components/user-info/index.ts
+++ b/frontend/app/components/user-info/index.ts
@@ -1,8 +1,8 @@
+import './user-info.scss';
+
+import './__avatar/user-info__avatar.scss';
+import './__id/user-info__id.scss';
+import './__preloader/user-info__preloader.scss';
+import './__title/user-info__title.scss';
+
export { ConnectedUserInfo as UserInfo } from './user-info';
-
-require('./user-info.scss');
-
-require('./__avatar/user-info__avatar.scss');
-require('./__id/user-info__id.scss');
-require('./__preloader/user-info__preloader.scss');
-require('./__title/user-info__title.scss');
diff --git a/frontend/app/remark.tsx b/frontend/app/remark.tsx
index 9eaa17e9..65ba48ae 100644
--- a/frontend/app/remark.tsx
+++ b/frontend/app/remark.tsx
@@ -18,6 +18,8 @@ import { StaticStore } from '@app/common/static_store';
import api from '@app/common/api';
import { bindActionCreators } from 'redux';
import { fetchHiddenUsers } from './store/user/actions';
+import { restoreProvider } from './store/provider/actions';
+import { restoreCollapsedThreads } from './store/thread/actions';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
@@ -37,8 +39,13 @@ async function init(): Promise {
return;
}
- const boundFetchHiddenUsers = bindActionCreators(fetchHiddenUsers, reduxStore.dispatch);
- boundFetchHiddenUsers();
+ const boundActions = bindActionCreators(
+ { fetchHiddenUsers, restoreProvider, restoreCollapsedThreads },
+ reduxStore.dispatch
+ );
+ boundActions.fetchHiddenUsers();
+ boundActions.restoreProvider();
+ boundActions.restoreCollapsedThreads();
const params = window.location.search
.replace(/^\?/, '')
diff --git a/frontend/app/store/actions.ts b/frontend/app/store/actions.ts
index f95b2821..dbfcfdd9 100644
--- a/frontend/app/store/actions.ts
+++ b/frontend/app/store/actions.ts
@@ -5,6 +5,7 @@ import { THEME_ACTIONS } from './theme/types';
import { THREAD_ACTIONS } from './thread/types';
import { USER_ACTIONS } from './user/types';
import { USER_INFO_ACTIONS } from './user-info/types';
+import { PROVIDER_ACTIONS } from './provider/types';
/** Merged store actions */
export type ACTIONS =
@@ -14,4 +15,5 @@ export type ACTIONS =
| THEME_ACTIONS
| THREAD_ACTIONS
| USER_ACTIONS
- | USER_INFO_ACTIONS;
+ | USER_INFO_ACTIONS
+ | PROVIDER_ACTIONS;
diff --git a/frontend/app/store/comments/actions.ts b/frontend/app/store/comments/actions.ts
index 5fbeb4bd..09d853de 100644
--- a/frontend/app/store/comments/actions.ts
+++ b/frontend/app/store/comments/actions.ts
@@ -1,56 +1,40 @@
import api from '@app/common/api';
-import { Tree, Comment, Sorting, CommentMode } from '@app/common/types';
+import { Tree, Comment, Sorting, CommentMode, Node } from '@app/common/types';
import { StoreAction, StoreState } from '../index';
import { POST_INFO_SET } from '../post_info/types';
-import {
- getPinnedComments,
- addComment as uAddComment,
- replaceComment as uReplaceComment,
- removeComment as uRemoveComment,
- setCommentPin as uSetCommentPin,
- filterTree,
-} from './utils';
-import { COMMENTS_SET, PINNED_COMMENTS_SET, COMMENT_MODE_SET } from './types';
+import { filterTree } from './utils';
+import { COMMENTS_SET, COMMENT_MODE_SET, COMMENTS_APPEND, COMMENTS_EDIT } from './types';
/** sets comments, and put pinned comments in cache */
-export const setComments = (comments: StoreState['comments']): StoreAction => dispatch => {
+export const setComments = (comments: Node[]): StoreAction => dispatch => {
dispatch({
type: COMMENTS_SET,
comments,
});
- dispatch({
- type: PINNED_COMMENTS_SET,
- comments: getPinnedComments(comments),
- });
};
/** appends comment to tree */
-export const addComment = (text: string, title: string, pid?: Comment['id']): StoreAction> => async (
- dispatch,
- getState
-) => {
+export const addComment = (
+ text: string,
+ title: string,
+ pid?: Comment['id']
+): StoreAction> => async dispatch => {
const comment = await api.addComment({ text, title, pid });
- const comments = getState().comments;
- dispatch(setComments(uAddComment(comments, comment)));
+ dispatch({ type: COMMENTS_APPEND, pid: pid || null, comment });
};
/** edits comment in tree */
-export const updateComment = (id: Comment['id'], text: string): StoreAction> => async (
- dispatch,
- getState
-) => {
+export const updateComment = (id: Comment['id'], text: string): StoreAction> => async dispatch => {
const comment = await api.updateComment({ id, text });
- const comments = getState().comments;
- dispatch(setComments(uReplaceComment(comments, comment)));
+ dispatch({ type: COMMENTS_EDIT, comment });
};
/** edits comment in tree */
-export const putVote = (id: Comment['id'], value: number): StoreAction> => async (dispatch, getState) => {
+export const putVote = (id: Comment['id'], value: number): StoreAction> => async dispatch => {
await api.putCommentVote({ id, value });
- const updatedComment = await api.getComment(id);
- const comments = getState().comments;
- dispatch(setComments(uReplaceComment(comments, updatedComment)));
+ const comment = await api.getComment(id);
+ dispatch({ type: COMMENTS_EDIT, comment });
};
/** edits comment in tree */
@@ -63,8 +47,9 @@ export const setPinState = (id: Comment['id'], value: boolean): StoreAction> =>
} else {
await api.removeMyComment(id);
}
- const comments = getState().comments;
- dispatch(setComments(uRemoveComment(comments, id)));
+ let comment = getState().comments[id];
+ comment = { ...comment, delete: true, edit: { summary: '', time: new Date().toISOString() } };
+ dispatch({ type: COMMENTS_EDIT, comment });
};
/** fetches comments from server */
diff --git a/frontend/app/store/comments/reducers.ts b/frontend/app/store/comments/reducers.ts
index 7f33d525..8ece8c96 100644
--- a/frontend/app/store/comments/reducers.ts
+++ b/frontend/app/store/comments/reducers.ts
@@ -1,29 +1,129 @@
-import { Node, Comment } from '@app/common/types';
+import { Node, Comment, CommentMode } from '@app/common/types';
-import { StoreState } from '../index';
import {
COMMENTS_SET,
COMMENTS_SET_ACTION,
- PINNED_COMMENTS_SET_ACTION,
- PINNED_COMMENTS_SET,
COMMENT_MODE_SET,
COMMENT_MODE_SET_ACTION,
+ COMMENTS_APPEND_ACTION,
+ COMMENTS_APPEND,
+ COMMENTS_EDIT_ACTION,
+ COMMENTS_EDIT,
+ COMMENTS_PATCH,
+ COMMENTS_PATCH_ACTION,
} from './types';
+import { getPinnedComments } from './utils';
+import { cmpRef } from '@app/utils/cmpRef';
-export const comments = (state: StoreState['comments'] = [], action: COMMENTS_SET_ACTION): Node[] => {
+export const topComments = (
+ state: (Comment['id'])[] = [],
+ action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
+): (Comment['id'])[] => {
switch (action.type) {
case COMMENTS_SET: {
- return action.comments;
+ return cmpRef(state, action.comments.map(x => x.comment.id));
+ }
+ case COMMENTS_APPEND: {
+ if (action.comment.pid) return state;
+ return [action.comment.id, ...state];
}
default:
return state;
}
};
+const reduceChildIds = (
+ c: Record,
+ x: Node
+): Record => {
+ if (!x.replies) return c;
+ if (!c[x.comment.id]) {
+ c[x.comment.id] = [];
+ }
+ for (const reply of x.replies) {
+ c[x.comment.id].push(reply.comment.id);
+ if (reply.replies) {
+ reduceChildIds(c, reply);
+ }
+ }
+
+ return c;
+};
+
+export const childComments = (
+ state: Record = {},
+ action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
+): Record => {
+ switch (action.type) {
+ case COMMENTS_SET: {
+ return action.comments.reduce>(reduceChildIds, {});
+ }
+ case COMMENTS_APPEND: {
+ if (!action.comment.pid) return state;
+ return { ...state, [action.comment.pid]: [action.comment.id, ...(state[action.comment.pid] || [])] };
+ }
+ default:
+ return state;
+ }
+};
+
+const cmpComment = (a: Comment | undefined, b: Comment): Comment => {
+ if (!a) return b;
+ if (a.id !== b.id) return b;
+ if (!a.edit) {
+ if (!b.edit) return a;
+ return b;
+ }
+ if (!b.edit) return b;
+ if (a.edit.time !== b.edit.time) return b;
+ return a;
+};
+
+const reduceComments = (c: Record, x: Node): Record => {
+ c[x.comment.id] = cmpComment(c[x.comment.id], x.comment);
+ if (x.replies) {
+ x.replies.reduce(reduceComments, c);
+ }
+ return c;
+};
+
+export const comments = (
+ state: Record