Converted Comments-UI App to TypeScript and React hooks (#17760)

refs https://github.com/TryGhost/Product/issues/3504

- App component now uses React hooks intead of React class component
- App is now written in TypeScript
- All JavaScript is now removed from the Comments-UI project
- Removed `PopupNotification` because these were never displayed
- Removed `action` from AppContext (never used)
- Moved options parsing out of `index.ts` into a separate utility file,
similar to the signup-form
- Improved reliability of some editor tests by always waiting for the
editor to be focused (was not always the case) + added an utility method
for this
This commit is contained in:
Simon Backx 2023-08-18 15:30:59 +02:00 committed by GitHub
parent d9cee38a77
commit f1b51729fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 389 additions and 449 deletions

View File

@ -128,11 +128,11 @@ if (DASH_DASH_ARGS.includes('lexical')) {
// Safari needs HTTPS for it to work
// To make this work, you'll need a CADDY server running in front
// Note the port is different because of this extra layer. Use the following Caddyfile:
// https://localhost:4174 {
// https://localhost:41730 {
// reverse_proxy http://127.0.0.1:4173
// }
COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js';
COMMAND_GHOST.env['editor__url'] = 'https://localhost:41730/koenig-lexical.umd.js';
} else {
COMMAND_GHOST.env['editor__url'] = 'http://localhost:4173/koenig-lexical.umd.js';
}

View File

@ -1,304 +0,0 @@
import ContentBox from './components/ContentBox';
import PopupBox from './components/PopupBox';
import React from 'react';
import setupGhostApi from './utils/api';
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
import {AppContext} from './AppContext';
import {CommentsFrame} from './components/Frame';
import {createPopupNotification} from './utils/helpers';
import {hasMode} from './utils/check-mode';
function AuthFrame({adminUrl, onLoad}) {
if (!adminUrl) {
return null;
}
const iframeStyle = {
display: 'none'
};
return (
<iframe data-frame="admin-auth" src={adminUrl + 'auth-frame/'} style={iframeStyle} title="auth-frame" onLoad={onLoad}></iframe>
);
}
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
action: 'init:running',
initStatus: 'running',
member: null,
admin: null,
comments: null,
pagination: null,
popupNotification: null,
customSiteUrl: props.customSiteUrl,
postId: props.postId,
popup: null,
accentColor: props.accentColor,
secundaryFormCount: 0
};
this.adminApi = null;
this.GhostApi = null;
// Bind to make sure we have a variable reference (and don't need to create a new binded function in our context value every time the state changes)
this.dispatchAction = this.dispatchAction.bind(this);
this.initAdminAuth = this.initAdminAuth.bind(this);
}
/** Initialize comments setup on load, fetch data and setup state*/
async initSetup() {
try {
// Fetch data from API, links, preview, dev sources
const {member} = await this.fetchApiData();
const {comments, pagination, count} = await this.fetchComments();
const state = {
member,
action: 'init:success',
initStatus: 'success',
comments,
pagination,
commentCount: count
};
this.setState(state);
} catch (e) {
/* eslint-disable no-console */
console.error(`[Comments] Failed to initialize:`, e);
/* eslint-enable no-console */
this.setState({
action: 'init:failed',
initStatus: 'failed'
});
}
}
async initAdminAuth() {
if (this.adminApi) {
return;
}
try {
this.adminApi = this.setupAdminAPI();
let admin = null;
try {
admin = await this.adminApi.getUser();
} catch (e) {
// Loading of admin failed. Could be not signed in, or a different error (not important)
// eslint-disable-next-line no-console
console.warn(`[Comments] Failed to fetch current admin user:`, e);
}
const state = {
admin
};
this.setState(state);
} catch (e) {
/* eslint-disable no-console */
console.error(`[Comments] Failed to initialize admin authentication:`, e);
}
}
/** Handle actions from across App and update App state */
async dispatchAction(action, data) {
if (isSyncAction(action)) {
// Makes sure we correctly handle the old state
// because updates to state may be asynchronous
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
this.setState((state) => {
return SyncActionHandler({action, data, state, api: this.GhostApi, adminApi: this.adminApi});
});
return;
}
clearTimeout(this.timeoutId);
this.setState({
action: `${action}:running`
});
try {
const updatedState = await ActionHandler({action, data, state: this.state, api: this.GhostApi, adminApi: this.adminApi});
this.setState(updatedState);
/** Reset action state after short timeout if not failed*/
if (updatedState && updatedState.action && !updatedState.action.includes(':failed')) {
this.timeoutId = setTimeout(() => {
this.setState({
action: ''
});
}, 2000);
}
} catch (error) {
// todo: Keep this error log here until we implement popup notifications?
// eslint-disable-next-line no-console
console.error(error);
const popupNotification = createPopupNotification({
type: `${action}:failed`,
autoHide: true, closeable: true, status: 'error', state: this.state,
meta: {
error
}
});
this.setState({
action: `${action}:failed`,
popupNotification
});
}
}
/** Fetch site and member session data with Ghost Apis */
async fetchApiData() {
const {siteUrl, customSiteUrl, apiUrl, apiKey} = this.props;
try {
this.GhostApi = this.props.api || setupGhostApi({siteUrl, apiUrl, apiKey});
const {member} = await this.GhostApi.init();
return {member};
} catch (e) {
if (hasMode(['dev', 'test'], {customSiteUrl})) {
return {};
}
throw e;
}
}
/** Fetch first few comments */
async fetchComments() {
const dataPromise = this.GhostApi.comments.browse({page: 1, postId: this.state.postId});
const countPromise = this.GhostApi.comments.count({postId: this.state.postId});
const [data, count] = await Promise.all([dataPromise, countPromise]);
return {
comments: data.comments,
pagination: data.meta.pagination,
count: count
};
}
setupAdminAPI() {
const frame = document.querySelector('iframe[data-frame="admin-auth"]');
let uid = 1;
let handlers = {};
const adminOrigin = new URL(this.props.adminUrl).origin;
window.addEventListener('message', function (event) {
if (event.origin !== adminOrigin) {
// Other message that is not intended for us
return;
}
let data = null;
try {
data = JSON.parse(event.data);
} catch (err) {
/* eslint-disable no-console */
console.error('Error parsing event data', err);
/* eslint-enable no-console */
return;
}
const handler = handlers[data.uid];
if (!handler) {
return;
}
delete handlers[data.uid];
handler(data.error, data.result);
});
function callApi(action, args) {
return new Promise((resolve, reject) => {
function handler(error, result) {
if (error) {
return reject(error);
}
return resolve(result);
}
uid += 1;
handlers[uid] = handler;
frame.contentWindow.postMessage(JSON.stringify({
uid,
action,
...args
}), adminOrigin);
});
}
const api = {
async getUser() {
const result = await callApi('getUser');
if (!result || !result.users) {
return null;
}
return result.users[0];
},
async hideComment(id) {
return await callApi('hideComment', {id});
},
async showComment(id) {
return await callApi('showComment', {id});
}
};
return api;
}
/**Get final App level context from App state*/
getContextFromState() {
const {action, popupNotification, customSiteUrl, member, comments, pagination, commentCount, postId, admin, popup, secundaryFormCount} = this.state;
return {
action,
popupNotification,
customSiteUrl,
member,
admin,
comments,
pagination,
commentCount,
postId,
title: this.props.title,
showCount: this.props.showCount,
colorScheme: this.props.colorScheme,
avatarSaturation: this.props.avatarSaturation,
accentColor: this.props.accentColor,
commentsEnabled: this.props.commentsEnabled,
publication: this.props.publication,
secundaryFormCount: secundaryFormCount,
popup,
// Warning: make sure we pass a variable here (binded in constructor), because if we create a new function here, it will also change when anything in the state changes
// causing loops in useEffect hooks that depend on dispatchAction
dispatchAction: this.dispatchAction
};
}
componentDidMount() {
this.initSetup();
}
componentWillUnmount() {
/**Clear timeouts and event listeners on unmount */
clearTimeout(this.timeoutId);
}
render() {
const done = this.state.initStatus === 'success';
return (
<AppContext.Provider value={this.getContextFromState()}>
<CommentsFrame>
<ContentBox done={done} />
</CommentsFrame>
<AuthFrame adminUrl={this.props.adminUrl} onLoad={this.initAdminAuth}/>
<PopupBox />
</AppContext.Provider>
);
}
}

