Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically re-join calls after code-push-triggered reload #1324

Closed
wants to merge 1 commit into from
Closed
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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"allow": [
"_id", "_this", "_dropIndex", "_redirectUri", "_postRequest",
"_anyMethodsAreOutstanding", "_schemaKeys", "_schema", "_retrieveCredentialSecret",
"_loginStyle", "_stateParam", "_name", "_allSubscriptionsReady"
"_onMigrate", "_migrationData", "_loginStyle", "_stateParam", "_name",
"_allSubscriptionsReady"
]
}
],
Expand Down
88 changes: 48 additions & 40 deletions imports/client/components/ChatPeople.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,49 +229,57 @@ const ChatPeople = ({

const { muted, deafened } = audioControls;

const joinCall = useCallback(async () => {
const joinCall = useCallback(() => {
trace('ChatPeople joinCall');
if (navigator.mediaDevices) {
callDispatch({ type: 'request-capture' });
const preferredAudioDeviceId = localStorage.getItem(PREFERRED_AUDIO_DEVICE_STORAGE_KEY) ??
undefined;
// Get the user media stream.
const mediaStreamConstraints = {
audio: {
echoCancellation: { ideal: true },
autoGainControl: { ideal: true },
noiseSuppression: { ideal: true },
deviceId: preferredAudioDeviceId,
},
// TODO: conditionally allow video if enabled by feature flag?
};
callDispatch({ type: 'trigger-join-call' });
}, [callDispatch]);

let mediaSource: MediaStream;
try {
mediaSource = await navigator.mediaDevices.getUserMedia(mediaStreamConstraints);
} catch (e) {
setError(`Couldn't get local microphone: ${(e as Error).message}`);
callDispatch({ type: 'capture-error', error: e as Error });
return;
useEffect(() => {
void (async () => {
if (callState.callState === CallJoinState.TRIGGER_JOIN_CALL) {
if (navigator.mediaDevices) {
callDispatch({ type: 'request-capture' });
const preferredAudioDeviceId = localStorage.getItem(PREFERRED_AUDIO_DEVICE_STORAGE_KEY) ??
undefined;
// Get the user media stream.
const mediaStreamConstraints = {
audio: {
echoCancellation: { ideal: true },
autoGainControl: { ideal: true },
noiseSuppression: { ideal: true },
deviceId: preferredAudioDeviceId,
},
// TODO: conditionally allow video if enabled by feature flag?
};

let mediaSource: MediaStream;
try {
mediaSource = await navigator.mediaDevices.getUserMedia(mediaStreamConstraints);
} catch (e) {
setError(`Couldn't get local microphone: ${(e as Error).message}`);
callDispatch({ type: 'capture-error', error: e as Error });
return;
}

const AudioContext = window.AudioContext ||
(window as {webkitAudioContext?: AudioContext}).webkitAudioContext;
const audioContext = new AudioContext();

callDispatch({
type: 'join-call',
audioState: {
mediaSource,
audioContext,
},
});
} else {
const msg = 'Couldn\'t get local microphone: browser denies access on non-HTTPS origins';
setError(msg);
callDispatch({ type: 'capture-error', error: new Error(msg) });
}
}

const AudioContext = window.AudioContext ||
(window as {webkitAudioContext?: AudioContext}).webkitAudioContext;
const audioContext = new AudioContext();

callDispatch({
type: 'join-call',
audioState: {
mediaSource,
audioContext,
},
});
} else {
const msg = 'Couldn\'t get local microphone: browser denies access on non-HTTPS origins';
setError(msg);
callDispatch({ type: 'capture-error', error: new Error(msg) });
}
}, [callDispatch]);
})();
}, [callState.callState, callDispatch]);

useLayoutEffect(() => {
trace('ChatPeople useLayoutEffect', {
Expand Down
9 changes: 7 additions & 2 deletions imports/client/components/PuzzlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import undestroyPuzzle from '../../methods/undestroyPuzzle';
import updatePuzzle from '../../methods/updatePuzzle';
import GoogleScriptInfo from '../GoogleScriptInfo';
import { useBreadcrumb } from '../hooks/breadcrumb';
import { useTabId } from '../hooks/persisted-state';
import useCallState, { Action, CallState } from '../hooks/useCallState';
import useDocumentTitle from '../hooks/useDocumentTitle';
import useSubscribeDisplayNames from '../hooks/useSubscribeDisplayNames';
Expand All @@ -102,8 +103,6 @@ import { mediaBreakpointDown } from './styling/responsive';
// Shows a state dump as an in-page overlay when enabled.
const DEBUG_SHOW_CALL_STATE = false;

const tabId = Random.id();

const FilteredChatFields = ['_id', 'puzzle', 'text', 'content', 'sender', 'timestamp'] as const;
type FilteredChatMessageType = Pick<ChatMessageType, typeof FilteredChatFields[number]>

Expand Down Expand Up @@ -1824,6 +1823,12 @@ const PuzzleDeletedModal = ({
};

const PuzzlePage = React.memo(() => {
const [tabId, setTabId] = useTabId(() => Random.id());
// Ensure that tabId is persisted when initially populated
useEffect(() => {
setTabId(tabId);
}, [tabId, setTabId]);

const puzzlePageDivRef = useRef<HTMLDivElement | null>(null);
const chatSectionRef = useRef<ChatSectionHandle | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(DefaultSidebarWidth);
Expand Down
45 changes: 45 additions & 0 deletions imports/client/hooks/persisted-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Reload } from 'meteor/reload';
import { SetStateAction, useCallback } from 'react';
import createPersistedState from 'use-persisted-state';

Expand Down Expand Up @@ -111,3 +112,47 @@ export const useHuntPuzzleListCollapseGroup = (huntId: string, tagId: string) =>
}, [setHuntPuzzleListCollapseGroups, tagId]),
] as const;
};

// Allow tab ID to be persistent cor the duration of a single window/tab's session
export const useTabId = createPersistedState<string>('tabId', sessionStorage);

// Tie together Meteor's reload hooks and direct use of sessionStorage to create
// a state storage that persists across Meteor-triggered reloads, but not other
// reloads. This ensures that (e.g.) call state is persisted when Meteor code
// pushes happen, but not when the user manually reloads the page.
//
// To ensure that data doesn't stick around, we cache writes in memory until we
// get a reload trigger from Meteor, at which point we flush to sessionStorage.
// We read and delete that sessionStorage at startup, so it's not still around
// for subsequent reloads.
const sessionStorageKey = 'reloadOnlyPersistedState';
const meteorReloadOnlyCache = new Map<string, string>(
Object.entries(
JSON.parse(
sessionStorage.getItem(sessionStorageKey) ?? '[]'
)
)
);
sessionStorage.removeItem(sessionStorageKey);
Reload._onMigrate(() => {
sessionStorage.setItem(sessionStorageKey, JSON.stringify(
Object.fromEntries(
meteorReloadOnlyCache.entries()
)
));
return [true];
});
const meteorReloadOnlySessionStorage: Pick<Storage, 'getItem' | 'setItem'> = {
getItem(key) {
return meteorReloadOnlyCache.get(key) ?? null;
},

setItem(key, value) {
meteorReloadOnlyCache.set(key, value);
},
};

export const useSavedCallState = createPersistedState<'idle' | 'active' | 'muted' | 'deafened'>(
'savedCallState',
meteorReloadOnlySessionStorage,
);
55 changes: 48 additions & 7 deletions imports/client/hooks/useCallState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import mediasoupAckPeerRemoteMute from '../../methods/mediasoupAckPeerRemoteMute
import mediasoupConnectTransport from '../../methods/mediasoupConnectTransport';
import mediasoupSetPeerState from '../../methods/mediasoupSetPeerState';
import mediasoupSetProducerPaused from '../../methods/mediasoupSetProducerPaused';
import { useSavedCallState } from './persisted-state';

const logger = defaultLogger.child({ label: 'useCallState' });

export enum CallJoinState {
CHAT_ONLY = 'chatonly',
TRIGGER_JOIN_CALL = 'triggerjoin',
REQUESTING_STREAM = 'requestingstream',
STREAM_ERROR = 'streamerror',
IN_CALL = 'call',
Expand Down Expand Up @@ -65,7 +67,10 @@ export type Transports = {
};

export type CallState = ({
callState: CallJoinState.CHAT_ONLY | CallJoinState.REQUESTING_STREAM | CallJoinState.STREAM_ERROR;
callState: CallJoinState.CHAT_ONLY |
CallJoinState.TRIGGER_JOIN_CALL |
CallJoinState.REQUESTING_STREAM |
CallJoinState.STREAM_ERROR;
audioState?: AudioState;
} | {
callState: CallJoinState.IN_CALL;
Expand All @@ -87,6 +92,7 @@ export type CallState = ({
};

export type Action =
| { type: 'trigger-join-call' }
| { type: 'request-capture' }
| { type: 'capture-error', error: Error }
| { type: 'join-call', audioState: AudioState }
Expand Down Expand Up @@ -126,6 +132,8 @@ const INITIAL_STATE: CallState = {
function reducer(state: CallState, action: Action): CallState {
logger.debug('dispatch', action);
switch (action.type) {
case 'trigger-join-call':
return { ...state, callState: CallJoinState.TRIGGER_JOIN_CALL };
case 'request-capture':
return { ...state, callState: CallJoinState.REQUESTING_STREAM };
case 'capture-error':
Expand Down Expand Up @@ -298,6 +306,8 @@ function reducer(state: CallState, action: Action): CallState {
}
}

const INITIAL_STATE_JOIN_CALL = reducer(INITIAL_STATE, { type: 'trigger-join-call' });

const useTransport = (
device: types.Device | undefined,
direction: 'send' | 'recv',
Expand Down Expand Up @@ -406,14 +416,44 @@ const useCallState = ({ huntId, puzzleId, tabId }: {
puzzleId: string,
tabId: string,
}): [CallState, React.Dispatch<Action>] => {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const [savedState, setSavedState] = useSavedCallState('idle');
const initialSavedState = useRef<typeof savedState>();
if (!initialSavedState.current) {
initialSavedState.current = savedState;
}
const [state, dispatch] = useReducer(reducer, savedState === 'idle' ? INITIAL_STATE : INITIAL_STATE_JOIN_CALL);

// Update saved state as internal state changes
useEffect(() => {
if (state.callState === CallJoinState.IN_CALL) {
if (state.audioControls.deafened) {
setSavedState('deafened');
} else if (state.audioControls.muted) {
setSavedState('muted');
} else {
setSavedState('active');
}
} else {
setSavedState('idle');
}
}, [setSavedState, state.audioControls.deafened, state.audioControls.muted, state.callState]);

const tuple = useRef<{ huntId: string, puzzleId: string, tabId: string }>();
if (!tuple.current) {
tuple.current = { huntId, puzzleId, tabId };
}
useEffect(() => {
// If huntId, puzzleId, or tabId change (but mostly puzzleId), reset
// call state.
return () => {
logger.debug('huntId/puzzleId/tabId changed, resetting call state');
dispatch({ type: 'reset' });
if (!tuple.current ||
tuple.current.huntId !== huntId ||
tuple.current.puzzleId !== puzzleId ||
tuple.current.tabId !== tabId) {
logger.debug('huntId/puzzleId/tabId changed, resetting call state');
dispatch({ type: 'reset' });
tuple.current = { huntId, puzzleId, tabId };
}
};
}, [huntId, puzzleId, tabId]);

Expand Down Expand Up @@ -457,16 +497,17 @@ const useCallState = ({ huntId, puzzleId, tabId }: {
useEffect(() => {
if (state.callState === CallJoinState.IN_CALL && !joinSubRef.current) {
// Subscribe to 'mediasoup:join' for huntId, puzzleId, tabId
joinSubRef.current = Meteor.subscribe('mediasoup:join', huntId, puzzleId, tabId);
const init = initialSavedState.current !== 'idle' ? initialSavedState.current : undefined;
joinSubRef.current = Meteor.subscribe('mediasoup:join', huntId, puzzleId, tabId, init);
}

return () => {
if (joinSubRef.current) {
if (joinSubRef.current && state.callState !== CallJoinState.IN_CALL) {
joinSubRef.current.stop();
joinSubRef.current = undefined;
}
};
}, [state.callState, huntId, puzzleId, tabId]);
}, [state.callState, savedState, huntId, puzzleId, tabId]);

const userId = useTracker(() => Meteor.userId(), []);
const peers = useFind(() => Peers.find({ hunt: huntId, call: puzzleId }), [huntId, puzzleId]);
Expand Down
33 changes: 21 additions & 12 deletions imports/server/mediasoup-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ Meteor.publish('mediasoup:metadata', async function (hunt, call) {
];
});

Meteor.publish('mediasoup:join', async function (hunt, call, tab) {
Meteor.publish('mediasoup:join', async function (hunt, call, tab, initialClientState) {
check(hunt, String);
check(call, String);
check(tab, String);
check(initialClientState, Match.Maybe(Match.OneOf('active', 'muted', 'deafened')));

if (!this.userId) {
throw new Meteor.Error(401, 'Not logged in');
Expand Down Expand Up @@ -153,26 +154,34 @@ Meteor.publish('mediasoup:join', async function (hunt, call, tab) {
});
}

// The server ultimately determines the initial state of a peer, but takes
// input. The algorithm is:
// - If either the client or the server believes that this peer was
// previously on the call, restore their previous state. (Client expresses
// this belief by populating the initialState parameter; server looks for
// an old Peer record. Client wins ties)
// - Otherwise, if this peer would be the 8th (or higher) member of the
// room, start muted, because large calls can get noisy. (Drop this to 3
// in local development so we don't have to open as many tabs)
// - Otherwise, start active.
const peerCount = await Peers.find({ hunt, call }).countAsync();
// If we are a new joiner and would be the 8th (or more) peer, join the
// call starting out muted, because large calls can get noisy.
// In local development, make this just 3 because it's annoying to open that many browser tabs.
const crowdSize = Meteor.isDevelopment ? 3 : 8;
let initialPeerState: 'active' | 'muted' | 'deafened' = peerCount + 1 >= crowdSize ? 'muted' : 'active';
// But if we were previously in call, just restore whatever the previous
// mute state was.

let initialServerState;
if (maybeOldPeer) {
let oldPeerState;
if (maybeOldPeer.deafened) {
oldPeerState = 'deafened' as const;
initialServerState = 'deafened' as const;
} else if (maybeOldPeer.muted) {
oldPeerState = 'muted' as const;
initialServerState = 'muted' as const;
} else {
oldPeerState = 'active' as const;
initialServerState = 'active' as const;
}
initialPeerState = oldPeerState;
}

const initialPeerState: 'active' | 'muted' | 'deafened' =
initialClientState ?? initialServerState ??
(peerCount + 1 >= crowdSize ? 'muted' : 'active');

peerId = await Peers.insertAsync({
createdServer: serverId,
hunt,
Expand Down
14 changes: 14 additions & 0 deletions types/meteor/reload.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare module 'meteor/reload' {
namespace Reload {
function _onMigrate(
cb: (retry: () => void, options: { immediateMigration?: boolean }) =>
[false] | [ready: true, data?: any]
): void;
function _onMigrate(
name: string,
cb: (retry: () => void, options: { immediateMigration?: boolean }) =>
[false] | [ready: true, data?: any]
): void;
function _migrationData(name: string): unknown;
}
}