Skip to content

Add presence detection using the firebase realtime database websocket #507

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@
"storage": {
"rules": "firestore/storage.rules"
},
"database": {
"rules": "firestore/database.rules.json"
},
"emulators": {
"auth": {
"port": 9099
},
"database": {
"port": 9000
},
"functions": {
"port": 5001
},
Expand Down
12 changes: 12 additions & 0 deletions firestore/database.rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"rules": {
"status": {
"$experimentId": {
"$privateId": {
".read": "true",
".write": "true"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this make the db open to the public?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes this key open to the public, but you can only access records for known IDs. Since research participants are anonymous (from an auth perspective) there is no other way.

However, I notice that I misnamed that ID. So lemme go fix that...

}
}
}
}
}
7 changes: 7 additions & 0 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {AuthService} from './services/auth.service';
import {HomeService} from './services/home.service';
import {Pages, RouterService} from './services/router.service';
import {SettingsService} from './services/settings.service';
import {PresenceService} from './services/presence.service';

import {ColorMode} from './shared/types';

Expand All @@ -36,6 +37,7 @@ export class App extends MobxLitElement {
private readonly homeService = core.getService(HomeService);
private readonly routerService = core.getService(RouterService);
private readonly settingsService = core.getService(SettingsService);
private readonly presenceService = core.getService(PresenceService);

override connectedCallback() {
super.connectedCallback();
Expand Down Expand Up @@ -87,6 +89,11 @@ export class App extends MobxLitElement {
<experiment-builder></experiment-builder>
`;
case Pages.PARTICIPANT:
this.presenceService.setupPresence(
this.routerService.activeRoute.params['experiment'],
this.routerService.activeRoute.params['participant'],
);

return html`
<page-header></page-header>
<participant-view></participant-view>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class Preview extends MobxLitElement {
private renderChips() {
return html`
<div class="chip-container">
${this.renderStatusChip()} ${this.renderStageChip()}
${this.renderStatusChip()} ${this.renderConnectedChip()} ${this.renderStageChip()}
${this.renderTOSChip()}
</div>
`;
Expand Down Expand Up @@ -163,6 +163,17 @@ export class Preview extends MobxLitElement {
}
}

private getConnectedChipStyle(connected: boolean): {
emoji: string;
className: string;
} {
if (connected) {
return {emoji: '🔗', className: 'success'};
} else {
return {emoji: '📵', className: 'error'};
}
}

private renderTOSChip() {
if (!this.profile) {
return nothing;
Expand Down Expand Up @@ -192,6 +203,20 @@ export class Preview extends MobxLitElement {
`;
}

private renderConnectedChip() {
if (!this.profile) {
return nothing;
}
const {connected} = this.profile;
const {emoji, className} = this.getConnectedChipStyle(connected);

return html`
<div class="chip ${className}">
<b>${emoji} Connected:</b> ${connected}
</div>
`;
}

private renderStageChip() {
if (!this.profile) {
return nothing;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export class ParticipantSummary extends MobxLitElement {
private renderStatus() {
if (!this.participant) return nothing;

const {connected} = this.participant;

if(!connected && !isParticipantEndedExperiment(this.participant)) {
return html`<div class="chip error">disconnected</div>`;
}

if (isPendingParticipant(this.participant)) {
return html`<div class="chip secondary">transfer pending</div>`;
} else if (isObsoleteParticipant(this.participant)) {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/service_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {RouterService} from './services/router.service';
import {SettingsService} from './services/settings.service';
import {ExperimentEditor} from './services/experiment.editor';
import {ExperimentManager} from './services/experiment.manager';
import {PresenceService} from './services/presence.service';

/**
* Defines a map of services to their identifier
Expand Down Expand Up @@ -63,6 +64,9 @@ export function makeServiceProvider(self: Core) {
get settingsService() {
return self.getService(SettingsService);
},
get presenceService() {
return self.getService(PresenceService);
},
// Editors
get experimentEditor() {
return self.getService(ExperimentEditor);
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/services/firebase.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
connectAuthEmulator,
getAuth,
} from 'firebase/auth';
import {
Database,
getDatabase,
connectDatabaseEmulator,
} from 'firebase/database';
import {
Firestore,
Unsubscribe,
Expand All @@ -28,6 +33,7 @@ import {
FIREBASE_LOCAL_HOST_PORT_FIRESTORE,
FIREBASE_LOCAL_HOST_PORT_FUNCTIONS,
FIREBASE_LOCAL_HOST_PORT_STORAGE,
FIREBASE_LOCAL_HOST_PORT_RTDB,
} from '../shared/constants';
import {FIREBASE_CONFIG} from '../../firebase_config';

Expand All @@ -44,6 +50,7 @@ export class FirebaseService extends Service {
this.auth = getAuth(this.app);
this.functions = getFunctions(this.app);
this.storage = getStorage(this.app);
this.rtdb = getDatabase(this.app);

// Only register emulators if in dev mode
if (process.env.NODE_ENV === 'development') {
Expand All @@ -61,6 +68,7 @@ export class FirebaseService extends Service {
provider: GoogleAuthProvider;
storage: FirebaseStorage;
unsubscribe: Unsubscribe[] = [];
rtdb: Database;

registerEmulators() {
connectFirestoreEmulator(
Expand All @@ -82,5 +90,10 @@ export class FirebaseService extends Service {
'localhost',
FIREBASE_LOCAL_HOST_PORT_FUNCTIONS,
);
connectDatabaseEmulator(
this.rtdb,
'localhost',
FIREBASE_LOCAL_HOST_PORT_RTDB,
);
}
}
51 changes: 51 additions & 0 deletions frontend/src/services/presence.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ref,
onValue,
onDisconnect,
set,
serverTimestamp,
} from 'firebase/database';

import {makeObservable} from 'mobx';

import {Service} from './service';
import {FirebaseService} from './firebase.service';

interface ServiceProvider {
firebaseService: FirebaseService;
}

/** Tracks whether a participant is connected, using the firebase realtime database's websocket */
export class PresenceService extends Service {
constructor(private readonly sp: ServiceProvider) {
super();
makeObservable(this);
}

setupPresence(experimentId: string, participantPrivateId: string) {
const statusRef = ref(this.sp.firebaseService.rtdb, `/status/${experimentId}/${participantPrivateId}`);

const isOffline = {
connected: false,
last_changed: serverTimestamp(),
};

const isOnline = {
connected: true,
last_changed: serverTimestamp(),
};

onValue(ref(this.sp.firebaseService.rtdb, '.info/connected'), (snapshot) => {
const isConnected = snapshot.val();
if (!isConnected) {
return;
}

// Set the user's status in rtdb. The callback will reset it to online
// if the user reconnects.
onDisconnect(statusRef).set(isOffline).then(() => {
set(statusRef, isOnline);
});
});
}
}
1 change: 1 addition & 0 deletions frontend/src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const FIREBASE_LOCAL_HOST_PORT_FIRESTORE = 8080;
export const FIREBASE_LOCAL_HOST_PORT_STORAGE = 9199;
export const FIREBASE_LOCAL_HOST_PORT_AUTH = 9099;
export const FIREBASE_LOCAL_HOST_PORT_FUNCTIONS = 5001;
export const FIREBASE_LOCAL_HOST_PORT_RTDB = 9000;

/** App name. */
export const APP_NAME = 'Deliberate Lab';
Expand Down
28 changes: 22 additions & 6 deletions frontend/src/shared/participant.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function getParticipantStatusDetailText(
isStageInWaitingPhase = false,
defaultText = '',
) {
if (isStageInWaitingPhase) {
if (isStageInWaitingPhase && profile.connected) {
return '⏸️ This participant currently sees a wait stage; they are waiting for others in the cohort to catch up.';
}

Expand All @@ -72,6 +72,8 @@ export function getParticipantStatusDetailText(
return '‼️ This participant has failed an attention check and can no longer participate.';
} else if (profile.currentStatus === ParticipantStatus.TRANSFER_DECLINED) {
return '🛑 This participant declined a transfer and can no longer participate.';
} else if(isDisconnectedUnfinishedParticipant(profile)) {
return '🔌 This participant is disconnected, and cannot continue until they reconnect.';
} else if (profile.currentStatus === ParticipantStatus.ATTENTION_CHECK) {
return '⚠️ This participant has been sent an attention check.';
} else if (profile.currentStatus === ParticipantStatus.TRANSFER_PENDING) {
Expand All @@ -81,14 +83,19 @@ export function getParticipantStatusDetailText(
return defaultText;
}

/** True if participating in experiment (not dropped out, not transfer pending)
/** True if participating in experiment (online, not dropped out, not transfer pending)
* (note that participants who completed experiment are included here)
*/
export function isActiveParticipant(participant: ParticipantProfile) {
return (
participant.currentStatus === ParticipantStatus.IN_PROGRESS ||
participant.currentStatus === ParticipantStatus.ATTENTION_CHECK ||
participant.currentStatus === ParticipantStatus.SUCCESS
participant.currentStatus === ParticipantStatus.SUCCESS ||
(
participant.connected === true &&
(
participant.currentStatus === ParticipantStatus.IN_PROGRESS ||
participant.currentStatus === ParticipantStatus.ATTENTION_CHECK
)
)
);
}

Expand All @@ -97,6 +104,7 @@ export function isActiveParticipant(participant: ParticipantProfile) {
*/
export function isObsoleteParticipant(participant: ParticipantProfile) {
return (
isDisconnectedUnfinishedParticipant(participant) ||
participant.currentStatus === ParticipantStatus.TRANSFER_FAILED ||
participant.currentStatus === ParticipantStatus.TRANSFER_DECLINED ||
participant.currentStatus === ParticipantStatus.TRANSFER_TIMEOUT ||
Expand All @@ -105,6 +113,13 @@ export function isObsoleteParticipant(participant: ParticipantProfile) {
);
}

export function isDisconnectedUnfinishedParticipant(participant: ParticipantProfile) {
return (
participant.connected === false &&
! isParticipantEndedExperiment(participant)
);
}

/** If successfully completed experiment. */
export function isSuccessParticipant(participant: ParticipantProfile) {
return participant.currentStatus === ParticipantStatus.SUCCESS;
Expand All @@ -115,7 +130,7 @@ export function isSuccessParticipant(participant: ParticipantProfile) {
* has not left yet)
*/
export function isPendingParticipant(participant: ParticipantProfile) {
return participant.currentStatus === ParticipantStatus.TRANSFER_PENDING;
return participant.connected === true && participant.currentStatus === ParticipantStatus.TRANSFER_PENDING;
}

/** If pending transfer to given cohort ID. */
Expand All @@ -124,6 +139,7 @@ export function isParticipantPendingTransfer(
cohortId: string,
) {
return (
participant.connected === true &&
participant.currentStatus === ParticipantStatus.TRANSFER_PENDING &&
participant.transferCohortId === cohortId
);
Expand Down
6 changes: 5 additions & 1 deletion functions/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ import * as admin from 'firebase-admin';
// Start writing functions
// https://firebase.google.com/docs/functions/typescript

export const app = admin.initializeApp();
// By default, cloud functions use a legacy db name (just the project id), whereas the rest of
// firebase uses the new db name (project id + -default-rtdb).
export const app = admin.initializeApp({
databaseURL: `https://${process.env.GCLOUD_PROJECT}-default-rtdb.firebaseio.com`,
});
2 changes: 2 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export * from './participant.endpoints';
export * from './participant.triggers';
export * from './participant.utils';

export * from './presence.triggers';

export * from './alert.endpoints';

export * from './agent.endpoints';
Expand Down
1 change: 1 addition & 0 deletions functions/src/participant.triggers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {onDocumentCreated} from 'firebase-functions/v2/firestore';

import {
ChipPublicStageData,
ParticipantProfileExtended,
Expand Down
Loading