Migrated code + history from Comments-UI

refs https://github.com/TryGhost/Toolbox/issues/400

- this moves comments-ui into the monorepo to aid with the development
  workflow
This commit is contained in:
Daniel Lockyer 2023-06-22 09:48:32 +02:00
commit 36af781a67
No known key found for this signature in database
69 changed files with 10304 additions and 0 deletions

View File

@ -0,0 +1,26 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.hbs]
insert_final_newline = false
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab

1
apps/comments-ui/.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_VERSION=$npm_package_version

View File

@ -0,0 +1,21 @@
/* eslint-env node */
module.exports = {
root: true,
extends: [
'react-app',
'plugin:ghost/browser'
],
plugins: [
'ghost',
'tailwindcss'
],
rules: {
'tailwindcss/classnames-order': 'error',
'tailwindcss/enforces-negative-arbitrary-values': 'off',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': 'error'
}
};

View File

@ -0,0 +1,12 @@
name: Test
on:
pull_request:
push:
branches:
- main
- 'renovate/*'
jobs:
test:
uses: tryghost/actions/.github/workflows/test.yml@main
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

86
apps/comments-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,86 @@
# Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# IDE
.idea/*
*.iml
*.sublime-*
.vscode/*
# OSX
.DS_Store
# Comments Ui Custom
# dotenv environment variables file
.env.*
# Membersjs build folders
umd/
build/
# Allow .env file
!.env
## We use .env file to define NODE_PATH as recommended test-utils setup pattern to avoid relative imports.
# Refs: https://testing-library.com/docs/react-testing-library/setup#jest-and-create-react-app
# CRA also suggests `.env` files should be checked into source control
# Ref: https://create-react-app.dev/docs/adding-custom-environment-variables/#adding-development-environment-variables-in-env
public/main.css

2
apps/comments-ui/.yarnrc Normal file
View File

@ -0,0 +1,2 @@
version-tag-prefix "@tryghost/comments-ui@"
version-git-message "Released comments-ui v%s"

21
apps/comments-ui/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2023 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,18 @@
# Comments UI
Comments widget that is embedded at the bottom of posts in Ghost.
## Development
### Pre-requisites
- Run `yarn` in Ghost monorepo root
- Run `yarn` in this directory
### Running via Ghost `yarn dev` in root folder
You can automatically start the comments dev server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --comments`. This will host the comments JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
# Copyright & License
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -0,0 +1,86 @@
{
"name": "@tryghost/comments-ui",
"version": "0.12.4",
"license": "MIT",
"repository": "git@github.com:TryGhost/comments-ui.git",
"author": "Ghost Foundation",
"unpkg": "umd/comments-ui.umd.js",
"files": [
"umd/",
"LICENSE",
"README.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"dev": "concurrently \"yarn preview -l silent\" \"yarn build:watch\"",
"build": "vite build",
"build:watch": "vite build --watch",
"preview": "vite preview",
"test": "vitest run",
"test:ci": "yarn test --coverage",
"test:unit": "yarn test:ci",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
"prepublishOnly": "yarn build"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@headlessui/react": "1.6.6",
"@sentry/react": "7.11.1",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-blockquote": "2.0.0-beta.29",
"@tiptap/extension-document": "2.0.0-beta.17",
"@tiptap/extension-hard-break": "2.0.0-beta.33",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-paragraph": "2.0.0-beta.26",
"@tiptap/extension-placeholder": "2.0.0-beta.53",
"@tiptap/extension-text": "2.0.0-beta.17",
"@tiptap/react": "2.0.0-beta.114",
"react": "17.0.2",
"react-dom": "17.0.2"
},
"devDependencies": {
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3",
"@vitejs/plugin-react": "4.0.1",
"@vitest/coverage-v8": "0.32.2",
"autoprefixer": "10.4.14",
"bson-objectid": "2.0.4",
"concurrently": "8.2.0",
"eslint": "8.43.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-ghost": "2.12.0",
"eslint-plugin-tailwindcss": "^3.6.0",
"jsdom": "22.1.0",
"postcss": "8.4.24",
"tailwindcss": "3.3.2",
"vite": "4.3.9",
"vite-plugin-css-injected-by-js": "3.1.1",
"vite-plugin-svgr": "3.2.0",
"vitest": "0.32.2"
},
"resolutions": {
"@tiptap/extension-bubble-menu": "2.0.0-beta.61",
"@tiptap/extension-floating-menu": "2.0.0-beta.56",
"prosemirror-state": "1.4.1",
"prosemirror-model": "1.18.1",
"prosemirror-transform": "1.7.0"
}
}

View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"@tryghost:quietJS"
]
}

324
apps/comments-ui/src/App.js Normal file
View File

@ -0,0 +1,324 @@
import {CommentsFrame} from './components/Frame';
import React from 'react';
import {isSyncAction, ActionHandler, SyncActionHandler} from './actions';
import {createPopupNotification} from './utils/helpers';
import AppContext from './AppContext';
import {hasMode} from './utils/check-mode';
import setupGhostApi from './utils/api';
import ContentBox from './components/ContentBox';
import PopupBox from './components/PopupBox';
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>
);
}
function SentryErrorBoundary({dsn, children}) {
// todo: add Sentry.ErrorBoundary wrapper if Sentry is enabled
return (
<>
{children}
</>
);
}
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();
this.setupSentry();
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;
}
/** Setup Sentry */
setupSentry() {
// Not implemented yet
}
/**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,
appVersion: this.props.appVersion,
stylesUrl: this.props.stylesUrl,
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 (
<SentryErrorBoundary dsn={this.props.sentryDsn}>
<AppContext.Provider value={this.getContextFromState()}>
<CommentsFrame>
<ContentBox done={done} />
</CommentsFrame>
<AuthFrame adminUrl={this.props.adminUrl} onLoad={this.initAdminAuth}/>
<PopupBox />
</AppContext.Provider>
</SentryErrorBoundary>
);
}
}

View File

@ -0,0 +1,422 @@
import {render, within, waitFor, act, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import {ROOT_DIV_ID} from './utils/constants';
import {buildComment, buildMember} from './utils/test-utils';
function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
const postId = 'my-post';
const api = {
init: async () => {
return {
member
};
},
comments: {
count: async () => {
return {
[postId]: 0
};
},
browse: async () => {
return {
comments: [],
meta: {
pagination: {
limit: 5,
total: 0,
next: null,
prev: null,
page: 1
}
}
};
},
add: async ({comment}) => {
return {
comments: [
{
...buildComment(),
...comment,
member,
replies: [],
liked: false,
count: {
likes: 0
}
}
]
};
},
replies: async () => {
return {
comments: [],
meta: {
pagination: {
limit: 3,
total: 0,
next: null,
prev: null,
page: 1
}
}
};
},
like: async () => {
// noop
},
unlike: async () => {
// noop
}
}
};
// In tests, we currently don't wait for the styles to have loaded. In the app we check if the styles url is set or not.
const stylesUrl = '';
const {container} = render(<div style={documentStyles}><div id={ROOT_DIV_ID}><App api={api} adminUrl="https://admin.example/" stylesUrl={stylesUrl} {...props}/></div></div>);
const iframeElement = container.querySelector('iframe[title="comments-frame"]');
expect(iframeElement).toBeInTheDocument();
const iframeDocument = iframeElement.contentDocument;
return {container, api, iframeDocument};
}
beforeEach(() => {
window.scrollTo = jest.fn();
Range.prototype.getClientRects = function getClientRects() {
return [
{
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0
}
];
};
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Auth frame', () => {
it('renders the auth frame', () => {
const {container} = renderApp();
const iframeElement = container.querySelector('iframe[data-frame="admin-auth"]');
expect(iframeElement).toBeInTheDocument();
});
});
describe('Dark mode', () => {
it('uses dark mode when container has a light text color', async () => {
const {iframeDocument} = renderApp({documentStyles: {
color: '#FFFFFF'
}});
const darkModeContentBox = await within(iframeDocument).findByTestId('content-box');
expect([...darkModeContentBox.classList]).toContain('dark');
});
it('uses dark mode when container has a dark text color', async () => {
const {iframeDocument} = renderApp({documentStyles: {
color: '#000000'
}});
const darkModeContentBox = await within(iframeDocument).findByTestId('content-box');
expect([...darkModeContentBox.classList]).not.toContain('dark');
});
it('uses dark mode when custom mode has been passed as a property', async () => {
const {iframeDocument} = renderApp({
props: {
colorScheme: 'dark'
}
});
const darkModeContentBox = await within(iframeDocument).findByTestId('content-box');
expect([...darkModeContentBox.classList]).toContain('dark');
});
it('uses light mode when custom mode has been passed as a property', async () => {
const {iframeDocument} = renderApp({
props: {
colorScheme: 'light'
},
color: '#FFFFFF'
});
const darkModeContentBox = await within(iframeDocument).findByTestId('content-box');
expect([...darkModeContentBox.classList]).not.toContain('dark');
});
});
describe('Comments', () => {
it('renders comments', async () => {
const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(() => {
return {
comments: [
buildComment({html: '<p>This is a comment body</p>'})
],
meta: {
pagination: {
limit: 5,
total: 1,
next: null,
prev: null,
page: 1
}
}
};
});
const commentBody = await within(iframeDocument).findByText(/This is a comment body/i);
expect(commentBody).toBeInTheDocument();
});
it('shows pagination button on top', async () => {
const user = userEvent.setup();
const limit = 5;
const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) {
return {
comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a paginated comment</p>'})),
meta: {
pagination: {
limit,
total: limit + 1,
next: null,
prev: 1,
page
}
}
};
}
return {
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>'})),
meta: {
pagination: {
limit,
total: limit + 1,
next: 2,
prev: null,
page
}
}
};
});
const comments = await within(iframeDocument).findAllByText(/This is a comment body/i);
expect(comments).toHaveLength(limit);
const button = await within(iframeDocument).findByText(/Show 1 previous comment/i);
await user.click(button);
await within(iframeDocument).findByText(/This is a paginated comment/i);
expect(button).not.toBeInTheDocument();
});
it('can handle deleted members', async () => {
const limit = 5;
const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
return {
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})),
meta: {
pagination: {
limit,
total: limit + 1,
next: 2,
prev: null,
page
}
}
};
});
const comments = await within(iframeDocument).findAllByText(/This is a comment body/i);
expect(comments).toHaveLength(limit);
});
it('shows a different UI when not logged in', async () => {
const limit = 5;
const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) {
throw new Error('Not requested');
}
return {
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>'})),
meta: {
pagination: {
limit,
total: limit + 1,
next: 2,
prev: null,
page
}
}
};
});
const comments = await within(iframeDocument).findAllByText(/This is a comment body/i);
expect(comments).toHaveLength(limit);
// Does not show the reply buttons if not logged in
const replyButton = within(iframeDocument).queryByTestId('reply-button');
expect(replyButton).toBeNull(); // it doesn't exist
// Does not show the main form
const form = within(iframeDocument).queryByTestId('form');
expect(form).toBeNull(); // it doesn't exist
// todo: Does show the CTA
});
});
describe('Likes', () => {
it('can like and unlike a comment', async () => {
const limit = 5;
const member = buildMember();
const {api, iframeDocument} = renderApp({
member
});
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) {
throw new Error('Not requested');
}
return {
comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', count: {likes: 5, replies: 0}, liked: false})),
meta: {
pagination: {
limit,
total: 1,
next: null,
prev: null,
page
}
}
};
});
const likeSpy = jest.spyOn(api.comments, 'like');
const unlikeSpy = jest.spyOn(api.comments, 'unlike');
const comment = await within(iframeDocument).findByTestId('comment-component');
const likeButton = within(comment).queryByTestId('like-button');
expect(likeButton).toBeInTheDocument();
// Initial likes are 5
expect(likeButton.lastChild.textContent).toEqual('5');
// Check not filled
const icon = likeButton.querySelector('svg');
// SVG className has a different meaning than on normal element (TIL!)
// So we have to do this black magic to check the string value
expect(icon.className.baseVal).not.toContain('fill');
await userEvent.click(likeButton);
expect(likeSpy).toBeCalledTimes(1);
// Test like icon is filled
expect(icon.className.baseVal).toContain('fill');
// Test count went up with one
expect(likeButton.lastChild.textContent).toEqual('6');
// Can unlike
await userEvent.click(likeButton);
expect(likeButton.lastChild.textContent).toEqual('5');
expect(icon.className.baseVal).not.toContain('fill');
expect(unlikeSpy).toBeCalledTimes(1);
});
});
describe('Replies', () => {
// Test is currently hanging for an unknown reason
it.skip('can reply to a comment', async () => {
const limit = 5;
const member = buildMember();
const {api, iframeDocument} = renderApp({
member
});
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) {
throw new Error('Not requested');
}
return {
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>'})),
meta: {
pagination: {
limit,
total: limit + 1,
next: 2,
prev: null,
page
}
}
};
});
const repliesSpy = jest.spyOn(api.comments, 'replies');
const comments = await within(iframeDocument).findAllByTestId('comment-component');
expect(comments).toHaveLength(limit);
// Does show the main form
const form = within(iframeDocument).queryByTestId('form');
expect(form).toBeInTheDocument();
const replyButton = await within(comments[0]).queryByTestId('reply-button');
expect(replyButton).toBeInTheDocument();
await userEvent.click(replyButton);
const replyForm = within(comments[0]).queryByTestId('form');
expect(replyForm).toBeInTheDocument();
// todo: Check if the main form has been hidden
expect(repliesSpy).toBeCalledTimes(1);
// Enter some text
const editor = replyForm.querySelector('[contenteditable="true"]');
await act(async () => {
await userEvent.type(editor, '> This is a quote');
fireEvent.keyDown(editor, {key: 'Enter', code: 'Enter', charCode: 13});
fireEvent.keyDown(editor, {key: 'Enter', code: 'Enter', charCode: 13});
await userEvent.type(editor, 'This is a reply');
});
// Press save
const submitButton = within(replyForm).queryByTestId('submit-form-button');
expect(submitButton).toBeInTheDocument();
await userEvent.click(submitButton);
// Form should get removed
await waitFor(() => {
expect(replyForm).not.toBeInTheDocument();
});
// Check if reply is visible
const replies = within(comments[0]).queryAllByTestId('comment-component');
expect(replies).toHaveLength(1);
const content = within(replies[0]).queryByTestId('comment-content');
expect(content.innerHTML).toEqual('<blockquote><p>This is a quote</p></blockquote><p></p><p>This is a reply</p>');
// Check if pagination button is NOT visible
const replyPagination = within(iframeDocument).queryByTestId('reply-pagination-button');
expect(replyPagination).toBeNull(); // it doesn't exist
});
});

View File

@ -0,0 +1,6 @@
// Ref: https://reactjs.org/docs/context.html
import React from 'react';
const AppContext = React.createContext({});
export default AppContext;

View File

@ -0,0 +1,392 @@
async function loadMoreComments({state, api}) {
let page = 1;
if (state.pagination && state.pagination.page) {
page = state.pagination.page + 1;
}
const data = await api.comments.browse({page, postId: state.postId});
// Note: we store the comments from new to old, and show them in reverse order
return {
comments: [...state.comments, ...data.comments],
pagination: data.meta.pagination
};
}
async function loadMoreReplies({state, api, data: {comment, limit}}) {
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
return {
comments: state.comments.map((c) => {
if (c.id === comment.id) {
return {
...comment,
replies: [...comment.replies, ...data.comments]
};
}
return c;
})
};
}
async function addComment({state, api, data: comment}) {
const data = await api.comments.add({comment});
comment = data.comments[0];
return {
comments: [comment, ...state.comments],
commentCount: state.commentCount + 1
};
}
async function addReply({state, api, data: {reply, parent}}) {
let comment = reply;
comment.parent_id = parent.id;
const data = await api.comments.add({comment});
comment = data.comments[0];
// When we add a reply,
// it is possible that we didn't load all the replies for the given comment yet.
// To fix that, we'll save the reply to a different field that is created locally to differentiate between replies before and after pagination 😅
// Replace the comment in the state with the new one
return {
comments: state.comments.map((c) => {
if (c.id === parent.id) {
return {
...parent,
replies: [...parent.replies, comment],
count: {
...parent.count,
replies: parent.count.replies + 1
}
};
}
return c;
}),
commentCount: state.commentCount + 1
};
}
async function hideComment({state, adminApi, data: comment}) {
await adminApi.hideComment(comment.id);
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return {
...r,
status: 'hidden'
};
}
return r;
});
if (c.id === comment.id) {
return {
...c,
status: 'hidden',
replies
};
}
return {
...c,
replies
};
}),
commentCount: state.commentCount - 1
};
}
async function showComment({state, api, adminApi, data: comment}) {
await adminApi.showComment(comment.id);
// We need to refetch the comment, to make sure we have an up to date HTML content
// + all relations are loaded as the current member (not the admin)
const data = await api.comments.read(comment.id);
const updatedComment = data.comments[0];
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return updatedComment;
}
return r;
});
if (c.id === comment.id) {
return updatedComment;
}
return {
...c,
replies
};
}),
commentCount: state.commentCount + 1
};
}
async function likeComment({state, api, data: comment}) {
await api.comments.like({comment});
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return {
...r,
liked: true,
count: {
...r.count,
likes: r.count.likes + 1
}
};
}
return r;
});
if (c.id === comment.id) {
return {
...c,
liked: true,
replies,
count: {
...c.count,
likes: c.count.likes + 1
}
};
}
return {
...c,
replies
};
})
};
}
async function reportComment({state, api, data: comment}) {
await api.comments.report({comment});
return {};
}
async function unlikeComment({state, api, data: comment}) {
await api.comments.unlike({comment});
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return {
...r,
liked: false,
count: {
...r.count,
likes: r.count.likes - 1
}
};
}
return r;
});
if (c.id === comment.id) {
return {
...c,
liked: false,
replies,
count: {
...c.count,
likes: c.count.likes - 1
}
};
}
return {
...c,
replies
};
})
};
}
async function deleteComment({state, api, data: comment}) {
await api.comments.edit({
comment: {
id: comment.id,
status: 'deleted'
}
});
return {
comments: state.comments.map((c) => {
const replies = c.replies.map((r) => {
if (r.id === comment.id) {
return {
...r,
status: 'deleted'
};
}
return r;
});
if (c.id === comment.id) {
return {
...c,
status: 'deleted',
replies
};
}
return {
...c,
replies
};
}),
commentCount: state.commentCount - 1
};
}
async function editComment({state, api, data: {comment, parent}}) {
const data = await api.comments.edit({
comment
});
comment = data.comments[0];
// Replace the comment in the state with the new one
return {
comments: state.comments.map((c) => {
if (parent && parent.id === c.id) {
return {
...c,
replies: c.replies.map((r) => {
if (r.id === comment.id) {
return comment;
}
return r;
})
};
} else if (c.id === comment.id) {
return comment;
}
return c;
})
};
}
async function updateMember({data, state, api}) {
const {name, expertise} = data;
const patchData = {};
const originalName = state?.member?.name;
if (name && originalName !== name) {
patchData.name = name;
}
const originalExpertise = state?.member?.expertise;
if (expertise !== undefined && originalExpertise !== expertise) {
// Allow to set it to an empty string or to null
patchData.expertise = expertise;
}
if (Object.keys(patchData).length > 0) {
try {
const member = await api.member.update(patchData);
if (!member) {
throw new Error('Failed to update member');
}
return {
member,
success: true
};
} catch (err) {
return {
success: false,
error: err
};
}
}
return null;
}
function openPopup({data}) {
return {
popup: data
};
}
function closePopup() {
return {
popup: null
};
}
function increaseSecundaryFormCount({state}) {
return {
secundaryFormCount: state.secundaryFormCount + 1
};
}
function decreaseSecundaryFormCount({state}) {
return {
secundaryFormCount: state.secundaryFormCount - 1
};
}
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
const SyncActions = {
openPopup,
closePopup,
increaseSecundaryFormCount,
decreaseSecundaryFormCount
};
const Actions = {
// Put your actions here
addComment,
editComment,
hideComment,
deleteComment,
showComment,
likeComment,
unlikeComment,
reportComment,
addReply,
loadMoreComments,
loadMoreReplies,
updateMember
};
export function isSyncAction(action) {
return !!SyncActions[action];
}
/** Handle actions in the App, returns updated state */
export async function ActionHandler({action, data, state, api, adminApi}) {
const handler = Actions[action];
if (handler) {
return await handler({data, state, api, adminApi}) || {};
}
return {};
}
/** Handle actions in the App, returns updated state */
export function SyncActionHandler({action, data, state, api, adminApi}) {
const handler = SyncActions[action];
if (handler) {
// Do not await here
return handler({data, state, api, adminApi}) || {};
}
return {};
}

