Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
27 changes: 27 additions & 0 deletions ghost/admin/app/components/gh-presence-avatars.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{{#if this.users.length}}
<div class="gh-presence-avatars {{this.sizeClass}}" data-test-presence-avatars={{@postId}}>
{{#each this.users as |user|}}
<div
class="gh-presence-avatar gh-tooltip-trigger tooltip-bottom no-shortcut {{if user.isIdle "gh-presence-avatar--idle"}}"
data-test-presence-avatar={{user.id}}
data-test-presence-idle={{if user.isIdle "true" "false"}}
>
{{#if user.profileImage}}
<img src={{user.profileImage}} alt={{user.name}} />
{{else}}
<span class="gh-presence-avatar-initials">{{user.initials}}</span>
{{/if}}
<GhTooltip @text={{user.tooltipText}} />
</div>
{{/each}}
{{#if this.overflowCount}}
<span
class="gh-presence-avatars-overflow gh-tooltip-trigger tooltip-bottom no-shortcut"
data-test-presence-overflow={{this.overflowCount}}
>
+{{this.overflowCount}}
<GhTooltip @text={{this.overflowTooltip}} />
</span>
{{/if}}
</div>
{{/if}}
64 changes: 64 additions & 0 deletions ghost/admin/app/components/gh-presence-avatars.js
Original file line number Diff line number Diff line change
@@ -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';
}
}
5 changes: 5 additions & 0 deletions ghost/admin/app/components/posts-list/list-item-analytics.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@
{{/if}}
</div>

{{!-- Presence column --}}
{{#if (feature 'editorPresence')}}
<GhPresenceAvatars @postId={{@post.id}} @size="sm" />
{{/if}}

{{!-- Button column --}}
{{#if @post.hasAnalyticsPage }}
<a href="#/posts/analytics/{{@post.id}}" class="permalink gh-list-data gh-post-list-button" title="">
Expand Down
19 changes: 19 additions & 0 deletions ghost/admin/app/routes/lexical-editor/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {pluralize} from 'ember-inflector';
import {inject as service} from '@ember/service';
export default class EditRoute extends AuthenticatedRoute {
@service feature;
@service presence;

_activePostId = null;

beforeModel(transition) {
super.beforeModel(...arguments);
Expand Down Expand Up @@ -76,5 +79,21 @@ export default class EditRoute extends AuthenticatedRoute {
setupController(controller, post) {
let editor = this.controllerFor('lexical-editor');
editor.setPost(post);
// editor → editor navigation reuses this route instance and does
// not fire deactivate. The presence service's enterPost detects
// the post id change and sends a leave for the previous post
// before entering the new one.
this._activePostId = post?.id || null;
if (this._activePostId) {
this.presence.enterPost(this._activePostId);
}
}

deactivate() {
super.deactivate(...arguments);
if (this._activePostId) {
this.presence.leavePost(this._activePostId);
this._activePostId = null;
}
}
}
1 change: 1 addition & 0 deletions ghost/admin/app/services/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default class FeatureService extends Service {
@feature('tagsX') tagsX;
@feature('commentModeration') commentModeration;
@feature('giftSubscriptions') giftSubscriptions;
@feature('editorPresence') editorPresence;
_user = null;

@computed('settings.labs')
Expand Down
207 changes: 207 additions & 0 deletions ghost/admin/app/services/presence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import Service, {inject as service} from '@ember/service';
import fetch from 'fetch';
import {tracked} from '@glimmer/tracking';

// Wire-format event types. Must match PRESENCE_EVENT_TYPES in
// ghost/core/core/server/services/post-presence/post-presence-service.js
// (no shared module across the Node/Ember boundary).
const EVENT_TYPE_SNAPSHOT = 'snapshot';
const EVENT_TYPE_POST = 'post';

const CONNECTING_ERROR_LOG_THRESHOLD = 3;

/**
* Subscribes to a long-lived SSE stream that pushes editor presence
* updates to the admin.
*/
export default class PresenceService extends Service {
@service feature;
@service ghostPaths;
@service session;

@tracked _byPostId = new Map();

_source = null;
_currentPostId = null;
_beforeUnloadHandler = null;
_initFailed = false;
_connectingErrorCount = 0;
_connectingErrorLogged = false;

start() {
if (this._source || typeof window === 'undefined' || !window.EventSource) {
return;
}
if (!this.feature.get('editorPresence')) {
return;
}
const streamUrl = this.ghostPaths.url.api('presence', 'stream');
try {
this._source = new EventSource(streamUrl, {withCredentials: true});
} catch (e) {
this._initFailed = true;
console.warn('[presence] EventSource construction failed', e); // eslint-disable-line no-console
return;
}
this._source.onmessage = event => this._handleMessage(event);
this._source.onopen = () => {
// Fires on initial connect AND after EventSource auto-reconnect.
// Re-send the current enter so peers see the user without
// waiting for the next autosave heartbeat.
if (this._connectingErrorLogged) {
this._connectingErrorLogged = false;
}
this._connectingErrorCount = 0;
if (this._currentPostId) {
this._sendEnter(this._currentPostId);
}
};
this._source.onerror = () => {
// EventSource auto-reconnects on transient errors. Terminal
// closures (401/403/404) leave readyState === CLOSED; log
// those so silent presence failures are debuggable.
if (!this._source) {
return;
}
if (this._source.readyState === EventSource.CLOSED) {
console.warn('[presence] SSE stream closed; not reconnecting'); // eslint-disable-line no-console
return;
}
if (this._source.readyState === EventSource.CONNECTING) {
this._connectingErrorCount += 1;
if (this._connectingErrorCount >= CONNECTING_ERROR_LOG_THRESHOLD && !this._connectingErrorLogged) {
this._connectingErrorLogged = true;
console.warn('[presence] SSE reconnects are failing'); // eslint-disable-line no-console
}
}
};

// Pagehide covers tab/window close, where the Ember route's
// deactivate hook does not fire. TTL is the safety net if the
// beacon does not arrive.
this._beforeUnloadHandler = () => {
const postId = this._currentPostId;
if (!postId) {
return;
}
this._sendLeave(postId);
};
window.addEventListener('pagehide', this._beforeUnloadHandler);
}

stop() {
if (this._source) {
this._source.close();
this._source = null;
}
if (this._beforeUnloadHandler) {
window.removeEventListener('pagehide', this._beforeUnloadHandler);
this._beforeUnloadHandler = null;
}
this._currentPostId = null;
this._byPostId = new Map();
}

/**
* The user opened a post in the editor. Sends an explicit enter so
* peers see the avatar (the read endpoint deliberately does not mark
* presence — that would have lit up analytics views too).
*/
enterPost(postId) {
if (!postId) {
return;
}
if (this._initFailed) {
console.warn('[presence] skipping enter; SSE init failed earlier'); // eslint-disable-line no-console
return;
}
if (this._currentPostId && this._currentPostId !== postId) {
this.leavePost(this._currentPostId);
}
this._currentPostId = postId;
this._sendEnter(postId);
}

_sendEnter(postId) {
const enterUrl = this.ghostPaths.url.api('presence', 'posts', postId, 'enter');
fetch(enterUrl, {method: 'POST', credentials: 'include', keepalive: true})
.catch(err => console.warn('[presence] enter failed', err)); // eslint-disable-line no-console
}

leavePost(postId) {
if (!postId) {
return;
}
if (this._currentPostId === postId) {
this._currentPostId = null;
}
this._sendLeave(postId);
}

usersForPost(postId) {
if (!postId) {
return [];
}
const users = this._byPostId.get(postId) || [];
const currentUserId = this.session.user?.id;
if (!currentUserId) {
return users;
}
return users.filter(user => user && user.id !== currentUserId);
}

_sendLeave(postId) {
const leaveUrl = this.ghostPaths.url.api('presence', 'posts', postId, 'leave');
// sendBeacon returns false when the UA's beacon queue is full
// (Firefox enforces this); fall through to fetch so the leave
// doesn't silently drop.
const queued = typeof navigator !== 'undefined'
&& typeof navigator.sendBeacon === 'function'
&& navigator.sendBeacon(leaveUrl);
if (queued) {
return;
}
fetch(leaveUrl, {method: 'POST', credentials: 'include', keepalive: true})
.catch(err => console.warn('[presence] leave failed', err)); // eslint-disable-line no-console
}

_handleMessage(event) {
this._connectingErrorCount = 0;
this._connectingErrorLogged = false;

let payload;
try {
payload = JSON.parse(event.data);
} catch (e) {
console.warn('[presence] malformed event payload, dropping', {err: e, data: event.data}); // eslint-disable-line no-console
return;
}

if (payload?.type === EVENT_TYPE_SNAPSHOT && Array.isArray(payload.posts)) {
const next = new Map();
for (const entry of payload.posts) {
if (entry?.postId && Array.isArray(entry.users)) {
next.set(entry.postId, entry.users);
}
}
this._byPostId = next;
return;
}

if (payload?.type === EVENT_TYPE_POST && payload.postId) {
const next = new Map(this._byPostId);
const users = Array.isArray(payload.users) ? payload.users : [];
if (users.length === 0) {
next.delete(payload.postId);
} else {
next.set(payload.postId, users);
}
this._byPostId = next;
}
}

willDestroy() {
super.willDestroy();
this.stop();
}
}
9 changes: 9 additions & 0 deletions ghost/admin/app/services/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {tracked} from '@glimmer/tracking';

export default class SessionService extends ESASessionService {
@service configManager;
@service presence;
@service('store') dataStore;
@service feature;
@service koenig;
Expand Down Expand Up @@ -73,6 +74,11 @@ export default class SessionService extends ESASessionService {

this.loadServerNotifications();

// Open the presence stream once features are loaded so the
// service can check the editorPresence flag. No-op when the
// flag is off or the browser lacks EventSource.
this.presence.start();

// pre-emptively load editor code in the background to avoid loading state when opening editor
this.koenig.fetch();
}
Expand Down Expand Up @@ -129,6 +135,9 @@ export default class SessionService extends ESASessionService {
}

handleInvalidation() {
// Close the presence SSE stream alongside the session.
this.presence.stop();

let transition = this.appLoadTransition;

if (transition) {
Expand Down
Loading
Loading