diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts
index 6db48cdf5..c1f297231 100644
--- a/frontend/src/components/stages/group_chat_editor.ts
+++ b/frontend/src/components/stages/group_chat_editor.ts
@@ -146,6 +146,31 @@ export class ChatEditor extends MobxLitElement {
`
: nothing}
+ ${timeLimit !== null
+ ? html`
+
+
+ {
+ const val = Number((e.target as HTMLInputElement).value);
+ if (!this.stage) return;
+ this.experimentEditor.updateStage({
+ ...this.stage,
+ timeMinimumInMinutes: val > 0 ? val : null,
+ });
+ }}
+ />
+
+ `
+ : nothing}
`;
}
diff --git a/frontend/src/components/stages/group_chat_participant_view.ts b/frontend/src/components/stages/group_chat_participant_view.ts
index 0d975a4da..27ebaad2b 100644
--- a/frontend/src/components/stages/group_chat_participant_view.ts
+++ b/frontend/src/components/stages/group_chat_participant_view.ts
@@ -26,6 +26,7 @@ import {
ChatStageConfig,
DiscussionItem,
StageKind,
+ getTimeElapsed,
} from '@deliberation-lab/utils';
import {styles} from './group_chat_participant_view.scss';
@@ -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 {
@@ -183,13 +184,16 @@ export class GroupChatView extends MobxLitElement {
this.participantService.isReadyToEndChatDiscussion(
this.stage.id,
currentDiscussionId,
- );
+ ) ||
+ this.isMinimumTimeNotMet;
return html`
{
if (!this.stage?.progress.showParticipantProgress) {
return nothing;
@@ -306,6 +312,23 @@ export class GroupChatView extends MobxLitElement {
`;
}
+
+ 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 {
diff --git a/frontend/src/components/stages/private_chat_editor.ts b/frontend/src/components/stages/private_chat_editor.ts
index 6d9557d34..bb1526d6f 100644
--- a/frontend/src/components/stages/private_chat_editor.ts
+++ b/frontend/src/components/stages/private_chat_editor.ts
@@ -117,6 +117,31 @@ export class ChatEditor extends MobxLitElement {
`
: nothing}
+ ${timeLimit !== null
+ ? html`
+
+
+ {
+ const val = Number((e.target as HTMLInputElement).value);
+ if (!this.stage) return;
+ this.experimentEditor.updateStage({
+ ...this.stage,
+ timeMinimumInMinutes: val > 0 ? val : null,
+ });
+ }}
+ />
+
+ `
+ : nothing}
`;
}
diff --git a/frontend/src/components/stages/private_chat_participant_view.ts b/frontend/src/components/stages/private_chat_participant_view.ts
index c415f7a76..50b10151e 100644
--- a/frontend/src/components/stages/private_chat_participant_view.ts
+++ b/frontend/src/components/stages/private_chat_participant_view.ts
@@ -14,6 +14,7 @@ import {
ChatMessage,
PrivateChatStageConfig,
UserType,
+ getTimeElapsed,
} from '@deliberation-lab/utils';
import {getHashBasedColor} from '../../shared/utils';
import {ResponseTimeoutTracker} from '../../shared/response_timeout';
@@ -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
@@ -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`
${chatMessages.map((message) => this.renderChatMessage(message))}
@@ -119,13 +148,16 @@ export class PrivateChatView extends MobxLitElement {
: nothing}
${isConversationOver ? this.renderConversationEndedMessage() : nothing}
-
+
${this.stage.progress.showParticipantProgress
? html``
: nothing}
${!minTurnsMet && !isConversationOver
? this.renderMinTurnsMessage(participantMessageCount)
: nothing}
+ ${!minTimeMet && minTurnsMet && !isConversationOver
+ ? this.renderMinTimeMessage()
+ : nothing}
`;
}
@@ -198,6 +230,15 @@ export class PrivateChatView extends MobxLitElement {
`;
}
+
+ private renderMinTimeMessage() {
+ return html`
+
+ You must wait until ${this.stage?.timeMinimumInMinutes} minutes have
+ passed.
+
+ `;
+ }
}
declare global {
diff --git a/functions/src/chat/chat.utils.ts b/functions/src/chat/chat.utils.ts
index ca5128c0f..cd19a4fff 100644
--- a/functions/src/chat/chat.utils.ts
+++ b/functions/src/chat/chat.utils.ts
@@ -19,6 +19,7 @@ import {
ChatStagePublicData,
ChatStageParticipantAnswer,
createChatStageParticipantAnswer,
+ getTimeElapsed,
} from '@deliberation-lab/utils';
import {updateParticipantNextStage} from '../participant.utils';
@@ -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,
@@ -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,
diff --git a/functions/src/stages/chat.time.ts b/functions/src/stages/chat.time.ts
index 2ecc198a3..fbe674026 100644
--- a/functions/src/stages/chat.time.ts
+++ b/functions/src/stages/chat.time.ts
@@ -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;
diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts
index ae89949a7..c1448b8f6 100644
--- a/utils/src/stages/chat_stage.ts
+++ b/utils/src/stages/chat_stage.ts
@@ -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
}
@@ -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,
};
}
diff --git a/utils/src/stages/private_chat_stage.ts b/utils/src/stages/private_chat_stage.ts
index bb3836695..3b9999ee3 100644
--- a/utils/src/stages/private_chat_stage.ts
+++ b/utils/src/stages/private_chat_stage.ts
@@ -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)
@@ -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,