View File

@ -0,0 +1,56 @@
import React, {useContext} from 'react';
import AppContext from '../AppContext';
import {ROOT_DIV_ID} from '../utils/constants';
import Content from './content/Content';
import Loading from './content/Loading';
const ContentBox = ({done}) => {
const luminance = (r, g, b) => {
var a = [r, g, b].map(function (v) {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
};
const contrast = (rgb1, rgb2) => {
var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]);
var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]);
var brightest = Math.max(lum1, lum2);
var darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
};
const {accentColor, colorScheme} = useContext(AppContext);
const darkMode = () => {
if (colorScheme === 'light') {
return false;
} else if (colorScheme === 'dark') {
return true;
} else {
const containerColor = getComputedStyle(document.getElementById(ROOT_DIV_ID).parentNode).getPropertyValue('color');
const colorsOnly = containerColor.substring(containerColor.indexOf('(') + 1, containerColor.lastIndexOf(')')).split(/,\s*/);
const red = colorsOnly[0];
const green = colorsOnly[1];
const blue = colorsOnly[2];
return contrast([255, 255, 255], [red, green, blue]) < 5;
}
};
const containerClass = darkMode() ? 'dark' : '';
const style = {
'--gh-accent-color': accentColor ?? 'blue',
paddingTop: 0,
paddingBottom: 24 // remember to allow for bottom shadow on comment text box
};
return (
<section className={'ghost-display ' + containerClass} style={style} data-testid="content-box">
{done ? <Content /> : <Loading />}
</section>
);
};
export default ContentBox;

View File

@ -0,0 +1,73 @@
import React, {useCallback, useState} from 'react';
import IFrame from './IFrame';
import styles from '../styles/iframe.css?inline';
/**
* Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded.
*/
const TailwindFrame = ({children, onResize, style, title}) => {
const head = (
<>
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
</>
);
// For now we're using <NewFrame> because using a functional component with portal caused some weird issues with modals
return (
<IFrame head={head} style={style} onResize={onResize} title={title}>
{children}
</IFrame>
);
};
/**
* This iframe has the same height as it contents and mimics a shadow DOM component
*/
const ResizableFrame = ({children, style, title}) => {
const [iframeStyle, setIframeStyle] = useState(style);
const onResize = useCallback((iframeRoot) => {
setIframeStyle((current) => {
return {
...current,
height: `${iframeRoot.scrollHeight}px`
};
});
}, []);
return (
<TailwindFrame style={iframeStyle} onResize={onResize} title={title}>
{children}
</TailwindFrame>
);
};
export const CommentsFrame = ({children}) => {
const style = {
width: '100%',
height: '400px'
};
return (
<ResizableFrame style={style} title="comments-frame">
{children}
</ResizableFrame>
);
};
export const PopupFrame = ({children, title}) => {
const style = {
zIndex: '3999999',
position: 'fixed',
left: '0',
top: '0',
width: '100%',
height: '100%',
overflow: 'hidden'
};
return (
<TailwindFrame style={style} title={title}>
{children}
</TailwindFrame>
);
};

View File

