Skip to content
Open
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
25 changes: 25 additions & 0 deletions frontend/src/components/stages/group_chat_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ export class ChatEditor extends MobxLitElement {
</div>
`
: nothing}
${timeLimit !== null
? html`
<div class="number-input tab tab-bottom">
<label for="timeMinimum">
Minimum required wait time (in minutes)
</label>
<input
type="number"
id="timeMinimum"
name="timeMinimum"
min="0"
.value=${this.stage?.timeMinimumInMinutes ?? 0}
?disabled=${!this.experimentEditor.canEditStages}
@input=${(e: InputEvent) => {
const val = Number((e.target as HTMLInputElement).value);
if (!this.stage) return;
this.experimentEditor.updateStage({
...this.stage,
timeMinimumInMinutes: val > 0 ? val : null,
});
}}
/>
</div>
`
: nothing}
</div>
`;
}
Expand Down
33 changes: 28 additions & 5 deletions frontend/src/components/stages/group_chat_participant_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ChatStageConfig,
DiscussionItem,
StageKind,
getTimeElapsed,
} from '@deliberation-lab/utils';

import {styles} from './group_chat_participant_view.scss';
Expand Down Expand Up @@ -164,7 +165,7 @@ export class GroupChatView extends MobxLitElement {
}

const onClick = async () => {
if (!this.stage) return;
if (!this.stage || this.isMinimumTimeNotMet) return;

this.readyToEndDiscussionLoading = true;
try {
Expand All @@ -183,13 +184,16 @@ export class GroupChatView extends MobxLitElement {
this.participantService.isReadyToEndChatDiscussion(
this.stage.id,
currentDiscussionId,
);
) ||
this.isMinimumTimeNotMet;

return html`
<pr-tooltip
text=${isDisabled
? 'You can move on once others are also ready to move on.'
: ''}
text=${this.isMinimumTimeNotMet
? `You must wait until ${this.stage.timeMinimumInMinutes} minutes have passed.`
: isDisabled
? 'You can move on once others are also ready to move on.'
: ''}
position="TOP_END"
>
<pr-button
Expand Down Expand Up @@ -267,6 +271,8 @@ export class GroupChatView extends MobxLitElement {
}
}

disableNext = disableNext || this.isMinimumTimeNotMet;

const renderProgress = () => {
if (!this.stage?.progress.showParticipantProgress) {
return nothing;
Expand Down Expand Up @@ -306,6 +312,23 @@ export class GroupChatView extends MobxLitElement {
</stage-footer>
`;
}

get isMinimumTimeNotMet() {
if (!this.stage || !this.stage.timeMinimumInMinutes) return false;

const publicStageData = this.cohortService.stagePublicDataMap[
this.stage.id
] as ChatStagePublicData;

if (!publicStageData?.discussionStartTimestamp) {
return true;
}

return (
getTimeElapsed(publicStageData.discussionStartTimestamp, 'm') <
this.stage.timeMinimumInMinutes
);
}
}

