Ghost/ghost/admin/app/services/feature.js

165 lines
5.2 KiB
JavaScript
Raw Permalink Normal View History

import $ from 'jquery';
import Ember from 'ember';
import EmberError from '@ember/error';
import Service, {inject as service} from '@ember/service';
import classic from 'ember-classic-decorator';
import {computed, set} from '@ember/object';
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
import {inject} from 'ghost-admin/decorators/inject';
export function feature(name, options = {}) {
let {user, onChange} = options;
let watchedProps = user ? [`accessibility.${name}`] : [`config.${name}`, `labs.${name}`];
return computed.apply(Ember, watchedProps.concat({
get() {
let enabled = false;
if (user) {
enabled = this.get(`accessibility.${name}`);
} else if (typeof this.get(`config.${name}`) === 'boolean') {
enabled = this.get(`config.${name}`);
} else {
enabled = this.get(`labs.${name}`) || false;
}
return enabled;
},
set(key, value) {
this.update(key, value, options);
if (onChange) {
// value must be passed here because the value isn't set until
// the setter function returns
this.get(onChange).bind(this)(value);
}
return value;
}
}));
}
@classic
export default class FeatureService extends Service {
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
@service lazyLoader;
@service notifications;
@service session;
@service settings;
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
@service store;
Fixed hosting management screen not loading after sign-in process (#15763) refs https://github.com/TryGhost/Team/issues/2110 - dynamically defined properties on the config service did not have autotracking set up properly if they were accessed in any way before the property was defined, this caused problems in a number of areas because we have both "unauthed" and "authed" sets of config and when not logged in we had parts of the app checking for authed config properties that don't exist until after sign-in and subsequent config re-fetch - renamed `config` service to `configManager` and updated to only contain methods for fetching config data - added a `config` instance initializer that sets up a `TrackedObject` instance with some custom properties/methods and registers it on `config:main` - uses application instance initializer rather than a standard initializer because standard initializers are only called once when setting up the test suite so we'd end up with config leaking across tests - added an `@inject` decorator that when used takes the property name and injects whatever is registered at `${propertyName}:main`, this allows us to use dependency injection for any object rather than just services or controllers - using `application.inject()` in the initializer was initially used but that only works for objects that extend from `EmberObject`, the injections weren't available in native-class glimmer components so this decorator keeps the injection syntax consistent - swapped all `@service config` uses to `@inject config`
2022-11-03 14:14:36 +03:00
@inject config;
// features
@feature('emailAnalytics') emailAnalytics;
// user-specific flags
@feature('nightShift', {user: true, onChange: '_setAdminTheme'})
nightShift;
// user-specific referral invitation
@feature('referralInviteDismissed', {user: true}) referralInviteDismissed;
// labs flags
@feature('urlCache') urlCache;
Initial setup for Lexical multiplayer websockets service (#16611) no issue Rough prototype only, current limitations: - **No persistence**. Docs are in-memory only, YJS state will be lost on server restart although it could be re-populated by clients if they reconnect without closing their local doc (needs testing/investigation) - **No tie-in with saved lexical state**. Lexical state is updated in the post model via normal API requests from Admin which can mean the multiplayer doc and the saved lexical state become out of sync but there's no detection/indication of that state at present. Will also trigger the "someone else is editing" errors because multiplayer doesn't yet override the default post update collision detection - **New posts don't start in multiplayer**. New posts don't have an ID and so can't have a respective YJS doc, after initial save we don't transition to multiplayer because the React component in Ember doesn't re-render on prop changes yet - **No tests**. Experimental code just to get something working and help answer questions for what's next Changes: - added `lexicalMultiplayer` labs flag - updated `<KoenigLexicalEditor>` to pass through the required `<KoenigComposer>` props for multiplayer when enabled - added `lexical-multiplayer` service - `init()` called during boot, used to set up the `enable()` and `disable()` methods so the flag can be toggled without restarts - when enabled it adds `upgrade` request handling to the base Ghost server - returns 404 if the URL doesn't match `/ghost/api/admin/posts/multiplayer/*` - returns 401 if a valid session cookie is not present - if everything is good, hands off to code in `y-websocket.js` that handles YJS doc creation, awareness, keepalive, etc - uses doc names in the format `${post.id}/${docId}` where `docId` is `main` for the primary document and a GUID for any sub-documents like captions and nested editors in cards - updated `SettingsBREADService` to check if the `labs` setting is changed, and enables/disables the `lexical-multiplayer` service as needed so the websockets server can be started and shutdown when toggling without requiring a restart
2023-04-12 22:24:02 +03:00
@feature('lexicalMultiplayer') lexicalMultiplayer;
@feature('audienceFeedback') audienceFeedback;
@feature('webmentions') webmentions;
@feature('stripeAutomaticTax') stripeAutomaticTax;
@feature('emailCustomization') emailCustomization;
@feature('i18n') i18n;
@feature('announcementBar') announcementBar;
@feature('signupCard') signupCard;
@feature('collections') collections;
@feature('mailEvents') mailEvents;
@feature('collectionsCard') collectionsCard;
@feature('importMemberTier') importMemberTier;
@feature('lexicalIndicators') lexicalIndicators;
@feature('adminXDemo') adminXDemo;
@feature('ActivityPub') ActivityPub;
@feature('editorExcerpt') editorExcerpt;
@feature('contentVisibility') contentVisibility;
@feature('commentImprovements') commentImprovements;
_user = null;
@computed('settings.labs')
get labs() {
let labs = this.settings.labs;
try {
return JSON.parse(labs) || {};
} catch (e) {
return {};
}
}
@computed('_user.accessibility')
get accessibility() {
let accessibility = this.get('_user.accessibility');
try {
return JSON.parse(accessibility) || {};
} catch (e) {
return {};
}
}
fetch() {
Made `session.user` a synchronous property rather than a promise no issue Having `session.user` return a promise made dealing with it in components difficult because you always had to remember it returned a promise rather than a model and had to handle the async behaviour. It also meant that you couldn't use any current user properties directly inside getters which made refactors to Glimmer/Octane idioms harder to reason about. `session.user` was a cached computed property so it really made no sense for it to be a promise - it was loaded on first access and then always returned instantly but with a fulfilled promise rather than the underlying model. Refactoring to a synchronous property that is loaded as part of the authentication flows (we load the current user to check that we're logged in - we may as well make use of that!) means one less thing to be aware of/remember and provides a nicer migration process to Glimmer components. As part of the refactor, the auth flows and pre-load of required data across other services was also simplified to make it easier to find and follow. - refactored app setup and `session.user` - added `session.populateUser()` that fetches a user model from the current user endpoint and sets it on `session.user` - removed knowledge of app setup from the `cookie` authenticator and moved it into = `session.postAuthPreparation()`, this means we have the same post-authentication setup no matter which authenticator is used so we have more consistent behaviour in tests which don't use the `cookie` authenticator - switched `session` service to native class syntax to get the expected `super()` behaviour - updated `handleAuthentication()` so it populate's `session.user` and performs post-auth setup before transitioning (handles sign-in after app load) - updated `application` route to remove duplicated knowledge of app preload behaviour that now lives in `session.postAuthPreparation()` (handles already-authed app load) - removed out-of-date attempt at pre-loading data from setup controller as that's now handled automatically via `session.handleAuthentication` - updated app code to not treat `session.user` as a promise - predominant usage was router `beforeModel` hooks that transitioned users without valid permissions, this sets us up for an easier removal of the `current-user-settings` mixin in the future
2021-07-08 16:37:31 +03:00
return this.settings.fetch().then(() => {
this.set('_user', this.session.user);
return this._setAdminTheme().then(() => true);
});
}
update(key, value, options = {}) {
let serviceProperty = options.user ? 'accessibility' : 'labs';
let model = this.get(options.user ? '_user' : 'settings');
let featureObject = this.get(serviceProperty);
// set the new key value for either the labs property or the accessibility property
set(featureObject, key, value);
if (options.requires && value === true) {
options.requires.forEach((flag) => {
set(featureObject, flag, true);
});
}
// update the 'labs' or 'accessibility' key of the model
model.set(serviceProperty, JSON.stringify(featureObject));
return model.save().then(() => {
// return the labs key value that we get from the server
this.notifyPropertyChange(serviceProperty);
return this.get(`${serviceProperty}.${key}`);
}).catch((error) => {
model.rollbackAttributes();
this.notifyPropertyChange(serviceProperty);
// we'll always have an errors object unless we hit a
// validation error
if (!error) {
throw new EmberError(`Validation of the feature service ${options.user ? 'user' : 'settings'} model failed when updating ${serviceProperty}.`);
}
this.notifications.showAPIError(error);
return this.get(`${serviceProperty}.${key}`);
});
}
_setAdminTheme(enabled) {
let nightShift = enabled;
if (typeof nightShift === 'undefined') {
nightShift = enabled || this.nightShift;
}
return this.lazyLoader.loadStyle('dark', 'assets/ghost-dark.css', true).then(() => {
$('link[title=dark]').prop('disabled', !nightShift);
}).catch(() => {
//TODO: Also disable toggle from settings and Labs hover
$('link[title=dark]').prop('disabled', true);
});
}
}