@ -0,0 +1,60 @@
import React, {Component} from 'react';
import {createPortal} from 'react-dom';
export default class IFrame extends Component {
constructor() {
super();
this.setNode = this.setNode.bind(this);
this.node = null;
}
componentDidMount() {
this.node.addEventListener('load', this.handleLoad);
}
handleLoad = () => {
this.setupFrameBaseStyle();
};
componentWillUnmount() {
this.node.removeEventListener('load', this.handleLoad);
}
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();
if (this.props.onResize) {
(new ResizeObserver(_ => 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
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused
// To get around this, we pass down the keydown events to the main window
// No need to detach, because the iframe would get removed
this.node.contentWindow.addEventListener('keydown', (e) => {
// dispatch a new event
window.dispatchEvent(
new KeyboardEvent('keydown', e)
);
});
}
}
setNode(node) {
this.node = node;
}
render() {
const {children, head, title = '', style = {}, onResize, ...rest} = this.props;
return (
<iframe srcDoc={`<!DOCTYPE html>`} {...rest} ref={this.setNode} title={title} style={style} frameBorder="0">
{this.iframeHead && createPortal(head, this.iframeHead)}
{this.iframeRoot && createPortal(children, this.iframeRoot)}
</iframe>
);
}
}

View File

@ -0,0 +1,54 @@
import {useContext, useEffect, useState} from 'react';
import AppContext from '../AppContext';
import Pages from '../pages';
import GenericPopup from './popups/GenericPopup';
export default function PopupBox() {
const {popup} = useContext(AppContext);
// To make sure we can properly animate a popup that goes away, we keep a state of the last visible popup
// This way, when the popup context is set to null, we still can show the popup while we transition it away
const [lastPopup, setLastPopup] = useState(popup);
useEffect(() => {
if (popup !== null) {
setLastPopup(popup);
}
if (popup === null) {
// Remove lastPopup from memory after 250ms (leave transition has ended + 50ms safety margin)
// If, during those 250ms, the popup is reassigned, the timer will get cleared first.
// This fixes an issue in HeadlessUI where the <Transition show={show}> component is not removed from DOM when show is set to true and false very fast.
const timer = setTimeout(() => {
setLastPopup(null);
}, 250);
return () => {
clearTimeout(timer);
};
}
}, [popup, setLastPopup]);
if (!lastPopup || !lastPopup.type) {
return null;
}
const {type, ...popupProps} = popup ?? lastPopup;
const PageComponent = Pages[type];
if (!PageComponent) {
// eslint-disable-next-line no-console
console.warn('Unknown popup of type ', type);
return null;
}
const show = popup === lastPopup;
return (
<>
<GenericPopup show={show} callback={popupProps.callback} title={type}>
<PageComponent {...popupProps}/>
</GenericPopup>
</>
);
}

View File

@ -0,0 +1,103 @@
import React, {useContext} from 'react';
import AppContext from '../../AppContext';
import {getInitials} from '../../utils/helpers';
import {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg';
function getDimensionClasses() {
return 'w-9 h-9 sm:w-[40px] sm:h-[40px]';
}
export const BlankAvatar = () => {
const dimensionClasses = getDimensionClasses();
return (
<figure className={`relative ${dimensionClasses}`}>
<div className={`flex items-center justify-center rounded-full bg-[rgba(0,0,0,0.085)] dark:bg-[rgba(255,255,255,0.15)] ${dimensionClasses}`}>
<AvatarIcon className="stroke-white opacity-80" />
</div>
</figure>
);
};
export const Avatar = ({comment}) => {
const {member, avatarSaturation} = useContext(AppContext);
const dimensionClasses = getDimensionClasses();
const memberName = member?.name ?? comment?.member?.name;
const getHashOfString = (str) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
hash = Math.abs(hash);
return hash;
};
const normalizeHash = (hash, min, max) => {
return Math.floor((hash % (max - min)) + min);
};
const generateHSL = () => {
let commentMember = (comment ? comment.member : member);
if (!commentMember || !commentMember.name) {
return [0,0,10];
}
const saturation = isNaN(avatarSaturation) ? 50 : avatarSaturation;
const hRange = [0, 360];
const lRangeTop = Math.round(saturation / (100 / 30)) + 30;
const lRangeBottom = lRangeTop - 20;
const lRange = [lRangeBottom, lRangeTop];
const hash = getHashOfString(commentMember.name);
const h = normalizeHash(hash, hRange[0], hRange[1]);
const l = normalizeHash(hash, lRange[0], lRange[1]);
return [h, saturation, l];
};
const HSLtoString = (hsl) => {
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
};
const commentGetInitials = () => {
if (comment && !comment.member) {
return getInitials('Deleted member');
}
let commentMember = (comment ? comment.member : member);
if (!commentMember || !commentMember.name) {
return getInitials('Anonymous');
}
return getInitials(commentMember.name);
};
let commentMember = (comment ? comment.member : member);
const bgColor = HSLtoString(generateHSL());
const avatarStyle = {
background: bgColor
};
let avatarEl = (
<>
{memberName ?
(<div className={`flex items-center justify-center rounded-full ${dimensionClasses}`} style={avatarStyle}>
<p className="font-sans text-lg font-semibold text-white">{ commentGetInitials() }</p>
</div>) :
(<div className={`flex items-center justify-center rounded-full bg-neutral-900 dark:bg-[rgba(255,255,255,0.7)] ${dimensionClasses}`}>
<AvatarIcon className="stroke-white dark:stroke-[rgba(0,0,0,0.6)]" />
</div>)}
{commentMember && <img className={`absolute left-0 top-0 rounded-full ${dimensionClasses}`} src={commentMember.avatar_image} alt="Avatar"/>}
</>
);
return (
<figure className={`relative ${dimensionClasses}`}>
{avatarEl}
</figure>
);
};

View File

@ -0,0 +1,44 @@
import {useContext} from 'react';
import AppContext from '../../AppContext';
const CTABox = ({isFirst, isPaid}) => {
const {accentColor, publication, member} = useContext(AppContext);
const buttonStyle = {
backgroundColor: accentColor
};
const linkStyle = {
color: accentColor
};
const titleText = (isFirst ? 'Start the conversation' : 'Join the discussion');
const handleSignUpClick = () => {
window.location.href = (isPaid && member) ? '#/portal/account/plans' : '#/portal/signup';
};
const handleSignInClick = () => {
window.location.href = '#/portal/signin';
};
return (
<section className={`flex flex-col items-center pt-[40px] ${member ? 'pb-[32px]' : 'pb-[48px]'} ${!isFirst && 'mt-4'} border-y border-[rgba(0,0,0,0.075)] dark:border-[rgba(255,255,255,0.1)] sm:px-8`}>
<h1 className={`mb-[8px] text-center font-sans text-[24px] tracking-tight text-black dark:text-[rgba(255,255,255,0.85)] ${isFirst ? 'font-semibold' : 'font-bold'}`}>
{titleText}
</h1>
<p className="sm:max-w-screen-sm mb-[28px] w-full px-0 text-center font-sans text-[16px] leading-normal text-neutral-600 dark:text-[rgba(255,255,255,0.85)] sm:px-8">
Become a {isPaid && 'paid'} member of <span className="font-semibold">{publication}</span> to start commenting.
</p>
<button onClick={handleSignUpClick} className="font-san mb-[12px] inline-block rounded px-5 py-[14px] font-medium leading-none text-white transition-all hover:opacity-90" style={buttonStyle}>
{(isPaid && member) ? 'Upgrade now' : 'Sign up now'}
</button>
{!member && (<p className="text-center font-sans text-sm text-[rgba(0,0,0,0.4)] dark:text-[rgba(255,255,255,0.5)]">
<span className='mr-1 inline-block text-[15px]'>Already a member?</span>
<button onClick={handleSignInClick} className="rounded-md text-sm font-semibold transition-all hover:opacity-90" style={linkStyle}>Sign in</button>
</p>)}
</section>
);
};
export default CTABox;

View File

@ -0,0 +1,263 @@
import React, {useContext, useState} from 'react';
import {Transition} from '@headlessui/react';
import {BlankAvatar, Avatar} from './Avatar';
import LikeButton from './buttons/LikeButton';
import ReplyButton from './buttons/ReplyButton';
import MoreButton from './buttons/MoreButton';
import Replies from './Replies';
import AppContext from '../../AppContext';
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
import {useRelativeTime} from '../../utils/hooks';
import ReplyForm from './forms/ReplyForm';
import EditForm from './forms/EditForm';
function AnimatedComment({comment, parent}) {
return (
<Transition
appear
show={true}
enter="transition-opacity duration-300 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<EditableComment comment={comment} parent={parent} />
</Transition>
);
}
function EditableComment({comment, parent}) {
const [isInEditMode, setIsInEditMode] = useState(false);
const closeEditMode = () => {
setIsInEditMode(false);
};
const openEditMode = () => {
setIsInEditMode(true);
};
if (isInEditMode) {
return (
<EditForm comment={comment} close={closeEditMode} parent={parent} />
);
} else {
return (<Comment comment={comment} openEditMode={openEditMode} parent={parent} />);
}
}
function Comment({comment, parent, openEditMode}) {
const isPublished = isCommentPublished(comment);
if (isPublished) {
return (<PublishedComment comment={comment} parent={parent} openEditMode={openEditMode} />);
}
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
}
function PublishedComment({comment, parent, openEditMode}) {
const [isInReplyMode, setIsInReplyMode] = useState(false);
const {dispatchAction} = useContext(AppContext);
const toggleReplyMode = async () => {
if (!isInReplyMode) {
// First load all the replies before opening the reply model
await dispatchAction('loadMoreReplies', {comment, limit: 'all'});
}
setIsInReplyMode(current => !current);
};
const closeReplyMode = () => {
setIsInReplyMode(false);
};
const hasReplies = isInReplyMode || (comment.replies && comment.replies.length > 0);
const avatar = (<Avatar comment={comment} />);
return (
<CommentLayout hasReplies={hasReplies} avatar={avatar}>
<CommentHeader comment={comment} />
<CommentBody html={comment.html} />
<CommentMenu comment={comment} parent={parent} isInReplyMode={isInReplyMode} toggleReplyMode={toggleReplyMode} openEditMode={openEditMode} />
<RepliesContainer comment={comment} />
<ReplyFormBox comment={comment} isInReplyMode={isInReplyMode} closeReplyMode={closeReplyMode} />
</CommentLayout>
);
}
function UnpublishedComment({comment, openEditMode}) {
const {admin} = useContext(AppContext);
let notPublishedMessage;
if (admin && comment.status === 'hidden') {
notPublishedMessage = 'This comment has been hidden.';
} else {
notPublishedMessage = 'This comment has been removed.';
}
const avatar = (<BlankAvatar />);
const hasReplies = comment.replies && comment.replies.length > 0;
return (
<CommentLayout hasReplies={hasReplies} avatar={avatar}>
<div className="-mt-[3px] mb-2 flex items-start">
<div className="flex h-12 flex-row items-center gap-4 pb-[8px] pr-4">
<p className="mt-[4px] font-sans text-[16px] italic leading-normal text-[rgba(0,0,0,0.2)] dark:text-[rgba(255,255,255,0.35)]">{notPublishedMessage}</p>
<div className="mt-[4px]">
<MoreButton comment={comment} toggleEdit={openEditMode} />
</div>
</div>
</div>
<RepliesContainer comment={comment} />
</CommentLayout>
);
}
// Helper components
function MemberExpertise({comment}) {
const {member} = useContext(AppContext);
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
if (!memberExpertise) {
return null;
}
return (
<span>{memberExpertise}<span className="mx-[0.3em]">·</span></span>
);
}
function EditedInfo({comment}) {
if (!comment.edited_at) {
return null;
}
return (
<span>
<span className="mx-[0.3em]">·</span>Edited
</span>
);
}
function RepliesContainer({comment}) {
const hasReplies = comment.replies && comment.replies.length > 0;
if (!hasReplies) {
return null;
}
return (
<div className="mb-4 mt-10 sm:mb-0">
<Replies comment={comment} />
</div>
);
}
function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
if (!isInReplyMode) {
return null;
}
return (
<div className="my-10">
<ReplyForm parent={comment} close={closeReplyMode} />
</div>
);
}
//
// -- Published comment components --
//
// TODO: move name detection to helper
function AuthorName({comment}) {
const name = !comment.member ? 'Deleted member' : (comment.member.name ? comment.member.name : 'Anonymous');
return (
<h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
{name}
</h4>
);
}
function CommentHeader({comment}) {
const createdAtRelative = useRelativeTime(comment.created_at);
return (
<div className="-mt-[3px] mb-2 flex items-start">
<div>
<AuthorName comment={comment} />
<div className="flex items-baseline pr-4 font-sans text-[14px] tracking-tight text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">
<span>
<MemberExpertise comment={comment}/>
<span title={formatExplicitTime(comment.created_at)}>{createdAtRelative}</span>
<EditedInfo comment={comment} />
</span>
</div>
</div>
</div>
);
}
function CommentBody({html}) {
const dangerouslySetInnerHTML = {__html: html};
return (
<div className="mt mb-2 flex flex-row items-center gap-4 pr-4">
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content font-sans text-[16px] leading-normal text-neutral-900 dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/>
</div>
);
}
function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) {
// If this comment is from the current member, always override member
// with the member from the context, so we update the expertise in existing comments when we change it
const {member, commentsEnabled} = useContext(AppContext);
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const canReply = member && (isPaidMember || !paidOnly) && !parent;
return (
<div className="flex items-center gap-5">
{<LikeButton comment={comment} />}
{(canReply && <ReplyButton comment={comment} toggleReply={toggleReplyMode} isReplying={isInReplyMode} />)}
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div>
);
}
//
// -- Layout --
//
function RepliesLine({hasReplies}) {
if (!hasReplies) {
return null;
}
return (<div className="mb-2 h-full w-[3px] grow rounded bg-gradient-to-b from-[rgba(0,0,0,0.05)] via-[rgba(0,0,0,0.05)] to-transparent dark:from-[rgba(255,255,255,0.08)] dark:via-[rgba(255,255,255,0.08)]" />);
}
function CommentLayout({children, avatar, hasReplies}) {
return (
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-10'}`} data-testid="comment-component">
<div className="mr-3 flex flex-col items-center justify-start">
<div className="flex-0 mb-4">
{avatar}
</div>
<RepliesLine hasReplies={hasReplies} />
</div>
<div className="grow">
{children}
</div>
</div>
);
}
//
// -- Default --
//
export default AnimatedComment;

View File

@ -0,0 +1,54 @@
import React, {useContext, useEffect} from 'react';
import AppContext from '../../AppContext';
import CTABox from './CTABox';
import MainForm from './forms/MainForm';
import Comment from './Comment';
import Pagination from './Pagination';
import ContentTitle from './ContentTitle';
import {ROOT_DIV_ID} from '../../utils/constants';
const Content = () => {
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useContext(AppContext);
const commentsElements = comments.slice().reverse().map(comment => <Comment comment={comment} key={comment.id} />);
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
useEffect(() => {
const elem = document.getElementById(ROOT_DIV_ID);
// Check scroll position
if (elem && window.location.hash === `#ghost-comments`) {
// Only scroll if the user didn't scroll by the time we loaded the comments
// We could remove this, but if the network connection is slow, we risk having a page jump when the user already started scrolling
if (window.scrollY === 0) {
// This is a bit hacky, but one animation frame is not enough to wait for the iframe height to have changed and the DOM to be updated correctly before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => {
elem.scrollIntoView();
});
});
}
}
}, []);
const hasOpenSecundaryForms = secundaryFormCount > 0;
return (
<>
<ContentTitle title={title} showCount={showCount} count={commentCount}/>
<Pagination />
<div className={!pagination ? 'mt-4' : ''} data-test="comment-elements">
{commentsElements}
</div>
<div>
{!hasOpenSecundaryForms
? (member ? (isPaidMember || !paidOnly ? <MainForm commentsCount={commentCount} /> : <CTABox isFirst={pagination?.total === 0} isPaid={paidOnly} />) : <CTABox isFirst={pagination?.total === 0} isPaid={paidOnly} />)
: null
}
</div>
</>
);
};
export default Content;

