feature-flags: initial implementation

The welcomePageEnabled and pictureInPictureEnabled props on mobile have been
converted to feature flags.
This commit is contained in:
Saúl Ibarra Corretgé 2019-05-24 13:06:05 +02:00 committed by Saúl Ibarra Corretgé
parent d798f93614
commit cf7b10d53d
16 changed files with 220 additions and 59 deletions

View File

@ -54,6 +54,11 @@ public class JitsiMeetConferenceOptions implements Parcelable {
*/ */
private Bundle colorScheme; private Bundle colorScheme;
/**
* Feature flags. See: https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/flags/constants.js
*/
private Bundle featureFlags;
/** /**
* Set to {@code true} to join the conference with audio / video muted or to start in audio * Set to {@code true} to join the conference with audio / video muted or to start in audio
* only mode respectively. * only mode respectively.
@ -62,12 +67,6 @@ public class JitsiMeetConferenceOptions implements Parcelable {
private Boolean audioOnly; private Boolean audioOnly;
private Boolean videoMuted; private Boolean videoMuted;
/**
* Set to {@code true} to enable the welcome page. Typically SDK users won't need this enabled
* since the host application decides which meeting to join.
*/
private Boolean welcomePageEnabled;
/** /**
* Class used to build the immutable {@link JitsiMeetConferenceOptions} object. * Class used to build the immutable {@link JitsiMeetConferenceOptions} object.
*/ */
@ -78,14 +77,14 @@ public class JitsiMeetConferenceOptions implements Parcelable {
private String token; private String token;
private Bundle colorScheme; private Bundle colorScheme;
private Bundle featureFlags;
private Boolean audioMuted; private Boolean audioMuted;
private Boolean audioOnly; private Boolean audioOnly;
private Boolean videoMuted; private Boolean videoMuted;
private Boolean welcomePageEnabled;
public Builder() { public Builder() {
featureFlags = new Bundle();
} }
/**\ /**\
@ -186,7 +185,25 @@ public class JitsiMeetConferenceOptions implements Parcelable {
* @return - The {@link Builder} object itself so the method calls can be chained. * @return - The {@link Builder} object itself so the method calls can be chained.
*/ */
public Builder setWelcomePageEnabled(boolean enabled) { public Builder setWelcomePageEnabled(boolean enabled) {
this.welcomePageEnabled = enabled; this.featureFlags.putBoolean("welcomepage.enabled", enabled);
return this;
}
public Builder setFeatureFlag(String flag, boolean value) {
this.featureFlags.putBoolean(flag, value);
return this;
}
public Builder setFeatureFlag(String flag, String value) {
this.featureFlags.putString(flag, value);
return this;
}
public Builder setFeatureFlag(String flag, int value) {
this.featureFlags.putInt(flag, value);
return this; return this;
} }
@ -204,10 +221,10 @@ public class JitsiMeetConferenceOptions implements Parcelable {
options.subject = this.subject; options.subject = this.subject;
options.token = this.token; options.token = this.token;
options.colorScheme = this.colorScheme; options.colorScheme = this.colorScheme;
options.featureFlags = this.featureFlags;
options.audioMuted = this.audioMuted; options.audioMuted = this.audioMuted;
options.audioOnly = this.audioOnly; options.audioOnly = this.audioOnly;
options.videoMuted = this.videoMuted; options.videoMuted = this.videoMuted;
options.welcomePageEnabled = this.welcomePageEnabled;
return options; return options;
} }
@ -221,30 +238,29 @@ public class JitsiMeetConferenceOptions implements Parcelable {
subject = in.readString(); subject = in.readString();
token = in.readString(); token = in.readString();
colorScheme = in.readBundle(); colorScheme = in.readBundle();
featureFlags = in.readBundle();
byte tmpAudioMuted = in.readByte(); byte tmpAudioMuted = in.readByte();
audioMuted = tmpAudioMuted == 0 ? null : tmpAudioMuted == 1; audioMuted = tmpAudioMuted == 0 ? null : tmpAudioMuted == 1;
byte tmpAudioOnly = in.readByte(); byte tmpAudioOnly = in.readByte();
audioOnly = tmpAudioOnly == 0 ? null : tmpAudioOnly == 1; audioOnly = tmpAudioOnly == 0 ? null : tmpAudioOnly == 1;
byte tmpVideoMuted = in.readByte(); byte tmpVideoMuted = in.readByte();
videoMuted = tmpVideoMuted == 0 ? null : tmpVideoMuted == 1; videoMuted = tmpVideoMuted == 0 ? null : tmpVideoMuted == 1;
byte tmpWelcomePageEnabled = in.readByte();
welcomePageEnabled = tmpWelcomePageEnabled == 0 ? null : tmpWelcomePageEnabled == 1;
} }
Bundle asProps() { Bundle asProps() {
Bundle props = new Bundle(); Bundle props = new Bundle();
// Android always has the PiP flag set by default.
if (!featureFlags.containsKey("pip.enabled")) {
featureFlags.putBoolean("pip.enabled", true);
}
props.putBundle("flags", featureFlags);
if (colorScheme != null) { if (colorScheme != null) {
props.putBundle("colorScheme", colorScheme); props.putBundle("colorScheme", colorScheme);
} }
if (welcomePageEnabled != null) {
props.putBoolean("welcomePageEnabled", welcomePageEnabled);
}
// TODO: get rid of this.
props.putBoolean("pictureInPictureEnabled", true);
Bundle config = new Bundle(); Bundle config = new Bundle();
if (audioMuted != null) { if (audioMuted != null) {
@ -305,10 +321,10 @@ public class JitsiMeetConferenceOptions implements Parcelable {
dest.writeString(subject); dest.writeString(subject);
dest.writeString(token); dest.writeString(token);
dest.writeBundle(colorScheme); dest.writeBundle(colorScheme);
dest.writeBundle(featureFlags);
dest.writeByte((byte) (audioMuted == null ? 0 : audioMuted ? 1 : 2)); dest.writeByte((byte) (audioMuted == null ? 0 : audioMuted ? 1 : 2));
dest.writeByte((byte) (audioOnly == null ? 0 : audioOnly ? 1 : 2)); dest.writeByte((byte) (audioOnly == null ? 0 : audioOnly ? 1 : 2));
dest.writeByte((byte) (videoMuted == null ? 0 : videoMuted ? 1 : 2)); dest.writeByte((byte) (videoMuted == null ? 0 : videoMuted ? 1 : 2));
dest.writeByte((byte) (welcomePageEnabled == null ? 0 : welcomePageEnabled ? 1 : 2));
} }
@Override @Override

View File

@ -96,4 +96,10 @@
[self _onJitsiMeetViewDelegateEvent:@"CONFERENCE_WILL_JOIN" withData:data]; [self _onJitsiMeetViewDelegateEvent:@"CONFERENCE_WILL_JOIN" withData:data];
} }
#if 0
- (void)enterPictureInPicture:(NSDictionary *)data {
[self _onJitsiMeetViewDelegateEvent:@"ENTER_PICTURE_IN_PICTURE" withData:data];
}
#endif
@end @end

View File

@ -41,6 +41,11 @@
*/ */
@property (nonatomic, copy, nullable) NSDictionary *colorScheme; @property (nonatomic, copy, nullable) NSDictionary *colorScheme;
/**
* Feature flags. See: https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/flags/constants.js
*/
@property (nonatomic, readonly, nonnull) NSDictionary *featureFlags;
/** /**
* Set to YES to join the conference with audio / video muted or to start in audio * Set to YES to join the conference with audio / video muted or to start in audio
* only mode respectively. * only mode respectively.
@ -55,6 +60,9 @@
*/ */
@property (nonatomic) BOOL welcomePageEnabled; @property (nonatomic) BOOL welcomePageEnabled;
- (void)setFeatureFlag:(NSString *_Nonnull)flag withBoolean:(BOOL)value;
- (void)setFeatureFlag:(NSString *_Nonnull)flag withValue:(id _Nonnull)value;
@end @end
@interface JitsiMeetConferenceOptions : NSObject @interface JitsiMeetConferenceOptions : NSObject
@ -66,6 +74,7 @@
@property (nonatomic, copy, nullable, readonly) NSString *token; @property (nonatomic, copy, nullable, readonly) NSString *token;
@property (nonatomic, copy, nullable) NSDictionary *colorScheme; @property (nonatomic, copy, nullable) NSDictionary *colorScheme;
@property (nonatomic, readonly, nonnull) NSDictionary *featureFlags;
@property (nonatomic, readonly) BOOL audioOnly; @property (nonatomic, readonly) BOOL audioOnly;
@property (nonatomic, readonly) BOOL audioMuted; @property (nonatomic, readonly) BOOL audioMuted;

View File

@ -18,11 +18,17 @@
#import "JitsiMeetConferenceOptions+Private.h" #import "JitsiMeetConferenceOptions+Private.h"
/**
* Backwards compatibility: turn the boolean property into a feature flag.
*/
static NSString *const WelcomePageEnabledFeatureFlag = @"welcomepage.enabled";
@implementation JitsiMeetConferenceOptionsBuilder { @implementation JitsiMeetConferenceOptionsBuilder {
NSNumber *_audioOnly; NSNumber *_audioOnly;
NSNumber *_audioMuted; NSNumber *_audioMuted;
NSNumber *_videoMuted; NSNumber *_videoMuted;
NSNumber *_welcomePageEnabled; NSMutableDictionary *_featureFlags;
} }
@dynamic audioOnly; @dynamic audioOnly;
@ -38,17 +44,24 @@
_token = nil; _token = nil;
_colorScheme = nil; _colorScheme = nil;
_featureFlags = [[NSMutableDictionary alloc] init];
_audioOnly = nil; _audioOnly = nil;
_audioMuted = nil; _audioMuted = nil;
_videoMuted = nil; _videoMuted = nil;
_welcomePageEnabled = nil;
} }
return self; return self;
} }
- (void)setFeatureFlag:(NSString *)flag withBoolean:(BOOL)value {
[self setFeatureFlag:flag withValue:[NSNumber numberWithBool:value]];
}
- (void)setFeatureFlag:(NSString *)flag withValue:(id)value {
_featureFlags[flag] = value;
}
#pragma mark - Dynamic properties #pragma mark - Dynamic properties
- (void)setAudioOnly:(BOOL)audioOnly { - (void)setAudioOnly:(BOOL)audioOnly {
@ -76,11 +89,14 @@
} }
- (void)setWelcomePageEnabled:(BOOL)welcomePageEnabled { - (void)setWelcomePageEnabled:(BOOL)welcomePageEnabled {
_welcomePageEnabled = [NSNumber numberWithBool:welcomePageEnabled]; [self setFeatureFlag:WelcomePageEnabledFeatureFlag
withBoolean:welcomePageEnabled];
} }
- (BOOL)welcomePageEnabled { - (BOOL)welcomePageEnabled {
return _welcomePageEnabled && [_welcomePageEnabled boolValue]; NSNumber *n = _featureFlags[WelcomePageEnabledFeatureFlag];
return n != nil ? [n boolValue] : NO;
} }
#pragma mark - Private API #pragma mark - Private API
@ -97,17 +113,13 @@
return _videoMuted; return _videoMuted;
} }
- (NSNumber *)getWelcomePageEnabled {
return _welcomePageEnabled;
}
@end @end
@implementation JitsiMeetConferenceOptions { @implementation JitsiMeetConferenceOptions {
NSNumber *_audioOnly; NSNumber *_audioOnly;
NSNumber *_audioMuted; NSNumber *_audioMuted;
NSNumber *_videoMuted; NSNumber *_videoMuted;
NSNumber *_welcomePageEnabled; NSDictionary *_featureFlags;
} }
@dynamic audioOnly; @dynamic audioOnly;
@ -130,7 +142,9 @@
} }
- (BOOL)welcomePageEnabled { - (BOOL)welcomePageEnabled {
return _welcomePageEnabled && [_welcomePageEnabled boolValue]; NSNumber *n = _featureFlags[WelcomePageEnabledFeatureFlag];
return n != nil ? [n boolValue] : NO;
} }
#pragma mark - Internal initializer #pragma mark - Internal initializer
@ -148,7 +162,7 @@
_audioMuted = [builder getAudioMuted]; _audioMuted = [builder getAudioMuted];
_videoMuted = [builder getVideoMuted]; _videoMuted = [builder getVideoMuted];
_welcomePageEnabled = [builder getWelcomePageEnabled]; _featureFlags = [NSDictionary dictionaryWithDictionary:builder.featureFlags];
} }
return self; return self;
@ -167,14 +181,12 @@
- (NSDictionary *)asProps { - (NSDictionary *)asProps {
NSMutableDictionary *props = [[NSMutableDictionary alloc] init]; NSMutableDictionary *props = [[NSMutableDictionary alloc] init];
props[@"flags"] = [NSMutableDictionary dictionaryWithDictionary:_featureFlags];
if (_colorScheme != nil) { if (_colorScheme != nil) {
props[@"colorScheme"] = self.colorScheme; props[@"colorScheme"] = self.colorScheme;
} }
if (_welcomePageEnabled != nil) {
props[@"welcomePageEnabled"] = @(self.welcomePageEnabled);
}
NSMutableDictionary *config = [[NSMutableDictionary alloc] init]; NSMutableDictionary *config = [[NSMutableDictionary alloc] init];
if (_audioOnly != nil) { if (_audioOnly != nil) {
config[@"startAudioOnly"] = @(self.audioOnly); config[@"startAudioOnly"] = @(self.audioOnly);

View File

@ -24,6 +24,12 @@
#import "RNRootView.h" #import "RNRootView.h"
/**
* Backwards compatibility: turn the boolean prop into a feature flag.
*/
static NSString *const PiPEnabledFeatureFlag = @"pip.enabled";
@implementation JitsiMeetView { @implementation JitsiMeetView {
/** /**
* The unique identifier of this `JitsiMeetView` within the process for the * The unique identifier of this `JitsiMeetView` within the process for the
@ -122,11 +128,15 @@ static void initializeViewsMap() {
- (void)setProps:(NSDictionary *_Nonnull)newProps { - (void)setProps:(NSDictionary *_Nonnull)newProps {
NSMutableDictionary *props = mergeProps([[JitsiMeet sharedInstance] getDefaultProps], newProps); NSMutableDictionary *props = mergeProps([[JitsiMeet sharedInstance] getDefaultProps], newProps);
props[@"externalAPIScope"] = externalAPIScope; // Set the PiP flag if it wasn't manually set.
NSMutableDictionary *featureFlags = props[@"flags"];
if (featureFlags[PiPEnabledFeatureFlag] == nil) {
featureFlags[PiPEnabledFeatureFlag]
= [NSNumber numberWithBool:
self.delegate && [self.delegate respondsToSelector:@selector(enterPictureInPicture:)]];
}
// TODO: put this in some 'flags' field props[@"externalAPIScope"] = externalAPIScope;
props[@"pictureInPictureEnabled"]
= @(self.delegate && [self.delegate respondsToSelector:@selector(enterPictureInPicture:)]);
// This method is supposed to be imperative i.e. a second // This method is supposed to be imperative i.e. a second
// invocation with one and the same URL is expected to join the respective // invocation with one and the same URL is expected to join the respective

View File

@ -6,6 +6,7 @@ import '../../analytics';
import '../../authentication'; import '../../authentication';
import { setColorScheme } from '../../base/color-scheme'; import { setColorScheme } from '../../base/color-scheme';
import { DialogContainer } from '../../base/dialog'; import { DialogContainer } from '../../base/dialog';
import { updateFlags } from '../../base/flags';
import '../../base/jwt'; import '../../base/jwt';
import { Platform } from '../../base/react'; import { Platform } from '../../base/react';
import { import {
@ -47,18 +48,9 @@ type Props = AbstractAppProps & {
externalAPIScope: string, externalAPIScope: string,
/** /**
* Whether Picture-in-Picture is enabled. If {@code true}, a toolbar button * An object with the feature flags.
* is rendered in the {@link Conference} view to afford entering
* Picture-in-Picture.
*/ */
pictureInPictureEnabled: boolean, flags: Object
/**
* Whether the Welcome page is enabled. If {@code true}, the Welcome page is
* rendered when the {@link App} is not at a location (URL) identifying
* a Jitsi Meet conference/room.
*/
welcomePageEnabled: boolean
}; };
/** /**
@ -99,6 +91,9 @@ export class App extends AbstractApp {
// We set the color scheme early enough so then we avoid any // We set the color scheme early enough so then we avoid any
// unnecessary re-renders. // unnecessary re-renders.
this.state.store.dispatch(setColorScheme(this.props.colorScheme)); this.state.store.dispatch(setColorScheme(this.props.colorScheme));
// Ditto for feature flags.
this.state.store.dispatch(updateFlags(this.props.flags));
}); });
} }

View File

@ -0,0 +1,10 @@
/**
* The type of Redux action which updates the feature flags.
*
* {
* type: UPDATE_FLAGS,
* flags: Object
* }
*
*/
export const UPDATE_FLAGS = 'UPDATE_FLAGS';

View File

@ -0,0 +1,19 @@
// @flow
import { UPDATE_FLAGS } from './actionTypes';
/**
* Updates the current features flags with the given ones. They will be merged.
*
* @param {Object} flags - The new flags object.
* @returns {{
* type: UPDATE_FLAGS,
* flags: Object
* }}
*/
export function updateFlags(flags: Object) {
return {
type: UPDATE_FLAGS,
flags
};
}

View File

@ -0,0 +1,13 @@
// @flow
/**
* Flag indicating if Picture-in-Picture should be enabled.
* Default: auto-detected.
*/
export const PIP_ENABLED = 'pip.enabled';
/**
* Flag indicating if the welcome page should be enabled.
* Default: disabled (false).
*/
export const WELCOME_PAGE_ENABLED = 'welcomepage.enabled';

View File

@ -0,0 +1,32 @@
// @flow
import { getAppProp } from '../app';
import { toState } from '../redux';
/**
* Gets the value of a specific feature flag.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @param {string} flag - The name of the React {@code Component} prop of
* the currently mounted {@code App} to get.
* @param {*} defaultValue - A default value for the flag, in case it's not defined.
* @returns {*} The value of the specified React {@code Compoennt} prop of the
* currently mounted {@code App}.
*/
export function getFeatureFlag(stateful: Function | Object, flag: string, defaultValue: any) {
const state = toState(stateful)['features/base/flags'];
if (state) {
const value = state[flag];
if (typeof value !== 'undefined') {
return value;
}
}
// Maybe the value hasn't made it to the redux store yet, check the app props.
const flags = getAppProp(stateful, 'flags') || {};
return flags[flag] || defaultValue;
}

View File

@ -0,0 +1,6 @@
export * from './actions';
export * from './actionTypes';
export * from './constants';
export * from './functions';
import './reducer';

View File

@ -0,0 +1,33 @@
// @flow
import _ from 'lodash';
import { ReducerRegistry } from '../redux';
import { UPDATE_FLAGS } from './actionTypes';
/**
* Default state value for the feature flags.
*/
const DEFAULT_STATE = {};
/**
* Reduces redux actions which handle feature flags.
*
* @param {State} state - The current redux state.
* @param {Action} action - The redux action to reduce.
* @param {string} action.type - The type of the redux action to reduce.
* @returns {State} The next redux state that is the result of reducing the
* specified action.
*/
ReducerRegistry.register('features/base/flags', (state = DEFAULT_STATE, action) => {
switch (action.type) {
case UPDATE_FLAGS: {
const newState = _.merge({}, state, action.flags);
return _.isEqual(state, newState) ? state : newState;
}
}
return state;
});

View File

@ -4,7 +4,7 @@ import React from 'react';
import { BackHandler, NativeModules, SafeAreaView, StatusBar, View } from 'react-native'; import { BackHandler, NativeModules, SafeAreaView, StatusBar, View } from 'react-native';
import { appNavigate } from '../../../app'; import { appNavigate } from '../../../app';
import { getAppProp } from '../../../base/app'; import { PIP_ENABLED, getFeatureFlag } from '../../../base/flags';
import { getParticipantCount } from '../../../base/participants'; import { getParticipantCount } from '../../../base/participants';
import { Container, LoadingIndicator, TintedView } from '../../../base/react'; import { Container, LoadingIndicator, TintedView } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
@ -452,7 +452,7 @@ function _mapStateToProps(state) {
* @private * @private
* @type {boolean} * @type {boolean}
*/ */
_pictureInPictureEnabled: getAppProp(state, 'pictureInPictureEnabled'), _pictureInPictureEnabled: getFeatureFlag(state, PIP_ENABLED),
/** /**
* The indicator which determines whether the UI is reduced (to * The indicator which determines whether the UI is reduced (to

View File

@ -3,7 +3,7 @@
import { NativeModules } from 'react-native'; import { NativeModules } from 'react-native';
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { getAppProp } from '../../base/app'; import { PIP_ENABLED, getFeatureFlag } from '../../base/flags';
import { Platform } from '../../base/react'; import { Platform } from '../../base/react';
import { ENTER_PICTURE_IN_PICTURE } from './actionTypes'; import { ENTER_PICTURE_IN_PICTURE } from './actionTypes';
@ -25,7 +25,7 @@ export function enterPictureInPicture() {
// XXX At the time of this writing this action can only be dispatched by // XXX At the time of this writing this action can only be dispatched by
// the button which is on the conference view, which means that it's // the button which is on the conference view, which means that it's
// fine to enter PiP mode. // fine to enter PiP mode.
if (getAppProp(getState, 'pictureInPictureEnabled')) { if (getFeatureFlag(getState, PIP_ENABLED)) {
const { PictureInPicture } = NativeModules; const { PictureInPicture } = NativeModules;
const p const p
= Platform.OS === 'android' = Platform.OS === 'android'

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { getAppProp } from '../../../base/app'; import { PIP_ENABLED, getFeatureFlag } from '../../../base/flags';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { AbstractButton } from '../../../base/toolbox'; import { AbstractButton } from '../../../base/toolbox';
@ -62,7 +62,7 @@ class PictureInPictureButton extends AbstractButton<Props, *> {
*/ */
function _mapStateToProps(state): Object { function _mapStateToProps(state): Object {
return { return {
_enabled: Boolean(getAppProp(state, 'pictureInPictureEnabled')) _enabled: Boolean(getFeatureFlag(state, PIP_ENABLED))
}; };
} }

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { getAppProp } from '../base/app'; import { WELCOME_PAGE_ENABLED, getFeatureFlag } from '../base/flags';
import { toState } from '../base/redux'; import { toState } from '../base/redux';
declare var APP: Object; declare var APP: Object;
@ -24,7 +24,7 @@ export function isWelcomePageAppEnabled(stateful: Function | Object) {
// - Enabling/disabling the Welcome page on Web historically // - Enabling/disabling the Welcome page on Web historically
// automatically redirects to a random room and that does not make sense // automatically redirects to a random room and that does not make sense
// on mobile (right now). // on mobile (right now).
return Boolean(getAppProp(stateful, 'welcomePageEnabled')); return Boolean(getFeatureFlag(stateful, WELCOME_PAGE_ENABLED));
} }
return true; return true;