View File

@ -0,0 +1,172 @@
import AuthFrame from './AuthFrame';
import ContentBox from './components/ContentBox';
import PopupBox from './components/PopupBox';
import React, {useCallback, useEffect, useState} from 'react';
import setupGhostApi from './utils/api';
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
import {AdminApi, setupAdminAPI} from './utils/adminApi';
import {AppContext, AppContextType, CommentsOptions, DispatchActionType, EditableAppContext} from './AppContext';
import {CommentsFrame} from './components/Frame';
import {useOptions} from './utils/options';
type AppProps = {
scriptTag: HTMLElement;
};
function createContext(options: CommentsOptions, state: EditableAppContext): AppContextType {
return {
...options,
...state,
dispatchAction: (() => {}) as DispatchActionType
};
}
const App: React.FC<AppProps> = ({scriptTag}) => {
const options = useOptions(scriptTag);
const [state, setFullState] = useState<EditableAppContext>({
initStatus: 'running',
member: null,
admin: null,
comments: [],
pagination: null,
commentCount: 0,
secundaryFormCount: 0,
popup: null
});
const api = React.useMemo(() => {
return setupGhostApi({
siteUrl: options.siteUrl,
apiUrl: options.apiUrl!,
apiKey: options.apiKey!
})
}, [options]);
const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
const context = createContext(options, state)
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
setFullState((state) => {
if (typeof newState === 'function') {
newState = newState(state);
}
return {
...state,
...newState
}
});
}, [setFullState]);
const dispatchAction = useCallback(async (action, data) => {
if (isSyncAction(action)) {
// Makes sure we correctly handle the old state
// because updates to state may be asynchronous
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
setState((state) => {
return SyncActionHandler({action, data, state, api, adminApi: adminApi!, options})
});
return;
}
// This is a bit a ugly hack, but only reliable way to make sure we can get the latest state asynchronously
// without creating infinite rerenders because dispatchAction needs to change on every state change
// So state shouldn't be a dependency of dispatchAction
setState((state) => {
ActionHandler({action, data, state, api, adminApi: adminApi!, options}).then((updatedState) => {
setState({...updatedState});
}).catch(console.error);
// No immediate changes
return {};
});
}, [api, adminApi, options]); // Do not add state or context as a dependency here -> infinite render loop
context.dispatchAction = dispatchAction as DispatchActionType;
const initAdminAuth = async () => {
if (adminApi || !options.adminUrl) {
return;
}
try {
const adminApi = setupAdminAPI({
adminUrl: options.adminUrl
});
setAdminApi(adminApi);
let admin = null;
try {
admin = await adminApi.getUser();
} catch (e) {
// Loading of admin failed. Could be not signed in, or a different error (not important)
// eslint-disable-next-line no-console
console.warn(`[Comments] Failed to fetch current admin user:`, e);
}
setState({
admin
});
} catch (e) {
/* eslint-disable no-console */
console.error(`[Comments] Failed to initialize admin authentication:`, e);
}
};
/** Fetch first few comments */
const fetchComments = async () => {
const dataPromise = api.comments.browse({page: 1, postId: options.postId});
const countPromise = api.comments.count({postId: options.postId});
const [data, count] = await Promise.all([dataPromise, countPromise]);
return {
comments: data.comments,
pagination: data.meta.pagination,
count: count
};
}
/** Initialize comments setup on load, fetch data and setup state*/
const initSetup = async () => {
try {
// Fetch data from API, links, preview, dev sources
const {member} = await api.init();
const {comments, pagination, count} = await fetchComments();
const state = {
member,
initStatus: 'success',
comments,
pagination,
commentCount: count
};
setState(state);
} catch (e) {
/* eslint-disable no-console */
console.error(`[Comments] Failed to initialize:`, e);
/* eslint-enable no-console */
setState({
initStatus: 'failed'
});
}
};
useEffect(() => {
initSetup();
}, []);
const done = state.initStatus === 'success';
return (
<AppContext.Provider value={context}>
<CommentsFrame>
<ContentBox done={done} />
</CommentsFrame>
<AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/>
<PopupBox />
</AppContext.Provider>
);
};
export default App;