View File

@ -0,0 +1,45 @@
import {formatNumber} from '../../utils/helpers';
const Count = ({showCount, count}) => {
if (!showCount) {
return null;
}
if (count === 1) {
return (
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">1 comment</div>
);
}
return (
<div className="text-[1.6rem] text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]">{formatNumber(count)} comments</div>
);
};
const Title = ({title}) => {
if (title === null) {
return (
<><span className="hidden sm:inline">Member </span><span className="capitalize sm:normal-case">discussion</span></>
);
}
return title;
};
const ContentTitle = ({title, showCount, count}) => {
// We have to check for null for title because null means default, wheras empty string means empty
if (!title && !showCount && title !== null) {
return null;
}
return (
<div className="mb-8 flex w-full items-baseline justify-between font-sans">
<h2 className="text-[2.8rem] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
<Title title={title}/>
</h2>
<Count count={count} showCount={showCount} />
</div>
);
};
export default ContentTitle;

View File

@ -0,0 +1,12 @@
import React from 'react';
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
function Loading() {
return (
<div className="flex h-32 w-full items-center justify-center">
<SpinnerIcon className="mb-6 h-12 w-12 fill-[rgb(225,225,225,0.9)] dark:fill-[rgba(255,255,255,0.6)]" />
</div>
);
}
export default Loading;

View File

@ -0,0 +1,29 @@
import React, {useContext} from 'react';
import {formatNumber} from '../../utils/helpers';
import AppContext from '../../AppContext';
const Pagination = () => {
const {pagination, dispatchAction} = useContext(AppContext);
const loadMore = () => {
dispatchAction('loadMoreComments');
};
if (!pagination) {
return null;
}
const left = pagination.total - pagination.page * pagination.limit;
if (left <= 0) {
return null;
}
return (
<button data-testid="pagination-component" type="button" className="group mb-10 flex w-full items-center px-0 pb-2 pt-0 text-left font-sans text-md font-semibold text-neutral-700 dark:text-white" onClick={loadMore}>
<span className="flex h-[39px] w-full items-center justify-center whitespace-nowrap rounded-[6px] bg-[rgba(0,0,0,0.05)] px-3 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-[opacity,background] duration-150 hover:bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.08)] dark:text-neutral-100 dark:hover:bg-[rgba(255,255,255,0.1)]"> Show {formatNumber(left)} previous {left === 1 ? 'comment' : 'comments'}</span>
</button>
);
};
export default Pagination;

View File

@ -0,0 +1,23 @@
import {useContext} from 'react';
import AppContext from '../../AppContext';
import Comment from './Comment';
import RepliesPagination from './RepliesPagination';
const Replies = ({comment}) => {
const {dispatchAction} = useContext(AppContext);
const repliesLeft = comment.count.replies - comment.replies.length;
const loadMore = () => {
dispatchAction('loadMoreReplies', {comment});
};
return (
<div>
{comment.replies.map((reply => <Comment comment={reply} parent={comment} key={reply.id} isReply={true} />))}
{repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>}
</div>
);
};
export default Replies;

View File

@ -0,0 +1,14 @@
import React from 'react';
import {formatNumber} from '../../utils/helpers';
const RepliesPagination = ({loadMore, count}) => {
return (
<div className="flex w-full items-center justify-start">
<button type="button" className="group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans text-md font-semibold text-neutral-700 dark:text-white sm:mb-12 " onClick={loadMore} data-testid="reply-pagination-button">
<span className="flex h-[39px] w-auto items-center justify-center whitespace-nowrap rounded-[6px] bg-[rgba(0,0,0,0.05)] px-4 py-2 text-center font-sans text-sm font-semibold text-neutral-700 outline-0 transition-[opacity,background] duration-150 hover:bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.08)] dark:text-neutral-100 dark:hover:bg-[rgba(255,255,255,0.1)]"> Show {formatNumber(count)} more {count === 1 ? 'reply' : 'replies'}</span>
</button>
</div>
);
};
export default RepliesPagination;

View File

@ -0,0 +1,45 @@
import {useContext, useState} from 'react';
import {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
import AppContext from '../../../AppContext';
function LikeButton({comment}) {
const {dispatchAction, member, commentsEnabled} = useContext(AppContext);
const [animationClass, setAnimation] = useState('');
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
const canLike = member && (isPaidMember || !paidOnly);
const toggleLike = () => {
if (!canLike) {
return;
}
if (!comment.liked) {
dispatchAction('likeComment', comment);
setAnimation('animate-heartbeat');
setTimeout(() => {
setAnimation('');
}, 400);
} else {
dispatchAction('unlikeComment', comment);
}
};
// If can like: use <button> element, otherwise use a <span>
const CustomTag = canLike ? `button` : `span`;
let likeCursor = 'cursor-pointer';
if (!canLike) {
likeCursor = 'cursor-text';
}
return (
<CustomTag type="button" className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${comment.liked ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.5)]'} ${!comment.liked && canLike && 'hover:text-[rgba(0,0,0,0.75)] hover:dark:text-[rgba(255,255,255,0.25)]'} ${likeCursor}`} onClick={toggleLike} data-testid="like-button">
<LikeIcon className={animationClass + ` mr-[6px] ${comment.liked ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] dark:stroke-[rgba(255,255,255,0.5)]'} ${!comment.liked && canLike && 'group-hover:stroke-[rgba(0,0,0,0.75)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} transition duration-50 ease-linear`} />
{comment.count.likes}
</CustomTag>
);
}
export default LikeButton;

View File

@ -0,0 +1,36 @@
import React, {useContext, useState} from 'react';
import CommentContextMenu from '../context-menus/CommentContextMenu';
import AppContext from '../../../AppContext';
import {ReactComponent as MoreIcon} from '../../../images/icons/more.svg';
const MoreButton = ({comment, toggleEdit}) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const {member, admin} = useContext(AppContext);
const toggleContextMenu = () => {
setIsContextMenuOpen(current => !current);
};
const closeContextMenu = () => {
setIsContextMenuOpen(false);
};
/*
* Whether we have at least one action inside the context menu
* (to hide the 'more' icon if we don't have any actions)
*/
const show = (!!member && comment.status === 'published') || !!admin;
if (!member) {
return null;
}
return (
<div className="relative" data-testid="more-button">
{show ? <button type="button" onClick={toggleContextMenu} className="outline-0"><MoreIcon className='duration-50 gh-comments-icon gh-comments-icon-more fill-[rgba(0,0,0,0.5)] outline-0 transition ease-linear hover:fill-[rgba(0,0,0,0.75)] dark:fill-[rgba(255,255,255,0.5)] dark:hover:fill-[rgba(255,255,255,0.25)]' /></button> : null}
{isContextMenuOpen ? <CommentContextMenu comment={comment} close={closeContextMenu} toggleEdit={toggleEdit} /> : null}
</div>
);
};
export default MoreButton;

View File

