From 389a8d29f4aff4ca2042d7bd55bec67c95baa8fe Mon Sep 17 00:00:00 2001 From: Antoine Jaussoin Date: Thu, 2 Feb 2023 19:39:17 +0000 Subject: [PATCH] Add Timer functionality (#462) --- backend/src/common/actions.ts | 4 + backend/src/common/models.ts | 4 + backend/src/common/types.ts | 4 + backend/src/common/ws.ts | 7 + backend/src/db/actions/timer.ts | 30 +++ backend/src/db/entities/Session.ts | 4 + backend/src/db/entities/SessionOptions.ts | 9 + .../1674905273870-TimerOnSession.ts | 14 ++ .../migrations/1674905786619-TimerOptions.ts | 20 ++ .../1675096520361-LockControlsTimerEnd.ts | 16 ++ backend/src/game.ts | 50 +++- .../public/fonts/digital/DIGITALDREAM.ttf | Bin 0 -> 29320 bytes .../public/fonts/digital/DIGITALDREAM.woff2 | Bin 0 -> 4372 bytes .../public/fonts/digital/DIGITALDREAMFAT.ttf | Bin 0 -> 28820 bytes .../fonts/digital/DIGITALDREAMFATNARROW.ttf | Bin 0 -> 28664 bytes .../fonts/digital/DIGITALDREAMFATSKEW.ttf | Bin 0 -> 32576 bytes .../digital/DIGITALDREAMFATSKEWNARROW.ttf | Bin 0 -> 32684 bytes .../fonts/digital/DIGITALDREAMNARROW.ttf | Bin 0 -> 29380 bytes .../public/fonts/digital/DIGITALDREAMSKEW.ttf | Bin 0 -> 31980 bytes .../fonts/digital/DIGITALDREAMSKEWNARROW.ttf | Bin 0 -> 32104 bytes .../fonts/digital/pizzadude.dk License.txt | 7 + frontend/src/GlobalStyles.tsx | 5 + frontend/src/common/actions.ts | 4 + frontend/src/common/models.ts | 4 + frontend/src/common/types.ts | 4 + frontend/src/common/ws.ts | 7 + frontend/src/components/ClosableAlert.tsx | 30 +++ frontend/src/testing/index.tsx | 1 + frontend/src/translations/locales/ar-SA.json | 16 ++ frontend/src/translations/locales/de-DE.json | 16 ++ frontend/src/translations/locales/en-GB.json | 16 ++ frontend/src/translations/locales/es-ES.json | 16 ++ frontend/src/translations/locales/fr-FR.json | 16 ++ frontend/src/translations/locales/hu-HU.json | 16 ++ frontend/src/translations/locales/it-IT.json | 16 ++ frontend/src/translations/locales/ja-JP.json | 16 ++ frontend/src/translations/locales/nl-NL.json | 16 ++ frontend/src/translations/locales/pl-PL.json | 16 ++ frontend/src/translations/locales/pt-BR.json | 16 ++ frontend/src/translations/locales/pt-PT.json | 16 ++ frontend/src/translations/locales/uk-UA.json | 16 ++ frontend/src/translations/locales/zh-CN.json | 16 ++ frontend/src/translations/locales/zh-TW.json | 16 ++ frontend/src/views/Game.tsx | 215 +++++++++--------- frontend/src/views/game/GameFooter.tsx | 128 ----------- frontend/src/views/game/TimerProvider.tsx | 42 ++++ .../board/__tests__/permissions-logic.test.ts | 72 ++++-- .../views/game/board/header/BoardHeader.tsx | 64 ++++-- .../src/views/game/board/permissions-logic.ts | 28 ++- .../game/board/usePostUserPermissions.ts | 7 +- .../game/board/useSessionUserPermissions.ts | 9 +- frontend/src/views/game/footer/GameFooter.tsx | 156 +++++++++++++ frontend/src/views/game/footer/Timer.tsx | 123 ++++++++++ frontend/src/views/game/footer/Users.tsx | 40 ++++ frontend/src/views/game/state.ts | 5 + frontend/src/views/game/summary/useSummary.ts | 2 +- frontend/src/views/game/useGame.ts | 48 +++- frontend/src/views/game/useTimer.ts | 24 ++ .../views/session-editor/SessionEditor.tsx | 6 + .../sections/timer/DurationSelection.tsx | 55 +++++ .../sections/timer/TimerSection.tsx | 85 +++++++ 61 files changed, 1278 insertions(+), 295 deletions(-) create mode 100644 backend/src/db/actions/timer.ts create mode 100644 backend/src/db/migrations/1674905273870-TimerOnSession.ts create mode 100644 backend/src/db/migrations/1674905786619-TimerOptions.ts create mode 100644 backend/src/db/migrations/1675096520361-LockControlsTimerEnd.ts create mode 100644 frontend/public/fonts/digital/DIGITALDREAM.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAM.woff2 create mode 100644 frontend/public/fonts/digital/DIGITALDREAMFAT.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMFATNARROW.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMFATSKEW.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMFATSKEWNARROW.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMNARROW.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMSKEW.ttf create mode 100644 frontend/public/fonts/digital/DIGITALDREAMSKEWNARROW.ttf create mode 100644 frontend/public/fonts/digital/pizzadude.dk License.txt create mode 100644 frontend/src/components/ClosableAlert.tsx delete mode 100644 frontend/src/views/game/GameFooter.tsx create mode 100644 frontend/src/views/game/TimerProvider.tsx create mode 100644 frontend/src/views/game/footer/GameFooter.tsx create mode 100644 frontend/src/views/game/footer/Timer.tsx create mode 100644 frontend/src/views/game/footer/Users.tsx create mode 100644 frontend/src/views/game/useTimer.ts create mode 100644 frontend/src/views/session-editor/sections/timer/DurationSelection.tsx create mode 100644 frontend/src/views/session-editor/sections/timer/TimerSection.tsx diff --git a/backend/src/common/actions.ts b/backend/src/common/actions.ts index e3f94a320..bcf7de433 100644 --- a/backend/src/common/actions.ts +++ b/backend/src/common/actions.ts @@ -19,6 +19,8 @@ const actions = { REQUEST_BOARD: 'retrospected/session/request', USER_READY: 'retrospected/user-ready', CHAT_MESSAGE: 'retrospected/session/chat', + START_TIMER: 'retrospected/timer/start', + STOP_TIMER: 'retrospected/timer/stop', RECEIVE_POST: 'retrospected/posts/receive/add', RECEIVE_DELETE_POST: 'retrospected/posts/receive/delete', @@ -40,6 +42,8 @@ const actions = { RECEIVE_ERROR: 'retrospected/receive/error', RECEIVE_USER_READY: 'retrospected/receive/user-ready', RECEIVE_CHAT_MESSAGE: 'retrospected/session/chat/receive', + RECEIVE_TIMER_START: 'retrospected/timer/start/receive', + RECEIVE_TIMER_STOP: 'retrospected/timer/stop/receive', }; export default actions; diff --git a/backend/src/common/models.ts b/backend/src/common/models.ts index 52f9bb700..ce479cc27 100644 --- a/backend/src/common/models.ts +++ b/backend/src/common/models.ts @@ -14,6 +14,9 @@ export const defaultOptions: SessionOptions = { allowCancelVote: false, blurCards: false, newPostsFirst: true, + allowTimer: false, + timerDuration: 0, + readonlyOnTimerEnd: true, }; export const defaultSession: Omit = { @@ -31,4 +34,5 @@ export const defaultSession: Omit = { encrypted: null, locked: false, ready: [], + timer: null, }; diff --git a/backend/src/common/types.ts b/backend/src/common/types.ts index 8ad54b7e1..e9a673f34 100644 --- a/backend/src/common/types.ts +++ b/backend/src/common/types.ts @@ -9,6 +9,7 @@ export interface Session extends PostContainer, Entity { locked: boolean; createdBy: User; ready: string[]; + timer: Date | null; } export interface SessionMetadata extends Entity { @@ -60,6 +61,9 @@ export interface SessionOptions { allowCancelVote: boolean; blurCards: boolean; newPostsFirst: boolean; + allowTimer: boolean; + timerDuration: number; + readonlyOnTimerEnd: boolean; } export interface Entity { diff --git a/backend/src/common/ws.ts b/backend/src/common/ws.ts index 43024079d..f879e36cd 100644 --- a/backend/src/common/ws.ts +++ b/backend/src/common/ws.ts @@ -90,3 +90,10 @@ export interface WsErrorPayload { type: WsErrorType; details: string | null; } + +export interface WsReceiveTimerStartPayload { + /** + * Duration in seconds + */ + duration: number; +} diff --git a/backend/src/db/actions/timer.ts b/backend/src/db/actions/timer.ts new file mode 100644 index 000000000..f33ad035c --- /dev/null +++ b/backend/src/db/actions/timer.ts @@ -0,0 +1,30 @@ +import { addSeconds } from 'date-fns'; +import SessionRepository from '../repositories/SessionRepository.js'; +import { transaction } from './transaction.js'; + +export async function startTimer(sessionId: string): Promise { + return await transaction(async (manager) => { + const sessionRepository = manager.withRepository(SessionRepository); + const session = await sessionRepository.findOneBy({ id: sessionId }); + if (!session) { + throw new Error('Session not found'); + } + const duration = session.options.timerDuration; + session.timer = addSeconds(new Date(), duration); + await sessionRepository.save(session); + + return duration; + }); +} + +export async function stopTimer(sessionId: string): Promise { + return await transaction(async (manager) => { + const sessionRepository = manager.withRepository(SessionRepository); + const session = await sessionRepository.findOneBy({ id: sessionId }); + if (!session) { + throw new Error('Session not found'); + } + session.timer = null; + await sessionRepository.save(session); + }); +} diff --git a/backend/src/db/entities/Session.ts b/backend/src/db/entities/Session.ts index fdcc0451c..5fa22eaa1 100644 --- a/backend/src/db/entities/Session.ts +++ b/backend/src/db/entities/Session.ts @@ -64,6 +64,8 @@ export default class SessionEntity { visitors: UserEntity[] | undefined; @Column({ default: false }) public locked: boolean; + @Column({ default: null, type: 'timestamp with time zone', nullable: true }) + public timer: Date | null; @Column('text', { array: true, default: '{}' }) public ready: string[]; @CreateDateColumn({ type: 'timestamp with time zone' }) @@ -87,6 +89,7 @@ export default class SessionEntity { encrypted: this.encrypted, locked: this.locked, ready: this.ready, + timer: this.timer, }; } @@ -103,5 +106,6 @@ export default class SessionEntity { this.encrypted = null; this.locked = false; this.ready = []; + this.timer = null; } } diff --git a/backend/src/db/entities/SessionOptions.ts b/backend/src/db/entities/SessionOptions.ts index 4daa9585e..2683229fb 100644 --- a/backend/src/db/entities/SessionOptions.ts +++ b/backend/src/db/entities/SessionOptions.ts @@ -29,6 +29,12 @@ export default class SessionOptionsEntity { @Column({ default: false }) public allowCancelVote: boolean; @Column({ default: false }) + public allowTimer: boolean; + @Column({ type: 'numeric', default: 15 * 60 }) + public timerDuration: number; + @Column({ default: true }) + public readonlyOnTimerEnd: boolean; + @Column({ default: false }) public blurCards: boolean; @Column({ default: true }) public newPostsFirst: boolean; @@ -54,6 +60,9 @@ export default class SessionOptionsEntity { this.allowCancelVote = optionsWithDefault.allowCancelVote; this.blurCards = optionsWithDefault.blurCards; this.newPostsFirst = optionsWithDefault.newPostsFirst; + this.allowTimer = optionsWithDefault.allowTimer; + this.timerDuration = optionsWithDefault.timerDuration; + this.readonlyOnTimerEnd = optionsWithDefault.readonlyOnTimerEnd; } } diff --git a/backend/src/db/migrations/1674905273870-TimerOnSession.ts b/backend/src/db/migrations/1674905273870-TimerOnSession.ts new file mode 100644 index 000000000..f5d69ee51 --- /dev/null +++ b/backend/src/db/migrations/1674905273870-TimerOnSession.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TimerOnSession1674905273870 implements MigrationInterface { + name = 'TimerOnSession1674905273870' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" ADD "timer" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "timer"`); + } + +} diff --git a/backend/src/db/migrations/1674905786619-TimerOptions.ts b/backend/src/db/migrations/1674905786619-TimerOptions.ts new file mode 100644 index 000000000..044ba0aa5 --- /dev/null +++ b/backend/src/db/migrations/1674905786619-TimerOptions.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TimerOptions1674905786619 implements MigrationInterface { + name = 'TimerOptions1674905786619' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "templates" ADD "options_allow_timer" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "templates" ADD "options_timer_duration" numeric NOT NULL DEFAULT '900'`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "options_allow_timer" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "options_timer_duration" numeric NOT NULL DEFAULT '900'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "options_timer_duration"`); + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "options_allow_timer"`); + await queryRunner.query(`ALTER TABLE "templates" DROP COLUMN "options_timer_duration"`); + await queryRunner.query(`ALTER TABLE "templates" DROP COLUMN "options_allow_timer"`); + } + +} diff --git a/backend/src/db/migrations/1675096520361-LockControlsTimerEnd.ts b/backend/src/db/migrations/1675096520361-LockControlsTimerEnd.ts new file mode 100644 index 000000000..e64dc1c2f --- /dev/null +++ b/backend/src/db/migrations/1675096520361-LockControlsTimerEnd.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class LockControlsTimerEnd1675096520361 implements MigrationInterface { + name = 'LockControlsTimerEnd1675096520361' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "templates" ADD "options_readonly_on_timer_end" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "sessions" ADD "options_readonly_on_timer_end" boolean NOT NULL DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "sessions" DROP COLUMN "options_readonly_on_timer_end"`); + await queryRunner.query(`ALTER TABLE "templates" DROP COLUMN "options_readonly_on_timer_end"`); + } + +} diff --git a/backend/src/game.ts b/backend/src/game.ts index 9c035d498..4d024443f 100644 --- a/backend/src/game.ts +++ b/backend/src/game.ts @@ -23,6 +23,7 @@ import { Message, WsCancelVotesPayload, WsReceiveCancelVotesPayload, + WsReceiveTimerStartPayload, } from './common/index.js'; import { RateLimiterMemory } from 'rate-limiter-flexible'; import chalk from 'chalk-template'; @@ -64,6 +65,8 @@ import { cancelVotes, registerVote } from './db/actions/votes.js'; import { deserialiseIds, UserIds } from './utils.js'; import { QueryFailedError } from 'typeorm'; import { saveChatMessage } from './db/actions/chat.js'; +import { startTimer, stopTimer } from './db/actions/timer.js'; +import { differenceInSeconds } from 'date-fns'; const { ACK, @@ -104,6 +107,10 @@ const { REQUEST_BOARD, CHAT_MESSAGE, RECEIVE_CHAT_MESSAGE, + START_TIMER, + STOP_TIMER, + RECEIVE_TIMER_START, + RECEIVE_TIMER_STOP, } = Actions; interface Users { @@ -333,7 +340,7 @@ export default (io: Server) => { if (user) { const userEntity = await getUser(user.id); if (userEntity) { - // TODO : inneficient, rework all this + // TODO : inefficient, rework all this await storeVisitor(sessionId, userEntity); const sessionEntity2 = await getSessionWithVisitors(sessionId); if (sessionEntity2) { @@ -346,6 +353,15 @@ export default (io: Server) => { const session = await getSession(sessionId); if (session) { sendToSelf(socket, RECEIVE_BOARD, session); + if (session.timer) { + sendToSelf( + socket, + RECEIVE_TIMER_START, + { + duration: differenceInSeconds(session.timer, new Date()), + } + ); + } } else { sendToSelf(socket, RECEIVE_ERROR, { type: 'cannot_get_session', @@ -595,6 +611,35 @@ export default (io: Server) => { sendToAll(socket, sessionId, RECEIVE_LOCK_SESSION, locked); }; + const onStartTimer = async ( + userIds: UserIds | null, + sessionId: string, + _: unknown, + socket: Socket + ) => { + if (checkUser(userIds, socket)) { + const duration = await startTimer(sessionId); + sendToAll( + socket, + sessionId, + RECEIVE_TIMER_START, + { duration } + ); + } + }; + + const onStopTimer = async ( + userIds: UserIds | null, + sessionId: string, + _: unknown, + socket: Socket + ) => { + if (checkUser(userIds, socket)) { + await stopTimer(sessionId); + sendToAll(socket, sessionId, RECEIVE_TIMER_STOP, undefined); + } + }; + io.on('connection', async (socket) => { const ip = // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -641,6 +686,9 @@ export default (io: Server) => { { type: CHAT_MESSAGE, handler: onChatMessage }, + { type: START_TIMER, handler: onStartTimer }, + { type: STOP_TIMER, handler: onStopTimer }, + { type: JOIN_SESSION, handler: onJoinSession }, { type: REQUEST_BOARD, handler: onRequestBoard }, { type: RENAME_SESSION, handler: onRenameSession }, diff --git a/frontend/public/fonts/digital/DIGITALDREAM.ttf b/frontend/public/fonts/digital/DIGITALDREAM.ttf new file mode 100644 index 0000000000000000000000000000000000000000..25ab5de5a3a75144e94852b67b4449ca69cb90cd GIT binary patch literal 29320 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 Hu8IEx0x4|* literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/digital/DIGITALDREAM.woff2 b/frontend/public/fonts/digital/DIGITALDREAM.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7e5f34f56b36eea87754a0d57706516e206852bd GIT binary patch literal 4372 zcmV+v5$o=EPew8T0RR9101*@b4*&oF0CMyI01&tU0RR9100000000000000000000 z00006Ruwh?hC~P$2^0!}yj+2rc?*L?01}Q!0X7081A{;WAO(e12ZBcoG#f`>vk?(& z8~_yl4I-*G!)B^=0yjm_yrR1>CM;o+oH+b6niJ2@wu}yYC_y9&^?rAXSH02DXEmLh zWMfZg5Yg#Y6ks$kgnn!=8ekN3j?mMs}5C(IN@g$|@j5l%Mk6 z-sGo%@~4yo7UB}sZ3Q1L+v`H`dDpU^W&tqCK!&KmD8cpB7{0S-#TtFe?V{7NBqO&5 z7);95Hf*0|9-fE)KlQ$kkQ0!IO&&`-rf9&+Jq0R3flK8EK>MKC!737z|67_;o!6x{ zrS->sC$}cR5)_&jr0o9n=rpr?7wySS?pk1bteoVX z_95W+)tl+&yU(~_XY{DKUD@(Gm!Rv1`r^)7wNL}3g;reByQ5MGp_lk z!{tu@r5qpLL=mLtl3g;kE|Q9$r_@a5L6rEuAs|(n5Db=`^4VPG;xk7Z#V6S60{7H#WDncYwY9gTtfa zlhd>Fi_5EPT2Z7}iBe_CRj5>@T8&!NsYerYu`v%15OkiX>-;t@Ai7`Z%XtxzM5bua zs7bRHt;iT-P)QW-!fp5pzJVWjJpB(~*-s>PguxeO>Rb#FKdt6$S|z_Y28+WpW|~h8 z93N`O+u)dZUG*yOISARl0h`(vZ=|dDwew_a=jD0N2e=l(*8s^0U|0tx4~x@`*)V`m zpP*l*iPu7w4@q6v$W_WZU&~tXB(KlpbtUR2_LsuoQs>By=!k7Txa}rA!A4sCHBYM(DtDw;%>+wE3w4|EeT=0xO#08ihdaJET@r82+6kPR`45!C=(U5Jo&g)NP*QL1O-nD zqE~|hjHtDUP)jv-=NFBaFw)yJTgk^2ZEsqts8HXWvZGYmreq8iQfVhyM5r~G<+XrN zKctyj8-JcwAJhasB-qdd;f_>KYo98~_OuSFomp@*OMT~=A%qy~?j}z>5h%&PiG`6ZmCM%YNbhRXkg|Np~DbydOZnm>k&Q zfCIIkQg2sNbLpG^n(mKHzI}s>{m$Xvsh8n68EI|zI9VY1+2MKxN9Pf z`||DDXnC%W-b|OZ<;y<5Qxm5>0~X&q>C~Isiy`55 zv)Vw%SlyvHTKTV|inzIsjagNhcS_F7oY%jutsW-XBm|~ebj$%As@mcseGNY-dq z#2oDp8^&54q!otU5e)mJ36JH^b@DisPS6H=VC)>xWYxemx|U3Z{2^!^pB9`ruAWs> zdnsx^MJ;$Z9CV-#1k&|5>`Q69jC1Z`Ati8MvmmqnAo;o-t2Wun|Gmxy3&^+ZHJ|Jb zaqm_pY-uWJb8W{QYyW3%#Cs3%wX~qmOK9!6=D_LPL&imU504>n@9Iai%ZoYWPBSbT ziFOSm;F?q(Y}KjB`PtsZ_pPOJKq^vPp7D6f+un&8pp3y-@v298b90yt(4|j`g6bHt ze^3W8v@#mT3@y~KH+D{x;A^ZKpKZ`TLb2t;VwYI^TSom6uIw`OAhKxArd@}-$$2pP zih&l<2rB%5%md6rIV5NK);Dnf%KK}(ZF1X2NlwA0q`9{9(ktnuG+ zG4hWwz4%}ta2sU$CIDz>dfo{|?NSMU6SV(djX2FC-s+KBZ_Nx4;v_tBnv668yrjl| zq2?i+O0a@5$&0Gh!Y=XtRu_9447Ga?Au+N3i67I`k?nfV2&nf zh(GP`F>8eRmc=BHPy(RbRw0>25(?J%;T_W3Zhhon)lx|m54>B74~!sXUuBz{n52PL z{9D^}8V1LzBrP^|Q_wjBvO!IeY-&(K(r!OZ=xa`j5&AOMlA?D;^F$~JNiqllwb#b$ zvaHszW37!+Iz*1C6rXdkW+FMaKpqu_EA~hJ2DwS@e`WG^WMxwMpKFW!+;a#SyL2=Q zR!H7(_;3uRlf}i?if&8z$lG zL{cjx0AmVcV6zG}b3{}Ra5+X{NC3MPsSV{W9o^e^7Qt-?&yE#aW9%fgnzh~>(E*+# zdCvsc<;EL<+1|0ufYs+yr?}m*&lI5b-Q@2jQP;X?7k)fE471zknS6G?y<2zIyBZ{dS+7=R%lg}zY8MYq9mw%p8UN7q*! zs_Zdru;sit;wy;N6rJ~@nf^uAnRMl94pn*_NlAQYA3Si#Dzpq-V>m@_?N!P0p^fqB$^^7Pj()0cE#8_+SjH}T93kANh~w=nkV2fv~m;pBmD+8St;p# z8dRcaHQ!gm6$yvE#${`q)vDsD(u3qNjb+8GmU&sZ)!Mw0^5S0LVc~IZFc1|aa*SQ1GpPt`Gs z0U2*gh=eF?t(%sL2one0`#KNp#hYcY1*`{fU`E65mO8c+#Hu$_TfJ(VheG>7IU|^H#{?F%m@r zFjj)@cifEnx+2AQxC)JmJ73)G+DeW=5{P@2x`2_+I>nSZcpskLdFxd17z@o>N&Bd# z_#F?S3Sds5CWAVhxhy13%S}+H( zhPusyS5oC#ra*2=2se0~N8LNE`*7>t>kP|0c1%xlA3SKEnkJ#qmSe1^b8XzS?FLX> zAkJfTGQCSJI*ybl*2X!u*73`g=O+7_cZi98*=zHb zRjdk)1Z>+UU>P7b0|6Y-knOAUS1UO;yOsc)4aF?4Vo4zvG3zqa_ULz(R`W4S3>GDo7zHAP{9nVLy+AVqB8q{5U7K3F5{ydj={W9s9OzSvMYztzLXOvlonU z?E4tkef>HbW%%vgQ*qY)eYY3c>2pf%um_8piDhL%elW>A%@^Mp^bz2ZOG4wLzJ*i@+P{&x8;4J7B$wwz4#7Pv77tFF%O={?x zUzrq>g5jARw~a?*Z=7@B$0&X~Z#$okQhasx0~C;ezdaY|TmK#FIcIaCef*B+aoyKptO}rOnV# ztby2)N%#EO8fPWVvsXZ(+b24n_l*vry;ZWd;HSnvF6^-10@IzGr zz`VVPH$iyYs^#c?mF;|zz;!vyO-|nR1P5C*bu3IkIri3yTw`s8C5nm#cUIL?r#JN| zAz2UG^X=)Xyt~eA1#*7a>jGQ4l{u|tT|;~?s^L&PvLf-XGvj->5LEC!B@J0iUB2|H z0S;|Wg5kOR+MJ-TVfxo{6_ujmCst-+7si^wU*0q|d4_(t}=-)

;&qps6Y^F6q53duJT|84!|ch#gsKm7X3Q{y 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

Du}yThAm>iJxY5N0dFXZxIF&pLXw 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 E5Bpu0bpQYW literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/digital/DIGITALDREAMFATNARROW.ttf b/frontend/public/fonts/digital/DIGITALDREAMFATNARROW.ttf new file mode 100644 index 0000000000000000000000000000000000000000..21ed294442168d8d3b4bdeacf1b7eb4a98029996 GIT binary patch literal 28664 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 aiYt48^Z5$vXfUvl4o`yU&shS0WB(UqEgby- literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/digital/DIGITALDREAMFATSKEWNARROW.ttf b/frontend/public/fonts/digital/DIGITALDREAMFATSKEWNARROW.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5eef1103a7dda892a5414316d48f3cbdb7d9eb76 GIT binary patch literal 32684 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/digital/DIGITALDREAMSKEWNARROW.ttf b/frontend/public/fonts/digital/DIGITALDREAMSKEWNARROW.ttf new file mode 100644 index 0000000000000000000000000000000000000000..280293ea0abdd35e1a6d4d8c335d6ab888b8acc5 GIT binary patch literal 32104 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 literal 0 HcmV?d00001 diff --git a/frontend/public/fonts/digital/pizzadude.dk License.txt b/frontend/public/fonts/digital/pizzadude.dk License.txt new file mode 100644 index 000000000..066d86c02 --- /dev/null +++ b/frontend/public/fonts/digital/pizzadude.dk License.txt @@ -0,0 +1,7 @@ +All fonts designed and copyrighted Jakob Fischer / pizzadude.dk + +The fonts are provided free for personal or commercial use, however they may not be redistributed, sold or modified without the permission of Jakob Fischer / pizzadude.dk. + +I have decided to let people use my freeware fonts without paying the usual $US25 commercial fee - but, I urge people to buy one of my commercial fonts as compensation and/or creating a link to www.pizzadude.dk + +Jakob Fischer / pizzadude.dk is not liable for any damage resulting from the use of these fonts. \ No newline at end of file diff --git a/frontend/src/GlobalStyles.tsx b/frontend/src/GlobalStyles.tsx index 7d5653a77..bbe7458ee 100644 --- a/frontend/src/GlobalStyles.tsx +++ b/frontend/src/GlobalStyles.tsx @@ -1,6 +1,11 @@ import { css } from '@emotion/react'; const GlobalStyle = css` + @font-face { + font-family: 'DIGITALDREAM'; + src: url('/fonts/digital/DIGITALDREAM.woff2') format('woff2'); + } + html { touch-action: manipulation; font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif; diff --git a/frontend/src/common/actions.ts b/frontend/src/common/actions.ts index e3f94a320..bcf7de433 100644 --- a/frontend/src/common/actions.ts +++ b/frontend/src/common/actions.ts @@ -19,6 +19,8 @@ const actions = { REQUEST_BOARD: 'retrospected/session/request', USER_READY: 'retrospected/user-ready', CHAT_MESSAGE: 'retrospected/session/chat', + START_TIMER: 'retrospected/timer/start', + STOP_TIMER: 'retrospected/timer/stop', RECEIVE_POST: 'retrospected/posts/receive/add', RECEIVE_DELETE_POST: 'retrospected/posts/receive/delete', @@ -40,6 +42,8 @@ const actions = { RECEIVE_ERROR: 'retrospected/receive/error', RECEIVE_USER_READY: 'retrospected/receive/user-ready', RECEIVE_CHAT_MESSAGE: 'retrospected/session/chat/receive', + RECEIVE_TIMER_START: 'retrospected/timer/start/receive', + RECEIVE_TIMER_STOP: 'retrospected/timer/stop/receive', }; export default actions; diff --git a/frontend/src/common/models.ts b/frontend/src/common/models.ts index 25f87f0c1..3ae6c1c38 100644 --- a/frontend/src/common/models.ts +++ b/frontend/src/common/models.ts @@ -14,6 +14,9 @@ export const defaultOptions: SessionOptions = { blurCards: false, newPostsFirst: true, allowCancelVote: false, + allowTimer: false, + timerDuration: 0, + readonlyOnTimerEnd: true, }; export const defaultSession: Omit = { @@ -31,4 +34,5 @@ export const defaultSession: Omit = { encrypted: null, locked: false, ready: [], + timer: null, }; diff --git a/frontend/src/common/types.ts b/frontend/src/common/types.ts index 8ad54b7e1..e9a673f34 100644 --- a/frontend/src/common/types.ts +++ b/frontend/src/common/types.ts @@ -9,6 +9,7 @@ export interface Session extends PostContainer, Entity { locked: boolean; createdBy: User; ready: string[]; + timer: Date | null; } export interface SessionMetadata extends Entity { @@ -60,6 +61,9 @@ export interface SessionOptions { allowCancelVote: boolean; blurCards: boolean; newPostsFirst: boolean; + allowTimer: boolean; + timerDuration: number; + readonlyOnTimerEnd: boolean; } export interface Entity { diff --git a/frontend/src/common/ws.ts b/frontend/src/common/ws.ts index 43024079d..f879e36cd 100644 --- a/frontend/src/common/ws.ts +++ b/frontend/src/common/ws.ts @@ -90,3 +90,10 @@ export interface WsErrorPayload { type: WsErrorType; details: string | null; } + +export interface WsReceiveTimerStartPayload { + /** + * Duration in seconds + */ + duration: number; +} diff --git a/frontend/src/components/ClosableAlert.tsx b/frontend/src/components/ClosableAlert.tsx new file mode 100644 index 000000000..017e0aa99 --- /dev/null +++ b/frontend/src/components/ClosableAlert.tsx @@ -0,0 +1,30 @@ +import { Alert, AlertColor, AlertTitle } from '@mui/material'; +import { useState } from 'react'; + +type ClosableAlertProps = { + title?: React.ReactNode; + children: React.ReactNode; + severity: AlertColor; + closable?: boolean; +}; + +export default function ClosableAlert({ + title, + children, + severity, + closable, +}: ClosableAlertProps) { + const [open, setOpen] = useState(true); + + if (!open) return null; + + return ( + setOpen(false) : undefined} + > + {title} + {children} + + ); +} diff --git a/frontend/src/testing/index.tsx b/frontend/src/testing/index.tsx index 3d2003037..86cbf6696 100644 --- a/frontend/src/testing/index.tsx +++ b/frontend/src/testing/index.tsx @@ -29,6 +29,7 @@ export const initialSession: Session = { ...defaultOptions, }, ready: [], + timer: null, }; export function AllTheProviders({ children }: PropsWithChildren<{}>) { diff --git a/frontend/src/translations/locales/ar-SA.json b/frontend/src/translations/locales/ar-SA.json index 646103210..d46c3e1bd 100644 --- a/frontend/src/translations/locales/ar-SA.json +++ b/frontend/src/translations/locales/ar-SA.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "تخصيص الجلسة الخاصة بك", + "timerCategory": "المؤقت", + "timerCategorySub": "تعيين المؤقت للدورة", + "allowTimer": "السماح بالتوقيت", + "allowTimerHelp": "عرض المؤقت في الجزء السفلي من الشاشة", + "timerDuration": "مدة المؤقت", + "timerDurationHelp": "سيتم تعيين المؤقت إلى {{duration}} دقائق", + "lockOnTimerEnd": "قفل في نهاية المؤقت", + "lockOnTimerEndHelp": "قفل الجلسة (قم بقراءتها فقط) عند انتهاء المؤقت", "votingCategory": "التصويت", "votingCategorySub": "تعيين القواعد حول الإعجاب و عدم الإعجاب", "postCategory": "إعدادات النشر", @@ -120,6 +128,8 @@ "disconnected": "لقد تم فصلك عن الدورة الحالية.", "reconnect": "إعادة الاتصال", "notLoggedIn": "لم تقم بتسجيل الدخول. يمكنك عرض هذه الجلسة كمتفرج، ولكن يجب تسجيل الدخول للمشاركة.", + "lockedTitle": "انتهت صلاحية المؤقت", + "lockedDescription": "لم يعد بإمكانك إضافة أو تحرير المشاركات، مع نفاد المؤقت. يمكن تغيير هذا في إعدادات الجلسة.", "error_action_unauthorised": "غير مسموح لك بتنفيذ هذا الإجراء.", "error_cannot_edit_group": "فشل تحرير المجموعة.", "error_cannot_edit_post": "فشل تحرير المشاركة.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "اكتب رسالة هنا..." + }, + "Timer": { + "stopTimerTitle": "هل أنت متأكد من أنك تريد إيقاف (وإعادة تعيين) المؤقت ؟", + "stopTimerDescription": "إذا توقفت عن المؤقت ، فسيتم إعادة تعيينه إلى مدته الأصلية.", + "stopTimerButton": "إيقاف المؤقت", + "stopTimerCancelButton": "إلغاء" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/de-DE.json b/frontend/src/translations/locales/de-DE.json index c864819d8..43ba58d6d 100644 --- a/frontend/src/translations/locales/de-DE.json +++ b/frontend/src/translations/locales/de-DE.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Personalisieren Sie die Sitzung", + "timerCategory": "Timer", + "timerCategorySub": "Timer für die Sitzung festlegen", + "allowTimer": "Timer erlauben", + "allowTimerHelp": "Timer am unteren Bildschirmrand anzeigen", + "timerDuration": "Timer-Dauer", + "timerDurationHelp": "Der Timer wird auf {{duration}} Minuten gesetzt", + "lockOnTimerEnd": "Timer-Ende sperren", + "lockOnTimerEndHelp": "Sitzung sperren (nur lesen) wenn der Timer endet", "votingCategory": "Abstimmung", "votingCategorySub": "Setze die Abstimmregeln", "postCategory": "Beitragseinstellungen", @@ -120,6 +128,8 @@ "disconnected": "Sie wurden von der aktuellen Sitzung getrennt.", "reconnect": "Neu verbinden", "notLoggedIn": "Sie sind nicht eingeloggt. Sie können diese Sitzung als Zuschauer betrachten, müssen sich aber einloggen, um daran teilzunehmen.", + "lockedTitle": "Der Timer ist ausgelaufen", + "lockedDescription": "Sie können keine Beiträge mehr hinzufügen oder bearbeiten, da der Timer ausgelaufen ist. Dies kann in den Sitzungseinstellungen geändert werden.", "error_action_unauthorised": "Sie sind nicht berechtigt, diese Aktion auszuführen.", "error_cannot_edit_group": "Bearbeiten der Gruppe fehlgeschlagen.", "error_cannot_edit_post": "Bearbeiten des Beitrags fehlgeschlagen.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Hier eine Nachricht schreiben..." + }, + "Timer": { + "stopTimerTitle": "Sind Sie sicher, dass Sie den Timer stoppen (und zurücksetzen) möchten?", + "stopTimerDescription": "Wenn Sie den Timer stoppen, wird er auf seine ursprüngliche Dauer zurückgesetzt.", + "stopTimerButton": "Timer stoppen", + "stopTimerCancelButton": "Abbrechen" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/en-GB.json b/frontend/src/translations/locales/en-GB.json index 0de4a0c01..f35c40f75 100644 --- a/frontend/src/translations/locales/en-GB.json +++ b/frontend/src/translations/locales/en-GB.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Customise your Session", + "timerCategory": "Timer", + "timerCategorySub": "Set the timer for the session", + "allowTimer": "Allow Timer", + "allowTimerHelp": "Display a timer at the bottom of the screen", + "timerDuration": "Timer Duration", + "timerDurationHelp": "The timer will be set to {{duration}} minutes", + "lockOnTimerEnd": "Lock on Timer End", + "lockOnTimerEndHelp": "Lock the session (make it read-only) when the timer ends", "votingCategory": "Voting", "votingCategorySub": "Set the rules about likes and dislikes", "postCategory": "Post settings", @@ -120,6 +128,8 @@ "disconnected": "You have been disconnected from the current session.", "reconnect": "Reconnect", "notLoggedIn": "You are not logged in. You can view this session as a spectator, but must login to participate.", + "lockedTitle": "The timer has run out", + "lockedDescription": "You can no longer add or edit posts, as the timer ran out. This can be changed in the session settings.", "error_action_unauthorised": "You are not allowed to perform this action.", "error_cannot_edit_group": "Editing the group failed.", "error_cannot_edit_post": "Editing the post failed.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Write a message here..." + }, + "Timer": { + "stopTimerTitle": "Are you sure you want to stop (and reset) the timer?", + "stopTimerDescription": "If you stop the timer, it will be reset to its original duration.", + "stopTimerButton": "Stop Timer", + "stopTimerCancelButton": "Cancel" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/es-ES.json b/frontend/src/translations/locales/es-ES.json index b9dcc0299..358ae2c85 100644 --- a/frontend/src/translations/locales/es-ES.json +++ b/frontend/src/translations/locales/es-ES.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Personaliza tu sesión", + "timerCategory": "Temporizador", + "timerCategorySub": "Establecer el temporizador de la sesión", + "allowTimer": "Permitir temporizador", + "allowTimerHelp": "Mostrar un temporizador en la parte inferior de la pantalla", + "timerDuration": "Duración del temporizador", + "timerDurationHelp": "El temporizador se establecerá a {{duration}} minutos", + "lockOnTimerEnd": "Bloquear al final del temporizador", + "lockOnTimerEndHelp": "Bloquear la sesión (hacer sólo lectura) cuando finalice el temporizador", "votingCategory": "Votaciones", "votingCategorySub": "Establecer las reglas sobre \"me gusta\" y \"me gusta\"", "postCategory": "Ajustes de publicación", @@ -120,6 +128,8 @@ "disconnected": "Has sido desconectado de la sesión actual.", "reconnect": "Volver a conectar", "notLoggedIn": "No estás conectado. Puedes ver esta sesión como un espectador, pero debes iniciar sesión para participar.", + "lockedTitle": "El temporizador se ha agotado", + "lockedDescription": "Ya no puede añadir o editar mensajes, ya que se acabó el temporizador. Esto puede cambiarse en la configuración de la sesión.", "error_action_unauthorised": "No tienes permisos para realizar esta acción.", "error_cannot_edit_group": "Falló la edición del grupo.", "error_cannot_edit_post": "Error al editar el mensaje.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Escribe un mensaje aquí..." + }, + "Timer": { + "stopTimerTitle": "¿Está seguro que desea detener (y restablecer) el temporizador?", + "stopTimerDescription": "Si detiene el temporizador, se restablecerá a su duración original.", + "stopTimerButton": "Detener temporizador", + "stopTimerCancelButton": "Cancelar" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/fr-FR.json b/frontend/src/translations/locales/fr-FR.json index dd4a8e5a2..6263238e4 100644 --- a/frontend/src/translations/locales/fr-FR.json +++ b/frontend/src/translations/locales/fr-FR.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Nouvelle session personalisée", + "timerCategory": "Minuteur", + "timerCategorySub": "Régler le minuteur pour la session", + "allowTimer": "Utiliser le minuteur", + "allowTimerHelp": "Afficher un minuteur en bas de l'écran", + "timerDuration": "Durée du minuteur", + "timerDurationHelp": "Le minuteur sera réglé sur {{duration}} minutes", + "lockOnTimerEnd": "Verrouiller la session", + "lockOnTimerEndHelp": "Verrouiller la session (lecture seule) quand le minuteur se termine", "votingCategory": "Votes", "votingCategorySub": "Règles concernant les votes", "postCategory": "Options des posts", @@ -120,6 +128,8 @@ "disconnected": "Vous avez été déconnecté de la session.", "reconnect": "Se reconnecter", "notLoggedIn": "Vous n'êtes pas connecté. Vous pouvez regarder cette session en tant que spectateur, mais vous devez vous connecter pour participer.", + "lockedTitle": "Le minuteur est terminé", + "lockedDescription": "Vous ne pouvez plus ajouter ou modifier les messages, car le minuteur a expiré. Cela peut être modifié dans les paramètres de la session.", "error_action_unauthorised": "Vous n'avez pas la permission de performer cette action.", "error_cannot_edit_group": "La modification du groupe a échoué.", "error_cannot_edit_post": "La modification du message a échoué.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Écrivez un message ici..." + }, + "Timer": { + "stopTimerTitle": "Êtes-vous sûr de vouloir arrêter (et réinitialiser) le minuteur ?", + "stopTimerDescription": "Si vous arrêtez le minuteur, il sera réinitialisé à sa durée initiale.", + "stopTimerButton": "Arrêter le minuteur", + "stopTimerCancelButton": "Annuler" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/hu-HU.json b/frontend/src/translations/locales/hu-HU.json index 043345b3b..d6fced2c3 100644 --- a/frontend/src/translations/locales/hu-HU.json +++ b/frontend/src/translations/locales/hu-HU.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "A munkamenet testreszabása", + "timerCategory": "", + "timerCategorySub": "", + "allowTimer": "", + "allowTimerHelp": "", + "timerDuration": "", + "timerDurationHelp": "", + "lockOnTimerEnd": "", + "lockOnTimerEndHelp": "", "votingCategory": "Szavazás", "votingCategorySub": "Állítsd be a tetszésnyilvánításokra és a nemtetszésekre vonatkozó szabályokat", "postCategory": "Hozzászólás beállításai", @@ -120,6 +128,8 @@ "disconnected": "Lekapcsolták az aktuális munkamenetről.", "reconnect": "Csatlakozzon újra", "notLoggedIn": "Nincs bejelentkezve. Ezt az ülést nézőként tekintheti meg, de a részvételhez be kell jelentkeznie.", + "lockedTitle": "", + "lockedDescription": "", "error_action_unauthorised": "Ezt a műveletet nem hajthatja végre.", "error_cannot_edit_group": "A csoport szerkesztése nem sikerült.", "error_cannot_edit_post": "A bejegyzés szerkesztése nem sikerült.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Írj üzenetet ide..." + }, + "Timer": { + "stopTimerTitle": "", + "stopTimerDescription": "", + "stopTimerButton": "", + "stopTimerCancelButton": "" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/it-IT.json b/frontend/src/translations/locales/it-IT.json index 196a40b49..ee459f54f 100644 --- a/frontend/src/translations/locales/it-IT.json +++ b/frontend/src/translations/locales/it-IT.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Personalizza il tuo gioco!", + "timerCategory": "Timer", + "timerCategorySub": "Imposta il timer per la sessione", + "allowTimer": "Consenti Timer", + "allowTimerHelp": "Mostra un timer nella parte inferiore dello schermo", + "timerDuration": "Durata Timer", + "timerDurationHelp": "Il timer sarà impostato a {{duration}} minuti", + "lockOnTimerEnd": "Blocca alla fine del timer", + "lockOnTimerEndHelp": "Blocca la sessione (fai in sola lettura) quando il timer finisce", "votingCategory": "Votazione", "votingCategorySub": "Imposta tutte le regole relative a \"mi piace\" e \"non mi piace\"", "postCategory": "Impostazioni del Post", @@ -120,6 +128,8 @@ "disconnected": "Ti sei disconnesso/a dalla sessione corrente.", "reconnect": "Riconnesso", "notLoggedIn": "Non sei autenticato. Puoi assistere a questa sessione come spettatore ma non puoi partecipare", + "lockedTitle": "Il timer è scaduto", + "lockedDescription": "Non è più possibile aggiungere o modificare post, dato che il timer è scaduto. Questo può essere modificato nelle impostazioni della sessione.", "error_action_unauthorised": "Non hai i permessi per eseguire questa azione.", "error_cannot_edit_group": "Modifica del gruppo non riuscita.", "error_cannot_edit_post": "Modifica del post non riuscita.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Scrivi un messaggio qui..." + }, + "Timer": { + "stopTimerTitle": "Sei sicuro di voler fermare (e resettare) il timer?", + "stopTimerDescription": "Se si ferma il timer, verrà ripristinato alla sua durata originale.", + "stopTimerButton": "Ferma Timer", + "stopTimerCancelButton": "Annulla" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/ja-JP.json b/frontend/src/translations/locales/ja-JP.json index a9f4b7743..d1b86c993 100644 --- a/frontend/src/translations/locales/ja-JP.json +++ b/frontend/src/translations/locales/ja-JP.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "セッションをカスタマイズ", + "timerCategory": "タイマー", + "timerCategorySub": "セッションのタイマーを設定する", + "allowTimer": "タイマーを許可", + "allowTimerHelp": "画面下部にタイマーを表示する", + "timerDuration": "タイマー時間", + "timerDurationHelp": "タイマーは {{duration}} 分に設定されます", + "lockOnTimerEnd": "タイマー終了時にロック", + "lockOnTimerEndHelp": "タイマーが終了したときにセッションをロックする", "votingCategory": "投票", "votingCategorySub": "「いいね」と「嫌い」に関するルールを設定", "postCategory": "投稿設定", @@ -120,6 +128,8 @@ "disconnected": "現在のセッションから切断されました。", "reconnect": "再接続", "notLoggedIn": "ログインしていません。このセッションは観戦者として表示できますが、参加するにはログインする必要があります。", + "lockedTitle": "タイマーが切れました", + "lockedDescription": "タイマーがなくなったため、投稿の追加や編集はできません。これはセッション設定で変更できます。", "error_action_unauthorised": "この操作を実行する権限がありません。", "error_cannot_edit_group": "グループの編集に失敗しました。", "error_cannot_edit_post": "投稿の編集に失敗しました。", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "ここにメッセージを書いてください" + }, + "Timer": { + "stopTimerTitle": "タイマーを停止してもよろしいですか?", + "stopTimerDescription": "タイマーを停止すると、タイマーは元の時間にリセットされます。", + "stopTimerButton": "停止タイマー", + "stopTimerCancelButton": "キャンセル" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/nl-NL.json b/frontend/src/translations/locales/nl-NL.json index bb5cb8bfb..dd7307dde 100644 --- a/frontend/src/translations/locales/nl-NL.json +++ b/frontend/src/translations/locales/nl-NL.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Sessie aanpassen", + "timerCategory": "Timer", + "timerCategorySub": "Zet de timer voor de sessie", + "allowTimer": "Timer toestaan", + "allowTimerHelp": "Toon een timer aan de onderkant van het scherm", + "timerDuration": "Timer duur", + "timerDurationHelp": "De timer wordt ingesteld op {{duration}} minuten", + "lockOnTimerEnd": "Vergrendel op Einde Timer", + "lockOnTimerEndHelp": "Vergrendel de sessie (maak het alleen-lezen) wanneer de timer eindigt", "votingCategory": "Stemmen", "votingCategorySub": "Instellen regels voor stemmen", "postCategory": "Retropunten instellingen", @@ -120,6 +128,8 @@ "disconnected": "Je huidige sessie is verbroken", "reconnect": "Opnieuw verbinden", "notLoggedIn": "Je bent niet ingelogd. Je kan de sessie bekijken als toeschouwer maar moet inloggen om deel te nemen.", + "lockedTitle": "De timer is opgeraakt", + "lockedDescription": "Je kunt geen berichten meer toevoegen of bewerken omdat de timer is opgeraakt. Dit kan worden gewijzigd in de sessie-instellingen.", "error_action_unauthorised": "U bent niet bevoegd om deze actie uit te voeren.", "error_cannot_edit_group": "Bewerken van de groep is mislukt.", "error_cannot_edit_post": "Bewerken van post mislukt.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Schrijf hier een bericht..." + }, + "Timer": { + "stopTimerTitle": "Weet je zeker dat je de timer wilt stoppen (en opnieuw instellen?", + "stopTimerDescription": "Als je de timer stopt, wordt deze gereset naar de oorspronkelijke duur.", + "stopTimerButton": "Stop timer", + "stopTimerCancelButton": "annuleren" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/pl-PL.json b/frontend/src/translations/locales/pl-PL.json index 3cc8c5b0b..8044a52e8 100644 --- a/frontend/src/translations/locales/pl-PL.json +++ b/frontend/src/translations/locales/pl-PL.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Dostosuj swoją sesję", + "timerCategory": "Timer", + "timerCategorySub": "Ustaw czasomierz dla sesji", + "allowTimer": "Zezwalaj na Timer", + "allowTimerHelp": "Wyświetlaj licznik na dole ekranu", + "timerDuration": "Czas trwania zegara", + "timerDurationHelp": "Licznik czasu zostanie ustawiony na {{duration}} minut", + "lockOnTimerEnd": "Zablokuj na końcu timera", + "lockOnTimerEndHelp": "Zablokuj sesję (utwórz tylko do odczytu) po zakończeniu timera", "votingCategory": "Głosowanie", "votingCategorySub": "Ustaw reguły dotyczące polubień i polubień", "postCategory": "Ustawienia postów", @@ -120,6 +128,8 @@ "disconnected": "Zostałeś odłączony od bieżącej sesji.", "reconnect": "Połącz ponownie", "notLoggedIn": "Nie jesteś zalogowany. Możesz zobaczyć tę sesję jako obserwator, ale musisz się zalogować, aby wziąć udział.", + "lockedTitle": "Minutnik skończył się", + "lockedDescription": "Nie możesz już dodawać ani edytować postów, ponieważ skończył się czas. Można to zmienić w ustawieniach sesji.", "error_action_unauthorised": "Nie masz uprawnień do wykonania tej czynności.", "error_cannot_edit_group": "Edycja grupy nie powiodła się.", "error_cannot_edit_post": "Edycja wpisu nie powiodła się.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Napisz wiadomość tutaj..." + }, + "Timer": { + "stopTimerTitle": "Czy na pewno chcesz zatrzymać (i zresetować) licznika?", + "stopTimerDescription": "Jeśli zatrzymasz minutnik, zostanie on zresetowany do pierwotnego czasu trwania.", + "stopTimerButton": "Zatrzymaj Timer", + "stopTimerCancelButton": "Anuluj" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/pt-BR.json b/frontend/src/translations/locales/pt-BR.json index 563bbb083..03f8a5c2e 100644 --- a/frontend/src/translations/locales/pt-BR.json +++ b/frontend/src/translations/locales/pt-BR.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Personalize sua sessão", + "timerCategory": "Cronômetro", + "timerCategorySub": "Definir o temporizador para a sessão", + "allowTimer": "Permitir Temporizador", + "allowTimerHelp": "Exibir um cronômetro na parte inferior da tela", + "timerDuration": "Duração do temporizador", + "timerDurationHelp": "O temporizador será definido para {{duration}} minutos", + "lockOnTimerEnd": "Bloquear no término do cronômetro", + "lockOnTimerEndHelp": "Bloquear a sessão (torná-la somente leitura) quando o timer terminar", "votingCategory": "Votação", "votingCategorySub": "Definir regras sobre curtidas e não curtidas", "postCategory": "Configurações de postagem", @@ -120,6 +128,8 @@ "disconnected": "Você foi desconectado da sessão atual.", "reconnect": "Reconectar", "notLoggedIn": "Você não está conectado. Você pode ver esta sessão como um espectador, mas precisa fazer o login para participar.", + "lockedTitle": "O temporizador acabou", + "lockedDescription": "Você não pode mais adicionar ou editar postagens, pois o temporizador acabou. Isso pode ser alterado nas configurações da sessão.", "error_action_unauthorised": "Você não tem permissão para executar esta ação.", "error_cannot_edit_group": "Falha ao editar o grupo.", "error_cannot_edit_post": "Falha ao editar o post.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Escreva uma mensagem aqui..." + }, + "Timer": { + "stopTimerTitle": "Tem certeza que deseja parar (e redefinir) o temporizador?", + "stopTimerDescription": "Se você parar o temporizador, ele será redefinido para a sua duração original.", + "stopTimerButton": "Parar Temporizador", + "stopTimerCancelButton": "cancelar" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/pt-PT.json b/frontend/src/translations/locales/pt-PT.json index ffd16c66e..5a2ed3f51 100644 --- a/frontend/src/translations/locales/pt-PT.json +++ b/frontend/src/translations/locales/pt-PT.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Personalize sua sessão", + "timerCategory": "Cronômetro", + "timerCategorySub": "Definir o temporizador para a sessão", + "allowTimer": "Permitir Temporizador", + "allowTimerHelp": "Exibir um cronômetro na parte inferior da tela", + "timerDuration": "Duração do temporizador", + "timerDurationHelp": "O temporizador será definido para {{duration}} minutos", + "lockOnTimerEnd": "Bloquear no término do cronômetro", + "lockOnTimerEndHelp": "Bloquear a sessão (torná-la somente leitura) quando o timer terminar", "votingCategory": "Votação", "votingCategorySub": "Definir regras sobre curtidas e não curtidas", "postCategory": "Configurações de postagem", @@ -120,6 +128,8 @@ "disconnected": "Você foi desconectado da sessão atual.", "reconnect": "Reconectar", "notLoggedIn": "Você não está conectado. Você pode ver esta sessão como um espectador, mas precisa fazer o login para participar.", + "lockedTitle": "O temporizador acabou", + "lockedDescription": "Você não pode mais adicionar ou editar postagens, pois o temporizador acabou. Isso pode ser alterado nas configurações da sessão.", "error_action_unauthorised": "Você não tem permissão para executar esta ação.", "error_cannot_edit_group": "Falha ao editar o grupo.", "error_cannot_edit_post": "Falha ao editar o post.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Escreva uma mensagem aqui..." + }, + "Timer": { + "stopTimerTitle": "Tem certeza que deseja parar (e redefinir) o temporizador?", + "stopTimerDescription": "Se você parar o temporizador, ele será redefinido para a sua duração original.", + "stopTimerButton": "Parar Temporizador", + "stopTimerCancelButton": "cancelar" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/uk-UA.json b/frontend/src/translations/locales/uk-UA.json index fc5964832..64912e8c9 100644 --- a/frontend/src/translations/locales/uk-UA.json +++ b/frontend/src/translations/locales/uk-UA.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "Налаштуйте вашу сесію", + "timerCategory": "Таймер", + "timerCategorySub": "Встановити таймер для сеансу", + "allowTimer": "Дозволити таймер", + "allowTimerHelp": "Відображати таймер внизу екрана", + "timerDuration": "Тривалість таймера", + "timerDurationHelp": "Таймер буде встановлено на {{duration}} хвилин", + "lockOnTimerEnd": "Блокувати після закінчення таймера", + "lockOnTimerEndHelp": "Блокувати сесію (зробити її тільки для читання) коли таймер завершується", "votingCategory": "Голосування", "votingCategorySub": "Встановіть правила про подібні та вподобання", "postCategory": "Налаштування повідомлень", @@ -120,6 +128,8 @@ "disconnected": "Вас відключено від поточної сесії.", "reconnect": "Перепід'єднатись", "notLoggedIn": "Ви не ввійшли. Ви можете переглядати цю сесію в якості глядача, але повинні увійти, щоб брати участь.", + "lockedTitle": "Таймер вичерпався", + "lockedDescription": "Ви більше не можете додавати і редагувати повідомлення, оскільки таймер виключився. Це може бути змінено в налаштуваннях сесії.", "error_action_unauthorised": "Ви не можете виконати цю дію.", "error_cannot_edit_group": "Не вдалося змінити групу.", "error_cannot_edit_post": "Не вдалося внести зміни до повідомлення.", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "Напишіть повідомлення тут..." + }, + "Timer": { + "stopTimerTitle": "Ви впевнені, що хочете зупинити (і скинути) таймер?", + "stopTimerDescription": "Якщо зупинити таймер, він буде скинутий до його початкової тривалісті.", + "stopTimerButton": "Зупинити таймер", + "stopTimerCancelButton": "Скасувати" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/zh-CN.json b/frontend/src/translations/locales/zh-CN.json index 531577c81..9aa399b4a 100644 --- a/frontend/src/translations/locales/zh-CN.json +++ b/frontend/src/translations/locales/zh-CN.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "自定义您的会话", + "timerCategory": "定时器", + "timerCategorySub": "设置会话计时器", + "allowTimer": "允许计时器", + "allowTimerHelp": "在屏幕底部显示计时器", + "timerDuration": "计时器持续时间", + "timerDurationHelp": "计时器将设置为 {{duration}} 分钟", + "lockOnTimerEnd": "计时器结束时锁定", + "lockOnTimerEndHelp": "当计时器结束时锁定会话(只读)", "votingCategory": "表 决", "votingCategorySub": "设置关于喜欢和不喜欢的规则", "postCategory": "帖子设置", @@ -120,6 +128,8 @@ "disconnected": "您已与当前会话断开连接。", "reconnect": "重新连接", "notLoggedIn": "您尚未登录。您可以将此会话视为旁观者,但必须登录才能参与。", + "lockedTitle": "计时器已过期", + "lockedDescription": "您不能再添加或编辑帖子,因为计时器已经退出。这可以在会话设置中更改。", "error_action_unauthorised": "您无权执行此操作。", "error_cannot_edit_group": "编辑组失败。", "error_cannot_edit_post": "编辑帖子失败。", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "在此处写一条消息..." + }, + "Timer": { + "stopTimerTitle": "您确定要停止(并重置)计时器吗?", + "stopTimerDescription": "如果您停止计时器,它将被重置为其原始持续时间。", + "stopTimerButton": "停止计时器", + "stopTimerCancelButton": "取消" } } \ No newline at end of file diff --git a/frontend/src/translations/locales/zh-TW.json b/frontend/src/translations/locales/zh-TW.json index fa3a35202..76317a565 100644 --- a/frontend/src/translations/locales/zh-TW.json +++ b/frontend/src/translations/locales/zh-TW.json @@ -60,6 +60,14 @@ }, "Customize": { "title": "自定義您的會話", + "timerCategory": "", + "timerCategorySub": "", + "allowTimer": "", + "allowTimerHelp": "", + "timerDuration": "", + "timerDurationHelp": "", + "lockOnTimerEnd": "", + "lockOnTimerEndHelp": "", "votingCategory": "表決", "votingCategorySub": "設定好惡規則", "postCategory": "帖子設置", @@ -120,6 +128,8 @@ "disconnected": "您已與當前會話斷開連接。", "reconnect": "重新連接", "notLoggedIn": "您尚未登錄。您可以作為旁觀者查看此會話,但必須登錄才能參與。", + "lockedTitle": "", + "lockedDescription": "", "error_action_unauthorised": "您無權執行此操作。", "error_cannot_edit_group": "編輯組失敗。", "error_cannot_edit_post": "編輯帖子失敗。", @@ -451,5 +461,11 @@ }, "Chat": { "writeAMessage": "在這裡寫留言..." + }, + "Timer": { + "stopTimerTitle": "", + "stopTimerDescription": "", + "stopTimerButton": "", + "stopTimerCancelButton": "" } } \ No newline at end of file diff --git a/frontend/src/views/Game.tsx b/frontend/src/views/Game.tsx index 13cb237f4..e111190e0 100644 --- a/frontend/src/views/Game.tsx +++ b/frontend/src/views/Game.tsx @@ -24,10 +24,11 @@ import NoContent from '../components/NoContent'; import useCrypto from '../crypto/useCrypto'; import Unauthorized from './game/Unauthorized'; import SearchBar from './game/SearchBar'; -import GameFooter from './game/GameFooter'; +import GameFooter from './game/footer/GameFooter'; import AckWarning from './game/AckWarning'; import useUnauthorised from './game/useUnauthorised'; import useSession from './game/useSession'; +import TimerProvider from './game/TimerProvider'; interface RouteParams { gameId: string; @@ -72,6 +73,8 @@ function GamePage() { onSaveTemplate, onLockSession, onUserReady, + onTimerStart, + onTimerReset, reconnect, } = useGame(gameId || ''); @@ -97,110 +100,116 @@ function GamePage() { } return ( -

- - - {decrypt(session.name) || t('SessionName.defaultSessionName')} - - Retrospected - - - - - {status === 'disconnected' ? ( - - - -  {t('PostBoard.disconnected')} - - - - ) : null} - - - - } - value={rootUrl} - /> - {!session.options.blurCards ? ( + +
+ + + {decrypt(session.name) || t('SessionName.defaultSessionName')} - + Retrospected + + + + + {status === 'disconnected' ? ( + + + +  {t('PostBoard.disconnected')} + + + + ) : null} + + + } - value={summaryUrl} + label={t('GameMenu.board')} + icon={} + value={rootUrl} + /> + {!session.options.blurCards ? ( + } + value={summaryUrl} + /> + ) : null} + + + + + + + + + - ) : null} - - - - - - - - - - } - /> - - ) : null - } - /> - - - - -
+ } + /> + + ) : null + } + /> + + + + +
+ ); } diff --git a/frontend/src/views/game/GameFooter.tsx b/frontend/src/views/game/GameFooter.tsx deleted file mode 100644 index 5650fa3a6..000000000 --- a/frontend/src/views/game/GameFooter.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Chat, Check, CheckCircle, Create } from '@mui/icons-material'; -import { - AvatarGroup, - Badge, - Button, - colors, - IconButton, - useMediaQuery, -} from '@mui/material'; -import CustomAvatar from '../../components/Avatar'; -import useParticipants from './useParticipants'; -import useSession from './useSession'; -import styled from '@emotion/styled'; -import useUser from '../../auth/useUser'; -import { useCallback, useEffect, useState } from 'react'; -import { trackEvent } from '../../track'; -import { Message } from 'common'; -import useModal from '../../hooks/useModal'; -import ChatModal from './chat/ChatModal'; -import { useTranslation } from 'react-i18next'; - -type GameFooterProps = { - onReady: () => void; - messages: Message[]; - onMessage: (content: string) => void; -}; - -function GameFooter({ onReady, onMessage, messages }: GameFooterProps) { - const { participants } = useParticipants(); - const { session } = useSession(); - const user = useUser(); - const { t } = useTranslation(); - const isUserReady = !!user && !!session && session.ready.includes(user.id); - const fullScreen = useMediaQuery('(min-width:600px)'); - const [chatOpen, openChat, closeChat] = useModal(); - const [readCount, setReadCount] = useState(0); - const handleReady = useCallback(() => { - trackEvent('game/session/user-ready'); - onReady(); - }, [onReady]); - useEffect(() => { - if (chatOpen) { - setReadCount(messages.length); - } - }, [chatOpen, messages.length]); - const unreadCount = messages.length - readCount; - return ( - - - {participants - .filter((u) => u.online) - .map((user) => { - return ( - - ) : undefined - } - > - - - ); - })} - - {user && !fullScreen ? ( - - {isUserReady ? ( - - ) : ( - - )} - - ) : null} - {user && fullScreen ? ( - - ) : null} - {user ? ( - - - - - - ) : null} - {chatOpen ? ( - - ) : null} - - ); -} - -const Container = styled.div` - display: flex; - gap: 10px; - > div:first-of-type { - flex: 1; - } -`; - -export default GameFooter; diff --git a/frontend/src/views/game/TimerProvider.tsx b/frontend/src/views/game/TimerProvider.tsx new file mode 100644 index 000000000..5ec44606f --- /dev/null +++ b/frontend/src/views/game/TimerProvider.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useTimer } from './useTimer'; + +type TimerProviderProps = { + children: React.ReactNode; +}; + +const TimerContext = createContext({ + hasRunOut: false, +}); + +export function useHasRunOut() { + return useContext(TimerContext).hasRunOut; +} + +export default function TimerProvider({ children }: TimerProviderProps) { + const [hasRunOut, setHasRunOut] = useState(false); + const timer = useTimer(); + + useEffect(() => { + const handle = setInterval(() => { + if (!timer) { + setHasRunOut(false); + return; + } + + const remaining = Math.floor( + (timer.getTime() - new Date().getTime()) / 1000 + ); + setHasRunOut(remaining <= 0); + }, 100); + return () => { + clearInterval(handle); + }; + }, [timer]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/views/game/board/__tests__/permissions-logic.test.ts b/frontend/src/views/game/board/__tests__/permissions-logic.test.ts index ec04f8ff5..a74ff53f6 100644 --- a/frontend/src/views/game/board/__tests__/permissions-logic.test.ts +++ b/frontend/src/views/game/board/__tests__/permissions-logic.test.ts @@ -77,6 +77,7 @@ const session = (options: SessionOptions, ...posts: Post[]): Session => ({ locked: false, messages: [], ready: [], + timer: null, }); describe('Session Permission Logic', () => { @@ -88,6 +89,14 @@ describe('Session Permission Logic', () => { expect(result.hasReachedMaxPosts).toBe(false); }); + it('When using default rules, with a logged in user, but readonly', () => { + const s = session(defaultOptions); + const result = sessionPermissionLogic(s, currentUser, true, true); + expect(result.canCreatePost).toBe(false); + expect(result.canCreateGroup).toBe(false); + expect(result.hasReachedMaxPosts).toBe(false); + }); + it('When using default rules, with a logged out user (no user)', () => { const s = session(defaultOptions); const result = sessionPermissionLogic(s, null, true, false); @@ -151,7 +160,7 @@ describe('Posts Permission Logic', () => { it('When using default rules, a user on its own post', () => { const p = post(currentUser); const s = session(defaultOptions, p); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(true); expect(result.canDelete).toBe(true); @@ -161,10 +170,23 @@ describe('Posts Permission Logic', () => { expect(result.canDisplayDownVote).toBe(true); }); + it('When using default rules, a user on its own post, but set to readonly', () => { + const p = post(currentUser); + const s = session(defaultOptions, p); + const result = postPermissionLogic(p, s, currentUser, true); + expect(result.canCreateAction).toBe(false); + expect(result.canEdit).toBe(false); + expect(result.canDelete).toBe(false); + expect(result.canDownVote).toBe(false); + expect(result.canUpVote).toBe(false); + expect(result.canDisplayUpVote).toBe(true); + expect(result.canDisplayDownVote).toBe(true); + }); + it('When using default rules, a non-logged in user', () => { const p = post(currentUser); const s = session(defaultOptions, p); - const result = postPermissionLogic(p, s, null); + const result = postPermissionLogic(p, s, null, false); expect(result.canCreateAction).toBe(false); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -177,7 +199,7 @@ describe('Posts Permission Logic', () => { it('When using default rules, a user on another users post', () => { const p = post(anotherUser); const s = session(defaultOptions, p); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -190,7 +212,7 @@ describe('Posts Permission Logic', () => { it('When using default rules, a user on another users post but already voted', () => { const p = post(anotherUser, [currentUser]); const s = session(defaultOptions, p); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -211,7 +233,7 @@ describe('Posts Permission Logic', () => { p1, p2 ); - const result = postPermissionLogic(p2, s, currentUser); + const result = postPermissionLogic(p2, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -232,7 +254,7 @@ describe('Posts Permission Logic', () => { p2, p3 ); - const result = postPermissionLogic(p3, s, currentUser); + const result = postPermissionLogic(p3, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -251,7 +273,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(false); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -270,7 +292,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(false); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -289,7 +311,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(true); expect(result.canDelete).toBe(true); @@ -308,7 +330,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -328,7 +350,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -348,7 +370,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -368,7 +390,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateAction).toBe(true); expect(result.canEdit).toBe(false); expect(result.canDelete).toBe(false); @@ -387,7 +409,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canUseGiphy).toBe(true); }); @@ -400,7 +422,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canUseGiphy).toBe(false); }); @@ -413,7 +435,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canReorder).toBe(true); }); @@ -426,7 +448,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canReorder).toBe(false); }); @@ -439,7 +461,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateGroup).toBe(true); }); @@ -452,7 +474,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCreateGroup).toBe(false); }); @@ -465,7 +487,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.isBlurred).toBe(false); }); @@ -478,7 +500,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.isBlurred).toBe(true); }); @@ -491,7 +513,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.isBlurred).toBe(false); }); @@ -504,7 +526,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCancelVote).toBe(true); }); @@ -518,7 +540,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCancelVote).toBe(false); }); @@ -531,7 +553,7 @@ describe('Posts Permission Logic', () => { }, p ); - const result = postPermissionLogic(p, s, currentUser); + const result = postPermissionLogic(p, s, currentUser, false); expect(result.canCancelVote).toBe(false); }); }); diff --git a/frontend/src/views/game/board/header/BoardHeader.tsx b/frontend/src/views/game/board/header/BoardHeader.tsx index 8d0af79a2..92d7c438d 100644 --- a/frontend/src/views/game/board/header/BoardHeader.tsx +++ b/frontend/src/views/game/board/header/BoardHeader.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import styled from '@emotion/styled'; import { SessionOptions, ColumnDefinition } from 'common'; import Typography from '@mui/material/Typography'; @@ -9,7 +9,6 @@ import useCanReveal from './useCanReveal'; import EditableLabel from '../../../../components/EditableLabel'; import RemainingVotes from './RemainingVotes'; import useUser from '../../../../auth/useUser'; -import { Alert, AlertTitle } from '@mui/material'; import RevealButton from './RevealButton'; import ModifyOptions from './ModifyOptions'; import useCanModifyOptions from './useCanModifyOptions'; @@ -23,6 +22,8 @@ import LockSession from './LockSession'; import useSession from '../../useSession'; import useSessionUserPermissions from '../useSessionUserPermissions'; import useIsDisabled from '../../../../hooks/useIsDisabled'; +import { useShouldLockSession } from 'views/game/useTimer'; +import ClosableAlert from 'components/ClosableAlert'; interface BoardHeaderProps { onRenameSession: (name: string) => void; @@ -65,6 +66,7 @@ function BoardHeader({ const shouldDisplayEncryptionWarning = useShouldDisplayEncryptionWarning(); const { session } = useSession(); const permissions = useSessionUserPermissions(); + const locked = useShouldLockSession(); const handleReveal = useCallback(() => { if (session) { @@ -90,22 +92,40 @@ function BoardHeader({ return ( <> {!canDecrypt ? : null} - {!isLoggedIn ? ( - {t('PostBoard.notLoggedIn')} - ) : null} - {!canDecrypt ? ( - {t('Encryption.sessionEncryptionError')} - ) : null} - {permissions.hasReachedMaxPosts ? ( - {t('PostBoard.maxPostsReached')} - ) : null} - {isDisabled ? ( - - {t('TrialPrompt.allowanceReachedTitle')} - {t('TrialPrompt.allowanceReachedDescription')} - - ) : null} - + + {!isLoggedIn ? ( + + {t('PostBoard.notLoggedIn')} + + ) : null} + {!canDecrypt ? ( + + {t('Encryption.sessionEncryptionError')} + + ) : null} + {permissions.hasReachedMaxPosts ? ( + + {t('PostBoard.maxPostsReached')} + + ) : null} + {isDisabled ? ( + + {t('TrialPrompt.allowanceReachedDescription')} + + ) : null} + {locked ? ( + + {t('PostBoard.lockedDescription')} + + ) : null} +
{canReveal ? : null} @@ -153,6 +173,12 @@ function BoardHeader({ ); } +const Alerts = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + const Header = styled.div` display: grid; justify-items: center; @@ -228,4 +254,4 @@ const RightOptions = styled.div` } `; -export default BoardHeader; +export default memo(BoardHeader); diff --git a/frontend/src/views/game/board/permissions-logic.ts b/frontend/src/views/game/board/permissions-logic.ts index 39eaa9324..e6b38e216 100644 --- a/frontend/src/views/game/board/permissions-logic.ts +++ b/frontend/src/views/game/board/permissions-logic.ts @@ -11,7 +11,7 @@ export function sessionPermissionLogic( session: Session | null, user: User | null, canDecrypt: boolean, - userDisabled: boolean + readonly: boolean ): SessionUserPermissions { const numberOfPosts = session && user @@ -23,7 +23,7 @@ export function sessionPermissionLogic( session.options.maxPosts !== null && session.options.maxPosts <= numberOfPosts; const canCreatePost = - !!user && canDecrypt && !userDisabled && !hasReachedMaxPosts; + !!user && canDecrypt && !readonly && !hasReachedMaxPosts; const canCreateGroup = canCreatePost && !!session && session.options.allowGrouping; @@ -53,7 +53,8 @@ export interface PostUserPermissions { export function postPermissionLogic( post: Post, session: Session | null, - user: User | null + user: User | null, + readonly: boolean ): PostUserPermissions { if (!session) { return { @@ -87,10 +88,11 @@ export function postPermissionLogic( } = session.options; const isLoggedIn = !!user; - const canCreateAction = isLoggedIn && allowActions; + const canCreateAction = !readonly && isLoggedIn && allowActions; const userId = user ? user.id : -1; const isAuthor = user ? user.id === post.user.id : false; - const canPotentiallyVote = isLoggedIn && allowSelfVoting ? true : !isAuthor; + const canPotentiallyVote = + !readonly && isLoggedIn && allowSelfVoting ? true : !isAuthor; const hasVoted = some(post.votes, (u) => u.userId === userId); const hasVotedOrAuthor = (!allowMultipleVotes && @@ -103,17 +105,19 @@ export function postPermissionLogic( const hasMaxedUpVotes = maxUpVotes === null ? false : upVotes >= maxUpVotes; const hasMaxedDownVotes = maxDownVotes === null ? false : downVotes >= maxDownVotes; - const canUpVote = isLoggedIn && !hasVotedOrAuthor && !hasMaxedUpVotes; - const canDownVote = isLoggedIn && !hasVotedOrAuthor && !hasMaxedDownVotes; + const canUpVote = + !readonly && isLoggedIn && !hasVotedOrAuthor && !hasMaxedUpVotes; + const canDownVote = + !readonly && isLoggedIn && !hasVotedOrAuthor && !hasMaxedDownVotes; const canDisplayUpVote = maxUpVotes !== null ? maxUpVotes > 0 : true; const canDisplayDownVote = maxDownVotes !== null ? maxDownVotes > 0 : true; - const canEdit = isLoggedIn && isAuthor; - const canDelete = isLoggedIn && isAuthor; + const canEdit = !readonly && isLoggedIn && isAuthor; + const canDelete = !readonly && isLoggedIn && isAuthor; const canShowAuthor = allowAuthorVisible; const canUseGiphy = isLoggedIn && allowGiphy; - const canReorder = isLoggedIn && allowReordering; - const canCreateGroup = isLoggedIn && allowGrouping; - const canCancelVote = hasVoted && allowCancelVote; + const canReorder = !readonly && isLoggedIn && allowReordering; + const canCreateGroup = !readonly && isLoggedIn && allowGrouping; + const canCancelVote = !readonly && hasVoted && allowCancelVote; const isBlurred = blurCards && !isAuthor; return { diff --git a/frontend/src/views/game/board/usePostUserPermissions.ts b/frontend/src/views/game/board/usePostUserPermissions.ts index 852c97a26..927db58a1 100644 --- a/frontend/src/views/game/board/usePostUserPermissions.ts +++ b/frontend/src/views/game/board/usePostUserPermissions.ts @@ -2,21 +2,24 @@ import { Post } from 'common'; import { postPermissionLogic, PostUserPermissions } from './permissions-logic'; import useUser from '../../../auth/useUser'; import useSession from '../useSession'; +import { useShouldLockSession } from '../useTimer'; export function usePostUserPermissions(post: Post): PostUserPermissions { const { session } = useSession(); const user = useUser(); - return postPermissionLogic(post, session, user); + const readonly = useShouldLockSession(); + return postPermissionLogic(post, session, user, readonly); } export function usePostUserPermissionsNullable( post?: Post ): PostUserPermissions | undefined { const { session } = useSession(); + const readonly = useShouldLockSession(); const user = useUser(); if (!post) { return undefined; } - return postPermissionLogic(post, session, user); + return postPermissionLogic(post, session, user, readonly); } diff --git a/frontend/src/views/game/board/useSessionUserPermissions.ts b/frontend/src/views/game/board/useSessionUserPermissions.ts index 034febee5..fd02b77a0 100644 --- a/frontend/src/views/game/board/useSessionUserPermissions.ts +++ b/frontend/src/views/game/board/useSessionUserPermissions.ts @@ -2,6 +2,7 @@ import useUser from '../../../auth/useUser'; import useCanDecrypt from '../../../crypto/useCanDecrypt'; import useIsDisabled from '../../../hooks/useIsDisabled'; import useSession from '../useSession'; +import { useShouldLockSession } from '../useTimer'; import { sessionPermissionLogic, SessionUserPermissions, @@ -12,5 +13,11 @@ export default function useSessionUserPermissions(): SessionUserPermissions { const user = useUser(); const canDecrypt = useCanDecrypt(); const isDisabled = useIsDisabled(); - return sessionPermissionLogic(session, user, canDecrypt, isDisabled); + const shouldLockSession = useShouldLockSession(); + return sessionPermissionLogic( + session, + user, + canDecrypt, + isDisabled || shouldLockSession + ); } diff --git a/frontend/src/views/game/footer/GameFooter.tsx b/frontend/src/views/game/footer/GameFooter.tsx new file mode 100644 index 000000000..55dc21f52 --- /dev/null +++ b/frontend/src/views/game/footer/GameFooter.tsx @@ -0,0 +1,156 @@ +import { Chat, Check, Create } from '@mui/icons-material'; +import { + Badge, + Button, + colors, + IconButton, + useMediaQuery, +} from '@mui/material'; +import useSession from '../useSession'; +import styled from '@emotion/styled'; +import useUser from '../../../auth/useUser'; +import { useCallback, useEffect, useState } from 'react'; +import { trackEvent } from '../../../track'; +import { Message } from 'common'; +import useModal from '../../../hooks/useModal'; +import ChatModal from '../chat/ChatModal'; +import { useTranslation } from 'react-i18next'; +import Users from './Users'; +import { Timer } from './Timer'; +import useCanModifyOptions from '../board/header/useCanModifyOptions'; + +type GameFooterProps = { + onReady: () => void; + timer: boolean; + timerDuration: number; + messages: Message[]; + onMessage: (content: string) => void; + onTimerStart: () => void; + onTimerReset: () => void; +}; + +function GameFooter({ + onReady, + onMessage, + messages, + timer, + timerDuration, + onTimerStart, + onTimerReset, +}: GameFooterProps) { + const { session } = useSession(); + const user = useUser(); + const { t } = useTranslation(); + const isUserReady = !!user && !!session && session.ready.includes(user.id); + const fullScreen = useMediaQuery('(min-width:600px)'); + const [chatOpen, openChat, closeChat] = useModal(); + const [readCount, setReadCount] = useState(0); + const canActionTimer = useCanModifyOptions(); + const handleReady = useCallback(() => { + trackEvent('game/session/user-ready'); + onReady(); + }, [onReady]); + useEffect(() => { + if (chatOpen) { + setReadCount(messages.length); + } + }, [chatOpen, messages.length]); + const unreadCount = messages.length - readCount; + return ( + + + + + {timer ? ( + + + + ) : null} + + + {user && !fullScreen ? ( + + {isUserReady ? ( + + ) : ( + + )} + + ) : null} + {user && fullScreen ? ( + + ) : null} + {user ? ( + + + + + + ) : null} + + {chatOpen ? ( + + ) : null} + + ); +} + +const Container = styled.div` + display: grid; + justify-items: stretch; + justify-content: stretch; + align-items: center; + grid-template-columns: repeat(auto-fit, 1fr); + grid-template-rows: repeat(auto-fit, 1fr); + grid-template-areas: 'users timer controls'; + column-gap: 10px; + row-gap: 10px; + @media screen and (max-width: 700px) { + grid-template-areas: + 'timer timer' + 'users controls'; + grid-template-columns: repeat(auto-fit, 1fr); + grid-template-rows: repeat(auto-fit, 1fr); + } +`; + +const EndControlsContainer = styled.div` + grid-area: controls; + display: flex; + gap: 10px; + justify-self: flex-end; +`; + +const UsersContainer = styled.div` + grid-area: users; + padding-left: 10px; +`; + +const TimerContainer = styled.div` + grid-area: timer; + justify-self: center; +`; + +export default GameFooter; diff --git a/frontend/src/views/game/footer/Timer.tsx b/frontend/src/views/game/footer/Timer.tsx new file mode 100644 index 000000000..342d14356 --- /dev/null +++ b/frontend/src/views/game/footer/Timer.tsx @@ -0,0 +1,123 @@ +import styled from '@emotion/styled'; +import { PlayArrow, Stop, TimerOutlined } from '@mui/icons-material'; +import { Color, colors, IconButton } from '@mui/material'; +import { differenceInSeconds } from 'date-fns'; +import { noop } from 'lodash'; +import { useConfirm } from 'material-ui-confirm'; +import { useCallback, useEffect, useState, memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useTimer } from '../useTimer'; + +type TimerProps = { + duration: number; + canControl: boolean; + onStart: () => void; + onStop: () => void; +}; + +export const Timer = memo(function Timer({ + duration, + canControl, + onStart, + onStop, +}: TimerProps) { + const confirm = useConfirm(); + const end = useTimer(); + const { t } = useTranslation(); + const [remaining, setRemaining] = useState