mirror of
https://github.com/wassname/talk.git
synced 2026-07-14 11:18:50 +08:00
[next] Email (#2261)
* feat: suspending, banning, now propogation * feat: added email rendering + localization support * fix: fix related to lib * refactor: moved juicer to queue task * refactor: cleanup of job processor * refactor: improved error messaging around failed email * feat: initial forgot passwor impl * fix: fixed rebase errors * feat: send back Content-Language header with requests * feat: added ban email * feat: implemented forgotten password API * fix: linting * feat: support more emails * fix: promise patches * feat: initial confirm email API * feat: added rate limiting * feat: added URL support * feat: added email docs * fix: updated docs * chore: documentation review * fix: fixed build bug * feat: implement forgot password in auth popup * test: add tests + fixes * chore: rename StatelessComponent to FunctionComponent * fix: types and test fixes * chore: upgrade deps * fix: THANK YOU TESTS FOR SAVING MY A** * chore: reorder imports * chore: remove obsolete ! * feat: implement accounts bundle * refactor: review suggestion * fix: rebase upgrade error * fix: rebase bug * feat: reset password link support * test: add tests for account password reset page * fix: remove redirect uri * fix: revert local state changes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Button } from "talk-ui/components";
|
||||
@@ -26,7 +26,7 @@ const facebookIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FacebookButton: StatelessComponent<Props> = props => (
|
||||
const FacebookButton: FunctionComponent<Props> = props => (
|
||||
<Button
|
||||
classes={styles}
|
||||
variant="filled"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Button } from "talk-ui/components";
|
||||
@@ -26,7 +26,7 @@ const googleIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GoogleButton: StatelessComponent<Props> = props => (
|
||||
const GoogleButton: FunctionComponent<Props> = props => (
|
||||
<Button
|
||||
classes={styles}
|
||||
variant="filled"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Button } from "talk-ui/components";
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const OIDCButton: StatelessComponent<Props> = props => (
|
||||
const OIDCButton: FunctionComponent<Props> = props => (
|
||||
<Button
|
||||
classes={styles}
|
||||
variant="filled"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Omit, PropTypesOf } from "talk-framework/types";
|
||||
import { PasswordField as PasswordFieldUI } from "talk-ui/components";
|
||||
@@ -10,7 +10,7 @@ export interface Props
|
||||
"showPasswordTitle" | "hidePasswordTitle"
|
||||
> {}
|
||||
|
||||
const PasswordField: StatelessComponent<Props> = props => (
|
||||
const PasswordField: FunctionComponent<Props> = props => (
|
||||
<Localized
|
||||
id="framework-passwordField"
|
||||
attrs={{ showPasswordTitle: true, hidePasswordTitle: true }}
|
||||
|
||||
@@ -19,7 +19,7 @@ function createContextHOC<Context>(
|
||||
): InferableComponentEnhancer<T> {
|
||||
return hoistStatics<T>(
|
||||
<U extends T>(WrappedComponent: React.ComponentType<U>) => {
|
||||
const Component: React.StatelessComponent<any> = props => (
|
||||
const Component: React.FunctionComponent<any> = props => (
|
||||
<Consumer>
|
||||
{context => (
|
||||
<WrappedComponent {...props} {...propsCallback(context)} />
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
export default (process.env.NODE_ENV !== "development"
|
||||
? {
|
||||
admin: "/admin",
|
||||
embed: {
|
||||
stream: "/embed/stream",
|
||||
auth: "/embed/auth",
|
||||
authCallback: "/embed/auth/callback",
|
||||
},
|
||||
}
|
||||
: {
|
||||
admin: "/admin",
|
||||
embed: {
|
||||
stream: "/stream.html",
|
||||
auth: "/auth.html",
|
||||
authCallback: "/auth-callback.html",
|
||||
},
|
||||
});
|
||||
export default {
|
||||
admin: "/admin",
|
||||
embed: {
|
||||
stream: "/embed/stream",
|
||||
auth: "/embed/auth",
|
||||
authCallback: "/embed/auth/callback",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter2 } from "eventemitter2";
|
||||
import { LocalizationProvider } from "fluent-react/compat";
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
import { Child as PymChild } from "pym.js";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { MediaQueryMatchers } from "react-responsive";
|
||||
import { Formatter } from "react-timeago";
|
||||
import { Environment } from "relay-runtime";
|
||||
@@ -37,7 +37,7 @@ export interface TalkContext {
|
||||
/** media query values for testing purposes */
|
||||
mediaQueryValues?: MediaQueryMatchers;
|
||||
|
||||
/** Rest client */
|
||||
/** Rest Client */
|
||||
rest: RestClient;
|
||||
|
||||
/** postMessage service */
|
||||
@@ -81,7 +81,7 @@ export const TalkContextConsumer = TalkReactContext.Consumer;
|
||||
* In addition to just providing the context, TalkContextProvider also
|
||||
* renders the `LocalizationProvider` with the appropite data.
|
||||
*/
|
||||
export const TalkContextProvider: StatelessComponent<{
|
||||
export const TalkContextProvider: FunctionComponent<{
|
||||
value: TalkContext;
|
||||
}> = ({ value, children }) => (
|
||||
<TalkReactContext.Provider value={value}>
|
||||
|
||||
@@ -104,7 +104,7 @@ function createRelayEnvironment() {
|
||||
return { environment, tokenGetter };
|
||||
}
|
||||
|
||||
function createRestAPI(tokenGetter: () => string) {
|
||||
function createRestClient(tokenGetter: () => string) {
|
||||
return new RestClient("/api", tokenGetter);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ function createMangedTalkContextProvider(
|
||||
const newContext = {
|
||||
...this.state.context,
|
||||
relayEnvironment: newEnvironment,
|
||||
rest: createRestAPI(newTokenGetter),
|
||||
rest: createRestClient(newTokenGetter),
|
||||
};
|
||||
|
||||
// Initialize local state.
|
||||
@@ -244,7 +244,7 @@ export default async function createManaged({
|
||||
pym,
|
||||
eventEmitter,
|
||||
registerClickFarAway,
|
||||
rest: createRestAPI(tokenGetter),
|
||||
rest: createRestClient(tokenGetter),
|
||||
postMessage: new PostMessageService(),
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./ExternalLink.css";
|
||||
|
||||
const ExternalLink: StatelessComponent<{
|
||||
const ExternalLink: FunctionComponent<{
|
||||
href?: string;
|
||||
children?: string;
|
||||
}> = ({ href, children }) => (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Middleware } from "react-relay-network-modern/es";
|
||||
|
||||
import extractError from "./extractError";
|
||||
import extractGraphQLError from "./extractGraphQLError";
|
||||
|
||||
const customErrorMiddleware: Middleware = next => async req => {
|
||||
const res = await next(req);
|
||||
if (res.errors) {
|
||||
// Extract custom error.
|
||||
const error = extractError(res.errors);
|
||||
const error = extractGraphQLError(res.errors);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -6,21 +6,28 @@ import {
|
||||
UnknownServerError,
|
||||
} from "../errors";
|
||||
|
||||
export default function extractError(errors: Error[]): Error | null {
|
||||
if (errors.length > 1 || !(errors[0] as any).extensions) {
|
||||
// Multiple errors are GraphQL errors.
|
||||
// TODO: (cvle) Is this assumption correct?
|
||||
// No extensions == GraphQL error.
|
||||
// TODO: (cvle) harmonize with server.
|
||||
return null;
|
||||
interface TalkError {
|
||||
type: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isTalkError(err: any): err is TalkError {
|
||||
return err.type && err.code && err.message;
|
||||
}
|
||||
|
||||
export default function extractError(
|
||||
err: TalkError,
|
||||
unknownErrorMessage: string = "Unknown error"
|
||||
): Error {
|
||||
if (!isTalkError(err)) {
|
||||
return new UnknownServerError(unknownErrorMessage, err);
|
||||
}
|
||||
// Handle custom errors here.
|
||||
const err = errors[0];
|
||||
if ((err as any).extensions.type === ERROR_TYPES.INVALID_REQUEST_ERROR) {
|
||||
return new InvalidRequestError((err as any).extensions);
|
||||
if (err.type === ERROR_TYPES.INVALID_REQUEST_ERROR) {
|
||||
return new InvalidRequestError(err as any);
|
||||
}
|
||||
if ((err as any).extensions.type === ERROR_TYPES.MODERATION_NUDGE_ERROR) {
|
||||
return new ModerationNudgeError((err as any).extensions);
|
||||
if (err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR) {
|
||||
return new ModerationNudgeError(err as any);
|
||||
}
|
||||
return new UnknownServerError(err.message, (err as any).extensions);
|
||||
return new UnknownServerError(err.message, err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import extractError from "./extractError";
|
||||
|
||||
interface GraphQLErrorWithExtension {
|
||||
extensions: any;
|
||||
}
|
||||
|
||||
function isGraphQLErrorWithExtension(
|
||||
err: any
|
||||
): err is GraphQLErrorWithExtension {
|
||||
return Boolean(err && err.extensions);
|
||||
}
|
||||
|
||||
export default function extractGraphQLError(errors: Error[]): Error | null {
|
||||
if (errors.length > 1) {
|
||||
// Multiple errors are GraphQL errors.
|
||||
// TODO: (cvle) Is this assumption correct?
|
||||
return null;
|
||||
}
|
||||
const err = errors[0];
|
||||
if (!isGraphQLErrorWithExtension(err)) {
|
||||
// No extensions == GraphQL error.
|
||||
// TODO: (cvle) harmonize with server.
|
||||
return null;
|
||||
}
|
||||
return extractError(err.extensions, err.message);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as createNetwork, TokenGetter } from "./createNetwork";
|
||||
export { default as extractGraphQLError } from "./extractGraphQLError";
|
||||
export { default as extractError } from "./extractError";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { ComponentType, StatelessComponent } from "react";
|
||||
import React, { ComponentType, FunctionComponent } from "react";
|
||||
|
||||
// TODO: (cvle) This currently a workaround to hide ForwardRef from
|
||||
// enzyme which does not support it well yet.
|
||||
export default function hideForwardRef(component: ComponentType) {
|
||||
const wrapped: StatelessComponent = (props: any) =>
|
||||
const wrapped: FunctionComponent = (props: any) =>
|
||||
React.createElement(component, props);
|
||||
wrapped.displayName = component.displayName;
|
||||
return wrapped;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { merge } from "lodash";
|
||||
import { Overwrite } from "talk-framework/types";
|
||||
|
||||
import { extractError } from "./network";
|
||||
|
||||
const buildOptions = (inputOptions: RequestInit = {}) => {
|
||||
const defaultOptions: RequestInit = {
|
||||
method: "GET",
|
||||
@@ -26,8 +28,14 @@ const handleResp = async (res: Response) => {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const response = await res.json();
|
||||
throw new Error(response.error.message);
|
||||
const ctype = res.headers.get("content-type");
|
||||
if (ctype && ctype.includes("application/json")) {
|
||||
const response = await res.json();
|
||||
throw extractError(response.error);
|
||||
} else {
|
||||
const response = await res.text();
|
||||
throw new Error(response);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
@@ -37,7 +45,9 @@ const handleResp = async (res: Response) => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }>;
|
||||
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }> & {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export class RestClient {
|
||||
public readonly uri: string;
|
||||
@@ -53,10 +63,11 @@ export class RestClient {
|
||||
options: PartialRequestInit
|
||||
): Promise<T> {
|
||||
let opts = options;
|
||||
if (this.tokenGetter) {
|
||||
const token = options.token || (this.tokenGetter && this.tokenGetter());
|
||||
if (token) {
|
||||
opts = merge({}, options, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.tokenGetter()}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { RouteMatch, RouteProps } from "found";
|
||||
import * as React from "react";
|
||||
|
||||
interface InjectedProps<T> {
|
||||
error?: Error | null;
|
||||
data: T | null | undefined;
|
||||
retry?: Error | null;
|
||||
}
|
||||
|
||||
type RouteConfig<QueryResponse> = Partial<
|
||||
type RouteConfig<Props = any, QueryResponse = undefined> = Partial<
|
||||
Pick<RouteProps, "query" | "getQuery">
|
||||
> &
|
||||
Partial<Pick<RouteProps, "data" | "getData" | "defer">> & {
|
||||
@@ -23,14 +17,17 @@ type RouteConfig<QueryResponse> = Partial<
|
||||
data: QueryResponse | null;
|
||||
retry: () => void;
|
||||
match: RouteMatch;
|
||||
Component: React.ComponentType<any>;
|
||||
Component: React.ComponentType<Partial<Props>>;
|
||||
}) => React.ReactElement;
|
||||
};
|
||||
|
||||
function withRouteConfig<QueryResponse>(config: RouteConfig<QueryResponse>) {
|
||||
const hoc = <T extends InjectedProps<QueryResponse>>(
|
||||
component: React.ComponentType<T>
|
||||
) => {
|
||||
function withRouteConfig<
|
||||
Props = any,
|
||||
QueryResponse = Props extends { data: infer T | null | undefined }
|
||||
? T
|
||||
: undefined
|
||||
>(config: RouteConfig<Props, QueryResponse>) {
|
||||
const hoc = <T>(component: React.ComponentType<T>) => {
|
||||
(component as any).routeConfig = {
|
||||
...config,
|
||||
Component: component,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createRelayEnvironment,
|
||||
} from "talk-framework/testHelpers";
|
||||
|
||||
import { commit } from "./SetAccessTokenMutation";
|
||||
import SetAccessTokenMutation from "./SetAccessTokenMutation";
|
||||
|
||||
let environment: Environment;
|
||||
const source: RecordSource = new RecordSource();
|
||||
@@ -28,7 +28,11 @@ it("Sets auth token to localStorage", async () => {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
await commit(environment, { accessToken }, context as any);
|
||||
await SetAccessTokenMutation.commit(
|
||||
environment,
|
||||
{ accessToken },
|
||||
context as any
|
||||
);
|
||||
expect(source.get(LOCAL_ID)!.accessToken).toEqual(accessToken);
|
||||
await expect(context.localStorage!.getItem("accessToken")).resolves.toEqual(
|
||||
accessToken
|
||||
@@ -43,7 +47,11 @@ it("Removes auth token from localStorage", async () => {
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
await commit(environment, { accessToken: null }, context as any);
|
||||
await SetAccessTokenMutation.commit(
|
||||
environment,
|
||||
{ accessToken: null },
|
||||
context as any
|
||||
);
|
||||
await expect(
|
||||
context.localStorage!.getItem("accessToken")
|
||||
).resolves.toBeNull();
|
||||
|
||||
@@ -3,36 +3,32 @@ import { Environment } from "relay-runtime";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
createMutationContainer,
|
||||
createMutation,
|
||||
setAccessTokenInLocalState,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
export interface SetAccessTokenInput {
|
||||
interface SetAccessTokenInput {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
export type SetAccessTokenMutation = (
|
||||
input: SetAccessTokenInput
|
||||
) => Promise<void>;
|
||||
|
||||
export async function commit(
|
||||
environment: Environment,
|
||||
input: SetAccessTokenInput,
|
||||
{ localStorage, clearSession }: TalkContext
|
||||
) {
|
||||
await commitLocalUpdatePromisified(environment, async store => {
|
||||
setAccessTokenInLocalState(input.accessToken, store);
|
||||
if (input.accessToken) {
|
||||
await localStorage.setItem("accessToken", input.accessToken);
|
||||
} else {
|
||||
await localStorage.removeItem("accessToken");
|
||||
}
|
||||
// Clear current session, as we are starting a new one.
|
||||
await clearSession();
|
||||
});
|
||||
}
|
||||
|
||||
export const withSetAccessTokenMutation = createMutationContainer(
|
||||
const SetAccessTokenMutation = createMutation(
|
||||
"setAccessToken",
|
||||
commit
|
||||
async (
|
||||
environment: Environment,
|
||||
input: SetAccessTokenInput,
|
||||
{ localStorage, clearSession }: TalkContext
|
||||
) => {
|
||||
await commitLocalUpdatePromisified(environment, async store => {
|
||||
setAccessTokenInLocalState(input.accessToken, store);
|
||||
if (input.accessToken) {
|
||||
await localStorage.setItem("accessToken", input.accessToken);
|
||||
} else {
|
||||
await localStorage.removeItem("accessToken");
|
||||
}
|
||||
// Clear current session, as we are starting a new one.
|
||||
await clearSession();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default SetAccessTokenMutation;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Environment } from "relay-runtime";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { createMutationContainer } from "talk-framework/lib/relay";
|
||||
import signOut from "../rest/signOut";
|
||||
import { commit as setAccessToken } from "./SetAccessTokenMutation";
|
||||
import SetAccessTokenMutation from "./SetAccessTokenMutation";
|
||||
|
||||
export type SignOutMutation = () => Promise<void>;
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function commit(
|
||||
ctx: TalkContext
|
||||
) {
|
||||
await signOut(ctx.rest);
|
||||
await setAccessToken(environment, { accessToken: "" }, ctx);
|
||||
await SetAccessTokenMutation.commit(environment, { accessToken: "" }, ctx);
|
||||
}
|
||||
|
||||
export const withSignOutMutation = createMutationContainer("signOut", commit);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export {
|
||||
withSetAccessTokenMutation,
|
||||
SetAccessTokenMutation,
|
||||
SetAccessTokenInput,
|
||||
} from "./SetAccessTokenMutation";
|
||||
export { default as SetAccessTokenMutation } from "./SetAccessTokenMutation";
|
||||
export { withSignOutMutation, SignOutMutation } from "./SignOutMutation";
|
||||
export {
|
||||
withSetNetworkStatusMutation,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { RestClient } from "../lib/rest";
|
||||
|
||||
export interface ForgotPasswordInput {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function forgotPassword(
|
||||
rest: RestClient,
|
||||
input: ForgotPasswordInput
|
||||
) {
|
||||
return rest.fetch<void>("/auth/local/forgot", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
@@ -2,3 +2,7 @@ export { default as signIn, SignInInput } from "./signIn";
|
||||
export { default as signUp, SignUpInput } from "./signUp";
|
||||
export { default as signOut } from "./signOut";
|
||||
export { default as install, InstallInput } from "./install";
|
||||
export {
|
||||
default as forgotPassword,
|
||||
ForgotPasswordInput,
|
||||
} from "./forgotPassword";
|
||||
|
||||
@@ -30,7 +30,7 @@ const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => (
|
||||
};
|
||||
|
||||
interface SelectorOptions {
|
||||
selector?: string | React.ComponentClass<any> | React.StatelessComponent<any>;
|
||||
selector?: string | React.ComponentClass<any> | React.FunctionComponent<any>;
|
||||
}
|
||||
|
||||
export function getByText(
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function findParentWithType(
|
||||
selector:
|
||||
| string
|
||||
| React.ComponentClass<any>
|
||||
| React.StatelessComponent<any> = "*"
|
||||
| React.FunctionComponent<any> = "*"
|
||||
): ReactTestInstance | null {
|
||||
if (selector === "*") {
|
||||
return instance;
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function findParentsWithType(
|
||||
selector:
|
||||
| string
|
||||
| React.ComponentClass<any>
|
||||
| React.StatelessComponent<any> = "*"
|
||||
| React.FunctionComponent<any> = "*"
|
||||
): ReactTestInstance[] {
|
||||
return instances
|
||||
.map(i => findParentWithType(i, selector))
|
||||
|
||||
@@ -2,3 +2,4 @@ export { default as buildURL } from "./buildURL";
|
||||
export { default as parseURL } from "./parseURL";
|
||||
export { default as modifyQuery } from "./modifyQuery";
|
||||
export { default as areWeInIframe } from "./areWeInIframe";
|
||||
export { default as parseHashQuery } from "./parseHashQuery";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { parseQuery } from "talk-common/utils";
|
||||
|
||||
export default function parseQueryHash(hash: string): Record<string, string> {
|
||||
export default function parseQueryHash(
|
||||
hash: string
|
||||
): Record<string, string | undefined> {
|
||||
let normalized = hash;
|
||||
if (normalized[0] === "#") {
|
||||
normalized = normalized.substr(1);
|
||||
|
||||
Reference in New Issue
Block a user