@ -0,0 +1,14 @@
import React, {useContext} from 'react';
import AppContext from '../../../AppContext';
import {ReactComponent as ReplyIcon} from '../../../images/icons/reply.svg';
function ReplyButton({disabled, isReplying, toggleReply}) {
const {member} = useContext(AppContext);
return member ?
(<button disabled={!!disabled} type="button" className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${isReplying ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.25)]'}`} onClick={toggleReply} data-testid="reply-button">
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] group-hover:stroke-[rgba(0,0,0,0.75)] dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} duration-50 transition ease-linear`} />Reply
</button>) : null;
}
export default ReplyButton;

View File

@ -0,0 +1,35 @@
import React, {useContext} from 'react';
import AppContext from '../../../AppContext';
const AdminContextMenu = ({comment, close}) => {
const {dispatchAction} = useContext(AppContext);
const hideComment = () => {
dispatchAction('hideComment', comment);
close();
};
const showComment = () => {
dispatchAction('showComment', comment);
close();
};
const isHidden = comment.status !== 'published';
return (
<div className="flex flex-col">
{
isHidden ?
<button type="button" className="w-full text-left text-[14px]" onClick={showComment}>
<span>Show </span><span className="hidden sm:inline">comment</span>
</button>
:
<button type="button" className="w-full text-left text-[14px]" onClick={hideComment}>
<span>Hide </span><span className="hidden sm:inline">comment</span>
</button>
}
</div>
);
};
export default AdminContextMenu;

View File

@ -0,0 +1,24 @@
import React, {useContext} from 'react';
import AppContext from '../../../AppContext';
const AuthorContextMenu = ({comment, close, toggleEdit}) => {
const {dispatchAction} = useContext(AppContext);
const deleteComment = (event) => {
dispatchAction('deleteComment', comment);
close();
};
return (
<div className="flex flex-col">
<button type="button" className="mb-3 w-full text-left text-[14px]" onClick={toggleEdit} data-testid="edit">
Edit
</button>
<button type="button" className="w-full text-left text-[14px] text-red-600" onClick={deleteComment}>
Delete
</button>
</div>
);
};
export default AuthorContextMenu;

View File

@ -0,0 +1,82 @@
import React, {useContext, useEffect, useRef} from 'react';
import AppContext from '../../../AppContext';
import AdminContextMenu from './AdminContextMenu';
import AuthorContextMenu from './AuthorContextMenu';
import NotAuthorContextMenu from './NotAuthorContextMenu';
const CommentContextMenu = ({comment, close, toggleEdit}) => {
const {member, admin} = useContext(AppContext);
const isAuthor = member && comment.member?.uuid === member?.uuid;
const isAdmin = !!admin;
const element = useRef();
useEffect(() => {
const listener = () => {
close();
};
// We need to listen for the window outside the iframe, and also the iframe window events
window.addEventListener('click', listener, {passive: true});
const el = element.current?.ownerDocument?.defaultView;
if (el && el !== window) {
el.addEventListener('click', listener, {passive: true});
}
return () => {
window.removeEventListener('click', listener, {passive: true});
if (el && el !== window) {
el.removeEventListener('click', listener, {passive: true});
}
};
}, [close]);
useEffect(() => {
const listener = (event) => {
if (event.key === 'Escape') {
close();
}
};
// For keydown, we only need to listen to the main window, because we pass the events
// manually in the Iframe component
window.addEventListener('keydown', listener, {passive: true});
return () => {
window.removeEventListener('keydown', listener, {passive: true});
};
}, [close]);
// Prevent closing the context menu when clicking inside of it
const stopPropagation = (event) => {
event.stopPropagation();
};
let contextMenu = null;
if (comment.status === 'published') {
if (isAuthor) {
contextMenu = <AuthorContextMenu comment={comment} close={close} toggleEdit={toggleEdit} />;
} else {
if (isAdmin) {
contextMenu = <AdminContextMenu comment={comment} close={close}/>;
} else {
contextMenu = <NotAuthorContextMenu comment={comment} close={close}/>;
}
}
} else {
if (isAdmin) {
contextMenu = <AdminContextMenu comment={comment} close={close}/>;
} else {
return null;
}
}
return (
<div ref={element} onClick={stopPropagation}>
<div className="absolute z-10 min-w-min whitespace-nowrap rounded bg-white py-3 pl-4 pr-8 font-sans text-sm shadow-lg outline-0 dark:bg-zinc-900 dark:text-white sm:min-w-[150px]">
{contextMenu}
</div>
</div>
);
};
export default CommentContextMenu;

View File

@ -0,0 +1,24 @@
import React, {useContext} from 'react';
import AppContext from '../../../AppContext';
const NotAuthorContextMenu = ({comment, close}) => {
const {dispatchAction} = useContext(AppContext);
const openModal = () => {
dispatchAction('openPopup', {
type: 'reportPopup',
comment
});
close();
};
return (
<div className="flex flex-col">
<button type="button" className="w-full text-left text-[14px]" onClick={openModal}>
<span>Report </span><span className="hidden sm:inline">comment</span>
</button>
</div>
);
};
export default NotAuthorContextMenu;

View File

@ -0,0 +1,69 @@
import {useEditor} from '@tiptap/react';
import {default as React, useCallback, useContext, useEffect} from 'react';
import AppContext from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import SecundaryForm from './SecundaryForm';
const EditForm = ({comment, parent, close}) => {
const {dispatchAction} = useContext(AppContext);
const config = {
placeholder: 'Edit this comment',
// warning: we cannot use autofocus on the edit field, because that sets
// the cursor position at the beginning of the text field instead of the end
autofocus: false,
content: comment.html
};
const editor = useEditor({
...getEditorConfig(config)
});
// Instead of autofocusing, we focus and jump to end manually
// // jump to end manually
useEffect(() => {
if (!editor) {
return;
}
editor
.chain()
.focus()
.command(({tr, commands}) => {
return commands.setTextSelection({
from: tr.doc.content.size,
to: tr.doc.content.size
});
})
.run();
}, [editor]);
const submit = useCallback(async ({html}) => {
// Send comment to server
await dispatchAction('editComment', {
comment: {
id: comment.id,
html
},
parent: parent
});
}, [parent, comment, dispatchAction]);
const submitProps = {
submitText: 'Save',
submitSize: 'small',
submit
};
const closeIfNotChanged = useCallback(() => {
if (editor?.getHTML() === comment.html) {
close();
}
}, [editor, close, comment.html]);
return (
<SecundaryForm editor={editor} close={close} closeIfNotChanged={closeIfNotChanged} {...submitProps} />
);
};
export default EditForm;

View File

@ -0,0 +1,246 @@
import {Transition} from '@headlessui/react';
import {EditorContent} from '@tiptap/react';
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
import AppContext from '../../../AppContext';
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
import {Avatar} from '../Avatar';
import {usePopupOpen} from '../../../utils/hooks';
const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => {
let buttonIcon = null;
if (progress === 'sending') {
submitText = null;
buttonIcon = <SpinnerIcon className="h-[24px] w-[24px] fill-white dark:fill-black" />;
}
const stopIfFocused = useCallback((event) => {
if (editor?.isFocused) {
event.stopPropagation();
return;
}
}, [editor]);
const submitForm = useCallback(async () => {
if (editor.isEmpty) {
return;
}
setProgress('sending');
try {
await submit({
html: editor.getHTML()
});
} catch (e) {
setProgress('error');
return;
}
if (close) {
close();
} else {
// Clear message and blur
setProgress('sent');
editor.chain().clearContent().blur().run();
}
return false;
}, [setProgress, editor, submit, close]);
// Keyboard shortcuts to submit and close/blur the form
useEffect(() => {
// Add some basic keyboard shortcuts
// ESC to blur the editor
const keyDownListener = (event) => {
if (event.metaKey || event.ctrlKey) {
// CMD on MacOS or CTRL
if (event.key === 'Enter' && editor?.isFocused) {
// Try submit
submitForm();
// Prevent inserting an enter in the editor
editor?.commands.blur();
}
return;
}
if (event.key === 'Escape') {
if (editor?.isFocused) {
if (close) {
close();
} else {
editor?.commands.blur();
}
}
return;
}
};
// Note: normally we would need to attach this listener to the window + the iframe window. But we made listener
// in the Iframe component that passes down all the keydown events to the main window to prevent that
window.addEventListener('keydown', keyDownListener, {passive: true});
return () => {
window.removeEventListener('keydown', keyDownListener, {passive: true});
};
}, [editor, close, submitForm]);
return (
<div className={`relative w-full pl-[52px] transition-[padding] delay-100 duration-150 ${reduced && 'pl-0'} ${isOpen && 'pl-[1px] pt-[64px] sm:pl-[52px]'}`}>
<div
className={`w-full rounded-md border border-none border-slate-50 bg-[rgba(255,255,255,0.9)] px-3 py-4 font-sans text-[16.5px] leading-normal shadow-form transition-all delay-100 duration-150 hover:shadow-formxl focus:outline-0 dark:border-none dark:bg-[rgba(255,255,255,0.08)] dark:text-neutral-300 dark:shadow-transparent ${isOpen ? 'min-h-[144px] cursor-text pb-[68px] pt-2' : 'min-h-[48px] cursor-pointer overflow-hidden hover:border-slate-300'}
`}>
<EditorContent
onMouseDown={stopIfFocused} onTouchStart={stopIfFocused}
editor={editor}
/>
</div>
<div className="absolute bottom-[9px] right-[9px] flex space-x-4 transition-[opacity] duration-150">
{close &&
<button type="button" onClick={close} className="ml-2.5 font-sans text-sm font-medium text-neutral-500 outline-0 dark:text-neutral-400">Cancel</button>
}
<button
className={`flex w-auto items-center justify-center sm:w-[128px] ${submitSize === 'medium' && 'sm:w-[100px]'} ${submitSize === 'small' && 'sm:w-[64px]'} h-[39px] rounded-[6px] border bg-neutral-900 px-3 py-2 text-center font-sans text-sm font-semibold text-white outline-0 transition-[opacity] duration-150 dark:bg-[rgba(255,255,255,0.9)] dark:text-neutral-800`}
type="button"
data-testid="submit-form-button"
onClick={submitForm}
>
<span>{buttonIcon}</span>
{submitText && <span>{submitText}</span>}
</button>
</div>
</div>
);
};
const FormHeader = ({show, name, expertise, editName, editExpertise}) => {
return (
<Transition
show={show}
enter="transition duration-500 delay-100 ease-in-out"
enterFrom="opacity-0 -translate-x-2"
enterTo="opacity-100 translate-x-0"
leave="transition-none duration-0"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="font-sans text-[17px] font-bold tracking-tight text-[rgb(23,23,23)] dark:text-[rgba(255,255,255,0.85)]"
onClick={editName}
data-testid="member-name"
>
{name ? name : 'Anonymous'}
</div>
<div className="flex items-baseline justify-start">
<button
type="button"
className={`group flex max-w-[80%] items-center justify-start whitespace-nowrap text-left font-sans text-[14px] tracking-tight text-[rgba(0,0,0,0.5)] transition duration-150 hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.4)] sm:max-w-[90%] ${!expertise && 'text-[rgba(0,0,0,0.3)] hover:text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.3)]'}`}
onClick={editExpertise}
>
<span className="... overflow-hidden text-ellipsis">{expertise ? expertise : 'Add your expertise'}</span>
{expertise && <EditIcon className="ml-1 h-[12px] w-[12px] -translate-x-[6px] stroke-[rgba(0,0,0,0.5)] opacity-0 transition-all duration-100 ease-out group-hover:translate-x-0 group-hover:stroke-[rgba(0,0,0,0.75)] group-hover:opacity-100 dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.3)]" />}
</button>
</div>
</Transition>
);
};
const Form = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => {
const {member, dispatchAction} = useContext(AppContext);
const isAskingDetails = usePopupOpen('addDetailsPopup');
const [progress, setProgress] = useState('default');
const formEl = useRef(null);
const memberName = member?.name ?? comment?.member?.name;
const memberExpertise = member?.expertise ?? comment?.member?.expertise;
if (progress === 'sending' || (memberName && isAskingDetails)) {
// Force open
isOpen = true;
}
const preventIfFocused = (event) => {
if (editor?.isFocused) {
event.preventDefault();
return;
}
};
const openEditDetails = useCallback((options) => {
editor?.commands?.blur();
dispatchAction('openPopup', {
type: 'addDetailsPopup',
expertiseAutofocus: options.expertiseAutofocus ?? false,
// WIP
callback: function (succeeded) {
if (!editor || !formEl.current) {
return;
}
// Don't use focusEditor to avoid loop
if (!succeeded) {
return;
}
// useEffect is not fast enought to enable it
editor.setEditable(true);
editor.commands.focus();
}
});
}, [editor, dispatchAction, formEl]);
const editName = useCallback(() => {
openEditDetails({expertiseAutofocus: false});
}, [openEditDetails]);
const editExpertise = useCallback(() => {
openEditDetails({expertiseAutofocus: true});
}, [openEditDetails]);
const focusEditor = useCallback(() => {
if (editor.isFocused) {
return;
}
// Force to input a name first
if (!memberName) {
editName();
return;
}
editor.commands.focus();
}, [editor, editName, memberName]);
useEffect(() => {
if (!editor) {
return;
}
// Disable editing if the member doesn't have a name or when we are submitting the form
editor.setEditable(!!memberName && progress !== 'sending');
}, [editor, memberName, progress]);
return (
<form ref={formEl} data-testid="form" onClick={focusEditor} onMouseDown={preventIfFocused} onTouchStart={preventIfFocused} className={`-mx-3 -mt-[10px] mb-10 rounded-md px-3 pb-2 pt-3 transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'} ${reduced && 'pl-1'}
`}>
<div className="relative w-full">
<div className="pr-[1px] font-sans leading-normal dark:text-neutral-300">
<FormEditor close={close} reduced={reduced} isOpen={isOpen} editor={editor} submitText={submitText} submitSize={submitSize} submit={submit} setProgress={setProgress} progress={progress} />
</div>
<div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'>
<div className="mr-3 grow-0">
<Avatar comment={comment} className="pointer-events-none" />
</div>
<div className="grow-1 w-full">
<FormHeader show={isOpen} name={memberName} expertise={memberExpertise} editExpertise={editExpertise} editName={editName} />
</div>
</div>
</div>
</form>
);
};
export default Form;

View File

@ -0,0 +1,100 @@
import {useEditor} from '@tiptap/react';
import React, {useCallback, useContext, useEffect, useRef} from 'react';
import AppContext from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {scrollToElement} from '../../../utils/helpers';
import Form from './Form';
const MainForm = ({commentsCount}) => {
const {postId, dispatchAction} = useContext(AppContext);
const config = {
placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'),
autofocus: false
};
const editor = useEditor({
...getEditorConfig(config)
});
const submit = useCallback(async ({html}) => {
// Send comment to server
await dispatchAction('addComment', {
post_id: postId,
status: 'published',
html
});
}, [postId, dispatchAction]);
// C keyboard shortcut to focus main form
const formEl = useRef(null);
useEffect(() => {
if (!editor) {
return;
}
// Add some basic keyboard shortcuts
// ESC to blur the editor
const keyDownListener = (event) => {
if (!editor) {
return;
}
if (event.metaKey || event.ctrlKey) {
// CMD on MacOS or CTRL
// Don't do anything
return;
}
let focusedElement = document.activeElement;
while (focusedElement && focusedElement.tagName === 'IFRAME') {
if (!focusedElement.contentDocument) {
// CORS issue
// disable the C shortcut when we have a focused external iframe
break;
}
focusedElement = focusedElement.contentDocument.activeElement;
}
const hasInputFocused = focusedElement && (focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA' || focusedElement.tagName === 'IFRAME' || focusedElement.contentEditable === 'true');
if (event.key === 'c' && !editor?.isFocused && !hasInputFocused) {
editor?.commands.focus();
if (formEl.current) {
scrollToElement(formEl.current);
}
return;
}
};
// Note: normally we would need to attach this listener to the window + the iframe window. But we made listener
// in the Iframe component that passes down all the keydown events to the main window to prevent that
window.addEventListener('keydown', keyDownListener, {passive: true});
return () => {
window.removeEventListener('keydown', keyDownListener, {passive: true});
};
}, [editor]);
const submitProps = {
submitText: (
<>
<span className="hidden sm:inline">Add </span><span className="capitalize sm:normal-case">comment</span>
</>
),
submitSize: 'large',
submit
};
const isOpen = editor?.isFocused ?? false;
return (
<div className='-mt-[4px]' ref={formEl} data-testid="main-form">
<Form editor={editor} reduced={false} isOpen={isOpen} {...submitProps} />
</div>
);
};
export default MainForm;

View File

@ -0,0 +1,57 @@
import {useEditor} from '@tiptap/react';
import {default as React, useCallback, useContext} from 'react';
import AppContext from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor';
import {scrollToElement} from '../../../utils/helpers';
import {useRefCallback} from '../../../utils/hooks';
import SecundaryForm from './SecundaryForm';
const ReplyForm = ({parent, close}) => {
const {postId, dispatchAction} = useContext(AppContext);
const [, setForm] = useRefCallback(scrollToElement);
const config = {
placeholder: 'Reply to comment',
autofocus: true
};
const editor = useEditor({
...getEditorConfig(config)
});
const submit = useCallback(async ({html}) => {
// Send comment to server
await dispatchAction('addReply', {
parent: parent,
reply: {
post_id: postId,
status: 'published',
html
}
});
}, [parent, postId, dispatchAction]);
const submitProps = {
submitText: (
<>
<span className="hidden sm:inline">Add </span><span className="capitalize sm:normal-case">reply</span>
</>
),
submitSize: 'medium',
submit
};
const closeIfNotChanged = useCallback(() => {
if (editor?.isEmpty) {
close();
}
}, [editor, close]);
return (
<div ref={setForm}>
<SecundaryForm close={close} closeIfNotChanged={closeIfNotChanged} editor={editor} {...submitProps} />
</div>
);
};
export default ReplyForm;

View File

@ -0,0 +1,39 @@
import React, {useContext, useEffect} from 'react';
import AppContext from '../../../AppContext';
import {isMobile} from '../../../utils/helpers';
import {useSecondUpdate} from '../../../utils/hooks';
import Form from './Form';
const SecundaryForm = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
const {dispatchAction, secundaryFormCount} = useContext(AppContext);
// Keep track of the amount of open forms
useEffect(() => {
dispatchAction('increaseSecundaryFormCount');
return () => {
dispatchAction('decreaseSecundaryFormCount');
};
}, [dispatchAction]);
useSecondUpdate(() => {
// We use useSecondUpdate because:
// first call is the mounting of the form
// second call is the increaseSecundaryFormCount from our own
// third call means a different SecondaryForm is mounted or unmounted, and we need to close if not changed
if (secundaryFormCount > 1) {
closeIfNotChanged();
}
}, [secundaryFormCount]);
const reduced = isMobile();
return (
<div className='-mt-[20px]'>
<Form editor={editor} submit={submit} close={close} submitText={submitText} submitSize={submitSize} reduced={reduced} isOpen={true} />
</div>
);
};
export default SecundaryForm;

View File

@ -0,0 +1,197 @@
import React, {useContext, useState, useRef, useEffect} from 'react';
import {Transition} from '@headlessui/react';
import CloseButton from './CloseButton';
import AppContext from '../../AppContext';
import {isMobile} from '../../utils/helpers';
const AddDetailsPopup = (props) => {
const inputNameRef = useRef(null);
const inputExpertiseRef = useRef(null);
const {dispatchAction, member, accentColor} = useContext(AppContext);
const [name, setName] = useState(member.name ?? '');
const [expertise, setExpertise] = useState(member.expertise ?? '');
const maxExpertiseChars = 50;
let initialExpertiseChars = maxExpertiseChars;
if (member.expertise) {
initialExpertiseChars -= member.expertise.length;
}
const [expertiseCharsLeft, setExpertiseCharsLeft] = useState(initialExpertiseChars);
const [error, setError] = useState({name: '', expertise: ''});
const stopPropagation = (event) => {
event.stopPropagation();
};
const close = (succeeded) => {
dispatchAction('closePopup');
props.callback(succeeded);
};
const submit = async () => {
if (name.trim() !== '') {
await dispatchAction('updateMember', {
name,
expertise
});
close(true);
} else {
setError({name: 'Enter your name'});
setName('');
inputNameRef.current?.focus();
}
};
// using <input autofocus> breaks transitions in browsers. So we need to use a timer
useEffect(() => {
if (!isMobile()) {
const timer = setTimeout(() => {
if (props.expertiseAutofocus) {
inputExpertiseRef.current?.focus();
} else {
inputNameRef.current?.focus();
}
}, 200);
return () => {
clearTimeout(timer);
};
}
}, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);
const renderExampleProfiles = (index) => {
const renderEl = (profile) => {
return (
<Transition
appear
enter={`transition duration-200 delay-[400ms] ease-out`}
enterFrom="opacity-0 translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition duration-200 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-2"
key={profile.name}
>
<div className="flex flex-row items-center justify-start gap-3 pr-4">
<div className="h-10 w-10 rounded-full border-2 border-white bg-cover bg-no-repeat" style={{backgroundImage: `url(${profile.avatar})`}} />
<div className="flex flex-col items-start justify-center">
<div className="text-base font-sans font-semibold tracking-tight text-white">
{profile.name}
</div>
<div className="font-sans text-[14px] tracking-tight text-neutral-400">
{profile.expertise}
</div>
</div>
</div>
</Transition>
);
};
let returnable = [];
// using URLS over real images for avatars as serving JPG images was not optimal (based on discussion with team)
let exampleProfiles = [
{avatar: 'https://randomuser.me/api/portraits/men/32.jpg', name: 'James Fletcher', expertise: 'Full-time parent'},
{avatar: 'https://randomuser.me/api/portraits/women/30.jpg', name: 'Naomi Schiff', expertise: 'Founder @ Acme Inc'},
{avatar: 'https://randomuser.me/api/portraits/men/4.jpg', name: 'Franz Tost', expertise: 'Neurosurgeon'},
{avatar: 'https://randomuser.me/api/portraits/women/51.jpg', name: 'Katrina Klosp', expertise: 'Local resident'}
];
for (let i = 0; i < exampleProfiles.length; i++) {
returnable.push(renderEl(exampleProfiles[i]));
}
return returnable;
};
return (
<div className="rounded-none relative h-screen w-screen overflow-hidden bg-white p-[28px] text-center shadow-modal sm:h-auto sm:w-[720px] sm:rounded-xl sm:p-0" onMouseDown={stopPropagation}>
<div className="flex">
{!isMobile() &&
<div className={`flex w-[40%] flex-col items-center justify-center bg-[#1C1C1C]`}>
<div className="-mt-[1px] flex flex-col gap-9">
{renderExampleProfiles()}
</div>
</div>
}
<div className={`${isMobile() ? 'w-full' : 'w-[60%]'} p-0 sm:p-8`}>
<h1 className="mb-1 text-center font-sans text-[24px] font-bold tracking-tight text-black sm:text-left">Complete your profile<span className="hidden sm:inline">.</span></h1>
<p className="text-base pr-0 text-center font-sans leading-9 text-neutral-500 sm:pr-10 sm:text-left">Add context to your comment, share your name and expertise to foster a healthy discussion.</p>
<section className="mt-8 text-left">
<div className="mb-2 flex flex-row justify-between">
<label htmlFor="comments-name" className="font-sans text-[1.3rem] font-semibold">Name</label>
<Transition
show={!!error.name}
enter="transition duration-300 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-100 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="font-sans text-sm text-red-500">{error.name}</div>
</Transition>
</div>
<input
id="comments-name"
className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`}
type="text"
name="name"
ref={inputNameRef}
value={name}
placeholder="Jamie Larson"
onChange={(e) => {
setName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setName(e.target.value);
submit();
}
}}
maxLength="64"
/>
<div className="mb-2 mt-6 flex flex-row justify-between">
<label htmlFor="comments-name" className="font-sans text-[1.3rem] font-semibold">Expertise</label>
<div className={`font-sans text-[1.3rem] text-neutral-400 ${(expertiseCharsLeft === 0) && 'text-red-500'}`}><b>{expertiseCharsLeft}</b> characters left</div>
</div>
<input
id="comments-expertise"
className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${(expertiseCharsLeft === 0) && 'border-red-500 focus:border-red-500'}`}
type="text"
name="expertise"
ref={inputExpertiseRef}
value={expertise}
placeholder="Head of Marketing at Acme, Inc"
onChange={(e) => {
let expertiseText = e.target.value;
setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
setExpertise(expertiseText);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setExpertise(e.target.value);
submit();
}
}}
maxLength={maxExpertiseChars}
/>
<button
type="button"
className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`}
style={{backgroundColor: accentColor ?? '#000000'}}
onClick={submit}
>
Save
</button>
</section>
</div>
<CloseButton close={() => close(false)} />
</div>
</div>
);
};
export default AddDetailsPopup;

View File

@ -0,0 +1,12 @@
import React from 'react';
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
const CloseButton = (props) => {
return (
<button type="button" className="absolute right-6 top-6 opacity-20 transition-opacity duration-100 ease-out hover:opacity-40 sm:right-8 sm:top-10" onClick={props.close}>
<CloseIcon className="h-[20px] w-[20px]" />
</button>
);
};
export default CloseButton;

View File

@ -0,0 +1,61 @@
import React, {useContext, useEffect} from 'react';
import {Transition} from '@headlessui/react';
import {PopupFrame} from '../Frame';
import AppContext from '../../AppContext';
const GenericPopup = ({show, children, title, callback}) => {
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
const {dispatchAction} = useContext(AppContext);
const close = (event) => {
dispatchAction('closePopup');
if (callback) {
callback(false);
}
};
useEffect(() => {
const listener = (event) => {
if (event.key === 'Escape') {
close();
}
};
window.addEventListener('keydown', listener, {passive: true});
return () => {
window.removeEventListener('keydown', listener, {passive: true});
};
});
return (
<Transition show={show} appear={true}>
<PopupFrame title={title}>
<div>
<Transition.Child
enter="transition duration-200 linear"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-200 linear"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="to-rgba(0,0,0,0.1) fixed left-0 top-0 flex h-screen w-screen justify-center overflow-hidden bg-gradient-to-b from-[rgba(0,0,0,0.2)] pt-0 backdrop-blur-[2px] sm:pt-12" onMouseDown={close}>
<Transition.Child
enter="transition duration-200 delay-150 linear"
enterFrom="translate-y-4 opacity-0"
enterTo="translate-y-0 opacity-100"
leave="transition duration-200 linear"
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-4 opacity-0"
>
{children}
</Transition.Child>
</div>
</Transition.Child>
</div>
</PopupFrame>
</Transition>
);
};
export default GenericPopup;

View File

@ -0,0 +1,74 @@
import React, {useContext, useState} from 'react';
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg';
import CloseButton from './CloseButton';
import AppContext from '../../AppContext';
const ReportPopup = (props) => {
const {dispatchAction} = useContext(AppContext);
const [progress, setProgress] = useState('default');
let buttonColor = 'bg-red-600';
if (progress === 'sent') {
buttonColor = 'bg-green-600';
}
let buttonText = 'Report this comment';
if (progress === 'sending') {
buttonText = 'Sending';
} else if (progress === 'sent') {
buttonText = 'Sent';
}
let buttonIcon = null;
if (progress === 'sending') {
buttonIcon = <SpinnerIcon className="mr-2 h-[24px] w-[24px] fill-white" />;
} else if (progress === 'sent') {
buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />;
}
const stopPropagation = (event) => {
event.stopPropagation();
};
const close = (event) => {
dispatchAction('closePopup');
};
const submit = (event) => {
event.stopPropagation();
setProgress('sending');
// purposely faking the timing of the report being sent for user feedback purposes
setTimeout(() => {
setProgress('sent');
dispatchAction('reportComment', props.comment);
setTimeout(() => {
close();
}, 750);
}, 1000);
};
return (
<div className="rounded-none relative h-screen w-screen bg-white p-[28px] text-center shadow-modal sm:h-auto sm:w-[500px] sm:rounded-xl sm:p-8 sm:text-left" onMouseDown={stopPropagation}>
<h1 className="mb-1 font-sans text-[24px] font-bold tracking-tight text-black">You want to report<span className="hidden sm:inline"> this comment</span>?</h1>
<p className="text-base px-4 font-sans leading-9 text-neutral-500 sm:pl-0 sm:pr-4">Your request will be sent to the owner of this site.</p>
<div className="mt-10 flex flex-col items-center justify-start gap-4 sm:flex-row">
<button
type="button"
className={`flex h-[44px] w-full items-center justify-center rounded-md px-4 font-sans text-[15px] font-semibold text-white transition duration-200 ease-linear sm:w-[200px] ${buttonColor} opacity-100 hover:opacity-90`}
onClick={submit}
style={{backgroundColor: buttonColor ?? '#000000'}}
>
{buttonIcon}{buttonText}
</button>
<button type="button" onClick={close} className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400">Cancel</button>
</div>
<CloseButton close={() => close(false)} />
</div>
);
};
export default ReportPopup;

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9.5C7 10.1566 7.12933 10.8068 7.3806 11.4134C7.63188 12.02 8.00017 12.5712 8.46447 13.0355C8.92876 13.4998 9.47995 13.8681 10.0866 14.1194C10.6932 14.3707 11.3434 14.5 12 14.5C12.6566 14.5 13.3068 14.3707 13.9134 14.1194C14.52 13.8681 15.0712 13.4998 15.5355 13.0355C15.9998 12.5712 16.3681 12.02 16.6194 11.4134C16.8707 10.8068 17 10.1566 17 9.5C17 8.84339 16.8707 8.19321 16.6194 7.58658C16.3681 6.97995 15.9998 6.42876 15.5355 5.96447C15.0712 5.50017 14.52 5.13188 13.9134 4.8806C13.3068 4.62933 12.6566 4.5 12 4.5C11.3434 4.5 10.6932 4.62933 10.0866 4.8806C9.47995 5.13188 8.92876 5.50017 8.46447 5.96447C8.00017 6.42876 7.63188 6.97995 7.3806 7.58658C7.12933 8.19321 7 8.84339 7 9.5V9.5Z" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="11.375" stroke="white" stroke-width="1.25"/>
<path d="M19.5 20C19.092 19.6069 18.4941 19.2498 17.7403 18.9489C16.9864 18.6481 16.0915 18.4094 15.1066 18.2466C14.1217 18.0838 13.0661 18 12 18C10.9339 18 9.87831 18.0838 8.8934 18.2466C7.90848 18.4094 7.01357 18.6481 6.25975 18.9489C5.50593 19.2498 4.90796 19.6069 4.5 20" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="gh-portal-closeicon" alt="Close"><defs><style>.a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.2px !important;}</style></defs><path class="a" d="M.75 23.249l22.5-22.5M23.25 23.249L.75.749"></path></svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g stroke-linecap="round" stroke-width="2" fill="none" stroke-linejoin="round" class="nc-icon-wrapper"><polygon points="7 21 2 22 3 17 18 2 22 6 7 21"></polygon></g></svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="black">
<path d="M8.00025 13.8739L2.46073 8.09636C1.97388 7.60989 1.65255 6.98248 1.54231 6.30312C1.43208 5.62376 1.53853 4.92694 1.84658 4.31148V4.31148C2.07887 3.84703 2.41819 3.44443 2.83659 3.13684C3.25499 2.82925 3.74049 2.62547 4.25308 2.54231C4.76567 2.45914 5.29069 2.49896 5.78488 2.65849C6.27906 2.81802 6.72827 3.09269 7.09549 3.45987L8.00025 4.36406L8.90502 3.45987C9.27224 3.09269 9.72145 2.81802 10.2156 2.65849C10.7098 2.49896 11.2348 2.45914 11.7474 2.54231C12.26 2.62547 12.7455 2.82925 13.1639 3.13684C13.5823 3.44443 13.9216 3.84703 14.1539 4.31148C14.4616 4.9267 14.5678 5.62311 14.4577 6.30208C14.3476 6.98105 14.0267 7.60817 13.5404 8.09462L8.00025 13.8739Z" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 860 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="black" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12C3.82843 12 4.5 11.3284 4.5 10.5C4.5 9.67157 3.82843 9 3 9C2.17157 9 1.5 9.67157 1.5 10.5C1.5 11.3284 2.17157 12 3 12Z" />
<path d="M8 12C8.82843 12 9.5 11.3284 9.5 10.5C9.5 9.67157 8.82843 9 8 9C7.17157 9 6.5 9.67157 6.5 10.5C6.5 11.3284 7.17157 12 8 12Z" />
<path d="M13 12C13.8284 12 14.5 11.3284 14.5 10.5C14.5 9.67157 13.8284 9 13 9C12.1716 9 11.5 9.67157 11.5 10.5C11.5 11.3284 12.1716 12 13 12Z" />
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="black" xmlns="http://www.w3.org/2000/svg">
<path d="M7.17463 2.98741C7.17439 2.7888 7.11434 2.59487 7.00229 2.43088C6.89025 2.26689 6.73141 2.14046 6.54647 2.06805C6.36153 1.99564 6.15907 1.98061 5.96546 2.02492C5.77186 2.06923 5.5961 2.17083 5.46107 2.31648L1.86192 6.21418C1.69351 6.39677 1.60001 6.63606 1.60001 6.88445C1.60001 7.13284 1.69351 7.37213 1.86192 7.55472L5.45909 11.4524C5.59412 11.5981 5.76988 11.6997 5.96349 11.744C6.15709 11.7883 6.35955 11.7733 6.54449 11.7009C6.72943 11.6284 6.88827 11.502 7.00032 11.338C7.11236 11.174 7.17242 10.9801 7.17265 10.7815V8.86164H9.12703C10.438 8.86164 11.6953 9.38241 12.6222 10.3094C13.5492 11.2364 14.07 12.4937 14.07 13.8046V9.85023C14.07 8.53927 13.5492 7.28201 12.6222 6.35502C11.6953 5.42803 10.438 4.90726 9.12703 4.90726H7.17265L7.17463 2.98741Z" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

View File

@ -0,0 +1,7 @@
<svg version="1.1" id="loader-1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="40px" height="40px" viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
<path d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z">
<animateTransform attributeType="xml" attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.6s" repeatCount="indefinite"/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@ -0,0 +1,5 @@
<svg width="26px" height="26px" viewBox="-1 -1 26 26" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="check-circle" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path class="animated-check-circle" d="M19.6004116,2.71490714 C17.5353801,1.0196018 14.8927356,0 12,0 C5.38938053,0 0,5.38938053 0,12 C0,18.6106195 5.38938053,24 12,24 C18.6106195,24 24,18.6106195 24,12 C24,9.71681416 23.3628319,7.59292035 22.2743363,5.78761062 L11.0442478,17.0442478 L5.49557522,11.4955752" id="Shape" stroke="#FFFFFF" stroke-width="2"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1,90 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import {ROOT_DIV_ID} from './utils/constants';
function addRootDiv() {
const scriptTag = document.querySelector('script[data-ghost-comments]');
// We need to inject the comment box at the same place as the script tag
if (scriptTag) {
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
scriptTag.parentElement.insertBefore(elem, scriptTag);
} else if (process.env.NODE_ENV === 'development') {
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
document.body.appendChild(elem);
} else {
// eslint-disable-next-line no-console
console.warn('[Comments] Comment box location was not found: could not load comments box.');
}
}
function getSiteData() {
/**
* @type {HTMLElement}
*/
const scriptTag = document.querySelector('script[data-ghost-comments]');
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 sentryDsn = dataset.sentryDsn;
const postId = dataset.postId;
const colorScheme = dataset.colorScheme;
const avatarSaturation = dataset.avatarSaturation;
const accentColor = dataset.accentColor;
const appVersion = dataset.appVersion;
const commentsEnabled = dataset.commentsEnabled;
const stylesUrl = dataset.styles;
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, stylesUrl, apiKey, apiUrl, sentryDsn, postId, adminUrl, colorScheme, avatarSaturation, accentColor, appVersion, commentsEnabled, title, showCount, publication};
}
function handleTokenUrl() {
const url = new URL(window.location.href);
if (url.searchParams.get('token')) {
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.href);
}
}
function setup({siteUrl}) {
addRootDiv();
handleTokenUrl();
}
function init() {
// const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, ...siteData} = getSiteData();
const siteUrl = customSiteUrl || window.location.origin;
try {
setup({siteUrl});
ReactDOM.render(
<React.StrictMode>
{<App siteUrl={siteUrl} customSiteUrl={customSiteUrl} {...siteData} />}
</React.StrictMode>,
document.getElementById(ROOT_DIV_ID)
);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
init();

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,11 @@
import AddDetailsPopup from './components/popups/AddDetailsPopup';
import ReportPopup from './components/popups/ReportPopup';
/** List of all available pages in Comments-UI, mapped to their UI component
* Any new page added to comments-ui needs to be mapped here
*/
const Pages = {
addDetailsPopup: AddDetailsPopup,
reportPopup: ReportPopup
};
export default Pages;

View File

@ -0,0 +1,15 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// TODO: remove this once we're switched `jest` to `vi` in code
// eslint-disable-next-line no-undef
globalThis.jest = vi;
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn()
}));

View File

@ -0,0 +1,77 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Disable scrolling inside iframe */
body, html {
overflow: hidden;
}
:host {
/* Reset all CSS properties */
all: initial !important;
}
html {
font-size: 62.5%;
}
body {
font-size: 1.5rem;
}
/* Comment HTML styles */
/* This makes sure we can have empty lines in comments (= <p></p>) */
.gh-comment-content p:empty::after {
content: "\00A0";
}
/* Links */
.gh-comment-content a {
word-break: break-word;
text-decoration: underline;
color: var(--gh-accent-color);
}
/* Blockquotes */
.gh-comment-content blockquote {
border-left: 3px solid rgba(13,13,13,.1);
padding-left: 1rem;
margin: 0 0 1.2rem;
}
/* Paragraphs */
.gh-comment-content p {
margin: 0 0 1.2rem;
}
.gh-comment-content p:last-child,
.gh-comment-content blockquote:last-child {
margin: 0;
}
/* The following lines are needed for the editor */
/* Placeholder */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
--tw-text-opacity: 1;
color: rgb(212 212 212 / var(--tw-text-opacity));
}
.ProseMirror * {
white-space: pre-wrap;
word-wrap: break-word;
}
[contenteditable]:focus {
outline: 0px solid transparent;
}
.ghost-display {
display: block !important;
}

View File

@ -0,0 +1,279 @@
function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
const apiPath = 'members/api';
function endpointFor({type, resource, params = ''}) {
if (type === 'members') {
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/${params}`;
}
}
function contentEndpointFor({resource, params = ''}) {
if (apiUrl && apiKey) {
return `${apiUrl.replace(/\/$/, '')}/${resource}/?key=${apiKey}&limit=all${params}`;
}
return '';
}
function makeRequest({url, method = 'GET', headers = {}, credentials = undefined, body = undefined}) {
const options = {
method,
headers,
credentials,
body
};
return fetch(url, options);
}
const api = {};
api.site = {
settings() {
const url = contentEndpointFor({resource: 'settings'});
return makeRequest({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to fetch site data');
}
});
}
};
api.member = {
identity() {
const url = endpointFor({type: 'members', resource: 'session'});
return makeRequest({
url,
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok || res.status === 204) {
return null;
}
return res.text();
});
},
sessionData() {
const url = endpointFor({type: 'members', resource: 'member'});
return makeRequest({
url,
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok || res.status === 204) {
return null;
}
return res.json();
});
},
update({name, expertise}) {
const url = endpointFor({type: 'members', resource: 'member'});
const body = {
name,
expertise
};
return makeRequest({
url,
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.json();
});
}
};
// To fix pagination when we create new comments (or people post comments after you loaded the page, we need to only load comments creatd AFTER the page load)
let firstCommentsLoadedAt = null;
api.comments = {
async count({postId}) {
const params = postId ? `?ids=${postId}` : '';
const url = endpointFor({type: 'members', resource: `comments/counts`, params});
const response = await makeRequest({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const json = await response.json();
return json[postId];
},
browse({page, postId}) {
firstCommentsLoadedAt = firstCommentsLoadedAt ?? new Date().toISOString();
const filter = encodeURIComponent(`post_id:${postId}+created_at:<=${firstCommentsLoadedAt}`);
const order = encodeURIComponent('created_at DESC, id DESC');
const url = endpointFor({type: 'members', resource: 'comments', params: `?limit=5&order=${order}&filter=${filter}&page=${page}`});
return makeRequest({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to fetch comments');
}
});
},
async replies({page, commentId, afterReplyId, limit}) {
const filter = encodeURIComponent(`id:>${afterReplyId}`);
const order = encodeURIComponent('created_at ASC, id ASC');
const url = endpointFor({type: 'members', resource: `comments/${commentId}/replies`, params: `?limit=${limit ?? 5}&order=${order}&filter=${filter}`});
const res = await makeRequest({
url,
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to fetch replies');
}
},
add({comment}) {
const body = {
comments: [comment]
};
const url = endpointFor({type: 'members', resource: 'comments'});
return makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to add comment');
}
});
},
edit({comment}) {
const body = {
comments: [comment]
};
const url = endpointFor({type: 'members', resource: `comments/${comment.id}`});
return makeRequest({
url,
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to edit comment');
}
});
},
read(commentId) {
const url = endpointFor({type: 'members', resource: `comments/${commentId}`});
return makeRequest({
url,
method: 'GET',
credentials: 'same-origin'
}).then(function (res) {
if (res.ok) {
return res.json();
} else {
throw new Error('Failed to read comment');
}
});
},
like({comment}) {
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
return makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
if (res.ok) {
return 'Success';
} else {
throw new Error('Failed to like comment');
}
});
},
unlike({comment}) {
const body = {
comments: [comment]
};
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
return makeRequest({
url,
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}).then(function (res) {
if (res.ok) {
return 'Success';
} else {
throw new Error('Failed to unlike comment');
}
});
},
report({comment}) {
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/report`});
return makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(function (res) {
if (res.ok) {
return 'Success';
} else {
throw new Error('Failed to report comment');
}
});
}
};
api.init = async () => {
let [member] = await Promise.all([
api.member.sessionData()
]);
return {member};
};
return api;
}
export default setupGhostApi;

View File

@ -0,0 +1,47 @@
import setupGhostApi from './api';
test('should call counts endpoint', () => {
jest.spyOn(window, 'fetch');
window.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({success: true})
});
const api = setupGhostApi({});
api.comments.count({postId: null});
expect(window.fetch).toHaveBeenCalledTimes(1);
expect(window.fetch).toHaveBeenCalledWith(
'http://localhost:3000/members/api/comments/counts/',
expect.objectContaining({
method: 'GET',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: undefined
}),
);
});
test('should call counts endpoint with postId query param', () => {
jest.spyOn(window, 'fetch');
window.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({success: true})
});
const api = setupGhostApi({});
api.comments.count({postId: '123'});
expect(window.fetch).toHaveBeenCalledTimes(1);
expect(window.fetch).toHaveBeenCalledWith(
'http://localhost:3000/members/api/comments/counts/?ids=123',
expect.objectContaining({
method: 'GET',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: undefined
}),
);
});

View File

@ -0,0 +1,22 @@
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 = [], options = {}) => {
return modes.some((mode) => {
const modeFn = modeFns[mode];
return !!(modeFn && modeFn(options));
});
};

View File

@ -0,0 +1,2 @@
// Note: do not use ghost-comments as that makes the #ghost-comments scrolling unreliable because we inject this div after page load!
export const ROOT_DIV_ID = 'ghost-comments-root';

View File

@ -0,0 +1,40 @@
import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text';
import Link from '@tiptap/extension-link';
import Paragraph from '@tiptap/extension-paragraph';
import Document from '@tiptap/extension-document';
import Blockquote from '@tiptap/extension-blockquote';
import HardBreak from '@tiptap/extension-hard-break';
export function getEditorConfig({placeholder, autofocus = false, content = ''}) {
return {
extensions: [
Document,
Text,
Paragraph,
Link.configure({
openOnClick: false
}),
Placeholder.configure({
placeholder,
showOnlyWhenEditable: false
}),
Blockquote.configure({}),
// Enable shift + enter to insert <br> tags
HardBreak.configure({})
],
content,
autofocus,
editorProps: {
attributes: {
class: `gh-comment-content focus:outline-0`
}
},
parseOptions: {
preserveWhitespace: 'full'
}
};
}
/** We need to post process the HTML from tiptap, because tiptap by default */

View File

@ -0,0 +1,192 @@
export const createPopupNotification = ({type, status, autoHide, duration = 2600, closeable, state, message, meta = {}}) => {
let count = 0;
if (state && state.popupNotification) {
count = (state.popupNotification.count || 0) + 1;
}
return {
type,
status,
autoHide,
closeable,
duration,
meta,
message,
count
};
};
export function isSentryEventAllowed({event: sentryEvent}) {
const frames = sentryEvent?.exception?.values?.[0]?.stacktrace?.frames || [];
const fileNames = frames.map(frame => frame.filename).filter(filename => !!filename);
const lastFileName = fileNames[fileNames.length - 1] || '';
return lastFileName.includes('@tryghost/comments');
}
export function formatNumber(number) {
if (number !== 0 && !number) {
return '';
}
// Adds in commas for separators
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
export function formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
// Diff is in seconds
let diff = Math.round((now.getTime() - date.getTime()) / 1000);
if (diff < 5) {
return 'Just now';
}
if (diff < 60) {
return `${diff} seconds ago`;
}
// Diff in minutes
diff = diff / 60;
if (diff < 60) {
if (Math.floor(diff) === 1) {
return `One minute ago`;
}
return `${Math.floor(diff)} minutes ago`;
}
// First check for yesterday
// (we ignore setting 'yesterday' if close to midnight and keep using minutes until 1 hour difference)
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
if (date.getFullYear() === yesterday.getFullYear() && date.getMonth() === yesterday.getMonth() && date.getDate() === yesterday.getDate()) {
return 'Yesterday';
}
// Diff in hours
diff = diff / 60;
if (diff < 24) {
if (Math.floor(diff) === 1) {
return `One hour ago`;
}
return `${Math.floor(diff)} hours ago`;
}
// Diff in days
diff = diff / 24;
if (diff < 7) {
if (Math.floor(diff) === 1) {
// Special case, we should compare based on dates in the future instead
return `One day ago`;
}
return `${Math.floor(diff)} days ago`;
}
// Diff in weeks
diff = diff / 7;
if (Math.floor(diff) === 1) {
// Special case, we should compare based on dates in the future instead
return `One week ago`;
}
return `${Math.floor(diff)} weeks ago`;
}
export function formatExplicitTime(dateString) {
const date = new Date(dateString);
let day = date.toLocaleDateString('en-us', {day: '2-digit'}); // eg. 01
let month = date.toLocaleString('en-us', {month: 'short'}); // eg. Jan
let year = date.getFullYear(); // eg. 2022
let hour = (date.getHours() < 10 ? '0' : '') + date.getHours(); // eg. 02
let minute = (date.getMinutes() < 10 ? '0' : '') + date.getMinutes(); // eg. 09
return `${day} ${month} ${year} ${hour}:${minute}`;
}
export function getInitials(name) {
if (!name) {
return '';
}
const parts = name.split(' ');
if (parts.length === 0) {
return '';
}
if (parts.length === 1) {
return parts[0].substring(0, 1).toLocaleUpperCase();
}
return parts[0].substring(0, 1).toLocaleUpperCase() + parts[parts.length - 1].substring(0, 1).toLocaleUpperCase();
}
// Rudimentary check for screen width
// Note, this should be the same as breakpoint defined in Tailwind config
export function isMobile() {
return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480);
}
export function isCommentPublished(comment) {
return comment.status === 'published';
}
/**
* Returns the y scroll position (top) of the main window of a given element that is in one or multiple stacked iframes
*/
export const getScrollToPosition = (element) => {
let yOffset = 0;
// Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window
// Get the window of the element, not the window (which is the top window)
let currentWindow = element.ownerDocument.defaultView;
// Loop all iframe parents (if we have multiple)
while (currentWindow !== window) {
const currentParentWindow = currentWindow.parent;
for (let idx = 0; idx < currentParentWindow.frames.length; idx++) {
if (currentParentWindow.frames[idx] === currentWindow) {
for (let frameElement of currentParentWindow.document.getElementsByTagName('iframe')) {
if (frameElement.contentWindow === currentWindow) {
const rect = frameElement.getBoundingClientRect();
yOffset += rect.top + currentWindow.pageYOffset;
}
}
currentWindow = currentParentWindow;
break;
}
}
}
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
return y;
};
/**
* Scroll to an element that is in an iframe, only if it is outside the current viewport
*/
export const scrollToElement = (element) => {
// Is the form already in view?
const elementHeight = element.offsetHeight;
// Start y position of the form
const yMin = getScrollToPosition(element);
// Y position of the end of the form
const yMax = yMin + elementHeight;
// Trigger scrolling when yMin and yMax is closer than this to the border of the viewport
const offset = 64;
const viewportHeight = window.innerHeight;
const viewPortYMin = window.scrollY;
const viewPortYMax = viewPortYMin + viewportHeight;
if (yMin - offset < viewPortYMin || yMax + offset > viewPortYMax) {
// Center the form in the viewport
const yCenter = (yMin + yMax) / 2;
window.scrollTo({
top: yCenter - viewportHeight / 2,
left: 0,
behavior: 'smooth'
});
}
};

View File

@ -0,0 +1,19 @@
import * as helpers from './helpers';
describe('formatNumber', function () {
it('adds commas to large numbers', function () {
expect(helpers.formatNumber(1234567)).toEqual('1,234,567');
});
it('handles 0', function () {
expect(helpers.formatNumber(0)).toEqual('0');
});
it('handles undefined', function () {
expect(helpers.formatNumber()).toEqual('');
});
it('handles null', function () {
expect(helpers.formatNumber(null)).toEqual('');
});
});

View File

@ -0,0 +1,59 @@
import {useCallback, useEffect, useRef, useContext, useMemo} from 'react';
import AppContext from '../AppContext';
import {formatRelativeTime} from './helpers';
/**
* Execute a callback when a ref is set and unset.
* Warning: make sure setup and clear are both functions that do not change on every rerender. So use useCallback if required on them.
*/
export function useRefCallback(setup, clear) {
const ref = useRef(null);
const setRef = useCallback((node) => {
if (ref.current && clear) {
// Make sure to cleanup any events/references added to the last instance
clear(ref.current);
}
if (node && setup) {
// Check if a node is actually passed. Otherwise node would be null.
// You can now do what you need to, addEventListeners, measure, etc.
setup(node);
}
// Save a reference to the node
ref.current = node;
}, [setup, clear]);
return [ref, setRef];
}
/**
* Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored)
* @param {Same} fn
* @param {*} inputs
*/
export function useSecondUpdate(fn, inputs) {
const didMountRef = useRef(0);
useEffect(() => {
if (didMountRef.current >= 2) {
return fn();
}
didMountRef.current += 1;
// We shouldn't listen for fn changes, so ignore exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, inputs);
}
export function usePopupOpen(type) {
const {popup} = useContext(AppContext);
return popup?.type === type;
}
/**
* Avoids a rerender of the relative time unless the date changed, and not the current timestamp changed
*/
export function useRelativeTime(dateString) {
return useMemo(() => {
return formatRelativeTime(dateString);
}, [dateString]);
}

View File

@ -0,0 +1,50 @@
const ObjectId = require('bson-objectid').default;
let memberCounter = 0;
export function buildMember(override) {
memberCounter += 1;
return {
avatar_image: 'https://www.gravatar.com/avatar/7a68f69cc9c9e9b45d97ecad6f24184a?s=250&r=g&d=blank',
expertise: 'Head of Testing',
id: ObjectId(),
name: 'Test Member ' + memberCounter,
uuid: ObjectId(),
paid: false,
...override
};
}
export function buildComment(override) {
return {
id: ObjectId(),
html: '<p>Empty</p>',
replies: [],
count: {
replies: 0,
likes: 0
},
liked: false,
created_at: '2022-08-11T09:26:34.000Z',
edited_at: null,
member: buildMember(),
status: 'published',
...override
};
}
export function buildReply(override) {
return {
id: ObjectId(),
html: '<p>Empty</p>',
count: {
likes: 0
},
liked: false,
created_at: '2022-08-11T09:26:34.000Z',
edited_at: null,
member: buildMember(),
status: 'published',
...override
};
}

View File

@ -0,0 +1,170 @@
module.exports = {
darkMode: 'class',
theme: {
screens: {
sm: '481px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1400px'
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.2rem',
1: '0.4rem',
1.5: '0.6rem',
2: '0.8rem',
2.5: '1rem',
3: '1.2rem',
3.5: '1.4rem',
4: '1.6rem',
5: '2rem',
6: '2.4rem',
7: '2.8rem',
8: '3.2rem',
9: '3.6rem',
10: '4rem',
11: '4.4rem',
12: '4.8rem',
14: '5.6rem',
16: '6.4rem',
20: '8rem',
24: '9.6rem',
28: '11.2rem',
32: '12.8rem',
36: '14.4rem',
40: '16rem',
44: '17.6rem',
48: '19.2rem',
52: '20.8rem',
56: '22.4rem',
60: '24rem',
64: '25.6rem',
72: '28.8rem',
80: '32rem',
96: '38.4rem'
},
maxWidth: {
none: 'none',
0: '0rem',
xs: '32rem',
sm: '38.4rem',
md: '44.8rem',
lg: '51.2rem',
xl: '57.6rem',
'2xl': '67.2rem',
'3xl': '76.8rem',
'4xl': '89.6rem',
'5xl': '102.4rem',
'6xl': '115.2rem',
'7xl': '128rem',
'8xl': '140rem',
'9xl': '156rem',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
prose: '65ch'
},
minWidth: {
none: 'none',
0: '0rem',
xs: '32rem',
sm: '38.4rem',
md: '44.8rem',
lg: '51.2rem',
xl: '57.6rem',
'2xl': '67.2rem',
'3xl': '76.8rem',
'4xl': '89.6rem',
'5xl': '102.4rem',
'6xl': '115.2rem',
'7xl': '128rem',
'8xl': '140rem',
'9xl': '156rem',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
prose: '65ch'
},
borderRadius: {
sm: '0.2rem',
DEFAULT: '0.4rem',
md: '0.6rem',
lg: '0.8rem',
xl: '1.2rem',
'2xl': '1.6rem',
'3xl': '2.4rem',
full: '9999px'
},
fontSize: {
xs: '1.2rem',
sm: '1.4rem',
md: '1.5rem',
lg: '1.8rem',
xl: '2rem',
'2xl': '2.4rem',
'3xl': '3rem',
'4xl': '3.6rem',
'5xl': ['4.8rem', '1.15'],
'6xl': ['6rem', '1'],
'7xl': ['7.2rem', '1'],
'8xl': ['9.6rem', '1'],
'9xl': ['12.8rem', '1']
},
letterSpacing: {
tightest: '-.075em',
tighter: '-.05em',
tight: '-.018em',
normal: '0',
wide: '.018em',
wider: '.05em',
widest: '.1em'
},
boxShadow: {
lg: [
'0px 0px 1px rgba(0, 0, 0, 0.12)',
'0px 4px 8px rgba(0, 0, 0, 0.04)',
'0px 8px 48px rgba(0, 0, 0, 0.05)'
],
xl: [
'0px 0px 1px rgba(0, 0, 0, 0.12)',
'0px 13px 20px rgba(0, 0, 0, 0.04)',
'0px 14px 57px rgba(0, 0, 0, 0.06)'
],
form: [
'0px 78px 57px -57px rgba(0, 0, 0, 0.1)',
'0px 15px 20px -8px rgba(0, 0, 0, 0.08)',
'0px 0px 1px 0px rgba(0,0,0,0.32)'
],
formxl: [
'0px 78px 57px -57px rgba(0, 0, 0, 0.125)',
'0px 15px 20px -8px rgba(0, 0, 0, 0.1)',
'0px 0px 1px 0px rgba(0, 0, 0, 0.32)'
],
modal: [
'0 3.8px 2.2px rgba(0, 0, 0, 0.028)',
'0 9.2px 5.3px rgba(0, 0, 0, 0.04)',
'0 17.3px 10px rgba(0, 0, 0, 0.05)',
'0 30.8px 17.9px rgba(0, 0, 0, 0.06)',
'0 57.7px 33.4px rgba(0, 0, 0, 0.072)',
'0 138px 80px rgba(0, 0, 0, 0.1)'
]
},
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
}
}
},
content: [
'./src/**/*.{js,jsx,ts,tsx}'
],
plugins: []
};

View File

@ -0,0 +1,81 @@
import {resolve} from 'path';
import fs from 'fs/promises';
import {defineConfig} from 'vitest/config';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import reactPlugin from '@vitejs/plugin-react';
import svgrPlugin from 'vite-plugin-svgr';
import pkg from './package.json';
export default defineConfig((config) => {
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
return {
clearScreen: false,
define: {
'process.env.NODE_ENV': JSON.stringify(config.mode),
REACT_APP_VERSION: JSON.stringify(process.env.npm_package_version)
},
preview: {
port: 7174
},
server: {
port: 5368
},
plugins: [
cssInjectedByJsPlugin(),
reactPlugin(),
svgrPlugin()
],
esbuild: {
loader: 'jsx',
include: /src\/.*\.jsx?$/,
exclude: []
},
optimizeDeps: {
esbuildOptions: {
plugins: [
{
name: 'load-js-files-as-jsx',
setup(build) {
build.onLoad({filter: /src\/.*\.js$/}, async args => ({
loader: 'jsx',
contents: await fs.readFile(args.path, 'utf8')
}));
}
}
]
}
},
build: {
outDir: resolve(__dirname, 'umd'),
emptyOutDir: true,
minify: true,
sourcemap: true,
cssCodeSplit: false,
lib: {
entry: resolve(__dirname, 'src/index.js'),
formats: ['umd'],
name: pkg.name,
fileName: format => `${outputFileName}.min.js`
},
rollupOptions: {
output: {
manualChunks: false
}
}
/*commonjsOptions: {
include: [/ghost/, /node_modules/],
dynamicRequireRoot: '../../',
dynamicRequireTargets: SUPPORTED_LOCALES.map(locale => `../../ghost/i18n/locales/${locale}/portal.json`)
}*/
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
testTimeout: 10000
}
};
});

5743
apps/comments-ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff