diff --git a/client/src/components/AppContainer.tsx b/client/src/components/AppContainer.tsx index 9de844c5..1eba28f7 100644 --- a/client/src/components/AppContainer.tsx +++ b/client/src/components/AppContainer.tsx @@ -12,7 +12,7 @@ import ShortcutContainer from './ShortcutContainer'; import actions from '../store/actions'; import {StateDerivedProps, State} from '../store/types'; import logger, {setLogLevel, LogLevel} from '../helpers/logger'; -import {flushMessageQueue} from '../server/messageQueue'; +import {flushOutgoingMessageQueue, queueingMessageHandler} from '../server/messageQueue'; import {artifactPathToString} from '../types'; import {history, getCurrentArtifactPath} from '../router'; import {Router, Route, Switch} from 'react-router-dom'; @@ -20,20 +20,20 @@ import i18n from '../i18n'; import FeatureDiagramRouteContainer from './FeatureDiagramRouteContainer'; class AppContainer extends React.Component { - flushMessageQueueInterval: number; + flushOutgoingMessageQueueInterval: number; componentDidMount() { - openWebSocket(this.props.handleMessage); + openWebSocket(queueingMessageHandler(this.props.handleMessage)); - this.flushMessageQueueInterval = window.setInterval( - flushMessageQueue, this.props.settings!.intervals.flushMessageQueue); + this.flushOutgoingMessageQueueInterval = window.setInterval( + flushOutgoingMessageQueue, this.props.settings!.intervals.flushOutgoingMessageQueue); if (this.props.settings!.developer.debug) setLogLevel(LogLevel.info); } componentWillUnmount() { - window.clearInterval(this.flushMessageQueueInterval); + window.clearInterval(this.flushOutgoingMessageQueueInterval); } componentDidUpdate() { diff --git a/client/src/components/CommandBarContainer.tsx b/client/src/components/CommandBarContainer.tsx index 74a50004..9bf12ef3 100644 --- a/client/src/components/CommandBarContainer.tsx +++ b/client/src/components/CommandBarContainer.tsx @@ -108,7 +108,10 @@ const CommandBarContainer = (props: StateDerivedProps & RouteProps) => ( commands.about(props.onShowOverlay!) ] } - } + }, + ...props.featureModel + ? commands.featureDiagram.manualSync(props.settings!, props.handleMessage) + : [] ]} farItems={[{ key: 'collaboratorFacepile', @@ -144,6 +147,7 @@ export default withRouter(connect( }; }), (dispatch): StateDerivedProps => ({ + handleMessage: message => dispatch(actions.server.receive(message)), onSetFeatureDiagramLayout: payload => dispatch(actions.ui.featureDiagram.setLayout(payload)), onSetSelectMultipleFeatures: payload => dispatch(actions.ui.featureDiagram.feature.setSelectMultiple(payload)), onSelectAllFeatures: () => dispatch(actions.ui.featureDiagram.feature.selectAll()), diff --git a/client/src/components/commands.ts b/client/src/components/commands.ts index d0ecdc70..f479acf5 100644 --- a/client/src/components/commands.ts +++ b/client/src/components/commands.ts @@ -5,16 +5,17 @@ */ import i18n from '../i18n'; -import {FeatureDiagramLayoutType, OverlayType, FormatType} from '../types'; +import {FeatureDiagramLayoutType, OverlayType, FormatType, Message} from '../types'; import {ContextualMenuItemType} from 'office-ui-fabric-react/lib/ContextualMenu'; import {getShortcutText} from '../shortcuts'; import {canExport} from './featureDiagramView/export'; import {OnShowOverlayFunction, OnCollapseFeaturesFunction, OnExpandFeaturesFunction, OnSetFeatureDiagramLayoutFunction, OnFitToScreenFunction, OnDeselectAllFeaturesFunction, OnCollapseFeaturesBelowFunction, OnExpandFeaturesBelowFunction, OnSetSelectMultipleFeaturesFunction, OnSelectAllFeaturesFunction, OnCollapseAllFeaturesFunction, OnExpandAllFeaturesFunction, OnRemoveFeatureFunction, OnUndoFunction, OnRedoFunction, OnCreateFeatureBelowFunction, OnCreateFeatureAboveFunction, OnRemoveFeatureSubtreeFunction, OnSetFeatureAbstractFunction, OnSetFeatureHiddenFunction, OnSetFeatureOptionalFunction, OnSetFeatureAndFunction, OnSetFeatureOrFunction, OnSetFeatureAlternativeFunction, OnSetSettingFunction} from '../store/types'; import FeatureModel from '../modeling/FeatureModel'; import {Feature} from '../modeling/types'; -import {defaultSettings} from '../store/settings'; +import {defaultSettings, Settings} from '../store/settings'; import {preconditions} from '../modeling/preconditions'; import logger from '../helpers/logger'; +import {forceFlushMessageQueues} from '../server/messageQueue'; const exportFormatItem = (featureDiagramLayout: FeatureDiagramLayoutType, onShowOverlay: OnShowOverlayFunction, format: FormatType) => @@ -77,6 +78,15 @@ const commands = { onClick: onRedo }), featureDiagram: { + manualSync: (settings: Settings, handleMessage?: (msg: Message) => void) => + settings.featureDiagram.manualSync + ? [{ + key: 'manualSync', + iconProps: {iconName: 'Sync'}, + text: i18n.t('commands.featureDiagram.manualSync'), + iconOnly: true, + onClick: () => forceFlushMessageQueues(handleMessage) + }] : [], addArtifact: (onShowOverlay: OnShowOverlayFunction) => ({ key: 'addArtifact', text: i18n.t('commands.addArtifact'), diff --git a/client/src/components/overlays/CommandPalette.tsx b/client/src/components/overlays/CommandPalette.tsx index 2400d898..e4e80297 100644 --- a/client/src/components/overlays/CommandPalette.tsx +++ b/client/src/components/overlays/CommandPalette.tsx @@ -613,6 +613,11 @@ export default class extends React.Component { }], (votingStrategy, onlyInvolved) => this.props.onSetVotingStrategy( {votingStrategy, onlyInvolved: onlyInvolved === 'true'})) + }, { + text: i18n.t('commandPalette.featureDiagram.manualSync'), + icon: 'Sync', + action: this.action(() => this.props.onSetSetting( + {path: 'featureDiagram.manualSync', value: (bool: boolean) => !bool})) }, { text: i18n.t('commandPalette.developer.debug'), icon: 'DeveloperTools', diff --git a/client/src/i18n.tsx b/client/src/i18n.tsx index 7d6700c5..2f9802d7 100644 --- a/client/src/i18n.tsx +++ b/client/src/i18n.tsx @@ -51,8 +51,8 @@ const translationMap = { ), - hasUnflushedMessages: (numberofUnflushedMessages: number) => - `${numberofUnflushedMessages} messages have not yet been synchronized.\nIf you proceed, these messages will be lost.`, + hasUnflushedOutgoingMessages: (numberofUnflushedOutgoingMessages: number) => + `${numberofUnflushedOutgoingMessages} messages have not yet been synchronized.\nIf you proceed, these messages will be lost.`, commands: { file: 'File', edit: 'Edit', @@ -77,6 +77,7 @@ const translationMap = { fitToScreen: 'Fit feature model to screen', showConstraintView: 'Show constraint view', splitConstraintViewHorizontally: 'Constraint view sidebar', + manualSync: 'Synchronize', feature: { newMenu: { title: 'New', @@ -180,7 +181,8 @@ const translationMap = { firstVote: 'First vote wins', plurality: 'Plurality vote', majority: 'Majority vote', - consensus: 'Consensus (default)' + consensus: 'Consensus (default)', + manualSync: 'Toggle manual synchronization' }, developer: { debug: 'Developer: Toggle debug mode', diff --git a/client/src/index.tsx b/client/src/index.tsx index 20cd7e28..64f8f693 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -21,7 +21,7 @@ import actions, {Action} from './store/actions'; import {LogLevel, setLogLevel} from './helpers/logger'; import Kernel from './modeling/Kernel'; import {initialState, State} from './store/types'; -import {numberofUnflushedMessages} from './server/messageQueue'; +import {numberofUnflushedOutgoingMessages} from './server/messageQueue'; import i18n from './i18n'; import uuidv4 from 'uuid/v4'; import {defaultSettings} from './store/settings'; @@ -52,8 +52,8 @@ if (!window.name) window.setInterval(updateLastActive, defaultSettings.intervals.lastActive); window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => { - if (numberofUnflushedMessages() > 0) - e.returnValue = i18n.getFunction('hasUnflushedMessages')(numberofUnflushedMessages()); + if (numberofUnflushedOutgoingMessages() > 0) + e.returnValue = i18n.getFunction('hasUnflushedOutgoingMessages')(numberofUnflushedOutgoingMessages()); }); if (window.location.protocol !== 'http:') diff --git a/client/src/server/messageQueue.ts b/client/src/server/messageQueue.ts index 421bdb3a..3ee7ef60 100644 --- a/client/src/server/messageQueue.ts +++ b/client/src/server/messageQueue.ts @@ -1,24 +1,29 @@ import {ArtifactPath, Message} from '../types'; -import {sendMessage, isSimulateOffline} from './webSocket'; +import {sendMessage, isSimulateOffline, isManualSync} from './webSocket'; import logger from '../helpers/logger'; const tag = 'queue'; -const messageQueue: Message[] = []; // TODO: save in localStorage -let isFlushingMessageQueue = false; +const outgoingMessageQueue: Message[] = []; // TODO: save in localStorage +const incomingMessageQueue: Message[] = []; +let isFlushingOutgoingMessageQueue = false; -export function enqueueMessage(message: Message, artifactPath?: ArtifactPath): Message { +export function enqueueOutgoingMessage(message: Message, artifactPath?: ArtifactPath): Message { if (artifactPath) message = {artifactPath, ...message}; - messageQueue.push(message); + outgoingMessageQueue.push(message); return message; } -export function numberofUnflushedMessages(): number { - return messageQueue.length; +function enqueueIncomingMessage(message: Message): void { + incomingMessageQueue.push(message); } -export async function flushMessageQueue(): Promise { - if (numberofUnflushedMessages() > 0) { +export function numberofUnflushedOutgoingMessages(): number { + return outgoingMessageQueue.length; +} + +export async function flushOutgoingMessageQueue(forceFlush = false): Promise { + if (numberofUnflushedOutgoingMessages() > 0) { if (!document.title.startsWith('(*) ')) document.title = '(*) ' + document.title; } @@ -28,24 +33,27 @@ export async function flushMessageQueue(): Promise { return; } - if (isFlushingMessageQueue) { + if (isFlushingOutgoingMessageQueue) { logger.warnTagged({tag}, () => 'already flushing message queue, abort'); return; } - isFlushingMessageQueue = true; - const numberOfMessages = messageQueue.length; - while (numberofUnflushedMessages() > 0) { + if (isManualSync() && !forceFlush) + return; + + isFlushingOutgoingMessageQueue = true; + const numberOfMessages = outgoingMessageQueue.length; + while (numberofUnflushedOutgoingMessages() > 0) { try { - await sendMessage(messageQueue[0]); + await sendMessage(outgoingMessageQueue[0]); } catch (e) { // TODO: warn the user that the message will be sent when reconnected (maybe give an undo // button to remove the message from the queue and undo the operation) - logger.warnTagged({tag}, () => `could not send ${messageQueue[0].type} message, abort flushing message queue`); - isFlushingMessageQueue = false; + logger.warnTagged({tag}, () => `could not send ${outgoingMessageQueue[0].type} message, abort flushing message queue`); + isFlushingOutgoingMessageQueue = false; return; } - messageQueue.shift(); + outgoingMessageQueue.shift(); } if (document.title.startsWith('(*) ')) @@ -53,5 +61,27 @@ export async function flushMessageQueue(): Promise { if (numberOfMessages > 0) logger.infoTagged({tag}, () => `successfully sent ${numberOfMessages} messages`); - isFlushingMessageQueue = false; + isFlushingOutgoingMessageQueue = false; +} + +export function flushIncomingMessageQueue(handleMessage?: (msg: Message) => void, forceFlush = false): void { + if (isManualSync() && !forceFlush) + return; + + while (incomingMessageQueue.length > 0) { + if (handleMessage) + handleMessage(incomingMessageQueue[0]); + incomingMessageQueue.shift(); + } +} + +export const queueingMessageHandler = (handleMessage?: (msg: Message) => void) => + (message: Message): void => { + enqueueIncomingMessage(message); + flushIncomingMessageQueue(handleMessage); + }; + +export function forceFlushMessageQueues(handleMessage?: (msg: Message) => void): void { + flushOutgoingMessageQueue(true); + flushIncomingMessageQueue(handleMessage, true); } \ No newline at end of file diff --git a/client/src/server/webSocket.ts b/client/src/server/webSocket.ts index 916f191b..54098e0d 100644 --- a/client/src/server/webSocket.ts +++ b/client/src/server/webSocket.ts @@ -9,12 +9,14 @@ import {wait} from '../helpers/wait'; import {State} from '../store/types'; import Sockette from './Sockette'; import {Persistor} from 'redux-persist'; +import {getCurrentFeatureModel} from '../store/selectors'; type HandleMessageFunction = (data: Message) => void; let handleMessage: HandleMessageFunction; const tag = 'socket'; +// this is _not_ good code, but it gets the job done >_< function getSimulateDelay() { const state: State | undefined = (window as any).app && (window as any).app.store && (window as any).app.store.getState(); @@ -39,6 +41,14 @@ export function isSimulateOffline() { return state ? state.settings.developer.simulateOffline : 0; } +export function isManualSync() { + const state: State | undefined = + (window as any).app && (window as any).app.store && (window as any).app.store.getState(); + if (!state) + logger.warn(() => 'store not accessible, can not synchronize manually'); + return state ? getCurrentFeatureModel(state) && state.settings.featureDiagram.manualSync : false; +} + const getWebSocket = ((): () => Promise => { let promise: Promise | undefined; diff --git a/client/src/store/actions.ts b/client/src/store/actions.ts index 0ba12620..d6714c8b 100644 --- a/client/src/store/actions.ts +++ b/client/src/store/actions.ts @@ -10,7 +10,7 @@ import {ThunkAction} from 'redux-thunk'; import {State} from './types'; import {Feature, PropertyType, GroupType, KernelConstraintFormula} from '../modeling/types'; import Kernel from '../modeling/Kernel'; -import {enqueueMessage, flushMessageQueue} from '../server/messageQueue'; +import {enqueueOutgoingMessage, flushOutgoingMessageQueue} from '../server/messageQueue'; import deferred from '../helpers/deferred'; import {getCurrentArtifactPath} from '../router'; @@ -22,8 +22,8 @@ function createMessageAction

(fn: (payload: P) => Message): (payload: P) => Th return async (dispatch: Dispatch, getState: () => State) => { const state = getState(), artifactPath = getCurrentArtifactPath(state.collaborativeSessions), - message = enqueueMessage(fn(payload), artifactPath); - deferred(flushMessageQueue)(); + message = enqueueOutgoingMessage(fn(payload), artifactPath); + deferred(flushOutgoingMessageQueue)(); return dispatch(action(SERVER_SEND_MESSAGE, message)); }; }; @@ -38,8 +38,8 @@ function createOperationAction

(makePOSequence: (payload: P, kernel: Kernel) = Kernel.run(state, artifactPath, kernel => kernel.generateOperation(makePOSequence(payload, kernel))); const message: Message = {type: MessageType.KERNEL, message: operation}; - enqueueMessage(message, artifactPath); - deferred(flushMessageQueue)(); + enqueueOutgoingMessage(message, artifactPath); + deferred(flushOutgoingMessageQueue)(); return dispatch(action(KERNEL_GENERATE_OPERATION, {artifactPath, kernelFeatureModel, kernelContext})); }; }; diff --git a/client/src/store/reducer.ts b/client/src/store/reducer.ts index d749c1a9..0135dd29 100644 --- a/client/src/store/reducer.ts +++ b/client/src/store/reducer.ts @@ -20,7 +20,7 @@ import {AnyAction, Store} from 'redux'; import Kernel from '../modeling/Kernel'; import {KernelFeatureModel, isKernelConflictDescriptor} from '../modeling/types'; import {getCurrentArtifactPath, redirectToArtifactPath} from '../router'; -import {enqueueMessage, flushMessageQueue} from '../server/messageQueue'; +import {enqueueOutgoingMessage, flushOutgoingMessageQueue} from '../server/messageQueue'; import deferred from '../helpers/deferred'; function getNewState(state: State, ...args: any[]): State { @@ -210,8 +210,8 @@ function serverReceiveReducer(state: State, action: Action): State { const artifactPath = collaborativeSession.artifactPath; let heartbeat; [kernelContext, heartbeat] = Kernel.run(state, artifactPath, kernel => kernel.generateHeartbeat()); - enqueueMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath); - deferred(flushMessageQueue)(); + enqueueOutgoingMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath); + deferred(flushOutgoingMessageQueue)(); } return {...collaborativeSession, kernelContext, kernelCombinedEffect}; })); @@ -255,8 +255,8 @@ function serverReceiveReducer(state: State, action: Action): State { const artifactPath = collaborativeSession.artifactPath; let heartbeat; [kernelContext, heartbeat] = Kernel.run(state, artifactPath, kernel => kernel.generateHeartbeat()); - enqueueMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath); - deferred(flushMessageQueue)(); + enqueueOutgoingMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath); + deferred(flushOutgoingMessageQueue)(); return { ...collaborativeSession, kernelContext, diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 6106cd59..74eaf2b5 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -65,7 +65,8 @@ export interface Settings { throttleUpdate: number, // how often to reposition the overlay on zoom or pan in ms gapSpace: number, // space between node and overlay in px width: number // width of feature callout in px - } + }, + manualSync: boolean // whether manual syncing is enabled or not }, collaboratorFacepile: { maxDisplayableCollaborators: number, // number of users to display before overflowing @@ -78,7 +79,7 @@ export interface Settings { } }, intervals: { - flushMessageQueue: number, // how often the message queue should additionally be flushed, interval in ms + flushOutgoingMessageQueue: number, // how often the message queue should additionally be flushed, interval in ms lastActive: number // used to guarantee that only one tab is active at once, can not be changed at runtime! // TODO: GC, heartbeat } @@ -138,7 +139,8 @@ export const defaultSettings: Settings = { throttleUpdate: 200, gapSpace: 5, width: 300 - } + }, + manualSync: false }, collaboratorFacepile: { maxDisplayableCollaborators: 3, @@ -151,7 +153,7 @@ export const defaultSettings: Settings = { } }, intervals: { - flushMessageQueue: 2000, + flushOutgoingMessageQueue: 2000, lastActive: 2000 } };