Skip to content
Draft
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
2 changes: 2 additions & 0 deletions ghost/admin/app/components/gh-editor-post-status.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,6 @@
Draft
{{unless @hasDirtyAttributes "- Saved"}}
{{/if}}

<GhPostEditingIndicator @post={{@post}} />
</div>
2 changes: 1 addition & 1 deletion ghost/admin/app/components/gh-member-avatar.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<figure class="gh-member-gravatar {{@containerClass}}">
{{#if @member.email}}
{{#if (or @member.email @member.name @member.avatarImage @member.avatar_image @name)}}
<div class="gh-member-initials flex items-center justify-center br-100 {{@containerClass}}" style={{this.backgroundStyle}}>
<span class="gh-member-avatar-label {{or @sizeClass "gh-member-list-avatar"}}">{{this.initials}}</span>
</div>
Expand Down
22 changes: 22 additions & 0 deletions ghost/admin/app/components/gh-post-editing-indicator.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{{#if this.activeEditor}}
<span
class={{this.indicatorClass}}
data-test-active-editor
title="Last activity {{gh-format-post-time this.activeEditor.heartbeatAt absolute=true}}"
>
{{#if this.isListVariant}}
{{svg-jar "pen"}}
<span class="gh-post-editing-indicator-label">{{this.indicatorText}}</span>
{{else}}
{{#if this.indicatorPrefix}}
<span class="gh-post-editing-indicator-prefix">{{this.indicatorPrefix}}</span>
{{/if}}
<GhMemberAvatar
@member={{this.editingMember}}
@containerClass="gh-post-editing-indicator-avatar"
@sizeClass="gh-post-editing-indicator-avatar-label"
/>
<span class="gh-post-editing-indicator-label">{{this.indicatorText}}</span>
{{/if}}
</span>
{{/if}}
76 changes: 76 additions & 0 deletions ghost/admin/app/components/gh-post-editing-indicator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Component from '@glimmer/component';
import moment from 'moment-timezone';
import {get} from '@ember/object';
import {inject as service} from '@ember/service';

const ACTIVE_EDITING_TTL_SECONDS = 120;

export default class GhPostEditingIndicatorComponent extends Component {
@service clock;
@service session;

get activeEditor() {
// force a recompute while the lease is ticking down
get(this.clock, 'second');

const heartbeatAt = this.args.post?.editingHeartbeatAt;
const editingBy = this.args.post?.editingBy;
const editingName = this.args.post?.editingName;

if (!heartbeatAt || !editingBy || editingBy === this.session.user.id) {
return null;
}

if (moment.utc().diff(moment.utc(heartbeatAt), 'seconds') >= ACTIVE_EDITING_TTL_SECONDS) {
return null;
}

return {
name: editingName || 'Another staff user',
heartbeatAt: moment.utc(heartbeatAt).toISOString()
};
}

get isListVariant() {
return this.args.variant === 'list';
}

get indicatorClass() {

Check warning on line 38 in ghost/admin/app/components/gh-post-editing-indicator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this property or method or refactor "GhPostEditingIndicatorComponent", as "indicatorClass" is not used inside component body

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2kZlhVC2U7dMZ87&open=AZ0wD2kZlhVC2U7dMZ87&pullRequest=27005
return `gh-post-editing-indicator gh-post-editing-indicator--${this.isListVariant ? 'list' : 'editor'}`;
}

get indicatorText() {

Check warning on line 42 in ghost/admin/app/components/gh-post-editing-indicator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this property or method or refactor "GhPostEditingIndicatorComponent", as "indicatorText" is not used inside component body

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2kZlhVC2U7dMZ88&open=AZ0wD2kZlhVC2U7dMZ88&pullRequest=27005
if (!this.activeEditor) {
return null;
}

if (this.isListVariant) {
return `Being edited by ${this.activeEditor.name}`;
}

return this.activeEditor.name;
}

get indicatorPrefix() {

Check warning on line 54 in ghost/admin/app/components/gh-post-editing-indicator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this property or method or refactor "GhPostEditingIndicatorComponent", as "indicatorPrefix" is not used inside component body

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2kZlhVC2U7dMZ89&open=AZ0wD2kZlhVC2U7dMZ89&pullRequest=27005
if (!this.activeEditor) {
return null;
}

if (this.isListVariant) {
return null;
}

return 'Currently being edited by';
}

get editingMember() {

Check warning on line 66 in ghost/admin/app/components/gh-post-editing-indicator.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this property or method or refactor "GhPostEditingIndicatorComponent", as "editingMember" is not used inside component body

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2kZlhVC2U7dMZ8-&open=AZ0wD2kZlhVC2U7dMZ8-&pullRequest=27005
if (!this.activeEditor) {
return null;
}

return {
name: this.activeEditor.name,
avatarImage: this.args.post?.editingAvatar || null
};
}
}
2 changes: 2 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 @@ -53,6 +53,7 @@
{{/if}}
</span>
</p>
<GhPostEditingIndicator @post={{@post}} @variant="list" />
{{/unless}}
</div>
</a>
Expand Down Expand Up @@ -160,6 +161,7 @@
</span>
{{/if}}
</p>
<GhPostEditingIndicator @post={{@post}} @variant="list" />
{{/unless}}
</div>
</LinkTo>
Expand Down
12 changes: 7 additions & 5 deletions ghost/admin/app/components/posts-list/list-item.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@
and sent to {{gh-pluralize @post.email.emailCount "member"}}
{{else}}
and sent
{{/if}}
{{/if}}
{{/if}}
</span>
</span>
</p>
<GhPostEditingIndicator @post={{@post}} @variant="list" />
{{/unless}}
</a>
{{else}}
Expand Down Expand Up @@ -137,10 +138,11 @@
{{#if this.isHovered}}
<span {{css-transition "anim-fade-in-scale"}}>to {{gh-pluralize @post.email.emailCount "member"}}</span>
{{/if}}
{{/if}}
</span>
{{/if}}
{{/if}}
</span>
{{/if}}
</p>
<GhPostEditingIndicator @post={{@post}} @variant="list" />
{{/unless}}
</LinkTo>
{{/if}}
Expand Down
57 changes: 57 additions & 0 deletions ghost/admin/app/controllers/lexical-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
const AUTOSAVE_TIMEOUT = 3000;
// time in ms to force a save if the user is continuously typing
const TIMEDSAVE_TIMEOUT = 60000;
const EDITING_LEASE_INTERVAL = 30000;

const TK_REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u);
const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u);
Expand Down Expand Up @@ -160,6 +161,7 @@
@service settings;
@service ui;
@service localRevisions;
@service postEditing;

@inject config;

Expand All @@ -185,6 +187,7 @@
_leaveConfirmed = false;
_saveOnLeavePerformed = false;
_previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes
editingSessionId = null;

/* debug properties ------------------------------------------------------*/

Expand Down Expand Up @@ -913,6 +916,8 @@
if (titlesMatch && bodiesMatch) {
this.set('hasDirtyAttributes', false);
}

this._startEditingLease(post);
}

@task
Expand Down Expand Up @@ -1071,6 +1076,7 @@
// don't do anything else if we're setting the same post
if (post === this.post) {
this.set('shouldFocusTitle', post.get('isNew'));
this._startEditingLease(post);
return;
}

Expand All @@ -1090,6 +1096,7 @@
// TODO: can these be `boundOneWay` on the model as per the other attrs?
post.set('titleScratch', post.get('title'));
post.set('lexicalScratch', post.get('lexical'));
this._startEditingLease(post);

this._previousTagNames = this._tagNames;

Expand Down Expand Up @@ -1250,10 +1257,14 @@
// called when the editor route is left or the post model is swapped
reset() {
let post = this.post;
const editingSessionId = this.editingSessionId;
const postId = post?.id;
const postType = post?.displayName;

// make sure the save tasks aren't still running in the background
// after leaving the edit route
this.cancelAutosave();
this.editingLeaseTask.cancelAll();

if (post) {
// clear post of any unsaved, client-generated tags
Expand All @@ -1267,9 +1278,16 @@
}
}

if (postId && editingSessionId) {
this.postEditing.release({postId, postType, sessionId: editingSessionId}).catch(() => {
// ignore lease release failures and rely on TTL expiry
});
}

this._previousTagNames = [];
this._leaveConfirmed = false;
this._saveOnLeavePerformed = false;
this.editingSessionId = null;

this._setPostState = null;
this._postStates = [];
Expand Down Expand Up @@ -1318,6 +1336,20 @@
}).drop())
_timedSaveTask;

@restartableTask
*editingLeaseTask(postId, postType, sessionId) {
while (this.post?.id === postId && this.editingSessionId === sessionId) {
try {
const lease = yield this.postEditing.touch({postId, postType, sessionId});
this._applyEditingLease(lease);
} catch (error) {
// ignore presence errors, this is informational only
}

Check warning on line 1347 in ghost/admin/app/controllers/lexical-editor.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2k2lhVC2U7dMZ8_&open=AZ0wD2k2lhVC2U7dMZ8_&pullRequest=27005

yield timeout(config.environment === 'test' ? 100 : EDITING_LEASE_INTERVAL);
}
}

/* Private methods -------------------------------------------------------*/

_assignLexicalDiffToLeaveModalReason() {
Expand Down Expand Up @@ -1360,6 +1392,31 @@
}
}

_applyEditingLease(lease) {
if (!lease || !this.post || lease.id !== this.post.id) {
return;
}

this.post.setProperties({
editingBy: lease.editing_by || null,
editingName: lease.editing_name || null,
editingAvatar: lease.editing_avatar || null,
editingHeartbeatAt: lease.editing_heartbeat_at ? moment.utc(lease.editing_heartbeat_at) : null
});
}

_startEditingLease(post = this.post) {
if (!post || post.isNew || !post.id) {
return;
}

if (!this.editingSessionId) {
this.editingSessionId = this.postEditing.generateSessionId();
}

this.editingLeaseTask.perform(post.id, post.displayName, this.editingSessionId);
}

_hasDirtyAttributes() {
let post = this.post;

Expand Down
4 changes: 4 additions & 0 deletions ghost/admin/app/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export default Model.extend(Comparable, ValidationEngine, {
featureImageAlt: attr('string'),
featureImageCaption: attr('string'),
showTitleAndFeatureImage: attr('boolean', {defaultValue: true}),
editingBy: attr('string'),
editingName: attr('string'),
editingAvatar: attr('string'),
editingHeartbeatAt: attr('moment-utc'),

authors: hasMany('user', {embedded: 'always', async: false}),
email: belongsTo('email', {async: false}),
Expand Down
8 changes: 8 additions & 0 deletions ghost/admin/app/serializers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export default class PostSerializer extends ApplicationSerializer.extend(Embedde
publishedAtUTC: {key: 'published_at'},
createdAtUTC: {key: 'created_at'},
updatedAtUTC: {key: 'updated_at'},
editingBy: {key: 'editing_by'},
editingName: {key: 'editing_name'},
editingAvatar: {key: 'editing_avatar'},
editingHeartbeatAt: {key: 'editing_heartbeat_at'},
email: {embedded: 'always'},
newsletter: {embedded: 'always'},
postRevisions: {embedded: 'always'}
Expand All @@ -27,6 +31,10 @@ export default class PostSerializer extends ApplicationSerializer.extend(Embedde
delete json.email;
delete json.newsletter;
delete json.post_revisions;
delete json.editing_by;
delete json.editing_name;
delete json.editing_avatar;
delete json.editing_heartbeat_at;
// Deprecated property (replaced with data.authors)
delete json.author;
// Page-only properties
Expand Down
39 changes: 39 additions & 0 deletions ghost/admin/app/services/post-editing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Service from '@ember/service';

Check warning on line 1 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'@ember/service' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h0lhVC2U7dMZ80&open=AZ0wD2h0lhVC2U7dMZ80&pullRequest=27005
import {inject as service} from '@ember/service';

Check warning on line 2 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'@ember/service' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h1lhVC2U7dMZ81&open=AZ0wD2h1lhVC2U7dMZ81&pullRequest=27005

export default class PostEditingService extends Service {
@service ajax;
@service ghostPaths;

generateSessionId() {
if (window.crypto?.randomUUID) {

Check warning on line 9 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h1lhVC2U7dMZ82&open=AZ0wD2h1lhVC2U7dMZ82&pullRequest=27005
return window.crypto.randomUUID();

Check warning on line 10 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h1lhVC2U7dMZ83&open=AZ0wD2h1lhVC2U7dMZ83&pullRequest=27005
}

return `editing-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

async touch({postId, postType, sessionId}) {
const url = new URL(this.ghostPaths.url.api(this._resourcePath(postType), postId, 'editing'), window.location.href);

Check warning on line 17 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h1lhVC2U7dMZ85&open=AZ0wD2h1lhVC2U7dMZ85&pullRequest=27005
url.searchParams.set('session_id', sessionId);

const response = await this.ajax.request(url.href, {
method: 'POST'
});

return response[this._resourcePath(postType)]?.[0] || null;
}

async release({postId, postType, sessionId}) {
const url = new URL(this.ghostPaths.url.api(this._resourcePath(postType), postId, 'editing'), window.location.href);

Check warning on line 28 in ghost/admin/app/services/post-editing.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ0wD2h1lhVC2U7dMZ86&open=AZ0wD2h1lhVC2U7dMZ86&pullRequest=27005
url.searchParams.set('session_id', sessionId);

await this.ajax.request(url.href, {
method: 'DELETE'
});
}

_resourcePath(postType) {
return postType === 'page' ? 'pages' : 'posts';
}
}
Loading
Loading