From ba1a92a2b0b430cbd1572155b1b70d55dd79928a Mon Sep 17 00:00:00 2001 From: Renato Costa Date: Thu, 28 May 2026 12:25:09 +0300 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20Added=20editor=20presence=20i?= =?UTF-8?q?ndicator=20behind=20editorPresence=20labs=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows avatars of staff currently editing a post in the editor header and on each post-list row. SSE-based, gated by the editorPresence labs flag. State and event bus live in-process on PostPresenceService — Ghost(Pro) is single-tenant per site so no Redis or cross-process plumbing is needed. Avatars fade (idle) at 90s of API silence and are removed at 180s. Explicit enter/leave POSTs handle in-app navigation; a pagehide beacon (with fetch keepalive fallback) handles tab close. TTL is the safety net. The cleanup timer starts lazily on the first mark, so a process with no active editors does no presence work. --- .../advanced/labs/private-features.tsx | 4 + .../app/components/gh-presence-avatars.hbs | 27 ++ .../app/components/gh-presence-avatars.js | 50 ++++ .../posts-list/list-item-analytics.hbs | 5 + ghost/admin/app/routes/lexical-editor/edit.js | 19 ++ ghost/admin/app/services/feature.js | 1 + ghost/admin/app/services/presence.js | 167 ++++++++++++ ghost/admin/app/services/session.js | 9 + ghost/admin/app/styles/app.css | 1 + .../styles/components/presence-avatars.css | 101 ++++++++ ghost/admin/app/styles/layouts/editor.css | 2 +- ghost/admin/app/templates/lexical-editor.hbs | 19 +- ghost/core/core/server/api/endpoints/posts.js | 30 ++- .../server/services/post-presence/index.js | 6 + .../post-presence/post-presence-service.js | 245 ++++++++++++++++++ .../api/endpoints/admin/lib/presence-enter.js | 29 +++ .../api/endpoints/admin/lib/presence-leave.js | 24 ++ .../endpoints/admin/lib/presence-stream.js | 90 +++++++ .../server/web/api/endpoints/admin/routes.js | 8 + ghost/core/core/shared/labs.js | 3 +- 20 files changed, 830 insertions(+), 10 deletions(-) create mode 100644 ghost/admin/app/components/gh-presence-avatars.hbs create mode 100644 ghost/admin/app/components/gh-presence-avatars.js create mode 100644 ghost/admin/app/services/presence.js create mode 100644 ghost/admin/app/styles/components/presence-avatars.css create mode 100644 ghost/core/core/server/services/post-presence/index.js create mode 100644 ghost/core/core/server/services/post-presence/post-presence-service.js create mode 100644 ghost/core/core/server/web/api/endpoints/admin/lib/presence-enter.js create mode 100644 ghost/core/core/server/web/api/endpoints/admin/lib/presence-leave.js create mode 100644 ghost/core/core/server/web/api/endpoints/admin/lib/presence-stream.js diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 321f382936d..c8c45a8c715 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -67,6 +67,10 @@ const features: Feature[] = [{ title: 'LLMs.txt', description: 'Serve llms.txt, per-entry markdown exports, and Accept: text/markdown content negotiation for AI and LLM tooling', flag: 'llmsTxt' +}, { + title: 'Editor presence', + description: 'Show avatars in the editor header and the post list for staff currently editing a post.', + flag: 'editorPresence' }]; const AlphaFeatures: React.FC = () => { diff --git a/ghost/admin/app/components/gh-presence-avatars.hbs b/ghost/admin/app/components/gh-presence-avatars.hbs new file mode 100644 index 00000000000..fe635a1ee61 --- /dev/null +++ b/ghost/admin/app/components/gh-presence-avatars.hbs @@ -0,0 +1,27 @@ +{{#if this.users.length}} +
+ {{#each this.users as |user|}} +
+ {{#if user.profileImage}} + {{user.name}} + {{else}} + {{user.initials}} + {{/if}} + +
+ {{/each}} + {{#if this.overflowCount}} + + +{{this.overflowCount}} + + + {{/if}} +
+{{/if}} diff --git a/ghost/admin/app/components/gh-presence-avatars.js b/ghost/admin/app/components/gh-presence-avatars.js new file mode 100644 index 00000000000..492958b4790 --- /dev/null +++ b/ghost/admin/app/components/gh-presence-avatars.js @@ -0,0 +1,50 @@ +import Component from '@glimmer/component'; +import {inject as service} from '@ember/service'; + +// Smaller cap in the editor header — that row uses flex-wrap and a +// fixed height, so a stack wider than the available space wraps and +// overlaps page content. Post-list rows have a dedicated row of width +// per item and can comfortably hold one more. +const CAP_BY_SIZE = {md: 2, sm: 3}; + +export default class GhPresenceAvatars extends Component { + @service presence; + + get _allUsers() { + return this.presence.usersForPost(this.args.postId).map((user) => { + const name = user.name || 'Someone'; + const parts = name.split(/\s+/).filter(Boolean); + const firstName = parts[0] || name; + const tooltip = firstName.length > 20 ? `${firstName.slice(0, 20)}…` : firstName; + return { + id: user.id, + name, + firstName, + tooltipText: user.isIdle ? `${tooltip} (idle)` : tooltip, + profileImage: user.profileImage || null, + isIdle: Boolean(user.isIdle), + initials: parts.slice(0, 2).map(part => part[0]).join('').toUpperCase() + }; + }); + } + + get _cap() { + return CAP_BY_SIZE[this.args.size === 'sm' ? 'sm' : 'md']; + } + + get users() { + return this._allUsers.slice(0, this._cap); + } + + get overflowCount() { + return Math.max(0, this._allUsers.length - this._cap); + } + + get overflowTooltip() { + return this._allUsers.slice(this._cap).map(u => u.firstName).join(', '); + } + + get sizeClass() { + return this.args.size === 'sm' ? 'gh-presence-avatars--sm' : 'gh-presence-avatars--md'; + } +} diff --git a/ghost/admin/app/components/posts-list/list-item-analytics.hbs b/ghost/admin/app/components/posts-list/list-item-analytics.hbs index d407579512d..96b9b841c3c 100644 --- a/ghost/admin/app/components/posts-list/list-item-analytics.hbs +++ b/ghost/admin/app/components/posts-list/list-item-analytics.hbs @@ -330,6 +330,11 @@ {{/if}} + {{!-- Presence column --}} + {{#if (feature 'editorPresence')}} + + {{/if}} + {{!-- Button column --}} {{#if @post.hasAnalyticsPage }}