- + - @if (experiments.data(); as response) { - @for (experiment of response.data; track experiment.name) { - - - - } - } @else { - + @for (experiment of experiments(); track experiment.name) { + + + } - +
diff --git a/webapp/src/app/experimenter-view/experimenter-view.component.scss b/webapp/src/app/experimenter-view/experimenter-view.component.scss index 23ac0000..3b45ec96 100644 --- a/webapp/src/app/experimenter-view/experimenter-view.component.scss +++ b/webapp/src/app/experimenter-view/experimenter-view.component.scss @@ -6,7 +6,6 @@ $toolbar-height: 60px; background-color: rgb(33, 33, 33); color: white; font-weight: 600; - flex: 0 0 $toolbar-height; /* Fixed top toolbar */ position: fixed; @@ -30,8 +29,7 @@ $toolbar-height: 60px; } .page { - margin-left: 20px; - margin-right: 20px; + margin: 20px; } .title { @@ -50,24 +48,13 @@ $toolbar-height: 60px; // Content display display: flex; - flex: 1 1 auto; - flex-wrap: wrap; - flex-direction: row; - justify-content: flex-start; - align-items: flex-start; - - margin: 0; - overflow: auto; + height: calc(100vh - $toolbar-height); // Full height minus toolbar height + overflow: hidden; // Prevent scrolling on the .content itself .highlighted { background-color: #ddd; } - // .working-on-stage { - // .menu-item-inner { - // } - // } - .active { color: #d00; } @@ -107,29 +94,18 @@ $toolbar-height: 60px; .menu-buttons { // Fixed to the top toolbar - // BUG: it need to stick to the top, but the rest must be aware of it position: static; top: $toolbar-height; display: flex; - // flex-wrap: wrap; flex-direction: column; - // justify-content: flex-start; - // align-items: flex-start; - // max-width: 600px; - // min-height: 600px; } .icon-menu-item { display: flex; - // flex-wrap: wrap; - // flex-direction: column; justify-content: flex-start; align-content: center; - // align-items: flex-start; - // max-width: 600px; - // min-height: 600px; mat-icon { margin-right: 5px; } @@ -141,34 +117,26 @@ $toolbar-height: 60px; border-radius: 50%; } -// .menu-buttons li { -// margin: 8px 8px 8px 0; -// float: left; -// list-style: none; -// text-align: center; -// // background-color: #000000; -// margin-right: 30px; -// width: 150px; -// line-height: 60px; -// } -// .menu-buttons li a { -// text-decoration: none; -// color: #000000; -// display: block; -// } -// .menu-buttons li a:hover { -// text-decoration: none; -// color: #000000; -// // background-color: #33B5E5; -// } - -// .menu-button .mat-mdc-button-base { -// margin: 8px 8px 8px 0; -// } - .error { color: #f00; background-color: #fee; border-radius: 5px; border: 1px solid #aaa; } + +.mat-sidenav-container { + height: 100%; + display: flex; + flex: 1; +} + +.sidenav { + width: 200px; // Adjust width as necessary + overflow: auto; // Allow the sidenav itself to scroll if needed +} + +.sidenav-content { + flex-grow: 1; + overflow-y: auto; // Scroll vertically if needed + overflow-x: hidden; // Prevent horizontal scrolling +} diff --git a/webapp/src/app/experimenter-view/experimenter-view.component.ts b/webapp/src/app/experimenter-view/experimenter-view.component.ts index c201d5af..9eda608a 100644 --- a/webapp/src/app/experimenter-view/experimenter-view.component.ts +++ b/webapp/src/app/experimenter-view/experimenter-view.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, Signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -9,9 +9,10 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSidenavModule } from '@angular/material/sidenav'; import { RouterModule } from '@angular/router'; +import { Experiment } from '@llm-mediation-experiments/utils'; import { signOut } from 'firebase/auth'; import { auth } from 'src/lib/api/firebase'; -import { experimentsQuery } from 'src/lib/api/queries'; +import { AppStateService } from '../services/app-state.service'; import { ExperimentMonitorComponent } from './experiment-monitor/experiment-monitor.component'; import { ExperimentSettingsComponent } from './experiment-settings/experiment-settings.component'; @@ -39,8 +40,11 @@ import { ExperimentSettingsComponent } from './experiment-settings/experiment-se styleUrl: './experimenter-view.component.scss', }) export class ExperimenterViewComponent { - // Fetch experiments from database - experiments = experimentsQuery(); + experiments: Signal; + + constructor(public readonly appState: AppStateService) { + this.experiments = appState.experimenter.get().experiments; + } logout() { signOut(auth); diff --git a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html index 0ca9371f..8e3494b7 100644 --- a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html +++ b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.html @@ -1,13 +1,13 @@
Mediator Chat View
- @for (message of messages(); track $index) { + @for (message of chatRepository()?.messages(); track message.uid) {
- @if (message.messageType === 'userMessage') { + @if (message.kind === 'userMessage') { - } @else if (message.messageType === 'mediatorMessage') { + } @else if (message.kind === 'mediatorMessage') { - } @else if (message.messageType === 'discussItemsMessage') { + } @else if (message.kind === 'discussItemsMessage') { diff --git a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts index c05c9c27..60fe8409 100644 --- a/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts +++ b/webapp/src/app/experimenter-view/mediator-chat/mediator-chat.component.ts @@ -1,22 +1,21 @@ -import { Component, Input, OnDestroy, Signal, WritableSignal, effect, signal } from '@angular/core'; +import { Component, Input, Signal, WritableSignal, computed, signal } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { - ChatAboutItems, - Message, - ParticipantExtended, - mergeByKey, + GroupChatStageConfig, + MessageKind, + ParticipantProfileExtended, + lookupTable, } from '@llm-mediation-experiments/utils'; -import { Unsubscribe } from 'firebase/firestore'; +import { AppStateService } from 'src/app/services/app-state.service'; import { VertexApiService } from 'src/app/services/vertex-api.service'; -import { mediatorMessageMutation } from 'src/lib/api/mutations'; +import { ChatRepository } from 'src/lib/repositories/chat.repository'; import { FewShotTemplate } from 'src/lib/text-templates/fewshot_template'; import { preparePalm2Request, sendPalm2Request } from 'src/lib/text-templates/llm_vertexapi_palm2'; import { nv, template } from 'src/lib/text-templates/template'; -import { chatMessagesSubscription } from 'src/lib/utils/firestore.utils'; import { ChatDiscussItemsMessageComponent } from '../../participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component'; import { ChatMediatorMessageComponent } from '../../participant-view/participant-stage-view/exp-chat/chat-mediator-message/chat-mediator-message.component'; import { ChatUserMessageComponent } from '../../participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component'; @@ -40,22 +39,20 @@ import { MediatorFeedbackComponent } from '../../participant-view/participant-st templateUrl: './mediator-chat.component.html', styleUrl: './mediator-chat.component.scss', }) -export class MediatorChatComponent implements OnDestroy { - @Input() experiment?: Signal; - @Input() participants?: Signal; +export class MediatorChatComponent { + @Input() participants!: Signal; + @Input() experimentId!: Signal; @Input() - set chatValue(value: ChatAboutItems | undefined) { - this.chat.set(value); + set chatValue(value: GroupChatStageConfig | undefined) { + this.chatConfig.set(value); } - public chat: WritableSignal = signal(undefined); - - public messages: WritableSignal; - public unsubscribeMessages: Unsubscribe | undefined; + public chatConfig: WritableSignal = signal(undefined); + public viewingParticipantId: WritableSignal = signal(undefined); // TODO: make it possible in the UI to select a participant whose chat to view + public chatRepository: Signal = signal(undefined); // Message mutation & form - public messageMutation = mediatorMessageMutation(); public message = new FormControl('', Validators.required); public defaultPrefix: string = @@ -65,30 +62,30 @@ export class MediatorChatComponent implements OnDestroy { public prefix: string = this.defaultPrefix; public suffix: string = this.defaultSuffix; - constructor(private llmService: VertexApiService) { - // Firestore subscription for messages, dynamically changes based on the input chat id - this.messages = signal([]); - effect(() => { - const id = this.chat()?.chatId; - - this.unsubscribeMessages?.(); - - if (id !== undefined) { - this.unsubscribeMessages = chatMessagesSubscription(id, (m) => { - // Merge new messages with existing messages, uniquly identifying them by their message uid - this.messages.set(mergeByKey(this.messages(), m, 'uid')); - }); - } + constructor( + private llmService: VertexApiService, + appState: AppStateService, + ) { + // Dynamically get the chat repository + this.chatRepository = computed(() => { + const participantId = this.viewingParticipantId(); + const chatId = this.chatConfig()?.chatId; + const experimentId = this.experimentId(); + + if (!participantId || !chatId || !experimentId) return undefined; + + return appState.chats.get({ + experimentId, + participantId, + chatId, + }); }); } sendMessage() { - if (!this.message.valid) return; + if (!this.message.valid || !this.message.value) return; - this.messageMutation.mutate({ - chatId: this.chat()!.chatId, - text: this.message.value!, - }); + this.chatRepository()?.sendMediatorMessage(this.message.value); this.message.setValue(''); } @@ -123,11 +120,17 @@ ${nv('conversation')} ${this.suffix}`; // Create empty list in conversation - const conversation: { username: string; message: string }[] = this.messages().map((m) => ({ - message: m.text, - // TODO: add an experiment provider at the experimenter base. - username: m.messageType === 'userMessage' ? 'User' : 'Mediator', // TODO: display user profile - })); + const participantsLookup = lookupTable(this.participants(), 'publicId'); + const conversation: { username: string; message: string }[] = + this.chatRepository() + ?.messages() + .map((m) => ({ + message: m.text, + username: + m.kind === MessageKind.UserMessage + ? participantsLookup[m.fromPublicParticipantId].name ?? 'User' + : 'Mediator', + })) ?? []; const mediationWithMessages = mediationTempl.substs({ conversation: nMediationExamplesTempl.apply(conversation).escaped, @@ -148,8 +151,4 @@ ${this.suffix}`; this.message.setValue(response.predictions[0].content); this.sendMessage(); } - - ngOnDestroy() { - this.unsubscribeMessages?.(); - } } diff --git a/webapp/src/app/firebase.service.ts b/webapp/src/app/firebase.service.ts index 30342318..2297b0d7 100644 --- a/webapp/src/app/firebase.service.ts +++ b/webapp/src/app/firebase.service.ts @@ -1,7 +1,6 @@ -import { Injectable, OnDestroy, Signal, WritableSignal, signal } from '@angular/core'; +import { Injectable, Signal, WritableSignal, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { Unsubscribe, User, onAuthStateChanged } from 'firebase/auth'; -import { environment } from 'src/environments/environment'; +import { User, onAuthStateChanged } from 'firebase/auth'; import { auth } from 'src/lib/api/firebase'; // NOTE: if using gapi to save files to google drive is REALLY necessary, modify the authentication this way: @@ -16,9 +15,7 @@ const DISCOVERY_DOC = 'https://www.googleapis.com/discovery/v1/apis/drive/v3/res @Injectable({ providedIn: 'root', }) -export class FirebaseService implements OnDestroy { - unsubscribeAuth: Unsubscribe; - +export class FirebaseService { // User authentication data private _user: WritableSignal = signal(null); public get user() { @@ -30,7 +27,7 @@ export class FirebaseService implements OnDestroy { private gapiClientLoaded = new Promise((resolve) => { gapi.load('client', async () => { await gapi.client.init({ - apiKey: environment.driveApiKey, + apiKey: '', // environment.driveApiKey, discoveryDocs: [DISCOVERY_DOC], }); resolve(); @@ -39,25 +36,21 @@ export class FirebaseService implements OnDestroy { constructor(router: Router) { // Subscribe to auth state changes & navigate to the appropriate page when the user is signed in - this.unsubscribeAuth = onAuthStateChanged(auth, async (user) => { + onAuthStateChanged(auth, async (user) => { if (user) { const isOnHomePage = window.location.hash === '#/'; + // Redirect experimenters to the correct page on login + // Note that no redirection on logout needs to be specified, as they are handled by route guards if (isOnHomePage) { // User is signed in, navigate to the appropriate page. const { claims } = await user.getIdTokenResult(); - - if (claims['role'] === 'participant') { - router.navigate(['/participant', claims['participantId']]); - } else if (claims['role'] === 'experimenter') { + if (claims['role'] === 'experimenter') { router.navigate(['/experimenter']); } } - } else { - if (window.location.hash.includes('experimenter')) - // No user is signed in, navigate back to home - router.navigate(['/']); } + this._user.set(user); }); } @@ -104,8 +97,4 @@ export class FirebaseService implements OnDestroy { } }); } - - ngOnDestroy() { - this.unsubscribeAuth(); - } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.html index 3e03f2d7..7413f099 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.html @@ -4,17 +4,17 @@
{{ discussItemsMessage.itemPair.item1.name }} - {{ discussItemsMessage.itemPair.item1.name }} + {{ ITEMS[discussItemsMessage.itemPair.item1].name }}
{{ discussItemsMessage.itemPair.item2.name }} - {{ discussItemsMessage.itemPair.item2.name }} + {{ ITEMS[discussItemsMessage.itemPair.item2].name }}
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts index 43390d79..21fc4fa2 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-discuss-items-message/chat-discuss-items-message.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { DiscussItemsMessage, dateStrOfTimestamp } from '@llm-mediation-experiments/utils'; +import { DiscussItemsMessage, ITEMS, dateStrOfTimestamp } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-chat-discuss-items-message', @@ -12,4 +12,5 @@ export class ChatDiscussItemsMessageComponent { @Input() discussItemsMessage!: DiscussItemsMessage; readonly dateStrOfTimestamp = dateStrOfTimestamp; + readonly ITEMS = ITEMS; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html index 80ef822a..0f1e5a1b 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.html @@ -1,7 +1,9 @@
{{ dateStrOfTimestamp(message.timestamp) }}
- +
{{ message.text }}
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts index 09a22e39..d9aa8d63 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/chat-user-message/chat-user-message.component.ts @@ -1,11 +1,10 @@ -import { Component, Inject, Input, Signal, computed } from '@angular/core'; +import { Component, Input, Signal, computed } from '@angular/core'; import { - ParticipantExtended, + ParticipantProfile, UserMessage, dateStrOfTimestamp, - lookupTable, } from '@llm-mediation-experiments/utils'; -import { EXPERIMENT_PROVIDER_TOKEN, ExperimentProvider } from 'src/lib/provider-tokens'; +import { ParticipantService } from 'src/app/services/participant.service'; import { ChatUserProfileComponent } from '../chat-user-profile/chat-user-profile.component'; @Component({ @@ -18,11 +17,11 @@ import { ChatUserProfileComponent } from '../chat-user-profile/chat-user-profile export class ChatUserMessageComponent { @Input() message!: UserMessage; - lookup: Signal>; + participants: Signal>; - constructor(@Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider) { - this.lookup = computed(() => - lookupTable(experimentProvider.get()()?.participants ?? [], 'uid'), + constructor(participantService: ParticipantService) { + this.participants = computed( + () => participantService.experiment()?.experiment()?.participants ?? {}, ); } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html index 15135a87..f139247b 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.html @@ -1,8 +1,10 @@
Other chat members
- @for (user of this.otherParticipants(); track user.uid) { - @if (user.workingOnStageName === this.participant.userData()?.workingOnStageName) { + @for (user of participantService.otherParticipants(); track user.publicId) { + @if ( + user.workingOnStageName === participantService.participant()?.profile()?.workingOnStageName + ) {
avatar
@@ -22,13 +24,13 @@ Discussion
- @for (message of messages(); track $index) { + @for (message of chat?.messages(); track $index) {
- @if (message.messageType === 'userMessage') { + @if (message.kind === 'userMessage') { - } @else if (message.messageType === 'mediatorMessage') { + } @else if (message.kind === 'mediatorMessage') { - } @else if (message.messageType === 'discussItemsMessage') { + } @else if (message.kind === 'discussItemsMessage') { @@ -40,7 +42,9 @@
- + Message to send @@ -49,7 +53,7 @@ type="submit" color="primary" mat-button - [disabled]="this.readyToEndChat() || !this.message.valid" + [disabled]="readyToEndChat() || !message.valid" > Send @@ -59,34 +63,42 @@
- I'm done with chatting and ready to move on + @if (currentRatingsIndex() < stage.config().chatConfig.ratingsToDiscuss.length - 1) { + I'm done with chatting and ready to move on to the next pair of items + } @else { + I'm done with chatting and ready to move on to the next stage + }
@if (currentRatingsToDiscuss()) { - Your discussion is now focusing on selecting the best item between the following items + Your discussion is now focusing on selecting the best item between the following items ({{ + currentRatingsIndex() + 1 + }}/{{ stage.config().chatConfig.ratingsToDiscuss.length }})
{{ currentRatingsToDiscuss().item1.name }} - {{ currentRatingsToDiscuss().item1.name }} + {{ ITEMS[currentRatingsToDiscuss().item1].name }}
{{ currentRatingsToDiscuss().item2.name }} - {{ currentRatingsToDiscuss().item2.name }} + {{ ITEMS[currentRatingsToDiscuss().item2].name }}
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss index 25110b03..d54e5a20 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.scss @@ -98,3 +98,8 @@ } } } + +.messages { + max-height: 25em; + overflow: auto; +} diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts index 0f8e77c7..0021eaeb 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-chat/exp-chat.component.ts @@ -8,15 +8,13 @@ import { Component, - Inject, + EnvironmentInjector, Input, - OnDestroy, Signal, - WritableSignal, computed, effect, + runInInjectionContext, signal, - untracked, } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -24,32 +22,20 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { - DiscussItemsMessage, - ExpStageChatAboutItems, + ChatKind, + GroupChatStageConfig, + GroupChatStagePublicData, + ITEMS, ItemPair, - Message, - MessageType, - ParticipantExtended, - ReadyToEndChat, + StageKind, + assertCast, getDefaultItemPair, - mergeByKey, } from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { Unsubscribe } from 'firebase/firestore'; -import { - toggleChatMutation, - updateChatStageMutation, - userMessageMutation, -} from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { - EXPERIMENT_PROVIDER_TOKEN, - ExperimentProvider, - PARTICIPANT_PROVIDER_TOKEN, - ParticipantProvider, -} from 'src/lib/provider-tokens'; + +import { AppStateService } from 'src/app/services/app-state.service'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { ChatRepository } from 'src/lib/repositories/chat.repository'; import { localStorageTimer } from 'src/lib/utils/angular.utils'; -import { chatMessagesSubscription, firestoreDocSubscription } from 'src/lib/utils/firestore.utils'; import { ChatDiscussItemsMessageComponent } from './chat-discuss-items-message/chat-discuss-items-message.component'; import { ChatMediatorMessageComponent } from './chat-mediator-message/chat-mediator-message.component'; import { ChatUserMessageComponent } from './chat-user-message/chat-user-message.component'; @@ -77,130 +63,88 @@ const TIMER_SECONDS = 60; // 1 minute between item pairs for discussions templateUrl: './exp-chat.component.html', styleUrl: './exp-chat.component.scss', }) -export class ExpChatComponent implements OnDestroy { +export class ExpChatComponent { + private _stage?: CastViewingStage; + readonly ITEMS = ITEMS; + // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageChatAboutItems) { + set stage(value: CastViewingStage) { this._stage = value; - this.everyoneReachedTheChat = this.participant.everyoneReachedCurrentStage(this.stage.name); - - // Initialize the current rating to discuss with the first available pair - const { id1, id2 } = this.stage.config.ratingsToDiscuss[0]; - this.currentRatingsToDiscuss = signal({ - item1: this.stage.config.items[id1], - item2: this.stage.config.items[id2], - }); - - this.unsubscribeMessages = chatMessagesSubscription( - this.stage.config.chatId, - (incomingMessages) => { - // Merge incoming and current message, giving incoming messages priority. Messages are uniquely identified by their uid. - this.messages.set(mergeByKey(this.messages(), incomingMessages, 'uid')); - - // Find if new discuss items message have arrived - const last = incomingMessages.find( - (m) => m.messageType === MessageType.DiscussItemsMessage, - ) as DiscussItemsMessage | undefined; - - if (last) this.currentRatingsToDiscuss.set(last.itemPair); - }, + this.everyoneReachedTheChat = computed(() => + this.participantService.experiment()!.everyoneReachedStage(this.stage.config().name)(), ); - // Firestore subscription for ready to end chat - this.unsubscribeReadyToEndChat = firestoreDocSubscription( - `participants_ready_to_end_chat/${this.stage.config.chatId}`, - (d) => { - if (this.discussingPairIndex() !== d?.currentPair && d) - this.discussingPairIndex.set(d?.currentPair); - }, - ); + runInInjectionContext(this.injector, () => { + // On config change, extract the relevant chat repository and recompute signals + effect(() => { + const config = this.stage.config(); + + // Extract the relevant chat repository for this chat + this.chat = this.appState.chats.get({ + chatId: config.chatId, + experimentId: this.participantService.experimentId()!, + participantId: this.participantService.participantId()!, + }); + + this.readyToEndChat = computed(() => this.chat!.chat()?.readyToEndChat ?? false); + this.currentRatingsIndex = computed(() => { + return this.stage.public!().chatData.currentRatingIndex ?? 0; + }); + + // Initialize the current rating to discuss with the first available pair + const { item1, item2 } = config.chatConfig.ratingsToDiscuss[0]; + this.currentRatingsToDiscuss = signal({ item1, item2 }); + this.currentRatingsToDiscuss = computed( + () => config.chatConfig.ratingsToDiscuss[this.currentRatingsIndex()], + ); + }); + + effect(() => { + // Only if we are currently working on this stage + if (this.participantService.workingOnStageName() !== this.stage.config().name) return; + this.currentRatingsIndex(); // Trigger reactivity when the currentRatingsIndex changes + this.chat?.markReadyToEndChat(false); // Reset readyToEndChat when the items to discuss change + this.timer.reset(TIMER_SECONDS); // Reset the timer + }); + + if (this.participantService.workingOnStageName() === this.stage.config().name) { + // Automatic next step progression when the chat has ended + effect(() => { + const config = this.stage.config(); + const pub = this.stage.public!(); + if (chatReadyToEnd(config, pub)) this.nextStep(); + }); + } + }); } get stage() { - return this._stage as ExpStageChatAboutItems; + return this._stage as CastViewingStage; } - public _stage?: ExpStageChatAboutItems; - - public participant: Participant; - public otherParticipants: Signal; public everyoneReachedTheChat: Signal; + public readyToEndChat: Signal = signal(false); // Extracted stage data - public currentRatingsToDiscuss: WritableSignal; - - // Queries - private client = injectQueryClient(); - - // Message subscription - public messages: WritableSignal; - private unsubscribeMessages: Unsubscribe | undefined; - - // Ready to end chat subscription - private unsubscribeReadyToEndChat: Unsubscribe | undefined; + public currentRatingsIndex: Signal; + public currentRatingsToDiscuss: Signal; // Message mutation & form - public messageMutation = userMessageMutation(); public message = new FormControl('', Validators.required); - // Chat completion mutation - public finishChatMutation = updateChatStageMutation(this.client, () => - this.participant.navigateToNextStage(), - ); - - public discussingPairIndex = signal(0); - - public toggleMutation = toggleChatMutation(); - public readyToEndChat: WritableSignal = signal(false); // Frontend-only, no need to have fine-grained backend sync for this - public timer = localStorageTimer('chat-timer', TIMER_SECONDS, () => this.toggleEndChat()); // 1 minute timer + public chat: ChatRepository | undefined; constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ParticipantProvider, - @Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider, + private appState: AppStateService, + public participantService: ParticipantService, + private injector: EnvironmentInjector, ) { - this.participant = participantProvider.get(); // Get the participant instance - // Extract stage data this.everyoneReachedTheChat = signal(false); + this.currentRatingsIndex = signal(0); this.currentRatingsToDiscuss = signal(getDefaultItemPair()); - - this.otherParticipants = computed( - () => - experimentProvider - .get()() - ?.participants.filter(({ uid }) => uid !== this.participant.userData()?.uid) ?? [], - ); - - // Firestore subscription for messages - this.messages = signal([]); - - effect( - () => { - if ( - this.participant.workingOnStage()?.name !== this.stage.name || - !this.everyoneReachedTheChat() - ) - return; // Continue only if this stage is active - - const index = this.discussingPairIndex(); - - if (index < this.stage.config.ratingsToDiscuss.length) { - // Update to the next, reset the counter. - this.timer.reset(TIMER_SECONDS); - this.readyToEndChat.set(false); - } else { - // The chat experiment has ended - this.finishChatMutation.mutate({ - uid: untracked(this.participant.userData)!.uid, - name: this.stage.name, - data: { readyToEndChat: true }, - ...this.participant.getStageProgression(), - }); - } - }, - { allowSignalWrites: true }, - ); } isSilent() { @@ -208,33 +152,40 @@ export class ExpChatComponent implements OnDestroy { // return this.stageData().isSilent !== false; } - sendMessage() { - if (!this.message.valid) return; - - this.messageMutation.mutate({ - chatId: this.stage.config.chatId, - text: this.message.value!, - fromUserId: this.participant.userData()!.uid, - }); + async sendMessage() { + if (!this.message.valid || !this.message.value) return; + this.chat?.sendUserMessage(this.message.value); this.message.setValue(''); } toggleEndChat() { if (this.readyToEndChat()) return; - this.readyToEndChat.set(true); - this.toggleMutation.mutate({ - chatId: this.stage.config.chatId, - participantId: this.participant.userData()!.uid, - readyToEndChat: true, - }); + this.chat?.markReadyToEndChat(true); this.message.disable(); this.timer.remove(); } - ngOnDestroy() { - this.unsubscribeMessages?.(); - this.unsubscribeReadyToEndChat?.(); + async nextStep() { + await this.participantService.workOnNextStage(); + this.timer.remove(); } } + +const chatReadyToEnd = (config: GroupChatStageConfig, pub: GroupChatStagePublicData) => { + // If someone is not ready to end, return false + if (Object.values(pub.readyToEndChat).some((bool) => !bool)) return false; + + // If this is a chat about items, all items must have been discussed + if (config.chatConfig.kind === ChatKind.ChatAboutItems) { + if ( + assertCast(pub.chatData, ChatKind.ChatAboutItems).currentRatingIndex < + config.chatConfig.ratingsToDiscuss.length + ) + return false; + } + + // If all checks passed, the chat stage is ready to end + return true; +}; diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html index 164c90c4..24c85c5e 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.html @@ -5,7 +5,8 @@
} @else {
- The leader is {{ finalLeader() }} + The leader is +
} diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts index 8ab77c9e..4ae1ec4d 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-reveal/exp-leader-reveal.component.ts @@ -1,65 +1,63 @@ -import { Component, Inject, Input, signal, Signal } from '@angular/core'; +import { Component, computed, Input, signal, Signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; - -import { ExpStageVoteReveal, VoteReveal } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; -import { updateLeaderRevealStageMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; +import { + assertCast, + ParticipantProfile, + StageKind, + VoteForLeaderStagePublicData, +} from '@llm-mediation-experiments/utils'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { ChatUserProfileComponent } from '../exp-chat/chat-user-profile/chat-user-profile.component'; @Component({ selector: 'app-exp-leader-reveal', standalone: true, - imports: [MatButtonModule], + imports: [MatButtonModule, ChatUserProfileComponent], templateUrl: './exp-leader-reveal.component.html', styleUrl: './exp-leader-reveal.component.scss', }) export class ExpLeaderRevealComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageVoteReveal) { + set stage(value: CastViewingStage) { this._stage = value; - this.stageData = this.stage.config; - this.everyoneReachedThisStage = this.participant.everyoneReachedCurrentStage(this.stage.name); + this.everyoneReachedThisStage = computed(() => + this.participantService.experiment()!.everyoneReachedStage(this.stage.config().name)(), + ); + + // Extract results from the public vote for leader stage data + this.results = computed(() => + assertCast( + this.participantService.experiment()!.publicStageDataMap[ + this.stage.config().pendingVoteStageName + ]!(), + StageKind.VoteForLeader, + ), + ); + + this.winner = computed(() => { + return this.participantService.experiment()?.experiment()?.participants[ + this.results()!.currentLeader! + ]; + }); } - private queryClient = injectQueryClient(); - private mutationReveal = updateLeaderRevealStageMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); - - get stage(): ExpStageVoteReveal { - return this._stage as ExpStageVoteReveal; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageVoteReveal; - - public participant: Participant; - public stageData: VoteReveal; + private _stage?: CastViewingStage; public everyoneReachedThisStage: Signal; - public finalLeader: Signal; + public results: Signal = signal(undefined); + public winner: Signal = signal(undefined); - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - this.stageData = this.stage?.config; // This will truly be initialized in ngOnInit. this.stage can be undefined here - - this.everyoneReachedThisStage = signal(false); - - // TODO: use the new backend - this.finalLeader = signal('TODO'); + constructor(private participantService: ParticipantService) { + this.everyoneReachedThisStage = signal(false); } - nextStep() { - this.mutationReveal.mutate({ - data: undefined, - name: this.stage.name, - ...this.participant.getStageProgression(), - uid: this.participant.userData()!.uid, - }); + async nextStep() { + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html index 042bc535..4bbe3258 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.html @@ -1,9 +1,9 @@
- @for (user of this.otherParticipants(); track user.uid) { - - + @for (user of participantService.otherParticipants(); track user.publicId) { + + {{ Vote.Positive }} @@ -14,7 +14,12 @@ {{ Vote.Negative }} - diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts index 6e5fa62c..4d8f6437 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-leader-vote/exp-leader-vote.component.ts @@ -6,8 +6,7 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, computed, Inject, Input, Signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; +import { Component, Input } from '@angular/core'; import { FormBuilder, FormControl, @@ -17,18 +16,10 @@ import { } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { ProviderService } from 'src/app/services/provider.service'; -import { updateLeaderVoteStageMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { - EXPERIMENT_PROVIDER_TOKEN, - ExperimentProvider, - PARTICIPANT_PROVIDER_TOKEN, -} from 'src/lib/provider-tokens'; -import { ExpStageVotes, ParticipantExtended, Vote, Votes } from '@llm-mediation-experiments/utils'; -import { forbiddenValueValidator } from 'src/lib/utils/angular.utils'; +import { StageKind, Vote, VoteForLeaderStageAnswer } from '@llm-mediation-experiments/utils'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; +import { forbiddenValueValidator, subscribeSignal } from 'src/lib/utils/angular.utils'; @Component({ selector: 'app-exp-leader-vote', @@ -40,55 +31,33 @@ import { forbiddenValueValidator } from 'src/lib/utils/angular.utils'; export class ExpLeaderVoteComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageVotes) { + set stage(value: CastViewingStage) { this._stage = value; - this.voteConfig = this.stage.config; - this.initializeForm(); + // Update the form when the answers change (this will also initialize the form the first time) + if (this.stage.answers) + subscribeSignal(this.stage.answers, (answers: VoteForLeaderStageAnswer) => + this.initializeForm(answers), + ); + else this.initializeForm({ votes: {}, kind: StageKind.VoteForLeader }); // Initialize the form with empty answers } - get stage(): ExpStageVotes { - return this._stage as ExpStageVotes; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageVotes; - - public otherParticipants: Signal; + private _stage?: CastViewingStage; readonly Vote = Vote; - - public participant: Participant; - public voteConfig: Votes; - public votesForm: FormGroup; - private client = injectQueryClient(); - - // Vote completion mutation - public voteMutation = updateLeaderVoteStageMutation(this.client, () => - this.participant.navigateToNextStage(), - ); - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - @Inject(EXPERIMENT_PROVIDER_TOKEN) experimentProvider: ExperimentProvider, + public participantService: ParticipantService, fb: FormBuilder, ) { - this.participant = participantProvider.get(); - this.voteConfig = this.stage?.config; - this.votesForm = fb.group({ votes: fb.group({}), }); - - this.otherParticipants = computed( - () => - experimentProvider - .get()() - ?.participants.filter(({ uid }) => uid !== this.participant.userData()?.uid) ?? [], - ); - - toObservable(this.otherParticipants).subscribe(() => this.initializeForm()); } get votes() { @@ -110,23 +79,21 @@ export class ExpLeaderVoteComponent { }); } - nextStep() { - this.voteMutation.mutate({ - data: this.votesForm.value.votes, - name: this.stage.name, - uid: this.participant.userData()!.uid, - ...this.participant.getStageProgression(), - }); + async nextStep() { + await this.participantService + .participant() + ?.updateVoteForLeaderStage(this.stage.config().name, this.votesForm.value.votes); + await this.participantService.workOnNextStage(); } /** Call this when the input or the other participants signal change in order to stay up to date */ - initializeForm() { + initializeForm(answers: VoteForLeaderStageAnswer) { this.clearForm(); - for (const p of this.otherParticipants()) { + for (const p of this.participantService.otherParticipants()) { this.votes.addControl( - p.uid, + p.publicId, new FormControl( - this.voteConfig[p.uid] || Vote.NotRated, + answers.votes[p.publicId] || Vote.NotRated, forbiddenValueValidator(Vote.NotRated), ), ); diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html index a3ce96c8..4d627a44 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.html @@ -1,28 +1,28 @@
- @for (question of stage.config.questions; track question.id; let index = $index) { + @for (question of stage.config().questions; track index; let index = $index) { @switch (question.kind) { @case (SurveyQuestionKind.Check) { } @case (SurveyQuestionKind.Text) { } @case (SurveyQuestionKind.Rating) { } @case (SurveyQuestionKind.Scale) { } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts index 8edcc05b..dbdd132a 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/exp-survey.component.ts @@ -6,7 +6,13 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Inject, Input } from '@angular/core'; +import { + Component, + EnvironmentInjector, + Input, + effect, + runInInjectionContext, +} from '@angular/core'; import { FormArray, FormBuilder, @@ -18,20 +24,9 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSliderModule } from '@angular/material/slider'; -import { ProviderService } from 'src/app/services/provider.service'; -import { Participant } from 'src/lib/participant'; - import { MatButtonModule } from '@angular/material/button'; -import { - ExpStageSurvey, - SurveyQuestionKind, - SurveyStageUpdate, - questionAsKind, -} from '@llm-mediation-experiments/utils'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { updateSurveyStageMutation } from 'src/lib/api/mutations'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; -import { MutationType } from 'src/lib/types/tanstack.types'; +import { StageKind, SurveyQuestionKind, assertCast } from '@llm-mediation-experiments/utils'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; import { buildQuestionForm } from 'src/lib/utils/angular.utils'; import { SurveyCheckQuestionComponent } from './survey-check-question/survey-check-question.component'; import { SurveyRatingQuestionComponent } from './survey-rating-question/survey-rating-question.component'; @@ -59,62 +54,58 @@ import { SurveyTextQuestionComponent } from './survey-text-question/survey-text- export class ExpSurveyComponent { // Reload the internal logic dynamically when the stage changes @Input({ required: true }) - set stage(value: ExpStageSurvey) { + set stage(value: CastViewingStage) { this._stage = value; - // Regenerate the questions - this.questions.clear(); - this.stage.config.questions.forEach((question) => { - this.questions.push(buildQuestionForm(this.fb, question)); + // Regenerate the questions everytime the stage config or answers change + runInInjectionContext(this.injector, () => { + effect(() => { + const { questions } = this.stage.config(); + const answers = this.stage.answers?.(); + + this.answers.clear(); + questions.forEach((config) => { + const answer = answers?.answers[config.id]; + // The config serves as the source of truth for the question type + // The answer, if defined, will be used to populate the form + this.answers.push(buildQuestionForm(this.fb, config, answer)); + }); + }); }); } - get stage(): ExpStageSurvey { - return this._stage as ExpStageSurvey; + get stage() { + return this._stage as CastViewingStage; } - private _stage?: ExpStageSurvey; - - public participant: Participant; + private _stage?: CastViewingStage; - public questions: FormArray; + public answers: FormArray; public surveyForm: FormGroup; readonly SurveyQuestionKind = SurveyQuestionKind; - readonly questionAsKind = questionAsKind; - - queryClient = injectQueryClient(); - - surveyMutation: MutationType; + readonly assertCast = assertCast; constructor( private fb: FormBuilder, - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, + public participantService: ParticipantService, + private injector: EnvironmentInjector, ) { - this.participant = participantProvider.get(); - this.questions = fb.array([]); + this.answers = fb.array([]); this.surveyForm = fb.group({ - questions: this.questions, + answers: this.answers, }); - - this.surveyMutation = updateSurveyStageMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); } /** Returns controls for each individual question component */ get questionControls() { - return this.questions.controls as FormGroup[]; + return this.answers.controls as FormGroup[]; } - nextStep() { - this.surveyMutation.mutate({ - name: this.stage.name, - data: { - questions: this.surveyForm.value.questions, - }, - ...this.participant.getStageProgression(), - uid: this.participant.userData()?.uid as string, - }); + async nextStep() { + await this.participantService + .participant() + ?.updateSurveyStage(this.stage.config().name, this.surveyForm.value.answers); + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts index d77fc39a..4d494383 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-check-question/survey-check-question.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { CheckQuestion } from '@llm-mediation-experiments/utils'; +import { CheckQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-check-question', @@ -11,6 +11,6 @@ import { CheckQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-check-question.component.scss', }) export class SurveyCheckQuestionComponent { - @Input() question!: CheckQuestion; + @Input() question!: CheckQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html index ee8eb0b7..fbe5715f 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.html @@ -3,18 +3,18 @@
{{ question.questionText }}
- {{ question.item1.name }} - {{ question.item1.name }} + {{ ITEMS[question.item1].name }} + {{ ITEMS[question.item1].name }} - {{ question.item2.name }} - {{ question.item2.name }} + {{ ITEMS[question.item2].name }} + {{ ITEMS[question.item2].name }} @@ -22,12 +22,18 @@
Confidence: - - {{ question.confidence !== null ? question.confidence.toFixed(1) : 'Not rated' }} + + {{ + questionForm.value['confidence'] !== null + ? questionForm.value['confidence'].toFixed(1) + : 'Not rated' + }}
- @if (question.confidence === 0 || question.confidence === null) { + @if ( + questionForm.value['confidence'] === 0 || questionForm.value['confidence'] === null + ) { 50/50 🤷 } @else { 50/50 🤷 @@ -35,7 +41,7 @@ - @if (question.confidence === 1) { + @if (questionForm.value['confidence'] === 1) { 👍 Full
confidence
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts index 3da3c4b4..dea81ad4 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-rating-question/survey-rating-question.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatRadioModule } from '@angular/material/radio'; import { MatSliderModule } from '@angular/material/slider'; -import { RatingQuestion } from '@llm-mediation-experiments/utils'; +import { ITEMS, RatingQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-rating-question', @@ -12,6 +12,8 @@ import { RatingQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-rating-question.component.scss', }) export class SurveyRatingQuestionComponent { - @Input() question!: RatingQuestion; + @Input() question!: RatingQuestionConfig; @Input() questionForm!: FormGroup; + + readonly ITEMS = ITEMS; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts index aef00eef..5daf13fc 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-scale-question/survey-scale-question.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatSliderModule } from '@angular/material/slider'; -import { ScaleQuestion } from '@llm-mediation-experiments/utils'; +import { ScaleQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-scale-question', @@ -11,6 +11,6 @@ import { ScaleQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-scale-question.component.scss', }) export class SurveyScaleQuestionComponent { - @Input() question!: ScaleQuestion; + @Input() question!: ScaleQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts index 937d1ac4..6f1dd792 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-survey/survey-text-question/survey-text-question.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { TextQuestion } from '@llm-mediation-experiments/utils'; +import { TextQuestionConfig } from '@llm-mediation-experiments/utils'; @Component({ selector: 'app-survey-text-question', @@ -12,6 +12,6 @@ import { TextQuestion } from '@llm-mediation-experiments/utils'; styleUrl: './survey-text-question.component.scss', }) export class SurveyTextQuestionComponent { - @Input() question!: TextQuestion; + @Input() question!: TextQuestionConfig; @Input() questionForm!: FormGroup; } diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html index 6e6f104f..8ce7c2c6 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.html @@ -66,7 +66,7 @@

Terms of Service

Please read and accept the following terms of service before proceeding:

    - @for (line of tosLines; track line) { + @for (line of stage.config().tosLines; track line) {
  • {{ line }}
  • }
@@ -79,12 +79,7 @@

Terms of Service

I accept the terms of service -
diff --git a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts index bb2b0d09..4146648c 100644 --- a/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/exp-tos-and-profile/exp-tos-and-profile.component.ts @@ -6,22 +6,17 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { HttpClient } from '@angular/common/http'; -import { Component, Inject, Input, inject } from '@angular/core'; +import { Component, Input, effect } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; -import { injectQueryClient } from '@tanstack/angular-query-experimental'; -import { ExpStageTosAndUserProfile, ProfileTOSData } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; -import { updateProfileAndTOSMutation } from 'src/lib/api/mutations'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; -import { MutationType } from 'src/lib/types/tanstack.types'; +import { StageKind, UnifiedTimestamp } from '@llm-mediation-experiments/utils'; +import { Timestamp } from 'firebase/firestore'; +import { CastViewingStage, ParticipantService } from 'src/app/services/participant.service'; enum Pronouns { HeHim = 'He/Him', @@ -46,61 +41,34 @@ enum Pronouns { }) export class ExpTosAndProfileComponent { // Reload the internal logic dynamically when the stage changes - @Input({ required: true }) - set stage(value: ExpStageTosAndUserProfile) { - this._stage = value; - - // Extract the TOS lines and make them available for the template - this.tosLines = this.stage?.config.tosLines; - } - - get stage(): ExpStageTosAndUserProfile { - return this._stage as ExpStageTosAndUserProfile; - } - - private _stage?: ExpStageTosAndUserProfile; - - public participant: Participant; - public tosLines: string[] | undefined; + @Input({ required: true }) stage!: CastViewingStage; readonly Pronouns = Pronouns; + tosLines: string[] = []; profileFormControl = new FormGroup({ name: new FormControl('', Validators.required), pronouns: new FormControl('', Validators.required), avatarUrl: new FormControl('', Validators.required), - acceptTosTimestamp: new FormControl(null, Validators.required), + acceptTosTimestamp: new FormControl(null, Validators.required), }); - http = inject(HttpClient); - queryClient = injectQueryClient(); - - profileMutation: MutationType; value = ''; // Custom pronouns input value - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - this.profileMutation = updateProfileAndTOSMutation(this.queryClient, () => - this.participant.navigateToNextStage(), - ); + constructor(public participantService: ParticipantService) { + // Refresh the form data when the participant profile changes + effect(() => { + const profile = participantService.participant()?.profile(); - // This WILL already have been fetched by the backend at this point, - // because the auth guard ensures that the participant data is available before rendering this component. - const data = this.participant.userData(); + if (!profile) return; - if (data) { this.profileFormControl.setValue({ - name: data.name, - pronouns: data.pronouns, - avatarUrl: data.avatarUrl, - acceptTosTimestamp: data.acceptTosTimestamp, + name: profile.name, + pronouns: profile.pronouns, + avatarUrl: profile.avatarUrl, + acceptTosTimestamp: profile.acceptTosTimestamp, }); - if (this.isOtherPronoun(data.pronouns)) { - this.value = data.pronouns; - } - } + }); } isOtherPronoun(s: string) { @@ -117,15 +85,12 @@ export class ExpTosAndProfileComponent { updateCheckboxValue(updatedValue: MatCheckboxChange) { this.profileFormControl.patchValue({ - acceptTosTimestamp: updatedValue.checked ? new Date().toISOString() : null, + acceptTosTimestamp: updatedValue.checked ? Timestamp.now() : null, }); } - nextStep() { - this.profileMutation.mutate({ - ...this.profileFormControl.value, - ...this.participant.getStageProgression(), - uid: this.participant.userData()?.uid, - } as ProfileTOSData); + async nextStep() { + await this.participantService.participant()?.updateProfile(this.profileFormControl.value); + await this.participantService.workOnNextStage(); } } diff --git a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html index 274f585e..c1fb13ec 100644 --- a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html +++ b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.html @@ -1,44 +1,56 @@
-

{{ this.participant.viewingStage()?.name }} ({{ this.participant.viewingStage()?.kind }})

-
- @switch (this.participant.viewingStage()?.kind) { - @case (StageKind.AcceptTosAndSetProfile) { - + @if (participantService.viewingStage() === undefined) { + Loading... + } @else { +

+ {{ participantService.viewingStage()!.config().name }} ({{ + participantService.viewingStage()!.config().kind + }}) +

+
+ @switch (participantService.viewingStage()!.config().kind) { + @case (StageKind.AcceptTosAndSetProfile) { + + } + @case (StageKind.GroupChat) { + + } + @case (StageKind.TakeSurvey) { + + } + @case (StageKind.VoteForLeader) { + + } + @case (StageKind.RevealVoted) { + + } + @default { +
+ Oh no! It looks like something went wrong, there is no component for + {{ participantService.viewingStage()!.config().kind }} +
+ } } - @case (StageKind.GroupChat) { - - } - @case (StageKind.TakeSurvey) { - - } - @case (StageKind.VoteForLeader) { - - } - @case (StageKind.RevealVoted) { - - } - @default { -
- Oh no! It looks like something went wrong, there is no component for - {{ this.participant.viewingStage()?.kind }} + + @if ( + participantService.viewingStage()!.config().name !== participantService.workingOnStageName() + ) { +
+
} - } - - @if (this.participant.viewingStage()?.name !== this.participant.workingOnStage()?.name) { -
- -
- } -
+
+ }
diff --git a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts index 3aeeb8f6..07cae464 100644 --- a/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts +++ b/webapp/src/app/participant-view/participant-stage-view/participant-stage-view.component.ts @@ -6,17 +6,14 @@ * found in the LICENSE file and http://www.apache.org/licenses/LICENSE-2.0 ==============================================================================*/ -import { Component, Inject } from '@angular/core'; +import { Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { StageKind } from '@llm-mediation-experiments/utils'; +import { ParticipantService, assertCastStageSignals } from 'src/app/services/participant.service'; import { ExpChatComponent } from './exp-chat/exp-chat.component'; import { ExpLeaderRevealComponent } from './exp-leader-reveal/exp-leader-reveal.component'; import { ExpLeaderVoteComponent } from './exp-leader-vote/exp-leader-vote.component'; -//import { ExpRatingComponent } from '../exp-rating/exp-rating.component'; -import { StageKind, stageAsKind } from '@llm-mediation-experiments/utils'; -import { ProviderService } from 'src/app/services/provider.service'; -import { Participant } from 'src/lib/participant'; -import { PARTICIPANT_PROVIDER_TOKEN } from 'src/lib/provider-tokens'; import { ExpSurveyComponent } from './exp-survey/exp-survey.component'; import { ExpTosAndProfileComponent } from './exp-tos-and-profile/exp-tos-and-profile.component'; @@ -35,24 +32,8 @@ import { ExpTosAndProfileComponent } from './exp-tos-and-profile/exp-tos-and-pro styleUrl: './participant-stage-view.component.scss', }) export class ParticipantStageViewComponent { - public participant: Participant; readonly StageKind = StageKind; - readonly stageAsKind = stageAsKind; + readonly assertCast = assertCastStageSignals; - constructor( - @Inject(PARTICIPANT_PROVIDER_TOKEN) participantProvider: ProviderService, - ) { - this.participant = participantProvider.get(); - } - - shouldShowNextStep() { - const userData = this.participant.userData(); - const workingOnStage = this.participant.workingOnStage(); - - if (!userData || !workingOnStage) { - return false; - } - - return userData.allowedStageProgressionMap[workingOnStage.name]; - } + constructor(public readonly participantService: ParticipantService) {} } diff --git a/webapp/src/app/participant-view/participant-view.component.html b/webapp/src/app/participant-view/participant-view.component.html index 9dc4f42b..2807aed9 100644 --- a/webapp/src/app/participant-view/participant-view.component.html +++ b/webapp/src/app/participant-view/participant-view.component.html @@ -1,8 +1,8 @@