diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index 83c9f30c955..a494511e0a8 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -58,6 +58,14 @@ const COMMAND_ADMIN = { env: {} }; +const COMMAND_BROWSERTESTS = { + name: 'browser-tests', + command: 'nx run ghost:test:browser', + cwd: path.resolve(__dirname, '../../ghost/core'), + prefixColor: 'blue', + env: {} +}; + const COMMAND_TYPESCRIPT = { name: 'ts', command: `while [ 1 ]; do nx watch --projects=${tsPackages} -- nx run \\$NX_PROJECT_NAME:build:ts; done`, @@ -86,6 +94,8 @@ if (DASH_DASH_ARGS.includes('ghost')) { commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT]; } else if (DASH_DASH_ARGS.includes('admin')) { commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX]; +} else if (DASH_DASH_ARGS.includes('browser-tests')) { + commands = [COMMAND_BROWSERTESTS, COMMAND_TYPESCRIPT]; } else { commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, ...COMMANDS_ADMINX]; } @@ -186,7 +196,7 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) { async function handleStripe() { if (DASH_DASH_ARGS.includes('stripe') || DASH_DASH_ARGS.includes('all')) { - if (DASH_DASH_ARGS.includes('offline')) { + if (DASH_DASH_ARGS.includes('offline') || DASH_DASH_ARGS.includes('browser-tests')) { return; } diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml index a8d89941f7d..c9ba7eda1c6 100644 --- a/.github/scripts/docker-compose.yml +++ b/.github/scripts/docker-compose.yml @@ -17,6 +17,7 @@ services: volumes: # Turns out you can drop .sql or .sql.gz files in here, cool! - ./mysql-preload:/docker-entrypoint-initdb.d + - mysql-data:/var/lib/mysql healthcheck: test: "mysql -uroot -proot ghost -e 'select 1'" interval: 1s @@ -27,13 +28,5 @@ services: ports: - "6379:6379" restart: always - jaeger: - image: jaegertracing/all-in-one:1.58 - container_name: ghost-jaeger - ports: - - "4318:4318" - - "16686:16686" - - "9411:9411" - restart: always - environment: - COLLECTOR_ZIPKIN_HOST_PORT: :9411 \ No newline at end of file +volumes: + mysql-data: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09d3b804cd..0b6d2fcfd19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,6 @@ env: ~/.cache/ms-playwright/ CACHED_BUILD_PATHS: | ${{ github.workspace }}/ghost/*/build - NX_CACHE_RESTORE_KEYS: | - nx-Linux-${{ github.ref }}-${{ github.sha }} - nx-Linux-${{ github.ref }} - nx-Linux NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 concurrency: @@ -31,9 +27,12 @@ concurrency: cancel-in-progress: true jobs: - job_get_metadata: - name: Metadata + job_setup: + name: Setup runs-on: ubuntu-latest + timeout-minutes: 15 + env: + IS_MAIN: ${{ github.ref == 'refs/heads/main' }} permissions: pull-requests: read steps: @@ -106,28 +105,7 @@ jobs: - 'apps/sodo-search/**' any-code: - '!**/*.md' - outputs: - changed_admin: ${{ steps.changed.outputs.admin }} - changed_core: ${{ steps.changed.outputs.core }} - changed_admin_x_settings: ${{ steps.changed.outputs.admin-x-settings }} - changed_announcement_bar: ${{ steps.changed.outputs.announcement-bar }} - changed_comments_ui: ${{ steps.changed.outputs.comments-ui }} - changed_portal: ${{ steps.changed.outputs.portal }} - changed_signup_form: ${{ steps.changed.outputs.signup-form }} - changed_sodo_search: ${{ steps.changed.outputs.sodo-search }} - changed_any_code: ${{ steps.changed.outputs.any-code }} - changed_new_package: ${{ steps.added.outputs.new-package }} - base_commit: ${{ env.BASE_COMMIT }} - is_canary_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/arch') }} - is_main: ${{ github.ref == 'refs/heads/main' }} - has_browser_tests_label: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browser-tests') }} - job_install_deps: - name: Install Dependencies - needs: job_get_metadata - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - name: 'Checkout current commit' uses: actions/checkout@v4 with: @@ -154,7 +132,10 @@ jobs: with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} - restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.NX_CACHE_RESTORE_KEYS || 'nx-never-restore'}} + restore-keys: | + nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} + nx-Linux-${{ github.ref }} + nx-Linux - name: Check dependency cache uses: actions/cache@v4 @@ -162,7 +143,7 @@ jobs: with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} key: ${{ env.cachekey }} - restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.DEPENDENCY_CACHE_RESTORE_KEYS || 'dep-never-restore'}} + restore-keys: ${{ env.IS_MAIN == 'false' && env.DEPENDENCY_CACHE_RESTORE_KEYS || 'dep-never-restore'}} - name: Check build cache uses: actions/cache@v4 @@ -185,13 +166,28 @@ jobs: - name: Build packages if: steps.cache_built_packages.outputs.cache-hit != 'true' run: yarn nx run-many -t build:ts + outputs: + changed_admin: ${{ steps.changed.outputs.admin }} + changed_core: ${{ steps.changed.outputs.core }} + changed_admin_x_settings: ${{ steps.changed.outputs.admin-x-settings }} + changed_announcement_bar: ${{ steps.changed.outputs.announcement-bar }} + changed_comments_ui: ${{ steps.changed.outputs.comments-ui }} + changed_portal: ${{ steps.changed.outputs.portal }} + changed_signup_form: ${{ steps.changed.outputs.signup-form }} + changed_sodo_search: ${{ steps.changed.outputs.sodo-search }} + changed_any_code: ${{ steps.changed.outputs.any-code }} + changed_new_package: ${{ steps.added.outputs.new-package }} + base_commit: ${{ env.BASE_COMMIT }} + is_canary_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/arch') }} + is_main: ${{ env.IS_MAIN }} + has_browser_tests_label: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browser-tests') }} dependency_cache_key: ${{ env.cachekey }} job_lint: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_any_code == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_any_code == 'true' name: Lint steps: - uses: actions/checkout@v4 @@ -206,14 +202,14 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - uses: actions/cache@v4 with: path: ghost/**/.eslintcache key: eslint-cache - - run: yarn nx affected -t lint --base=${{ needs.job_get_metadata.outputs.BASE_COMMIT }} + - run: yarn nx affected -t lint --base=${{ needs.job_setup.outputs.BASE_COMMIT }} - uses: tryghost/actions/actions/slack-build@main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -224,13 +220,13 @@ jobs: job_i18n: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] + needs: [job_setup] name: i18n if: | - needs.job_get_metadata.outputs.changed_comments_ui == 'true' - || needs.job_get_metadata.outputs.changed_signup_form == 'true' - || needs.job_get_metadata.outputs.changed_portal == 'true' - || needs.job_get_metadata.outputs.changed_core == 'true' + needs.job_setup.outputs.changed_comments_ui == 'true' + || needs.job_setup.outputs.changed_signup_form == 'true' + || needs.job_setup.outputs.changed_portal == 'true' + || needs.job_setup.outputs.changed_core == 'true' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -240,15 +236,15 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Run i18n tests run: yarn nx run @tryghost/i18n:test job_admin-tests: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_admin == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_admin == 'true' name: Admin tests - Chrome env: MOZ_HEADLESS: 1 @@ -264,7 +260,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - run: yarn nx run ghost-admin:test env: @@ -291,8 +287,8 @@ jobs: timeout-minutes: 60 runs-on: labels: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_any_code == 'true' && (needs.job_get_metadata.outputs.is_main == 'true' || needs.job_get_metadata.outputs.has_browser_tests_label == 'true') + needs: [job_setup] + if: needs.job_setup.outputs.changed_any_code == 'true' && (needs.job_setup.outputs.is_main == 'true' || needs.job_setup.outputs.has_browser_tests_label == 'true') concurrency: group: ${{ github.workflow }} steps: @@ -317,7 +313,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Run migrations working-directory: ghost/core @@ -343,7 +339,6 @@ jobs: run: yarn nx run ghost-admin:build:dev - name: Run Playwright tests locally - working-directory: ghost/core run: yarn test:browser env: CI: true @@ -367,8 +362,8 @@ jobs: job_perf-tests: runs-on: labels: ubuntu-latest-4-cores - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_core == 'true' && needs.job_get_metadata.outputs.is_main == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_main == 'true' name: Performance tests steps: - uses: actions/checkout@v4 @@ -383,7 +378,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Install hyperfine run: | @@ -414,8 +409,8 @@ jobs: job_unit-tests: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_any_code == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_any_code == 'true' strategy: matrix: node: [ '18.12.1', '20.11.1' ] @@ -433,9 +428,9 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - - run: yarn nx affected -t test:unit --base=${{ needs.job_get_metadata.outputs.BASE_COMMIT }} + - run: yarn nx affected -t test:unit --base=${{ needs.job_setup.outputs.BASE_COMMIT }} - uses: actions/upload-artifact@v4 if: startsWith(matrix.node, '18') @@ -452,8 +447,8 @@ jobs: job_database-tests: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_core == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_core == 'true' strategy: matrix: node: [ '18.12.1', '20.11.1' ] @@ -492,7 +487,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Record start time run: date +%s > ${{ runner.temp }}/startTime # Get start time for test suite @@ -573,8 +568,8 @@ jobs: job_regression-tests: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_core == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_core == 'true' strategy: matrix: include: @@ -615,7 +610,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Set env vars (SQLite) if: contains(matrix.env.DB, 'sqlite') @@ -638,8 +633,8 @@ jobs: job_admin_x_settings: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_admin_x_settings == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_admin_x_settings == 'true' name: Admin-X Settings tests env: CI: true @@ -654,7 +649,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Get Playwright version id: playwright-version @@ -691,8 +686,8 @@ jobs: job_comments_ui: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_comments_ui == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_comments_ui == 'true' name: Comments-UI tests env: CI: true @@ -707,7 +702,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Get Playwright version id: playwright-version @@ -744,8 +739,8 @@ jobs: job_signup_form: runs-on: ubuntu-latest - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_signup_form == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_signup_form == 'true' name: Signup-form tests env: CI: true @@ -760,7 +755,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - name: Get Playwright version id: playwright-version @@ -797,8 +792,8 @@ jobs: job_ghost-cli: name: Ghost-CLI tests - needs: [job_get_metadata, job_install_deps] - if: needs.job_get_metadata.outputs.changed_core == 'true' + needs: [job_setup] + if: needs.job_setup.outputs.changed_core == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -817,7 +812,7 @@ jobs: - name: Restore caches uses: ./.github/actions/restore-cache env: - DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} - run: npm --no-git-tag-version version minor # We need to artificially bump the minor version to get migrations to run working-directory: ghost/core @@ -924,8 +919,7 @@ jobs: name: All required tests passed or skipped needs: [ - job_get_metadata, - job_install_deps, + job_setup, job_lint, job_i18n, job_ghost-cli, @@ -951,12 +945,12 @@ jobs: canary: needs: [ - job_get_metadata, + job_setup, job_required_tests ] name: Canary runs-on: ubuntu-latest - if: always() && needs.job_get_metadata.outputs.is_canary_branch == 'true' && needs.job_required_tests.result == 'success' && needs.job_get_metadata.result == 'success' + if: always() && needs.job_setup.outputs.is_canary_branch == 'true' && needs.job_setup.result == 'success' && needs.job_setup.result == 'success' steps: - name: Output needs (for debugging) run: echo "${{ toJson(needs) }}" diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index d73be09910f..ea67be64a2d 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -25,20 +25,21 @@ "lint": "yarn run lint:code && yarn run lint:test", "lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src", "lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test", - "test:unit": "vitest run", + "test:unit": "tsc --noEmit && vitest run", "test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", "test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed", "test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance", "preview": "vite preview" }, "devDependencies": { - "@playwright/test": "1.38.1", + "@playwright/test": "1.46.1", "@testing-library/react": "14.3.1", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", "@types/jest": "29.5.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", + "@radix-ui/react-form": "0.0.3", "jest": "29.7.0", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 7d505974470..17abd51cb29 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -108,7 +108,7 @@ describe('ActivityPubAPI', function () { response: JSONResponse({ type: 'Collection', - items: [{ + orderedItems: [{ type: 'Create', object: { type: 'Note' @@ -138,7 +138,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns an array when the items key is a single object', async function () { + test('Returns an array when the orderedItems key is a single object', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -151,7 +151,7 @@ describe('ActivityPubAPI', function () { response: JSONResponse({ type: 'Collection', - items: { + orderedItems: { type: 'Create', object: { type: 'Note' @@ -255,7 +255,7 @@ describe('ActivityPubAPI', function () { response: JSONResponse({ type: 'Collection', - items: [{ + orderedItems: [{ type: 'Person' }] }) @@ -443,4 +443,77 @@ describe('ActivityPubAPI', function () { await api.follow('@user@domain.com'); }); }); + + describe('getAllActivities', function () { + test('It fetches all activities navigating pagination', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=false': { + response: JSONResponse({ + items: [{type: 'Create', object: {type: 'Note'}}], + nextCursor: 'next-cursor' + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=false&cursor=next-cursor': { + response: JSONResponse({ + items: [{type: 'Announce', object: {type: 'Article'}}], + nextCursor: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getAllActivities(); + const expected: Activity[] = [ + {type: 'Create', object: {type: 'Note'}}, + {type: 'Announce', object: {type: 'Article'}} + ]; + + expect(actual).toEqual(expected); + }); + + test('It fetches a user\'s own activities', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=true': { + response: JSONResponse({ + items: [{type: 'Create', object: {type: 'Note'}}], + nextCursor: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getAllActivities(true); + const expected: Activity[] = [ + {type: 'Create', object: {type: 'Note'}} + ]; + + expect(actual).toEqual(expected); + }); + }); }); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 0549e5994f5..e65a1ec8836 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -22,15 +22,20 @@ export class ActivityPubAPI { } } - private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET'): Promise { + private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET', body?: object): Promise { const token = await this.getToken(); - const response = await this.fetch(url, { + const options: RequestInit = { method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/activity+json' } - }); + }; + if (body) { + options.body = JSON.stringify(body); + (options.headers! as Record)['Content-Type'] = 'application/json'; + } + const response = await this.fetch(url, options); const json = await response.json(); return json; } @@ -44,6 +49,9 @@ export class ActivityPubAPI { if (json === null) { return []; } + if ('orderedItems' in json) { + return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } if ('items' in json) { return Array.isArray(json.items) ? json.items : [json.items]; } @@ -59,6 +67,9 @@ export class ActivityPubAPI { if (json === null) { return []; } + if ('orderedItems' in json) { + return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } if ('items' in json) { return Array.isArray(json.items) ? json.items : [json.items]; } @@ -86,7 +97,7 @@ export class ActivityPubAPI { return []; } if ('orderedItems' in json) { - return json.orderedItems as Activity[]; + return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; } return []; } @@ -106,4 +117,99 @@ export class ActivityPubAPI { const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); await this.fetchJSON(url, 'POST'); } + + async getActor(url: string): Promise { + const json = await this.fetchJSON(new URL(url)); + return json as Actor; + } + + get likedApiUrl() { + return new URL(`.ghost/activitypub/liked/${this.handle}`, this.apiUrl); + } + + async getLiked() { + const json = await this.fetchJSON(this.likedApiUrl); + if (json === null) { + return []; + } + if ('orderedItems' in json) { + return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + return []; + } + + async like(id: string): Promise { + const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl); + await this.fetchJSON(url, 'POST'); + } + + async unlike(id: string): Promise { + const url = new URL(`.ghost/activitypub/actions/unlike/${encodeURIComponent(id)}`, this.apiUrl); + await this.fetchJSON(url, 'POST'); + } + + get activitiesApiUrl() { + return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl); + } + + async getAllActivities(includeOwn: boolean = false): Promise { + const LIMIT = 50; + + const fetchActivities = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + // If the response is null, return early + if (json === null) { + return []; + } + + // If the response doesn't have an items array, return early + if (!('items' in json)) { + return []; + } + + // If the response has an items property, but it's not an array + // use an empty array + const items = Array.isArray(json.items) ? json.items : []; + + // If the response has a nextCursor property, fetch the next page + // recursively and concatenate the results + if ('nextCursor' in json && typeof json.nextCursor === 'string') { + const nextUrl = new URL(url); + + nextUrl.searchParams.set('cursor', json.nextCursor); + nextUrl.searchParams.set('limit', LIMIT.toString()); + nextUrl.searchParams.set('includeOwn', includeOwn.toString()); + + const nextItems = await fetchActivities(nextUrl); + + return items.concat(nextItems); + } + + return items; + }; + + // Make a copy of the activities API URL and set the limit + const url = new URL(this.activitiesApiUrl); + url.searchParams.set('limit', LIMIT.toString()); + url.searchParams.set('includeOwn', includeOwn.toString()); + + // Fetch the activities + return fetchActivities(url); + } + + async reply(id: string, content: string) { + const url = new URL(`.ghost/activitypub/actions/reply/${encodeURIComponent(id)}`, this.apiUrl); + const response = await this.fetchJSON(url, 'POST', {content}); + return response; + } + + get userApiUrl() { + return new URL(`.ghost/activitypub/users/${this.handle}`, this.apiUrl); + } + + async getUser() { + const json = await this.fetchJSON(this.userApiUrl); + return json; + } } diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index 9f784beed20..f7e7966e65b 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,46 +1,36 @@ +import NiceModal from '@ebay/nice-modal-react'; +import React from 'react'; +import {Button, NoValueLabel} from '@tryghost/admin-x-design-system'; +import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; + import APAvatar, {AvatarBadge} from './global/APAvatar'; -import ActivityItem from './activities/ActivityItem'; +import ActivityItem, {type Activity} from './activities/ActivityItem'; +import ArticleModal from './feed/ArticleModal'; import MainNavigation from './navigation/MainNavigation'; -import React from 'react'; -import {Button} from '@tryghost/admin-x-design-system'; -import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent'; + +import getUsername from '../utils/get-username'; +import {useAllActivitiesForUser, useSiteUrl} from '../hooks/useActivityPubQueries'; +import {useFollowersForUser} from '../MainContent'; interface ActivitiesProps {} // eslint-disable-next-line no-shadow enum ACTVITY_TYPE { + CREATE = 'Create', LIKE = 'Like', FOLLOW = 'Follow' } -type Actor = { - id: string - name: string - preferredUsername: string - url: string -} - -type ActivityObject = { - name: string - url: string -} - -type Activity = { - id: string - type: ACTVITY_TYPE - object?: ActivityObject - actor: Actor -} - -const getActorUsername = (actor: Actor): string => { - const url = new URL(actor.url); - const domain = url.hostname; +const getActivityDescription = (activity: Activity, activityObjectsMap: Map): string => { + switch (activity.type) { + case ACTVITY_TYPE.CREATE: + const object = activityObjectsMap.get(activity.object?.inReplyTo || ''); - return `@${actor.preferredUsername}@${domain}`; -}; + if (object?.name) { + return `Commented on your article "${object.name}"`; + } -const getActivityDescription = (activity: Activity): string => { - switch (activity.type) { + return ''; case ACTVITY_TYPE.FOLLOW: return 'Followed you'; case ACTVITY_TYPE.LIKE: @@ -52,9 +42,23 @@ const getActivityDescription = (activity: Activity): string => { return ''; }; +const getExtendedDescription = (activity: Activity): JSX.Element | null => { + // If the activity is a reply + if (Boolean(activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo)) { + return ( +
+ ); + } + + return null; +}; + const getActivityUrl = (activity: Activity): string | null => { if (activity.object) { - return activity.object.url; + return activity.object.url || null; } return null; @@ -70,50 +74,125 @@ const getActorUrl = (activity: Activity): string | null => { const getActivityBadge = (activity: Activity): AvatarBadge => { switch (activity.type) { + case ACTVITY_TYPE.CREATE: + return 'comment-fill'; case ACTVITY_TYPE.FOLLOW: return 'user-fill'; case ACTVITY_TYPE.LIKE: if (activity.object) { return 'heart-fill'; } - } -}; - -const isFollower = (id: string, followerIds: string[]): boolean => { - return followerIds.includes(id); + } }; const Activities: React.FC = ({}) => { const user = 'index'; - const {data: activityData} = useBrowseInboxForUser(user); - const activities = (activityData || []) - .filter((activity) => { - return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); - }) - .reverse(); // Endpoint currently returns items oldest-newest - const {data: followerData} = useFollowersForUser(user); - const followers = followerData || []; + + let {data: activities = []} = useAllActivitiesForUser({handle: 'index', includeOwn: true}); + const siteUrl = useSiteUrl(); + + // Create a map of activity objects from activities in the inbox and outbox. + // This allows us to quickly look up an object associated with an activity + // We could just make a http request to get the object, but this is more + // efficient seeming though we already have the data in the inbox and outbox + const activityObjectsMap = new Map(); + + activities.forEach((activity) => { + if (activity.object) { + activityObjectsMap.set(activity.object.id, activity.object); + } + }); + + // Filter the activities to show + activities = activities.filter((activity) => { + if (activity.type === ACTVITY_TYPE.CREATE) { + // Only show "Create" activities that are replies to a post created + // by the user + + const replyToObject = activityObjectsMap.get(activity.object?.inReplyTo || ''); + + // If the reply object is not found, or it doesn't have a URL or + // name, do not show the activity + if (!replyToObject || !replyToObject.url || !replyToObject.name) { + return false; + } + + // Verify that the reply is to a post created by the user by + // checking that the hostname associated with the reply object + // is the same as the hostname of the site. This is not a bullet + // proof check, but it's a good enough for now + const hostname = new URL(siteUrl).hostname; + const replyToObjectHostname = new URL(replyToObject.url).hostname; + + return hostname === replyToObjectHostname; + } + + return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); + }); + + // Create a map of activity comments, grouping them by the parent activity + // This allows us to quickly look up all comments for a given activity + const commentsMap = new Map(); + + for (const activity of activities) { + if (activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo) { + const comments = commentsMap.get(activity.object.inReplyTo) ?? []; + + comments.push(activity); + + commentsMap.set(activity.object.inReplyTo, comments.reverse()); + } + } + + const getCommentsForObject = (id: string) => { + return commentsMap.get(id) ?? []; + }; + + // Retrieve followers for the user + const {data: followers = []} = useFollowersForUser(user); + + const isFollower = (id: string): boolean => { + return followers.includes(id); + }; return ( <>
{activities.length === 0 && ( -
This is an empty state when there are no activities
+
+ + When other Fediverse users interact with you, you'll see it here. + +
)} {activities.length > 0 && (
{activities?.map(activity => ( - + { + NiceModal.show(ArticleModal, { + object: activity.object, + actor: activity.actor, + comments: getCommentsForObject(activity.object.id), + allComments: commentsMap + }); + } : undefined + } + > -
+
{activity.actor.name} - {getActorUsername(activity.actor)} + {getUsername(activity.actor)}
-
{getActivityDescription(activity)}
+
{getActivityDescription(activity, activityObjectsMap)}
+ {getExtendedDescription(activity)}
- {isFollower(activity.actor.id, followers) === false && ( + {isFollower(activity.actor.id) === false && (
+ ), counter: followingCount }, { id: 'followers', title: 'Followers', - contents: (
- Nobody’s following you yet. Their loss! -
), + contents: ( +
+ {followers.length === 0 ? ( + + Nobody's following you yet. Their loss! + + ) : ( + + {followers.map((item) => { + return ( + + +
+
+ {item.name || item.preferredUsername || 'Unknown'} +
{getUsername(item)}
+
+
+
+ ); + })} +
+ )} +
+ ), counter: followersCount } ].filter(Boolean) as Tab[]; @@ -96,27 +142,16 @@ const Profile: React.FC = ({}) => {
- John Doe - @index@site.com -

This is a summary/bio/etc which could be kinda long in certain cases but not always, so...

- www.coolsite.com + Building ActivityPub + @index@activitypub.ghost.org +

Ghost is federating over ActivityPub to become part of the world's largest publishing network

+ activitypub.ghost.org containerClassName='mt-6' selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
- - {/*
-
updateRoute('/profile/following')}> - {followingCount} - Following -
-
updateRoute('/profile/followers')}> - {followersCount} - Followers -
-
*/}
); }; -export default Profile; \ No newline at end of file +export default Profile; diff --git a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx index 6db9b4186c1..e48e04323a1 100644 --- a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx +++ b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx @@ -1,23 +1,31 @@ import React, {ReactNode} from 'react'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; + export type Activity = { type: string, - object: { - type: string + actor: ActorProperties, + object: ObjectProperties & { + inReplyTo: string | null // TODO: Move this to the ObjectProperties type } } interface ActivityItemProps { children?: ReactNode; url?: string | null; + onClick?: () => void; } -const ActivityItem: React.FC = ({children, url = null}) => { +const ActivityItem: React.FC = ({children, url = null, onClick}) => { const childrenArray = React.Children.toArray(children); const Item = ( -
-
+
{ + if (!url && onClick) { + onClick(); + } + }}> +
{childrenArray[0]} {childrenArray[1]} {childrenArray[2]} @@ -27,7 +35,12 @@ const ActivityItem: React.FC = ({children, url = null}) => { if (url) { return ( - + { + if (onClick) { + e.preventDefault(); + onClick(); + } + }}> {Item} ); diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index 1f1fd1386c9..9358212cc3b 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -1,15 +1,22 @@ -import FeedItem from './FeedItem'; -import MainHeader from '../navigation/MainHeader'; +import React, {useEffect, useRef, useState} from 'react'; + import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React, {useEffect, useRef} from 'react'; -import articleBodyStyles from '../articleBodyStyles'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Modal} from '@tryghost/admin-x-design-system'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import FeedItem from './FeedItem'; + +import APReplyBox from '../global/APReplyBox'; +import articleBodyStyles from '../articleBodyStyles'; +import {type Activity} from '../activities/ActivityItem'; + interface ArticleModalProps { object: ObjectProperties; actor: ActorProperties; + comments: Activity[]; + allComments: Map; + focusReply: boolean; } const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { @@ -63,8 +70,67 @@ ${image && ); }; -const ArticleModal: React.FC = ({object, actor}) => { +const FeedItemDivider: React.FC = () => ( +
+); + +const ArticleModal: React.FC = ({object, actor, comments, allComments, focusReply}) => { + const MODAL_SIZE_SM = 640; + const MODAL_SIZE_LG = 2800; + const [commentsState, setCommentsState] = useState(comments); + const [isFocused, setFocused] = useState(focusReply ? 1 : 0); + function setReplyBoxFocused(focused: boolean) { + if (focused) { + setFocused(prev => prev + 1); + } else { + setFocused(0); + } + } + + const [modalSize, setModalSize] = useState(MODAL_SIZE_SM); const modal = useModal(); + + // Navigation stack to navigate between comments - This could probably use a + // more robust solution, but for now, thanks to the fact modal.show() updates + // the existing modal instead of creating a new one (i think 😅) we can use + // a stack to navigate between comments pretty easily + // + // @TODO: Look into a more robust solution for navigation + const [navigationStack, setNavigationStack] = useState<[ObjectProperties, ActorProperties, Activity[]][]>([]); + const [canNavigateBack, setCanNavigateBack] = useState(false); + const navigateBack = () => { + const [previousObject, previousActor, previousComments] = navigationStack.pop() ?? []; + + if (navigationStack.length === 0) { + setCanNavigateBack(false); + } + + modal.show({ + object: previousObject, + actor: previousActor, + comments: previousComments, + allComments: allComments + }); + }; + const navigateForward = (nextObject: ObjectProperties, nextActor: ActorProperties, nextComments: Activity[]) => { + setCanNavigateBack(true); + setNavigationStack([...navigationStack, [object, actor, commentsState]]); + + modal.show({ + object: nextObject, + actor: nextActor, + comments: nextComments, + allComments: allComments + }); + }; + const toggleModalSize = () => { + setModalSize(modalSize === MODAL_SIZE_SM ? MODAL_SIZE_LG : MODAL_SIZE_SM); + }; + + function handleNewReply(activity: Activity) { + setCommentsState(prev => [activity].concat(prev)); + } + return ( = ({object, actor}) => { height={'full'} padding={false} size='bleed' - width={640} + width={modalSize} > - -
-
-
- {object.type} +
+
+ {canNavigateBack && ( +
+
+ )} +
+ {/* {object.type} */} +
+
+
-
-
- +
{object.type === 'Note' && ( -
- - {/* {object.content &&
} */} - {/* {renderAttachment(object)} */} - - -
- - - -
)} - {object.type === 'Article' && } +
+ { + setReplyBoxFocused(true); + }} + /> + + + + {commentsState.map((comment, index) => { + const showDivider = index !== commentsState.length - 1; + const nestedComments = allComments.get(comment.object.id) ?? []; + const hasNestedComments = nestedComments.length > 0; + + return ( + <> + { + navigateForward(comment.object, comment.actor, nestedComments); + }} + onCommentClick={() => {}} + /> + {hasNestedComments && } + {nestedComments.map((nestedComment, nestedCommentIndex) => { + const nestedNestedComments = allComments.get(nestedComment.object.id) ?? []; + + return ( + { + navigateForward(nestedComment.object, nestedComment.actor, nestedNestedComments); + }} + onCommentClick={() => {}} + /> + ); + })} + {showDivider && } + + ); + })} +
+ )} + {object.type === 'Article' && ( + + )}
); }; -export default NiceModal.create(ArticleModal); \ No newline at end of file +export default NiceModal.create(ArticleModal); diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx index 87d41de12ce..6d31f31176a 100644 --- a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -1,11 +1,15 @@ -import APAvatar from '../global/APAvatar'; import React, {useState} from 'react'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-x-design-system'; + +import APAvatar from '../global/APAvatar'; + import getRelativeTimestamp from '../../utils/get-relative-timestamp'; import getUsername from '../../utils/get-username'; -import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import {Button, Heading, Icon} from '@tryghost/admin-x-design-system'; +import {type Activity} from '../activities/ActivityItem'; +import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries'; -export function renderFeedAttachment(object: ObjectProperties, layout: string) { +function getAttachment(object: ObjectProperties) { let attachment; if (object.image) { attachment = object.image; @@ -19,6 +23,25 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { return null; } + if (Array.isArray(attachment)) { + if (attachment.length === 0) { + return null; + } + if (attachment.length === 1) { + return attachment[0]; + } + } + + return attachment; +} + +export function renderFeedAttachment(object: ObjectProperties, layout: string) { + const attachment = getAttachment(object); + + if (!attachment) { + return null; + } + if (Array.isArray(attachment)) { const attachmentCount = attachment.length; @@ -32,9 +55,9 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { } return ( -
+
{attachment.map((item, index) => ( - {`attachment-${index}`} + {`attachment-${index}`} ))}
); @@ -44,10 +67,10 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { case 'image/jpeg': case 'image/png': case 'image/gif': - return attachment; + return attachment; case 'video/mp4': case 'video/webm': - return
+ return
; @@ -62,14 +85,7 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { } function renderInboxAttachment(object: ObjectProperties) { - let attachment; - if (object.image) { - attachment = object.image; - } - - if (object.type === 'Note' && !attachment) { - attachment = object.attachment; - } + const attachment = getAttachment(object); if (!attachment) { return null; @@ -77,12 +93,7 @@ function renderInboxAttachment(object: ObjectProperties) { if (Array.isArray(attachment)) { const attachmentCount = attachment.length; - // let gridClass = ''; - // if (attachmentCount === 2) { - // gridClass = 'grid-cols-2 auto-rows-[150px]'; // Two images, side by side - // } else if (attachmentCount === 3 || attachmentCount === 4) { - // gridClass = 'grid-cols-2 auto-rows-[150px]'; // Three or four images, two per row - // } + return (
@@ -126,28 +137,107 @@ function renderInboxAttachment(object: ObjectProperties) { } } +const FeedItemStats: React.FC<{ + object: ObjectProperties; + likeCount: number; + commentCount: number; + onLikeClick: () => void; + onCommentClick: () => void; +}> = ({object, likeCount, commentCount, onLikeClick, onCommentClick}) => { + const [isClicked, setIsClicked] = useState(false); + const [isLiked, setIsLiked] = useState(object.liked); + const likeMutation = useLikeMutationForUser('index'); + const unlikeMutation = useUnlikeMutationForUser('index'); + + const handleLikeClick = async () => { + setIsClicked(true); + if (!isLiked) { + likeMutation.mutate(object.id); + } else { + unlikeMutation.mutate(object.id); + } + + setIsLiked(!isLiked); + + onLikeClick(); + setTimeout(() => setIsClicked(false), 300); + }; + + return (
+
+
+
+
+
); +}; + interface FeedItemProps { actor: ActorProperties; object: ObjectProperties; layout: string; type: string; + comments?: Activity[]; last?: boolean; + onClick?: () => void; + onCommentClick: () => void; } -const FeedItem: React.FC = ({actor, object, layout, type, last}) => { +const noop = () => {}; + +const FeedItem: React.FC = ({actor, object, layout, type, comments = [], last, onClick = noop, onCommentClick}) => { const timestamp = new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'}); const date = new Date(object?.published ?? new Date()); + const [isCopied, setIsCopied] = useState(false); - const [isClicked, setIsClicked] = useState(false); - const [isLiked, setIsLiked] = useState(false); + const onLikeClick = () => { + // Do API req or smth + // Don't need to know about setting timeouts or anything like that + }; - const handleLikeClick = (event: React.MouseEvent | undefined) => { - event?.stopPropagation(); - setIsClicked(true); - setIsLiked(!isLiked); - setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms + const handleDelete = () => { + // Handle delete action + }; + + const handleCopyLink = async () => { + if (object?.url) { // Check if url is defined + await navigator.clipboard.writeText(object.url); + setIsCopied(true); + showToast({ + title: 'Link copied', + type: 'success' + }); + setTimeout(() => setIsCopied(false), 2000); + } }; let author = actor; @@ -155,37 +245,72 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; } + const menuItems: MenuItem[] = []; + + menuItems.push({ + id: 'copy-link', + label: 'Copy link to post', + onClick: handleCopyLink + }); + + // TODO: If this is your own Note/Article, you should be able to delete it + menuItems.push({ + id: 'delete', + label: 'Delete', + destructive: true, + onClick: handleDelete + }); + + const UserMenuTrigger = ( +
@@ -201,7 +326,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) <> {object && (
-
+
{(type === 'Announce' && object.type === 'Note') &&
{actor.name} reposted @@ -223,15 +348,17 @@ const FeedItem: React.FC = ({actor, object, layout, type, last})
{object.name && {object.name}} -
+
{renderFeedAttachment(object, layout)} - {/*
-
*/} -
-
@@ -239,9 +366,9 @@ const FeedItem: React.FC = ({actor, object, layout, type, last})
-
+
- + )} ); @@ -249,12 +376,12 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) return ( <> {object && ( -
+
{(type === 'Announce' && object.type === 'Note') &&
{actor.name} reposted
} -
+
@@ -273,16 +400,22 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) {object.name && {object.name}}
{renderFeedAttachment(object, layout)} -
-
{/*
*/}
- {!last &&
} + {!last &&
}
)} @@ -291,7 +424,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) return ( <> {object && ( -
+
@@ -307,6 +440,16 @@ const FeedItem: React.FC = ({actor, object, layout, type, last})
{renderInboxAttachment(object)}
+
+ + +
@@ -318,4 +461,4 @@ const FeedItem: React.FC = ({actor, object, layout, type, last}) return (<>); }; -export default FeedItem; \ No newline at end of file +export default FeedItem; diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 8b3a23bc9e8..5709bbc96cc 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -3,7 +3,7 @@ import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Icon} from '@tryghost/admin-x-design-system'; type AvatarSize = 'xs' | 'sm' | 'lg'; -export type AvatarBadge = 'user-fill' | 'heart-fill' | undefined; +export type AvatarBadge = 'user-fill' | 'heart-fill' | 'comment-fill' | undefined; interface APAvatarProps { author?: ActorProperties; @@ -14,7 +14,7 @@ interface APAvatarProps { const APAvatar: React.FC = ({author, size, badge}) => { let iconSize = 18; let containerClass = ''; - let imageClass = 'z-10 rounded w-10 h-10'; + let imageClass = 'z-10 rounded w-10 h-10 object-cover'; const badgeClass = `w-6 h-6 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `; let badgeColor = ''; @@ -25,13 +25,16 @@ const APAvatar: React.FC = ({author, size, badge}) => { case 'heart-fill': badgeColor = ' bg-red-500'; break; + case 'comment-fill': + badgeColor = ' bg-purple-500'; + break; } switch (size) { case 'xs': iconSize = 12; containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[3px] w-6 h-6'; - imageClass = 'z-10 rounded w-6 h-6'; + imageClass = 'z-10 rounded w-6 h-6 object-cover'; break; case 'sm': containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10'; @@ -47,7 +50,7 @@ const APAvatar: React.FC = ({author, size, badge}) => { return ( <> {author && author.icon?.url ? ( - )} -
+ ) : (
{ + title?: string; + value?: string; + rows?: number; + error?: boolean; + placeholder?: string; + hint?: React.ReactNode; + className?: string; + onChange?: (event: React.ChangeEvent) => void; + onNewReply?: (activity: Activity) => void; + object: ObjectProperties; + focused: number; +} + +const APReplyBox: React.FC = ({ + title, + value, + rows = 1, + maxLength, + error, + hint, + className, + object, + focused, + onNewReply, + // onChange, + // onFocus, + // onBlur, + ...props +}) => { + const id = useId(); + const [textValue, setTextValue] = useState(value); // Manage the textarea value with state + const replyMutation = useReplyMutationForUser('index'); + + const {data: user} = useUserDataForUser('index'); + + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current && focused) { + textareaRef.current.focus(); + } + }, [focused]); + + async function handleClick() { + if (!textValue) { + return; + } + await replyMutation.mutate({id: object.id, content: textValue}, { + onSuccess(activity: Activity) { + setTextValue(''); + showToast({ + message: 'Reply sent', + type: 'success' + }); + if (onNewReply) { + onNewReply(activity); + } + } + }); + } + + function handleChange(event: React.ChangeEvent) { + setTextValue(event.target.value); // Update the state on every change + } + + const [isFocused, setFocused] = useState(false); + + function handleBlur() { + setFocused(false); + } + + function handleFocus() { + setFocused(true); + } + + const styles = clsx( + `ap-textarea order-2 w-full resize-none rounded-lg border py-2 pr-3 text-[1.5rem] transition-all dark:text-white ${isFocused && 'pb-12'}`, + error ? 'border-red' : 'border-transparent placeholder:text-grey-500 dark:placeholder:text-grey-800', + title && 'mt-1.5', + className + ); + + // We disable the button if either the textbox isn't focused, or the reply is currently being sent. + const buttonDisabled = !isFocused || replyMutation.isLoading; + + let placeholder = 'Reply...'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attributedTo = (object.attributedTo || {}) as any; + if (typeof attributedTo.preferredUsername === 'string' && typeof attributedTo.id === 'string') { + placeholder = `Reply to ${getUsername(attributedTo)}...`; + } + + return ( +
+ +
+ +
+ + + + + + {title} + {hint} +
+
+
+
+
+
+ ); +}; + +export default APReplyBox; diff --git a/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx b/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx index db0dd424f34..469029b7785 100644 --- a/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx +++ b/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx @@ -1,10 +1,11 @@ import NiceModal from '@ebay/nice-modal-react'; +import React from 'react'; import getUsername from '../../utils/get-username'; import {ActivityPubAPI} from '../../api/activitypub'; import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; -import {useMutation, useQuery} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; function useFollowersForUser(handle: string) { const site = useBrowseSite(); @@ -18,39 +19,21 @@ function useFollowersForUser(handle: string) { return useQuery({ queryKey: [`followers:${handle}`], async queryFn() { - return api.getFollowers(); - } - }); -} - -function useFollow(handle: string) { - const site = useBrowseSite(); - const siteData = site.data?.site; - const siteUrl = siteData?.url ?? window.location.origin; - const api = new ActivityPubAPI( - new URL(siteUrl), - new URL('/ghost/api/admin/identities/', window.location.origin), - handle - ); - return useMutation({ - async mutationFn(username: string) { - return api.follow(username); + const followerUrls = await api.getFollowers(); + const followerActors = await Promise.all(followerUrls.map(url => api.getActor(url))); + return followerActors; } }); } const ViewFollowersModal: React.FC = ({}) => { const {updateRoute} = useRouting(); - // const modal = NiceModal.useModal(); - const mutation = useFollow('index'); - const {data: items = []} = useFollowersForUser('index'); + const {data: followers = [], isLoading} = useFollowersForUser('index'); - const followers = Array.isArray(items) ? items : [items]; return ( { - mutation.reset(); updateRoute('profile'); }} cancelLabel='' @@ -61,11 +44,22 @@ const ViewFollowersModal: React.FC = ({}) => { topRightContent='close' >
- - {followers.map(item => ( - mutation.mutate(getUsername(item))} />} avatar={} detail={getUsername(item)} id='list-item' title={item.name}> - ))} - + {isLoading ? ( +

Loading followers...

+ ) : ( + + {followers.map(item => ( + } + avatar={} + detail={getUsername(item)} + id='list-item' + title={item.name || getUsername(item)} + /> + ))} + + )}
); diff --git a/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx b/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx index 6b58dc94bd2..c78313176fc 100644 --- a/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx +++ b/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx @@ -26,9 +26,8 @@ function useFollowingForUser(handle: string) { const ViewFollowingModal: React.FC = ({}) => { const {updateRoute} = useRouting(); - const {data: items = []} = useFollowingForUser('index'); + const {data: following = []} = useFollowingForUser('index'); - const following = Array.isArray(items) ? items : [items]; return ( { @@ -44,25 +43,16 @@ const ViewFollowingModal: React.FC = ({}) => {
{following.map(item => ( - } avatar={} detail={getUsername(item)} id='list-item' title={item.name}> + } + avatar={} + detail={getUsername(item)} + id='list-item' + title={item.name} + /> ))} - {/* - - -
-
-
- - Platformer Platformer Platformer Platformer Platformer - @index@platformerplatformerplatformerplatformer.news -
-
-
-
-
Unfollow
-
-
*/}
); diff --git a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts new file mode 100644 index 00000000000..0b6a9d6d09c --- /dev/null +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -0,0 +1,199 @@ +import {Activity} from '../components/activities/ActivityItem'; +import {ActivityPubAPI} from '../api/activitypub'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; + +export function useSiteUrl() { + const site = useBrowseSite(); + return site.data?.site?.url ?? window.location.origin; +}; + +function createActivityPubAPI(handle: string, siteUrl: string) { + return new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); +} + +export function useLikedForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`liked:${handle}`], + async queryFn() { + return api.getLiked(); + } + }); +} + +export function useReplyMutationForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useMutation({ + async mutationFn({id, content}: {id: string, content: string}) { + return await api.reply(id, content) as Activity; + } + }); +} + +export function useLikeMutationForUser(handle: string) { + const queryClient = useQueryClient(); + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useMutation({ + mutationFn(id: string) { + return api.like(id); + }, + onMutate: (id) => { + const previousInbox = queryClient.getQueryData([`inbox:${handle}`]); + if (previousInbox) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return old?.map((item: any) => { + if (item.object.id === id) { + return { + ...item, + object: { + ...item.object, + liked: true + } + }; + } + return item; + }); + }); + } + + // This sets the context for the onError handler + return {previousInbox}; + }, + onError: (_err, _id, context) => { + if (context?.previousInbox) { + queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox); + } + }, + onSettled: () => { + queryClient.invalidateQueries({queryKey: [`liked:${handle}`]}); + } + }); +} + +export function useUnlikeMutationForUser(handle: string) { + const queryClient = useQueryClient(); + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useMutation({ + mutationFn: (id: string) => { + return api.unlike(id); + }, + onMutate: async (id) => { + const previousInbox = queryClient.getQueryData([`inbox:${handle}`]); + const previousLiked = queryClient.getQueryData([`liked:${handle}`]); + + if (previousInbox) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryClient.setQueryData([`inbox:${handle}`], (old?: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return old?.map((item: any) => { + if (item.object.id === id) { + return { + ...item, + object: { + ...item.object, + liked: false + } + }; + } + return item; + }); + }); + } + if (previousLiked) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryClient.setQueryData([`liked:${handle}`], (old?: any[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return old?.filter((item: any) => item.object.id !== id); + }); + } + + // This sets the context for the onError handler + return {previousInbox, previousLiked}; + }, + onError: (_err, _id, context) => { + if (context?.previousInbox) { + queryClient.setQueryData([`inbox:${handle}`], context?.previousInbox); + } + if (context?.previousLiked) { + queryClient.setQueryData([`liked:${handle}`], context?.previousLiked); + } + } + }); +} + +export function useUserDataForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`user:${handle}`], + async queryFn() { + return api.getUser(); + } + }); +} + +export function useFollowersCountForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`followersCount:${handle}`], + async queryFn() { + return api.getFollowersCount(); + } + }); +} + +export function useFollowingCountForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`followingCount:${handle}`], + async queryFn() { + return api.getFollowingCount(); + } + }); +} + +export function useFollowingForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`following:${handle}`], + async queryFn() { + return api.getFollowing(); + } + }); +} + +export function useFollowersForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`followers:${handle}`], + async queryFn() { + return api.getFollowers(); + } + }); +} + +export function useAllActivitiesForUser({handle, includeOwn = false}: {handle: string, includeOwn?: boolean}) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`activities:${handle}:includeOwn=${includeOwn.toString()}`], + async queryFn() { + return api.getAllActivities(includeOwn); + } + }); +} diff --git a/apps/admin-x-activitypub/src/styles/index.css b/apps/admin-x-activitypub/src/styles/index.css index d9a471ebfb4..5190e70eda5 100644 --- a/apps/admin-x-activitypub/src/styles/index.css +++ b/apps/admin-x-activitypub/src/styles/index.css @@ -37,3 +37,19 @@ animation: bump 0.3s ease-in-out; margin-top: 1.5rem !important; } +.ap-likes .ellipsis::after { + content: "…"; +} + +.ap-likes .invisible { + display: inline-block; + font-size: 0; + height: 0; + line-height: 0; + position: absolute; + width: 0; +} + +.ap-textarea { + field-sizing: content; +} diff --git a/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts b/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts index b36554270f0..645fdaf3b13 100644 --- a/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts +++ b/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts @@ -26,8 +26,12 @@ export const getRelativeTimestamp = (date: Date): string => { if (interval >= 1) { return `${interval}m`; } + + if (seconds < 1) { + return `Just now`; + } - return `${seconds} seconds`; + return `${seconds}s`; }; export default getRelativeTimestamp; \ No newline at end of file diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 07888275e31..468d58230ba 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -11,8 +11,8 @@ "scripts": { "build": "tsc -p tsconfig.declaration.json && vite build", "prepare": "yarn build", - "test": "yarn test:unit && yarn test:types", - "test:unit": "yarn nx build && vitest run", + "test": "yarn test:unit", + "test:unit": "yarn test:types && yarn nx build && vitest run", "test:types": "tsc --noEmit", "lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache", "lint": "yarn lint:code && yarn lint:test", @@ -74,17 +74,17 @@ "@radix-ui/react-tabs": "1.1.0", "@radix-ui/react-tooltip": "1.1.2", "@sentry/react": "7.119.0", - "@tailwindcss/forms": "0.5.7", + "@tailwindcss/forms": "0.5.9", "@tailwindcss/line-clamp": "0.4.4", - "@uiw/react-codemirror": "4.23.0", + "@uiw/react-codemirror": "4.23.3", "autoprefixer": "10.4.19", "clsx": "2.1.1", "postcss": "8.4.39", "postcss-import": "16.1.0", "react-colorful": "5.6.1", "react-hot-toast": "2.4.1", - "react-select": "5.8.0", - "tailwindcss": "3.4.10" + "react-select": "5.8.1", + "tailwindcss": "3.4.12" }, "peerDependencies": { "react": "^18.2.0", diff --git a/apps/admin-x-design-system/src/assets/icons/comment-fill.svg b/apps/admin-x-design-system/src/assets/icons/comment-fill.svg new file mode 100644 index 00000000000..b562e5dadfd --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/comment-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/dotdotdot.svg b/apps/admin-x-design-system/src/assets/icons/dotdotdot.svg new file mode 100644 index 00000000000..5a6a5fbe2a5 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/dotdotdot.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/Menu.tsx b/apps/admin-x-design-system/src/global/Menu.tsx index c9a99e73721..9047d742090 100644 --- a/apps/admin-x-design-system/src/global/Menu.tsx +++ b/apps/admin-x-design-system/src/global/Menu.tsx @@ -5,6 +5,7 @@ import Popover, {PopoverPosition} from './Popover'; export type MenuItem = { id: string, label: string; + destructive?: boolean; onClick?: () => void } @@ -23,14 +24,14 @@ const Menu: React.FC = ({ position = 'start' }) => { if (!trigger) { - trigger = + ))}
diff --git a/apps/admin-x-design-system/src/global/form/URLTextField.tsx b/apps/admin-x-design-system/src/global/form/URLTextField.tsx index 93985811f55..08f14875c5c 100644 --- a/apps/admin-x-design-system/src/global/form/URLTextField.tsx +++ b/apps/admin-x-design-system/src/global/form/URLTextField.tsx @@ -1,97 +1,7 @@ import React, {useEffect, useState} from 'react'; -import isEmail from 'validator/es/lib/isEmail'; import {useFocusContext} from '../../providers/DesignSystemProvider'; import TextField, {TextFieldProps} from './TextField'; - -export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { - if (nullable && !value) { - return {save: null, display: ''}; - } - - let url = value.trim(); - - if (!url) { - if (baseUrl) { - return {save: '/', display: baseUrl}; - } - return {save: '', display: ''}; - } - - // if we have an email address, add the mailto: - if (isEmail(url)) { - return {save: `mailto:${url}`, display: `mailto:${url}`}; - } - - const isAnchorLink = url.match(/^#/); - - if (isAnchorLink) { - return {save: url, display: url}; - } - - if (!baseUrl) { - // Absolute URL with no base URL - if (!url.startsWith('http')) { - url = `https://${url}`; - } - } - - // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc - if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { - return {save: url, display: url}; - } - - let parsedUrl: URL; - - try { - parsedUrl = new URL(url, baseUrl); - } catch (e) { - return {save: url, display: url}; - } - - if (!baseUrl) { - return {save: parsedUrl.toString(), display: parsedUrl.toString()}; - } - const parsedBaseUrl = new URL(baseUrl); - - let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; - - // if our path is only missing a trailing / mark it as relative - if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { - isRelativeToBasePath = true; - } - - const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; - - // if relative to baseUrl, remove the base url before sending to action - if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) { - url = url.replace(/^[a-zA-Z0-9-]+:/, ''); - url = url.replace(/^\/\//, ''); - url = url.replace(parsedBaseUrl.host, ''); - url = url.replace(parsedBaseUrl.pathname, ''); - - // handle case where url path is same as baseUrl path but missing trailing slash - if (parsedUrl.pathname.slice(-1) !== '/') { - url = url.replace(parsedBaseUrl.pathname.slice(0, -1), ''); - } - - if (!url.match(/^\//)) { - url = `/${url}`; - } - - if (!url.match(/\/$/) && !url.match(/[.#?]/)) { - url = `${url}/`; - } - } - - if (url.match(/^(\/\/|#)/)) { - return {save: url, display: url}; - } - - // we update with the relative URL but then transform it back to absolute - // for the input value. This avoids problems where the underlying relative - // value hasn't changed even though the input value has - return {save: url, display: new URL(url, baseUrl).toString()}; -}; +import {formatUrl} from '../../utils/formatUrl'; export interface URLTextFieldProps extends Omit { baseUrl?: string; diff --git a/apps/admin-x-design-system/src/index.ts b/apps/admin-x-design-system/src/index.ts index c2298cb1a28..1a0a95358ca 100644 --- a/apps/admin-x-design-system/src/index.ts +++ b/apps/admin-x-design-system/src/index.ts @@ -50,7 +50,7 @@ export {default as Toggle} from './global/form/Toggle'; export type {ToggleProps} from './global/form/Toggle'; export {default as ToggleGroup} from './global/form/ToggleGroup'; export type {ToggleGroupProps} from './global/form/ToggleGroup'; -export {default as URLTextField, formatUrl} from './global/form/URLTextField'; +export {default as URLTextField} from './global/form/URLTextField'; export type {URLTextFieldProps} from './global/form/URLTextField'; export {default as ConfirmationModal, ConfirmationModalContent} from './global/modal/ConfirmationModal'; @@ -166,6 +166,7 @@ export {default as useSortableIndexedList} from './hooks/useSortableIndexedList' export {debounce} from './utils/debounce'; export {confirmIfDirty} from './utils/modals'; +export {formatUrl} from './utils/formatUrl'; export {default as DesignSystemApp} from './DesignSystemApp'; export type {DesignSystemAppProps} from './DesignSystemApp'; diff --git a/apps/admin-x-design-system/src/utils/formatUrl.ts b/apps/admin-x-design-system/src/utils/formatUrl.ts new file mode 100644 index 00000000000..b05852be6c3 --- /dev/null +++ b/apps/admin-x-design-system/src/utils/formatUrl.ts @@ -0,0 +1,100 @@ +import isEmail from 'validator/es/lib/isEmail'; + +export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { + if (nullable && !value) { + return {save: null, display: ''}; + } + + let url = value.trim(); + + if (!url) { + if (baseUrl) { + return {save: '/', display: baseUrl}; + } + return {save: '', display: ''}; + } + + // if we have an email address, add the mailto: + if (isEmail(url)) { + return {save: `mailto:${url}`, display: `mailto:${url}`}; + } + + const isAnchorLink = url.match(/^#/); + if (isAnchorLink) { + return {save: url, display: url}; + } + + const isProtocolRelative = url.match(/^(\/\/)/); + if (isProtocolRelative) { + return {save: url, display: url}; + } + + if (!baseUrl) { + // Absolute URL with no base URL + if (!url.startsWith('http')) { + url = `https://${url}`; + } + } + + // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc + if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { + return {save: url, display: url}; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(url, baseUrl); + } catch (e) { + return {save: url, display: url}; + } + + if (!baseUrl) { + return {save: parsedUrl.toString(), display: parsedUrl.toString()}; + } + const parsedBaseUrl = new URL(baseUrl); + + let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; + + // if our path is only missing a trailing / mark it as relative + if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { + isRelativeToBasePath = true; + } + + const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; + + // if relative to baseUrl, remove the base url before sending to action + if (isOnSameHost && isRelativeToBasePath) { + url = url.replace(/^[a-zA-Z0-9-]+:/, ''); + url = url.replace(/^\/\//, ''); + url = url.replace(parsedBaseUrl.host, ''); + url = url.replace(parsedBaseUrl.pathname, ''); + + if (!url.match(/^\//)) { + url = `/${url}`; + } + } + + if (!url.match(/\/$/) && !url.match(/[.#?]/)) { + url = `${url}/`; + } + + // we update with the relative URL but then transform it back to absolute + // for the input value. This avoids problems where the underlying relative + // value hasn't changed even though the input value has + return {save: url, display: displayFromBase(url, baseUrl)}; +}; + +const displayFromBase = (url: string, baseUrl: string) => { + // Ensure base url has a trailing slash + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + // Remove leading slash from url + if (url.startsWith('/')) { + url = url.substring(1); + } + + return new URL(url, baseUrl).toString(); +}; diff --git a/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts b/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts new file mode 100644 index 00000000000..2f61dd649b6 --- /dev/null +++ b/apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'assert/strict'; +import {formatUrl} from '../../../src/utils/formatUrl'; + +describe('formatUrl', function () { + it('displays empty string if the input is empty and nullable is true', function () { + const formattedUrl = formatUrl('', undefined, true); + assert.deepEqual(formattedUrl, {save: null, display: ''}); + }); + + it('displays empty string value if the input has only whitespace', function () { + const formattedUrl = formatUrl(''); + assert.deepEqual(formattedUrl, {save: '', display: ''}); + }); + + it('displays base value if the input has only whitespace and base url is available', function () { + const formattedUrl = formatUrl('', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/', display: 'http://example.com'}); + }); + + it('displays a mailto address for an email address', function () { + const formattedUrl = formatUrl('test@example.com'); + assert.deepEqual(formattedUrl, {save: 'mailto:test@example.com', display: 'mailto:test@example.com'}); + }); + + it('displays an anchor link without formatting', function () { + const formattedUrl = formatUrl('#section'); + assert.deepEqual(formattedUrl, {save: '#section', display: '#section'}); + }); + + it('displays a protocol-relative link without formatting', function () { + const formattedUrl = formatUrl('//example.com'); + assert.deepEqual(formattedUrl, {save: '//example.com', display: '//example.com'}); + }); + + it('adds https:// automatically', function () { + const formattedUrl = formatUrl('example.com'); + assert.deepEqual(formattedUrl, {save: 'https://example.com/', display: 'https://example.com/'}); + }); + + it('saves a relative URL if the input is a pathname', function () { + const formattedUrl = formatUrl('/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'}); + }); + + it('saves a relative URL if the input is a pathname, even if the base url has an non-empty pathname', function () { + const formattedUrl = formatUrl('/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'}); + }); + + it('saves a relative URL if the input includes the base url', function () { + const formattedUrl = formatUrl('http://example.com/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'}); + }); + + it('saves a relative URL if the input includes the base url, even if the base url has an non-empty pathname', function () { + const formattedUrl = formatUrl('http://example.com/blog/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'}); + }); + + it('saves an absolute URL if the input has a different pathname to the base url', function () { + const formattedUrl = formatUrl('http://example.com/path', 'http://example.com/blog'); + assert.deepEqual(formattedUrl, {save: 'http://example.com/path', display: 'http://example.com/path'}); + }); + + it('saves an absolte URL if the input has a different hostname to the base url', function () { + const formattedUrl = formatUrl('http://another.com/path', 'http://example.com'); + assert.deepEqual(formattedUrl, {save: 'http://another.com/path', display: 'http://another.com/path'}); + }); +}); diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 51367b44d29..c35fc4f5fe4 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -109,7 +109,8 @@ "test:unit": { "dependsOn": [ "test:unit", - "^build" + "^build", + "@tryghost/admin-x-design-system:test:unit" ] } } diff --git a/apps/admin-x-framework/src/test/acceptance.ts b/apps/admin-x-framework/src/test/acceptance.ts index 4bf8a38ee92..a9ae2063abb 100644 --- a/apps/admin-x-framework/src/test/acceptance.ts +++ b/apps/admin-x-framework/src/test/acceptance.ts @@ -186,7 +186,14 @@ export async function mockApi }); } - const requestBody = JSON.parse(route.request().postData() || 'null'); + let requestBody = null; + try { + // Try to parse the post data as JSON + requestBody = JSON.parse(route.request().postData() || 'null'); + } catch { + // Post data isn't JSON (e.g. file upload) — use the raw post data + requestBody = route.request().postData(); + } lastApiRequests[matchingMock.name] = { body: requestBody, diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 9e0956699d2..4fc01263954 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -39,16 +39,16 @@ "dependencies": { "@codemirror/lang-html": "6.4.9", "@tryghost/color-utils": "0.2.2", - "@tryghost/kg-unsplash-selector": "0.2.4", + "@tryghost/kg-unsplash-selector": "0.2.5", "@tryghost/limit-service": "1.2.14", - "@tryghost/nql": "0.12.4", + "@tryghost/nql": "0.12.5", "@tryghost/timezone-data": "0.4.3", "react": "18.3.1", "react-dom": "18.3.1", "validator": "7.2.0" }, "devDependencies": { - "@playwright/test": "1.38.1", + "@playwright/test": "1.46.1", "@testing-library/react": "14.3.1", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index cfe4ec4f00e..fc43043012d 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -55,6 +55,10 @@ const features = [{ title: 'Content Visibility', description: 'Enables content visibility in Emails', flag: 'contentVisibility' +}, { + title: 'Comment Improvements', + description: 'Enables new comment features', + flag: 'commentImprovements' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin-x-settings/src/components/settings/general/Users.tsx b/apps/admin-x-settings/src/components/settings/general/Users.tsx index 703ef6547e1..a527510a26c 100644 --- a/apps/admin-x-settings/src/components/settings/general/Users.tsx +++ b/apps/admin-x-settings/src/components/settings/general/Users.tsx @@ -183,7 +183,7 @@ const InvitesUserList: React.FC = ({users}) => { } - avatar={()} + avatar={()} className='min-h-[64px]' detail={user.role} hideActions={true} diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/PortalLinks.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/PortalLinks.tsx index e58695daafb..4a91787824d 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/PortalLinks.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/PortalLinks.tsx @@ -24,7 +24,7 @@ const PortalLink: React.FC = ({name, value}) => { }}/>} separator > -
+
@@ -96,6 +96,7 @@ const PortalLinks: React.FC = () => { + diff --git a/apps/admin-x-settings/src/utils/linkToGithubReleases.ts b/apps/admin-x-settings/src/utils/linkToGithubReleases.ts index 27578a5197a..4271ba3894f 100644 --- a/apps/admin-x-settings/src/utils/linkToGithubReleases.ts +++ b/apps/admin-x-settings/src/utils/linkToGithubReleases.ts @@ -1,20 +1,35 @@ import semverParse from 'semver/functions/parse'; -export function linkToGitHubReleases(version: string):string { - if (version.includes('-pre.')) { - try { - const semverVersion = semverParse(version, {includePrerelease: true} as any); +// This function needs to support: +// - 5.94.1+moya +// - 5.94.1-0-g1f3e72eac8+moya +// - 5.95.0-pre-g028c1a6+moya +export function linkToGitHubReleases(version: string): string { + if (!version) { + return ''; + } + + const cleanedVersion = version.replace('+moya', ''); + + try { + const semverVersion = semverParse(cleanedVersion, {includePrerelease: true} as any); + const prerelease = semverVersion?.prerelease; + + if (prerelease && prerelease?.length > 0) { + const splitPrerelease = String(prerelease[0]).split('-'); + const commitHash = splitPrerelease[1]; - if (semverVersion && semverVersion.build?.[0]) { - return `https://github.com/TryGhost/Ghost/commit/${semverVersion.build[0]}`; + if (!commitHash || !commitHash.startsWith('g')) { + return ''; } - return ''; - } catch (e) { - return ''; + const commitHashWithoutG = commitHash.slice(1); + + return `https://github.com/TryGhost/Ghost/commit/${commitHashWithoutG}`; } - } - let cleanedVersion = version.replace('+moya', ''); - return `https://github.com/TryGhost/Ghost/releases/tag/v${cleanedVersion}`; + return `https://github.com/TryGhost/Ghost/releases/tag/v${cleanedVersion}`; + } catch (e) { + return ''; + } } diff --git a/apps/admin-x-settings/test/unit/utils/linkToGithubReleases.test.ts b/apps/admin-x-settings/test/unit/utils/linkToGithubReleases.test.ts index ef4cc9b2155..13c048eef2f 100644 --- a/apps/admin-x-settings/test/unit/utils/linkToGithubReleases.test.ts +++ b/apps/admin-x-settings/test/unit/utils/linkToGithubReleases.test.ts @@ -2,15 +2,33 @@ import * as assert from 'assert/strict'; import {linkToGitHubReleases} from '../../../src/utils/linkToGithubReleases'; describe('linkToGithubRelease', function () { - it('generates a link to a release', function () { - let version = '5.69.0'; - let link = linkToGitHubReleases(version); + it('handles empty version', function () { + const link = linkToGitHubReleases(''); + assert.equal(link, ''); + }); + + it('handles plain version release', function () { + const link = linkToGitHubReleases('5.69.0'); assert.equal(link, 'https://github.com/TryGhost/Ghost/releases/tag/v5.69.0'); }); - it('strips moya from the version', function () { - let version = '5.69.0+moya'; - let link = linkToGitHubReleases(version); + it('handles plain version with +moya suffix', function () { + const link = linkToGitHubReleases('5.69.0+moya'); assert.equal(link, 'https://github.com/TryGhost/Ghost/releases/tag/v5.69.0'); }); + + it('handles git describe output', function () { + const link = linkToGitHubReleases('5.69.0-0-gabcdef'); + assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abcdef'); + }); + + it('handles git describe output with +moya suffix', function () { + const link = linkToGitHubReleases('5.69.0-0-gabcdef+moya'); + assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abcdef'); + }); + + it('handles prerelease version', function () { + const link = linkToGitHubReleases('5.70.0-pre-gabcdef+moya'); + assert.equal(link, 'https://github.com/TryGhost/Ghost/commit/abcdef'); + }); }); diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 60b2b4d39e0..61336120008 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -59,7 +59,7 @@ "react-string-replace": "1.1.1" }, "devDependencies": { - "@playwright/test": "1.38.1", + "@playwright/test": "1.46.1", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/user-event": "14.5.2", @@ -75,7 +75,7 @@ "eslint-plugin-tailwindcss": "3.13.0", "jsdom": "24.1.3", "postcss": "8.4.39", - "tailwindcss": "3.4.10", + "tailwindcss": "3.4.12", "vite": "4.5.3", "vite-plugin-css-injected-by-js": "3.3.0", "vite-plugin-svgr": "3.3.0", diff --git a/apps/comments-ui/src/App.tsx b/apps/comments-ui/src/App.tsx index 0f42eeb9675..0b1dd3ae666 100644 --- a/apps/comments-ui/src/App.tsx +++ b/apps/comments-ui/src/App.tsx @@ -26,7 +26,8 @@ const App: React.FC = ({scriptTag}) => { pagination: null, commentCount: 0, secundaryFormCount: 0, - popup: null + popup: null, + labs: null }); const iframeRef = React.createRef(); @@ -135,15 +136,15 @@ const App: React.FC = ({scriptTag}) => { const initSetup = async () => { try { // Fetch data from API, links, preview, dev sources - const {member} = await api.init(); + const {member, labs} = await api.init(); const {comments, pagination, count} = await fetchComments(); - const state = { member, initStatus: 'success', comments, pagination, - commentCount: count + commentCount: count, + labs: labs }; setState(state); diff --git a/apps/comments-ui/src/AppContext.ts b/apps/comments-ui/src/AppContext.ts index 29403d2ba7f..eb5bf3035c9 100644 --- a/apps/comments-ui/src/AppContext.ts +++ b/apps/comments-ui/src/AppContext.ts @@ -33,6 +33,10 @@ export type AddComment = { html: string } +export type LabsContextType = { + [key: string]: boolean +} + export type CommentsOptions = { locale: string, siteUrl: string, @@ -40,7 +44,7 @@ export type CommentsOptions = { apiUrl: string | undefined, postId: string, adminUrl: string | undefined, - colorScheme: string| undefined, + colorScheme: string | undefined, avatarSaturation: number | undefined, accentColor: string, commentsEnabled: string | undefined, @@ -63,15 +67,16 @@ export type EditableAppContext = { commentCount: number, secundaryFormCount: number, popup: Page | null, + labs: LabsContextType | null } -export type TranslationFunction = (key: string, replacements?: Record) => string; +export type TranslationFunction = (key: string, replacements?: Record) => string; export type AppContextType = EditableAppContext & CommentsOptions & { // This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data) // eslint-disable-next-line @typescript-eslint/ban-types t: TranslationFunction, - dispatchAction: (action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends {data: any} ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise : void + dispatchAction: (action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends { data: any } ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise : void } // Copy time from AppContextType @@ -81,3 +86,14 @@ export const AppContext = React.createContext({} as any); export const AppContextProvider = AppContext.Provider; export const useAppContext = () => useContext(AppContext); + +// create a hook that will only get labs data from the context + +export const useLabs = () => { + try { + const context = useAppContext(); + return context.labs; + } catch (e) { + return null; + } +}; diff --git a/apps/comments-ui/src/components/content/Avatar.tsx b/apps/comments-ui/src/components/content/Avatar.tsx index 149e89a8c84..1ae4bc319f3 100644 --- a/apps/comments-ui/src/components/content/Avatar.tsx +++ b/apps/comments-ui/src/components/content/Avatar.tsx @@ -3,7 +3,7 @@ import {Comment, useAppContext} from '../../AppContext'; import {getInitials} from '../../utils/helpers'; function getDimensionClasses() { - return 'w-9 h-9 sm:w-[40px] sm:h-[40px]'; + return 'w-8 h-8'; } export const BlankAvatar = () => { @@ -88,7 +88,7 @@ export const Avatar: React.FC = ({comment}) => { <> {memberName ? (
-

{ commentGetInitials() }

+

{ commentGetInitials() }

) : (
diff --git a/apps/comments-ui/src/components/content/Comment.tsx b/apps/comments-ui/src/components/content/Comment.tsx index efd01664ad2..fbc252e5d13 100644 --- a/apps/comments-ui/src/components/content/Comment.tsx +++ b/apps/comments-ui/src/components/content/Comment.tsx @@ -116,9 +116,9 @@ const UnpublishedComment: React.FC = ({comment, openEdi return ( -
-
-

{notPublishedMessage}

+
+
+

{notPublishedMessage}

@@ -140,7 +140,7 @@ const MemberExpertise: React.FC<{comment: Comment}> = ({comment}) => { } return ( - {memberExpertise}· + ·{memberExpertise} ); }; @@ -151,7 +151,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => { } return ( - ·{t('Edited')} +  ({t('edited')}) ); }; @@ -164,7 +164,7 @@ const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => { } return ( -
+
); @@ -196,7 +196,7 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => { const {t} = useAppContext(); const name = !comment.member ? t('Deleted member') : (comment.member.name ? comment.member.name : t('Anonymous')); return ( -

+

{name}

); @@ -204,18 +204,18 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => { const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => { const createdAtRelative = useRelativeTime(comment.created_at); + const {member} = useAppContext(); + const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise; return ( -
-
- -
- - - {createdAtRelative} - - -
+
+ +
+ + + ·{createdAtRelative} + +
); @@ -225,7 +225,7 @@ const CommentBody: React.FC<{html: string}> = ({html}) => { const dangerouslySetInnerHTML = {__html: html}; return (
-

+

); }; @@ -247,7 +247,7 @@ const CommentMenu: React.FC = ({comment, toggleReplyMode, isIn const canReply = member && (isPaidMember || !paidOnly) && !parent; return ( -
+
{} {(canReply && )} {} @@ -274,9 +274,9 @@ type CommentLayoutProps = { } const CommentLayout: React.FC = ({children, avatar, hasReplies}) => { return ( -
-
-
+
+
+
{avatar}
diff --git a/apps/comments-ui/src/components/content/Content.tsx b/apps/comments-ui/src/components/content/Content.tsx index ce665a1a556..b14405439c7 100644 --- a/apps/comments-ui/src/components/content/Content.tsx +++ b/apps/comments-ui/src/components/content/Content.tsx @@ -4,12 +4,13 @@ import ContentTitle from './ContentTitle'; import MainForm from './forms/MainForm'; import Pagination from './Pagination'; import {ROOT_DIV_ID} from '../../utils/constants'; -import {useAppContext} from '../../AppContext'; +import {useAppContext, useLabs} from '../../AppContext'; import {useEffect} from 'react'; const Content = () => { const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useAppContext(); const commentsElements = comments.slice().reverse().map(comment => ); + const labs = useLabs(); const paidOnly = commentsEnabled === 'paid'; const isPaidMember = member && !!member.paid; @@ -47,6 +48,9 @@ const Content = () => { : null }
+ { + labs?.testFlag ?
: null + } ); }; diff --git a/apps/comments-ui/src/components/content/ContentTitle.tsx b/apps/comments-ui/src/components/content/ContentTitle.tsx index 30fcac08de5..30aa1ad131b 100644 --- a/apps/comments-ui/src/components/content/ContentTitle.tsx +++ b/apps/comments-ui/src/components/content/ContentTitle.tsx @@ -14,12 +14,12 @@ const Count: React.FC = ({showCount, count}) => { if (count === 1) { return ( -
{t('1 comment')}
+
{t('1 comment')}
); } return ( -
{t('{{amount}} comments', {amount: formatNumber(count)})}
+
{t('{{amount}} comments', {amount: formatNumber(count)})}
); }; @@ -48,7 +48,7 @@ const ContentTitle: React.FC = ({title, showCount, count}) => return (
-

+

</h2> <Count count={count} showCount={showCount} /> diff --git a/apps/comments-ui/src/components/content/buttons/LikeButton.tsx b/apps/comments-ui/src/components/content/buttons/LikeButton.tsx index 912197a02be..ebc8b3a1f2a 100644 --- a/apps/comments-ui/src/components/content/buttons/LikeButton.tsx +++ b/apps/comments-ui/src/components/content/buttons/LikeButton.tsx @@ -38,7 +38,7 @@ const LikeButton: React.FC<Props> = ({comment}) => { } return ( - <CustomTag 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}`} data-testid="like-button" type="button" onClick={toggleLike}> + <CustomTag className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${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}`} data-testid="like-button" type="button" onClick={toggleLike}> <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> diff --git a/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx b/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx index 764a1482520..702162006ec 100644 --- a/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx +++ b/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx @@ -10,7 +10,7 @@ const ReplyButton: React.FC<Props> = ({disabled, isReplying, toggleReply}) => { const {member, t} = useAppContext(); return member ? - (<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)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}> + (<button className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${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)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}> <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`} />{t('Reply')} </button>) : null; }; diff --git a/apps/comments-ui/src/components/content/forms/Form.tsx b/apps/comments-ui/src/components/content/forms/Form.tsx index 6e5f22fafe5..0c9b402b24f 100644 --- a/apps/comments-ui/src/components/content/forms/Form.tsx +++ b/apps/comments-ui/src/components/content/forms/Form.tsx @@ -103,9 +103,9 @@ const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, c }, [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={`relative w-full pl-[40px] transition-[padding] delay-100 duration-150 sm:pl-[44px] ${reduced && 'pl-0'} ${isOpen && 'pl-[1px] pt-[56px] sm:pl-[52px] sm:pt-[64px]'}`}> <div - className={`shadow-form hover:shadow-formxl 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 transition-all delay-100 duration-150 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'} + className={`shadow-form hover:shadow-formxl text-md w-full rounded-md border border-none border-slate-50 bg-[rgba(255,255,255,0.9)] px-2 font-sans leading-normal transition-all delay-100 duration-150 focus:outline-0 sm:px-3 sm:text-lg dark:border-none dark:bg-[rgba(255,255,255,0.08)] dark:text-neutral-300 dark:shadow-transparent ${isOpen ? 'min-h-[144px] cursor-text py-2 pb-[68px]' : 'min-h-[48px] cursor-pointer overflow-hidden py-3 hover:border-slate-300 sm:py-4'} `} data-testid="form-editor"> <EditorContent @@ -113,12 +113,12 @@ const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, c onTouchStart={stopIfFocused} /> </div> - <div className="absolute bottom-[9px] right-[9px] flex space-x-4 transition-[opacity] duration-150"> + <div className="absolute bottom-1 right-1 flex space-x-4 transition-[opacity] duration-150 sm:bottom-2 sm:right-2"> {close && <button className="ml-2.5 font-sans text-sm font-medium text-neutral-500 outline-0 dark:text-neutral-400" type="button" onClick={close}>{t('Cancel')}</button> } <button - className={`flex w-auto items-center justify-center sm:min-w-[128px] ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-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`} + className={`flex w-auto items-center justify-center ${submitSize === 'medium' && 'sm:min-w-[100px]'} ${submitSize === 'small' && 'sm:min-w-[64px]'} h-[40px] rounded-[6px] border bg-neutral-900 px-3 py-2 text-center font-sans text-base font-medium text-white outline-0 transition-[opacity] duration-150 sm:text-sm dark:bg-[rgba(255,255,255,0.9)] dark:text-neutral-800`} data-testid="submit-form-button" type="button" onClick={submitForm} @@ -150,23 +150,25 @@ const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, editName, leaveTo="opacity-0" show={show} > - <div - className="font-sans text-[17px] font-bold tracking-tight text-[rgb(23,23,23)] dark:text-[rgba(255,255,255,0.85)]" - data-testid="member-name" - onClick={editName} - > - {name ? name : 'Anonymous'} - </div> - <div className="flex items-baseline justify-start"> - <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)] sm:max-w-[90%] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.4)] ${!expertise && 'text-[rgba(0,0,0,0.3)] hover:text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.3)]'}`} - data-testid="expertise-button" - type="button" - onClick={editExpertise} + <div className="flex"> + <div + className="font-sans text-base font-bold leading-snug text-[rgb(23,23,23)] sm:text-sm dark:text-[rgba(255,255,255,0.85)]" + data-testid="member-name" + onClick={editName} > - <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> + {name ? name : 'Anonymous'} + </div> + <div className="flex items-baseline justify-start"> + <button + className={`group flex items-center justify-start whitespace-nowrap text-left font-sans text-base leading-snug text-[rgba(0,0,0,0.5)] transition duration-150 hover:text-[rgba(0,0,0,0.75)] sm:text-sm dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.4)] ${!expertise && 'text-[rgba(0,0,0,0.3)] hover:text-[rgba(0,0,0,0.5)] dark:text-[rgba(255,255,255,0.3)]'}`} + data-testid="expertise-button" + type="button" + onClick={editExpertise} + > + <span><span className="mx-[0.3em]">·</span>{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> </div> </Transition> ); @@ -263,13 +265,13 @@ const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, clo }, [editor, memberName, progress]); return ( - <form ref={formEl} className={`-mx-3 mb-10 mt-[-10px] rounded-md px-3 pb-2 pt-3 transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'} ${reduced && 'pl-1'}`} data-testid="form" onClick={focusEditor} onMouseDown={preventIfFocused} onTouchStart={preventIfFocused}> + <form ref={formEl} className={`-mx-3 mb-7 mt-[-10px] rounded-md px-3 pb-2 pt-3 transition duration-200 ${isOpen ? 'cursor-default' : 'cursor-pointer'} ${reduced && 'pl-1'}`} data-testid="form" onClick={focusEditor} onMouseDown={preventIfFocused} onTouchStart={preventIfFocused}> <div className="relative w-full"> <div className="pr-[1px] font-sans leading-normal dark:text-neutral-300"> <FormEditor close={close} editor={editor} isOpen={isOpen} progress={progress} reduced={reduced} setProgress={setProgress} submit={submit} submitSize={submitSize} submitText={submitText} /> </div> - <div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'> - <div className="pointer-events-none mr-3 grow-0"> + <div className='absolute left-0 top-1 flex h-11 w-full items-center justify-start sm:h-12'> + <div className="pointer-events-none mr-2 grow-0 sm:mr-3"> <Avatar comment={comment} /> </div> <div className="grow-1 w-full"> diff --git a/apps/comments-ui/src/images/icons/more.svg b/apps/comments-ui/src/images/icons/more.svg index 22c7c1342e4..5bb246b5073 100644 --- a/apps/comments-ui/src/images/icons/more.svg +++ b/apps/comments-ui/src/images/icons/more.svg @@ -1,5 +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" /> + <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> diff --git a/apps/comments-ui/src/utils/api.ts b/apps/comments-ui/src/utils/api.ts index 1d839be01cc..e33cd4e1b5c 100644 --- a/apps/comments-ui/src/utils/api.ts +++ b/apps/comments-ui/src/utils/api.ts @@ -1,4 +1,4 @@ -import {AddComment, Comment} from '../AppContext'; +import {AddComment, Comment, LabsContextType} from '../AppContext'; function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {siteUrl: string, apiUrl: string, apiKey: string}) { const apiPath = 'members/api'; @@ -286,7 +286,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site }); } }, - init: (() => {}) as () => Promise<{ member: any; }> + init: (() => {}) as () => Promise<{ member: any; labs: any}> }; api.init = async () => { @@ -294,7 +294,18 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site api.member.sessionData() ]); - return {member}; + let labs = {}; + + try { + const settings = await api.site.settings(); + if (settings.settings.labs) { + Object.assign(labs, settings.settings.labs); + } + } catch (e) { + labs = {}; + } + + return {member, labs}; }; return api; @@ -302,3 +313,4 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site export default setupGhostApi; export type GhostApi = ReturnType<typeof setupGhostApi>; +export type LabsType = LabsContextType; diff --git a/apps/comments-ui/src/utils/helpers.ts b/apps/comments-ui/src/utils/helpers.ts index 1516ac3533b..2e8d14b5153 100644 --- a/apps/comments-ui/src/utils/helpers.ts +++ b/apps/comments-ui/src/utils/helpers.ts @@ -27,9 +27,9 @@ export function formatRelativeTime(dateString: string, t: TranslationFunction): diff = diff / 60; if (diff < 60) { if (Math.floor(diff) === 1) { - return t(`One minute ago`); + return t(`One min ago`); } - return t('{{amount}} minutes ago', {amount: Math.floor(diff)}); + return t('{{amount}} mins ago', {amount: Math.floor(diff)}); } // First check for yesterday @@ -45,7 +45,7 @@ export function formatRelativeTime(dateString: string, t: TranslationFunction): if (Math.floor(diff) === 1) { return t(`One hour ago`); } - return t('{{amount}} hours ago', {amount: Math.floor(diff)}); + return t('{{amount}} hrs ago', {amount: Math.floor(diff)}); } // Diff in days diff --git a/apps/comments-ui/tailwind.config.js b/apps/comments-ui/tailwind.config.js index 2dbae8a3a36..87a442007f0 100644 --- a/apps/comments-ui/tailwind.config.js +++ b/apps/comments-ui/tailwind.config.js @@ -101,9 +101,10 @@ module.exports = { }, fontSize: { xs: '1.2rem', + base: '1.3rem', sm: '1.4rem', md: '1.5rem', - lg: '1.8rem', + lg: '1.65rem', xl: '2rem', '2xl': '2.4rem', '3xl': '3rem', diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index f2e7b8a635d..72c815209b7 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -126,7 +126,7 @@ test.describe('Actions', async () => { const expertiseButton = frame.getByTestId('expertise-button'); await expect(expertiseButton).toBeVisible(); - await expect(expertiseButton).toHaveText('Add your expertise'); + await expect(expertiseButton).toHaveText('·Add your expertise'); await expertiseButton.click(); const detailsFrame = page.frameLocator('iframe[title="addDetailsPopup"]'); @@ -144,7 +144,7 @@ test.describe('Actions', async () => { await expect(profileModal).not.toBeVisible(); await expect(frame.getByTestId('member-name')).toHaveText('Testy McTest'); - await expect(frame.getByTestId('expertise-button')).toHaveText('Software development'); + await expect(frame.getByTestId('expertise-button')).toHaveText('·Software development'); }); }); diff --git a/apps/comments-ui/test/e2e/labs.test.ts b/apps/comments-ui/test/e2e/labs.test.ts new file mode 100644 index 00000000000..8401f40fa07 --- /dev/null +++ b/apps/comments-ui/test/e2e/labs.test.ts @@ -0,0 +1,44 @@ +import {MockedApi, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; + +test.describe('Labs', async () => { + test('Can toggle content based on Lab settings', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.setMember({}); + + mockedApi.addComment({ + html: '<p>This is comment 1</p>' + }); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly', + labs: { + testFlag: true + } + }); + + await expect(frame.getByTestId('this-comes-from-a-flag')).toHaveCount(1); + }); + + test('test div is hidden if flag is not set', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.setMember({}); + + mockedApi.addComment({ + html: '<p>This is comment 1</p>' + }); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly', + labs: { + testFlag: false + } + }); + + await expect(frame.getByTestId('this-comes-from-a-flag')).not.toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/comments-ui/test/e2e/options.test.ts b/apps/comments-ui/test/e2e/options.test.ts index ccbfe3bb542..feed03a5540 100644 --- a/apps/comments-ui/test/e2e/options.test.ts +++ b/apps/comments-ui/test/e2e/options.test.ts @@ -355,7 +355,6 @@ test.describe('Options', async () => { }); expect(titleColor).toBe('rgb(0, 0, 0)'); }); - }); }); diff --git a/apps/comments-ui/test/utils/MockedApi.ts b/apps/comments-ui/test/utils/MockedApi.ts index 42b07a05559..c197c3280b3 100644 --- a/apps/comments-ui/test/utils/MockedApi.ts +++ b/apps/comments-ui/test/utils/MockedApi.ts @@ -1,17 +1,19 @@ import nql from '@tryghost/nql'; -import {buildComment, buildMember, buildReply} from './fixtures'; +import {buildComment, buildMember, buildReply, buildSettings} from './fixtures'; export class MockedApi { comments: any[]; postId: string; member: any; + settings: any; #lastCommentDate = new Date('2021-01-01T00:00:00.000Z'); - constructor({postId = 'ABC', comments = [], member = undefined}: {postId?: string, comments?: any[], member?: any}) { + constructor({postId = 'ABC', comments = [], member = undefined, settings = {}}: {postId?: string, comments?: any[], member?: any, settings?: any}) { this.postId = postId; this.comments = comments; this.member = member; + this.settings = settings; } addComment(overrides: any = {}) { @@ -49,6 +51,10 @@ export class MockedApi { this.member = buildMember(overrides); } + setSettings(overrides) { + this.settings = buildSettings(overrides); + } + commentsCounts() { return { [this.postId]: this.comments.length @@ -281,5 +287,14 @@ export class MockedApi { ) }); }); + + // get settings from content api + + await page.route(`${path}/settings/*`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify(this.settings) + }); + }); } } diff --git a/apps/comments-ui/test/utils/e2e.ts b/apps/comments-ui/test/utils/e2e.ts index 240b0ce2ca2..6039b3a7342 100644 --- a/apps/comments-ui/test/utils/e2e.ts +++ b/apps/comments-ui/test/utils/e2e.ts @@ -1,6 +1,6 @@ import {E2E_PORT} from '../../playwright.config'; +import {LabsType, MockedApi} from './MockedApi'; import {Locator, Page} from '@playwright/test'; -import {MockedApi} from './MockedApi'; import {expect} from '@playwright/test'; export const MOCKED_SITE_URL = 'https://localhost:1234'; @@ -84,7 +84,7 @@ export async function mockAdminAuthFrame204({admin, page}) { }); } -export async function initialize({mockedApi, page, bodyStyle, ...options}: { +export async function initialize({mockedApi, page, bodyStyle, labs = {}, key = '12345678', api = MOCKED_SITE_URL, ...options}: { mockedApi: MockedApi, page: Page, path?: string; @@ -101,8 +101,18 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: { publication?: string, postId?: string, bodyStyle?: string, + labs?: LabsType }) { const sitePath = MOCKED_SITE_URL; + + mockedApi.setSettings({ + settings: { + labs: { + ...labs + } + } + }); + await page.route(sitePath, async (route) => { await route.fulfill({ status: 200, @@ -124,6 +134,14 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: { options.postId = mockedApi.postId; } + if (!options.key) { + options.key = key; + } + + if (!options.api) { + options.api = api; + } + await page.evaluate((data) => { const scriptTag = document.createElement('script'); scriptTag.src = data.url; @@ -194,7 +212,7 @@ export async function setClipboard(page, text) { } export function getModifierKey() { - const os = require('os'); + const os = require('os'); // eslint-disable-line @typescript-eslint/no-var-requires const platform = os.platform(); if (platform === 'darwin') { return 'Meta'; diff --git a/apps/comments-ui/test/utils/fixtures.ts b/apps/comments-ui/test/utils/fixtures.ts index f8c591c95f2..2417ee94a8a 100644 --- a/apps/comments-ui/test/utils/fixtures.ts +++ b/apps/comments-ui/test/utils/fixtures.ts @@ -16,6 +16,14 @@ export function buildMember(override: any = {}) { }; } +export function buildSettings(override: any = {}) { + return { + meta: {}, + settings: {}, + ...override + }; +} + export function buildComment(override: any = {}) { return { id: ObjectId().toString(), @@ -31,7 +39,7 @@ export function buildComment(override: any = {}) { replies: 0, likes: 0, ...override.count - }, + } }; } diff --git a/apps/portal/package.json b/apps/portal/package.json index 050ffc6466c..0df62ed86fa 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.43.0", + "version": "2.43.1", "license": "MIT", "repository": { "type": "git", diff --git a/apps/portal/src/App.js b/apps/portal/src/App.js index a7371449d1d..acdc7904e41 100644 --- a/apps/portal/src/App.js +++ b/apps/portal/src/App.js @@ -861,7 +861,22 @@ export default class App extends React.Component { signup: false } }; + } else if (path === 'account/newsletters/help') { + return { + page: 'emailReceivingFAQ', + pageData: { + direct: true + } + }; + } else if (path === 'account/newsletters/disabled') { + return { + page: 'emailSuppressionFAQ', + pageData: { + direct: true + } + }; } + return { page: 'default' }; diff --git a/apps/portal/src/components/Global.styles.js b/apps/portal/src/components/Global.styles.js index afecf0c9136..cd690670bd2 100644 --- a/apps/portal/src/components/Global.styles.js +++ b/apps/portal/src/components/Global.styles.js @@ -180,6 +180,11 @@ export const GlobalStyles = ` margin-right: -4vmin; } + .gh-longform .gh-portal-btn.no-margin-right { + margin-right: 0; + width: 100%; + } + .gh-longform .gh-portal-btn-text { color: var(--brandcolor); cursor: pointer; diff --git a/apps/portal/src/components/common/NewsletterManagement.js b/apps/portal/src/components/common/NewsletterManagement.js index 3bc9782de23..a255f3da6f5 100644 --- a/apps/portal/src/components/common/NewsletterManagement.js +++ b/apps/portal/src/components/common/NewsletterManagement.js @@ -205,7 +205,7 @@ export default function NewsletterManagement({ <span className="gh-portal-footer-secondary-light">{t('Not receiving emails?')}</span> <button className="gh-portal-btn-text gh-email-faq-page-button" - onClick={() => onAction('switchPage', {page: 'emailReceivingFAQ'})} + onClick={() => onAction('switchPage', {page: 'emailReceivingFAQ', pageData: {direct: false}})} > {/* eslint-disable-next-line i18next/no-literal-string */} {t('Get help')} → diff --git a/apps/portal/src/components/common/ProductsSection.js b/apps/portal/src/components/common/ProductsSection.js index 472e5d37fd2..a550144dff6 100644 --- a/apps/portal/src/components/common/ProductsSection.js +++ b/apps/portal/src/components/common/ProductsSection.js @@ -594,12 +594,13 @@ function ProductCardTrialDays({trialDays, discount, selectedInterval}) { function ProductCardPrice({product}) { const {selectedInterval} = useContext(ProductsContext); - const {site} = useContext(AppContext); + const {t, site} = useContext(AppContext); const monthlyPrice = product.monthlyPrice; const yearlyPrice = product.yearlyPrice; const trialDays = product.trial_days; const activePrice = selectedInterval === 'month' ? monthlyPrice : yearlyPrice; const alternatePrice = selectedInterval === 'month' ? yearlyPrice : monthlyPrice; + const interval = activePrice.interval === 'year' ? t('year') : t('month'); if (!monthlyPrice || !yearlyPrice) { return null; } @@ -615,7 +616,7 @@ function ProductCardPrice({product}) { <div className="gh-portal-product-price"> <span className={'currency-sign' + (currencySymbol.length > 1 ? ' long' : '')}>{currencySymbol}</span> <span className="amount" data-testid="product-amount">{formatNumber(getStripeAmount(activePrice.amount))}</span> - <span className="billing-period">/{activePrice.interval}</span> + <span className="billing-period">/{interval}</span> </div> <ProductCardTrialDays trialDays={trialDays} discount={yearlyDiscount} selectedInterval={selectedInterval} /> </div> diff --git a/apps/portal/src/components/pages/AccountHomePage/components/AccountActions.js b/apps/portal/src/components/pages/AccountHomePage/components/AccountActions.js index f243f431aeb..f64cf11fa91 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/AccountActions.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/AccountActions.js @@ -26,7 +26,7 @@ const AccountActions = () => { <div className='gh-portal-list'> <section> <div className='gh-portal-list-detail'> - <h3>{(name ? name : 'Account')}</h3> + <h3>{(name ? name : t('Account'))}</h3> <p>{email}</p> </div> <button diff --git a/apps/portal/src/components/pages/AccountHomePage/components/PaidAccountActions.js b/apps/portal/src/components/pages/AccountHomePage/components/PaidAccountActions.js index 0dd8f042ae4..7bd14f80563 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/PaidAccountActions.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/PaidAccountActions.js @@ -31,7 +31,7 @@ const PaidAccountActions = () => { let label = ''; if (price) { const {amount = 0, currency, interval} = price; - label = `${Intl.NumberFormat('en', {currency, style: 'currency'}).format(amount / 100)}/${interval}`; + label = `${Intl.NumberFormat('en', {currency, style: 'currency'}).format(amount / 100)}/${t(interval)}`; } let offerLabelStr = getOfferLabel({price, offer, subscriptionStartDate: startDate, t}); const compExpiry = getCompExpiry({member}); diff --git a/apps/portal/src/components/pages/AccountPlanPage.js b/apps/portal/src/components/pages/AccountPlanPage.js index 95b23510b79..f7d8f92917f 100644 --- a/apps/portal/src/components/pages/AccountPlanPage.js +++ b/apps/portal/src/components/pages/AccountPlanPage.js @@ -52,7 +52,7 @@ function getConfirmationPageTitle({confirmationType, t}) { const Header = ({showConfirmation, confirmationType}) => { const {member, t} = useContext(AppContext); - let title = isPaidMember({member}) ? 'Change plan' : 'Choose a plan'; + let title = isPaidMember({member}) ? t('Change plan') : t('Choose a plan'); if (showConfirmation) { title = getConfirmationPageTitle({confirmationType, t}); } @@ -122,7 +122,7 @@ const PlanConfirmationSection = ({plan, type, onConfirm}) => { planStartingMessage = t('Starting today'); } const priceString = formatNumber(plan.price); - const planStartMessage = `${plan.currency_symbol}${priceString}/${plan.interval} – ${planStartingMessage}`; + const planStartMessage = `${plan.currency_symbol}${priceString}/${t(plan.interval)} – ${planStartingMessage}`; const product = getProductFromPrice({site, priceId: plan?.id}); const priceLabel = hasMultipleProductsFeature({site}) ? product?.name : t('Price'); if (type === 'changePlan') { diff --git a/apps/portal/src/components/pages/EmailReceivingFAQ.js b/apps/portal/src/components/pages/EmailReceivingFAQ.js index 33d3ae9ea04..f2a233cb93d 100644 --- a/apps/portal/src/components/pages/EmailReceivingFAQ.js +++ b/apps/portal/src/components/pages/EmailReceivingFAQ.js @@ -7,21 +7,25 @@ import Interpolate from '@doist/react-interpolate'; import {SYNTAX_I18NEXT} from '@doist/react-interpolate'; export default function EmailReceivingPage() { - const {brandColor, onAction, site, lastPage, member, t} = useContext(AppContext); + const {brandColor, onAction, site, lastPage, member, t, pageData} = useContext(AppContext); const supportAddressEmail = getSupportAddress({site}); const supportAddress = `mailto:${supportAddressEmail}`; const defaultNewsletterSenderEmail = getDefaultNewsletterSender({site}); + const directAccess = (pageData && pageData.direct) || false; + return ( <div className="gh-email-receiving-faq"> <header className='gh-portal-detail-header'> - <BackButton brandColor={brandColor} onClick={() => { - if (!lastPage) { - onAction('switchPage', {page: 'accountEmail', lastPage: 'accountHome'}); - } else { - onAction('switchPage', {page: 'accountHome'}); - } - }} /> + {!directAccess && + <BackButton brandColor={brandColor} onClick={() => { + if (!lastPage) { + onAction('switchPage', {page: 'accountEmail', lastPage: 'accountHome'}); + } else { + onAction('switchPage', {page: 'accountHome'}); + } + }} /> + } <CloseButton /> </header> diff --git a/apps/portal/src/components/pages/EmailSuppressionFAQ.js b/apps/portal/src/components/pages/EmailSuppressionFAQ.js index 2e4fa42fced..6d327a41bfa 100644 --- a/apps/portal/src/components/pages/EmailSuppressionFAQ.js +++ b/apps/portal/src/components/pages/EmailSuppressionFAQ.js @@ -5,18 +5,21 @@ import CloseButton from '../../components/common/CloseButton'; import {getSupportAddress} from '../../utils/helpers'; export default function EmailSuppressedPage() { - const {brandColor, onAction, site, t} = useContext(AppContext); + const {brandColor, onAction, site, t, pageData} = useContext(AppContext); const supportAddress = `mailto:${getSupportAddress({site})}`; + const directAccess = (pageData && pageData.direct) || false; return ( <div className="gh-email-suppression-faq"> - <header className='gh-portal-detail-header'> - <BackButton brandColor={brandColor} onClick={() => { - onAction('switchPage', {page: 'emailSuppressed', lastPage: 'accountHome'}); - }} /> - <CloseButton /> - </header> + {!directAccess && + <header className='gh-portal-detail-header'> + <BackButton brandColor={brandColor} onClick={() => { + onAction('switchPage', {page: 'emailSuppressed', lastPage: 'accountHome'}); + }} /> + <CloseButton /> + </header> + } <div className="gh-longform"> <h3>{t('Why has my email been disabled?')}</h3> @@ -29,7 +32,7 @@ export default function EmailSuppressedPage() { <p>{t('When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.')}</p> <p>{t('In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.')}</p> <p>{t('If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.')}</p> - <p><a className='gh-portal-btn gh-portal-btn-branded' href={supportAddress} onClick={() => { + <p><a className='gh-portal-btn gh-portal-btn-branded no-margin-right' href={supportAddress} onClick={() => { supportAddress && window.open(supportAddress); }}>{t('Need more help? Contact support')}</a></p> </div> diff --git a/apps/portal/src/tests/portal-links.test.js b/apps/portal/src/tests/portal-links.test.js index f3bdd93a91d..30c36f6b14e 100644 --- a/apps/portal/src/tests/portal-links.test.js +++ b/apps/portal/src/tests/portal-links.test.js @@ -215,4 +215,40 @@ describe('Portal Data links:', () => { expect(accountProfileTitle).toBeInTheDocument(); }); }); + + describe('#/portal/account/newsletter/help', () => { + test('opens portal newsletter receiving help page', async () => { + window.location.hash = '#/portal/account/newsletters/help'; + let { + popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: FixtureMember.free, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + const helpPageTitle = within(popupFrame.contentDocument).queryByText(/help! i'm not receiving emails/i); + expect(helpPageTitle).toBeInTheDocument(); + }); + }); + + describe('#/portal/account/newsletter/disabled', () => { + test('opens portal newsletter receiving help page', async () => { + window.location.hash = '#/portal/account/newsletters/disabled'; + let { + popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: FixtureMember.free, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + const helpPageTitle = within(popupFrame.contentDocument).queryByText(/why has my email been disabled/i); + expect(helpPageTitle).toBeInTheDocument(); + }); + }); }); diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index 64aa39e95da..a6186019527 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/signup-form", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "repository": { "type": "git", @@ -39,7 +39,7 @@ "react-dom": "18.3.1" }, "devDependencies": { - "@playwright/test": "1.38.1", + "@playwright/test": "1.46.1", "@storybook/addon-essentials": "7.6.20", "@storybook/addon-interactions": "7.6.20", "@storybook/addon-links": "7.6.20", @@ -65,7 +65,7 @@ "rollup-plugin-node-builtins": "2.1.2", "storybook": "7.6.20", "stylelint": "15.10.3", - "tailwindcss": "3.4.10", + "tailwindcss": "3.4.12", "vite": "4.5.3", "vite-plugin-commonjs": "0.10.1", "vite-plugin-svgr": "3.3.0", diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index 0af5771c24c..239c7ca41ad 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -17,6 +17,11 @@ {{#unless this.session.user.isContributor}} <div class="gh-nav-top"> <ul class="gh-nav-list gh-nav-main"> + {{#if (feature "ActivityPub")}} + <li> + <LinkTo @route="activitypub-x" @current-when="activitypub-x">{{svg-jar "star"}}ActivityPub</LinkTo> + </li> + {{/if}} {{#if (gh-user-can-admin this.session.user)}} <li class="relative gh-nav-list-home"> <LinkTo @route="dashboard" @alt="Dashboard" title="Dashboard" data-test-nav="dashboard">{{svg-jar "house"}} Dashboard</LinkTo> @@ -34,7 +39,7 @@ </li> {{#if (and (gh-user-can-admin this.session.user) this.config.stats)}} <li class="relative"> - <LinkTo @route="stats">{{svg-jar "stats"}}Stats</LinkTo> + <LinkTo @route="stats">{{svg-jar "stats-outline"}}Stats</LinkTo> </li> {{/if}} {{#if (gh-user-can-admin this.session.user)}} @@ -128,11 +133,6 @@ <LinkTo @route="demo-x" @current-when="demo-x">{{svg-jar "star"}}AdminX Demo</LinkTo> </li> {{/if}} - {{#if (feature "ActivityPub")}} - <li> - <LinkTo @route="activitypub-x" @current-when="activitypub-x">{{svg-jar "star"}}ActivityPub Demo</LinkTo> - </li> - {{/if}} </ul> {{#if this.session.user.isOwnerOnly}} diff --git a/ghost/admin/app/components/modal-stats-all.hbs b/ghost/admin/app/components/modal-stats-all.hbs new file mode 100644 index 00000000000..d402e33de36 --- /dev/null +++ b/ghost/admin/app/components/modal-stats-all.hbs @@ -0,0 +1,23 @@ +<div class="modal-content" data-test-publish-flow="complete"> + <header class="modal-header"> + <h1> + {{this.modalTitle}} + </h1> + </header> + + <button type="button" class="close" title="Close" {{on "click" @close}} data-test-button="close-publish-flow">{{svg-jar "close"}}<span class="hidden">Close</span></button> + + <div {{react-render this.ReactComponent props=(hash chartRange=this.chartRange audience=this.audience type=this.type)}}></div> + + <footer class="modal-footer"> + <button + class="gh-btn gh-btn-primary dismiss" + type="button" + {{on "click" @close}} + {{on "mousedown" (optional this.noop)}} + > + <span>Close</span> + </button> + </footer> +</div> + diff --git a/ghost/admin/app/components/modal-stats-all.js b/ghost/admin/app/components/modal-stats-all.js new file mode 100644 index 00000000000..3a380fc2aea --- /dev/null +++ b/ghost/admin/app/components/modal-stats-all.js @@ -0,0 +1,116 @@ +'use client'; + +import Component from '@glimmer/component'; +import React from 'react'; +import moment from 'moment-timezone'; +import {BarList, useQuery} from '@tinybirdco/charts'; +import {barListColor} from '../utils/stats'; +import {formatNumber} from 'ghost-admin/helpers/format-number'; +import {getCountryFlag} from 'ghost-admin/utils/stats'; +import {inject} from 'ghost-admin/decorators/inject'; + +export default class AllStatsModal extends Component { + @inject config; + + get type() { + return this.args.data.type; + } + + get chartRange() { + return this.args.data.chartRange; + } + + get audience() { + return this.args.data.audience; + } + + get modalTitle() { + switch (this.type) { + case 'top-sources': + return 'Sources'; + case 'top-locations': + return 'Locations'; + default: + return 'Content'; + } + } + + ReactComponent = (props) => { + let chartRange = props.chartRange; + let audience = props.audience || []; + let type = props.type; + + const endDate = moment().endOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); + + /** + * @typedef {Object} Params + * @property {string} cid + * @property {string} [date_from] + * @property {string} [date_to] + * @property {string} [member_status] + * @property {number} [limit] + * @property {number} [skip] + */ + const params = { + site_uuid: this.config.stats.id, + date_from: startDate.format('YYYY-MM-DD'), + date_to: endDate.format('YYYY-MM-DD'), + member_status: audience.length === 0 ? null : audience.join(',') + }; + + let endpoint; + let labelText; + let indexBy; + let unknownOption = 'Unknown'; + switch (type) { + case 'top-sources': + endpoint = `${this.config.stats.endpoint}/v0/pipes/top_sources.json`; + labelText = 'Source'; + indexBy = 'referrer'; + unknownOption = 'Direct'; + break; + case 'top-locations': + endpoint = `${this.config.stats.endpoint}/v0/pipes/top_locations.json`; + labelText = 'Country'; + indexBy = 'location'; + unknownOption = 'Unknown'; + break; + default: + endpoint = `${this.config.stats.endpoint}/v0/pipes/top_pages.json`; + labelText = 'Post or page'; + indexBy = 'pathname'; + break; + } + + const {data, meta, error, loading} = useQuery({ + endpoint: endpoint, + token: this.config.stats.token, + params + }); + + return ( + <BarList + data={data} + meta={meta} + error={error} + loading={loading} + index={indexBy} + indexConfig={{ + label: <span className="gh-stats-detail-header">{labelText}</span>, + renderBarContent: ({label}) => ( + <span className={`gh-stats-detail-label ${type === 'top-sources' && 'gh-stats-domain'}`}>{(type === 'top-locations') && getCountryFlag(label)} {type === 'top-sources' && (<img src={`https://www.google.com/s2/favicons?domain=${label || 'direct'}&sz=32`} className="gh-stats-favicon" />)} {label || unknownOption}</span> + ) + }} + categories={['hits']} + categoryConfig={{ + hits: { + label: <span className="gh-stats-detail-header">Visits</span>, + renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span> + } + }} + colorPalette={[barListColor]} + /> + ); + }; +} diff --git a/ghost/admin/app/components/stats/charts/kpis.hbs b/ghost/admin/app/components/stats/charts/kpis.hbs index 4b93ed6b223..1e6d79e08c8 100644 --- a/ghost/admin/app/components/stats/charts/kpis.hbs +++ b/ghost/admin/app/components/stats/charts/kpis.hbs @@ -1 +1 @@ -<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience selected=@selected)}}></div> \ No newline at end of file +<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div> \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/kpis.js b/ghost/admin/app/components/stats/charts/kpis.js index 2e8a7da5104..6db1cab3b62 100644 --- a/ghost/admin/app/components/stats/charts/kpis.js +++ b/ghost/admin/app/components/stats/charts/kpis.js @@ -4,19 +4,20 @@ import Component from '@glimmer/component'; import React from 'react'; import moment from 'moment-timezone'; import {AreaChart, useQuery} from '@tinybirdco/charts'; +import {formatNumber} from '../../../helpers/format-number'; +import {hexToRgba} from 'ghost-admin/utils/stats'; import {inject} from 'ghost-admin/decorators/inject'; +import {statsStaticColors} from '../../../utils/stats'; export default class KpisComponent extends Component { @inject config; ReactComponent = (props) => { - let chartDays = props.chartDays; + let chartRange = props.chartRange; let audience = props.audience; - // @TODO: ATM there's a two day worth gap (padding) on the right side - // of the chart. endDate needs to be adjusted to get rid of it const endDate = moment().endOf('day'); - const startDate = moment().subtract(chartDays - 1, 'days').startOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); /** * @typedef {Object} Params @@ -35,9 +36,9 @@ export default class KpisComponent extends Component { member_status: audience.length === 0 ? null : audience.join(',') }; - const LINE_COLOR = '#8E42FF'; + const LINE_COLOR = statsStaticColors[0]; const INDEX = 'date'; - const CATEGORY = props.selected; + const CATEGORY = props.selected === 'unique_visits' ? 'visits' : props.selected; const {data, meta, error, loading} = useQuery({ endpoint: `${this.config.stats.endpoint}/v0/pipes/kpis.json`, @@ -45,6 +46,46 @@ export default class KpisComponent extends Component { params }); + // Create an array with every second date value + const dateLabels = [ + startDate.format('YYYY-MM-DD'), + endDate.format('YYYY-MM-DD') + ]; + // let currentDate = startDate.clone(); + // let skipDays; + // switch (chartRange) { + // case 1: + // skipDays = 0; // Show all hours for 1 day + // break; + // case 7: + // skipDays = 0; // Skip every other day for 7 days + // break; + // case (30 + 1): + // skipDays = 2; // Skip every 3rd day for 30 and 90 days + // break; + // case (90 + 1): + // skipDays = 5; // Skip every 3rd day for 30 and 90 days + // break; + // case (365 + 1): + // case (12 * (30 + 1)): + // skipDays = 30; // Skip every 7th day for 1 year + // break; + // case 1000: + // skipDays = 29; // Skip every 30th day for all time + // break; + // default: + // skipDays = 1; // Default to skipping every other day + // } + + // let dayCounter = 0; + // while (currentDate.isSameOrBefore(endDate)) { + // if (dayCounter % (skipDays + 1) === 0) { + // dateLabels.push(currentDate.format('YYYY-MM-DD')); + // } + // currentDate.add(1, 'days'); + // dayCounter = dayCounter + 1; + // } + return ( <AreaChart data={data} @@ -53,7 +94,7 @@ export default class KpisComponent extends Component { error={error} index={INDEX} categories={[CATEGORY]} - colorPalette={[LINE_COLOR, '#008060', '#0EB1B9', '#9263AF', '#5A6FC0']} + colorPalette={[LINE_COLOR]} backgroundColor="transparent" fontSize="13px" textColor="#AEB7C1" @@ -61,27 +102,30 @@ export default class KpisComponent extends Component { params={params} options={{ grid: { - left: '0%', - right: '0%', + left: '10px', + right: '10px', top: '10%', bottom: 0, containLabel: true }, xAxis: { type: 'time', - // min: startDate.toISOString(), - // max: endDate.toISOString(), - boundaryGap: ['0%', '0.5%'], + min: startDate.toISOString(), + max: endDate.subtract(1, 'day').toISOString(), + boundaryGap: ['0%', '0%'], axisLabel: { - formatter: chartDays <= 7 ? '{ee}' : '{dd} {MMM}' + formatter: chartRange <= 7 ? '{ee}' : '{d} {MMM}', + customValues: dateLabels }, axisTick: { - alignWithLabel: true + show: false, + alignWithLabel: true, + interval: 0 }, axisPointer: { snap: true }, - splitNumber: chartDays <= 7 ? 7 : 5, + splitNumber: dateLabels.length, splitLine: { show: false }, @@ -92,12 +136,19 @@ export default class KpisComponent extends Component { } }, yAxis: { + type: 'value', splitLine: { - show: true, + show: false, lineStyle: { type: 'dashed', color: '#DDE1E5' // Adjust color as needed } + }, + axisLabel: { + show: true + }, + axisTick: { + show: false } }, tooltip: { @@ -112,7 +163,26 @@ export default class KpisComponent extends Component { }, extraCssText: 'box-shadow: 0px 100px 80px 0px rgba(0, 0, 0, 0.07), 0px 41.778px 33.422px 0px rgba(0, 0, 0, 0.05), 0px 22.336px 17.869px 0px rgba(0, 0, 0, 0.04), 0px 12.522px 10.017px 0px rgba(0, 0, 0, 0.04), 0px 6.65px 5.32px 0px rgba(0, 0, 0, 0.03), 0px 2.767px 2.214px 0px rgba(0, 0, 0, 0.02);', formatter: function (fparams) { - return `<div><div>${moment(fparams[0].value[0]).format('DD MMM, YYYY')}</div><div><span style="display: inline-block; margin-right: 16px; font-weight: 600;">Pageviews</span> ${fparams[0].value[1]}</div></div>`; + let displayValue; + let tooltipTitle; + switch (CATEGORY) { + case 'avg_session_sec': + tooltipTitle = 'Visit duration'; + displayValue = fparams[0].value[1] !== null && (fparams[0].value[1] / 60).toFixed(0) + ' min'; + break; + case 'bounce_rate': + tooltipTitle = 'Bounce rate'; + displayValue = fparams[0].value[1] !== null && fparams[0].value[1].toFixed(2) + '%'; + break; + default: + tooltipTitle = 'Unique visits'; + displayValue = fparams[0].value[1] !== null && formatNumber(fparams[0].value[1]); + break; + } + if (!displayValue) { + displayValue = 'N/A'; + } + return `<div><div>${moment(fparams[0].value[0]).format('D MMM, YYYY')}</div><div><span style="display: inline-block; margin-right: 16px; font-weight: 600;">${tooltipTitle}</span> ${displayValue}</div></div>`; } }, series: [ @@ -123,7 +193,6 @@ export default class KpisComponent extends Component { type: 'line', areaStyle: { opacity: 0.6, - // color: 'rgba(198, 220, 255, 1)' color: { type: 'linear', x: 0, @@ -131,11 +200,11 @@ export default class KpisComponent extends Component { x2: 0, y2: 1, colorStops: [{ - offset: 0, color: 'rgba(142, 66, 255, 0.3)' // color at 0% + offset: 0, color: hexToRgba(LINE_COLOR, 0.15) }, { - offset: 1, color: 'rgba(142, 66, 255, 0.0)' // color at 100% + offset: 1, color: hexToRgba(LINE_COLOR, 0.0) }], - global: false // default is false + global: false } }, lineStyle: { @@ -153,10 +222,11 @@ export default class KpisComponent extends Component { symbolSize: 10, z: 8, smooth: false, - name: props.selected, + smoothMonotone: 'x', + name: CATEGORY, data: (data ?? []).map(row => [ String(row[INDEX]), - row[props.selected] + row[CATEGORY] ]) } ] diff --git a/ghost/admin/app/components/stats/charts/technical.hbs b/ghost/admin/app/components/stats/charts/technical.hbs index 4b93ed6b223..0f06e37bbfa 100644 --- a/ghost/admin/app/components/stats/charts/technical.hbs +++ b/ghost/admin/app/components/stats/charts/technical.hbs @@ -1 +1 @@ -<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience selected=@selected)}}></div> \ No newline at end of file +<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div> diff --git a/ghost/admin/app/components/stats/charts/technical.js b/ghost/admin/app/components/stats/charts/technical.js index e5129fb3014..b0928d8ba15 100644 --- a/ghost/admin/app/components/stats/charts/technical.js +++ b/ghost/admin/app/components/stats/charts/technical.js @@ -4,16 +4,20 @@ import Component from '@glimmer/component'; import React from 'react'; import moment from 'moment-timezone'; import {DonutChart, useQuery} from '@tinybirdco/charts'; +import {formatNumber} from '../../../helpers/format-number'; import {inject} from 'ghost-admin/decorators/inject'; +import {statsStaticColors} from 'ghost-admin/utils/stats'; export default class KpisComponent extends Component { @inject config; ReactComponent = (props) => { - let chartDays = props.chartDays; + let chartRange = props.chartRange; let audience = props.audience; const endDate = moment().endOf('day'); - const startDate = moment().subtract(chartDays - 1, 'days').startOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); + + const colorPalette = statsStaticColors.slice(1, 5); /** * @typedef {Object} Params @@ -48,8 +52,6 @@ export default class KpisComponent extends Component { params }); - const colorPalette = ['#B78AFB', '#7FDE8A', '#FBCE75', '#F97DB7', '#6ED0FB']; - let transformedData; let indexBy; let tableHead; @@ -59,7 +61,7 @@ export default class KpisComponent extends Component { transformedData = (data ?? []).map((item, index) => ({ name: item.browser.charAt(0).toUpperCase() + item.browser.slice(1), value: item.hits, - color: colorPalette[index % colorPalette.length] + color: colorPalette[index] })); indexBy = 'browser'; tableHead = 'Browser'; @@ -68,7 +70,7 @@ export default class KpisComponent extends Component { transformedData = (data ?? []).map((item, index) => ({ name: item.device.charAt(0).toUpperCase() + item.device.slice(1), value: item.hits, - color: colorPalette[index % colorPalette.length] + color: colorPalette[index] })); indexBy = 'device'; tableHead = 'Device'; @@ -79,8 +81,8 @@ export default class KpisComponent extends Component { <table> <thead> <tr> - <th>{tableHead}</th> - <th>Hits</th> + <th><span className="gh-stats-detail-header">{tableHead}</span></th> + <th><span className="gh-stats-detail-header">Visits</span></th> </tr> </thead> <tbody> @@ -90,7 +92,7 @@ export default class KpisComponent extends Component { <span style={{backgroundColor: item.color, display: 'inline-block', width: '10px', height: '10px', marginRight: '5px', borderRadius: '2px'}}></span> {item.name} </td> - <td>{item.value}</td> + <td>{formatNumber(item.value)}</td> </tr> ))} </tbody> @@ -121,7 +123,7 @@ export default class KpisComponent extends Component { }, extraCssText: 'border: none !important; box-shadow: 0px 100px 80px 0px rgba(0, 0, 0, 0.07), 0px 41.778px 33.422px 0px rgba(0, 0, 0, 0.05), 0px 22.336px 17.869px 0px rgba(0, 0, 0, 0.04), 0px 12.522px 10.017px 0px rgba(0, 0, 0, 0.04), 0px 6.65px 5.32px 0px rgba(0, 0, 0, 0.03), 0px 2.767px 2.214px 0px rgba(0, 0, 0, 0.02);', formatter: function (fparams) { - return `<span style="background-color: ${fparams.color}; display: inline-block; width: 10px; height: 10px; margin-right: 5px; border-radius: 2px;"></span> ${fparams.name}: ${fparams.value}`; + return `<span style="background-color: ${fparams.color}; display: inline-block; width: 10px; height: 10px; margin-right: 5px; border-radius: 2px;"></span> ${fparams.name}: ${formatNumber(fparams.value)}`; } }, legend: { diff --git a/ghost/admin/app/components/stats/charts/top-locations.hbs b/ghost/admin/app/components/stats/charts/top-locations.hbs index 8f87e39f03c..73361d6ad19 100644 --- a/ghost/admin/app/components/stats/charts/top-locations.hbs +++ b/ghost/admin/app/components/stats/charts/top-locations.hbs @@ -1 +1,10 @@ -<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div> \ No newline at end of file +<div> + <div class="gh-stats-metric-header"><h5 class="gh-stats-metric-label">Locations</h5></div> + <div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div> +</div> + +<div class="gh-stats-see-all-container"> + <button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}> + <span>See all →</span> + </button> +</div> \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-locations.js b/ghost/admin/app/components/stats/charts/top-locations.js index 9157f690e1f..78c47c96717 100644 --- a/ghost/admin/app/components/stats/charts/top-locations.js +++ b/ghost/admin/app/components/stats/charts/top-locations.js @@ -1,18 +1,36 @@ +'use client'; + +import AllStatsModal from '../../modal-stats-all'; import Component from '@glimmer/component'; import React from 'react'; import moment from 'moment-timezone'; import {BarList, useQuery} from '@tinybirdco/charts'; +import {action} from '@ember/object'; +import {barListColor} from '../../../utils/stats'; +import {formatNumber} from '../../../helpers/format-number'; +import {getCountryFlag} from 'ghost-admin/utils/stats'; import {inject} from 'ghost-admin/decorators/inject'; +import {inject as service} from '@ember/service'; export default class TopLocations extends Component { @inject config; + @service modals; + + @action + openSeeAll() { + this.modals.open(AllStatsModal, { + type: 'top-locations', + chartRange: this.args.chartRange, + audience: this.args.audience + }); + } ReactComponent = (props) => { - let chartDays = props.chartDays; + let chartRange = props.chartRange; let audience = props.audience; const endDate = moment().endOf('day'); - const startDate = moment().subtract(chartDays - 1, 'days').startOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); /** * @typedef {Object} Params @@ -28,7 +46,7 @@ export default class TopLocations extends Component { date_from: startDate.format('YYYY-MM-DD'), date_to: endDate.format('YYYY-MM-DD'), member_status: audience.length === 0 ? null : audience.join(','), - limit: 6 + limit: 7 }; const {data, meta, error, loading} = useQuery({ @@ -44,8 +62,20 @@ export default class TopLocations extends Component { error={error} loading={loading} index="location" + indexConfig={{ + label: <span className="gh-stats-detail-header">Country</span>, + renderBarContent: ({label}) => ( + <span className="gh-stats-detail-label">{getCountryFlag(label)} {label || 'Unknown'}</span> + ) + }} categories={['hits']} - colorPalette={['#E8D9FF']} + categoryConfig={{ + hits: { + label: <span className="gh-stats-detail-header">Visits</span>, + renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span> + } + }} + colorPalette={[barListColor]} /> ); }; diff --git a/ghost/admin/app/components/stats/charts/top-pages.hbs b/ghost/admin/app/components/stats/charts/top-pages.hbs index 8f87e39f03c..cdf80962508 100644 --- a/ghost/admin/app/components/stats/charts/top-pages.hbs +++ b/ghost/admin/app/components/stats/charts/top-pages.hbs @@ -1 +1,28 @@ -<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div> \ No newline at end of file +<div> + <div class="gh-stats-metric-header"> + <h5 class="gh-stats-metric-label">Content</h5> + <div> + <PowerSelect + @selected={{this.contentOption}} + @options={{this.contentOptions}} + @searchEnabled={{false}} + @onChange={{this.onContentOptionChange}} + @triggerComponent={{component "gh-power-select/trigger"}} + @triggerClass="gh-btn gh-stats-section-dropdown" + @dropdownClass="gh-contentfilter-menu-dropdown is-narrow" + @matchTriggerWidth={{false}} + @horizontalPosition="right" + as |option| + > + {{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}} + </PowerSelect> + </div> + </div> + <div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div> +</div> + +<div class="gh-stats-see-all-container"> + <button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}> + <span>See all →</span> + </button> +</div> \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-pages.js b/ghost/admin/app/components/stats/charts/top-pages.js index 8529a419c17..227fa6fe97a 100644 --- a/ghost/admin/app/components/stats/charts/top-pages.js +++ b/ghost/admin/app/components/stats/charts/top-pages.js @@ -1,20 +1,46 @@ 'use client'; +import AllStatsModal from '../../modal-stats-all'; import Component from '@glimmer/component'; import React from 'react'; import moment from 'moment-timezone'; import {BarList, useQuery} from '@tinybirdco/charts'; +import {CONTENT_OPTIONS} from 'ghost-admin/utils/stats'; +import {action} from '@ember/object'; +import {barListColor} from '../../../utils/stats'; +import {formatNumber} from '../../../helpers/format-number'; import {inject} from 'ghost-admin/decorators/inject'; +import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; export default class TopPages extends Component { @inject config; + @tracked contentOption = CONTENT_OPTIONS[0]; + @tracked contentOptions = CONTENT_OPTIONS; + + @service modals; + + @action + openSeeAll(chartRange, audience) { + this.modals.open(AllStatsModal, { + type: 'top-pages', + chartRange, + audience + }); + } + + @action + onContentOptionChange(selected) { + this.contentOption = selected; + } + ReactComponent = (props) => { - let chartDays = props.chartDays; + let chartRange = props.chartRange; let audience = props.audience; const endDate = moment().endOf('day'); - const startDate = moment().subtract(chartDays - 1, 'days').startOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); /** * @typedef {Object} Params @@ -30,7 +56,7 @@ export default class TopPages extends Component { date_from: startDate.format('YYYY-MM-DD'), date_to: endDate.format('YYYY-MM-DD'), member_status: audience.length === 0 ? null : audience.join(','), - limit: 6 + limit: 7 }; const {data, meta, error, loading} = useQuery({ @@ -46,9 +72,20 @@ export default class TopPages extends Component { error={error} loading={loading} index="pathname" + indexConfig={{ + label: <span className="gh-stats-detail-header">Post or page</span>, + renderBarContent: ({label}) => ( + <span className="gh-stats-detail-label">{label}</span> + ) + }} categories={['hits']} - colorPalette={['#E8D9FF']} - height="300px" + categoryConfig={{ + hits: { + label: <span className="gh-stats-detail-header">Visits</span>, + renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span> + } + }} + colorPalette={[barListColor]} /> ); }; diff --git a/ghost/admin/app/components/stats/charts/top-sources.hbs b/ghost/admin/app/components/stats/charts/top-sources.hbs index 8f87e39f03c..25fe33b9652 100644 --- a/ghost/admin/app/components/stats/charts/top-sources.hbs +++ b/ghost/admin/app/components/stats/charts/top-sources.hbs @@ -1 +1,29 @@ -<div {{react-render this.ReactComponent props=(hash chartDays=@chartDays audience=@audience)}}></div> \ No newline at end of file +<div> + <div class="gh-stats-metric-header"> + <h5 class="gh-stats-metric-label">Sources</h5> + <div> + <PowerSelect + @selected={{this.campaignOption}} + @options={{this.campaignOptions}} + @searchEnabled={{false}} + @onChange={{this.onCampaignOptionChange}} + @triggerComponent={{component "gh-power-select/trigger"}} + @triggerClass="gh-btn gh-stats-section-dropdown" + @dropdownClass="gh-contentfilter-menu-dropdown is-narrow" + @matchTriggerWidth={{false}} + @horizontalPosition="right" + as |option| + > + {{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}} + </PowerSelect> + </div> + </div> + + <div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div> +</div> + +<div class="gh-stats-see-all-container"> + <button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}> + <span>See all →</span> + </button> +</div> \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-sources.js b/ghost/admin/app/components/stats/charts/top-sources.js index 635d75806e7..e986af12983 100644 --- a/ghost/admin/app/components/stats/charts/top-sources.js +++ b/ghost/admin/app/components/stats/charts/top-sources.js @@ -1,20 +1,45 @@ 'use client'; +import AllStatsModal from '../../modal-stats-all'; import Component from '@glimmer/component'; import React from 'react'; import moment from 'moment-timezone'; import {BarList, useQuery} from '@tinybirdco/charts'; +import {CAMPAIGN_OPTIONS} from 'ghost-admin/utils/stats'; +import {action} from '@ember/object'; +import {barListColor} from '../../../utils/stats'; +import {formatNumber} from '../../../helpers/format-number'; import {inject} from 'ghost-admin/decorators/inject'; +import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; export default class TopPages extends Component { @inject config; + @service modals; + + @tracked campaignOption = CAMPAIGN_OPTIONS[0]; + @tracked campaignOptions = CAMPAIGN_OPTIONS; + + @action + onCampaignOptionChange(selected) { + this.campaignOption = selected; + } + + @action + openSeeAll() { + this.modals.open(AllStatsModal, { + type: 'top-sources', + chartRange: this.args.chartRange, + audience: this.args.audience + }); + } ReactComponent = (props) => { - let chartDays = props.chartDays; + let chartRange = props.chartRange; let audience = props.audience; const endDate = moment().endOf('day'); - const startDate = moment().subtract(chartDays - 1, 'days').startOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); /** * @typedef {Object} Params @@ -29,14 +54,14 @@ export default class TopPages extends Component { site_uuid: this.config.stats.id, date_from: startDate.format('YYYY-MM-DD'), date_to: endDate.format('YYYY-MM-DD'), - member_status: audience.length === 0 ? null : audience.join(',') + member_status: audience.length === 0 ? null : audience.join(','), + limit: 7 }; const {data, meta, error, loading} = useQuery({ endpoint: `${this.config.stats.endpoint}/v0/pipes/top_sources.json`, token: this.config.stats.token, - params, - limit: 6 + params }); return ( @@ -46,9 +71,20 @@ export default class TopPages extends Component { error={error} loading={loading} index="referrer" + indexConfig={{ + label: <span className="gh-stats-detail-header">Source</span>, + renderBarContent: ({label}) => ( + <span className="gh-stats-detail-label"><span className="gh-stats-domain"><img src={`https://www.google.com/s2/favicons?domain=${label || 'direct'}&sz=32`} className="gh-stats-favicon" />{label || 'Direct'}</span></span> + ) + }} categories={['hits']} - colorPalette={['#E8D9FF']} - height="300px" + categoryConfig={{ + hits: { + label: <span className="gh-stats-detail-header">Visits</span>, + renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span> + } + }} + colorPalette={[barListColor]} /> ); }; diff --git a/ghost/admin/app/components/stats/kpis-overview.hbs b/ghost/admin/app/components/stats/kpis-overview.hbs index 71f65874649..6e3e3e50399 100644 --- a/ghost/admin/app/components/stats/kpis-overview.hbs +++ b/ghost/admin/app/components/stats/kpis-overview.hbs @@ -1,26 +1,49 @@ -<div class="gh-stats-tabs"> - <button type="button" class="gh-stats-tab min-width {{if this.visitsTabSelected 'is-selected'}}" {{on "click" this.changeTabToVisits}}> - <Stats::Parts::Metric - @label="Unique visitors" - @value={{this.totals.visits}} /> - </button> - <button type="button" class="gh-stats-tab min-width {{if this.pageviewsTabSelected 'is-selected'}}" {{on "click" this.changeTabToPageviews}}> - <Stats::Parts::Metric - @label="Site Pageviews" - @value={{this.totals.pageviews}} /> - </button> +<div class="gh-stats-tabs-header" {{did-update this.fetchDataIfNeeded @chartRange @audience}}> + <div class="gh-stats-tabs"> + <button type="button" class="gh-stats-tab min-width {{if this.uniqueVisitsTabSelected 'is-selected'}}" {{on "click" this.changeTabToUniqueVisits}}> + <Stats::Parts::Metric + @label="Unique visits" + @value={{this.totals.visits}} /> + </button> - <button type="button" class="gh-stats-tab min-width {{if this.avgVisitTimeTabSelected 'is-selected'}}" {{on "click" this.changeTabToAvgVisitTime}}> - <Stats::Parts::Metric - @label="Avg Visit Time" - @value="{{this.totals.avg_session_sec}}m" /> - </button> + <button type="button" class="gh-stats-tab min-width {{if this.pageviewsTabSelected 'is-selected'}}" {{on "click" this.changeTabToPageviews}}> + <Stats::Parts::Metric + @label="Pageviews" + @value={{this.totals.pageviews}} /> + </button> - <button type="button" class="gh-stats-tab min-width {{if this.bounceRateTabSelected 'is-selected'}}" {{on "click" this.changeTabToBounceRate}}> - <Stats::Parts::Metric - @label="Bounce Rate" - @value="{{this.totals.bounce_rate}}%" /> - </button> + <button type="button" class="gh-stats-tab min-width {{if this.bounceRateTabSelected 'is-selected'}}" {{on "click" this.changeTabToBounceRate}}> + <Stats::Parts::Metric + @label="Bounce rate" + @value="{{this.totals.bounce_rate}}%" /> + </button> + + <button type="button" class="gh-stats-tab min-width {{if this.avgVisitTimeTabSelected 'is-selected'}}" {{on "click" this.changeTabToAvgVisitTime}}> + <Stats::Parts::Metric + @label="Visit duration" + @value="{{this.totals.avg_session_sec}}m" /> + </button> + </div> + <div class="gh-stats-kpi-granularity"> + {{#if this.showGranularity}} + <PowerSelect + @selected={{this.granularity}} + @options={{this.granularityOptions}} + @searchEnabled={{false}} + @onChange={{this.onGranularityChange}} + @triggerComponent={{component "gh-power-select/trigger"}} + @triggerClass="gh-btn gh-stats-section-dropdown" + @dropdownClass="gh-contentfilter-menu-dropdown is-narrow" + @matchTriggerWidth={{false}} + @horizontalPosition="right" + as |option| + > + {{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}} + </PowerSelect> + {{/if}} + </div> +</div> +<div class="gh-stats-kpis-chart-container"> + <Stats::Charts::Kpis @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} /> </div> -<Stats::Charts::Kpis @chartDays={{@chartDays}} @audience={{@audience}} @selected={{this.selected}} /> \ No newline at end of file diff --git a/ghost/admin/app/components/stats/kpis-overview.js b/ghost/admin/app/components/stats/kpis-overview.js index 96794d2026d..da9128bf2f4 100644 --- a/ghost/admin/app/components/stats/kpis-overview.js +++ b/ghost/admin/app/components/stats/kpis-overview.js @@ -1,32 +1,73 @@ import Component from '@glimmer/component'; import fetch from 'fetch'; +import moment from 'moment-timezone'; import {action} from '@ember/object'; +import {formatNumber} from 'ghost-admin/helpers/format-number'; import {inject} from 'ghost-admin/decorators/inject'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; export default class KpisOverview extends Component { @inject config; - @tracked selected = 'visits'; + @tracked selected = 'unique_visits'; @tracked totals = null; + @tracked showGranularity = true; + + get granularityOptions() { + const chartRange = this.args.chartRange; + if (chartRange >= 8 && chartRange <= 30) { + return [ + {name: 'Days', value: 'days'}, + {name: 'Weeks', value: 'weeks'} + ]; + } else if (chartRange > 30 && chartRange <= 365) { + return [ + {name: 'Days', value: 'days'}, + {name: 'Weeks', value: 'weeks'}, + {name: 'Months', value: 'months'} + ]; + } else { + return [ + {name: 'Weeks', value: 'weeks'}, + {name: 'Months', value: 'months'} + ]; + } + } + + @tracked granularity = this.granularityOptions[0]; + + @action + onGranularityChange(selected) { + this.granularity = selected; + } constructor() { super(...arguments); - this.fetchData.perform(); + this.fetchDataIfNeeded(); } - setupFocusListener() { - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - this.fetchData.perform(); - } - }); + @action + fetchDataIfNeeded() { + this.fetchData.perform(this.args.chartRange, this.args.audience); } @task - *fetchData() { + *fetchData(chartRange, audience) { try { - const response = yield fetch(`${this.config.stats.endpoint}/v0/pipes/kpis.json?site_uuid=${this.config.stats.id}`, { + const endDate = moment().endOf('day'); + const startDate = moment().subtract(chartRange - 1, 'days').startOf('day'); + + const params = new URLSearchParams({ + site_uuid: this.config.stats.id, + date_from: startDate.format('YYYY-MM-DD'), + date_to: endDate.format('YYYY-MM-DD') + }); + + if (audience.length > 0) { + params.append('member_status', audience.join(',')); + } + + const response = yield fetch(`${this.config.stats.endpoint}/v0/pipes/kpis.json?${params}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -61,8 +102,8 @@ export default class KpisOverview extends Component { return { avg_session_sec: Math.floor(_ponderatedKPIsTotal('avg_session_sec') / 60), - pageviews: _KPITotal('pageviews'), - visits: totalVisits, + pageviews: formatNumber(_KPITotal('pageviews')), + visits: formatNumber(totalVisits), bounce_rate: _ponderatedKPIsTotal('bounce_rate').toFixed(2) }; } @@ -74,8 +115,8 @@ export default class KpisOverview extends Component { } @action - changeTabToVisits() { - this.selected = 'visits'; + changeTabToUniqueVisits() { + this.selected = 'unique_visits'; } @action @@ -93,8 +134,8 @@ export default class KpisOverview extends Component { this.selected = 'bounce_rate'; } - get visitsTabSelected() { - return (this.selected === 'visits'); + get uniqueVisitsTabSelected() { + return (this.selected === 'unique_visits'); } get pageviewsTabSelected() { diff --git a/ghost/admin/app/components/stats/parts/audience-filter.js b/ghost/admin/app/components/stats/parts/audience-filter.js index 8827e89b38e..eccd6c32c45 100644 --- a/ghost/admin/app/components/stats/parts/audience-filter.js +++ b/ghost/admin/app/components/stats/parts/audience-filter.js @@ -1,13 +1,8 @@ import Component from '@glimmer/component'; +import {AUDIENCE_TYPES} from 'ghost-admin/utils/stats'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; -export const AUDIENCE_TYPES = [ - {name: 'Logged out visitors', value: 'undefined'}, - {name: 'Free members', value: 'free'}, - {name: 'Paid members', value: 'paid'} -]; - function toggleAudienceType(audicenceType, audicenceTypes) { const excludedAudiences = new Set(audicenceTypes.filter(type => !type.isSelected).map(type => type.value)); if (excludedAudiences.has(audicenceType)) { diff --git a/ghost/admin/app/components/stats/technical-overview.hbs b/ghost/admin/app/components/stats/technical-overview.hbs index c5a908c07a6..304a73461eb 100644 --- a/ghost/admin/app/components/stats/technical-overview.hbs +++ b/ghost/admin/app/components/stats/technical-overview.hbs @@ -1,18 +1,22 @@ -<div class="gh-stats-tabs"> - <button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}> - <Stats::Parts::Metric - @label="Devices" /> - </button> +<div> + <div class="gh-stats-tabs-header"> + <div class="gh-stats-tabs"> + <button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}> + <Stats::Parts::Metric + @label="Devices" /> + </button> - <button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}> - <Stats::Parts::Metric - @label="Browsers" /> - </button> + <button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}> + <Stats::Parts::Metric + @label="Browsers" /> + </button> - {{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}> - <Stats::Parts::Metric - @label="Operating systems" /> - </button> --}} -</div> + {{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}> + <Stats::Parts::Metric + @label="Operating systems" /> + </button> --}} + </div> + </div> -<Stats::Charts::Technical @chartDays={{@chartDays}} @audience={{@audience}} @selected={{this.selected}} /> \ No newline at end of file + <Stats::Charts::Technical @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} /> +</div> \ No newline at end of file diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js index a57dff23362..7a6b2e30063 100644 --- a/ghost/admin/app/controllers/lexical-editor.js +++ b/ghost/admin/app/controllers/lexical-editor.js @@ -11,9 +11,10 @@ import boundOneWay from 'ghost-admin/utils/bound-one-way'; import classic from 'ember-classic-decorator'; import config from 'ghost-admin/config/environment'; import isNumber from 'ghost-admin/utils/isNumber'; +import microdiff from 'microdiff'; import moment from 'moment-timezone'; import {GENERIC_ERROR_MESSAGE} from '../services/notifications'; -import {action, computed} from '@ember/object'; +import {action, computed, get} from '@ember/object'; import {alias, mapBy} from '@ember/object/computed'; import {capitalizeFirstLetter} from '../helpers/capitalize-first-letter'; import {dropTask, enqueueTask, restartableTask, task, taskGroup, timeout} from 'ember-concurrency'; @@ -22,8 +23,9 @@ import {inject} from 'ghost-admin/decorators/inject'; import {isBlank} from '@ember/utils'; import {isArray as isEmberArray} from '@ember/array'; import {isHostLimitError, isServerUnreachableError, isVersionMismatchError} from 'ghost-admin/services/ajax'; -import {isInvalidError} from 'ember-ajax/errors'; +import {isInvalidError, isNotFoundError} from 'ember-ajax/errors'; import {mobiledocToLexical} from '@tryghost/kg-converters'; +import {observes} from '@ember-decorators/object'; import {inject as service} from '@ember/service'; import {slugify} from '@tryghost/string'; import {tracked} from '@glimmer/tracking'; @@ -157,6 +159,7 @@ export default class LexicalEditorController extends Controller { @service session; @service settings; @service ui; + @service localRevisions; @inject config; @@ -183,6 +186,28 @@ export default class LexicalEditorController extends Controller { _saveOnLeavePerformed = false; _previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes + /* debug properties ------------------------------------------------------*/ + + _setPostState = null; + _postStates = []; + + // eslint-disable-next-line ghost/ember/no-observers + @observes('post.currentState.stateName') + _pushPostState() { + const post = this.post; + + if (!post) { + return; + } + + const {stateName, isDeleted, isDirty, isEmpty, isLoading, isLoaded, isNew, isSaving, isValid} = post.currentState; + if (stateName) { + const postState = [stateName, {isDeleted, isDirty, isEmpty, isLoading, isLoaded, isNew, isSaving, isValid}]; + console.log('post state changed:', ...postState); // eslint-disable-line no-console + this._postStates.push(postState); + } + } + /* computed properties ---------------------------------------------------*/ @alias('model') @@ -260,7 +285,7 @@ export default class LexicalEditorController extends Controller { @computed('post.isDraft') get _canAutosave() { - return config.environment !== 'test' && this.get('post.isDraft'); + return this.post.isDraft; } TK_REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u); @@ -289,7 +314,17 @@ export default class LexicalEditorController extends Controller { @action updateScratch(lexical) { - this.set('post.lexicalScratch', JSON.stringify(lexical)); + const lexicalString = JSON.stringify(lexical); + this.set('post.lexicalScratch', lexicalString); + + try { + // schedule a local revision save + if (this.post.status === 'draft') { + this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), lexical: lexicalString}); + } + } catch (err) { + // ignore errors + } // save 3 seconds after last edit this._autosaveTask.perform(); @@ -305,6 +340,14 @@ export default class LexicalEditorController extends Controller { @action updateTitleScratch(title) { this.set('post.titleScratch', title); + try { + // schedule a local revision save + if (this.post.status === 'draft') { + this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), title: title}); + } + } catch (err) { + // ignore errors + } } @action @@ -652,8 +695,27 @@ export default class LexicalEditorController extends Controller { return; } - // re-throw if we have a general server error + // This shouldn't occur but we have a bug where a new post can get + // into a bad state where it's not saved but the store is treating + // it as saved and performing PUT requests with no id. We want to + // be noisy about this early to avoid data loss + if (isNotFoundError(error) && !this.post.id) { + const notFoundContext = this._getNotFoundErrorContext(); + console.error('saveTask failed with 404', notFoundContext); // eslint-disable-line no-console + Sentry.captureException(error, {tags: {savePostTask: true}, extra: notFoundContext}); + this._showErrorAlert(prevStatus, this.post.status, 'Editor has crashed. Please copy your content and start a new post.'); + return; + } + if (isNotFoundError(error) && this.post.id) { + const type = this.post.isPage ? 'page' : 'post'; + Sentry.captureMessage(`Attempted to edit deleted ${type}`, {extra: {post_id: this.post.id}}); + this._showErrorAlert(prevStatus, this.post.status, `${capitalizeFirstLetter(type)} has been deleted in a different session. If you need to keep this content, copy it and paste into a new ${type}.`); + return; + } + if (error && !isInvalidError(error)) { + console.error(error); // eslint-disable-line no-console + Sentry.captureException(error, {tags: {savePostTask: true}}); this.send('error', error); return; } @@ -668,6 +730,14 @@ export default class LexicalEditorController extends Controller { } } + _getNotFoundErrorContext() { + return { + setPostState: this._setPostState, + currentPostState: this.post.currentState.stateName, + allPostStates: this._postStates + }; + } + @task *beforeSaveTask(options = {}) { if (this.post?.isDestroyed || this.post?.isDestroying) { @@ -683,21 +753,18 @@ export default class LexicalEditorController extends Controller { // } } - // TODO: There's no need for (at least) most of these scratch values. - // Refactor so we're setting model attributes directly - // Set the properties that are indirected - // Set lexical equal to what's in the editor but create a copy so that - // nested objects/arrays don't keep references which can mean that both - // scratch and lexical get updated simultaneously + // Set lexical equal to what's in the editor this.set('post.lexical', this.post.lexicalScratch || null); // Set a default title - if (!this.get('post.titleScratch').trim()) { + if (!this.post.titleScratch?.trim()) { this.set('post.titleScratch', DEFAULT_TITLE); } + // TODO: There's no need for most of these scratch values. + // Refactor so we're setting model attributes directly this.set('post.title', this.get('post.titleScratch')); this.set('post.customExcerpt', this.get('post.customExcerptScratch')); this.set('post.footerInjection', this.get('post.footerExcerptScratch')); @@ -1032,6 +1099,8 @@ export default class LexicalEditorController extends Controller { // reset everything ready for a new post this.reset(); + this._setPostState = post.currentState.stateName; + this.set('post', post); this.backgroundLoaderTask.perform(); @@ -1148,7 +1217,15 @@ export default class LexicalEditorController extends Controller { if (this.post) { Object.assign(this._leaveModalReason, {status: this.post.status}); } - Sentry.captureMessage('showing leave editor modal', {extra: this._leaveModalReason}); + + if (this._leaveModalReason.code === 'SCRATCH_DIVERGED_FROM_SECONDARY') { + this._assignLexicalDiffToLeaveModalReason(); + } + + // don't push full lexical state to Sentry, it's too large, gets filtered often and not useful + const sentryContext = {...this._leaveModalReason.context, diff: JSON.stringify(this._leaveModalReason.context?.diff), secondaryLexical: undefined, scratch: undefined, lexical: undefined}; + Sentry.captureMessage('showing leave editor modal', {extra: {...this._leaveModalReason, context: sentryContext}}); + console.log('showing leave editor modal', this._leaveModalReason); // eslint-disable-line const reallyLeave = await this.modals.open(ConfirmEditorLeaveModal); @@ -1193,6 +1270,9 @@ export default class LexicalEditorController extends Controller { this._leaveConfirmed = false; this._saveOnLeavePerformed = false; + this._setPostState = null; + this._postStates = []; + this.set('post', null); this.set('hasDirtyAttributes', false); this.set('shouldFocusTitle', false); @@ -1219,7 +1299,7 @@ export default class LexicalEditorController extends Controller { return this.autosaveTask.perform(); } - yield timeout(AUTOSAVE_TIMEOUT); + yield timeout(config.environment === 'test' ? 100 : AUTOSAVE_TIMEOUT); this.autosaveTask.perform(); }).restartable()) _autosaveTask; @@ -1239,6 +1319,46 @@ export default class LexicalEditorController extends Controller { /* Private methods -------------------------------------------------------*/ + _assignLexicalDiffToLeaveModalReason() { + try { + const parsedSecondary = JSON.parse(this.post.secondaryLexicalState || JSON.stringify({})); + const parsedScratch = JSON.parse(this.post.scratch || JSON.stringify({})); + + const diff = microdiff(parsedScratch, parsedSecondary, {cyclesFix: false}); + + // create a more useful path by showing the node types + diff.forEach((change) => { + if (change.path) { + // use path array to fill in node types from parsedScratch when path shows an index + let humanPath = []; + change.path.forEach((child, i) => { + if (typeof child === 'number') { + const partialPath = diff.path.slice(0, i + 1); + const node = get(parsedScratch, partialPath.join('.')); + if (node && node.type) { + humanPath.push(`${child}[${node.type}]`); + } else { + humanPath.push(child); + } + } else { + humanPath.push(child); + } + }); + change.path = humanPath.join('.'); + } + }); + + if (!this._leaveModalReason.context) { + this._leaveModalReason.context = {}; + } + + Object.assign(this._leaveModalReason.context, {diff}); + } catch (error) { + console.error(error); // eslint-disable-line + Sentry.captureException(error); + } + } + _hasDirtyAttributes() { let post = this.post; @@ -1248,7 +1368,11 @@ export default class LexicalEditorController extends Controller { // If the Adapter failed to save the post, isError will be true, and we should consider the post still dirty. if (post.get('isError')) { - this._leaveModalReason = {reason: 'isError', context: post.errors.messages}; + this._leaveModalReason = { + reason: 'isError', + code: 'POST_HAS_ERROR', + context: post.errors.messages + }; return true; } @@ -1257,13 +1381,21 @@ export default class LexicalEditorController extends Controller { let currentTags = (this._tagNames || []).join(', '); let previousTags = (this._previousTagNames || []).join(', '); if (currentTags !== previousTags) { - this._leaveModalReason = {reason: 'tags are different', context: {currentTags, previousTags}}; + this._leaveModalReason = { + reason: 'tags are different', + code: 'POST_TAGS_DIVERGED', + context: {currentTags, previousTags} + }; return true; } // Title scratch comparison if (post.titleScratch.trim() !== post.title.trim()) { - this._leaveModalReason = {reason: 'title is different', context: {current: post.title, scratch: post.titleScratch}}; + this._leaveModalReason = { + reason: 'title is different', + code: 'POST_TITLE_DIVERGED', + context: {current: post.title, scratch: post.titleScratch} + }; return true; } @@ -1280,15 +1412,26 @@ export default class LexicalEditorController extends Controller { scratchChildNodes.forEach(child => child.direction = null); secondaryLexicalChildNodes.forEach(child => child.direction = null); - // Compare initLexical with scratch - let isSecondaryDirty = secondaryLexical && scratch && JSON.stringify(secondaryLexicalChildNodes) !== JSON.stringify(scratchChildNodes); + // Determine if main editor (scratch) has diverged from secondary editor + // (i.e. manual changes have been made since opening the editor) + const isSecondaryDirty = secondaryLexical && scratch && JSON.stringify(secondaryLexicalChildNodes) !== JSON.stringify(scratchChildNodes); - // Compare lexical with scratch - let isLexicalDirty = lexical && scratch && JSON.stringify(lexicalChildNodes) !== JSON.stringify(scratchChildNodes); + // Determine if main editor (scratch) has diverged from saved lexical + // (i.e. changes have been made since last save) + const isLexicalDirty = lexical && scratch && JSON.stringify(lexicalChildNodes) !== JSON.stringify(scratchChildNodes); // If both comparisons are dirty, consider the post dirty if (isSecondaryDirty && isLexicalDirty) { - this._leaveModalReason = {reason: 'initLexical and lexical are different from scratch', context: {secondaryLexical, lexical, scratch}}; + this._leaveModalReason = { + reason: 'main editor content has diverged from both hidden editor and saved content', + code: 'SCRATCH_DIVERGED_FROM_SECONDARY', + context: { + secondaryLexical, + lexical, + scratch + } + }; + return true; } @@ -1297,7 +1440,11 @@ export default class LexicalEditorController extends Controller { if (post.get('isNew')) { let changedAttributes = Object.keys(post.changedAttributes() || {}); if (changedAttributes.length) { - this._leaveModalReason = {reason: 'post.changedAttributes.length > 0', context: post.changedAttributes()}; + this._leaveModalReason = { + reason: 'post.changedAttributes.length > 0', + code: 'NEW_POST_HAS_CHANGED_ATTRIBUTES', + context: post.changedAttributes() + }; } return changedAttributes.length ? true : false; } @@ -1306,7 +1453,11 @@ export default class LexicalEditorController extends Controller { // back on Ember Data's default dirty attribute checks let {hasDirtyAttributes} = post; if (hasDirtyAttributes) { - this._leaveModalReason = {reason: 'post.hasDirtyAttributes === true', context: post.changedAttributes()}; + this._leaveModalReason = { + reason: 'post.hasDirtyAttributes === true', + code: 'POST_HAS_DIRTY_ATTRIBUTES', + context: post.changedAttributes() + }; return true; } diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index 9279418d233..08b0b9d3f6a 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -209,10 +209,8 @@ export default class MembersController extends Controller { return uniqueColumns.splice(0, 2); // Maximum 2 columns } - /* Due to a limitation with NQL when multiple member filters are used in combination, we currently have a safeguard around member bulk deletion. - * Member bulk deletion is not permitted when: - * 1) Multiple newsletters exist, and 2 or more newsletter filters are in use - * 2) If any of the following Stripe filters are used, even once: + /* + * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: * - Billing period * - Stripe subscription status * - Paid start date @@ -220,19 +218,15 @@ export default class MembersController extends Controller { * - Subscription started on post/page * - Offers * - * See issue https://linear.app/tryghost/issue/ENG-1484 for more context + * For more context, see: + * - https://linear.app/tryghost/issue/ENG-1484 + * - https://linear.app/tryghost/issue/ENG-1466 */ get isBulkDeletePermitted() { if (!this.isFiltered) { return false; } - const newsletterFilters = this.filters.filter(f => f.group === 'Newsletters'); - - if (newsletterFilters && newsletterFilters.length >= 2) { - return false; - } - const stripeFilters = this.filters.filter(f => [ 'subscriptions.plan_interval', 'subscriptions.status', diff --git a/ghost/admin/app/controllers/stats.js b/ghost/admin/app/controllers/stats.js index b51aa2880ad..8a24429eaa6 100644 --- a/ghost/admin/app/controllers/stats.js +++ b/ghost/admin/app/controllers/stats.js @@ -1,29 +1,16 @@ import Controller from '@ember/controller'; -import {AUDIENCE_TYPES} from 'ghost-admin/components/stats/parts/audience-filter'; +import {AUDIENCE_TYPES, RANGE_OPTIONS} from 'ghost-admin/utils/stats'; import {action} from '@ember/object'; import {tracked} from '@glimmer/tracking'; -// Options 30 and 90 need an extra day to be able to distribute ticks/gridlines evenly -const DAYS_OPTIONS = [{ - name: '7 Days', - value: 7 -}, { - name: '30 Days', - value: 30 + 1 -}, { - name: '90 Days', - value: 90 + 1 -}]; - export default class StatsController extends Controller { - daysOptions = DAYS_OPTIONS; + rangeOptions = RANGE_OPTIONS; audienceOptions = AUDIENCE_TYPES; - /** * @type {number|'all'} - * Amount of days to load for member count and MRR related charts + * Date range to load for member count and MRR related charts */ - @tracked chartDays = 30 + 1; + @tracked chartRange = 30 + 1; /** * @type {array} * Filter by audience @@ -32,8 +19,8 @@ export default class StatsController extends Controller { @tracked excludedAudiences = ''; @action - onDaysChange(selected) { - this.chartDays = selected.value; + onRangeChange(selected) { + this.chartRange = selected.value; } @action @@ -50,7 +37,7 @@ export default class StatsController extends Controller { } } - get selectedDaysOption() { - return this.daysOptions.find(d => d.value === this.chartDays); + get selectedRangeOption() { + return this.rangeOptions.find(d => d.value === this.chartRange); } } diff --git a/ghost/admin/app/routes/application.js b/ghost/admin/app/routes/application.js index 49a3362a03c..58d4a537e85 100644 --- a/ghost/admin/app/routes/application.js +++ b/ghost/admin/app/routes/application.js @@ -106,7 +106,7 @@ export default Route.extend(ShortcutsRoute, { save: K, error(error, transition) { - // unauthoirized errors are already handled in the ajax service + // unauthorized errors are already handled in the ajax service if (isUnauthorizedError(error)) { return false; } @@ -114,22 +114,27 @@ export default Route.extend(ShortcutsRoute, { if (isNotFoundError(error)) { if (transition) { transition.abort(); - } - let routeInfo = transition.to; - let router = this.router; - let params = []; + let routeInfo = transition?.to; + let router = this.router; + let params = []; - for (let key of Object.keys(routeInfo.params)) { - params.push(routeInfo.params[key]); - } + if (routeInfo) { + for (let key of Object.keys(routeInfo.params)) { + params.push(routeInfo.params[key]); + } - let url = router.urlFor(routeInfo.name, ...params) - .replace(/^#\//, '') - .replace(/^\//, '') - .replace(/^ghost\//, ''); + let url = router.urlFor(routeInfo.name, ...params) + .replace(/^#\//, '') + .replace(/^\//, '') + .replace(/^ghost\//, ''); - return this.replaceWith('error404', url); + return this.replaceWith('error404', url); + } + } + + // when there's no transition we fall through to our generic error handler + // for network errors that will hit the isAjaxError branch below } if (isVersionMismatchError(error)) { @@ -208,7 +213,14 @@ export default Route.extend(ShortcutsRoute, { // - http://ember-concurrency.com/docs/cancelation 'TaskCancelation' ], - integrations: [] + integrations: [], + beforeBreadcrumb(breadcrumb) { + // ignore breadcrumbs for event tracking to reduce noise in error reports + if (breadcrumb.category === 'http' && breadcrumb.data?.url?.match(/\/e\.ghost\.org|plausible\.io/)) { + return null; + } + return breadcrumb; + } }; try { diff --git a/ghost/admin/app/routes/lexical-editor.js b/ghost/admin/app/routes/lexical-editor.js index 485455ab760..8ebd41dd6e7 100644 --- a/ghost/admin/app/routes/lexical-editor.js +++ b/ghost/admin/app/routes/lexical-editor.js @@ -17,7 +17,7 @@ export default AuthenticatedRoute.extend({ }, setupController(controller, model, transition) { - if (transition.from?.name === 'posts.analytics') { + if (transition.from?.name === 'posts.analytics' && transition.to?.name !== 'lexical-editor.new') { controller.fromAnalytics = true; } }, diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index fb8a9882596..1edabf225af 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -77,6 +77,7 @@ export default class FeatureService extends Service { @feature('ActivityPub') ActivityPub; @feature('editorExcerpt') editorExcerpt; @feature('contentVisibility') contentVisibility; + @feature('commentImprovements') commentImprovements; _user = null; diff --git a/ghost/admin/app/services/local-revisions.js b/ghost/admin/app/services/local-revisions.js new file mode 100644 index 00000000000..65088e8e944 --- /dev/null +++ b/ghost/admin/app/services/local-revisions.js @@ -0,0 +1,246 @@ +import Service, {inject as service} from '@ember/service'; +import config from 'ghost-admin/config/environment'; +import {task, timeout} from 'ember-concurrency'; + +/** + * Service to manage local post revisions in localStorage + */ +export default class LocalRevisionsService extends Service { + constructor() { + super(...arguments); + if (this.isTesting === undefined) { + this.isTesting = config.environment === 'test'; + } + this.MIN_REVISION_TIME = this.isTesting ? 50 : 60000; // 1 minute in ms + this.performSave = this.performSave.bind(this); + } + + @service store; + + // base key prefix to avoid collisions in localStorage + _prefix = 'post-revision'; + latestRevisionTime = null; + + // key to store a simple index of all revisions + _indexKey = 'ghost-revisions'; + + /** + * + * @param {object} data - serialized post data, must include id and revisionTimestamp + * @returns {string} - key to store the revision in localStorage + */ + generateKey(data) { + return `${this._prefix}-${data.id}-${data.revisionTimestamp}`; + } + + /** + * Performs the save operations, either immediately or after a delay + * + * leepLatest ensures the latest changes will be saved + * @param {string} type - post or page + * @param {object} data - serialized post data + */ + @task({keepLatest: true}) + *saveTask(type, data) { + const currentTime = Date.now(); + if (!this.lastRevisionTime || currentTime - this.lastRevisionTime > this.MIN_REVISION_TIME) { + yield this.performSave(type, data); + this.lastRevisionTime = currentTime; + } else { + const waitTime = this.MIN_REVISION_TIME - (currentTime - this.lastRevisionTime); + yield timeout(waitTime); + yield this.performSave(type, data); + this.lastRevisionTime = Date.now(); + } + } + + /** + * Saves the revision to localStorage + * + * If localStorage is full, the oldest revision will be removed + * @param {string} type - post or page + * @param {object} data - serialized post data + * @returns {string | undefined} - key of the saved revision or undefined if it couldn't be saved + */ + performSave(type, data) { + data.id = data.id || 'draft'; + data.type = type; + data.revisionTimestamp = Date.now(); + const key = this.generateKey(data); + try { + const allKeys = this.keys(); + allKeys.push(key); + localStorage.setItem(this._indexKey, JSON.stringify(allKeys)); + localStorage.setItem(key, JSON.stringify(data)); + return key; + } catch (err) { + if (err.name === 'QuotaExceededError') { + // Remove the current key in case it's already in the index + this.remove(key); + + // If there are any revisions, remove the oldest one and try to save again + if (this.keys().length) { + this.removeOldest(); + return this.performSave(type, data); + } + // LocalStorage is full and there are no revisions to remove + // We can't save the revision + } + } + } + + /** + * Method to trigger the save task + * @param {string} type - post or page + * @param {object} data - serialized post data + */ + scheduleSave(type, data) { + this.saveTask.perform(type, data); + } + + /** + * Returns the specified revision from localStorage, or null if it doesn't exist + * @param {string} key - key of the revision to find + * @returns {string | null} + */ + find(key) { + return JSON.parse(localStorage.getItem(key)); + } + + /** + * Returns all revisions from localStorage, optionally filtered by key prefix + * @param {string | undefined} prefix - optional prefix to filter revision keys + * @returns + */ + findAll(prefix = undefined) { + const keys = this.keys(prefix); + const revisions = {}; + for (const key of keys) { + revisions[key] = JSON.parse(localStorage.getItem(key)); + } + return revisions; + } + + /** + * Removes the specified key from localStorage + * @param {string} key + */ + remove(key) { + localStorage.removeItem(key); + const keys = this.keys(); + let index = keys.indexOf(key); + if (index !== -1) { + keys.splice(index, 1); + } + localStorage.setItem(this._indexKey, JSON.stringify(keys)); + } + + /** + * Finds the oldest revision and removes it from localStorage to clear up space + */ + removeOldest() { + const keys = this.keys(); + const keysByTimestamp = keys.map(key => ({key, timestamp: this.find(key).revisionTimestamp})); + keysByTimestamp.sort((a, b) => a.timestamp - b.timestamp); + this.remove(keysByTimestamp[0].key); + } + + /** + * Removes all revisions from localStorage + */ + clear() { + const keys = this.keys(); + for (const key of keys) { + this.remove(key); + } + } + + /** + * Returns all revision keys from localStorage, optionally filtered by key prefix + * @param {string | undefined} prefix + * @returns {string[]} + */ + keys(prefix = undefined) { + let keys = JSON.parse(localStorage.getItem(this._indexKey) || '[]'); + if (prefix) { + keys = keys.filter(key => key.startsWith(prefix)); + } + return keys; + } + + /** + * Logs all revisions to the console + * + * Currently this is the only UI for local revisions + */ + list() { + const revisions = this.findAll(); + const data = {}; + for (const [key, revision] of Object.entries(revisions)) { + if (!data[revision.title]) { + data[revision.title] = []; + } + data[revision.title].push({ + key, + timestamp: revision.revisionTimestamp, + time: new Date(revision.revisionTimestamp).toLocaleString(), + title: revision.title, + type: revision.type, + id: revision.id + }); + } + /* eslint-disable no-console */ + console.groupCollapsed('Local revisions'); + for (const [title, row] of Object.entries(data)) { + // eslint-disable-next-line no-console + console.groupCollapsed(`${title}`); + for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) { + // eslint-disable-next-line no-console + console.groupCollapsed(`${item.time}`); + console.log('Revision ID: ', item.key); + console.groupEnd(); + } + console.groupEnd(); + } + console.groupEnd(); + /* eslint-enable no-console */ + } + + /** + * Creates a new post from the specified revision + * + * @param {string} key + * @returns {Promise} - the new post model + */ + async restore(key) { + try { + const revision = this.find(key); + let authors = []; + if (revision.authors) { + for (const author of revision.authors) { + const authorModel = await this.store.queryRecord('user', {id: author.id}); + authors.push(authorModel); + } + } + let post = this.store.createRecord('post', { + title: `(Restored) ${revision.title}`, + lexical: revision.lexical, + authors, + type: revision.type, + slug: revision.slug || 'untitled', + status: 'draft', + tags: revision.tags || [], + post_revisions: [] + }); + await post.save(); + const location = window.location; + const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`; + // eslint-disable-next-line no-console + console.log('Post restored: ', url); + return post; + } catch (err) { + // eslint-disable-next-line no-console + console.warn(err); + } + } +} \ No newline at end of file diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index a6f40e77ff3..46e54fa022a 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -1299,6 +1299,20 @@ kbd { background: transparent; } +.gh-post-analytics-meta .gh-post-list-cta { + border: none!important; + box-shadow: none!important; +} + +.gh-post-analytics-meta .gh-post-list-cta:hover, .gh-post-analytics-meta .refresh:hover { + background: var(--lightgrey-d1); + color: var(--black); +} + +.gh-post-analytics-meta .gh-btn.refresh { + border: none; +} + /* Post rows */ @@ -1436,3 +1450,19 @@ Onboarding checklist: Share publication modal */ .gh-sidebar-banner.gh-error-banner { background: var(--lightgrey-d1); } + + +/* --------------------------------- +Publish flow: Share modal */ + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin) { + background: var(--lightgrey-l1)!important; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin, .copy-link, .copy-preview-link):hover { + background: var(--lightgrey-l2)!important; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads) svg path { + fill: var(--darkgrey); +} \ No newline at end of file diff --git a/ghost/admin/app/styles/layouts/dashboard.css b/ghost/admin/app/styles/layouts/dashboard.css index fbb58efd483..49ef8ed9db4 100644 --- a/ghost/admin/app/styles/layouts/dashboard.css +++ b/ghost/admin/app/styles/layouts/dashboard.css @@ -2770,9 +2770,9 @@ Onboarding checklist */ align-items: center; flex-direction: column; min-height: 100vh; - margin-bottom: -48px; position: relative; padding: 32px 0; + margin-top: -48px; } .gh-onboarding-header { diff --git a/ghost/admin/app/styles/layouts/stats.css b/ghost/admin/app/styles/layouts/stats.css index b146744047f..e0e82be9836 100644 --- a/ghost/admin/app/styles/layouts/stats.css +++ b/ghost/admin/app/styles/layouts/stats.css @@ -1,10 +1,18 @@ -.gh-stats { +.gh-stats .view-container { display: flex; flex-direction: column; gap: 32px; padding-bottom: 32px !important; } +.gh-stats .view-actions { + flex-direction: row !important; +} + +.gh-stats .gh-canvas-header-content { + min-height: unset !important; +} + .gh-stats-grid { display: grid; gap: 32px; @@ -15,6 +23,10 @@ } .gh-stats-container { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 20px; padding: 20px; border: 1px solid var(--whitegrey); border-radius: 8px; @@ -22,23 +34,29 @@ transition: all ease-in-out 0.3s; } -.gh-stats-container > .gh-stats-metric-label { - margin-bottom: 20px; +.gh-stats-container.no-gap { + gap: 0; +} + +.gh-stats-container > .gh-stats-metric-label, +.gh-stats-metric-header { + margin-bottom: 12px; } .gh-stats-container:hover { box-shadow: 0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,.03), 0 8px 10px -8px rgba(0,0,0,.1); } -.gh-stats-tabs { +.gh-stats-tabs-header { position: relative; display: flex; margin: -20px -20px 20px; padding: 0; overflow: hidden; + justify-content: space-between; } -.gh-stats-tabs:before { +.gh-stats-tabs-header:before { display: block; content: ""; position: absolute; @@ -50,15 +68,19 @@ background: var(--whitegrey); } +.gh-stats-tabs { + display: flex; +} + .gh-stats-tab { position: relative; margin: 0 0 1px 0; - color: var(--midlightgrey); + /* color: var(--darkgrey); */ } -.gh-stats-tab:not(.is-selected) .gh-stats-metric-label { - color: var(--midlightgrey); -} +/* .gh-stats-tab:not(.is-selected) .gh-stats-metric-label { + color: var(--darkgrey); +} */ .gh-stats-tab:hover { background-color: var(--whitegrey-l2); @@ -89,6 +111,15 @@ min-width: 180px; } +.gh-stats-metric-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--whitegrey); + margin: -4px -20px 12px; + padding: 0 20px 16px; +} + .gh-stats-metric-data { display: flex; flex-direction: column; @@ -103,7 +134,7 @@ } .gh-stats-metric-value { - font-size: 28px; + font-size: 26px; letter-spacing: -0.05em; font-weight: 600; line-height: 1; @@ -117,7 +148,7 @@ } .gh-stats-piechart-container table { - margin-top: 5px; + margin-top: -15px; width: 100%; margin-bottom: 0; } @@ -134,4 +165,90 @@ height: 100%; flex-grow: 1; margin-right: -20px; +} + +.gh-stats-section-dropdown { + padding: 0 8px 0 0 !important; + height: 28px; + border: none !important; + cursor: pointer; + overflow: hidden; + margin-right: -8px; +} + +.gh-stats-section-dropdown.ember-power-select-trigger.gh-btn span { + padding-right: 4px; +} + +.gh-stats-section-dropdown.ember-power-select-trigger:not(.ember-power-select-multiple-trigger):not(.gh-preview-newsletter-trigger) svg { + margin-top: 0; +} + +.gh-stats-kpi-granularity { + padding: 12px 20px; +} + +@media (max-width: 1440px) { + .gh-stats-tab.min-width .gh-stats-metric { + min-width: 150px; + } + + .gh-stats-metric-label { + font-size: 14px; + } +} + +.gh-stats-detail-header { + font-size: 12px; + font-weight: 500; + text-transform: none; + color: var(--midgrey); +} + +.gh-stats-see-all-btn span { + height: unset; + padding: 0; + line-height: 1; + color: var(--black); +} + +.gh-stats-see-all-btn:hover span { + color: var(--green); +} + +.gh-stats-detail-label, +.gh-stats-detail-value { + font-size: 13.5px; + font-weight: 500; +} + +.gh-stats-see-all-container { + position: relative; +} + +.gh-stats-see-all-container::before { + position: absolute; + display: block; + content: ''; + left: 0; + right: 0; + top: -60px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.0) 0%, #FFF 100%); + height: 60px; + z-index: 9999; +} + +.gh-stats-kpis-chart-container { + margin-top: -20px; +} + +.gh-stats-domain { + display: flex; + align-items: center; + gap: 6px; +} + +.gh-stats-favicon { + width: 16px; + height: 16px; } \ No newline at end of file diff --git a/ghost/admin/app/templates/react-editor/edit-loading.hbs b/ghost/admin/app/templates/react-editor/edit-loading.hbs deleted file mode 100644 index 8255f3b7795..00000000000 --- a/ghost/admin/app/templates/react-editor/edit-loading.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="gh-view" {{did-insert (fn this.ui.setMainClass "gh-main-white")}}> - <div class="gh-content"> - <GhLoadingSpinner /> - </div> -</div> diff --git a/ghost/admin/app/templates/stats.hbs b/ghost/admin/app/templates/stats.hbs index f1816c277a8..96e513308da 100644 --- a/ghost/admin/app/templates/stats.hbs +++ b/ghost/admin/app/templates/stats.hbs @@ -1,4 +1,4 @@ -<section class="gh-canvas gh-canvas-sticky"> +<section class="gh-stats gh-canvas gh-canvas-sticky"> <GhCanvasHeader class="gh-canvas-header sticky break tablet post-header"> <GhCustomViewTitle @title="Stats" /> <div class="view-actions"> @@ -8,10 +8,10 @@ /> <PowerSelect - @selected={{this.selectedDaysOption}} - @options={{this.daysOptions}} + @selected={{this.selectedRangeOption}} + @options={{this.rangeOptions}} @searchEnabled={{false}} - @onChange={{this.onDaysChange}} + @onChange={{this.onRangeChange}} @triggerComponent={{component "gh-power-select/trigger"}} @triggerClass="gh-btn" @dropdownClass="gh-contentfilter-menu-dropdown is-narrow" @@ -24,29 +24,26 @@ </div> </GhCanvasHeader> - <section class="view-container gh-stats"> - <section class="gh-stats-container"> - <Stats::KpisOverview @chartDays={{this.chartDays}} @audience={{this.audience}} /> + <section class="view-container"> + <section class="gh-stats-container no-gap"> + <Stats::KpisOverview @chartRange={{this.chartRange}} @audience={{this.audience}} /> </section> <section class="gh-stats-grid cols-2"> <div class="gh-stats-container"> - <h5 class="gh-stats-metric-label">Top posts & pages</h5> - <Stats::Charts::TopPages @chartDays={{this.chartDays}} @audience={{this.audience}} /> + <Stats::Charts::TopPages @chartRange={{this.chartRange}} @audience={{this.audience}} /> </div> <div class="gh-stats-container"> - <h5 class="gh-stats-metric-label">Sources</h5> - <Stats::Charts::TopSources @chartDays={{this.chartDays}} @audience={{this.audience}} /> + <Stats::Charts::TopSources @chartRange={{this.chartRange}} @audience={{this.audience}} /> </div> </section> <section class="gh-stats-grid cols-2"> <div class="gh-stats-container"> - <h5 class="gh-stats-metric-label">Top locations</h5> - <Stats::Charts::TopLocations @chartDays={{this.chartDays}} @audience={{this.audience}} /> + <Stats::Charts::TopLocations @chartRange={{this.chartRange}} @audience={{this.audience}} /> </div> <div class="gh-stats-container"> - <Stats::TechnicalOverview @chartDays={{this.chartDays}} @audience={{this.audience}} /> + <Stats::TechnicalOverview @chartRange={{this.chartRange}} @audience={{this.audience}} /> </div> </section> diff --git a/ghost/admin/app/utils/stats.js b/ghost/admin/app/utils/stats.js new file mode 100644 index 00000000000..1e77f2913d6 --- /dev/null +++ b/ghost/admin/app/utils/stats.js @@ -0,0 +1,121 @@ +export const RANGE_OPTIONS = [ + {name: 'Last 24 hours', value: 1}, + {name: 'Last 7 days', value: 7}, + {name: 'Last 30 days', value: 30 + 1}, + {name: 'Last 3 months', value: 90 + 1}, + {name: 'Year to date', value: 365 + 1}, + {name: 'Last 12 months', value: 12 * (30 + 1)}, + {name: 'All time', value: 1000} +]; + +export const CONTENT_OPTIONS = [ + {name: 'Posts & pages', value: 'all'}, + {name: 'Posts', value: 'posts'}, + {name: 'Pages', value: 'pages'} +]; + +export const CAMPAIGN_OPTIONS = [ + {name: 'All campaigns', value: 'all'}, + {name: 'UTM Medium', value: 'utm-medium'}, + {name: 'UTM Source', value: 'utm-source'}, + {name: 'UTM Campaign', value: 'utm-campaign'}, + {name: 'UTM Content', value: 'utm-content'}, + {name: 'UTM Term', value: 'utm-term'} +]; + +export const AUDIENCE_TYPES = [ + {name: 'Logged out visitors', value: 'undefined'}, + {name: 'Free members', value: 'free'}, + {name: 'Paid members', value: 'paid'} +]; + +export function hexToRgba(hex, alpha = 1) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export function generateMonochromePalette(baseColor, count = 10) { + // Convert hex to RGB + let r = parseInt(baseColor.slice(1, 3), 16); + let g = parseInt(baseColor.slice(3, 5), 16); + let b = parseInt(baseColor.slice(5, 7), 16); + + // Convert RGB to HSL + r /= 255, g /= 255, b /= 255; + let max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + let d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + // Generate palette + let palette = []; + for (let i = 0; i < count; i++) { + // Adjust the range based on the base color's lightness + let rangeStart, rangeEnd; + if (l < 0.5) { + // For darker base colors + rangeStart = 0.1; + rangeEnd = 0.7; + } else { + // For lighter base colors + rangeStart = 0.3; + rangeEnd = 0.9; + } + + let newL = rangeStart + (i / (count - 1)) * (rangeEnd - rangeStart); + + // Convert back to RGB + let c = (1 - Math.abs(2 * newL - 1)) * s; + let x = c * (1 - Math.abs((h * 6) % 2 - 1)); + let m = newL - c / 2; + + if (0 <= h && h < 1 / 6) { + [r, g, b] = [c, x, 0]; + } else if (1 / 6 <= h && h < 2 / 6) { + [r, g, b] = [x, c, 0]; + } else if (2 / 6 <= h && h < 3 / 6) { + [r, g, b] = [0, c, x]; + } else if (3 / 6 <= h && h < 4 / 6) { + [r, g, b] = [0, x, c]; + } else if (4 / 6 <= h && h < 5 / 6) { + [r, g, b] = [x, 0, c]; + } else { + [r, g, b] = [c, 0, x]; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + palette.push(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`); + } + + return palette; +} + +export const barListColor = '#F1F3F4'; + +export const statsStaticColors = [ + '#8E42FF', '#B07BFF', '#C7A0FF', '#DDC6FF', '#EBDDFF', '#F7EDFF' +]; + +export const getCountryFlag = (countryCode) => { + if (!countryCode) { + return '🏳️'; + } + return countryCode.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397) + ); +}; \ No newline at end of file diff --git a/ghost/admin/package.json b/ghost/admin/package.json index ca51b1a8e1d..3cf08dee909 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.93.0", + "version": "5.94.2", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -44,16 +44,16 @@ "@sentry/ember": "7.119.0", "@sentry/integrations": "7.114.0", "@sentry/replay": "7.116.0", - "@tinybirdco/charts": "0.1.8", + "@tinybirdco/charts": "0.2.0-beta.2", "@tryghost/color-utils": "0.2.2", "@tryghost/ember-promise-modals": "2.0.1", "@tryghost/helpers": "1.1.90", "@tryghost/kg-clean-basic-html": "4.1.4", "@tryghost/kg-converters": "1.0.7", - "@tryghost/koenig-lexical": "1.3.23", + "@tryghost/koenig-lexical": "1.3.26", "@tryghost/limit-service": "1.2.14", "@tryghost/members-csv": "0.0.0", - "@tryghost/nql": "0.12.4", + "@tryghost/nql": "0.12.5", "@tryghost/nql-lang": "0.6.1", "@tryghost/string": "0.2.12", "@tryghost/timezone-data": "0.4.3", @@ -74,7 +74,7 @@ "element-resize-detector": "1.2.4", "ember-ajax": "5.1.2", "ember-assign-helper": "0.4.0", - "ember-auto-import": "2.7.4", + "ember-auto-import": "2.8.1", "ember-classic-decorator": "3.0.1", "ember-cli": "3.24.0", "ember-cli-app-version": "5.0.0", @@ -132,6 +132,7 @@ "liquid-fire": "0.34.0", "liquid-wormhole": "3.0.1", "loader.js": "4.7.0", + "microdiff": "1.4.0", "miragejs": "0.1.48", "moment-timezone": "0.5.45", "normalize.css": "3.0.3", @@ -175,7 +176,7 @@ "dependencies": { "jose": "4.15.9", "path-browserify": "1.0.1", - "webpack": "5.93.0" + "webpack": "5.94.0" }, "nx": { "targets": { @@ -193,6 +194,10 @@ ] }, "build": { + "outputs": [ + "{projectRoot}/dist", + "{workspaceRoot}/ghost/core/core/built/admin" + ], "dependsOn": [ "build", { diff --git a/ghost/admin/public/assets/icons/stats-outline.svg b/ghost/admin/public/assets/icons/stats-outline.svg new file mode 100644 index 00000000000..11777121fdc --- /dev/null +++ b/ghost/admin/public/assets/icons/stats-outline.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1" id="Analytics-Board-Graph-Line--Streamline-Ultimate"> + <path d="M1.836 3.02652C1.6269600000000002 3.056808 1.384152 3.1353120000000003 1.177176 3.239544C0.626064 3.517104 0.250848 4.013688 0.064176 4.712664L0.012 4.908 0.012 12.036L0.012 19.164 0.066144 19.368000000000002C0.277536 20.164536 0.8438640000000001 20.703792 1.7126640000000002 20.935824L1.9080000000000001 20.988 12.036 20.988L22.164 20.988 22.368000000000002 20.934408C22.4802 20.90496 22.682472 20.826336 22.817496000000002 20.759712C23.017176 20.661216 23.103144 20.6004 23.27808 20.433936C23.396376 20.321352 23.53104 20.172192000000003 23.577312000000003 20.102448C23.768352 19.814664 23.934383999999998 19.394351999999998 23.967144 19.115664C23.975568 19.043904 23.991816 18.990936 24.00324 18.997992C24.014904 19.005216 24.024 15.940655999999999 24.024 12.002832C24.024 7.356719999999999 24.015912 4.98984 24 4.98C23.986800000000002 4.97184 23.976 4.929744 23.976 4.886424C23.976 4.751832 23.883912000000002 4.437215999999999 23.782128 4.224C23.508816 3.651576 23.021664 3.2703599999999997 22.300488 3.064632L22.116 3.012 12.048 3.008832C6.451512 3.007056 1.91604 3.0149280000000003 1.836 3.02652M1.9369679999999998 4.550688C1.7866799999999998 4.596888 1.652928 4.732272 1.58076 4.91124L1.524 5.0520000000000005 1.524 12.036L1.524 19.02 1.581192 19.13616C1.6518000000000002 19.279608 1.781568 19.376976000000003 1.98648 19.440264C2.13972 19.487592 2.226672 19.488 12.034176 19.488C20.913984000000003 19.488 21.940152 19.484184 22.052976 19.450680000000002C22.214328 19.402752 22.344504 19.274136000000002 22.41924 19.08876L22.476 18.948 22.476 11.964L22.476 4.98 22.418808 4.86384C22.3482 4.720392 22.218432 4.623024 22.01352 4.559736C21.860232 4.512408 21.775728 4.512024 11.95548 4.51368C3.3041760000000004 4.51512 2.037456 4.519824 1.9369679999999998 4.550688M0.011832 12.024000000000001C0.011832 15.885 0.014616 17.464512 0.018000000000000002 15.534C0.021384 13.603512 0.021384 10.444512 0.018000000000000002 8.514C0.014616 6.583512 0.011832 8.163 0.011832 12.024000000000001M20.081616 7.512912C19.939968 7.548168 19.820496000000002 7.619136 19.712712000000003 7.7320079999999995C19.658112 7.7892 18.725328 9.1698 17.639856 10.8C16.554384 12.4302 15.656568 13.774464000000002 15.644712000000002 13.787232C15.631319999999999 13.801656 15.155952000000001 13.34484 14.393568 12.584928C12.993264 11.189184000000001 13.060248000000001 11.241816 12.709152 11.261112C12.571488 11.268672 12.484968 11.287464 12.408 11.326584C12.333816 11.364264 11.928431999999999 11.75196 11.113728 12.564336L9.92748 13.747224000000001 8.733984 11.547624C8.077584 10.337832 7.503096 9.304584 7.457376 9.251520000000001C7.1898480000000005 8.941032 6.685752 8.917463999999999 6.3916319999999995 9.20172C6.268944 9.32028 3.093816 14.600304 3.0325200000000003 14.787672C2.9826 14.940287999999999 2.99928 15.17376 3.070584 15.321096C3.143808 15.47232 3.293304 15.618096000000001 3.447672 15.688775999999999C3.5611680000000003 15.740736000000002 3.60528 15.747624 3.775416 15.739991999999999C4.011408 15.729432000000001 4.160880000000001 15.663816 4.294344000000001 15.512184000000001C4.342872 15.457056 4.931544 14.49576 5.602488 13.375968C6.273432 12.256176 6.834456 11.32716 6.8491919999999995 11.311488C6.8693040000000005 11.290104 7.152216 11.792976000000001 7.98 13.321319999999998C8.587200000000001 14.442408000000002 9.122112000000001 15.410856 9.168696 15.473400000000002C9.291288 15.638088 9.480072 15.729432000000001 9.72036 15.740352C10.061904 15.755904 9.993816 15.810312000000001 11.453784 14.354832000000002L12.743568 13.069032 14.021784 14.343792C14.903927999999999 15.22356 15.333456 15.635568 15.408 15.67344C15.485327999999999 15.712704 15.571536 15.731328 15.711552000000001 15.739032C15.885672000000001 15.748584000000001 15.922488000000001 15.742728 16.047552 15.685535999999999C16.124807999999998 15.650184000000001 16.219032000000002 15.5922 16.256976 15.556632C16.34448 15.474672 20.867520000000003 8.695488000000001 20.943288 8.532792C21.102263999999998 8.191368 20.923608 7.734336000000001 20.565672 7.566840000000001C20.43624 7.506264000000001 20.209656 7.481016 20.081616 7.512912" stroke="none" fill="currentColor" fill-rule="evenodd"></path> +</svg> \ No newline at end of file diff --git a/ghost/admin/tests/acceptance/editor-test.js b/ghost/admin/tests/acceptance/editor-test.js index b1d828862fd..73840eefc8b 100644 --- a/ghost/admin/tests/acceptance/editor-test.js +++ b/ghost/admin/tests/acceptance/editor-test.js @@ -1,10 +1,12 @@ import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import moment from 'moment-timezone'; import sinon from 'sinon'; +import {Response} from 'miragejs'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {beforeEach, describe, it} from 'mocha'; -import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers'; +import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn, waitFor} from '@ember/test-helpers'; import {datepickerSelect} from 'ember-power-datepicker/test-support'; +import {editorSelector, pasteInEditor, titleSelector} from '../helpers/editor'; import {enableLabsFlag} from '../helpers/labs-flag'; import {expect} from 'chai'; import {selectChoose} from 'ember-power-select/test-support'; @@ -114,9 +116,9 @@ describe('Acceptance: Editor', function () { let author; beforeEach(async function () { + this.server.loadFixtures(); let role = this.server.create('role', {name: 'Administrator'}); author = this.server.create('user', {roles: [role]}); - this.server.loadFixtures('settings'); await authenticateSession(); }); @@ -604,6 +606,23 @@ describe('Acceptance: Editor', function () { ).to.equal(`/ghost/posts/analytics/${post.id}`); }); + it('does not render analytics breadcrumb for a new post', async function () { + const post = this.server.create('post', { + authors: [author], + status: 'published', + title: 'Published Post' + }); + + // visit the analytics page for the post + await visit(`/posts/analytics/${post.id}`); + // start a new post + await visit('/editor/post'); + + // Breadcrumbs should not contain Analytics link + expect(find('[data-test-breadcrumb]'), 'breadcrumb text').to.contain.text('Posts'); + expect(find('[data-test-editor-post-status]')).to.contain.text('New'); + }); + it('handles TKs in title', async function () { let post = this.server.create('post', {authors: [author]}); @@ -669,5 +688,71 @@ describe('Acceptance: Editor', function () { 'TK reminder modal' ).to.exist; }); + + // We shouldn't ever see 404s from the API but we do/have had a bug where + // a new post can enter a state where it appears saved but hasn't hit + // the API to create the post meaning it has no ID but the store is + // making PUT requests rather than a POST request in which case it's + // hitting `/posts/` rather than `/posts/:id` and receiving a 404. On top + // of that our application error handler was erroring because there was + // no transition alongside the error so this test makes sure that works + // and we enter a visible error state rather than letting unsaved changes + // pile up and contributing to larger potential data loss. + it('handles 404 from invalid PUT API request', async function () { + this.server.put('/posts/', () => { + return new Response(404, {}, { + errors: [ + { + message: 'Resource could not be found.', + errorType: 'NotFoundError', + statusCode: 404 + } + ] + }); + }); + + await visit('/editor/post'); + await waitFor(editorSelector); + + // simulate the bad state where a post.save will trigger a PUT with no id + const controller = this.owner.lookup('controller:lexical-editor'); + controller.post.transitionTo('updated.uncommitted'); + + // this will trigger an autosave which will hit our simulated 404 + await pasteInEditor('Testing'); + + // we should see an error - previously this was failing silently + // error message comes from editor's own handling rather than our generic API error fallback + expect(find('.gh-alert-content')).to.have.trimmed.text('Saving failed: Editor has crashed. Please copy your content and start a new post.'); + }); + + it('handles 404 from valid PUT API request', async function () { + // this doesn't match what we're actually seeing in the above mentioned + // bug state but it's a good enough simulation for testing our error handler + this.server.put('/posts/:id/', () => { + return new Response(404, {}, { + errors: [ + { + message: 'Resource could not be found.', + errorType: 'NotFoundError', + statusCode: 404 + } + ] + }); + }); + + await visit('/editor/post'); + await waitFor(editorSelector); + await fillIn(titleSelector, 'Test 404 handling'); + // this triggers the initial creation request - in the actual bug this doesn't happen + await blur(titleSelector); + expect(currentRouteName()).to.equal('lexical-editor.edit'); + // this will trigger an autosave which will hit our simulated 404 + await pasteInEditor('Testing'); + + // we should see an error - previously this was failing silently + // error message comes from editor's own handling rather than our generic API error fallback + expect(find('.gh-alert-content')).to.contain.text('Post has been deleted in a different session'); + }); }); }); diff --git a/ghost/admin/tests/acceptance/editor/unsaved-changes-test.js b/ghost/admin/tests/acceptance/editor/unsaved-changes-test.js index c52cb4eb7ad..6f230af68fa 100644 --- a/ghost/admin/tests/acceptance/editor/unsaved-changes-test.js +++ b/ghost/admin/tests/acceptance/editor/unsaved-changes-test.js @@ -1,26 +1,14 @@ import loginAsRole from '../../helpers/login-as-role'; -import {click, currentURL, fillIn, find, waitFor, waitUntil} from '@ember/test-helpers'; +import {click, currentURL, fillIn, find} from '@ember/test-helpers'; +import {editorSelector, pasteInEditor, titleSelector} from '../../helpers/editor'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; import {visit} from '../../helpers/visit'; -const titleSelector = '[data-test-editor-title-input]'; -const editorSelector = '[data-secondary-instance="false"] [data-lexical-editor]'; const unsavedModalSelector = '[data-test-modal="unsaved-post-changes"]'; const backToPostsSelector = '[data-test-link="posts"]'; -const pasteInEditor = async (text) => { - await waitFor(editorSelector); - await click(editorSelector); - const dataTransfer = new DataTransfer(); - dataTransfer.setData('text/plain', text); - document.activeElement.dispatchEvent(new ClipboardEvent('paste', {clipboardData: dataTransfer, bubbles: true, cancelable: true})); - dataTransfer.clearData(); - const editor = find(editorSelector); - await waitUntil(() => editor.textContent.includes(text)); -}; - describe('Acceptance: Editor: Unsaved changes', function () { let hooks = setupApplicationTest(); setupMirage(hooks); diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js index 50ab1eeee27..babc7ceeb62 100644 --- a/ghost/admin/tests/acceptance/members-test.js +++ b/ghost/admin/tests/acceptance/members-test.js @@ -143,63 +143,23 @@ describe('Acceptance: Members', function () { .to.equal('example@domain.com'); }); - /* Due to a limitation with NQL when multiple member filters are used in combination, we currently have a safeguard around member bulk deletion. - * Member bulk deletion is not permitted when: - * 1) Multiple newsletters exist, and 2 or more newsletter filters are in use - * 2) If any of the following Stripe filters are used, even once: - * - Billing period - * - Stripe subscription status - * - Paid start date - * - Next billing date - * - Subscription started on post/page - * - Offers - * - * See code: ghost/admin/app/controllers/members.js:isBulkDeletePermitted - * See issue https://linear.app/tryghost/issue/ENG-1484 for more context - * - * TODO: delete this block of tests once the guardrail has been removed + /* + * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: + * - Billing period + * - Stripe subscription status + * - Paid start date + * - Next billing date + * - Subscription started on post/page + * - Offers + * + * For more context, see: + * - https://linear.app/tryghost/issue/ENG-1484 + * - https://linear.app/tryghost/issue/ENG-1466 + * + * See code: ghost/admin/app/controllers/members.js:isBulkDeletePermitted + * TODO: delete this block of tests once the guardrail has been removed */ describe('[Temp] Guardrail against bulk deletion', function () { - it('cannot bulk delete members if more than 1 newsletter filter is used', async function () { - // Create two newsletters and members subscribed to 1 or 2 newsletters - const newsletterOne = this.server.create('newsletter'); - const newsletterTwo = this.server.create('newsletter'); - this.server.createList('member', 2).forEach(member => member.update({newsletters: [newsletterOne], email_disabled: 0})); - this.server.createList('member', 2).forEach(member => member.update({newsletters: [newsletterOne, newsletterTwo], email_disabled: 0})); - - await visit('/members'); - expect(findAll('[data-test-member]').length).to.equal(4); - - // The delete button should not be visible by default - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // Apply a first filter - await click('[data-test-button="members-filter-actions"]'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', `newsletters.slug:${newsletterOne.slug}`); - await click(`[data-test-button="members-apply-filter"]`); - - expect(findAll('[data-test-member]').length).to.equal(4); - expect(currentURL()).to.equal(`/members?filter=(newsletters.slug%3A${newsletterOne.slug}%2Bemail_disabled%3A0)`); - - // Bulk deletion is permitted - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.exist; - - // Apply a second filter - await click('[data-test-button="members-filter-actions"]'); - await click('[data-test-button="add-members-filter"]'); - await fillIn('[data-test-members-filter="1"] [data-test-select="members-filter"]', `newsletters.slug:${newsletterTwo.slug}`); - await click(`[data-test-button="members-apply-filter"]`); - - expect(findAll('[data-test-member]').length).to.equal(2); - expect(currentURL()).to.equal(`/members?filter=(newsletters.slug%3A${newsletterOne.slug}%2Bemail_disabled%3A0)%2B(newsletters.slug%3A${newsletterTwo.slug}%2Bemail_disabled%3A0)`); - - // Bulk deletion is not permitted anymore - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - }); - it('can bulk delete members if a non-Stripe subscription filter is in use (member tier, status)', async function () { const tier = this.server.create('tier', {id: 'qwerty123456789'}); this.server.createList('member', 2, {status: 'free'}); diff --git a/ghost/admin/tests/acceptance/stats-test.js b/ghost/admin/tests/acceptance/stats-test.js index 26cc32ac542..6ec11412684 100644 --- a/ghost/admin/tests/acceptance/stats-test.js +++ b/ghost/admin/tests/acceptance/stats-test.js @@ -60,7 +60,7 @@ describe('Acceptance: Stats', function () { it('can filter by date range', async function () {}); it('can filter by number of days', async function () {}); - it('shows sum of unique visitors', async function () {}); + it('shows sum of unique visits', async function () {}); it('shows sum of visits', async function () {}); it('shows sum of pageviews', async function () {}); it('shows average bounce rate', async function () {}); @@ -68,7 +68,7 @@ describe('Acceptance: Stats', function () { it('can switch between kpi tabs', async function () {}); - it('shows unique visitors chart', async function () {}); + it('shows unique visits chart', async function () {}); it('shows visits chart', async function () {}); it('shows pageviews chart', async function () {}); it('shows bounce rate chart', async function () {}); diff --git a/ghost/admin/tests/helpers/editor.js b/ghost/admin/tests/helpers/editor.js new file mode 100644 index 00000000000..a7705f66afa --- /dev/null +++ b/ghost/admin/tests/helpers/editor.js @@ -0,0 +1,16 @@ +import {click, find, settled, waitFor, waitUntil} from '@ember/test-helpers'; + +export const titleSelector = '[data-test-editor-title-input]'; +export const editorSelector = '[data-secondary-instance="false"] [data-lexical-editor]'; + +export const pasteInEditor = async (text) => { + await waitFor(editorSelector); + await click(editorSelector); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', text); + document.activeElement.dispatchEvent(new ClipboardEvent('paste', {clipboardData: dataTransfer, bubbles: true, cancelable: true})); + dataTransfer.clearData(); + const editor = find(editorSelector); + await waitUntil(() => editor.textContent.includes(text)); + await settled(); +}; diff --git a/ghost/admin/tests/integration/services/local-revisions-test.js b/ghost/admin/tests/integration/services/local-revisions-test.js new file mode 100644 index 00000000000..dc8f1b0174c --- /dev/null +++ b/ghost/admin/tests/integration/services/local-revisions-test.js @@ -0,0 +1,59 @@ +import Pretender from 'pretender'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +function stubCreatePostEndpoint(server) { + server.post(`${ghostPaths().apiRoot}/posts/`, function () { + return [ + 201, + {'Content-Type': 'application/json'}, + JSON.stringify({posts: [{ + id: 'test id', + lexical: 'test lexical string', + title: 'test title', + post_revisions: [] + }]}) + ]; + }); + + server.get(`${ghostPaths().apiRoot}/users/`, function () { + return [ + 200, + {'Content-Type': 'application/json'}, + JSON.stringify({users: [{ + id: '1', + name: 'test name', + roles: ['owner'] + }]}) + ]; + }); +} + +describe('Integration: Service: local-revisions', function () { + setupTest(); + + let server; + + beforeEach(function () { + server = new Pretender(); + this.service = this.owner.lookup('service:local-revisions'); + this.service.clear(); + }); + + afterEach(function () { + server.shutdown(); + }); + + it('restores a post from a revision', async function () { + stubCreatePostEndpoint(server); + // create a post to restore + const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'}); + + // restore the post + const post = await this.service.restore(key); + + expect(post.get('lexical')).to.equal('test lexical string'); + }); +}); diff --git a/ghost/admin/tests/unit/controllers/editor-test.js b/ghost/admin/tests/unit/controllers/editor-test.js index 003de0507c1..d1557c5086e 100644 --- a/ghost/admin/tests/unit/controllers/editor-test.js +++ b/ghost/admin/tests/unit/controllers/editor-test.js @@ -1,5 +1,6 @@ import EmberObject from '@ember/object'; import RSVP from 'rsvp'; +import {authenticateSession} from 'ember-simple-auth/test-support'; import {defineProperty} from '@ember/object'; import {describe, it} from 'mocha'; import {expect} from 'chai'; @@ -424,4 +425,67 @@ describe('Unit: Controller: lexical-editor', function () { expect(isDirty).to.be.false; }); }); + + describe('post state debugging', function () { + let controller, store; + + beforeEach(async function () { + controller = this.owner.lookup('controller:lexical-editor'); + store = this.owner.lookup('service:store'); + + // avoid any unwanted network calls + const slugGenerator = this.owner.lookup('service:slug-generator'); + slugGenerator.generateSlug = async () => 'test-slug'; + + Object.defineProperty(controller, 'backgroundLoaderTask', { + get: () => ({perform: () => {}}) + }); + + // avoid waiting forever for authenticate modal + await authenticateSession(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should call _getNotFoundErrorContext() when hitting 404 during save', async function () { + const getErrorContextSpy = sinon.spy(controller, '_getNotFoundErrorContext'); + + const post = createPost(); + post.save = () => RSVP.reject(404); + + controller.set('post', post); + await controller.saveTask.perform(); // should not throw + + expect(getErrorContextSpy.calledOnce).to.be.true; + }); + + it('_getNotFoundErrorContext() includes setPost model state', async function () { + const newPost = store.createRecord('post'); + controller.setPost(newPost); + expect(controller._getNotFoundErrorContext().setPostState).to.equal('root.loaded.created.uncommitted'); + }); + + it('_getNotFoundErrorContext() includes current model state', async function () { + const newPost = store.createRecord('post'); + controller.setPost(newPost); + controller.post = {currentState: {stateName: 'this.is.a.test'}}; + expect(controller._getNotFoundErrorContext().currentPostState).to.equal('this.is.a.test'); + }); + + it('_getNotFoundErrorContext() includes all post states', async function () { + const newPost = store.createRecord('post'); + controller.setPost(newPost); + controller.post = {currentState: {stateName: 'state.one', isDirty: true}}; + controller.post = {currentState: {stateName: 'state.two', isDirty: false}}; + const allPostStates = controller._getNotFoundErrorContext().allPostStates; + const expectedStates = [ + ['root.loaded.created.uncommitted', {isDeleted: false, isDirty: true, isEmpty: false, isLoading: false, isLoaded: true, isNew: true, isSaving: false, isValid: true}], + ['state.one', {isDeleted: undefined, isDirty: true, isEmpty: undefined, isLoading: undefined, isLoaded: undefined, isNew: undefined, isSaving: undefined, isValid: undefined}], + ['state.two', {isDeleted: undefined, isDirty: false, isEmpty: undefined, isLoading: undefined, isLoaded: undefined, isNew: undefined, isSaving: undefined, isValid: undefined}] + ]; + expect(allPostStates).to.deep.equal(expectedStates); + }); + }); }); diff --git a/ghost/admin/tests/unit/services/local-revisions-test.js b/ghost/admin/tests/unit/services/local-revisions-test.js new file mode 100644 index 00000000000..782d8c13e70 --- /dev/null +++ b/ghost/admin/tests/unit/services/local-revisions-test.js @@ -0,0 +1,272 @@ +import Service from '@ember/service'; +import sinon from 'sinon'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +const sleep = ms => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +describe('Unit: Service: local-revisions', function () { + setupTest(); + + let localStore, setItemStub; + + this.beforeEach(function () { + // Mock localStorage + sinon.restore(); + localStore = {}; + sinon.stub(localStorage, 'getItem').callsFake(key => localStore[key] || null); + setItemStub = sinon.stub(localStorage, 'setItem').callsFake((key, value) => localStore[key] = value + ''); + sinon.stub(localStorage, 'removeItem').callsFake(key => delete localStore[key]); + sinon.stub(localStorage, 'clear').callsFake(() => localStore = {}); + + // Create the service + this.service = this.owner.lookup('service:local-revisions'); + this.service.clear(); + }); + + it('exists', function () { + expect(this.service).to.be.ok; + }); + + describe('generateKey', function () { + it('generates a key for a post with an id', function () { + const revisionTimestamp = Date.now(); + const key = this.service.generateKey({id: 'test', revisionTimestamp}); + expect(key).to.equal(`post-revision-test-${revisionTimestamp}`); + }); + + it('generates a key for a post without a post id', function () { + const revisionTimestamp = Date.now(); + const key = this.service.generateKey({id: 'draft', revisionTimestamp}); + expect(key).to.equal(`post-revision-draft-${revisionTimestamp}`); + }); + }); + + describe('performSave', function () { + it('saves a revision without a post id', function () { + // save a revision + const key = this.service.performSave('post', {id: 'draft', lexical: 'test'}); + const revision = this.service.find(key); + expect(key).to.match(/post-revision-draft-\d+/); + expect(revision.id).to.equal('draft'); + expect(revision.lexical).to.equal('test'); + }); + + it('saves a revision with a post id', function () { + // save a revision + const key = this.service.performSave('post', {id: 'test-id', lexical: 'test'}); + const revision = this.service.find(key); + expect(key).to.match(/post-revision-test-id-\d+/); + expect(revision.id).to.equal('test-id'); + expect(revision.lexical).to.equal('test'); + }); + + it('evicts the oldest version if localStorage is full', async function () { + // save a few revisions + const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'test'}); + await sleep(1); + this.service.performSave('post', {id: 'test-id', lexical: 'data-2'}); + await sleep(1); + + // Simulate a quota exceeded error + const quotaError = new Error('QuotaExceededError'); + quotaError.name = 'QuotaExceededError'; + const callCount = setItemStub.callCount; + setItemStub.onCall(callCount).throws(quotaError); + const keyToAdd = this.service.performSave('post', {id: 'test-id', lexical: 'data-3'}); + + // Ensure the oldest revision was removed + expect(this.service.find(keyToRemove)).to.be.null; + + // Ensure the latest revision saved + expect(this.service.find(keyToAdd)).to.not.be.null; + }); + + it('evicts multiple oldest versions if localStorage is full', async function () { + // save a few revisions + const keyToRemove = this.service.performSave('post', {id: 'test-id-1', lexical: 'test'}); + await sleep(1); + const nextKeyToRemove = this.service.performSave('post', {id: 'test-id-2', lexical: 'data-2'}); + await sleep(1); + // Simulate a quota exceeded error + const quotaError = new Error('QuotaExceededError'); + quotaError.name = 'QuotaExceededError'; + + setItemStub.onCall(setItemStub.callCount).throws(quotaError); + // remove calls setItem() to remove the key from the index + // it's called twice for each quota error, hence the + 3 + setItemStub.onCall(setItemStub.callCount + 3).throws(quotaError); + const keyToAdd = this.service.performSave('post', {id: 'test-id-3', lexical: 'data-3'}); + + // Ensure the oldest revision was removed + expect(this.service.find(keyToRemove)).to.be.null; + expect(this.service.find(nextKeyToRemove)).to.be.null; + + // Ensure the latest revision saved + expect(this.service.find(keyToAdd)).to.not.be.null; + }); + }); + + describe('scheduleSave', function () { + it('saves a revision', function () { + // save a revision + this.service.scheduleSave('post', {id: 'draft', lexical: 'test'}); + const key = this.service.keys()[0]; + const revision = this.service.find(key); + expect(key).to.match(/post-revision-draft-\d+/); + expect(revision.id).to.equal('draft'); + expect(revision.lexical).to.equal('test'); + }); + + it('does not save a revision more than once if scheduled multiple times', async function () { + // interval is set to 200 ms in testing + this.service.scheduleSave('post', {id: 'draft', lexical: 'test'}); + await sleep(40); + this.service.scheduleSave('post', {id: 'draft', lexical: 'test'}); + const keys = this.service.keys(); + expect(keys).to.have.lengthOf(1); + }); + + it('saves another revision if it has been longer than the revision interval', async function () { + // interval is set to 200 ms in testing + this.service.scheduleSave('post', {id: 'draft', lexical: 'test'}); + await sleep(100); + this.service.scheduleSave('post', {id: 'draft', lexical: 'test'}); + const keys = this.service.keys(); + expect(keys).to.have.lengthOf(2); + }); + }); + + describe('find', function () { + it('gets a revision by key', function () { + // save a revision + const key = this.service.performSave('post', {lexical: 'test'}); + const result = this.service.find(key); + expect(result.id).to.equal('draft'); + expect(result.lexical).to.equal('test'); + expect(result.revisionTimestamp).to.match(/\d+/); + }); + + it('returns null if the key does not exist', function () { + const result = this.service.find('non-existent-key'); + expect(result).to.be.null; + }); + }); + + describe('findAll', function () { + it('gets all revisions if no prefix is provided', function () { + // save a revision + this.service.performSave('post', {id: 'test-id', lexical: 'test'}); + this.service.performSave('post', {lexical: 'data-2'}); + const result = this.service.findAll(); + expect(Object.keys(result)).to.have.lengthOf(2); + }); + + it('gets revisions filtered by prefix', function () { + // save a revision + this.service.performSave('post', {id: 'test-id', lexical: 'test'}); + this.service.performSave('post', {lexical: 'data-2'}); + const result = this.service.findAll('post-revision-test-id'); + expect(Object.keys(result)).to.have.lengthOf(1); + }); + + it('returns an empty object if there are no revisions', function () { + const result = this.service.findAll(); + expect(result).to.deep.equal({}); + }); + }); + + describe('keys', function () { + it('returns an empty array if there are no revisions', function () { + const result = this.service.keys(); + expect(result).to.deep.equal([]); + }); + + it('returns the keys for all revisions if not prefix is provided', function () { + // save revision + this.service.performSave('post', {id: 'test-id', lexical: 'data'}); + const result = this.service.keys(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[0]).to.match(/post-revision-test-id-\d+/); + }); + + it('returns the keys filtered by prefix if provided', function () { + // save revision + this.service.performSave('post', {id: 'test-id', lexical: 'data'}); + this.service.performSave('post', {id: 'draft', lexical: 'data'}); + const result = this.service.keys('post-revision-test-id'); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[0]).to.match(/post-revision-test-id-\d+/); + }); + }); + + describe('remove', function () { + it('removes the specified key', function () { + // save revision + const key = this.service.performSave('post', {id: 'test-id', lexical: 'data'}); + this.service.performSave('post', {id: 'test-2', lexical: 'data'}); + this.service.remove(key); + const updatedKeys = this.service.keys(); + expect(updatedKeys).to.have.lengthOf(1); + expect(this.service.find(key)).to.be.null; + }); + + it('does nothing if the key does not exist', function () { + // save revision + this.service.performSave('post', {id: 'test-id', lexical: 'data'}); + this.service.performSave('post', {id: 'test-2', lexical: 'data'}); + this.service.remove('non-existent-key'); + const updatedKeys = this.service.keys(); + expect(updatedKeys).to.have.lengthOf(2); + }); + }); + + describe('removeOldest', function () { + it('removes the oldest revision', async function () { + // save revision + const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'data'}); + await sleep(1); + this.service.performSave('post', {id: 'test-2', lexical: 'data'}); + await sleep(1); + this.service.performSave('post', {id: 'test-3', lexical: 'data'}); + this.service.removeOldest(); + const updatedKeys = this.service.keys(); + expect(updatedKeys).to.have.lengthOf(2); + expect(this.service.find(keyToRemove)).to.be.null; + }); + }); + + describe('restore', function () { + it('creates a new post based on the revision data', async function () { + // stub out the store service + let saveStub = sinon.stub().resolves({id: 'test-id'}); + let setStub = sinon.stub(); + let getStub = sinon.stub().returns('post'); + let queryRecordStub = sinon.stub().resolves({id: '1'}); + this.owner.register('service:store', Service.extend({ + createRecord: () => { + return { + id: 'new-id', + save: saveStub, + set: setStub, + get: getStub + }; + }, + queryRecord: queryRecordStub + })); + // create a post to restore + const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'}); + // restore the post + const post = await this.service.restore(key); + + // Ensure the post is saved + expect(saveStub.calledOnce).to.be.true; + + // Restore should return the post object + expect(post.id).to.equal('new-id'); + }); + }); +}); diff --git a/ghost/bookshelf-repository/package.json b/ghost/bookshelf-repository/package.json index 33db7ccf47b..bbf859b2729 100644 --- a/ghost/bookshelf-repository/package.json +++ b/ghost/bookshelf-repository/package.json @@ -23,7 +23,7 @@ "c8": "7.14.0", "mocha": "10.2.0", "sinon": "15.2.0", - "@tryghost/nql": "0.12.4" + "@tryghost/nql": "0.12.5" }, "dependencies": { "@tryghost/mongo-utils": "0.6.2", diff --git a/ghost/collections/package.json b/ghost/collections/package.json index f26123e4ea6..31db0d121a4 100644 --- a/ghost/collections/package.json +++ b/ghost/collections/package.json @@ -30,7 +30,7 @@ "@tryghost/errors": "1.3.5", "@tryghost/in-memory-repository": "0.0.0", "@tryghost/logging": "2.4.18", - "@tryghost/nql": "0.12.4", + "@tryghost/nql": "0.12.5", "@tryghost/nql-filter-expansions": "0.0.0", "@tryghost/post-events": "0.0.0", "@tryghost/tpl": "0.1.32", diff --git a/ghost/core/core/app.js b/ghost/core/core/app.js index 5a996f6d346..3f8445623e4 100644 --- a/ghost/core/core/app.js +++ b/ghost/core/core/app.js @@ -1,6 +1,7 @@ const sentry = require('./shared/sentry'); const express = require('./shared/express'); const config = require('./shared/config'); +const logging = require('@tryghost/logging'); const urlService = require('./server/services/url'); const fs = require('fs'); @@ -27,12 +28,33 @@ const maintenanceMiddleware = function maintenanceMiddleware(req, res, next) { fs.createReadStream(path.resolve(__dirname, './server/views/maintenance.html')).pipe(res); }; +// Used by Ghost (Pro) to ensure that requests cannot be served by the wrong site +const siteIdMiddleware = function siteIdMiddleware(req, res, next) { + const configSiteId = config.get('hostSettings:siteId'); + const headerSiteId = req.headers['x-site-id']; + + if (`${configSiteId}` === `${headerSiteId}`) { + return next(); + } + + logging.warn(`Mismatched site id (expected ${configSiteId}, got ${headerSiteId})`); + + res.set({ + 'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' + }); + res.writeHead(500); + res.end(); +}; + const rootApp = () => { const app = express('root'); app.use(sentry.requestHandler); if (config.get('sentry')?.tracing?.enabled === true) { app.use(sentry.tracingHandler); } + if (config.get('hostSettings:siteId')) { + app.use(siteIdMiddleware); + } app.enable('maintenance'); app.use(maintenanceMiddleware); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js index 72e807acc37..8ccf99fd223 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js @@ -9,6 +9,7 @@ module.exports = { emailFailures: require('./email-failures'), images: require('./images'), integrations: require('./integrations'), + oembed: require('./oembed'), pages: require('./pages'), posts: require('./posts'), settings: require('./settings'), diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js new file mode 100644 index 00000000000..35fef8e62fd --- /dev/null +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js @@ -0,0 +1,5 @@ +const url = require('../utils/url'); + +module.exports = (path) => { + return url.forImage(path); +}; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js index aa2eb73abcc..1a8f45f30b7 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js @@ -1,8 +1,15 @@ const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:oembed'); +const mappers = require('./mappers'); module.exports = { all(data, apiConfig, frame) { debug('all'); + if (data?.metadata?.thumbnail) { + data.metadata.thumbnail = mappers.oembed(data.metadata.thumbnail); + } + if (data?.metadata?.icon) { + data.metadata.icon = mappers.oembed(data.metadata.icon); + } frame.response = data; } }; diff --git a/ghost/core/core/server/data/importer/import-manager.js b/ghost/core/core/server/data/importer/import-manager.js index 360c081af7e..ce87eb43826 100644 --- a/ghost/core/core/server/data/importer/import-manager.js +++ b/ghost/core/core/server/data/importer/import-manager.js @@ -3,7 +3,7 @@ const fs = require('fs-extra'); const path = require('path'); const os = require('os'); const glob = require('glob'); -const uuid = require('uuid'); +const crypto = require('crypto'); const config = require('../../../shared/config'); const {extract} = require('@tryghost/zip'); const tpl = require('@tryghost/tpl'); @@ -220,7 +220,7 @@ class ImportManager { * @returns {Promise<string>} full path to the extracted folder */ async extractZip(filePath) { - const tmpDir = path.join(os.tmpdir(), uuid.v4()); + const tmpDir = path.join(os.tmpdir(), crypto.randomUUID()); this.fileToDelete = tmpDir; try { diff --git a/ghost/core/core/server/data/importer/importers/data/PostsImporter.js b/ghost/core/core/server/data/importer/importers/data/PostsImporter.js index 5bd6ab3c848..3f66c832bcd 100644 --- a/ghost/core/core/server/data/importer/importers/data/PostsImporter.js +++ b/ghost/core/core/server/data/importer/importers/data/PostsImporter.js @@ -1,6 +1,6 @@ const debug = require('@tryghost/debug')('importer:posts'); const _ = require('lodash'); -const uuid = require('uuid'); +const crypto = require('crypto'); const BaseImporter = require('./Base'); const mobiledocLib = require('../../../../lib/mobiledoc'); const validator = require('@tryghost/validator'); @@ -29,7 +29,7 @@ class PostsImporter extends BaseImporter { sanitizeAttributes() { _.each(this.dataToImport, (obj) => { if (!validator.isUUID(obj.uuid || '')) { - obj.uuid = uuid.v4(); + obj.uuid = crypto.randomUUID(); } // we used to have post.page=true/false diff --git a/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js b/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js index 27f05845975..3352abbce7b 100644 --- a/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js +++ b/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js @@ -1,5 +1,5 @@ const logging = require('@tryghost/logging'); -const uuid = require('uuid'); +const crypto = require('crypto'); const {createTransactionalMigration} = require('../../utils'); module.exports = createTransactionalMigration( @@ -10,7 +10,7 @@ module.exports = createTransactionalMigration( // eslint-disable-next-line no-restricted-syntax for (const newsletter of newslettersWithoutUUID) { - await knex('newsletters').update('uuid', uuid.v4()).where('id', newsletter.id); + await knex('newsletters').update('uuid', crypto.randomUUID()).where('id', newsletter.id); } }, async function down() { diff --git a/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js b/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js index 8b6638e6dbe..1abe2a2c538 100644 --- a/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js +++ b/ghost/core/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js @@ -1,5 +1,5 @@ const ObjectId = require('bson-objectid').default; -const uuid = require('uuid'); +const crypto = require('crypto'); const logging = require('@tryghost/logging'); const startsWith = require('lodash/startsWith'); const {createTransactionalMigration} = require('../../utils'); @@ -9,7 +9,7 @@ module.exports = createTransactionalMigration( // This uses the default settings from core/server/data/schema/default-settings/default-settings.json const newsletter = { id: (new ObjectId()).toHexString(), - uuid: uuid.v4(), + uuid: crypto.randomUUID(), name: 'Ghost', description: '', slug: 'default-newsletter', diff --git a/ghost/core/core/server/data/migrations/versions/5.94/2024-09-03-20-09-40-null-analytics-jobs-timings.js b/ghost/core/core/server/data/migrations/versions/5.94/2024-09-03-20-09-40-null-analytics-jobs-timings.js new file mode 100644 index 00000000000..cd941c7551c --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.94/2024-09-03-20-09-40-null-analytics-jobs-timings.js @@ -0,0 +1,20 @@ +// For information on writing migrations, see https://www.notion.so/ghost/Database-migrations-eb5b78c435d741d2b34a582d57c24253 + +const logging = require('@tryghost/logging'); + +// For DML - data changes +const {createTransactionalMigration} = require('../../utils'); + +module.exports = createTransactionalMigration( + async function up(knex) { + try { + await knex('jobs') + .whereIn('name', ['email-analytics-latest-opened', 'email-analytics-latest-others', 'email-analytics-missing']) + .del(); + } catch (error) { + logging.info(`Failed to delete email analytics jobs: ${error.message}`); + } + }, + // down is a no-op + async function down() {} +); \ No newline at end of file diff --git a/ghost/core/core/server/models/email.js b/ghost/core/core/server/models/email.js index 61d188da7ad..96f223d776c 100644 --- a/ghost/core/core/server/models/email.js +++ b/ghost/core/core/server/models/email.js @@ -1,4 +1,4 @@ -const uuid = require('uuid'); +const crypto = require('crypto'); const ghostBookshelf = require('./base'); const Email = ghostBookshelf.Model.extend({ @@ -6,7 +6,7 @@ const Email = ghostBookshelf.Model.extend({ defaults: function defaults() { return { - uuid: uuid.v4(), + uuid: crypto.randomUUID(), status: 'pending', recipient_filter: 'status:-free', track_opens: false, diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index 5fe0456b2dd..9138ddd37f3 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -1,5 +1,5 @@ const ghostBookshelf = require('./base'); -const uuid = require('uuid'); +const crypto = require('crypto'); const _ = require('lodash'); const config = require('../../shared/config'); const {gravatar} = require('../lib/image'); @@ -10,8 +10,8 @@ const Member = ghostBookshelf.Model.extend({ defaults() { return { status: 'free', - uuid: uuid.v4(), - transient_id: uuid.v4(), + uuid: crypto.randomUUID(), + transient_id: crypto.randomUUID(), email_count: 0, email_opened_count: 0, enable_comment_notifications: true diff --git a/ghost/core/core/server/models/newsletter.js b/ghost/core/core/server/models/newsletter.js index 2e41447c609..a0c4c06f353 100644 --- a/ghost/core/core/server/models/newsletter.js +++ b/ghost/core/core/server/models/newsletter.js @@ -1,6 +1,6 @@ const ghostBookshelf = require('./base'); const ObjectID = require('bson-objectid').default; -const uuid = require('uuid'); +const crypto = require('crypto'); const urlUtils = require('../../shared/url-utils'); const Newsletter = ghostBookshelf.Model.extend({ @@ -8,7 +8,7 @@ const Newsletter = ghostBookshelf.Model.extend({ defaults: function defaults() { return { - uuid: uuid.v4(), + uuid: crypto.randomUUID(), sender_reply_to: 'newsletter', status: 'active', visibility: 'members', diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index c1313faa76c..15a75c0b111 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -1,6 +1,6 @@ // # Post Model const _ = require('lodash'); -const uuid = require('uuid'); +const crypto = require('crypto'); const moment = require('moment'); const {sequence} = require('@tryghost/promise'); const tpl = require('@tryghost/tpl'); @@ -89,7 +89,7 @@ Post = ghostBookshelf.Model.extend({ } return { - uuid: uuid.v4(), + uuid: crypto.randomUUID(), status: 'draft', featured: false, type: 'post', diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index a355bd28099..76455655798 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -1,5 +1,4 @@ const _ = require('lodash'); -const uuid = require('uuid'); const crypto = require('crypto'); const keypair = require('keypair'); const ObjectID = require('bson-objectid').default; @@ -50,7 +49,7 @@ function parseDefaultSettings() { const defaultSettingsFlattened = {}; const dynamicDefault = { - db_hash: () => uuid.v4(), + db_hash: () => crypto.randomUUID(), public_hash: () => crypto.randomBytes(15).toString('hex'), admin_session_secret: () => crypto.randomBytes(32).toString('hex'), theme_session_secret: () => crypto.randomBytes(32).toString('hex'), diff --git a/ghost/core/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js b/ghost/core/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js index c8892f5809b..6329d8c483d 100644 --- a/ghost/core/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +++ b/ghost/core/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js @@ -57,11 +57,22 @@ class EmailAnalyticsServiceWrapper { }); } - async fetchLatest({maxEvents} = {maxEvents: Infinity}) { - logging.info('[EmailAnalytics] Fetch latest started'); + async fetchLatestOpenedEvents({maxEvents} = {maxEvents: Infinity}) { + logging.info('[EmailAnalytics] Fetch latest opened events started'); const fetchStartDate = new Date(); - const totalEvents = await this.service.fetchLatest({maxEvents}); + const totalEvents = await this.service.fetchLatestOpenedEvents({maxEvents}); + const fetchEndDate = new Date(); + + logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest opens)`); + return totalEvents; + } + + async fetchLatestNonOpenedEvents({maxEvents} = {maxEvents: Infinity}) { + logging.info('[EmailAnalytics] Fetch latest non-opened events started'); + + const fetchStartDate = new Date(); + const totalEvents = await this.service.fetchLatestNonOpenedEvents({maxEvents}); const fetchEndDate = new Date(); logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest)`); @@ -69,7 +80,7 @@ class EmailAnalyticsServiceWrapper { } async fetchMissing({maxEvents} = {maxEvents: Infinity}) { - logging.info('[EmailAnalytics] Fetch missing started'); + logging.info('[EmailAnalytics] Fetch missing events started'); const fetchStartDate = new Date(); const totalEvents = await this.service.fetchMissing({maxEvents}); @@ -83,7 +94,7 @@ class EmailAnalyticsServiceWrapper { if (maxEvents < 300) { return 0; } - logging.info('[EmailAnalytics] Fetch scheduled started'); + logging.info('[EmailAnalytics] Fetch scheduled events started'); const fetchStartDate = new Date(); const totalEvents = await this.service.fetchScheduled({maxEvents}); @@ -100,13 +111,34 @@ class EmailAnalyticsServiceWrapper { } this.fetching = true; + // NOTE: Data shows we can process ~2500 events per minute on Pro for a large-ish db (150k members). + // This can vary locally, but we should be conservative with the number of events we fetch. try { - const c1 = await this.fetchLatest({maxEvents: Infinity}); - const c2 = await this.fetchMissing({maxEvents: Infinity}); - - // Only fetch scheduled if we didn't fetch a lot of normal events - await this.fetchScheduled({maxEvents: 20000 - c1 - c2}); - + // Prioritize opens since they are the most important (only data directly displayed to users) + const c1 = await this.fetchLatestOpenedEvents({maxEvents: 10000}); + if (c1 >= 10000) { + this._restartFetch('high opened event count'); + return; + } + + // Set limits on how much we fetch without checkings for opened events. During surge events (following newsletter send) + // we want to make sure we don't spend too much time collecting delivery data. + const c2 = await this.fetchLatestNonOpenedEvents({maxEvents: 10000 - c1}); + const c3 = await this.fetchMissing({maxEvents: 10000 - c1 - c2}); + + // Always restart immediately instead of waiting for the next scheduled job if we're fetching a lot of events + if ((c1 + c2 + c3) > 10000) { + this._restartFetch('high event count'); + return; + } + + // Only backfill if we're not currently fetching a lot of events + const c4 = await this.fetchScheduled({maxEvents: 10000}); + if (c4 > 0) { + this._restartFetch('scheduled backfill'); + return; + } + this.fetching = false; } catch (e) { logging.error(e, 'Error while fetching email analytics'); @@ -116,6 +148,12 @@ class EmailAnalyticsServiceWrapper { } this.fetching = false; } + + _restartFetch(reason) { + this.fetching = false; + logging.info(`[EmailAnalytics] Restarting fetch due to ${reason}`); + this.startFetch(); + } } module.exports = EmailAnalyticsServiceWrapper; diff --git a/ghost/core/core/server/services/email-analytics/lib/queries.js b/ghost/core/core/server/services/email-analytics/lib/queries.js index fbe2019fdc3..362affe5023 100644 --- a/ghost/core/core/server/services/email-analytics/lib/queries.js +++ b/ghost/core/core/server/services/email-analytics/lib/queries.js @@ -1,9 +1,29 @@ const _ = require('lodash'); const debug = require('@tryghost/debug')('services:email-analytics'); const db = require('../../../data/db'); +const logging = require('@tryghost/logging'); +const {default: ObjectID} = require('bson-objectid'); const MIN_EMAIL_COUNT_FOR_OPEN_RATE = 5; +/** @typedef {'email-analytics-latest-opened'|'email-analytics-latest-others'|'email-analytics-missing'|'email-analytics-scheduled'} EmailAnalyticsJobName */ +/** @typedef {'delivered'|'opened'|'failed'} EmailAnalyticsEvent */ + +/** + * Creates a job in the jobs table if it does not already exist. + * @param {EmailAnalyticsJobName} jobName - The name of the job to create. + * @returns {Promise<void>} + */ +async function createJobIfNotExists(jobName) { + await db.knex('jobs').insert({ + id: new ObjectID().toHexString(), + name: jobName, + started_at: new Date(), + created_at: new Date(), + status: 'started' + }).onConflict('name').ignore(); +} + module.exports = { async shouldFetchStats() { // don't fetch stats from Mailgun if we haven't sent any emails @@ -11,47 +31,151 @@ module.exports = { return emailCount && emailCount.count > 0; }, - async getLastSeenEventTimestamp() { + /** + * Retrieves the timestamp of the last seen event for the specified email analytics events. + * @param {EmailAnalyticsJobName} jobName - The name of the job to update. + * @param {EmailAnalyticsEvent[]} [events=['delivered', 'opened', 'failed']] - The email analytics events to consider. + * @returns {Promise<Date|null>} The timestamp of the last seen event, or null if no events are found. + */ + async getLastEventTimestamp(jobName, events = ['delivered', 'opened', 'failed']) { const startDate = new Date(); + + let maxOpenedAt; + let maxDeliveredAt; + let maxFailedAt; + const lastJobRunTimestamp = await this.getLastJobRunTimestamp(jobName); + + if (lastJobRunTimestamp) { + debug(`Using job data for ${jobName}`); + maxOpenedAt = events.includes('opened') ? lastJobRunTimestamp : null; + maxDeliveredAt = events.includes('delivered') ? lastJobRunTimestamp : null; + maxFailedAt = events.includes('failed') ? lastJobRunTimestamp : null; + } else { + debug(`Job data not found for ${jobName}, using email_recipients data`); + logging.info(`Job data not found for ${jobName}, using email_recipients data`); + if (events.includes('opened')) { + maxOpenedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first()).maxOpenedAt; + } + if (events.includes('delivered')) { + maxDeliveredAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first()).maxDeliveredAt; + } + if (events.includes('failed')) { + maxFailedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first()).maxFailedAt; + } + + await createJobIfNotExists(jobName); + } - // three separate queries is much faster than using max/greatest (with coalesce to handle nulls) across columns - let {maxDeliveredAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first() || {}; - let {maxOpenedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first() || {}; - let {maxFailedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first() || {}; + // Convert string dates to Date objects for SQLite compatibility + [maxOpenedAt, maxDeliveredAt, maxFailedAt] = [maxOpenedAt, maxDeliveredAt, maxFailedAt].map(date => ( + date && !(date instanceof Date) ? new Date(date) : date + )); - if (maxDeliveredAt && !(maxDeliveredAt instanceof Date)) { - // SQLite returns a string instead of a Date - maxDeliveredAt = new Date(maxDeliveredAt); - } + const lastSeenEventTimestamp = _.max([maxOpenedAt, maxDeliveredAt, maxFailedAt]); + debug(`getLastEventTimestamp: finished in ${Date.now() - startDate}ms`); - if (maxOpenedAt && !(maxOpenedAt instanceof Date)) { - // SQLite returns a string instead of a Date - maxOpenedAt = new Date(maxOpenedAt); - } + return lastSeenEventTimestamp; + }, - if (maxFailedAt && !(maxFailedAt instanceof Date)) { - // SQLite returns a string instead of a Date - maxFailedAt = new Date(maxFailedAt); - } + /** + * Retrieves the job data for the specified job name. + * @param {EmailAnalyticsJobName} jobName - The name of the job to retrieve data for. + * @returns {Promise<Object|null>} The job data, or null if no job data is found. + */ + async getJobData(jobName) { + return await db.knex('jobs').select('finished_at', 'started_at').where('name', jobName).first(); + }, + + /** + * Retrieves the timestamp of the last job run for the specified job name. + * @param {EmailAnalyticsJobName} jobName - The name of the job to retrieve the last run timestamp for. + * @returns {Promise<Date|null>} The timestamp of the last job run, or null if no job data is found. + */ + async getLastJobRunTimestamp(jobName) { + const jobData = await this.getJobData(jobName); + return jobData ? jobData.finished_at || jobData.started_at : null; + }, - const lastSeenEventTimestamp = _.max([maxDeliveredAt, maxOpenedAt, maxFailedAt]); - debug(`getLastSeenEventTimestamp: finished in ${Date.now() - startDate}ms`); + /** + * Sets the timestamp of the last seen event for the specified email analytics events. + * @param {EmailAnalyticsJobName} jobName - The name of the job to update. + * @param {'completed'|'started'} field - The field to update. + * @param {Date} date - The timestamp of the last seen event. + * @returns {Promise<void>} + * @description + * Updates the `finished_at` or `started_at` column of the specified job in the `jobs` table with the provided timestamp. + * This is used to keep track of the last time the job was run to avoid expensive queries following reboot. + */ + async setJobTimestamp(jobName, field, date) { + // Convert string dates to Date objects for SQLite compatibility + try { + debug(`Setting ${field} timestamp for job ${jobName} to ${date}`); + const updateField = field === 'completed' ? 'finished_at' : 'started_at'; + const status = field === 'completed' ? 'finished' : 'started'; + const result = await db.knex('jobs').update({[updateField]: date, updated_at: new Date(), status: status}).where('name', jobName); + if (result === 0) { + await db.knex('jobs').insert({ + id: new ObjectID().toHexString(), + name: jobName, + [updateField]: date.toISOString(), // force to iso string for sqlite + updated_at: date.toISOString(), // force to iso string for sqlite + status: status + }); + } + } catch (err) { + debug(`Error setting ${field} timestamp for job ${jobName}: ${err.message}`); + } + }, - return lastSeenEventTimestamp; + /** + * Sets the status of the specified email analytics job. + * @param {EmailAnalyticsJobName} jobName - The name of the job to update. + * @param {'started'|'finished'|'failed'} status - The new status of the job. + * @returns {Promise<void>} + * @description + * Updates the `status` column of the specified job in the `jobs` table with the provided status. + * This is used to keep track of the current state of the job. + */ + async setJobStatus(jobName, status) { + debug(`Setting status for job ${jobName} to ${status}`); + try { + const result = await db.knex('jobs') + .update({ + status: status, + updated_at: new Date() + }) + .where('name', jobName); + + if (result === 0) { + await db.knex('jobs').insert({ + id: new ObjectID().toHexString(), + name: jobName, + status: status, + created_at: new Date(), + updated_at: new Date() + }); + } + } catch (err) { + debug(`Error setting status for job ${jobName}: ${err.message}`); + throw err; + } }, - async aggregateEmailStats(emailId) { - const {totalCount} = await db.knex('emails').select(db.knex.raw('email_count as totalCount')).where('id', emailId).first() || {totalCount: 0}; - // use IS NULL here because that will typically match far fewer rows than IS NOT NULL making the query faster - const [undeliveredCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND delivered_at IS NULL', [emailId]); - const [openedCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND opened_at IS NOT NULL', [emailId]); + async aggregateEmailStats(emailId, updateOpenedCount) { + const [deliveredCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND delivered_at IS NOT NULL', [emailId]); const [failedCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND failed_at IS NOT NULL', [emailId]); - await db.knex('emails').update({ - delivered_count: totalCount - undeliveredCount.count, - opened_count: openedCount.count, + const updateData = { + delivered_count: deliveredCount.count, failed_count: failedCount.count - }).where('id', emailId); + }; + + if (updateOpenedCount) { + const [openedCount] = await db.knex('email_recipients').count('id as count').whereRaw('email_id = ? AND opened_at IS NOT NULL', [emailId]); + updateData.opened_count = openedCount.count; + } + + await db.knex('emails').update(updateData).where('id', emailId); }, async aggregateMemberStats(memberId) { @@ -78,4 +202,4 @@ module.exports = { .update(updateQuery) .where('id', memberId); } -}; +}; \ No newline at end of file diff --git a/ghost/core/core/server/services/members-events/index.js b/ghost/core/core/server/services/members-events/index.js index 411e28d840a..0ad9e1fdba4 100644 --- a/ghost/core/core/server/services/members-events/index.js +++ b/ghost/core/core/server/services/members-events/index.js @@ -12,7 +12,7 @@ class MembersEventsServiceWrapper { } // Wire up all the dependencies - const {EventStorage, LastSeenAtUpdater} = require('@tryghost/members-events-service'); + const {EventStorage, LastSeenAtUpdater, LastSeenAtCache} = require('@tryghost/members-events-service'); const models = require('../../models'); // Listen for events and store them in the database @@ -26,6 +26,14 @@ class MembersEventsServiceWrapper { const db = require('../../data/db'); + // Create the last seen at cache and inject it into the last seen at updater + this.lastSeenAtCache = new LastSeenAtCache({ + services: { + settingsCache + } + }); + + // Create the last seen at updater this.lastSeenAtUpdater = new LastSeenAtUpdater({ services: { settingsCache @@ -34,12 +42,22 @@ class MembersEventsServiceWrapper { return members.api; }, db, - events + events, + lastSeenAtCache: this.lastSeenAtCache }); + // Subscribe to domain events this.eventStorage.subscribe(DomainEvents); this.lastSeenAtUpdater.subscribe(DomainEvents); } + + // Clear the last seen at cache + // Utility used for testing purposes + clearLastSeenAtCache() { + if (this.lastSeenAtCache) { + this.lastSeenAtCache.clear(); + } + } } module.exports = new MembersEventsServiceWrapper(); diff --git a/ghost/core/core/server/services/oembed/service.js b/ghost/core/core/server/services/oembed/service.js index 77b7cd72d25..25f55e88b18 100644 --- a/ghost/core/core/server/services/oembed/service.js +++ b/ghost/core/core/server/services/oembed/service.js @@ -1,8 +1,9 @@ const config = require('../../../shared/config'); +const storage = require('../../adapters/storage'); const externalRequest = require('../../lib/request-external'); const OEmbed = require('@tryghost/oembed-service'); -const oembed = new OEmbed({config, externalRequest}); +const oembed = new OEmbed({config, externalRequest, storage}); const NFT = require('./NFTOEmbedProvider'); const nft = new NFT({ diff --git a/ghost/core/core/server/web/parent/middleware/request-id.js b/ghost/core/core/server/web/parent/middleware/request-id.js index 1e537cb191a..152b3b50928 100644 --- a/ghost/core/core/server/web/parent/middleware/request-id.js +++ b/ghost/core/core/server/web/parent/middleware/request-id.js @@ -1,4 +1,4 @@ -const uuid = require('uuid'); +const crypto = require('crypto'); /** * @TODO: move this middleware to Framework monorepo? @@ -8,7 +8,7 @@ const uuid = require('uuid'); * @param {import('express').NextFunction} next */ module.exports = function requestIdMw(req, res, next) { - const requestId = req.get('X-Request-ID') || uuid.v4(); + const requestId = req.get('X-Request-ID') || crypto.randomUUID(); // Set a value for internal use req.requestId = requestId; diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index eabd57ee2bb..4bde8a951c2 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -44,7 +44,8 @@ const ALPHA_FEATURES = [ 'importMemberTier', 'lexicalIndicators', 'adminXDemo', - 'contentVisibility' + 'contentVisibility', + 'commentImprovements' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/core/shared/settings-cache/public.js b/ghost/core/core/shared/settings-cache/public.js index 30fe80f3469..fa6b7766e6a 100644 --- a/ghost/core/core/shared/settings-cache/public.js +++ b/ghost/core/core/shared/settings-cache/public.js @@ -46,5 +46,6 @@ module.exports = { outbound_link_tagging: 'outbound_link_tagging', default_email_address: 'default_email_address', support_email_address: 'support_email_address', - editor_default_email_recipients: 'editor_default_email_recipients' + editor_default_email_recipients: 'editor_default_email_recipients', + labs: 'labs' }; diff --git a/ghost/core/monobundle.js b/ghost/core/monobundle.js index 72157fbf563..af358aabe05 100755 --- a/ghost/core/monobundle.js +++ b/ghost/core/monobundle.js @@ -119,8 +119,6 @@ function getWorkspaces(from) { continue; } - console.log(`packaging ${w}`); - workspacePkgInfo.pkg.version = pkgInfo.pkg.version; workspacePkgInfo.write(); @@ -128,21 +126,21 @@ function getWorkspaces(from) { const packedFilename = `file:` + path.join(bundlePath, `${slugifiedName}-${workspacePkgInfo.pkg.version}.tgz`); if (pkgInfo.pkg.dependencies[workspacePkgInfo.pkg.name]) { - console.log(`- dependencies override for ${workspacePkgInfo.pkg.name} to ${packedFilename}`); + console.log(`[${workspacePkgInfo.pkg.name}] dependencies override => ${packedFilename}`); pkgInfo.pkg.dependencies[workspacePkgInfo.pkg.name] = packedFilename; } if (pkgInfo.pkg.devDependencies[workspacePkgInfo.pkg.name]) { - console.log(`- devDependencies override for ${workspacePkgInfo.pkg.name} to ${packedFilename}`); + console.log(`[${workspacePkgInfo.pkg.name}] devDependencies override => ${packedFilename}`); pkgInfo.pkg.devDependencies[workspacePkgInfo.pkg.name] = packedFilename; } if (pkgInfo.pkg.optionalDependencies[workspacePkgInfo.pkg.name]) { - console.log(`- optionalDependencies override for ${workspacePkgInfo.pkg.name} to ${packedFilename}`); + console.log(`[${workspacePkgInfo.pkg.name}] optionalDependencies override => ${packedFilename}`); pkgInfo.pkg.optionalDependencies[workspacePkgInfo.pkg.name] = packedFilename; } - console.log(`- resolution override for ${workspacePkgInfo.pkg.name} to ${packedFilename}\n`); + console.log(`[${workspacePkgInfo.pkg.name}] resolution override => ${packedFilename}\n`); pkgInfo.pkg.resolutions[workspacePkgInfo.pkg.name] = packedFilename; packagesToPack.push(w); diff --git a/ghost/core/package.json b/ghost/core/package.json index 500f60ef617..792947d315d 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.93.0", + "version": "5.94.2", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -65,6 +65,7 @@ "@opentelemetry/sdk-node": "0.52.1", "@opentelemetry/sdk-trace-node": "1.25.1", "@sentry/node": "7.119.0", + "@slack/webhook": "7.0.3", "@tryghost/adapter-base-cache": "0.1.12", "@tryghost/adapter-cache-redis": "0.0.0", "@tryghost/adapter-manager": "0.0.0", @@ -73,7 +74,7 @@ "@tryghost/api-framework": "0.0.0", "@tryghost/api-version-compatibility-service": "0.0.0", "@tryghost/audience-feedback": "0.0.0", - "@tryghost/bookshelf-plugins": "0.6.23", + "@tryghost/bookshelf-plugins": "0.6.24", "@tryghost/bootstrap-socket": "0.0.0", "@tryghost/collections": "0.0.0", "@tryghost/color-utils": "0.2.2", @@ -108,9 +109,9 @@ "@tryghost/kg-converters": "1.0.7", "@tryghost/kg-default-atoms": "5.0.3", "@tryghost/kg-default-cards": "10.0.9", - "@tryghost/kg-default-nodes": "1.1.17", - "@tryghost/kg-html-to-lexical": "1.1.18", - "@tryghost/kg-lexical-html-renderer": "1.1.20", + "@tryghost/kg-default-nodes": "1.1.18", + "@tryghost/kg-html-to-lexical": "1.1.19", + "@tryghost/kg-lexical-html-renderer": "1.1.21", "@tryghost/kg-mobiledoc-html-renderer": "7.0.6", "@tryghost/limit-service": "1.2.14", "@tryghost/link-redirects": "0.0.0", @@ -141,7 +142,7 @@ "@tryghost/mw-version-match": "0.0.0", "@tryghost/mw-vhost": "0.0.0", "@tryghost/nodemailer": "0.3.45", - "@tryghost/nql": "0.12.4", + "@tryghost/nql": "0.12.5", "@tryghost/oembed-service": "0.0.0", "@tryghost/package-json": "0.0.0", "@tryghost/post-revisions": "0.0.0", @@ -168,7 +169,7 @@ "@tryghost/webmentions": "0.0.0", "@tryghost/zip": "1.1.46", "amperize": "0.6.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "bookshelf": "1.2.0", "bookshelf-relations": "2.7.0", "brute-knex": "4.0.1", @@ -181,7 +182,7 @@ "cookie-session": "2.1.0", "cors": "2.8.5", "downsize": "0.0.8", - "express": "4.19.2", + "express": "4.21.0", "express-brute": "1.0.1", "express-hbs": "2.5.0", "express-jwt": "8.4.1", @@ -193,7 +194,7 @@ "ghost-storage-base": "1.0.0", "glob": "8.1.0", "got": "11.8.6", - "gscan": "4.43.3", + "gscan": "4.43.5", "human-number": "2.0.4", "image-size": "1.1.1", "intl": "1.2.5", @@ -220,11 +221,12 @@ "sanitize-html": "2.13.0", "semver": "7.6.3", "stoppable": "1.1.0", - "uuid": "9.0.1", + "superagent": "5.1.0", + "superagent-throttle": "1.0.1", "ws": "8.18.0", "xml": "1.0.1", "y-protocols": "1.0.6", - "yjs": "13.6.18" + "yjs": "13.6.19" }, "optionalDependencies": { "@sentry/profiling-node": "7.119.0", @@ -233,7 +235,7 @@ }, "devDependencies": { "@actions/core": "1.10.1", - "@playwright/test": "1.38.1", + "@playwright/test": "1.46.1", "@tryghost/express-test": "0.13.15", "@tryghost/webhook-mock-receiver": "0.2.14", "@types/common-tags": "1.8.4", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index c80a2e73b77..9553d856dde 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -18,6 +18,7 @@ Object { "audienceFeedback": true, "collections": true, "collectionsCard": true, + "commentImprovements": true, "contentVisibility": true, "editorExcerpt": true, "emailCustomization": true, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index 9d567e3bafb..88762c1ab3d 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -118,10 +118,11 @@ Object { <title>Post with email-only card