diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js
index b6c35f90c2..b615fefa1d 100644
--- a/.github/scripts/dev.js
+++ b/.github/scripts/dev.js
@@ -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';
}
diff --git a/apps/comments-ui/src/App.jsx b/apps/comments-ui/src/App.jsx
deleted file mode 100644
index ef7f82c5e6..0000000000
--- a/apps/comments-ui/src/App.jsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-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 (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/apps/comments-ui/src/App.tsx b/apps/comments-ui/src/App.tsx
new file mode 100644
index 0000000000..433efeb6ba
--- /dev/null
+++ b/apps/comments-ui/src/App.tsx
@@ -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 = ({scriptTag}) => {
+ const options = useOptions(scriptTag);
+ const [state, setFullState] = useState({
+ 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(null);
+
+ const context = createContext(options, state)
+
+ const setState = useCallback((newState: Partial | ((state: EditableAppContext) => Partial)) => {
+ 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 (
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/apps/comments-ui/src/AppContext.ts b/apps/comments-ui/src/AppContext.ts
index e2b1bcdc10..2c33a89468 100644
--- a/apps/comments-ui/src/AppContext.ts
+++ b/apps/comments-ui/src/AppContext.ts
@@ -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: (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
}
+// Copy time from AppContextType
+export type DispatchActionType = AppContextType['dispatchAction'];
export const AppContext = React.createContext({} as any);
export const AppContextProvider = AppContext.Provider;
diff --git a/apps/comments-ui/src/AuthFrame.tsx b/apps/comments-ui/src/AuthFrame.tsx
new file mode 100644
index 0000000000..a1de1483ec
--- /dev/null
+++ b/apps/comments-ui/src/AuthFrame.tsx
@@ -0,0 +1,14 @@
+type Props = {
+ adminUrl: string|undefined;
+ onLoad: () => void;
+};
+const AuthFrame: React.FC = ({adminUrl, onLoad}) => {
+ const iframeStyle = {
+ display: 'none'
+ };
+
+ return (
+
+ );
+}
+export default AuthFrame;
diff --git a/apps/comments-ui/src/actions.ts b/apps/comments-ui/src/actions.ts
index 207e6a0652..bd0ccbcb87 100644
--- a/apps/comments-ui/src/actions.ts
+++ b/apps/comments-ui/src/actions.ts
@@ -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> {
+async function loadMoreComments({state, api, options}: {state: EditableAppContext, api: GhostApi, options: CommentsOptions}): Promise> {
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> {
+async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise> {
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 & {id: string}, parent?: Comment}}) {
+async function editComment({state, api, data: {comment, parent}}: {state: EditableAppContext, api: GhostApi, data: {comment: Partial & {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> {
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 {
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 {};
}
diff --git a/apps/comments-ui/src/components/IFrame.tsx b/apps/comments-ui/src/components/IFrame.tsx
index ef328261a8..ffb73b8a1c 100644
--- a/apps/comments-ui/src/components/IFrame.tsx
+++ b/apps/comments-ui/src/components/IFrame.tsx
@@ -37,7 +37,11 @@ export default class IFrame extends Component {
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
diff --git a/apps/comments-ui/src/index.tsx b/apps/comments-ui/src/index.tsx
index 41f28a5506..2ab4caecad 100644
--- a/apps/comments-ui/src/index.tsx
+++ b/apps/comments-ui/src/index.tsx
@@ -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(
- {}
+ {}
,
root
);
diff --git a/apps/comments-ui/src/logo.svg b/apps/comments-ui/src/logo.svg
deleted file mode 100644
index 9dfc1c058c..0000000000
--- a/apps/comments-ui/src/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/apps/comments-ui/src/utils/adminApi.ts b/apps/comments-ui/src/utils/adminApi.ts
new file mode 100644
index 0000000000..813a0ddd1e
--- /dev/null
+++ b/apps/comments-ui/src/utils/adminApi.ts
@@ -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 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 {
+ 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;
diff --git a/apps/comments-ui/src/utils/check-mode.ts b/apps/comments-ui/src/utils/check-mode.ts
deleted file mode 100644
index ce53aa3f62..0000000000
--- a/apps/comments-ui/src/utils/check-mode.ts
+++ /dev/null
@@ -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));
- });
-};
diff --git a/apps/comments-ui/src/utils/helpers.ts b/apps/comments-ui/src/utils/helpers.ts
index 9b1966f922..ca77dc2a53 100644
--- a/apps/comments-ui/src/utils/helpers.ts
+++ b/apps/comments-ui/src/utils/helpers.ts
@@ -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) {
diff --git a/apps/comments-ui/src/utils/options.ts b/apps/comments-ui/src/utils/options.ts
new file mode 100644
index 0000000000..222863c45f
--- /dev/null
+++ b/apps/comments-ui/src/utils/options.ts
@@ -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(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;
+}
diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts
index e063fe9f37..15d02de20a 100644
--- a/apps/comments-ui/test/e2e/actions.test.ts
+++ b/apps/comments-ui/test/e2e/actions.test.ts
@@ -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');
diff --git a/apps/comments-ui/test/e2e/editor.test.ts b/apps/comments-ui/test/e2e/editor.test.ts
index f5da78e184..7c286d5c60 100644
--- a/apps/comments-ui/test/e2e/editor.test.ts
+++ b/apps/comments-ui/test/e2e/editor.test.ts
@@ -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');
diff --git a/apps/comments-ui/test/e2e/options.test.ts b/apps/comments-ui/test/e2e/options.test.ts
index 30589957ae..ccbfe3bb54 100644
--- a/apps/comments-ui/test/e2e/options.test.ts
+++ b/apps/comments-ui/test/e2e/options.test.ts
@@ -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) {
diff --git a/apps/comments-ui/test/utils/e2e.ts b/apps/comments-ui/test/utils/e2e.ts
index 6d9d2991dd..1e3b951096 100644
--- a/apps/comments-ui/test/utils/e2e.ts
+++ b/apps/comments-ui/test/utils/e2e.ts
@@ -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, '&')
diff --git a/apps/comments-ui/tsconfig.json b/apps/comments-ui/tsconfig.json
index 1b09b73248..ad12ed3386 100644
--- a/apps/comments-ui/tsconfig.json
+++ b/apps/comments-ui/tsconfig.json
@@ -15,9 +15,6 @@
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
- /* Temporary */
- "allowJs": true,
-
/* Linting */
"strict": true,
"noUnusedLocals": true,