View File

@ -3,17 +3,6 @@ import React, {useContext} from 'react';
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
import {Page} from './pages';
export type PopupNotification = {
type: string,
status: string,
autoHide: boolean,
closeable: boolean,
duration: number,
meta: any,
message: string,
count: number
}
export type Member = {
id: string,
uuid: string,
@ -44,10 +33,23 @@ export type AddComment = {
html: string
}
export type AppContextType = {
action: string,
popupNotification: PopupNotification | null,
customSiteUrl: string | undefined,
export type CommentsOptions = {
siteUrl: string,
apiKey: string | undefined,
apiUrl: string | undefined,
postId: string,
adminUrl: string | undefined,
colorScheme: string| undefined,
avatarSaturation: number | undefined,
accentColor: string,
commentsEnabled: string | undefined,
title: string | null,
showCount: boolean,
publication: string
};
export type EditableAppContext = {
initStatus: string,
member: null | any,
admin: null | any,
comments: Comment[],
@ -58,22 +60,18 @@ export type AppContextType = {
total: number
} | null,
commentCount: number,
postId: string,
title: string,
showCount: boolean,
colorScheme: string | undefined,
avatarSaturation: number | undefined,
accentColor: string | undefined,
commentsEnabled: string | undefined,
publication: string,
secundaryFormCount: number,
popup: Page | null,
}
export type AppContextType = EditableAppContext & CommentsOptions & {
// This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data)
// eslint-disable-next-line @typescript-eslint/ban-types
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends {data: any} ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : {}) => T extends ActionType ? Promise<void> : void
}
// Copy time from AppContextType
export type DispatchActionType = AppContextType['dispatchAction'];
export const AppContext = React.createContext<AppContextType>({} as any);
export const AppContextProvider = AppContext.Provider;

View File

@ -0,0 +1,14 @@
type Props = {
adminUrl: string|undefined;
onLoad: () => void;
};
const AuthFrame: React.FC<Props> = ({adminUrl, onLoad}) => {
const iframeStyle = {
display: 'none'
};
return (
<iframe data-frame="admin-auth" src={adminUrl + 'auth-frame/'} style={iframeStyle} title="auth-frame" onLoad={onLoad}></iframe>
);
}
export default AuthFrame;

View File

@ -1,13 +1,14 @@
import {AddComment, AppContextType, Comment} from './AppContext';
import {AddComment, Comment, CommentsOptions, EditableAppContext} from './AppContext';
import {AdminApi} from './utils/adminApi';
import {GhostApi} from './utils/api';
import {Page} from './pages';
async function loadMoreComments({state, api}: {state: AppContextType, api: GhostApi}): Promise<Partial<AppContextType>> {
async function loadMoreComments({state, api, options}: {state: EditableAppContext, api: GhostApi, options: CommentsOptions}): Promise<Partial<EditableAppContext>> {
let page = 1;
if (state.pagination && state.pagination.page) {
page = state.pagination.page + 1;
}
const data = await api.comments.browse({page, postId: state.postId});
const data = await api.comments.browse({page, postId: options.postId});
// Note: we store the comments from new to old, and show them in reverse order
return {
@ -16,7 +17,7 @@ async function loadMoreComments({state, api}: {state: AppContextType, api: Ghost
};
}
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: AppContextType, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<AppContextType>> {
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<EditableAppContext>> {
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
// Note: we store the comments from new to old, and show them in reverse order
@ -33,7 +34,7 @@ async function loadMoreReplies({state, api, data: {comment, limit}}: {state: App
};
}
async function addComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: AddComment}) {
async function addComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: AddComment}) {
const data = await api.comments.add({comment});
comment = data.comments[0];
@ -43,7 +44,7 @@ async function addComment({state, api, data: comment}: {state: AppContextType, a
};
}
async function addReply({state, api, data: {reply, parent}}: {state: AppContextType, api: GhostApi, data: {reply: any, parent: any}}) {
async function addReply({state, api, data: {reply, parent}}: {state: EditableAppContext, api: GhostApi, data: {reply: any, parent: any}}) {
let comment = reply;
comment.parent_id = parent.id;
@ -73,7 +74,7 @@ async function addReply({state, api, data: {reply, parent}}: {state: AppContextT
};
}
async function hideComment({state, adminApi, data: comment}: {state: AppContextType, adminApi: any, data: {id: string}}) {
async function hideComment({state, adminApi, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
await adminApi.hideComment(comment.id);
return {
@ -106,7 +107,7 @@ async function hideComment({state, adminApi, data: comment}: {state: AppContextT
};
}
async function showComment({state, api, adminApi, data: comment}: {state: AppContextType, api: GhostApi, adminApi: any, data: {id: string}}) {
async function showComment({state, api, adminApi, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
await adminApi.showComment(comment.id);
// We need to refetch the comment, to make sure we have an up to date HTML content
@ -137,7 +138,7 @@ async function showComment({state, api, adminApi, data: comment}: {state: AppCon
};
}
async function likeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
async function likeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
await api.comments.like({comment});
return {
@ -183,7 +184,7 @@ async function reportComment({api, data: comment}: {api: GhostApi, data: {id: st
return {};
}
async function unlikeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
async function unlikeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
await api.comments.unlike({comment});
return {
@ -222,7 +223,7 @@ async function unlikeComment({state, api, data: comment}: {state: AppContextType
};
}
async function deleteComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
async function deleteComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
await api.comments.edit({
comment: {
id: comment.id,
@ -260,7 +261,7 @@ async function deleteComment({state, api, data: comment}: {state: AppContextType
};
}
async function editComment({state, api, data: {comment, parent}}: {state: AppContextType, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
async function editComment({state, api, data: {comment, parent}}: {state: EditableAppContext, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
const data = await api.comments.edit({
comment
});
@ -288,7 +289,7 @@ async function editComment({state, api, data: {comment, parent}}: {state: AppCon
};
}
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: AppContextType, api: GhostApi}) {
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: EditableAppContext, api: GhostApi}) {
const {name, expertise} = data;
const patchData: {name?: string, expertise?: string} = {};
@ -336,13 +337,13 @@ function closePopup() {
};
}
function increaseSecundaryFormCount({state}: {state: AppContextType}) {
function increaseSecundaryFormCount({state}: {state: EditableAppContext}) {
return {
secundaryFormCount: state.secundaryFormCount + 1
};
}
function decreaseSecundaryFormCount({state}: {state: AppContextType}) {
function decreaseSecundaryFormCount({state}: {state: EditableAppContext}) {
return {
secundaryFormCount: state.secundaryFormCount - 1
};
@ -381,20 +382,20 @@ export function isSyncAction(action: string): action is SyncActionType {
}
/** Handle actions in the App, returns updated state */
export async function ActionHandler({action, data, state, api, adminApi}: {action: ActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
export async function ActionHandler({action, data, state, api, adminApi, options}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Promise<Partial<EditableAppContext>> {
const handler = Actions[action];
if (handler) {
return await handler({data, state, api, adminApi} as any) || {};
return await handler({data, state, api, adminApi, options} as any) || {};
}
return {};
}
/** Handle actions in the App, returns updated state */
export function SyncActionHandler({action, data, state, api, adminApi}: {action: SyncActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
export function SyncActionHandler({action, data, state, api, adminApi, options}: {action: SyncActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Partial<EditableAppContext> {
const handler = SyncActions[action];
if (handler) {
// Do not await here
return handler({data, state, api, adminApi} as any) || {};
return handler({data, state, api, adminApi, options} as any) || {};
}
return {};
}

View File

@ -37,7 +37,11 @@ export default class IFrame extends Component<any> {
if (this.props.onResize) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(new ResizeObserver(_ => this.props.onResize(this.iframeRoot)))?.observe?.(this.iframeRoot);
(new ResizeObserver(_ => {
window.requestAnimationFrame(() => {
this.props.onResize(this.iframeRoot);
});
}))?.observe?.(this.iframeRoot);
}
// This is a bit hacky, but prevents us to need to attach even listeners to all the iframes we have

View File

@ -36,35 +36,6 @@ function getRootDiv(scriptTag: HTMLElement) {
return elem;
}
function getSiteData(scriptTag: HTMLElement) {
/**
* @type {HTMLElement}
*/
let dataset = scriptTag?.dataset;
if (!scriptTag && process.env.NODE_ENV === 'development') {
// Use queryparams in test mode
dataset = Object.fromEntries(new URLSearchParams(window.location.search).entries());
} else if (!scriptTag) {
return {};
}
const siteUrl = dataset.ghostComments;
const apiKey = dataset.key;
const apiUrl = dataset.api;
const adminUrl = dataset.admin;
const postId = dataset.postId;
const colorScheme = dataset.colorScheme;
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
const accentColor = dataset.accentColor ?? '#000000';
const commentsEnabled = dataset.commentsEnabled;
const title = dataset.title === 'null' ? null : dataset.title;
const showCount = dataset.count === 'true';
const publication = dataset.publication ?? ''; // TODO: replace with dynamic data from script
return {siteUrl, apiKey, apiUrl, postId, adminUrl, colorScheme, avatarSaturation, accentColor, commentsEnabled, title, showCount, publication};
}
function handleTokenUrl() {
const url = new URL(window.location.href);
if (url.searchParams.get('token')) {
@ -77,16 +48,12 @@ function init() {
const scriptTag = getScriptTag();
const root = getRootDiv(scriptTag);
// const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, ...siteData} = getSiteData(scriptTag);
const siteUrl = customSiteUrl || window.location.origin;
try {
handleTokenUrl();
ReactDOM.render(
<React.StrictMode>
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
{<App scriptTag={scriptTag} />}
</React.StrictMode>,
root
);

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,70 @@
export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
const frame = document.querySelector('iframe[data-frame="admin-auth"]') as HTMLIFrameElement;
let uid = 1;
const handlers: Record<string, (error: Error|undefined, result: any) => void> = {};
const adminOrigin = new URL(adminUrl).origin;
window.addEventListener('message', function (event) {
if (event.origin !== adminOrigin) {
// Other message that is not intended for us
return;
}
let data = null;
try {
data = JSON.parse(event.data);
} catch (err) {
/* eslint-disable no-console */
console.error('Error parsing event data', err);
/* eslint-enable no-console */
return;
}
const handler = handlers[data.uid];
if (!handler) {
return;
}
delete handlers[data.uid];
handler(data.error, data.result);
});
function callApi(action: string, args?: any): Promise<any> {
return new Promise((resolve, reject) => {
function handler(error: Error|undefined, result: any) {
if (error) {
return reject(error);
}
return resolve(result);
}
uid += 1;
handlers[uid] = handler;
frame.contentWindow!.postMessage(JSON.stringify({
uid,
action,
...args
}), adminOrigin);
});
}
const api = {
async getUser() {
const result = await callApi('getUser');
if (!result || !result.users) {
return null;
}
return result.users[0];
},
async hideComment(id: string) {
return await callApi('hideComment', {id});
},
async showComment(id: string) {
return await callApi('showComment', {id});
}
};
return api;
}
export type AdminApi = ReturnType<typeof setupAdminAPI>;

View File

@ -1,22 +0,0 @@
export const isDevMode = function ({customSiteUrl = ''} = {}) {
if (customSiteUrl && process.env.NODE_ENV === 'development') {
return false;
}
return (process.env.NODE_ENV === 'development');
};
export const isTestMode = function () {
return (process.env.NODE_ENV === 'test');
};
const modeFns = {
dev: isDevMode,
test: isTestMode
};
export const hasMode = (modes: ('dev' | 'test')[] = [], options: {customSiteUrl?: string} = {}) => {
return modes.some((mode) => {
const modeFn = modeFns[mode];
return !!(modeFn && modeFn(options));
});
};

View File

@ -1,30 +1,4 @@
import {Comment, PopupNotification} from '../AppContext';
export const createPopupNotification = ({type, status, autoHide, duration = 2600, closeable, state, message, meta = {}}: {
type: string,
status: string,
autoHide: boolean,
duration?: number,
closeable: boolean,
state: any,
message: string,
meta?: any
}): PopupNotification => {
let count = 0;
if (state && state.popupNotification) {
count = (state.popupNotification.count || 0) + 1;
}
return {
type,
status,
autoHide,
closeable,
duration,
meta,
message,
count
};
};
import {Comment} from '../AppContext';
export function formatNumber(number: number): string {
if (number !== 0 && !number) {

View File

@ -0,0 +1,47 @@
import React, {useMemo} from 'react';
import {CommentsOptions} from '../AppContext';
export function useOptions(scriptTag: HTMLElement) {
const buildOptions = React.useCallback(() => {
/**
* @type {HTMLElement}
*/
const dataset = scriptTag.dataset;
const siteUrl = dataset.ghostComments || window.location.origin;
const apiKey = dataset.key;
const apiUrl = dataset.api;
const adminUrl = dataset.admin;
const postId = dataset.postId || '';
const colorScheme = dataset.colorScheme;
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
const accentColor = dataset.accentColor ?? '#000000';
const commentsEnabled = dataset.commentsEnabled;
const title = dataset.title === 'null' ? null : (dataset.title ?? ''); // Null means use the default title. Missing = no title.
const showCount = dataset.count === 'true';
const publication = dataset.publication ?? ''; // TODO: replace with dynamic data from script
const options = {siteUrl, apiKey, apiUrl, postId, adminUrl, colorScheme, avatarSaturation, accentColor, commentsEnabled, title, showCount, publication};
return options;
}, [scriptTag]);
const initialOptions = useMemo(() => buildOptions(), []);
const [options, setOptions] = React.useState<CommentsOptions>(initialOptions);
React.useEffect(() => {
const observer = new MutationObserver((mutationList) => {
if (mutationList.some(mutation => mutation.type === 'attributes')) {
setOptions(buildOptions());
}
});
observer.observe(scriptTag, {
attributes: true
});
return () => {
observer.disconnect();
};
}, [scriptTag, buildOptions]);
return options;
}

View File

@ -1,4 +1,4 @@
import {MockedApi, initialize} from '../utils/e2e';
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Actions', async () => {
@ -91,6 +91,11 @@ test.describe('Actions', async () => {
const editor = frame.getByTestId('form-editor');
await expect(editor).toBeVisible();
await page.pause();
// Wait for focused
await waitEditorFocused(editor);
// Type some text
await page.keyboard.type('This is a reply 123');
await expect(editor).toHaveText('This is a reply 123');

View File

@ -1,4 +1,4 @@
import {MockedApi, getHeight, getModifierKey, initialize, selectText, setClipboard} from '../utils/e2e';
import {MockedApi, getHeight, getModifierKey, initialize, selectText, setClipboard, waitEditorFocused} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Editor', async () => {
@ -26,6 +26,9 @@ test.describe('Editor', async () => {
await editor.click({force: true});
// Wait for focused
await waitEditorFocused(editor);
// Wait for animation to finish
await page.waitForTimeout(200);
const newEditorHeight = await getHeight(editor);
@ -95,6 +98,8 @@ test.describe('Editor', async () => {
const editorHeight = await getHeight(editor);
await editor.click({force: true});
// Wait for focused
await waitEditorFocused(editor);
// Wait for animation to finish
await page.waitForTimeout(200);
@ -134,6 +139,9 @@ test.describe('Editor', async () => {
await editor.click({force: true});
// Wait for focused
await waitEditorFocused(editor);
// Type in the editor
await editor.type('> This is a quote');
await page.keyboard.press('Enter');
@ -169,7 +177,9 @@ test.describe('Editor', async () => {
// Check focused
const editorEditable = frame.getByTestId('editor');
await expect(editorEditable).toBeFocused();
// Wait for focused
await waitEditorFocused(editor);
// Type in the editor
await editor.type('Click here to go to a new page');
@ -205,9 +215,10 @@ test.describe('Editor', async () => {
await editor.click({force: true});
// Check focused
// Wait for focused
await waitEditorFocused(editor);
const editorEditable = frame.getByTestId('editor');
await expect(editorEditable).toBeFocused();
// Type in the editor
await editor.type('Click here to go to a new page');
@ -243,9 +254,10 @@ test.describe('Editor', async () => {
await editor.click({force: true});
// Check focused
// Wait for focused
await waitEditorFocused(editor);
const editorEditable = frame.getByTestId('editor');
await expect(editorEditable).toBeFocused();
// Type in the editor
await editor.type('Click here to go to a new page');
@ -280,9 +292,8 @@ test.describe('Editor', async () => {
await editor.click({force: true});
// Check focused
const editorEditable = frame.getByTestId('editor');
await expect(editorEditable).toBeFocused();
// Wait for focused
await waitEditorFocused(editor);
// Type in the editor
await editor.type('This is line 1');

View File

@ -6,13 +6,13 @@ function rgbToHsl(r: number, g: number, b: number) {
g /= 255;
b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
var d = max - min;
const d = max - min;
s = Math.round(l > 0.5 ? d / (2 - max - min) : d / (max + min) * 10) / 10;
switch (max) {

View File

@ -1,10 +1,17 @@
import {E2E_PORT} from '../../playwright.config';
import {Locator, Page} from '@playwright/test';
import {MockedApi} from './MockedApi';
import {expect} from '@playwright/test';
export const MOCKED_SITE_URL = 'https://localhost:1234';
export {MockedApi};
export async function waitEditorFocused(editor: Locator) {
// Wait for focused
const internalEditor = editor.getByTestId('editor');
await expect(internalEditor).toBeFocused();
}
function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, '&amp;')

View File

@ -15,9 +15,6 @@
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
/* Temporary */
"allowJs": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,