From e46e275ac7c0fc518344130ff1890a494389bd27 Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 28 Dec 2022 17:07:36 +0800 Subject: [PATCH] feat(flat-components): users panel (#1801) --- .../ChatMessageList.stories.tsx | 1 + .../ChatMessages/ChatMessages.stories.tsx | 1 + .../ChatPanel/ChatPanel.stories.tsx | 5 +- .../ChatPanel/ChatTypeBox/style.less | 9 +- .../ChatPanel/ChatUser/ChatUser.stories.tsx | 1 + .../components/ChatPanel/ChatUser/index.tsx | 2 +- .../ChatPanel/ChatUsers/ChatUsers.stories.tsx | 1 + .../src/components/ChatPanel/index.tsx | 52 +--- .../src/components/ChatPanel/style.less | 19 ++ .../ClassroomPage/RaiseHand/index.tsx | 34 ++- .../ClassroomPage/RaiseHand/style.less | 33 ++- .../ClassroomPage/VideoAvatar/IconMic.tsx | 2 +- .../UsersPanel/UsersPanel.stories.tsx | 46 ++++ .../src/components/UsersPanel/index.tsx | 236 ++++++++++++++++++ .../src/components/UsersPanel/style.less | 165 ++++++++++++ packages/flat-components/src/index.ts | 17 +- .../flat-components/src/theme/antd.mod.less | 15 ++ .../src/theme/antd.mod.stories.tsx | 23 +- packages/flat-components/src/types/user.ts | 3 + packages/flat-i18n/locales/en.json | 23 +- packages/flat-i18n/locales/zh-CN.json | 23 +- .../flat-pages/src/BigClassPage/index.tsx | 7 +- .../flat-pages/src/OneToOnePage/index.tsx | 8 +- .../flat-pages/src/SmallClassPage/index.tsx | 7 +- .../src/components/ChatPanel/index.tsx | 42 +--- .../src/components/UsersButton.less | 63 +++++ .../flat-pages/src/components/UsersButton.tsx | 116 +++++++++ .../flat-pages/src/components/Whiteboard.less | 70 +++++- .../flat-pages/src/components/Whiteboard.tsx | 38 +++ .../src/services/text-chat/commands.ts | 6 + .../src/services/text-chat/events.ts | 15 ++ .../src/services/whiteboard/whiteboard.ts | 7 + .../flat-stores/src/classroom-store/index.ts | 235 +++++++++++++++-- packages/flat-stores/src/user-store.ts | 2 + .../flat-stores/src/whiteboard-store/index.ts | 8 +- service-providers/agora-rtm/src/rtm.ts | 24 ++ service-providers/fastboard/src/index.ts | 21 +- 37 files changed, 1222 insertions(+), 158 deletions(-) create mode 100644 packages/flat-components/src/components/UsersPanel/UsersPanel.stories.tsx create mode 100644 packages/flat-components/src/components/UsersPanel/index.tsx create mode 100644 packages/flat-components/src/components/UsersPanel/style.less create mode 100644 packages/flat-pages/src/components/UsersButton.less create mode 100644 packages/flat-pages/src/components/UsersButton.tsx diff --git a/packages/flat-components/src/components/ChatPanel/ChatMessageList/ChatMessageList.stories.tsx b/packages/flat-components/src/components/ChatPanel/ChatMessageList/ChatMessageList.stories.tsx index fd841ff6e47..fd1f4090245 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatMessageList/ChatMessageList.stories.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatMessageList/ChatMessageList.stories.tsx @@ -23,6 +23,7 @@ const makeUser = (): User => ({ userUUID: faker.datatype.uuid(), name: faker.name.lastName(), isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), isRaiseHand: faker.datatype.boolean(), avatar: "http://placekitten.com/64/64", }); diff --git a/packages/flat-components/src/components/ChatPanel/ChatMessages/ChatMessages.stories.tsx b/packages/flat-components/src/components/ChatPanel/ChatMessages/ChatMessages.stories.tsx index c786a52f485..c681d063aa9 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatMessages/ChatMessages.stories.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatMessages/ChatMessages.stories.tsx @@ -23,6 +23,7 @@ const makeUser = (): User => ({ userUUID: faker.datatype.uuid(), name: faker.name.lastName(), isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), isRaiseHand: faker.datatype.boolean(), avatar: "http://placekitten.com/64/64", }); diff --git a/packages/flat-components/src/components/ChatPanel/ChatPanel.stories.tsx b/packages/flat-components/src/components/ChatPanel/ChatPanel.stories.tsx index 305ae6f5a3c..1551e5090c2 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatPanel.stories.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatPanel.stories.tsx @@ -23,6 +23,7 @@ const makeUser = (): User => ({ userUUID: faker.datatype.uuid(), name: faker.name.lastName(), isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), isRaiseHand: faker.datatype.boolean(), avatar: "http://placekitten.com/64/64", }); @@ -36,8 +37,6 @@ Overview.args = { unreadCount: faker.datatype.number(), isCreator: faker.datatype.boolean(), isBan: faker.datatype.boolean(), - hasHandRaising: faker.datatype.boolean(), - generateAvatar: () => "http://placekitten.com/64/64", getUserByUUID: uuid => users.find(e => e.userUUID === uuid) || makeUser(), messages: Array(20) .fill(0) @@ -49,9 +48,7 @@ Overview.args = { text: chance.sentence({ words: faker.datatype.number(20) }), senderID: chance.pickone(users).userUUID, })), - ownerUUID: faker.datatype.uuid(), userUUID: currentUser.userUUID, - users, }; Overview.argTypes = { loadMoreRows: { action: "loadMoreRows" }, diff --git a/packages/flat-components/src/components/ChatPanel/ChatTypeBox/style.less b/packages/flat-components/src/components/ChatPanel/ChatTypeBox/style.less index a8b48657b03..1556c4a4f82 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatTypeBox/style.less +++ b/packages/flat-components/src/components/ChatPanel/ChatTypeBox/style.less @@ -3,9 +3,10 @@ display: flex; align-items: center; width: 100%; - height: 45px; - padding: 0 6px 2px; - border-top: 0.5px solid var(--grey-1); + height: 40px; + padding: 8px; + border-top: 1px solid var(--grey-1); + font-size: 0; } .chat-typebox-icon { @@ -36,6 +37,8 @@ border: none; outline: none; color: var(--text); + font-size: 14px; + line-height: 24px; background-color: transparent; &::placeholder { diff --git a/packages/flat-components/src/components/ChatPanel/ChatUser/ChatUser.stories.tsx b/packages/flat-components/src/components/ChatPanel/ChatUser/ChatUser.stories.tsx index 5ad58045d82..bf629c39b10 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatUser/ChatUser.stories.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatUser/ChatUser.stories.tsx @@ -16,6 +16,7 @@ const makeUser = (): User => ({ userUUID: faker.datatype.uuid(), name: faker.name.lastName(), isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), isRaiseHand: faker.datatype.boolean(), avatar: "http://placekitten.com/64/64", hasLeft: faker.datatype.boolean(), diff --git a/packages/flat-components/src/components/ChatPanel/ChatUser/index.tsx b/packages/flat-components/src/components/ChatPanel/ChatUser/index.tsx index e7be1bdaa75..27f29a236c9 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatUser/index.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatUser/index.tsx @@ -47,7 +47,7 @@ export const ChatUser = /* @__PURE__ */ observer(function ChatUse /> {user.name} {ownerUUID === user.userUUID ? ( - {t("teacher")} + ({t("teacher")}) ) : user.hasLeft ? ( <> {t("has-left")} diff --git a/packages/flat-components/src/components/ChatPanel/ChatUsers/ChatUsers.stories.tsx b/packages/flat-components/src/components/ChatPanel/ChatUsers/ChatUsers.stories.tsx index c0d5f13baf2..afbf0962846 100644 --- a/packages/flat-components/src/components/ChatPanel/ChatUsers/ChatUsers.stories.tsx +++ b/packages/flat-components/src/components/ChatPanel/ChatUsers/ChatUsers.stories.tsx @@ -23,6 +23,7 @@ const makeUser = (): User => ({ userUUID: faker.datatype.uuid(), name: faker.name.lastName(), isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), isRaiseHand: faker.datatype.boolean(), avatar: "http://placekitten.com/64/64", }); diff --git a/packages/flat-components/src/components/ChatPanel/index.tsx b/packages/flat-components/src/components/ChatPanel/index.tsx index d175626b48a..3833b1f2f56 100644 --- a/packages/flat-components/src/components/ChatPanel/index.tsx +++ b/packages/flat-components/src/components/ChatPanel/index.tsx @@ -1,60 +1,24 @@ import "./style.less"; -import React, { useMemo, useState } from "react"; -import { Tabs } from "antd"; +import React from "react"; import { observer } from "mobx-react-lite"; import { useTranslate } from "@netless/flat-i18n"; import { ChatMessages, ChatMessagesProps } from "./ChatMessages"; import { ChatTabTitle, ChatTabTitleProps } from "./ChatTabTitle"; -import { ChatUsers, ChatUsersProps } from "./ChatUsers"; -export type ChatPanelProps = ChatTabTitleProps & - Omit & - ChatUsersProps; +export type ChatPanelProps = ChatTabTitleProps & Omit; export const ChatPanel = /* @__PURE__ */ observer(function ChatPanel(props) { const t = useTranslate(); - const [activeTab, setActiveTab] = useState<"messages" | "users">("messages"); - const usersCount = useMemo(() => { - const count = props.users.length; - if (count === 0) { - return ""; - } - if (count > 999) { - return "(999+)"; - } - return `(${count})`; - }, [props.users.length]); return (
- - {t("messages")} - - ), - children: , - }, - { - key: "users", - label: ( - - - {t("users")} {usersCount} - - - ), - children: , - }, - ]} - tabBarGutter={0} - onChange={setActiveTab as (key: string) => void} - > +
+ + {t("messages")} + +
+
); }); diff --git a/packages/flat-components/src/components/ChatPanel/style.less b/packages/flat-components/src/components/ChatPanel/style.less index db1a6738cd1..03d22032a96 100644 --- a/packages/flat-components/src/components/ChatPanel/style.less +++ b/packages/flat-components/src/components/ChatPanel/style.less @@ -1,5 +1,7 @@ .chat-panel { height: 100%; + display: flex; + flex-flow: column nowrap; .ant-tabs { height: 100%; @@ -43,6 +45,20 @@ } } +.chat-panel-header { + flex-shrink: 0; + flex-grow: 0; + display: flex; + align-items: center; + height: 40px; + padding: 8px 12px; + font-size: 14px; + line-height: 24px; + font-weight: 600; + color: var(--text-strong); + border-bottom: 1px solid var(--grey-1); +} + .flat-color-scheme-dark { .chat-panel { .ant-tabs-ink-bar { @@ -52,4 +68,7 @@ border-bottom-color: var(--grey-8); } } + .chat-panel-header { + border-bottom-color: var(--grey-8); + } } diff --git a/packages/flat-components/src/components/ClassroomPage/RaiseHand/index.tsx b/packages/flat-components/src/components/ClassroomPage/RaiseHand/index.tsx index c7fab03094f..df0bf9b4f5c 100644 --- a/packages/flat-components/src/components/ClassroomPage/RaiseHand/index.tsx +++ b/packages/flat-components/src/components/ClassroomPage/RaiseHand/index.tsx @@ -1,6 +1,8 @@ import "./style.less"; -import React from "react"; +import React, { useMemo } from "react"; +import classNames from "classnames"; +import { isInteger } from "lodash-es"; import { useTranslate } from "@netless/flat-i18n"; import { SVGHandUp } from "../../FlatIcons"; @@ -23,3 +25,33 @@ export const RaiseHand: React.FC = ({ ); }; + +export interface RaisingHandProps { + count: number; + onClick: () => void; +} + +export const RaisingHand: React.FC = ({ count, onClick }) => { + const t = useTranslate(); + + const countLabel = useMemo( + () => + isInteger(count) ? ( + 9, + })} + > + {count < 10 ? count : "9+"} + + ) : null, + [count], + ); + + return count > 0 ? ( + + ) : null; +}; diff --git a/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less b/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less index 1fb3f589170..03f562c8c16 100644 --- a/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less +++ b/packages/flat-components/src/components/ClassroomPage/RaiseHand/style.less @@ -5,12 +5,41 @@ border-radius: 24px; border: 1px solid rgba(0, 0, 0, 0.15); background: rgba(255, 255, 255, 0.9); + font-size: 0; outline: 0; } +.raise-hand-red-dot { + display: inline-flex; + justify-content: center; + align-items: center; + width: 22px; + height: 22px; + overflow: hidden; + position: absolute; + top: 0; + left: 75%; + transform: translate(-5px, -6px); + font-size: 12px; + border: 1px solid var(--grey-0); + border-radius: 22px; + color: var(--grey-0); + background: var(--danger); + + &.is-large { + width: auto; + height: auto; + padding: 0 5px; + } +} + .flat-color-scheme-dark { .raise-hand-btn { - background: var(--grey-10); - border: 1px solid rgba(255, 255, 255, 0.15); + background: var(--grey-9); + border: 1px solid var(--grey-8); + } + + .raise-hand-red-dot { + border-color: var(--grey-11); } } diff --git a/packages/flat-components/src/components/ClassroomPage/VideoAvatar/IconMic.tsx b/packages/flat-components/src/components/ClassroomPage/VideoAvatar/IconMic.tsx index a630875c104..964fbd10461 100644 --- a/packages/flat-components/src/components/ClassroomPage/VideoAvatar/IconMic.tsx +++ b/packages/flat-components/src/components/ClassroomPage/VideoAvatar/IconMic.tsx @@ -85,7 +85,7 @@ export const IconMic = /* @__PURE__ */ React.memo(function IconMic fill="#44AD00" height={vHeight * 2} style={{ - transform: `translateY(${Math.pow(1 - volumeLevel, 2.3) * vHeight}px)`, + transform: `translateY(${(1 - volumeLevel) * vHeight}px)`, transition: "transform .1s", }} width={vWidth} diff --git a/packages/flat-components/src/components/UsersPanel/UsersPanel.stories.tsx b/packages/flat-components/src/components/UsersPanel/UsersPanel.stories.tsx new file mode 100644 index 00000000000..dd665c9b00b --- /dev/null +++ b/packages/flat-components/src/components/UsersPanel/UsersPanel.stories.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import { UsersPanel, UsersPanelProps } from "./index"; +import { User } from "../../types/user"; +import faker from "faker"; +import Chance from "chance"; + +const storyMeta: Meta = { + title: "ChatPanel/UsersPanel", + component: UsersPanel, +}; + +export default storyMeta; + +const chance = new Chance(); +const makeUser = (): User => ({ + userUUID: faker.datatype.uuid(), + name: faker.name.lastName(), + isSpeak: faker.datatype.boolean(), + wbOperate: faker.datatype.boolean(), + isRaiseHand: faker.datatype.boolean(), + avatar: "http://placekitten.com/64/64", +}); +const currentUser = makeUser(); +const users = (() => { + const users = Array(20).fill(0).map(makeUser); + users.push(currentUser); + return chance.shuffle(users); +})(); + +export const Overview: Story = args => ( +
+ +
+); +const isCreator = chance.bool(); +Overview.args = { + ownerUUID: isCreator ? currentUser.userUUID : chance.pickone(users).userUUID, + userUUID: currentUser.userUUID, + users, + getDeviceState: () => ({ + camera: faker.datatype.boolean(), + mic: faker.datatype.boolean(), + }), + getVolumeLevel: () => faker.datatype.number({ min: 0, max: 1, precision: 0.01 }), +}; diff --git a/packages/flat-components/src/components/UsersPanel/index.tsx b/packages/flat-components/src/components/UsersPanel/index.tsx new file mode 100644 index 00000000000..fae98b5f545 --- /dev/null +++ b/packages/flat-components/src/components/UsersPanel/index.tsx @@ -0,0 +1,236 @@ +import "./style.less"; + +import classNames from "classnames"; +import React, { useCallback, useEffect, useState } from "react"; +import { Button, Switch } from "antd"; +import { observer } from "mobx-react-lite"; +import { FlatI18nTFunction, useTranslate } from "@netless/flat-i18n"; +import { User } from "../../types/user"; +import { IconMic } from "../ClassroomPage/VideoAvatar/IconMic"; +import { SVGCamera, SVGCameraMute, SVGMicrophoneMute } from "../FlatIcons"; + +export interface UsersPanelProps { + ownerUUID: string; + userUUID: string; + users: User[]; + getDeviceState?: (userUUID: string) => { camera: boolean; mic: boolean }; + getVolumeLevel?: (userUUID: string) => number; + onOffStageAll?: () => void; + onMuteAll?: () => void; + onStaging?: (userUUID: string, isOnStage: boolean) => void; + onWhiteboard?: (userUUID: string, enabled: boolean) => void; + onDeviceState?: (userUUID: string, camera: boolean, mic: boolean) => void; +} + +export const UsersPanel = /* @__PURE__ */ observer(function UsersPanel({ + ownerUUID, + userUUID, + users, + getDeviceState, + getVolumeLevel, + onOffStageAll, + onMuteAll, + onStaging, + onWhiteboard, + onDeviceState, +}) { + const t = useTranslate(); + + const isCreator = ownerUUID === userUUID; + const owner = users.find(user => user.userUUID === ownerUUID); + const students = users.filter(user => user.userUUID !== ownerUUID); + + return ( +
+
+ {owner && ( + <> + + + + {t("teacher")}: + {owner.name} + + )} + + {isCreator && ( + <> + + + + )} +
+
+ + + + + + + + + + + + + {students.map(user => ( + + ))} + +
+ {t("members")} ({students.length}) + {t("staging")}{t("whiteboard-access")}{t("camera")}{t("microphone")} + {t("raise-hand")} ( + {students.filter(user => user.isRaiseHand).length}) +
+
+
+ ); +}); + +interface RowProps { + t: FlatI18nTFunction; + isCreator: boolean; + userUUID: string; + user: User; + getDeviceState: UsersPanelProps["getDeviceState"]; + getVolumeLevel: UsersPanelProps["getVolumeLevel"]; + onStaging: UsersPanelProps["onStaging"]; + onWhiteboard: UsersPanelProps["onWhiteboard"]; + onDeviceState: UsersPanelProps["onDeviceState"]; +} + +const Row = /* @__PURE__ */ observer(function Row({ + t, + isCreator, + userUUID, + user, + getDeviceState, + getVolumeLevel: getAnyVolumeLevel, + onStaging, + onWhiteboard, + onDeviceState, +}: RowProps): React.ReactElement { + const [camera, setCamera] = useState(false); + const [mic, setMic] = useState(false); + const getVolumeLevel = useCallback(() => { + return getAnyVolumeLevel?.(user.userUUID) || 0; + }, [getAnyVolumeLevel, user.userUUID]); + + useEffect(() => { + if (getDeviceState) { + const timer = setInterval(() => { + const { camera, mic } = getDeviceState(user.userUUID); + setCamera(camera); + setMic(mic); + }, 500); + return () => clearInterval(timer); + } + return; + }, [getDeviceState, user.userUUID]); + + const isSelf = userUUID === user.userUUID; + + return ( + + + + + + + {user.name} + + + + onStaging?.(user.userUUID, checked)} + /> + + + onWhiteboard?.(user.userUUID, checked)} + /> + + + {user.isSpeak && getDeviceState ? ( + + ) : ( + -/- + )} + + + {user.isSpeak && getDeviceState ? ( + + ) : ( + -/- + )} + + + {user.isRaiseHand ? ( + <> + + + / + + + + ) : ( + -/- + )} + + + ); +}); diff --git a/packages/flat-components/src/components/UsersPanel/style.less b/packages/flat-components/src/components/UsersPanel/style.less new file mode 100644 index 00000000000..c7da59be82e --- /dev/null +++ b/packages/flat-components/src/components/UsersPanel/style.less @@ -0,0 +1,165 @@ +@import "../../theme/fancy-scrollbar.less"; + +.users-panel { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-flow: column nowrap; +} + +.users-panel-headline { + flex-shrink: 0; + display: flex; + align-items: center; + padding: 12px 16px; + gap: 8px; +} + +.users-panel-splitter { + flex: 1; +} + +.users-panel-headline-avatar, +.users-panel-list-avatar { + display: inline-block; + width: 24px; + height: 24px; + border-radius: 50%; + overflow: hidden; + font-size: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.users-panel-btn { + color: var(--text); + height: 24px; + padding: 0 15px; + line-height: 20px; +} + +.users-panel-list-wrapper { + flex: 1; + overflow: auto; + display: flex; + flex-flow: column nowrap; + + .fancy-scrollbar-mixin(); +} + +.users-panel-list { + thead { + height: 40px; + background-color: #F9F9F9; + box-shadow: inset 0px -1px 0px #E5E8F0; + } + th { + text-align: left; + padding: 8px 0 8px 16px; + min-width: 128px; + font-weight: 400; + } + td { + text-align: left; + padding: 12px 0 12px 16px; + } +} + +.users-panel-list-item { + font-size: 0; + position: relative; + &::after { + content: ""; + position: absolute; + left: 16px; + right: 16px; + bottom: -0.5px; + height: 1px; + background-color: var(--grey-1); + } +} + +.users-panel-list-avatar-name { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.users-panel-list-name { + font-size: 14px; +} + +.users-panel-btn-group { + white-space: nowrap; +} + +.users-panel-small-btn { + padding: 0; + + &.is-cancel { + color: var(--text-weak); + } +} + +.users-panel-media-btn { + appearance: none; + border: none; + padding: 0; + background: none; + border-radius: 50%; + overflow: hidden; + font-size: 0; + vertical-align: text-top; + color: var(--text-weak); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.8; + } + + &.is-disabled { + color: var(--danger); + } + + &.is-mic { + background: rgba(0, 0, 0, 0.5); + &.is-muted { + background: none; + } + } +} + +.users-panel-small-btn-splitter { + font-size: 14px; + font-family: monospace; + padding: 0 4px; + color: var(--text-weak); + + &.is-disabled { + color: var(--text-weaker); + } +} + +.users-panel-media-off { + font-size: 14px; + font-family: monospace; + color: var(--text-weaker); +} + +.flat-color-scheme-dark { + .users-panel-list { + thead { + background-color: var(--grey-9); + box-shadow: inset 0px -1px 0px var(--grey-8); + } + } + .users-panel-list-item::after { + background-color: var(--grey-9); + } +} diff --git a/packages/flat-components/src/index.ts b/packages/flat-components/src/index.ts index 7502075b757..5bfa5f3a6cf 100644 --- a/packages/flat-components/src/index.ts +++ b/packages/flat-components/src/index.ts @@ -4,12 +4,16 @@ export * from "./utils/hooks"; export * from "./utils/errorTip"; export * from "./components/ChatPanel"; export * from "./components/ClassroomPage"; +export * from "./components/ClassroomPage/CloudRecordBtn"; +export * from "./components/ClassroomPage/Timer"; export * from "./components/CloudStorage"; +export * from "./components/DeviceTestPage"; export * from "./components/EditRoomPage"; export * from "./components/ErrorPage"; -export * from "./components/FilePreview/FilePreviewImage"; export * from "./components/FilePreview/FilePreviewAudio"; +export * from "./components/FilePreview/FilePreviewImage"; export * from "./components/FilePreview/FilePreviewVideo"; +export * from "./components/FlatIcons"; export * from "./components/FlatThemeProvider"; export * from "./components/HomePage"; export * from "./components/InviteModal"; @@ -18,18 +22,15 @@ export * from "./components/LoginPage"; export * from "./components/MainPageLayout"; export * from "./components/MainPageLayoutHorizontal"; export * from "./components/PeriodicRoomPage"; +export * from "./components/PresetsModal"; export * from "./components/RemoveHistoryRoomModal"; export * from "./components/RemoveRoomModal"; -export * from "./components/SaveAnnotationModal"; -export * from "./components/PresetsModal"; export * from "./components/RoomDetailPage"; export * from "./components/RoomStatusElement"; -export * from "./containers/CloudStorageContainer"; -export * from "./components/DeviceTestPage"; -export * from "./components/ClassroomPage/CloudRecordBtn"; -export * from "./components/ClassroomPage/Timer"; -export * from "./components/FlatIcons"; +export * from "./components/SaveAnnotationModal"; export * from "./components/SettingPage/AppearancePicker"; +export * from "./components/UsersPanel"; +export * from "./containers/CloudStorageContainer"; export * from "./theme/antd.mod"; export { message } from "antd"; diff --git a/packages/flat-components/src/theme/antd.mod.less b/packages/flat-components/src/theme/antd.mod.less index 4f856a26baa..dd3d98fb1d9 100644 --- a/packages/flat-components/src/theme/antd.mod.less +++ b/packages/flat-components/src/theme/antd.mod.less @@ -483,6 +483,21 @@ } } +/* ------------------------ *\ + * Switch +\* ------------------------ */ +.flat-color-scheme-dark { + .ant-switch { + background-color: rgba(255, 255, 255, 0.25); + } + .ant-switch.ant-switch-checked { + background-color: var(--primary-strong); + } + .ant-switch:focus { + box-shadow: 0px 0px 4px 0px rgba(51, 129, 255, 0.24); + } +} + /* ------------------------ *\ * Input \* ------------------------ */ diff --git a/packages/flat-components/src/theme/antd.mod.stories.tsx b/packages/flat-components/src/theme/antd.mod.stories.tsx index 395168a5295..f321b3cfcb7 100644 --- a/packages/flat-components/src/theme/antd.mod.stories.tsx +++ b/packages/flat-components/src/theme/antd.mod.stories.tsx @@ -2,7 +2,7 @@ import "./antd.mod.stories.less"; import React from "react"; import { Story, Meta } from "@storybook/react"; -import { Input, Radio, Checkbox, Button, ButtonProps, InputRef, Table } from "antd"; +import { Input, Radio, Checkbox, Button, ButtonProps, InputRef, Table, Switch } from "antd"; import { SVGChat } from "../components/FlatIcons"; import { useRef } from "@storybook/client-api"; import faker from "faker"; @@ -141,6 +141,26 @@ export const Overview: Story = () => { ); + const switchExample = ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); + const buttonExample = (
@@ -220,6 +240,7 @@ export const Overview: Story = () => {
{radioExample}
{checkboxExample}
+
{switchExample}
diff --git a/packages/flat-components/src/types/user.ts b/packages/flat-components/src/types/user.ts index 15a443c926b..16b94fed4e6 100644 --- a/packages/flat-components/src/types/user.ts +++ b/packages/flat-components/src/types/user.ts @@ -1,7 +1,10 @@ export interface User { userUUID: string; name: string; + /** is on stage */ isSpeak: boolean; + /** can operate whiteboard */ + wbOperate: boolean; isRaiseHand: boolean; avatar: string; hasLeft?: boolean; diff --git a/packages/flat-i18n/locales/en.json b/packages/flat-i18n/locales/en.json index 4c6a7cd0880..5932e8922b2 100644 --- a/packages/flat-i18n/locales/en.json +++ b/packages/flat-i18n/locales/en.json @@ -21,13 +21,14 @@ "during-the-presentation": "(Speaking)", "end": "End", "me": "(Me)", - "messages": "Message list", + "messages": "Chat", "raise-your-hand": "Raise hand", + "raise-hand": "Raise hand", "raised-hand": "(Hand raised)", "has-left": "(Has left)", "say-something": "Say something...", "send": "send", - "teacher": "(Teacher)", + "teacher": "Teacher", "unban": "Unmuted", "users": "User list", "cancel": "Cancel", @@ -479,6 +480,8 @@ "quit-all-rooms-before-delete-account": "Please quit all rooms before deleting account.", "all-off-stage": "Down Stage All", "all-off-stage-toast": "All users are down stage", + "all-mute-mic": "Mute All", + "all-mute-mic-toast": "All users are muted", "switch-to-next-color": "Switch to next color", "switch-to-previous-color": "Switch to previous color", "pencil-tool-draws-circle": "Pencil tool draws circle", @@ -553,6 +556,18 @@ "new-directory": "New Directory", "local-file": "Local File", "new": "New", - "error-code-error": "An error occurred, error code: {code}", - "unknown-error": "Unknown error" + "error-code-error": "An error occurred, error code: {{code}}", + "unknown-error": "Unknown error", + "members": "Members", + "staging": "On Stage", + "whiteboard-access": "Whiteboard", + "teacher-request-camera": "Teacher requests to turn on camera", + "teacher-request-mic": "Teacher requests to turn on microphone", + "sent-invitation": "Invitation sent", + "refuse-to-turn-on-camera": "{{name}} refused to turn on camera", + "refuse-to-turn-on-mic": "{{name}} refused to turn on microphone", + "has-turn-off-camera": "Camera has been turned off", + "has-turn-off-mic": "Microphone has been turned off", + "teacher-has-turn-off-camera": "Teacher has turned your camera off", + "teacher-has-turn-off-mic": "Teacher has turned your microphone off" } diff --git a/packages/flat-i18n/locales/zh-CN.json b/packages/flat-i18n/locales/zh-CN.json index 83ae6a77956..bfaf59f98a5 100644 --- a/packages/flat-i18n/locales/zh-CN.json +++ b/packages/flat-i18n/locales/zh-CN.json @@ -18,9 +18,10 @@ "say-something": "说点什么…", "ban": "全体禁言", "raise-your-hand": "举手", + "raise-hand": "举手", "all-staff-are-under-ban": "全员禁言中", "send": "发送", - "teacher": "(老师)", + "teacher": "老师", "during-the-presentation": "(发言中)", "end": "结束", "raised-hand": "(已举手)", @@ -28,7 +29,7 @@ "agree": "通过", "me": "(我)", "cancel-hand-raising": "取消举手", - "messages": "消息列表", + "messages": "聊天", "users": "用户列表", "confirmation-of-the-end-of-classes": "确定结束上课", "end-of-class-tips": "一旦结束上课,所有用户自动退出房间,并且自动结束课程和录制(如有),不能继续直播。", @@ -479,6 +480,8 @@ "quit-all-rooms-before-delete-account": "请先退出所有房间", "all-off-stage": "全体下台", "all-off-stage-toast": "全体学生已下台", + "all-mute-mic": "全体静音", + "all-mute-mic-toast": "全体学生已静音", "switch-to-next-color": "切换到下一个颜色", "switch-to-previous-color": "切换到上一个颜色", "pencil-tool-draws-circle": "画笔工具画直线", @@ -553,6 +556,18 @@ "new-directory": "新建文件夹", "local-file": "本地文件", "new": "新建", - "error-code-error": "出现错误,错误码:{code}", - "unknown-error": "未知错误" + "error-code-error": "出现错误,错误码:{{code}}", + "unknown-error": "未知错误", + "members": "学生", + "staging": "上/下台", + "whiteboard-access": "白板权限", + "teacher-request-camera": "老师请求开启摄像头", + "teacher-request-mic": "老师请求开启麦克风", + "sent-invitation": "已发送邀请", + "refuse-to-turn-on-camera": "{{name}} 拒绝开启摄像头", + "refuse-to-turn-on-mic": "{{name}} 拒绝开启麦克风", + "has-turn-off-camera": "已关闭该学生的摄像头", + "has-turn-off-mic": "已关闭该学生的麦克风", + "teacher-has-turn-off-camera": "老师已关闭你的摄像头", + "teacher-has-turn-off-mic": "老师已关闭你的麦克风" } diff --git a/packages/flat-pages/src/BigClassPage/index.tsx b/packages/flat-pages/src/BigClassPage/index.tsx index 5f59928a6da..2137af65cdf 100644 --- a/packages/flat-pages/src/BigClassPage/index.tsx +++ b/packages/flat-pages/src/BigClassPage/index.tsx @@ -36,6 +36,7 @@ import { withClassroomStore, WithClassroomStoreProps } from "../utils/with-class import { WindowsSystemBtnContext } from "../components/StoreProvider"; import { ShareScreenPicker } from "../components/ShareScreen/ShareScreenPicker"; import { ExtraPadding } from "../components/ExtraPadding"; +import { UsersButton } from "../components/UsersButton"; export type BigClassPageProps = {}; @@ -159,6 +160,8 @@ export const BigClassPage = withClassroomStore( {/* TODO: open cloud-storage sub window */} + {/* TODO: open users sub window */} + {!windowsBtn?.showWindowsBtn && ( } @@ -182,9 +185,7 @@ export const BigClassPage = withClassroomStore( return ( - } + chatSlot={} isShow={isRealtimeSideOpen} isVideoOn={classroomStore.isJoinedRTC} videoSlot={ diff --git a/packages/flat-pages/src/OneToOnePage/index.tsx b/packages/flat-pages/src/OneToOnePage/index.tsx index e532c9bc331..7aacd8835ca 100644 --- a/packages/flat-pages/src/OneToOnePage/index.tsx +++ b/packages/flat-pages/src/OneToOnePage/index.tsx @@ -175,13 +175,7 @@ export const OneToOnePage = withClassroomStore( function renderRealtimePanel(): React.ReactNode { return ( - } + chatSlot={} isShow={isRealtimeSideOpen} isVideoOn={classroomStore.isJoinedRTC} videoSlot={ diff --git a/packages/flat-pages/src/SmallClassPage/index.tsx b/packages/flat-pages/src/SmallClassPage/index.tsx index 5d78d85d78e..a0015259601 100644 --- a/packages/flat-pages/src/SmallClassPage/index.tsx +++ b/packages/flat-pages/src/SmallClassPage/index.tsx @@ -243,12 +243,7 @@ export const SmallClassPage = withClassroomStore( function renderRealtimePanel(): React.ReactNode { return ( - } + chatSlot={} isShow={isRealtimeSideOpen} isVideoOn={false} videoSlot={null} diff --git a/packages/flat-pages/src/components/ChatPanel/index.tsx b/packages/flat-pages/src/components/ChatPanel/index.tsx index e6afca8560c..682a29fe81a 100644 --- a/packages/flat-pages/src/components/ChatPanel/index.tsx +++ b/packages/flat-pages/src/components/ChatPanel/index.tsx @@ -1,63 +1,27 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { ChatPanel as ChatPanelImpl, useComputed } from "flat-components"; -import { ClassroomStore, User } from "@netless/flat-stores"; -import { generateAvatar } from "../../utils/generate-avatar"; +import { ChatPanel as ChatPanelImpl } from "flat-components"; +import { ClassroomStore } from "@netless/flat-stores"; export interface ChatPanelProps { classRoomStore: ClassroomStore; - disableEndSpeaking?: boolean; - maxSpeakingUsers?: number; } // @TODO add rtm const noop = async (): Promise => void 0; -export const ChatPanel = observer(function ChatPanel({ - classRoomStore, - disableEndSpeaking, - maxSpeakingUsers = 1, -}) { - const users = useComputed(() => { - const onStageUsers = classRoomStore.onStageUserUUIDs - .map(userUUID => classRoomStore.users.cachedUsers.get(userUUID)) - .filter((user): user is User => !!user); - const { creator, handRaisingJoiners, otherJoiners } = classRoomStore.users; - return creator - ? [...onStageUsers, ...handRaisingJoiners, creator, ...otherJoiners] - : [...onStageUsers, ...handRaisingJoiners, ...otherJoiners]; - }).get(); - - const handHandRaising = classRoomStore.users.handRaisingJoiners.length > 0; - +export const ChatPanel = observer(function ChatPanel({ classRoomStore }) { return ( classRoomStore.users.cachedUsers.get(userUUID)} - hasHandRaising={handHandRaising} isBan={classRoomStore.isBan} isCreator={classRoomStore.isCreator} loadMoreRows={noop} messages={classRoomStore.chatStore.messages} openCloudStorage={() => classRoomStore.toggleCloudStoragePanel(true)} - ownerUUID={classRoomStore.ownerUUID} unreadCount={classRoomStore.users.handRaisingJoiners.length || null} userUUID={classRoomStore.userUUID} - users={users} - withAcceptHands={ - handHandRaising && classRoomStore.onStageUserUUIDs.length < maxSpeakingUsers - } - onAcceptRaiseHand={(userUUID: string) => { - if (classRoomStore.onStageUserUUIDs.length < maxSpeakingUsers) { - classRoomStore.acceptRaiseHand(userUUID); - } - }} onBanChange={classRoomStore.onToggleBan} - onCancelAllHandRaising={classRoomStore.onCancelAllHandRaising} - onEndSpeaking={userUUID => { - void classRoomStore.onStaging(userUUID, false); - }} onMessageSend={classRoomStore.onMessageSend} /> ); diff --git a/packages/flat-pages/src/components/UsersButton.less b/packages/flat-pages/src/components/UsersButton.less new file mode 100644 index 00000000000..87b84001624 --- /dev/null +++ b/packages/flat-pages/src/components/UsersButton.less @@ -0,0 +1,63 @@ +.users-button-modal { + min-width: 800px; + min-height: 80vh; + + & > .ant-modal-content { + overflow: hidden; + height: 80vh; + display: flex; + flex-direction: column; + + & > .ant-modal-close { + top: 0; + right: 0; + + & > .ant-modal-close-x { + width: 24px; + height: 24px; + line-height: 24px; + font-size: 12px; + } + } + + & > .ant-modal-header { + padding: 0; + text-align: center; + border: none; + + & > .ant-modal-title { + font-size: 12px; + line-height: 2; + font-weight: 400; + color: #444e60; + background: #ececec; + } + } + + & > .ant-modal-body { + flex: 1; + padding: 0; + overflow: hidden; + } + + & > .ant-modal-footer { + display: none; + } + } +} + +.flat-color-scheme-dark { + .users-button-modal { + & > .ant-modal-content { + & > .ant-modal-header { + & > .ant-modal-title { + font-size: 12px; + line-height: 2; + font-weight: 400; + color: var(--text); + background: var(--grey-9); + } + } + } + } +} diff --git a/packages/flat-pages/src/components/UsersButton.tsx b/packages/flat-pages/src/components/UsersButton.tsx new file mode 100644 index 00000000000..b17715be5cc --- /dev/null +++ b/packages/flat-pages/src/components/UsersButton.tsx @@ -0,0 +1,116 @@ +import "./UsersButton.less"; + +// TODO: remove this component when multi sub window is done +import React, { useCallback, useEffect, useState } from "react"; +import { Modal } from "antd"; +import { observer } from "mobx-react-lite"; +import { useTranslate } from "@netless/flat-i18n"; +import { ClassroomStore } from "@netless/flat-stores"; +import { SVGUserGroup, TopBarRightBtn, useComputed, UsersPanel } from "flat-components"; + +interface UsersButtonProps { + classroom: ClassroomStore; +} + +export const UsersButton = observer(function UsersButton({ classroom }) { + const t = useTranslate(); + const [open, setOpen] = useState(false); + + const users = useComputed(() => { + const { creator, speakingJoiners, handRaisingJoiners, otherJoiners } = classroom.users; + return creator + ? [...speakingJoiners, ...handRaisingJoiners, creator, ...otherJoiners] + : [...speakingJoiners, ...handRaisingJoiners, ...otherJoiners]; + }).get(); + + const getDeviceState = useCallback( + (userUUID: string): { camera: boolean; mic: boolean } => { + return classroom.deviceStateStorage?.state[userUUID] ?? { camera: false, mic: false }; + }, + [classroom.deviceStateStorage], + ); + + const getVolumeLevel = useCallback( + (userUUID: string): number => { + const uid = classroom.users.cachedUsers.get(userUUID)?.rtcUID; + return classroom.rtc.getVolumeLevel(uid); + }, + [classroom.rtc, classroom.users.cachedUsers], + ); + + useEffect(() => { + if (classroom.isRequestingCamera) { + const handle = Modal.confirm({ + content: t("teacher-request-camera"), + onOk() { + const { mic } = getDeviceState(classroom.userUUID); + classroom.updateDeviceState(classroom.userUUID, true, mic); + classroom.toggleRequestingDevice(false, undefined); + classroom.replyRequestingDevice("camera", true); + }, + onCancel() { + classroom.toggleRequestingDevice(false, undefined); + classroom.replyRequestingDevice("camera", false); + }, + }); + return () => handle.destroy(); + } + return; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, classroom.isRequestingCamera, getDeviceState]); + + useEffect(() => { + if (classroom.isRequestingMic) { + const handle = Modal.confirm({ + content: t("teacher-request-mic"), + onOk() { + const { camera } = getDeviceState(classroom.userUUID); + classroom.updateDeviceState(classroom.userUUID, camera, true); + classroom.toggleRequestingDevice(undefined, false); + classroom.replyRequestingDevice("mic", true); + }, + onCancel() { + classroom.toggleRequestingDevice(undefined, false); + classroom.replyRequestingDevice("mic", false); + }, + }); + return () => handle.destroy(); + } + return; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, classroom.isRequestingMic, getDeviceState]); + + const hasRaisingHand = !!classroom.classroomStorage?.state.raiseHandUsers.length; + + return ( + <> + } + title={t("users")} + onClick={() => setOpen(!open)} + /> + setOpen(false)} + > + + + + ); +}); diff --git a/packages/flat-pages/src/components/Whiteboard.less b/packages/flat-pages/src/components/Whiteboard.less index 5f001e4f50d..083ffdc4f3b 100644 --- a/packages/flat-pages/src/components/Whiteboard.less +++ b/packages/flat-pages/src/components/Whiteboard.less @@ -1,3 +1,5 @@ +@import "flat-components/theme/fancy-scrollbar.less"; + .whiteboard-container { position: relative; width: 100%; @@ -77,10 +79,67 @@ width: 48px; height: 48px; right: 8px; - bottom: 32px; + bottom: 48px; z-index: 3; } +.hand-raising-panel { + display: none; + position: absolute; + right: 64px; + bottom: 48px; + width: 200px; + max-height: 400px; + border: 1px solid var(--grey-3); + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 8px 4px; + background-color: #fff; + z-index: 4; + + .fancy-scrollbar-mixin(); + + &.is-active { + display: block; + } +} + +.hand-raising-user { + display: flex; + align-items: center; + gap: 16px; + font-size: 0; + padding: 8px 12px; + border-radius: 6px; + + &:hover { + background-color: var(--primary-weaker); + } +} + +.hand-raising-user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + overflow: hidden; +} + +.hand-raising-user-name { + font-size: 14px; +} + +.hand-raising-btn { + flex: 1; + text-align: right; + font-size: 14px; + color: var(--primary); + border: none; + outline: none; + padding: 0 8px 0 0; + background: none; + cursor: pointer; +} + .whiteboard-scroll-page { position: absolute; top: 50%; @@ -134,6 +193,15 @@ --fastboard-bg-color: var(--grey-9); --fastboard-border-color: var(--grey-8); } + + .hand-raising-panel { + background-color: var(--grey-9); + border-color: var(--grey-8); + } + + .hand-raising-user:hover { + background-color: var(--grey-8); + } } .telebox-color-scheme-dark.telebox-manager-container { diff --git a/packages/flat-pages/src/components/Whiteboard.tsx b/packages/flat-pages/src/components/Whiteboard.tsx index 890a4daac62..b13a29ef0a9 100644 --- a/packages/flat-pages/src/components/Whiteboard.tsx +++ b/packages/flat-pages/src/components/Whiteboard.tsx @@ -8,6 +8,7 @@ import { DarkModeContext, PresetsModal, RaiseHand, + RaisingHand, SaveAnnotationModal, SaveAnnotationModalProps, } from "flat-components"; @@ -189,6 +190,15 @@ export const Whiteboard = observer(function Whiteboard({ /> )} + {whiteboardStore.isCreator && + classRoomStore.users.handRaisingJoiners.length > 0 && ( +
+ +
+ )}
(function Whiteboard({ > {renderScrollPage(t, page, maxPage)}
+
0 && + classRoomStore.isHandRaisingPanelVisible, + })} + > + {classRoomStore.users.handRaisingJoiners.map(user => ( +
+ avatar + {user.name} + +
+ ))} +
)} ; diff --git a/packages/flat-services/src/services/whiteboard/whiteboard.ts b/packages/flat-services/src/services/whiteboard/whiteboard.ts index 39ec2caaa7a..edca40b15b7 100644 --- a/packages/flat-services/src/services/whiteboard/whiteboard.ts +++ b/packages/flat-services/src/services/whiteboard/whiteboard.ts @@ -30,6 +30,9 @@ export interface IServiceWhiteboardJoinRoomConfig extends IService { export interface IServiceWhiteboard$Val { readonly phase$: ReadonlyVal; + /** able to write data to whiteboard service */ + readonly isWritable$: ReadonlyVal; + /** able to draw on the whiteboard, requires isWritable = true */ readonly allowDrawing$: ReadonlyVal; } @@ -44,8 +47,12 @@ export abstract class IServiceWhiteboard { public abstract readonly phase: IServiceWhiteboardPhase; + public abstract readonly isWritable: boolean; + public abstract readonly allowDrawing: boolean; + public abstract setIsWritable(isWritable: boolean): void; + public abstract setAllowDrawing(allowDrawing: boolean): void; public abstract joinRoom(config: IServiceWhiteboardJoinRoomConfig): Promise; diff --git a/packages/flat-stores/src/classroom-store/index.ts b/packages/flat-stores/src/classroom-store/index.ts index a8c596252d1..23be04616a6 100644 --- a/packages/flat-stores/src/classroom-store/index.ts +++ b/packages/flat-stores/src/classroom-store/index.ts @@ -10,7 +10,6 @@ import { RoomStatus, RoomType, checkRTMCensor, - CloudRecordStartPayload, } from "@netless/flat-server-api"; import { FlatI18n } from "@netless/flat-i18n"; import { errorTips, message } from "flat-components"; @@ -43,10 +42,6 @@ export interface ClassroomStoreConfig { recording: IServiceRecording; } -export type RecordingConfig = Required< - CloudRecordStartPayload["agoraData"]["clientRequest"] ->["recordingConfig"]; - export type DeviceStateStorageState = Record; export type ClassroomStorageState = { ban: boolean; @@ -54,6 +49,7 @@ export type ClassroomStorageState = { shareScreen: boolean; }; export type OnStageUsersStorageState = Record; +export type WhiteboardStorageState = Record; export class ClassroomStore { private readonly sideEffect = new SideEffectManager(); @@ -72,8 +68,12 @@ export class ClassroomStore { public isRecordingLoading = false; /** is user login on other device */ public isRemoteLogin = false; + /** is being requested for turning on device */ + public isRequestingCamera = false; + public isRequestingMic = false; public isCloudStoragePanelVisible = false; + public isHandRaisingPanelVisible = false; public roomStatusLoading = RoomStatusLoadingType.Null; @@ -100,6 +100,8 @@ export class ClassroomStore { public deviceStateStorage?: Storage; public classroomStorage?: Storage; public onStageUsersStorage?: Storage; + /** users that can operate the whiteboard */ + public whiteboardStorage?: Storage; public readonly users: UserStore; @@ -187,11 +189,37 @@ export class ClassroomStore { ); if (!this.isCreator) { - this.rtm.events.on("update-room-status", event => { - if (event.roomUUID === this.roomUUID && event.senderID === this.ownerUUID) { - this.updateRoomStatus(event.status); - } - }); + this.sideEffect.addDisposer( + this.rtm.events.on("update-room-status", event => { + if (event.roomUUID === this.roomUUID && event.senderID === this.ownerUUID) { + this.updateRoomStatus(event.status); + } + }), + ); + + this.sideEffect.addDisposer( + this.rtm.events.on("request-device", event => { + if (event.roomUUID === this.roomUUID && event.senderID === this.ownerUUID) { + this.toggleRequestingDevice( + event.deviceState.camera, + event.deviceState.mic, + ); + } + }), + ); + + this.sideEffect.addDisposer( + this.rtm.events.on("notify-device-off", event => { + if (event.roomUUID === this.roomUUID && event.senderID === this.ownerUUID) { + if (event.deviceState.camera === false) { + message.info(FlatI18n.t("teacher-has-turn-off-camera")); + } + if (event.deviceState.mic === false) { + message.info(FlatI18n.t("teacher-has-turn-off-mic")); + } + } + }), + ); } if (this.isCreator) { @@ -223,6 +251,19 @@ export class ClassroomStore { } }), ); + this.sideEffect.addDisposer( + this.rtm.events.on("request-device-response", event => { + if (event.roomUUID === this.roomUUID) { + const name = this.users.cachedUsers.get(event.userUUID)?.name; + if (event.deviceState.camera === false) { + message.info(FlatI18n.t("refuse-to-turn-on-camera", { name })); + } + if (event.deviceState.mic === false) { + message.info(FlatI18n.t("refuse-to-turn-on-mic", { name })); + } + } + }), + ); this.sideEffect.addDisposer( reaction( () => this.isScreenSharing || this.isRemoteScreenSharing, @@ -304,9 +345,14 @@ export class ClassroomStore { "onStageUsers", {}, ); + const whiteboardStorage = fastboard.syncedStore.connectStorage( + "whiteboard", + {}, + ); this.deviceStateStorage = deviceStateStorage; this.classroomStorage = classroomStorage; this.onStageUsersStorage = onStageUsersStorage; + this.whiteboardStorage = whiteboardStorage; if (this.isCreator) { this.updateDeviceState( @@ -315,7 +361,13 @@ export class ClassroomStore { Boolean(preferencesStore.autoMicOn), ); } else { - this.whiteboardStore.updateWritable(Boolean(onStageUsersStorage.state[this.userUUID])); + this.whiteboardStore.updateWritable( + Boolean( + onStageUsersStorage.state[this.userUUID] || + whiteboardStorage.state[this.userUUID], + ), + ); + this.whiteboardStore.updateAllowDrawing(whiteboardStorage.state[this.userUUID]); } this._updateIsBan(classroomStorage.state.ban); @@ -350,6 +402,7 @@ export class ClassroomStore { user.camera = false; user.isRaiseHand = raiseHandUsers.has(user.userUUID); } + user.wbOperate = !!whiteboardStorage.state[user.userUUID]; }); this.sideEffect.addDisposer( @@ -359,6 +412,7 @@ export class ClassroomStore { if (user.userUUID === userUUID) { if (userUUID === this.ownerUUID || onStageUsersStorage.state[userUUID]) { user.isSpeak = true; + user.wbOperate = !!whiteboardStorage.state[userUUID]; user.isRaiseHand = false; const deviceState = deviceStateStorage.state[user.userUUID]; if (deviceState) { @@ -370,6 +424,11 @@ export class ClassroomStore { } return false; } + // not on stage, but has whiteboard access + if (whiteboardStorage.state[userUUID]) { + user.wbOperate = true; + return false; + } } return true; }); @@ -436,7 +495,10 @@ export class ClassroomStore { if (!this.isCreator) { const isJoinerOnStage = Boolean(onStageUsersStorage.state[this.userUUID]); - await this.whiteboardStore.updateWritable(isJoinerOnStage); + this.whiteboardStore.updateWritable( + isJoinerOnStage || whiteboardStorage.state[this.userUUID], + ); + this.whiteboardStore.updateAllowDrawing(whiteboardStorage.state[this.userUUID]); // @FIXME add reliable way to ensure writable is set if (isJoinerOnStage && !fastboard.syncedStore.isRoomWritable) { @@ -467,6 +529,23 @@ export class ClassroomStore { updateUserStagingState(); this.sideEffect.addDisposer(onStageUsersStorage.on("stateChanged", updateUserStagingState)); + this.sideEffect.addDisposer( + whiteboardStorage.on("stateChanged", () => { + this.users.updateUsers(user => { + user.wbOperate = + user.userUUID === this.ownerUUID || + !!whiteboardStorage.state[user.userUUID]; + }); + this.whiteboardStore.updateWritable( + this.isCreator || + onStageUsersStorage.state[this.userUUID] || + whiteboardStorage.state[this.userUUID], + ); + this.whiteboardStore.updateAllowDrawing( + this.isCreator || whiteboardStorage.state[this.userUUID], + ); + }), + ); this.sideEffect.addDisposer( autorun(() => { @@ -553,6 +632,7 @@ export class ClassroomStore { this.deviceStateStorage = undefined; this.onStageUsersStorage = undefined; this.classroomStorage = undefined; + this.whiteboardStorage = undefined; } public startClass = (): Promise => this.switchRoomStatus(RoomStatus.Started); @@ -692,6 +772,10 @@ export class ClassroomStore { } }; + public onToggleHandRaisingPanel = (): void => { + this.isHandRaisingPanelVisible = !this.isHandRaisingPanelVisible; + }; + public onToggleBan = (): void => { if (this.isCreator && this.classroomStorage?.isWritable) { this.classroomStorage.setState({ ban: !this.classroomStorage.state.ban }); @@ -712,6 +796,7 @@ export class ClassroomStore { } if (this.isCreator) { this.onStageUsersStorage.setState({ [userUUID]: onStage }); + this.isHandRaisingPanelVisible = false; } else { // joiner can only turn off speaking if (!onStage && userUUID === this.userUUID) { @@ -721,34 +806,134 @@ export class ClassroomStore { if (!onStage && (!this.isCreator || userUUID !== this.userUUID)) { this.updateDeviceState(userUUID, false, false); } + if (this.classroomStorage?.state.raiseHandUsers.includes(userUUID)) { + const raiseHandUsers = this.classroomStorage.state.raiseHandUsers; + this.classroomStorage.setState({ + raiseHandUsers: raiseHandUsers.filter(id => id !== userUUID), + }); + } + }; + + public offStageAll = async (): Promise => { + if (this.classMode === ClassModeType.Interaction || !this.onStageUsersStorage?.isWritable) { + return; + } + if (this.isCreator) { + this.onStageUsersStorage.resetState(); + this.whiteboardStorage?.resetState(); + message.info(FlatI18n.t("all-off-stage-toast")); + } + }; + + public muteAll = async (): Promise => { + if (this.isCreator && this.deviceStateStorage && this.deviceStateStorage.isWritable) { + const state = this.deviceStateStorage.state; + const payload: Partial = {}; + for (const userUUID in state) { + if (state[userUUID].mic) { + payload[userUUID] = { ...state[userUUID], mic: false }; + } + } + this.deviceStateStorage.setState(payload); + message.info(FlatI18n.t("all-mute-mic-toast")); + } + }; + + public authorizeWhiteboard = async (userUUID: string, enabled: boolean): Promise => { + if ( + this.classMode === ClassModeType.Interaction || + userUUID === this.ownerUUID || + !this.whiteboardStorage?.isWritable + ) { + return; + } + if (this.isCreator) { + this.whiteboardStorage.setState({ [userUUID]: enabled }); + } else { + // joiner can only turn off drawing + if (!enabled && userUUID === this.userUUID) { + this.whiteboardStorage.setState({ [userUUID]: false }); + } + } }; /** joiner updates own camera and mic state */ public updateDeviceState = (userUUID: string, camera: boolean, mic: boolean): void => { if (this.deviceStateStorage?.isWritable && (this.userUUID === userUUID || this.isCreator)) { - const deviceState = this.deviceStateStorage.state[userUUID]; - if (deviceState) { - // creator can turn off joiner's camera and mic - // creator can request joiner to turn on camera and mic - if (userUUID !== this.userUUID) { - if (camera && !deviceState.camera) { - camera = deviceState.camera; - } - - if (mic && !deviceState.mic) { - mic = deviceState.mic; - } + const deviceState = this.deviceStateStorage.state[userUUID] || { + camera: false, + mic: false, + }; + if (camera === deviceState.camera && mic === deviceState.mic) { + return; + } + let shouldNotify = false; + // creator can request joiner to turn on camera and mic + if (this.isCreator && userUUID !== this.userUUID) { + if (camera && !deviceState.camera) { + void this.rtm.sendPeerCommand( + "request-device", + { roomUUID: this.roomUUID, camera }, + userUUID, + ); + message.info(FlatI18n.t("sent-invitation")); + return; } - if (camera === deviceState.camera && mic === deviceState.mic) { + if (mic && !deviceState.mic) { + void this.rtm.sendPeerCommand( + "request-device", + { roomUUID: this.roomUUID, mic }, + userUUID, + ); + message.info(FlatI18n.t("sent-invitation")); return; } + shouldNotify = true; } + // creator can turn off joiner's camera and mic this.deviceStateStorage.setState({ [userUUID]: camera || mic ? { camera, mic } : undefined, }); + if (shouldNotify) { + if (!camera && deviceState.camera) { + message.info(FlatI18n.t("has-turn-off-camera")); + void this.rtm.sendPeerCommand( + "notify-device-off", + { roomUUID: this.roomUUID, camera }, + userUUID, + ); + } + if (!mic && deviceState.mic) { + message.info(FlatI18n.t("has-turn-off-mic")); + void this.rtm.sendPeerCommand( + "notify-device-off", + { roomUUID: this.roomUUID, mic }, + userUUID, + ); + } + } } }; + public toggleRequestingDevice(camera: boolean | undefined, mic: boolean | undefined): void { + if (camera !== undefined) { + this.isRequestingCamera = camera; + } + if (mic !== undefined) { + this.isRequestingMic = mic; + } + } + + public replyRequestingDevice(device: "camera" | "mic", enabled: boolean): void { + if (!this.isCreator) { + void this.rtm.sendPeerCommand( + "request-device-response", + { roomUUID: this.roomUUID, [device]: enabled }, + this.ownerUUID, + ); + } + } + private async switchRoomStatus(roomStatus: RoomStatus): Promise { if (!this.isCreator || this.roomStatusLoading !== RoomStatusLoadingType.Null) { return; diff --git a/packages/flat-stores/src/user-store.ts b/packages/flat-stores/src/user-store.ts index 3783213bdba..d0d945ad71e 100644 --- a/packages/flat-stores/src/user-store.ts +++ b/packages/flat-stores/src/user-store.ts @@ -10,6 +10,7 @@ export interface User { camera: boolean; mic: boolean; isSpeak: boolean; + wbOperate: boolean; isRaiseHand: boolean; hasLeft: boolean; } @@ -237,6 +238,7 @@ export class UserStore { camera: userUUID === this.userUUID ? preferencesStore.autoCameraOn : false, mic: userUUID === this.userUUID ? preferencesStore.autoMicOn : false, isSpeak: userUUID === this.userUUID && this.isCreator, + wbOperate: userUUID === this.userUUID && this.isCreator, isRaiseHand: false, hasLeft: !this.isInRoom(userUUID), }), diff --git a/packages/flat-stores/src/whiteboard-store/index.ts b/packages/flat-stores/src/whiteboard-store/index.ts index b4f532dc985..eb33c8ba4de 100644 --- a/packages/flat-stores/src/whiteboard-store/index.ts +++ b/packages/flat-stores/src/whiteboard-store/index.ts @@ -111,9 +111,13 @@ export class WhiteboardStore { this.windowManager = manager; }; - public updateWritable = async (isWritable: boolean): Promise => { + public updateWritable = (isWritable: boolean): void => { this.isWritable = isWritable; - this.whiteboard.setAllowDrawing(isWritable); + this.whiteboard.setIsWritable(isWritable); + }; + + public updateAllowDrawing = (allowDrawing: boolean): void => { + this.whiteboard.setAllowDrawing(allowDrawing); }; public setFileOpen = (open: boolean): void => { diff --git a/service-providers/agora-rtm/src/rtm.ts b/service-providers/agora-rtm/src/rtm.ts index 2349ededf94..dcf2df0c802 100644 --- a/service-providers/agora-rtm/src/rtm.ts +++ b/service-providers/agora-rtm/src/rtm.ts @@ -268,6 +268,30 @@ export class AgoraRTM extends IServiceTextChat { }); break; } + case "request-device": { + this.events.emit("request-device", { + roomUUID, + senderID, + deviceState: command.v, + }); + break; + } + case "request-device-response": { + this.events.emit("request-device-response", { + roomUUID, + userUUID: senderID, + deviceState: command.v, + }); + break; + } + case "notify-device-off": { + this.events.emit("notify-device-off", { + roomUUID, + senderID, + deviceState: command.v, + }); + break; + } } } catch (e) { console.error(e); diff --git a/service-providers/fastboard/src/index.ts b/service-providers/fastboard/src/index.ts index 7315d878025..2c4af395fa0 100644 --- a/service-providers/fastboard/src/index.ts +++ b/service-providers/fastboard/src/index.ts @@ -52,6 +52,7 @@ export class Fastboard extends IServiceWhiteboard { public readonly $Val: Readonly<{ phase$: ReadonlyVal; + isWritable$: Val; allowDrawing$: Val; }>; @@ -63,6 +64,14 @@ export class Fastboard extends IServiceWhiteboard { return this.$Val.phase$.value; } + public get isWritable(): boolean { + return this.$Val.isWritable$.value; + } + + public setIsWritable(isWritable: boolean): void { + this.$Val.isWritable$.setValue(isWritable); + } + public get allowDrawing(): boolean { return this.$Val.allowDrawing$.value; } @@ -82,6 +91,7 @@ export class Fastboard extends IServiceWhiteboard { this._app$ = new Val(null); this._el$ = new Val(null); this._roomPhase$ = new Val(RoomPhase.Disconnected); + const isWritable$ = new Val(false); const allowDrawing$ = new Val(false); const phase$ = combine([this._app$, this._roomPhase$], ([app, phase]) => @@ -90,6 +100,7 @@ export class Fastboard extends IServiceWhiteboard { this.$Val = { phase$, + isWritable$, allowDrawing$, }; this.sideEffect.push(() => { @@ -109,12 +120,18 @@ export class Fastboard extends IServiceWhiteboard { return; } room.disableDeviceInputs = !allowDrawing; + }), + combine([this._app$, isWritable$]).subscribe(([app, isWritable]) => { + const room = app?.room; + if (!room) { + return; + } // room.isWritable follows allowDrawing for now - if (allowDrawing !== room.isWritable) { + if (isWritable !== room.isWritable) { this.asyncSideEffect.add(async () => { let isDisposed = false; try { - if (allowDrawing) { + if (isWritable) { await app.room.setWritable(true); } else { // wait until room isWritable