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,