declare global {
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/stages/private_chat_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@ export class ChatEditor extends MobxLitElement {
</div>
`
: nothing}
${timeLimit !== null
? html`
<div class="number-input tab tab-bottom">
<label for="timeMinimum">
Minimum required wait time (in minutes)
</label>
<input
type="number"
id="timeMinimum"
name="timeMinimum"
min="0"
.value=${this.stage?.timeMinimumInMinutes ?? 0}
?disabled=${!this.experimentEditor.canEditStages}
@input=${(e: InputEvent) => {
const val = Number((e.target as HTMLInputElement).value);
if (!this.stage) return;
this.experimentEditor.updateStage({
...this.stage,
timeMinimumInMinutes: val > 0 ? val : null,
});
}}
/>
</div>
`
: nothing}
</div>
`;
}
Expand Down
47 changes: 44 additions & 3 deletions frontend/src/components/stages/private_chat_participant_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ChatMessage,
PrivateChatStageConfig,
UserType,
getTimeElapsed,
} from '@deliberation-lab/utils';
import {getHashBasedColor} from '../../shared/utils';
import {ResponseTimeoutTracker} from '../../shared/response_timeout';
Expand Down Expand Up @@ -86,8 +87,19 @@ export class PrivateChatView extends MobxLitElement {
participantMessageCount >= this.stage.maxNumberOfTurns &&
!isWaitingForResponse;

// Check if conversation has ended (max turns reached and not waiting for response)
const isConversationOver = maxTurnsReached;
const discussionStartTimestamp =
chatMessages.length > 0 ? chatMessages[0].timestamp : null;
const elapsedMinutes = discussionStartTimestamp
? getTimeElapsed(discussionStartTimestamp, 'm')
: 0;

const maxTimeReached =
this.stage.timeLimitInMinutes !== null &&
this.stage.timeLimitInMinutes > 0 &&
elapsedMinutes >= this.stage.timeLimitInMinutes;

// Check if conversation has ended
const isConversationOver = maxTurnsReached || maxTimeReached;

// Disable input if turn-taking is set and latest message
// is from participant OR if conversation is over
Expand All @@ -111,6 +123,23 @@ export class PrivateChatView extends MobxLitElement {
!isWaitingForResponse
: participantMessageCount >= this.stage.minNumberOfTurns;

// Check if minimum time is met
let minTimeMet = true;
if (
this.stage.timeMinimumInMinutes !== null &&
this.stage.timeMinimumInMinutes > 0
) {
if (!discussionStartTimestamp) {
minTimeMet = false;
} else {
if (elapsedMinutes < this.stage.timeMinimumInMinutes) {
minTimeMet = false;
}
}
}

const isNextDisabled = !minTurnsMet || !minTimeMet;

return html`
<chat-interface .stage=${this.stage} .disableInput=${isDisabledInput()}>
${chatMessages.map((message) => this.renderChatMessage(message))}
Expand All @@ -119,13 +148,16 @@ export class PrivateChatView extends MobxLitElement {
: nothing}
${isConversationOver ? this.renderConversationEndedMessage() : nothing}
</chat-interface>
<stage-footer .disabled=${!minTurnsMet}>
<stage-footer .disabled=${isNextDisabled}>
${this.stage.progress.showParticipantProgress
? html`<progress-stage-completed></progress-stage-completed>`
: nothing}
${!minTurnsMet && !isConversationOver
? this.renderMinTurnsMessage(participantMessageCount)
: nothing}
${!minTimeMet && minTurnsMet && !isConversationOver
? this.renderMinTimeMessage()
: nothing}
</stage-footer>
`;
}
Expand Down Expand Up @@ -198,6 +230,15 @@ export class PrivateChatView extends MobxLitElement {
</div>
`;
}

private renderMinTimeMessage() {
return html`
<div class="description">
You must wait until ${this.stage?.timeMinimumInMinutes} minutes have
passed.
</div>
`;
}
}

declare global {
Expand Down
31 changes: 31 additions & 0 deletions functions/src/chat/chat.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ChatStagePublicData,
ChatStageParticipantAnswer,
createChatStageParticipantAnswer,
getTimeElapsed,
} from '@deliberation-lab/utils';
import {updateParticipantNextStage} from '../participant.utils';

Expand Down Expand Up @@ -71,6 +72,35 @@ export async function updateParticipantReadyToEndChat(
);
if (!publicStageData) return;

let startTimestamp = (publicStageData as ChatStagePublicData)
.discussionStartTimestamp;

if (stage.kind === 'privateChat' && !startTimestamp) {
const chatRef = app
.firestore()
.collection('experiments')
.doc(experimentId)
.collection('participants')
.doc(participantId)
.collection('stageData')
.doc(stage.id)
.collection('privateChats')
.orderBy('timestamp', 'asc')
.limit(1);

const chatSnap = await chatRef.get();
if (!chatSnap.empty) {
startTimestamp = chatSnap.docs[0].data().timestamp as Timestamp;
}
}

const minTime = (stage as ChatStageConfig).timeMinimumInMinutes;
if (minTime !== undefined && minTime !== null && minTime > 0) {
if (!startTimestamp) return;
const elapsedMinutes = getTimeElapsed(startTimestamp, 'm');
if (elapsedMinutes < minTime) return;
}

const participantAnswerDoc = getFirestoreParticipantAnswerRef(
experimentId,
participantId,
Expand Down Expand Up @@ -111,6 +141,7 @@ export async function updateParticipantReadyToEndChat(
} else {
// Otherwise, move to next stage
const experiment = await getFirestoreExperiment(experimentId);
if (!experiment) return;
await updateParticipantNextStage(
experimentId,
participant,
Expand Down
8 changes: 8 additions & 0 deletions functions/src/stages/chat.time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export async function updateTimeElapsed(
await handleTimeElapsed(experimentId, cohortId, stageId);
return;
}
const isUnderMinimum =
stage.timeMinimumInMinutes !== null &&
elapsedMinutes < stage.timeMinimumInMinutes;

if (remainingTime <= 0 && !isUnderMinimum) {
await handleTimeElapsed(experimentId, cohortId, stageId);
return;
}

// Otherwise, continue to wait
const maxWaitTimeInMinutes = 5;
Expand Down
2 changes: 2 additions & 0 deletions utils/src/stages/chat_stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ChatStageConfig extends BaseStageConfig {
kind: StageKind.CHAT;
discussions: ChatDiscussion[]; // ordered list of discussions
timeLimitInMinutes: number | null; // How long remaining in the chat.
timeMinimumInMinutes: number | null; // Minimum amount of time participants must spend in chat.
requireFullTime: boolean; // Require participants to stay in chat until time limit is up
}

Expand Down Expand Up @@ -118,6 +119,7 @@ export function createChatStage(
createStageProgressConfig({waitForAllParticipants: true}),
discussions: config.discussions ?? [],
timeLimitInMinutes: config.timeLimitInMinutes ?? null,
timeMinimumInMinutes: config.timeMinimumInMinutes ?? null,
requireFullTime: config.requireFullTime ?? false,
};
}
Expand Down
3 changes: 3 additions & 0 deletions utils/src/stages/private_chat_stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface PrivateChatStageConfig extends BaseStageConfig {
// If defined, ends chat after specified time limit
// (starting from when the first message is sent)
timeLimitInMinutes: number | null;
// Minimum amount of time a participant must spend in chat
timeMinimumInMinutes: number | null;
// Require participants to stay in chat until time limit is up
requireFullTime: boolean;
// If true, requires participant to go back and forth with mediator(s)
Expand Down Expand Up @@ -58,6 +60,7 @@ export function createPrivateChatStage(
config.progress ??
createStageProgressConfig({waitForAllParticipants: true}),
timeLimitInMinutes: config.timeLimitInMinutes ?? null,
timeMinimumInMinutes: config.timeMinimumInMinutes ?? null,
requireFullTime: config.requireFullTime ?? false,
isTurnBasedChat: config.isTurnBasedChat ?? true,
minNumberOfTurns: config.minNumberOfTurns ?? 0,
Expand Down
Loading