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..47aa8dd5549 --- /dev/null +++ b/ghost/admin/app/components/gh-presence-avatars.js @@ -0,0 +1,64 @@ +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() { + const raw = this.presence.usersForPost(this.args.postId); + + // Detect first-name collisions so we can disambiguate ("Alex S." + // / "Alex J.") rather than rendering two identical tooltips. + const firstNameCounts = new Map(); + const parsed = raw.map((user) => { + const name = user.name || 'Someone'; + const parts = name.split(/\s+/).filter(Boolean); + const firstName = parts[0] || name; + firstNameCounts.set(firstName, (firstNameCounts.get(firstName) || 0) + 1); + return {user, name, parts, firstName}; + }); + + return parsed.map(({user, name, parts, firstName}) => { + let display = firstName; + if (firstNameCounts.get(firstName) > 1 && parts.length > 1) { + display = `${firstName} ${parts[parts.length - 1][0].toUpperCase()}.`; + } + const tooltip = display.length > 20 ? `${display.slice(0, 20)}…` : display; + return { + id: user.id, + name, + firstName: display, + 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 }}