diff --git a/audio-livecast.config.json b/audio-livecast.config.json index 9becbcdca..02ec4c190 100644 --- a/audio-livecast.config.json +++ b/audio-livecast.config.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-audio-livecast-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": false, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": true, "RAISE_HAND": true, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": true, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#FFFFFF", "FONT_COLOR": "#FFFFFF", - "BG": "", "BACKGROUND_COLOR": "#111111", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#00000040", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#333333", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#242529", "TOOLBAR_COLOR": "#111111", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false } diff --git a/audio-livecast.config.light.json b/audio-livecast.config.light.json index f0536c576..4a73960ec 100644 --- a/audio-livecast.config.light.json +++ b/audio-livecast.config.light.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-audio-livecast-light-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": false, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "appbuilder-dev-qa-test-recording", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": true, "RAISE_HAND": true, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": true, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#19394D", "FONT_COLOR": "#333333", - "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", "BACKGROUND_COLOR": "#FFFFFF", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#80808080", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#FFFFFF", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#EBF1F5", "TOOLBAR_COLOR": "#FFFFFF00", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false } diff --git a/config.json b/config.json index ceaa4b278..f6f2be54c 100644 --- a/config.json +++ b/config.json @@ -1,22 +1,5 @@ { - "PROJECT_ID": "49c705c1c9efb71000d7", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "APP_CERTIFICATE": "6545ecd19d554737be863eb1eaaf9cee", - "CUSTOMER_ID": "40b25d211955491580720cb54099c3c4", - "CUSTOMER_CERTIFICATE": "555d0c42035c450a9b562ec20773d6b4", - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "FRONTEND_ENDPOINT": "https://app-builder-core-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": true, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, "ENABLE_APPLE_OAUTH": false, @@ -26,6 +9,11 @@ "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", "BUCKET_NAME": "", "BUCKET_ACCESS_KEY": "", "BUCKET_ACCESS_SECRET": "", @@ -38,19 +26,40 @@ "PSTN_EMAIL": "", "PSTN_ACCOUNT": "", "PSTN_PASSWORD": "", - "RECORDING_REGION": 0, + "RECORDING_REGION": 3, "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "LOG_ENABLED": true, "EVENT_MODE": false, "RAISE_HAND": false, "AUDIO_ROOM": false, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", "BG": "", "PRIMARY_FONT_COLOR": "#363636", "SECONDARY_FONT_COLOR": "#FFFFFF", - "PROFILE": "720p_3", + "SENTRY_DSN": "", + "PROFILE": "480p_8", "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", @@ -58,7 +67,7 @@ "FONT_COLOR": "#FFFFFF", "BACKGROUND_COLOR": "#111111", "VIDEO_AUDIO_TILE_COLOR": "#222222", - "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#000004", + "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#00000040", "VIDEO_AUDIO_TILE_TEXT_COLOR": "#FFFFFF", "VIDEO_AUDIO_TILE_AVATAR_COLOR": "#BDD0DB", "SEMANTIC_ERROR": "#FF414D", @@ -76,28 +85,21 @@ "ICON_BG_COLOR": "#242529", "TOOLBAR_COLOR": "#111111", "ACTIVE_SPEAKER": true, - "ENABLE_IDP_AUTH": false, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, "ENABLE_WAITING_ROOM": false, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_CONVERSATIONAL_AI": false, - "CUSTOMIZE_AGENT": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": true } diff --git a/config.light.json b/config.light.json index 01c1388e2..ac7ea488f 100644 --- a/config.light.json +++ b/config.light.json @@ -1,23 +1,6 @@ { - "PROJECT_ID": "49c705c1c9efb71000d7", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "APP_CERTIFICATE": "6545ecd19d554737be863eb1eaaf9cee", - "CUSTOMER_ID": "40b25d211955491580720cb54099c3c4", - "CUSTOMER_CERTIFICATE": "555d0c42035c450a9b562ec20773d6b4", - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "FRONTEND_ENDPOINT": "https://app-builder-core-light-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": true, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", - "ENCRYPTION_ENABLED": false, + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", + "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, @@ -26,7 +9,12 @@ "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "BUCKET_NAME": "", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "appbuilder-dev-qa-test-recording", "BUCKET_ACCESS_KEY": "", "BUCKET_ACCESS_SECRET": "", "GOOGLE_CLIENT_SECRET": "", @@ -38,17 +26,41 @@ "PSTN_EMAIL": "", "PSTN_ACCOUNT": "", "PSTN_PASSWORD": "", - "RECORDING_REGION": 0, + "RECORDING_REGION": 3, "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "LOG_ENABLED": true, "EVENT_MODE": false, "RAISE_HAND": false, "AUDIO_ROOM": false, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", - "PROFILE": "720p_3", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#19394D", @@ -70,32 +82,24 @@ "CARD_LAYER_4_COLOR": "#FFFFFF", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#EBF1F5", "TOOLBAR_COLOR": "#FFFFFF00", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, "ENABLE_WAITING_ROOM": false, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_WAITING_ROOM_AUTO_APPROVAL": true, - "ENABLE_WAITING_ROOM_AUTO_REQUEST": true, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": true } diff --git a/live-streaming.config.json b/live-streaming.config.json index 21396c96a..538c7beb5 100644 --- a/live-streaming.config.json +++ b/live-streaming.config.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-live-streaming-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": true, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": true, "RAISE_HAND": true, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": false, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#FFFFFF", "FONT_COLOR": "#FFFFFF", - "BG": "", "BACKGROUND_COLOR": "#111111", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#00000040", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#333333", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#242529", "TOOLBAR_COLOR": "#111111", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false } diff --git a/live-streaming.config.light.json b/live-streaming.config.light.json index 37e46dc0b..e0ec6edae 100644 --- a/live-streaming.config.light.json +++ b/live-streaming.config.light.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-live-streaming-light-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": true, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "appbuilder-dev-qa-test-recording", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": true, "RAISE_HAND": true, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": false, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#19394D", "FONT_COLOR": "#333333", - "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", "BACKGROUND_COLOR": "#FFFFFF", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#80808080", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#FFFFFF", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#EBF1F5", "TOOLBAR_COLOR": "#FFFFFF00", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false } diff --git a/package.json b/package.json index 8f160cae9..6ce78b2b7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "scripts": { "vercel-build": "npm run dev-setup && cd template && npm run web:build && cd .. && npm run copy-vercel", - "uikit": "rm -rf template/agora-rn-uikit && git clone https://github.com/AgoraIO-Community/appbuilder-ui-kit.git template/agora-rn-uikit && cd template/agora-rn-uikit && git checkout appbuilder-uikit-3.1.8", + "uikit": "rm -rf template/agora-rn-uikit && git clone https://github.com/AgoraIO-Community/appbuilder-ui-kit.git template/agora-rn-uikit && cd template/agora-rn-uikit && git checkout appbuilder-uikit-3.1.10", "deps": "cd template && npm i --force", "dev-setup": "npm run uikit && npm run deps && node devSetup.js", "web-build": "cd template && npm run web:build && cd .. && npm run copy-vercel", diff --git a/template/android/app/build.gradle b/template/android/app/build.gradle index 5f57ad321..a434786ab 100644 --- a/template/android/app/build.gradle +++ b/template/android/app/build.gradle @@ -101,6 +101,13 @@ android { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } + + packagingOptions { + pickFirst '**/lib/arm64-v8a/libaosl.so' + pickFirst '**/lib/armeabi-v7a/libaosl.so' + pickFirst '**/lib/x86/libaosl.so' + pickFirst '**/lib/x86_64/libaosl.so' + } } dependencies { diff --git a/template/bridge/rtc/webNg/RtcEngine.ts b/template/bridge/rtc/webNg/RtcEngine.ts index 321a595dc..5f8fe58a9 100644 --- a/template/bridge/rtc/webNg/RtcEngine.ts +++ b/template/bridge/rtc/webNg/RtcEngine.ts @@ -1466,13 +1466,13 @@ export default class RtcEngine { this.client.setEncryptionConfig( mode, config.encryptionKey, - config.encryptionMode === 1? null:config.encryptionKdfSalt, + config.encryptionMode === 1 ? null : config.encryptionKdfSalt, true, // encryptDataStream ), this.screenClient.setEncryptionConfig( mode, config.encryptionKey, - config.encryptionMode === 1? null:config.encryptionKdfSalt, + config.encryptionMode === 1 ? null : config.encryptionKdfSalt, true, // encryptDataStream ), ]); diff --git a/template/bridge/rtm/web/Types.ts b/template/bridge/rtm/web/Types.ts index 8356dbf2f..786ed9b63 100644 --- a/template/bridge/rtm/web/Types.ts +++ b/template/bridge/rtm/web/Types.ts @@ -1,3 +1,5 @@ +import {ChannelType as WebChannelType} from 'agora-rtm-sdk'; + export interface AttributesMap { [key: string]: string; } @@ -11,3 +13,184 @@ export interface ChannelAttributeOptions { */ enableNotificationToChannelMembers?: undefined | false | true; } + +// LINK STATE +export const nativeLinkStateMapping = { + IDLE: 0, + CONNECTING: 1, + CONNECTED: 2, + DISCONNECTED: 3, + SUSPENDED: 4, + FAILED: 5, +}; + +// Create reverse mapping: number -> string +export const webLinkStateMapping = Object.fromEntries( + Object.entries(nativeLinkStateMapping).map(([key, value]) => [value, key]), +); + +export const linkStatusReasonCodeMapping: {[key: string]: number} = { + UNKNOWN: 0, + LOGIN: 1, + LOGIN_SUCCESS: 2, + LOGIN_TIMEOUT: 3, + LOGIN_NOT_AUTHORIZED: 4, + LOGIN_REJECTED: 5, + RELOGIN: 6, + LOGOUT: 7, + AUTO_RECONNECT: 8, + RECONNECT_TIMEOUT: 9, + RECONNECT_SUCCESS: 10, + JOIN: 11, + JOIN_SUCCESS: 12, + JOIN_FAILED: 13, + REJOIN: 14, + LEAVE: 15, + INVALID_TOKEN: 16, + TOKEN_EXPIRED: 17, + INCONSISTENT_APP_ID: 18, + INVALID_CHANNEL_NAME: 19, + INVALID_USER_ID: 20, + NOT_INITIALIZED: 21, + RTM_SERVICE_NOT_CONNECTED: 22, + CHANNEL_INSTANCE_EXCEED_LIMITATION: 23, + OPERATION_RATE_EXCEED_LIMITATION: 24, + CHANNEL_IN_ERROR_STATE: 25, + PRESENCE_NOT_CONNECTED: 26, + SAME_UID_LOGIN: 27, + KICKED_OUT_BY_SERVER: 28, + KEEP_ALIVE_TIMEOUT: 29, + CONNECTION_ERROR: 30, + PRESENCE_NOT_READY: 31, + NETWORK_CHANGE: 32, + SERVICE_NOT_SUPPORTED: 33, + STREAM_CHANNEL_NOT_AVAILABLE: 34, + STORAGE_NOT_AVAILABLE: 35, + LOCK_NOT_AVAILABLE: 36, + LOGIN_TOO_FREQUENT: 37, +}; + +// CHANNEL TYPE +// string -> number +export const nativeChannelTypeMapping = { + NONE: 0, + MESSAGE: 1, + STREAM: 2, + USER: 3, +}; +// number -> string +export const webChannelTypeMapping = Object.fromEntries( + Object.entries(nativeChannelTypeMapping).map(([key, value]) => [value, key]), +); + +// STORAGE TYPE +// string -> number +export const nativeStorageTypeMapping = { + NONE: 0, + /** + * 1: The user storage event. + */ + USER: 1, + /** + * 2: The channel storage event. + */ + CHANNEL: 2, +}; +// number -> string +export const webStorageTypeMapping = Object.fromEntries( + Object.entries(nativeStorageTypeMapping).map(([key, value]) => [value, key]), +); + +// STORAGE EVENT TYPE +export const nativeStorageEventTypeMapping = { + /** + * 0: Unknown event type. + */ + NONE: 0, + /** + * 1: Triggered when user subscribe user metadata state or join channel with options.withMetadata = true + */ + SNAPSHOT: 1, + /** + * 2: Triggered when a remote user set metadata + */ + SET: 2, + /** + * 3: Triggered when a remote user update metadata + */ + UPDATE: 3, + /** + * 4: Triggered when a remote user remove metadata + */ + REMOVE: 4, +}; +// number -> string +export const webStorageEventTypeMapping = Object.fromEntries( + Object.entries(nativeStorageEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); + +// PRESENCE EVENT TYPE +export const nativePresenceEventTypeMapping = { + /** + * 0: Unknown event type + */ + NONE: 0, + /** + * 1: The presence snapshot of this channel + */ + SNAPSHOT: 1, + /** + * 2: The presence event triggered in interval mode + */ + INTERVAL: 2, + /** + * 3: Triggered when remote user join channel + */ + REMOTE_JOIN: 3, + /** + * 4: Triggered when remote user leave channel + */ + REMOTE_LEAVE: 4, + /** + * 5: Triggered when remote user's connection timeout + */ + REMOTE_TIMEOUT: 5, + /** + * 6: Triggered when user changed state + */ + REMOTE_STATE_CHANGED: 6, + /** + * 7: Triggered when user joined channel without presence service + */ + ERROR_OUT_OF_SERVICE: 7, +}; +// number -> string +export const webPresenceEventTypeMapping = Object.fromEntries( + Object.entries(nativePresenceEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); + +// MESSAGE EVENT TYPE +// string -> number +export const nativeMessageEventTypeMapping = { + /** + * 0: The binary message. + */ + BINARY: 0, + /** + * 1: The ascii message. + */ + STRING: 1, +}; +// number -> string +export const webMessageEventTypeMapping = Object.fromEntries( + Object.entries(nativePresenceEventTypeMapping).map(([key, value]) => [ + value, + key, + ]), +); diff --git a/template/bridge/rtm/web/index-legacy.ts b/template/bridge/rtm/web/index-legacy.ts new file mode 100644 index 000000000..ed092d0c6 --- /dev/null +++ b/template/bridge/rtm/web/index-legacy.ts @@ -0,0 +1,540 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +// @ts-nocheck +import { + ChannelAttributeOptions, + RtmAttribute, + RtmChannelAttribute, + Subscription, +} from 'agora-react-native-rtm/lib/typescript/src'; +import {RtmClientEvents} from 'agora-react-native-rtm/lib/typescript/src/RtmEngine'; +import AgoraRTM, {VERSION} from 'agora-rtm-sdk'; +import RtmClient from 'agora-react-native-rtm'; +import {LogSource, logger} from '../../../src/logger/AppBuilderLogger'; +// export {RtmAttribute} +// +interface RtmAttributePlaceholder {} +export {RtmAttributePlaceholder as RtmAttribute}; + +type callbackType = (args?: any) => void; + +export default class RtmEngine { + public appId: string; + public client: RtmClient; + public channelMap = new Map([]); + public remoteInvititations = new Map([]); + public localInvititations = new Map([]); + public channelEventsMap = new Map([ + ['channelMessageReceived', () => null], + ['channelMemberJoined', () => null], + ['channelMemberLeft', () => null], + ]); + public clientEventsMap = new Map([ + ['connectionStateChanged', () => null], + ['messageReceived', () => null], + ['remoteInvitationReceived', () => null], + ['tokenExpired', () => null], + ]); + public localInvitationEventsMap = new Map([ + ['localInvitationAccepted', () => null], + ['localInvitationCanceled', () => null], + ['localInvitationFailure', () => null], + ['localInvitationReceivedByPeer', () => null], + ['localInvitationRefused', () => null], + ]); + public remoteInvitationEventsMap = new Map([ + ['remoteInvitationAccepted', () => null], + ['remoteInvitationCanceled', () => null], + ['remoteInvitationFailure', () => null], + ['remoteInvitationRefused', () => null], + ]); + constructor() { + this.appId = ''; + logger.debug(LogSource.AgoraSDK, 'Log', 'Using RTM Bridge'); + } + + on(event: any, listener: any) { + if ( + event === 'channelMessageReceived' || + event === 'channelMemberJoined' || + event === 'channelMemberLeft' + ) { + this.channelEventsMap.set(event, listener); + } else if ( + event === 'connectionStateChanged' || + event === 'messageReceived' || + event === 'remoteInvitationReceived' || + event === 'tokenExpired' + ) { + this.clientEventsMap.set(event, listener); + } else if ( + event === 'localInvitationAccepted' || + event === 'localInvitationCanceled' || + event === 'localInvitationFailure' || + event === 'localInvitationReceivedByPeer' || + event === 'localInvitationRefused' + ) { + this.localInvitationEventsMap.set(event, listener); + } else if ( + event === 'remoteInvitationAccepted' || + event === 'remoteInvitationCanceled' || + event === 'remoteInvitationFailure' || + event === 'remoteInvitationRefused' + ) { + this.remoteInvitationEventsMap.set(event, listener); + } + } + + createClient(APP_ID: string) { + this.appId = APP_ID; + this.client = AgoraRTM.createInstance(this.appId); + + if ($config.GEO_FENCING) { + try { + //include area is comma seperated value + let includeArea = $config.GEO_FENCING_INCLUDE_AREA + ? $config.GEO_FENCING_INCLUDE_AREA + : AREAS.GLOBAL; + + //exclude area is single value + let excludeArea = $config.GEO_FENCING_EXCLUDE_AREA + ? $config.GEO_FENCING_EXCLUDE_AREA + : ''; + + includeArea = includeArea?.split(','); + + //pass excludedArea if only its provided + if (excludeArea) { + AgoraRTM.setArea({ + areaCodes: includeArea, + excludedArea: excludeArea, + }); + } + //otherwise we can pass area directly + else { + AgoraRTM.setArea({areaCodes: includeArea}); + } + } catch (setAeraError) { + console.log('error on RTM setArea', setAeraError); + } + } + + window.rtmClient = this.client; + + this.client.on('ConnectionStateChanged', (state, reason) => { + this.clientEventsMap.get('connectionStateChanged')({state, reason}); + }); + + this.client.on('MessageFromPeer', (msg, uid, msgProps) => { + this.clientEventsMap.get('messageReceived')({ + text: msg.text, + ts: msgProps.serverReceivedTs, + offline: msgProps.isOfflineMessage, + peerId: uid, + }); + }); + + this.client.on('RemoteInvitationReceived', (remoteInvitation: any) => { + this.remoteInvititations.set(remoteInvitation.callerId, remoteInvitation); + this.clientEventsMap.get('remoteInvitationReceived')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + + remoteInvitation.on('RemoteInvitationAccepted', () => { + this.remoteInvitationEventsMap.get('RemoteInvitationAccepted')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + + remoteInvitation.on('RemoteInvitationCanceled', (content: string) => { + this.remoteInvitationEventsMap.get('remoteInvitationCanceled')({ + callerId: remoteInvitation.callerId, + content: content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + + remoteInvitation.on('RemoteInvitationFailure', (reason: string) => { + this.remoteInvitationEventsMap.get('remoteInvitationFailure')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + code: -1, //Web sends string, RN expect number but can't find enum + }); + }); + + remoteInvitation.on('RemoteInvitationRefused', () => { + this.remoteInvitationEventsMap.get('remoteInvitationRefused')({ + callerId: remoteInvitation.callerId, + content: remoteInvitation.content, + state: remoteInvitation.state, + channelId: remoteInvitation.channelId, + response: remoteInvitation.response, + }); + }); + }); + + this.client.on('TokenExpired', () => { + this.clientEventsMap.get('tokenExpired')({}); //RN expect evt: any + }); + } + + async login(loginParam: {uid: string; token?: string}): Promise { + return this.client.login(loginParam); + } + + async logout(): Promise { + return await this.client.logout(); + } + + async joinChannel(channelId: string): Promise { + this.channelMap.set(channelId, this.client.createChannel(channelId)); + this.channelMap + .get(channelId) + .on('ChannelMessage', (msg: {text: string}, uid: string, messagePros) => { + let text = msg.text; + let ts = messagePros.serverReceivedTs; + this.channelEventsMap.get('channelMessageReceived')({ + uid, + channelId, + text, + ts, + }); + }); + this.channelMap.get(channelId).on('MemberJoined', (uid: string) => { + this.channelEventsMap.get('channelMemberJoined')({uid, channelId}); + }); + this.channelMap.get(channelId).on('MemberLeft', (uid: string) => { + console.log('Member Left', this.channelEventsMap); + this.channelEventsMap.get('channelMemberLeft')({uid}); + }); + this.channelMap + .get(channelId) + .on('AttributesUpdated', (attributes: RtmChannelAttribute) => { + /** + * a) Kindly note the below event listener 'channelAttributesUpdated' expects type + * RtmChannelAttribute[] (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) + * whereas the above listener 'AttributesUpdated' receives attributes in object form + * {[valueOfKey]: valueOfValue} of type RtmChannelAttribute + * b) Hence in this bridge the data should be modified to keep in sync with both the + * listeners for web and listener for native + */ + /** + * 1. Loop through object + * 2. Create a object {key: "", value: ""} and push into array + * 3. Return the Array + */ + const channelAttributes = Object.keys(attributes).reduce((acc, key) => { + const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; + acc.push({key, value, lastUpdateTs, lastUpdateUserId}); + return acc; + }, []); + + this.channelEventsMap.get('ChannelAttributesUpdated')( + channelAttributes, + ); + }); + + return this.channelMap.get(channelId).join(); + } + + async leaveChannel(channelId: string): Promise { + if (this.channelMap.get(channelId)) { + return this.channelMap.get(channelId).leave(); + } else { + Promise.reject('Wrong channel'); + } + } + + async sendMessageByChannelId(channel: string, message: string): Promise { + if (this.channelMap.get(channel)) { + return this.channelMap.get(channel).sendMessage({text: message}); + } else { + console.log(this.channelMap, channel); + Promise.reject('Wrong channel'); + } + } + + destroyClient() { + console.log('Destroy called'); + this.channelEventsMap.forEach((callback, event) => { + this.client.off(event, callback); + }); + this.channelEventsMap.clear(); + this.channelMap.clear(); + this.clientEventsMap.clear(); + this.remoteInvitationEventsMap.clear(); + this.localInvitationEventsMap.clear(); + } + + async getChannelMembersBychannelId(channel: string) { + if (this.channelMap.get(channel)) { + let memberArray: Array = []; + let currentChannel = this.channelMap.get(channel); + await currentChannel.getMembers().then((arr: Array) => { + arr.map((elem: number) => { + memberArray.push({ + channelId: channel, + uid: elem, + }); + }); + }); + return {members: memberArray}; + } else { + Promise.reject('Wrong channel'); + } + } + + async queryPeersOnlineStatus(uid: Array) { + let peerArray: Array = []; + await this.client.queryPeersOnlineStatus(uid).then(list => { + Object.entries(list).forEach(value => { + peerArray.push({ + online: value[1], + uid: value[0], + }); + }); + }); + return {items: peerArray}; + } + + async renewToken(token: string) { + return this.client.renewToken(token); + } + + async getUserAttributesByUid(uid: string) { + let response = {}; + await this.client + .getUserAttributes(uid) + .then((attributes: string) => { + response = {attributes, uid}; + }) + .catch((e: any) => { + Promise.reject(e); + }); + return response; + } + + async getChannelAttributes(channelId: string) { + let response = {}; + await this.client + .getChannelAttributes(channelId) + .then((attributes: RtmChannelAttribute) => { + /** + * Here the attributes received are in the format {[valueOfKey]: valueOfValue} of type RtmChannelAttribute + * We need to convert it into (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) + /** + * 1. Loop through object + * 2. Create a object {key: "", value: ""} and push into array + * 3. Return the Array + */ + const channelAttributes = Object.keys(attributes).reduce((acc, key) => { + const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; + acc.push({key, value, lastUpdateTs, lastUpdateUserId}); + return acc; + }, []); + response = channelAttributes; + }) + .catch((e: any) => { + Promise.reject(e); + }); + return response; + } + + async removeAllLocalUserAttributes() { + return this.client.clearLocalUserAttributes(); + } + + async removeLocalUserAttributesByKeys(keys: string[]) { + return this.client.deleteLocalUserAttributesByKeys(keys); + } + + async replaceLocalUserAttributes(attributes: string[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.setLocalUserAttributes({...formattedAttributes}); + } + + async setLocalUserAttributes(attributes: string[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + // console.log('!!!!formattedAttributes', formattedAttributes, key, value); + }); + return this.client.setLocalUserAttributes({...formattedAttributes}); + } + + async addOrUpdateLocalUserAttributes(attributes: RtmAttribute[]) { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.addOrUpdateLocalUserAttributes({...formattedAttributes}); + } + + async addOrUpdateChannelAttributes( + channelId: string, + attributes: RtmChannelAttribute[], + option: ChannelAttributeOptions, + ): Promise { + let formattedAttributes: any = {}; + attributes.map(attribute => { + let key = Object.values(attribute)[0]; + let value = Object.values(attribute)[1]; + formattedAttributes[key] = value; + }); + return this.client.addOrUpdateChannelAttributes( + channelId, + {...formattedAttributes}, + option, + ); + } + + async sendLocalInvitation(invitationProps: any) { + let invite = this.client.createLocalInvitation(invitationProps.uid); + this.localInvititations.set(invitationProps.uid, invite); + invite.content = invitationProps.content; + + invite.on('LocalInvitationAccepted', (response: string) => { + this.localInvitationEventsMap.get('localInvitationAccepted')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response, + }); + }); + + invite.on('LocalInvitationCanceled', () => { + this.localInvitationEventsMap.get('localInvitationCanceled')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + }); + }); + + invite.on('LocalInvitationFailure', (reason: string) => { + this.localInvitationEventsMap.get('localInvitationFailure')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + code: -1, //Web sends string, RN expect number but can't find enum + }); + }); + + invite.on('LocalInvitationReceivedByPeer', () => { + this.localInvitationEventsMap.get('localInvitationReceivedByPeer')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: invite.response, + }); + }); + + invite.on('LocalInvitationRefused', (response: string) => { + this.localInvitationEventsMap.get('localInvitationRefused')({ + calleeId: invite.calleeId, + content: invite.content, + state: invite.state, + channelId: invite.channelId, + response: response, + }); + }); + return invite.send(); + } + + async sendMessageToPeer(AgoraPeerMessage: { + peerId: string; + offline: boolean; + text: string; + }) { + return this.client.sendMessageToPeer( + {text: AgoraPeerMessage.text}, + AgoraPeerMessage.peerId, + ); + //check promise result + } + + async acceptRemoteInvitation(remoteInvitationProps: { + uid: string; + response?: string; + channelId: string; + }) { + let invite = this.remoteInvititations.get(remoteInvitationProps.uid); + // console.log(invite); + // console.log(this.remoteInvititations); + // console.log(remoteInvitationProps.uid); + return invite.accept(); + } + + async refuseRemoteInvitation(remoteInvitationProps: { + uid: string; + response?: string; + channelId: string; + }) { + return this.remoteInvititations.get(remoteInvitationProps.uid).refuse(); + } + + async cancelLocalInvitation(LocalInvitationProps: { + uid: string; + content?: string; + channelId?: string; + }) { + console.log(this.localInvititations.get(LocalInvitationProps.uid)); + return this.localInvititations.get(LocalInvitationProps.uid).cancel(); + } + + getSdkVersion(callback: (version: string) => void) { + callback(VERSION); + } + + addListener( + event: EventType, + listener: RtmClientEvents[EventType], + ): Subscription { + if (event === 'ChannelAttributesUpdated') { + this.channelEventsMap.set(event, listener as callbackType); + } + return { + remove: () => { + console.log( + 'Use destroy method to remove all the event listeners from the RtcEngine instead.', + ); + }, + }; + } +} diff --git a/template/bridge/rtm/web/index.ts b/template/bridge/rtm/web/index.ts index ed092d0c6..6bc0aa3b6 100644 --- a/template/bridge/rtm/web/index.ts +++ b/template/bridge/rtm/web/index.ts @@ -1,540 +1,502 @@ -/* -******************************************** - Copyright Β© 2021 Agora Lab, Inc., all rights reserved. - AppBuilder and all associated components, source code, APIs, services, and documentation - (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be - accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. - Use without a license or in violation of any license terms and conditions (including use for - any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more - information visit https://appbuilder.agora.io. -********************************************* -*/ -// @ts-nocheck import { - ChannelAttributeOptions, - RtmAttribute, - RtmChannelAttribute, - Subscription, -} from 'agora-react-native-rtm/lib/typescript/src'; -import {RtmClientEvents} from 'agora-react-native-rtm/lib/typescript/src/RtmEngine'; -import AgoraRTM, {VERSION} from 'agora-rtm-sdk'; -import RtmClient from 'agora-react-native-rtm'; -import {LogSource, logger} from '../../../src/logger/AppBuilderLogger'; -// export {RtmAttribute} -// -interface RtmAttributePlaceholder {} -export {RtmAttributePlaceholder as RtmAttribute}; - -type callbackType = (args?: any) => void; - -export default class RtmEngine { - public appId: string; - public client: RtmClient; - public channelMap = new Map([]); - public remoteInvititations = new Map([]); - public localInvititations = new Map([]); - public channelEventsMap = new Map([ - ['channelMessageReceived', () => null], - ['channelMemberJoined', () => null], - ['channelMemberLeft', () => null], - ]); - public clientEventsMap = new Map([ - ['connectionStateChanged', () => null], - ['messageReceived', () => null], - ['remoteInvitationReceived', () => null], - ['tokenExpired', () => null], - ]); - public localInvitationEventsMap = new Map([ - ['localInvitationAccepted', () => null], - ['localInvitationCanceled', () => null], - ['localInvitationFailure', () => null], - ['localInvitationReceivedByPeer', () => null], - ['localInvitationRefused', () => null], - ]); - public remoteInvitationEventsMap = new Map([ - ['remoteInvitationAccepted', () => null], - ['remoteInvitationCanceled', () => null], - ['remoteInvitationFailure', () => null], - ['remoteInvitationRefused', () => null], + type Metadata as NativeMetadata, + type MetadataItem as NativeMetadataItem, + type GetUserMetadataOptions as NativeGetUserMetadataOptions, + type RtmChannelType as NativeRtmChannelType, + type SetUserMetadataResponse, + type LoginOptions as NativeLoginOptions, + type RTMClientEventMap as NativeRTMClientEventMap, + type GetUserMetadataResponse as NativeGetUserMetadataResponse, + type GetChannelMetadataResponse as NativeGetChannelMetadataResponse, + type SetOrUpdateUserMetadataOptions as NativeSetOrUpdateUserMetadataOptions, + type RemoveUserMetadataOptions as NativeRemoveUserMetadataOptions, + type RemoveUserMetadataResponse as NativeRemoveUserMetadataResponse, + type IMetadataOptions as NativeIMetadataOptions, + type StorageEvent as NativeStorageEvent, + type PresenceEvent as NativePresenceEvent, + type MessageEvent as NativeMessageEvent, + type SubscribeOptions as NativeSubscribeOptions, + type PublishOptions as NativePublishOptions, +} from 'agora-react-native-rtm'; +import AgoraRTM, { + RTMClient, + GetUserMetadataResponse, + GetChannelMetadataResponse, + PublishOptions, + ChannelType, + MetaDataDetail, + RemoveUserMetadataOptions, +} from 'agora-rtm-sdk'; +import { + linkStatusReasonCodeMapping, + nativeChannelTypeMapping, + nativeLinkStateMapping, + nativeMessageEventTypeMapping, + nativePresenceEventTypeMapping, + nativeStorageEventTypeMapping, + nativeStorageTypeMapping, + webChannelTypeMapping, +} from './Types'; + +type CallbackType = (args?: any) => void; + +// Conversion function +const convertWebToNativeMetadata = (webMetadata: any): NativeMetadata => { + // Convert object entries to MetadataItem array + const items: NativeMetadataItem[] = + Object.entries(webMetadata.metadata).map( + ([key, metadataItem]: [string, MetaDataDetail]) => { + return { + key: key, + value: metadataItem.value, + revision: metadataItem.revision, + authorUserId: metadataItem.authorUid, + updateTs: metadataItem.updated, + }; + }, + ) || []; + + // Create native Metadata object + const nativeMetadata: NativeMetadata = { + majorRevision: webMetadata?.revision || -1, // Use first item's revision as major revision + items: items, + itemCount: webMetadata?.totalCount || 0, + }; + + return nativeMetadata; +}; + +export class RTMWebClient { + private client: RTMClient; + private appId: string; + private userId: string; + private eventsMap = new Map([ + ['linkState', () => null], + ['storage', () => null], + ['presence', () => null], + ['message', () => null], ]); - constructor() { - this.appId = ''; - logger.debug(LogSource.AgoraSDK, 'Log', 'Using RTM Bridge'); - } - - on(event: any, listener: any) { - if ( - event === 'channelMessageReceived' || - event === 'channelMemberJoined' || - event === 'channelMemberLeft' - ) { - this.channelEventsMap.set(event, listener); - } else if ( - event === 'connectionStateChanged' || - event === 'messageReceived' || - event === 'remoteInvitationReceived' || - event === 'tokenExpired' - ) { - this.clientEventsMap.set(event, listener); - } else if ( - event === 'localInvitationAccepted' || - event === 'localInvitationCanceled' || - event === 'localInvitationFailure' || - event === 'localInvitationReceivedByPeer' || - event === 'localInvitationRefused' - ) { - this.localInvitationEventsMap.set(event, listener); - } else if ( - event === 'remoteInvitationAccepted' || - event === 'remoteInvitationCanceled' || - event === 'remoteInvitationFailure' || - event === 'remoteInvitationRefused' - ) { - this.remoteInvitationEventsMap.set(event, listener); - } - } - createClient(APP_ID: string) { - this.appId = APP_ID; - this.client = AgoraRTM.createInstance(this.appId); - - if ($config.GEO_FENCING) { - try { - //include area is comma seperated value - let includeArea = $config.GEO_FENCING_INCLUDE_AREA - ? $config.GEO_FENCING_INCLUDE_AREA - : AREAS.GLOBAL; - - //exclude area is single value - let excludeArea = $config.GEO_FENCING_EXCLUDE_AREA - ? $config.GEO_FENCING_EXCLUDE_AREA - : ''; - - includeArea = includeArea?.split(','); - - //pass excludedArea if only its provided - if (excludeArea) { - AgoraRTM.setArea({ - areaCodes: includeArea, - excludedArea: excludeArea, - }); - } - //otherwise we can pass area directly - else { - AgoraRTM.setArea({areaCodes: includeArea}); - } - } catch (setAeraError) { - console.log('error on RTM setArea', setAeraError); - } - } - - window.rtmClient = this.client; - - this.client.on('ConnectionStateChanged', (state, reason) => { - this.clientEventsMap.get('connectionStateChanged')({state, reason}); - }); - - this.client.on('MessageFromPeer', (msg, uid, msgProps) => { - this.clientEventsMap.get('messageReceived')({ - text: msg.text, - ts: msgProps.serverReceivedTs, - offline: msgProps.isOfflineMessage, - peerId: uid, - }); - }); - - this.client.on('RemoteInvitationReceived', (remoteInvitation: any) => { - this.remoteInvititations.set(remoteInvitation.callerId, remoteInvitation); - this.clientEventsMap.get('remoteInvitationReceived')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, + constructor(appId: string, userId: string) { + this.appId = appId; + this.userId = `${userId}`; + try { + // Create the actual web RTM client + this.client = new AgoraRTM.RTM(this.appId, this.userId); + + this.client.addEventListener('linkState', data => { + const nativeState = { + ...data, + currentState: + nativeLinkStateMapping[data.currentState] || + nativeLinkStateMapping.IDLE, + previousState: + nativeLinkStateMapping[data.previousState] || + nativeLinkStateMapping.IDLE, + reasonCode: linkStatusReasonCodeMapping[data.reasonCode] || 0, + }; + (this.eventsMap.get('linkState') ?? (() => {}))(nativeState); }); - remoteInvitation.on('RemoteInvitationAccepted', () => { - this.remoteInvitationEventsMap.get('RemoteInvitationAccepted')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - }); + this.client.addEventListener('storage', data => { + const nativeStorageEvent: NativeStorageEvent = { + channelType: nativeChannelTypeMapping[data.channelType], + storageType: nativeStorageTypeMapping[data.storageType], + eventType: nativeStorageEventTypeMapping[data.eventType], + data: convertWebToNativeMetadata(data.data), + timestamp: data.timestamp, + }; + (this.eventsMap.get('storage') ?? (() => {}))(nativeStorageEvent); }); - remoteInvitation.on('RemoteInvitationCanceled', (content: string) => { - this.remoteInvitationEventsMap.get('remoteInvitationCanceled')({ - callerId: remoteInvitation.callerId, - content: content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - }); + this.client.addEventListener('presence', data => { + const nativePresenceEvent: NativePresenceEvent = { + channelName: data.channelName, + channelType: nativeChannelTypeMapping[data.channelType], + type: nativePresenceEventTypeMapping[data.eventType], + publisher: data.publisher, + timestamp: data.timestamp, + }; + (this.eventsMap.get('presence') ?? (() => {}))(nativePresenceEvent); }); - remoteInvitation.on('RemoteInvitationFailure', (reason: string) => { - this.remoteInvitationEventsMap.get('remoteInvitationFailure')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - code: -1, //Web sends string, RN expect number but can't find enum - }); + this.client.addEventListener('message', data => { + const nativeMessageEvent: NativeMessageEvent = { + ...data, + channelType: nativeChannelTypeMapping[data.channelType], + messageType: nativeMessageEventTypeMapping[data.messageType], + message: `${data.message}`, + }; + (this.eventsMap.get('message') ?? (() => {}))(nativeMessageEvent); }); - - remoteInvitation.on('RemoteInvitationRefused', () => { - this.remoteInvitationEventsMap.get('remoteInvitationRefused')({ - callerId: remoteInvitation.callerId, - content: remoteInvitation.content, - state: remoteInvitation.state, - channelId: remoteInvitation.channelId, - response: remoteInvitation.response, - }); - }); - }); - - this.client.on('TokenExpired', () => { - this.clientEventsMap.get('tokenExpired')({}); //RN expect evt: any - }); - } - - async login(loginParam: {uid: string; token?: string}): Promise { - return this.client.login(loginParam); - } - - async logout(): Promise { - return await this.client.logout(); + } catch (error) { + const contextError = new Error( + `Failed to create RTMWebClient for appId: ${this.appId}, userId: ${ + this.userId + }. Error: ${error.message || error}`, + ); + console.error('RTMWebClient constructor error:', contextError); + throw contextError; + } } - async joinChannel(channelId: string): Promise { - this.channelMap.set(channelId, this.client.createChannel(channelId)); - this.channelMap - .get(channelId) - .on('ChannelMessage', (msg: {text: string}, uid: string, messagePros) => { - let text = msg.text; - let ts = messagePros.serverReceivedTs; - this.channelEventsMap.get('channelMessageReceived')({ - uid, - channelId, - text, - ts, + // Storage methods + get storage() { + return { + setUserMetadata: ( + data: NativeMetadata, + options?: NativeSetOrUpdateUserMetadataOptions, + ): Promise => { + // 1. Validate input parameters + if (!data) { + throw new Error('setUserMetadata: data parameter is required'); + } + if (!data.items || !Array.isArray(data.items)) { + throw new Error( + 'setUserMetadata: data.items must be a non-empty array', + ); + } + if (data.items.length === 0) { + throw new Error('setUserMetadata: data.items cannot be empty'); + } + // 2. Make sure key is present as this is mandatory + // https://docs.agora.io/en/signaling/reference/api?platform=web#storagesetuserpropsag_platform + const validatedItems = data.items.map((item, index) => { + if (!item.key || typeof item.key !== 'string') { + throw new Error( + `setUserMetadata: item at index ${index} missing required 'key' property`, + ); + } + return { + key: item.key, + value: item.value || '', // Default to empty string if not provided + revision: item.revision || -1, // Default to -1 if not provided + }; }); - }); - this.channelMap.get(channelId).on('MemberJoined', (uid: string) => { - this.channelEventsMap.get('channelMemberJoined')({uid, channelId}); - }); - this.channelMap.get(channelId).on('MemberLeft', (uid: string) => { - console.log('Member Left', this.channelEventsMap); - this.channelEventsMap.get('channelMemberLeft')({uid}); - }); - this.channelMap - .get(channelId) - .on('AttributesUpdated', (attributes: RtmChannelAttribute) => { - /** - * a) Kindly note the below event listener 'channelAttributesUpdated' expects type - * RtmChannelAttribute[] (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) - * whereas the above listener 'AttributesUpdated' receives attributes in object form - * {[valueOfKey]: valueOfValue} of type RtmChannelAttribute - * b) Hence in this bridge the data should be modified to keep in sync with both the - * listeners for web and listener for native - */ + // Map native signature to web signature + return this.client.storage.setUserMetadata(validatedItems, { + addTimeStamp: options?.addTimeStamp || true, + addUserId: options?.addUserId || true, + }); + }, + + getUserMetadata: async (options: NativeGetUserMetadataOptions) => { + // Validate input parameters + if (!options) { + throw new Error('getUserMetadata: options parameter is required'); + } + if ( + !options.userId || + typeof options.userId !== 'string' || + options.userId.trim() === '' + ) { + throw new Error( + 'getUserMetadata: options.userId must be a non-empty string', + ); + } + const webResponse: GetUserMetadataResponse = + await this.client.storage.getUserMetadata({ + userId: options.userId, + }); /** - * 1. Loop through object - * 2. Create a object {key: "", value: ""} and push into array - * 3. Return the Array + * majorRevision : 13483783553 + * metadata : + * { + * isHost: {authorUid: "", revision: 13483783553, updated: 0, value : "true"}, + * screenUid: {…}} + * } + * timestamp: 0 + * totalCount: 2 + * userId: "xxx" */ - const channelAttributes = Object.keys(attributes).reduce((acc, key) => { - const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; - acc.push({key, value, lastUpdateTs, lastUpdateUserId}); - return acc; - }, []); - - this.channelEventsMap.get('ChannelAttributesUpdated')( - channelAttributes, + const items = Object.entries(webResponse.metadata).map( + ([key, metadataItem]) => ({ + key: key, + value: metadataItem.value, + }), ); - }); + const nativeResponse: NativeGetUserMetadataResponse = { + items: [...items], + itemCount: webResponse.totalCount, + userId: webResponse.userId, + timestamp: webResponse.timestamp, + }; + return nativeResponse; + }, - return this.channelMap.get(channelId).join(); - } + removeUserMetadata: async ( + options?: NativeRemoveUserMetadataOptions, + ): Promise => { + // Build the options object for the web SDK call + const webOptions: RemoveUserMetadataOptions = {}; - async leaveChannel(channelId: string): Promise { - if (this.channelMap.get(channelId)) { - return this.channelMap.get(channelId).leave(); - } else { - Promise.reject('Wrong channel'); - } - } + // Add userId if provided (for removing another user's metadata, defaults to self if not provided) + if (options?.userId && typeof options.userId === 'string') { + webOptions.userId = options.userId; + } - async sendMessageByChannelId(channel: string, message: string): Promise { - if (this.channelMap.get(channel)) { - return this.channelMap.get(channel).sendMessage({text: message}); - } else { - console.log(this.channelMap, channel); - Promise.reject('Wrong channel'); - } - } + // Convert native Metadata to web MetadataItem[] format if provided + if ( + options?.data && + options.data.items && + Array.isArray(options.data.items) && + options.data.items.length > 0 + ) { + webOptions.data = options.data.items.map(item => ({ + key: item.key, + value: item.value || '', // Require not used for remove.we use keys + })); + } - destroyClient() { - console.log('Destroy called'); - this.channelEventsMap.forEach((callback, event) => { - this.client.off(event, callback); - }); - this.channelEventsMap.clear(); - this.channelMap.clear(); - this.clientEventsMap.clear(); - this.remoteInvitationEventsMap.clear(); - this.localInvitationEventsMap.clear(); - } + return await this.client.storage.removeUserMetadata(webOptions); + }, - async getChannelMembersBychannelId(channel: string) { - if (this.channelMap.get(channel)) { - let memberArray: Array = []; - let currentChannel = this.channelMap.get(channel); - await currentChannel.getMembers().then((arr: Array) => { - arr.map((elem: number) => { - memberArray.push({ - channelId: channel, - uid: elem, - }); + setChannelMetadata: async ( + channelName: string, + channelType: NativeRtmChannelType, + data: NativeMetadata, + options?: NativeIMetadataOptions, + ) => { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error( + 'setChannelMetadata: channelName must be a non-empty string', + ); + } + if (typeof channelType !== 'number') { + throw new Error('setChannelMetadata: channelType must be a number'); + } + if (!data) { + throw new Error('setChannelMetadata: data parameter is required'); + } + if (!data.items || !Array.isArray(data.items)) { + throw new Error('setChannelMetadata: data.items must be an array'); + } + if (data.items.length === 0) { + throw new Error('setChannelMetadata: data.items cannot be empty'); + } + // 2. Make sure key is present as this is mandatory + // https://docs.agora.io/en/signaling/reference/api?platform=web#storagesetuserpropsag_platform + const validatedItems = data.items.map((item, index) => { + if (!item.key || typeof item.key !== 'string') { + throw new Error( + `setChannelMetadata: item at index ${index} missing required 'key' property`, + ); + } + return { + key: item.key, + value: item.value || '', // Default to empty string if not provided + revision: item.revision || -1, // Default to -1 if not provided + }; }); - }); - return {members: memberArray}; - } else { - Promise.reject('Wrong channel'); - } - } + return this.client.storage.setChannelMetadata( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + validatedItems, + { + addUserId: options?.addUserId || true, + addTimeStamp: options?.addTimeStamp || true, + }, + ); + }, - async queryPeersOnlineStatus(uid: Array) { - let peerArray: Array = []; - await this.client.queryPeersOnlineStatus(uid).then(list => { - Object.entries(list).forEach(value => { - peerArray.push({ - online: value[1], - uid: value[0], - }); - }); - }); - return {items: peerArray}; + getChannelMetadata: async ( + channelName: string, + channelType: NativeRtmChannelType, + ) => { + try { + const webResponse: GetChannelMetadataResponse = + await this.client.storage.getChannelMetadata( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + ); + + const items = Object.entries(webResponse.metadata).map( + ([key, metadataItem]) => ({ + key: key, + value: metadataItem.value, + }), + ); + const nativeResponse: NativeGetChannelMetadataResponse = { + items: [...items], + itemCount: webResponse.totalCount, + timestamp: webResponse.timestamp, + channelName: webResponse.channelName, + channelType: nativeChannelTypeMapping.MESSAGE, + }; + return nativeResponse; + } catch (error) { + const contextError = new Error( + `Failed to get channel metadata for channel '${channelName}' with type ${channelType}: ${ + error.message || error + }`, + ); + console.error('BRIDGE getChannelMetadata error:', contextError); + throw contextError; + } + }, + }; } - async renewToken(token: string) { - return this.client.renewToken(token); - } + get presence() { + return { + getOnlineUsers: async ( + channelName: string, + channelType: NativeRtmChannelType, + ) => { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error( + 'getOnlineUsers: channelName must be a non-empty string', + ); + } + if (typeof channelType !== 'number') { + throw new Error('getOnlineUsers: channelType must be a number'); + } - async getUserAttributesByUid(uid: string) { - let response = {}; - await this.client - .getUserAttributes(uid) - .then((attributes: string) => { - response = {attributes, uid}; - }) - .catch((e: any) => { - Promise.reject(e); - }); - return response; - } + try { + // Call web SDK's presence method + const result = await this.client.presence.getOnlineUsers( + channelName, + (webChannelTypeMapping[channelType] as ChannelType) || 'MESSAGE', + ); + return result; + } catch (error) { + const contextError = new Error( + `Failed to get online users for channel '${channelName}' with type ${channelType}: ${ + error.message || error + }`, + ); + console.error('BRIDGE presence error:', contextError); + throw contextError; + } + }, - async getChannelAttributes(channelId: string) { - let response = {}; - await this.client - .getChannelAttributes(channelId) - .then((attributes: RtmChannelAttribute) => { - /** - * Here the attributes received are in the format {[valueOfKey]: valueOfValue} of type RtmChannelAttribute - * We need to convert it into (array of objects [{key: 'valueOfKey', value: 'valueOfValue}]) - /** - * 1. Loop through object - * 2. Create a object {key: "", value: ""} and push into array - * 3. Return the Array - */ - const channelAttributes = Object.keys(attributes).reduce((acc, key) => { - const {value, lastUpdateTs, lastUpdateUserId} = attributes[key]; - acc.push({key, value, lastUpdateTs, lastUpdateUserId}); - return acc; - }, []); - response = channelAttributes; - }) - .catch((e: any) => { - Promise.reject(e); - }); - return response; - } + whoNow: async ( + channelName: string, + channelType?: NativeRtmChannelType, + ) => { + const webChannelType = channelType + ? (webChannelTypeMapping[channelType] as ChannelType) + : 'MESSAGE'; + return this.client.presence.whoNow(channelName, webChannelType); + }, - async removeAllLocalUserAttributes() { - return this.client.clearLocalUserAttributes(); + whereNow: async (userId: string) => { + return this.client.presence.whereNow(userId); + }, + }; } - async removeLocalUserAttributesByKeys(keys: string[]) { - return this.client.deleteLocalUserAttributesByKeys(keys); + addEventListener( + event: keyof NativeRTMClientEventMap, + listener: (event: any) => void, + ) { + if (this.client) { + // Simply replace the handler in our map - web client listeners are fixed in constructor + this.eventsMap.set(event, listener as CallbackType); + } } - async replaceLocalUserAttributes(attributes: string[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.setLocalUserAttributes({...formattedAttributes}); + removeEventListener( + event: keyof NativeRTMClientEventMap, + _listener: (event: any) => void, + ) { + if (this.client && this.eventsMap.has(event)) { + const prevListener = this.eventsMap.get(event); + if (prevListener) { + this.client.removeEventListener(event, prevListener); + } + this.eventsMap.set(event, () => null); // reset to no-op + } } - async setLocalUserAttributes(attributes: string[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - // console.log('!!!!formattedAttributes', formattedAttributes, key, value); - }); - return this.client.setLocalUserAttributes({...formattedAttributes}); + // Core RTM methods - direct delegation to web SDK + async login(options?: NativeLoginOptions) { + if (!options?.token) { + throw new Error('login: token is required in options'); + } + return this.client.login({token: options.token}); } - async addOrUpdateLocalUserAttributes(attributes: RtmAttribute[]) { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.addOrUpdateLocalUserAttributes({...formattedAttributes}); + async logout() { + return this.client.logout(); } - async addOrUpdateChannelAttributes( - channelId: string, - attributes: RtmChannelAttribute[], - option: ChannelAttributeOptions, - ): Promise { - let formattedAttributes: any = {}; - attributes.map(attribute => { - let key = Object.values(attribute)[0]; - let value = Object.values(attribute)[1]; - formattedAttributes[key] = value; - }); - return this.client.addOrUpdateChannelAttributes( - channelId, - {...formattedAttributes}, - option, - ); + async subscribe(channelName: string, options?: NativeSubscribeOptions) { + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error('subscribe: channelName must be a non-empty string'); + } + return this.client.subscribe(channelName, options); } - async sendLocalInvitation(invitationProps: any) { - let invite = this.client.createLocalInvitation(invitationProps.uid); - this.localInvititations.set(invitationProps.uid, invite); - invite.content = invitationProps.content; - - invite.on('LocalInvitationAccepted', (response: string) => { - this.localInvitationEventsMap.get('localInvitationAccepted')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response, - }); - }); - - invite.on('LocalInvitationCanceled', () => { - this.localInvitationEventsMap.get('localInvitationCanceled')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - }); - }); - - invite.on('LocalInvitationFailure', (reason: string) => { - this.localInvitationEventsMap.get('localInvitationFailure')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - code: -1, //Web sends string, RN expect number but can't find enum - }); - }); - - invite.on('LocalInvitationReceivedByPeer', () => { - this.localInvitationEventsMap.get('localInvitationReceivedByPeer')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: invite.response, - }); - }); - - invite.on('LocalInvitationRefused', (response: string) => { - this.localInvitationEventsMap.get('localInvitationRefused')({ - calleeId: invite.calleeId, - content: invite.content, - state: invite.state, - channelId: invite.channelId, - response: response, - }); - }); - return invite.send(); + async unsubscribe(channelName: string) { + return this.client.unsubscribe(channelName); } - async sendMessageToPeer(AgoraPeerMessage: { - peerId: string; - offline: boolean; - text: string; - }) { - return this.client.sendMessageToPeer( - {text: AgoraPeerMessage.text}, - AgoraPeerMessage.peerId, - ); - //check promise result - } + async publish( + channelName: string, + message: string, + options?: NativePublishOptions, + ) { + // Validate input parameters + if ( + !channelName || + typeof channelName !== 'string' || + channelName.trim() === '' + ) { + throw new Error('publish: channelName must be a non-empty string'); + } + if (typeof message !== 'string') { + throw new Error('publish: message must be a string'); + } - async acceptRemoteInvitation(remoteInvitationProps: { - uid: string; - response?: string; - channelId: string; - }) { - let invite = this.remoteInvititations.get(remoteInvitationProps.uid); - // console.log(invite); - // console.log(this.remoteInvititations); - // console.log(remoteInvitationProps.uid); - return invite.accept(); + const webOptions: PublishOptions = { + ...options, + channelType: + (webChannelTypeMapping[options?.channelType] as ChannelType) || + 'MESSAGE', + }; + return this.client.publish(channelName, message, webOptions); } - async refuseRemoteInvitation(remoteInvitationProps: { - uid: string; - response?: string; - channelId: string; - }) { - return this.remoteInvititations.get(remoteInvitationProps.uid).refuse(); + async renewToken(token: string) { + return this.client.renewToken(token); } - async cancelLocalInvitation(LocalInvitationProps: { - uid: string; - content?: string; - channelId?: string; - }) { - console.log(this.localInvititations.get(LocalInvitationProps.uid)); - return this.localInvititations.get(LocalInvitationProps.uid).cancel(); + removeAllListeners() { + this.eventsMap = new Map([ + ['linkState', () => null], + ['storage', () => null], + ['presence', () => null], + ['message', () => null], + ]); + return this.client.removeAllListeners(); } +} - getSdkVersion(callback: (version: string) => void) { - callback(VERSION); - } +export class RtmConfig { + public appId: string; + public userId: string; - addListener( - event: EventType, - listener: RtmClientEvents[EventType], - ): Subscription { - if (event === 'ChannelAttributesUpdated') { - this.channelEventsMap.set(event, listener as callbackType); - } - return { - remove: () => { - console.log( - 'Use destroy method to remove all the event listeners from the RtcEngine instead.', - ); - }, - }; + constructor(config: {appId: string; userId: string}) { + this.appId = config.appId; + this.userId = config.userId; } } +// Factory function to create RTM client +export function createAgoraRtmClient(config: RtmConfig): RTMWebClient { + return new RTMWebClient(config.appId, config.userId); +} diff --git a/template/customization-api/typeDefinition.ts b/template/customization-api/typeDefinition.ts index 50745ca57..3750f3f43 100644 --- a/template/customization-api/typeDefinition.ts +++ b/template/customization-api/typeDefinition.ts @@ -86,6 +86,7 @@ export interface VideoCallInterface extends BeforeAndAfterInterface { captionPanel?: React.ComponentType; transcriptPanel?: React.ComponentType; virtualBackgroundPanel?: React.ComponentType; + breakoutRoomPanel?: React.ComponentType; customLayout?: (layouts: LayoutItem[]) => LayoutItem[]; wrapper?: React.ComponentType; customAgentInterface?: React.ComponentType; diff --git a/template/defaultConfig.js b/template/defaultConfig.js index 19d8fd349..82979f37f 100644 --- a/template/defaultConfig.js +++ b/template/defaultConfig.js @@ -11,7 +11,7 @@ const DefaultConfig = { PRECALL: true, CHAT: true, CLOUD_RECORDING: false, - RECORDING_MODE: 'WEB', + RECORDING_MODE: 'MIX', SCREEN_SHARING: true, LANDING_SUB_HEADING: 'The Real-Time Engagement Platform', ENCRYPTION_ENABLED: false, @@ -91,6 +91,7 @@ const DefaultConfig = { ENABLE_WAITING_ROOM_AUTO_APPROVAL: false, ENABLE_WAITING_ROOM_AUTO_REQUEST: false, ENABLE_TEXT_TRACKS: false, + ENABLE_BREAKOUT_ROOM: false, }; module.exports = DefaultConfig; diff --git a/template/global.d.ts b/template/global.d.ts index cf136cf05..4bc494e7c 100644 --- a/template/global.d.ts +++ b/template/global.d.ts @@ -178,6 +178,7 @@ interface ConfigInterface { ENABLE_WAITING_ROOM_AUTO_APPROVAL: boolean; ENABLE_WAITING_ROOM_AUTO_REQUEST: boolean; ENABLE_TEXT_TRACKS: boolean; + ENABLE_BREAKOUT_ROOM: boolean; } declare var $config: ConfigInterface; declare module 'customization' { diff --git a/template/ios/Podfile b/template/ios/Podfile index bbb0b8661..5f5bce43a 100644 --- a/template/ios/Podfile +++ b/template/ios/Podfile @@ -5,6 +5,13 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip +require 'cocoapods' +class Pod::Installer::Xcode::TargetValidator + def verify_no_duplicate_framework_and_library_names(*) + # Do nothing - allows duplicate frameworks + end +end + platform :ios, min_ios_version_supported prepare_react_native_project! @@ -59,6 +66,40 @@ target 'HelloWorld' do :mac_catalyst_enabled => false ) __apply_Xcode_12_5_M1_post_install_workaround(installer) + + # === BEGIN: Bitcode Stripping === + bitcode_strip_path = `xcrun --find bitcode_strip`.chomp + + def strip_bitcode(framework_path, bitcode_strip_path) + if File.exist?(framework_path) + puts ":wrench: Stripping bitcode from: #{framework_path}" + system("#{bitcode_strip_path} #{framework_path} -r -o #{framework_path}") + else + puts ":warning: Framework not found: #{framework_path}" + end + end + + frameworks_to_strip = [ + # Agora RTM - device & simulator + "Pods/AgoraRtm_iOS/AgoraRtmKit.xcframework/ios-arm64_armv7/AgoraRtmKit.framework/AgoraRtmKit", + "Pods/AgoraRtm_iOS/AgoraRtmKit.xcframework/ios-arm64_i386_x86_64-simulator/AgoraRtmKit.framework/AgoraRtmKit", + + # Hermes - device & simulator + "Pods/hermes-engine/destroot/Library/Frameworks/universal/hermes.xcframework/ios-arm64/hermes.framework/hermes", + "Pods/hermes-engine/destroot/Library/Frameworks/universal/hermes.xcframework/ios-arm64_x86_64-simulator/hermes.framework/hermes" + ] + + frameworks_to_strip.each do |framework| + strip_bitcode(framework, bitcode_strip_path) + end + + # Disable ENABLE_BITCODE for all Pod targets + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end + # === END: Bitcode Stripping === end end diff --git a/template/package.json b/template/package.json index 445b26a24..ef2865a74 100644 --- a/template/package.json +++ b/template/package.json @@ -48,8 +48,8 @@ "url": "https://github.com/AgoraIO-Community/app-builder-core" }, "dependencies": { - "@datadog/browser-logs": "^5.15.0", - "@datadog/mobile-react-native": "^2.3.2", + "@datadog/browser-logs": "6.17.0", + "@datadog/mobile-react-native": "2.11.0", "@gorhom/bottom-sheet": "4.4.7", "@netless/react-native-whiteboard": "^0.0.14", "@openspacelabs/react-native-zoomable-view": "^2.1.1", @@ -63,9 +63,9 @@ "agora-extension-ai-denoiser": "1.1.0", "agora-extension-beauty-effect": "^1.0.2-beta", "agora-extension-virtual-background": "^1.1.3", - "agora-react-native-rtm": "1.5.1", + "agora-react-native-rtm": "2.2.4", "agora-rtc-sdk-ng": "4.23.4", - "agora-rtm-sdk": "1.5.1", + "agora-rtm-sdk": "2.2.2", "buffer": "^6.0.3", "electron-log": "4.3.5", "electron-squirrel-startup": "1.0.0", diff --git a/template/src/AppRoutes.tsx b/template/src/AppRoutes.tsx index 18e3b9281..0d353c914 100644 --- a/template/src/AppRoutes.tsx +++ b/template/src/AppRoutes.tsx @@ -11,7 +11,6 @@ */ import React from 'react'; import Join from './pages/Join'; -import VideoCall from './pages/VideoCall'; import Create from './pages/Create'; import {Route, Switch, Redirect} from './components/Router'; import AuthRoute from './auth/AuthRoute'; @@ -25,6 +24,7 @@ import {useIsRecordingBot} from './subComponents/recording/useIsRecordingBot'; import {isValidReactComponent} from './utils/common'; import ErrorBoundary from './components/ErrorBoundary'; import {ErrorBoundaryFallback} from './components/ErrorBoundaryFallback'; +import VideoCallStateWrapper from './pages/video-call/VideoCallStateWrapper'; function VideoCallWrapper(props) { const {isRecordingBot} = useIsRecordingBot(); @@ -32,13 +32,13 @@ function VideoCallWrapper(props) { return isRecordingBot ? ( - + ) : ( - + ); diff --git a/template/src/ai-agent/components/ControlButtons.tsx b/template/src/ai-agent/components/ControlButtons.tsx index 5f19fe2c8..b3c6a320c 100644 --- a/template/src/ai-agent/components/ControlButtons.tsx +++ b/template/src/ai-agent/components/ControlButtons.tsx @@ -31,7 +31,7 @@ export const MicButton = () => { borderRadius: 50, marginHorizontal: 8, }} - onPress={() => muteToggle(MUTE_LOCAL_TYPE.audio)}> + onPress={async () => await muteToggle(MUTE_LOCAL_TYPE.audio)}> void; enabled: boolean; selectedValue: string; + containerStyle?: StyleProp; } const Dropdown: FC = ({ @@ -37,6 +40,7 @@ const Dropdown: FC = ({ enabled, selectedValue, icon, + containerStyle = {}, }) => { const DropdownButton = useRef(); const [visible, setVisible] = useState(false); @@ -148,6 +152,7 @@ const Dropdown: FC = ({ ref={DropdownButton} style={[ styles.dropdownOptionContainer, + containerStyle, !enabled || !data || !data.length ? {opacity: ThemeConfig.EmphasisOpacity.disabled} : {}, diff --git a/template/src/atoms/TertiaryButton.tsx b/template/src/atoms/TertiaryButton.tsx index 43cb749ce..bdb88408c 100644 --- a/template/src/atoms/TertiaryButton.tsx +++ b/template/src/atoms/TertiaryButton.tsx @@ -120,6 +120,6 @@ const styles = StyleSheet.create({ cursor: 'default', }, disabledText: { - color: $config.SEMANTIC_NEUTRAL, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, }, }); diff --git a/template/src/atoms/UserAvatar.tsx b/template/src/atoms/UserAvatar.tsx index 57f850efe..4a226b95e 100644 --- a/template/src/atoms/UserAvatar.tsx +++ b/template/src/atoms/UserAvatar.tsx @@ -10,7 +10,7 @@ function getInitials(name: string) { return 'U'; } -const UserAvatar = ({name, containerStyle, textStyle}) => { +const UserAvatar = ({name, containerStyle = {}, textStyle = {}}) => { return ( ) => void; } export enum controlMessageEnum { diff --git a/template/src/components/Controls.tsx b/template/src/components/Controls.tsx index 0828af9e2..d6631c1a0 100644 --- a/template/src/components/Controls.tsx +++ b/template/src/components/Controls.tsx @@ -104,6 +104,7 @@ import { toolbarItemVirtualBackgroundText, toolbarItemWhiteboardText, toolbarItemManageTextTracksText, + toolbarItemBreakoutRoomText, } from '../language/default-labels/videoCallScreenLabels'; import {LogSource, logger} from '../logger/AppBuilderLogger'; import {useModal} from '../utils/useModal'; @@ -117,6 +118,8 @@ import { ScreenshareToolbarItem, } from './controls/toolbar-items'; import ViewTextTracksModal from './text-tracks/ViewTextTracksModal'; +import {useBreakoutRoom} from './breakout-room/context/BreakoutRoomContext'; +import {ExitBreakoutRoomToolbarItem} from './controls/toolbar-items/ExitBreakoutRoomToolbarItem'; export const useToggleWhiteboard = () => { const { @@ -285,6 +288,7 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { const virtualBackgroundLabel = useString(toolbarItemVirtualBackgroundText)(); const chatLabel = useString(toolbarItemChatText)(); const inviteLabel = useString(toolbarItemInviteText)(); + const breakoutRoomLabel = useString(toolbarItemBreakoutRoomText)(); const peopleLabel = useString(toolbarItemPeopleText)(); const layoutLabel = useString(toolbarItemLayoutText)(); const {dispatch} = useContext(DispatchContext); @@ -482,7 +486,8 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { : false; // 2. whiteboard ends - if (isHost && $config.ENABLE_WHITEBOARD && isWebInternal()) { + const canAccessWhiteboard = useControlPermissionMatrix('whiteboardControl'); + if (canAccessWhiteboard) { actionMenuitems.push({ componentName: 'whiteboard', order: 2, @@ -524,7 +529,9 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } // 3. host can see stt options and attendee can view only when stt is enabled by a host in the channel - if ($config.ENABLE_STT && $config.ENABLE_CAPTION) { + const canAccessCaption = useControlPermissionMatrix('captionsControl'); + const canAccessTranscripts = useControlPermissionMatrix('transcriptsControl'); + if (canAccessCaption) { actionMenuitems.push({ componentName: 'caption', order: 3, @@ -554,7 +561,8 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { }, }); // 4. Meeting transcript - if ($config.ENABLE_MEETING_TRANSCRIPT) { + + if (canAccessTranscripts) { actionMenuitems.push({ componentName: 'transcript', order: 4, @@ -591,7 +599,8 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } // 5. view recordings - if (isHost && $config.CLOUD_RECORDING && isWeb()) { + const canAccessViewRecording = useControlPermissionMatrix('recordingControl'); + if (canAccessViewRecording && isWeb()) { actionMenuitems.push({ componentName: 'view-recordings', order: 5, @@ -677,6 +686,12 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } // 8. Screenshare + const {permissions} = useBreakoutRoom(); + const canAccessBreakoutRoom = useControlPermissionMatrix( + 'breakoutRoomControl', + ); + const canScreenshareInBreakoutRoom = permissions?.canScreenshare; + const canAccessScreenshare = useControlPermissionMatrix('screenshareControl'); if (canAccessScreenshare) { if ( @@ -693,13 +708,16 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { componentName: 'screenshare', order: 8, disabled: - rtcProps.role == ClientRoleType.ClientRoleAudience && - $config.EVENT_MODE && - $config.RAISE_HAND && - !isHost, + (rtcProps.role == ClientRoleType.ClientRoleAudience && + $config.EVENT_MODE && + $config.RAISE_HAND && + !isHost) || + !canScreenshareInBreakoutRoom, icon: isScreenshareActive ? 'stop-screen-share' : 'screen-share', iconColor: isScreenshareActive ? $config.SEMANTIC_ERROR + : !canScreenshareInBreakoutRoom + ? $config.SEMANTIC_NEUTRAL : $config.SECONDARY_ACTION_COLOR, textColor: isScreenshareActive ? $config.SEMANTIC_ERROR @@ -714,7 +732,8 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } // 9. Recording - if (isHost && $config.CLOUD_RECORDING) { + const canAccessRecording = useControlPermissionMatrix('recordingControl'); + if (canAccessRecording) { actionMenuitems.push({ hide: w => { return w >= BREAKPOINTS.sm ? true : false; @@ -817,9 +836,9 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { } // 13. Text-tracks to download - const canAccessAllTextTracks = - useControlPermissionMatrix('viewAllTextTracks'); - + const canAccessAllTextTracks = useControlPermissionMatrix( + 'viewAllTextTracksControl', + ); if (canAccessAllTextTracks) { actionMenuitems.push({ componentName: 'view-all-text-tracks', @@ -834,6 +853,22 @@ const MoreButton = (props: {fields: ToolbarMoreButtonDefaultFields}) => { }); } + // 14. Breakout Room + if (canAccessBreakoutRoom) { + actionMenuitems.push({ + componentName: 'breakoutRoom', + order: 14, + icon: 'breakout-room', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: isHost ? breakoutRoomLabel : 'View Breakout Rooms', + onPress: () => { + setActionMenuVisible(false); + setSidePanel(SidePanelType.BreakoutRoom); + }, + }); + } + useEffect(() => { if (isHovered) { setActionMenuVisible(true); @@ -1133,18 +1168,18 @@ export const MoreButtonToolbarItem = (props?: { forceUpdate(); }, [isHost]); + const canAccessRecording = useControlPermissionMatrix('recordingControl'); + const canAccessWhiteboard = useControlPermissionMatrix('whiteboardControl'); + const canAccessCaptions = useControlPermissionMatrix('captionsControl'); return width < BREAKPOINTS.lg || - ($config.ENABLE_STT && - $config.ENABLE_CAPTION && - (isHost || (!isHost && isSTTActive))) || + (canAccessCaptions && (isHost || (!isHost && isSTTActive))) || $config.ENABLE_NOISE_CANCELLATION || - (isHost && $config.CLOUD_RECORDING && isWeb()) || + (canAccessRecording && isWeb()) || ($config.ENABLE_VIRTUAL_BACKGROUND && !$config.AUDIO_ROOM) || - (isHost && $config.ENABLE_WHITEBOARD && isWebInternal()) ? ( + canAccessWhiteboard ? ( {((!$config.AUTO_CONNECT_RTM && !isHost) || $config.AUTO_CONNECT_RTM) && - $config.ENABLE_WHITEBOARD && - isWebInternal() ? ( + canAccessWhiteboard ? ( ) : ( <> @@ -1192,6 +1227,7 @@ const Controls = (props: ControlsProps) => { const {sttLanguage, isSTTActive} = useRoomInfo(); const {addStreamMessageListener} = useSpeechToText(); + const {permissions} = useBreakoutRoom(); React.useEffect(() => { defaultContentRef.current = defaultContent; @@ -1286,6 +1322,9 @@ const Controls = (props: ControlsProps) => { const canAccessInvite = useControlPermissionMatrix('inviteControl'); const canAccessScreenshare = useControlPermissionMatrix('screenshareControl'); + const canAccessRecordings = useControlPermissionMatrix('recordingControl'); + + const canAccessExitBreakoutRoomBtn = permissions?.canExitRoom; const defaultItems: ToolbarPresetProps['items'] = React.useMemo(() => { return { @@ -1335,7 +1374,7 @@ const Controls = (props: ControlsProps) => { }, recording: { align: 'center', - component: RecordingToolbarItem, + component: canAccessRecordings ? RecordingToolbarItem : null, order: 5, hide: w => { return w < BREAKPOINTS.sm ? true : false; @@ -1346,13 +1385,20 @@ const Controls = (props: ControlsProps) => { component: MoreButtonToolbarItem, order: 6, }, + 'exit-breakout-room': { + align: 'center', + component: canAccessExitBreakoutRoomBtn + ? ExitBreakoutRoomToolbarItem + : null, + order: 7, + }, 'end-call': { align: 'center', component: LocalEndcallToolbarItem, - order: 7, + order: 8, }, }; - }, [canAccessInvite, canAccessScreenshare]); + }, [canAccessInvite, canAccessScreenshare, canAccessExitBreakoutRoomBtn]); const mergedItems = CustomToolbarMerge( includeDefaultItems ? defaultItems : {}, diff --git a/template/src/components/DeviceConfigure.tsx b/template/src/components/DeviceConfigure.tsx index f6bfc3082..d3a9089a8 100644 --- a/template/src/components/DeviceConfigure.tsx +++ b/template/src/components/DeviceConfigure.tsx @@ -50,7 +50,7 @@ const log = (...args: any[]) => { type WebRtcEngineInstance = InstanceType; interface Props { - userRole: ClientRoleType; + userRole?: ClientRoleType; } export type deviceInfo = MediaDeviceInfo; export type deviceId = deviceInfo['deviceId']; diff --git a/template/src/components/EventsConfigure.tsx b/template/src/components/EventsConfigure.tsx index 61d10b8e0..3167997a9 100644 --- a/template/src/components/EventsConfigure.tsx +++ b/template/src/components/EventsConfigure.tsx @@ -268,7 +268,8 @@ const EventsConfigure: React.FC = ({ permissionStatusRef.current = permissionStatus; }, [permissionStatus]); - const {hasUserJoinedRTM, isInitialQueueCompleted} = useContext(ChatContext); + const {hasUserJoinedRTM, isInitialQueueCompleted, syncUserState} = + useContext(ChatContext); const {startSpeechToText, addStreamMessageListener} = useSpeechToText(); //auto start stt @@ -612,10 +613,11 @@ const EventsConfigure: React.FC = ({ if (!isHostRef.current) return; const {attendee_uid, approved} = JSON.parse(data?.payload); // update waiting room status in other host's panel - dispatch({ - type: 'UpdateRenderList', - value: [attendee_uid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [attendee_uid, {isInWaitingRoom: false}], + // }); + syncUserState(attendee_uid, {isInWaitingRoom: false}); waitingRoomRef.current[attendee_uid] = approved ? 'APPROVED' : 'REJECTED'; // hide toast in other host's screen @@ -660,10 +662,11 @@ const EventsConfigure: React.FC = ({ defaultContentRef.current.defaultContent[attendee_uid]?.name || 'Attendee'; // put the attendee in waitingroom in renderlist - dispatch({ - type: 'UpdateRenderList', - value: [attendee_uid, {isInWaitingRoom: true}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [attendee_uid, {isInWaitingRoom: true}], + // }); + syncUserState(attendee_uid, {isInWaitingRoom: true}); waitingRoomRef.current[attendee_uid] = 'PENDING'; // check if any other host has approved then dont show permission to join the room @@ -676,10 +679,11 @@ const EventsConfigure: React.FC = ({ attendee_screenshare_uid: attendee_screenshare_uid, approved: true, }); - dispatch({ - type: 'UpdateRenderList', - value: [attendee_uid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [attendee_uid, {isInWaitingRoom: false}], + // }); + syncUserState(attendee_uid, {isInWaitingRoom: false}); waitingRoomRef.current[attendee_uid] = 'APPROVED'; @@ -724,10 +728,11 @@ const EventsConfigure: React.FC = ({ attendee_screenshare_uid: attendee_screenshare_uid, approved: false, }); - dispatch({ - type: 'UpdateRenderList', - value: [attendee_uid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [attendee_uid, {isInWaitingRoom: false}], + // }); + syncUserState(attendee_uid, {isInWaitingRoom: false}); waitingRoomRef.current[attendee_uid] = 'REJECTED'; diff --git a/template/src/components/Navbar.tsx b/template/src/components/Navbar.tsx index abb66fd91..7ff2a9613 100644 --- a/template/src/components/Navbar.tsx +++ b/template/src/components/Navbar.tsx @@ -70,6 +70,7 @@ import { SettingsToolbarItem, } from './controls/toolbar-items'; import {useControlPermissionMatrix} from './controls/useControlPermissionMatrix'; +import BreakoutMeetingTitle from './breakout-room/ui/BreakoutMeetingTitle'; export const ParticipantsCountView = ({ isMobileView = false, @@ -376,13 +377,16 @@ export const MeetingTitleToolbarItem = () => { } = useRoomInfo(); return ( - - {trimText(meetingTitle)} - + + + {trimText(meetingTitle)} + + + ); }; @@ -632,12 +636,11 @@ const style = StyleSheet.create({ roomNameContainer: { zIndex: 10, flex: 1, - flexDirection: 'row', - alignItems: 'center', + flexDirection: 'column', + alignItems: 'flex-start', + paddingLeft: 13, }, - roomNameText: { - alignSelf: 'center', fontSize: ThemeConfig.FontSize.normal, color: $config.FONT_COLOR, fontWeight: '600', diff --git a/template/src/components/RTMConfigure-legacy.tsx b/template/src/components/RTMConfigure-legacy.tsx new file mode 100644 index 000000000..53010409d --- /dev/null +++ b/template/src/components/RTMConfigure-legacy.tsx @@ -0,0 +1,848 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +// @ts-nocheck +import React, {useState, useContext, useEffect, useRef} from 'react'; +import RtmEngine, {RtmChannelAttribute} from 'agora-react-native-rtm'; +import { + ContentInterface, + DispatchContext, + PropsContext, + useLocalUid, +} from '../../agora-rn-uikit'; +import ChatContext from './ChatContext'; +import {Platform} from 'react-native'; +import {backOff} from 'exponential-backoff'; +import {useString} from '../utils/useString'; +import {isAndroid, isIOS, isWeb, isWebInternal} from '../utils/common'; +import {useContent, useIsAttendee, useUserName} from 'customization-api'; +import { + safeJsonParse, + timeNow, + hasJsonStructure, + getMessageTime, + get32BitUid, +} from '../rtm/utils'; +import {EventUtils, EventsQueue, EventNames} from '../rtm-events'; +import events, {PersistanceLevel} from '../rtm-events-api'; +import RTMEngine from '../rtm/RTMEngine'; +import {filterObject} from '../utils'; +import SDKEvents from '../utils/SdkEvents'; +import isSDK from '../utils/isSDK'; +import {useAsyncEffect} from '../utils/useAsyncEffect'; +import { + WaitingRoomStatus, + useRoomInfo, +} from '../components/room-info/useRoomInfo'; +import LocalEventEmitter, { + LocalEventsEnum, +} from '../rtm-events-api/LocalEvents'; +import {PSTNUserLabel} from '../language/default-labels/videoCallScreenLabels'; +import {controlMessageEnum} from '../components/ChatContext'; +import {LogSource, logger} from '../logger/AppBuilderLogger'; +import {RECORDING_BOT_UID} from '../utils/constants'; + +export enum UserType { + ScreenShare = 'screenshare', +} + +const RtmConfigure = (props: any) => { + const rtmInitTimstamp = new Date().getTime(); + const localUid = useLocalUid(); + const {callActive} = props; + const {rtcProps} = useContext(PropsContext); + const {dispatch} = useContext(DispatchContext); + const {defaultContent, activeUids} = useContent(); + const defaultContentRef = useRef({defaultContent: defaultContent}); + const activeUidsRef = useRef({activeUids: activeUids}); + + const { + waitingRoomStatus, + data: {isHost}, + } = useRoomInfo(); + const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); + + const isHostRef = useRef({isHost: isHost}); + + useEffect(() => { + isHostRef.current.isHost = isHost; + }, [isHost]); + + useEffect(() => { + waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; + }, [waitingRoomStatus]); + + /** + * inside event callback state won't have latest value. + * so creating ref to access the state + */ + useEffect(() => { + activeUidsRef.current.activeUids = activeUids; + }, [activeUids]); + + useEffect(() => { + defaultContentRef.current.defaultContent = defaultContent; + }, [defaultContent]); + + const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); + const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); + const [onlineUsersCount, setTotalOnlineUsers] = useState(0); + + let engine = useRef(null!); + const timerValueRef: any = useRef(5); + + React.useEffect(() => { + setTotalOnlineUsers( + Object.keys( + filterObject( + defaultContent, + ([k, v]) => + v?.type === 'rtc' && + !v.offline && + activeUids.indexOf(v?.uid) !== -1, + ), + ).length, + ); + }, [defaultContent]); + + React.useEffect(() => { + if (!$config.ENABLE_CONVERSATIONAL_AI) { + const handBrowserClose = ev => { + ev.preventDefault(); + return (ev.returnValue = 'Are you sure you want to exit?'); + }; + const logoutRtm = () => { + engine.current.leaveChannel(rtcProps.channel); + }; + + if (!isWebInternal()) return; + window.addEventListener( + 'beforeunload', + isWeb() && !isSDK() ? handBrowserClose : () => {}, + ); + + window.addEventListener('pagehide', logoutRtm); + // cleanup this component + return () => { + window.removeEventListener( + 'beforeunload', + isWeb() && !isSDK() ? handBrowserClose : () => {}, + ); + window.removeEventListener('pagehide', logoutRtm); + }; + } + }, []); + + const doLoginAndSetupRTM = async () => { + try { + logger.log(LogSource.AgoraSDK, 'API', 'RTM login starts'); + await engine.current.login({ + uid: localUid.toString(), + token: rtcProps.rtm, + }); + logger.log(LogSource.AgoraSDK, 'API', 'RTM login done'); + RTMEngine.getInstance().setLocalUID(localUid.toString()); + logger.log(LogSource.AgoraSDK, 'API', 'RTM local Uid set'); + timerValueRef.current = 5; + await setAttribute(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM setting attribute done'); + } catch (error) { + logger.error(LogSource.AgoraSDK, 'Log', 'RTM login failed..Trying again'); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + doLoginAndSetupRTM(); + }, timerValueRef.current * 1000); + } + }; + + const setAttribute = async () => { + const rtmAttributes = [ + {key: 'screenUid', value: String(rtcProps.screenShareUid)}, + {key: 'isHost', value: String(isHostRef.current.isHost)}, + ]; + try { + await engine.current.setLocalUserAttributes(rtmAttributes); + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM setting local user attributes', + { + attr: rtmAttributes, + }, + ); + timerValueRef.current = 5; + await joinChannel(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM join channel done', { + data: rtmAttributes, + }); + setHasUserJoinedRTM(true); + await runQueuedEvents(); + setIsInitialQueueCompleted(true); + logger.log( + LogSource.AgoraSDK, + 'Log', + 'RTM queued events finished running', + { + attr: rtmAttributes, + }, + ); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM setAttribute failed..Trying again', + ); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + setAttribute(); + }, timerValueRef.current * 1000); + } + }; + + const joinChannel = async () => { + try { + if (RTMEngine.getInstance().channelUid !== rtcProps.channel) { + await engine.current.joinChannel(rtcProps.channel); + logger.log(LogSource.AgoraSDK, 'API', 'RTM joinChannel', { + data: rtcProps.channel, + }); + RTMEngine.getInstance().setChannelId(rtcProps.channel); + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM setChannelId', + rtcProps.channel, + ); + logger.debug( + LogSource.SDK, + 'Event', + 'Emitting rtm joined', + rtcProps.channel, + ); + SDKEvents.emit('_rtm-joined', rtcProps.channel); + } else { + logger.debug( + LogSource.AgoraSDK, + 'Log', + 'RTM already joined channel skipping', + rtcProps.channel, + ); + } + timerValueRef.current = 5; + await getMembers(); + await readAllChannelAttributes(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM getMembers done'); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM joinChannel failed..Trying again', + ); + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + joinChannel(); + }, timerValueRef.current * 1000); + } + }; + + const updateRenderListState = ( + uid: number, + data: Partial, + ) => { + dispatch({type: 'UpdateRenderList', value: [uid, data]}); + }; + + const getMembers = async () => { + try { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelMembersByID(getMembers) start', + ); + await engine.current + .getChannelMembersBychannelId(rtcProps.channel) + .then(async data => { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelMembersByID data received', + data, + ); + await Promise.all( + data.members.map(async (member: any) => { + const backoffAttributes = backOff( + async () => { + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM fetching getUserAttributesByUid for member ${member.uid}`, + ); + const attr = await engine.current.getUserAttributesByUid( + member.uid, + ); + if (!attr || !attr.attributes) { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM attributes for member not found', + ); + throw attr; + } + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM getUserAttributesByUid for member ${member.uid} received`, + { + attr, + }, + ); + for (const key in attr.attributes) { + if ( + attr.attributes.hasOwnProperty(key) && + attr.attributes[key] + ) { + return attr; + } else { + throw attr; + } + } + }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `[retrying] Attempt ${idx}. Fetching ${member.uid}'s name`, + e, + ); + return true; + }, + }, + ); + try { + const attr = await backoffAttributes; + console.log('[user attributes]:', {attr}); + //RTC layer uid type is number. so doing the parseInt to convert to number + //todo hari check android uid comparsion + const uid = parseInt(member.uid); + const screenUid = parseInt(attr?.attributes?.screenUid); + //start - updating user data in rtc + const userData = { + screenUid: screenUid, + //below thing for livestreaming + type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', + uid, + offline: false, + isHost: attr?.attributes?.isHost, + lastMessageTimeStamp: 0, + }; + updateRenderListState(uid, userData); + //end- updating user data in rtc + + //start - updating screenshare data in rtc + const screenShareUser = { + type: UserType.ScreenShare, + parentUid: uid, + }; + updateRenderListState(screenUid, screenShareUser); + //end - updating screenshare data in rtc + // setting screenshare data + // name of the screenUid, isActive: false, (when the user starts screensharing it becomes true) + // isActive to identify all active screenshare users in the call + for (const [key, value] of Object.entries(attr?.attributes)) { + if (hasJsonStructure(value as string)) { + const data = { + evt: key, + value: value, + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: data, + uid: member.uid, + ts: timeNow(), + }); + } + } + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `Could not retrieve name of ${member.uid}`, + e, + ); + } + }), + ); + logger.debug( + LogSource.AgoraSDK, + 'Log', + 'RTM fetched all data and user attr...RTM init done', + ); + }); + timerValueRef.current = 5; + } catch (error) { + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + await getMembers(); + }, timerValueRef.current * 1000); + } + }; + + const readAllChannelAttributes = async () => { + try { + await engine.current + .getChannelAttributes(rtcProps.channel) + .then(async data => { + for (const item of data) { + const {key, value, lastUpdateTs, lastUpdateUserId} = item; + if (hasJsonStructure(value as string)) { + const evtData = { + evt: key, + value, + }; + // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events + EventsQueue.enqueue({ + data: evtData, + uid: lastUpdateUserId, + ts: lastUpdateTs, + }); + } + } + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM getChannelAttributes data received', + data, + ); + }); + timerValueRef.current = 5; + } catch (error) { + setTimeout(async () => { + timerValueRef.current = timerValueRef.current + timerValueRef.current; + await readAllChannelAttributes(); + }, timerValueRef.current * 1000); + } + }; + + const init = async () => { + //on sdk due to multiple re-render we are getting rtm error code 8 + //you are joining the same channel too frequently, exceeding the allowed rate of joining the same channel multiple times within a short period + //so checking rtm connection state before proceed + if (engine?.current?.client?.connectionState === 'CONNECTED') { + return; + } + logger.log(LogSource.AgoraSDK, 'Log', 'RTM creating engine...'); + engine.current = RTMEngine.getInstance().engine; + RTMEngine.getInstance(); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM engine creation done'); + + engine.current.on('connectionStateChanged', (evt: any) => { + //console.log(evt); + }); + engine.current.on('error', (evt: any) => { + // console.log(evt); + }); + engine.current.on('channelMemberJoined', (data: any) => { + logger.log(LogSource.AgoraSDK, 'Event', 'channelMemberJoined', data); + const backoffAttributes = backOff( + async () => { + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM fetching getUserAttributesByUid for member ${data.uid}`, + ); + const attr = await engine.current.getUserAttributesByUid(data.uid); + if (!attr || !attr.attributes) { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM attributes for member not found', + ); + throw attr; + } + logger.log( + LogSource.AgoraSDK, + 'API', + `RTM getUserAttributesByUid for member ${data.uid} received`, + { + attr, + }, + ); + for (const key in attr.attributes) { + if (attr.attributes.hasOwnProperty(key) && attr.attributes[key]) { + return attr; + } else { + throw attr; + } + } + }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `[retrying] Attempt ${idx}. Fetching ${data.uid}'s name`, + e, + ); + return true; + }, + }, + ); + async function getname() { + try { + const attr = await backoffAttributes; + console.log('[user attributes]:', {attr}); + const uid = parseInt(data.uid); + const screenUid = parseInt(attr?.attributes?.screenUid); + + //start - updating user data in rtc + const userData = { + screenUid: screenUid, + //below thing for livestreaming + type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', + uid, + offline: false, + lastMessageTimeStamp: 0, + isHost: attr?.attributes?.isHost, + }; + updateRenderListState(uid, userData); + //end- updating user data in rtc + + //start - updating screenshare data in rtc + const screenShareUser = { + type: UserType.ScreenShare, + parentUid: uid, + }; + updateRenderListState(screenUid, screenShareUser); + //end - updating screenshare data in rtc + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Event', + `Failed to retrive name of ${data.uid}`, + e, + ); + } + } + getname(); + }); + + engine.current.on('channelMemberLeft', (data: any) => { + logger.debug(LogSource.AgoraSDK, 'Event', 'channelMemberLeft', data); + // Chat of left user becomes undefined. So don't cleanup + const uid = data?.uid ? parseInt(data?.uid) : undefined; + if (!uid) return; + SDKEvents.emit('_rtm-left', uid); + // updating the rtc data + updateRenderListState(uid, { + offline: true, + }); + }); + + engine.current.addListener( + 'ChannelAttributesUpdated', + (attributeList: RtmChannelAttribute[]) => { + try { + attributeList.map((attribute: RtmChannelAttribute) => { + const {key, value, lastUpdateTs, lastUpdateUserId} = attribute; + const timestamp = getMessageTime(lastUpdateTs); + const sender = Platform.OS + ? get32BitUid(lastUpdateUserId) + : parseInt(lastUpdateUserId); + eventDispatcher( + { + evt: key, + value, + }, + sender, + timestamp, + ); + }); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + error, + ); + } + }, + ); + + engine.current.on('messageReceived', (evt: any) => { + logger.debug(LogSource.Events, 'CUSTOM_EVENTS', 'messageReceived', evt); + const {peerId, ts, text} = evt; + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId); + + try { + eventDispatcher(msg, sender, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + err, + ); + } + }); + + engine.current.on('channelMessageReceived', evt => { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'channelMessageReceived', + evt, + ); + + const {uid, channelId, text, ts} = evt; + //whiteboard upload + if (uid == 1010101) { + const [err, res] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + if (res?.data?.data?.images) { + LocalEventEmitter.emit( + LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, + res?.data?.data?.images, + ); + } + } else { + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + err, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid); + + if (channelId === rtcProps.channel) { + try { + eventDispatcher(msg, sender, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + error, + ); + } + } + } + }); + + await doLoginAndSetupRTM(); + }; + + const runQueuedEvents = async () => { + try { + while (!EventsQueue.isEmpty()) { + const currEvt = EventsQueue.dequeue(); + await eventDispatcher(currEvt.data, currEvt.uid, currEvt.ts); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while running queue events', + error, + ); + } + }; + + const eventDispatcher = async ( + data: { + evt: string; + value: string; + }, + sender: string, + ts: number, + ) => { + console.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'inside eventDispatcher ', + data, + ); + + let evt = '', + value = {}; + + if (data.feat === 'WAITING_ROOM') { + if (data.etyp === 'REQUEST') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + attendee_uid: data.data.data.attendee_uid, + attendee_screenshare_uid: data.data.data.attendee_screenshare_uid, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; //rename if client side RTM meessage is to be sent for approval + value = formattedData; + } + if (data.etyp === 'RESPONSE') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + approved: data.data.data.approved, + channelName: data.data.data.channel_name, + mainUser: data.data.data.mainUser, + screenShare: data.data.data.screenShare, + whiteboard: data.data.data.whiteboard, + chat: data.data.data?.chat, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + } else { + if ( + $config.ENABLE_WAITING_ROOM && + !isHostRef.current?.isHost && + waitingRoomStatusRef.current?.waitingRoomStatus !== + WaitingRoomStatus.APPROVED + ) { + if ( + data.evt === controlMessageEnum.muteAudio || + data.evt === controlMessageEnum.muteVideo + ) { + return; + } else { + evt = data.evt; + value = data.value; + } + } else { + evt = data.evt; + value = data.value; + } + } + + try { + const {payload, persistLevel, source} = JSON.parse(value); + // Step 1: Set local attributes + if (persistLevel === PersistanceLevel.Session) { + const rtmAttribute = {key: evt, value: value}; + await engine.current.addOrUpdateLocalUserAttributes([rtmAttribute]); + } + // Step 2: Emit the event + console.debug(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: '); + EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); + // Because async gets evaluated in a different order when in an sdk + if (evt === 'name') { + setTimeout(() => { + EventUtils.emitEvent(evt, source, { + payload, + persistLevel, + sender, + ts, + }); + }, 200); + } + } catch (error) { + console.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while emiting event:', + error, + ); + } + }; + + const end = async () => { + if (!callActive) { + return; + } + await RTMEngine.getInstance().destroy(); + logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); + if (isIOS() || isAndroid()) { + EventUtils.clear(); + } + setHasUserJoinedRTM(false); + logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); + }; + + useAsyncEffect(async () => { + //waiting room attendee -> rtm login will happen on page load + if ($config.ENABLE_WAITING_ROOM) { + //attendee + //for waiting room attendee rtm login will happen on mount + if (!isHost && !callActive) { + await init(); + } + //host + if ( + isHost && + ($config.AUTO_CONNECT_RTM || (!$config.AUTO_CONNECT_RTM && callActive)) + ) { + await init(); + } + } else { + //non waiting room case + //host and attendee + if ( + $config.AUTO_CONNECT_RTM || + (!$config.AUTO_CONNECT_RTM && callActive) + ) { + await init(); + } + } + return async () => { + await end(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rtcProps.channel, rtcProps.appId, callActive]); + + return ( + + {props.children} + + ); +}; + +export default RtmConfigure; diff --git a/template/src/components/RTMConfigure.tsx b/template/src/components/RTMConfigure.tsx index 53010409d..38d0a0346 100644 --- a/template/src/components/RTMConfigure.tsx +++ b/template/src/components/RTMConfigure.tsx @@ -2,843 +2,56 @@ ******************************************** Copyright Β© 2021 Agora Lab, Inc., all rights reserved. AppBuilder and all associated components, source code, APIs, services, and documentation - (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. Use without a license or in violation of any license terms and conditions (including use for - any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more information visit https://appbuilder.agora.io. ********************************************* */ -// @ts-nocheck -import React, {useState, useContext, useEffect, useRef} from 'react'; -import RtmEngine, {RtmChannelAttribute} from 'agora-react-native-rtm'; -import { - ContentInterface, - DispatchContext, - PropsContext, - useLocalUid, -} from '../../agora-rn-uikit'; -import ChatContext from './ChatContext'; -import {Platform} from 'react-native'; -import {backOff} from 'exponential-backoff'; -import {useString} from '../utils/useString'; -import {isAndroid, isIOS, isWeb, isWebInternal} from '../utils/common'; -import {useContent, useIsAttendee, useUserName} from 'customization-api'; -import { - safeJsonParse, - timeNow, - hasJsonStructure, - getMessageTime, - get32BitUid, -} from '../rtm/utils'; -import {EventUtils, EventsQueue, EventNames} from '../rtm-events'; -import events, {PersistanceLevel} from '../rtm-events-api'; -import RTMEngine from '../rtm/RTMEngine'; -import {filterObject} from '../utils'; -import SDKEvents from '../utils/SdkEvents'; -import isSDK from '../utils/isSDK'; -import {useAsyncEffect} from '../utils/useAsyncEffect'; -import { - WaitingRoomStatus, - useRoomInfo, -} from '../components/room-info/useRoomInfo'; -import LocalEventEmitter, { - LocalEventsEnum, -} from '../rtm-events-api/LocalEvents'; -import {PSTNUserLabel} from '../language/default-labels/videoCallScreenLabels'; -import {controlMessageEnum} from '../components/ChatContext'; -import {LogSource, logger} from '../logger/AppBuilderLogger'; -import {RECORDING_BOT_UID} from '../utils/constants'; -export enum UserType { - ScreenShare = 'screenshare', +import React from 'react'; +import {useLocalUid} from '../../agora-rn-uikit'; +import ChatContext from './ChatContext'; +import {useRTMCore} from '../rtm/RTMCoreProvider'; +import {useRTMConfigureMain} from '../rtm/RTMConfigureMainRoomProvider'; +import {useRTMConfigureBreakout} from '../rtm/RTMConfigureBreakoutRoomProvider'; +import {RTM_ROOMS} from '../rtm/constants'; + +interface Props { + room: RTM_ROOMS; + children: React.ReactNode; } -const RtmConfigure = (props: any) => { - const rtmInitTimstamp = new Date().getTime(); +const RtmConfigure = (props: Props) => { const localUid = useLocalUid(); - const {callActive} = props; - const {rtcProps} = useContext(PropsContext); - const {dispatch} = useContext(DispatchContext); - const {defaultContent, activeUids} = useContent(); - const defaultContentRef = useRef({defaultContent: defaultContent}); - const activeUidsRef = useRef({activeUids: activeUids}); - - const { - waitingRoomStatus, - data: {isHost}, - } = useRoomInfo(); - const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); - - const isHostRef = useRef({isHost: isHost}); - - useEffect(() => { - isHostRef.current.isHost = isHost; - }, [isHost]); - - useEffect(() => { - waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; - }, [waitingRoomStatus]); - - /** - * inside event callback state won't have latest value. - * so creating ref to access the state - */ - useEffect(() => { - activeUidsRef.current.activeUids = activeUids; - }, [activeUids]); - - useEffect(() => { - defaultContentRef.current.defaultContent = defaultContent; - }, [defaultContent]); - - const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); - const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); - const [onlineUsersCount, setTotalOnlineUsers] = useState(0); - - let engine = useRef(null!); - const timerValueRef: any = useRef(5); - - React.useEffect(() => { - setTotalOnlineUsers( - Object.keys( - filterObject( - defaultContent, - ([k, v]) => - v?.type === 'rtc' && - !v.offline && - activeUids.indexOf(v?.uid) !== -1, - ), - ).length, - ); - }, [defaultContent]); - - React.useEffect(() => { - if (!$config.ENABLE_CONVERSATIONAL_AI) { - const handBrowserClose = ev => { - ev.preventDefault(); - return (ev.returnValue = 'Are you sure you want to exit?'); - }; - const logoutRtm = () => { - engine.current.leaveChannel(rtcProps.channel); - }; - - if (!isWebInternal()) return; - window.addEventListener( - 'beforeunload', - isWeb() && !isSDK() ? handBrowserClose : () => {}, - ); - - window.addEventListener('pagehide', logoutRtm); - // cleanup this component - return () => { - window.removeEventListener( - 'beforeunload', - isWeb() && !isSDK() ? handBrowserClose : () => {}, - ); - window.removeEventListener('pagehide', logoutRtm); - }; - } - }, []); - - const doLoginAndSetupRTM = async () => { - try { - logger.log(LogSource.AgoraSDK, 'API', 'RTM login starts'); - await engine.current.login({ - uid: localUid.toString(), - token: rtcProps.rtm, - }); - logger.log(LogSource.AgoraSDK, 'API', 'RTM login done'); - RTMEngine.getInstance().setLocalUID(localUid.toString()); - logger.log(LogSource.AgoraSDK, 'API', 'RTM local Uid set'); - timerValueRef.current = 5; - await setAttribute(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM setting attribute done'); - } catch (error) { - logger.error(LogSource.AgoraSDK, 'Log', 'RTM login failed..Trying again'); - setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - doLoginAndSetupRTM(); - }, timerValueRef.current * 1000); - } - }; - - const setAttribute = async () => { - const rtmAttributes = [ - {key: 'screenUid', value: String(rtcProps.screenShareUid)}, - {key: 'isHost', value: String(isHostRef.current.isHost)}, - ]; - try { - await engine.current.setLocalUserAttributes(rtmAttributes); - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM setting local user attributes', - { - attr: rtmAttributes, - }, - ); - timerValueRef.current = 5; - await joinChannel(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM join channel done', { - data: rtmAttributes, - }); - setHasUserJoinedRTM(true); - await runQueuedEvents(); - setIsInitialQueueCompleted(true); - logger.log( - LogSource.AgoraSDK, - 'Log', - 'RTM queued events finished running', - { - attr: rtmAttributes, - }, - ); - } catch (error) { - logger.error( - LogSource.AgoraSDK, - 'Log', - 'RTM setAttribute failed..Trying again', - ); - setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - setAttribute(); - }, timerValueRef.current * 1000); - } - }; - - const joinChannel = async () => { - try { - if (RTMEngine.getInstance().channelUid !== rtcProps.channel) { - await engine.current.joinChannel(rtcProps.channel); - logger.log(LogSource.AgoraSDK, 'API', 'RTM joinChannel', { - data: rtcProps.channel, - }); - RTMEngine.getInstance().setChannelId(rtcProps.channel); - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM setChannelId', - rtcProps.channel, - ); - logger.debug( - LogSource.SDK, - 'Event', - 'Emitting rtm joined', - rtcProps.channel, - ); - SDKEvents.emit('_rtm-joined', rtcProps.channel); - } else { - logger.debug( - LogSource.AgoraSDK, - 'Log', - 'RTM already joined channel skipping', - rtcProps.channel, - ); - } - timerValueRef.current = 5; - await getMembers(); - await readAllChannelAttributes(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM getMembers done'); - } catch (error) { - logger.error( - LogSource.AgoraSDK, - 'Log', - 'RTM joinChannel failed..Trying again', - ); - setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - joinChannel(); - }, timerValueRef.current * 1000); - } - }; - - const updateRenderListState = ( - uid: number, - data: Partial, - ) => { - dispatch({type: 'UpdateRenderList', value: [uid, data]}); - }; - - const getMembers = async () => { - try { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM getChannelMembersByID(getMembers) start', - ); - await engine.current - .getChannelMembersBychannelId(rtcProps.channel) - .then(async data => { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM getChannelMembersByID data received', - data, - ); - await Promise.all( - data.members.map(async (member: any) => { - const backoffAttributes = backOff( - async () => { - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM fetching getUserAttributesByUid for member ${member.uid}`, - ); - const attr = await engine.current.getUserAttributesByUid( - member.uid, - ); - if (!attr || !attr.attributes) { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM attributes for member not found', - ); - throw attr; - } - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM getUserAttributesByUid for member ${member.uid} received`, - { - attr, - }, - ); - for (const key in attr.attributes) { - if ( - attr.attributes.hasOwnProperty(key) && - attr.attributes[key] - ) { - return attr; - } else { - throw attr; - } - } - }, - { - retry: (e, idx) => { - logger.debug( - LogSource.AgoraSDK, - 'Log', - `[retrying] Attempt ${idx}. Fetching ${member.uid}'s name`, - e, - ); - return true; - }, - }, - ); - try { - const attr = await backoffAttributes; - console.log('[user attributes]:', {attr}); - //RTC layer uid type is number. so doing the parseInt to convert to number - //todo hari check android uid comparsion - const uid = parseInt(member.uid); - const screenUid = parseInt(attr?.attributes?.screenUid); - //start - updating user data in rtc - const userData = { - screenUid: screenUid, - //below thing for livestreaming - type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', - uid, - offline: false, - isHost: attr?.attributes?.isHost, - lastMessageTimeStamp: 0, - }; - updateRenderListState(uid, userData); - //end- updating user data in rtc - - //start - updating screenshare data in rtc - const screenShareUser = { - type: UserType.ScreenShare, - parentUid: uid, - }; - updateRenderListState(screenUid, screenShareUser); - //end - updating screenshare data in rtc - // setting screenshare data - // name of the screenUid, isActive: false, (when the user starts screensharing it becomes true) - // isActive to identify all active screenshare users in the call - for (const [key, value] of Object.entries(attr?.attributes)) { - if (hasJsonStructure(value as string)) { - const data = { - evt: key, - value: value, - }; - // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events - EventsQueue.enqueue({ - data: data, - uid: member.uid, - ts: timeNow(), - }); - } - } - } catch (e) { - logger.error( - LogSource.AgoraSDK, - 'Log', - `Could not retrieve name of ${member.uid}`, - e, - ); - } - }), - ); - logger.debug( - LogSource.AgoraSDK, - 'Log', - 'RTM fetched all data and user attr...RTM init done', - ); - }); - timerValueRef.current = 5; - } catch (error) { - setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - await getMembers(); - }, timerValueRef.current * 1000); - } - }; - - const readAllChannelAttributes = async () => { - try { - await engine.current - .getChannelAttributes(rtcProps.channel) - .then(async data => { - for (const item of data) { - const {key, value, lastUpdateTs, lastUpdateUserId} = item; - if (hasJsonStructure(value as string)) { - const evtData = { - evt: key, - value, - }; - // TODOSUP: Add the data to queue, dont add same mulitple events, use set so as to not repeat events - EventsQueue.enqueue({ - data: evtData, - uid: lastUpdateUserId, - ts: lastUpdateTs, - }); - } - } - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM getChannelAttributes data received', - data, - ); - }); - timerValueRef.current = 5; - } catch (error) { - setTimeout(async () => { - timerValueRef.current = timerValueRef.current + timerValueRef.current; - await readAllChannelAttributes(); - }, timerValueRef.current * 1000); - } - }; + const {room} = props; + const {client} = useRTMCore(); - const init = async () => { - //on sdk due to multiple re-render we are getting rtm error code 8 - //you are joining the same channel too frequently, exceeding the allowed rate of joining the same channel multiple times within a short period - //so checking rtm connection state before proceed - if (engine?.current?.client?.connectionState === 'CONNECTED') { - return; - } - logger.log(LogSource.AgoraSDK, 'Log', 'RTM creating engine...'); - engine.current = RTMEngine.getInstance().engine; - RTMEngine.getInstance(); - logger.log(LogSource.AgoraSDK, 'Log', 'RTM engine creation done'); + // Call hooks unconditionally, but only use data based on room type + let rtmMainData = null; + let rtmBreakoutData = null; + rtmMainData = useRTMConfigureMain(); + rtmBreakoutData = useRTMConfigureBreakout(); - engine.current.on('connectionStateChanged', (evt: any) => { - //console.log(evt); - }); - engine.current.on('error', (evt: any) => { - // console.log(evt); - }); - engine.current.on('channelMemberJoined', (data: any) => { - logger.log(LogSource.AgoraSDK, 'Event', 'channelMemberJoined', data); - const backoffAttributes = backOff( - async () => { - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM fetching getUserAttributesByUid for member ${data.uid}`, - ); - const attr = await engine.current.getUserAttributesByUid(data.uid); - if (!attr || !attr.attributes) { - logger.log( - LogSource.AgoraSDK, - 'API', - 'RTM attributes for member not found', - ); - throw attr; - } - logger.log( - LogSource.AgoraSDK, - 'API', - `RTM getUserAttributesByUid for member ${data.uid} received`, - { - attr, - }, - ); - for (const key in attr.attributes) { - if (attr.attributes.hasOwnProperty(key) && attr.attributes[key]) { - return attr; - } else { - throw attr; - } - } - }, - { - retry: (e, idx) => { - logger.debug( - LogSource.AgoraSDK, - 'Log', - `[retrying] Attempt ${idx}. Fetching ${data.uid}'s name`, - e, - ); - return true; - }, - }, - ); - async function getname() { - try { - const attr = await backoffAttributes; - console.log('[user attributes]:', {attr}); - const uid = parseInt(data.uid); - const screenUid = parseInt(attr?.attributes?.screenUid); + const rtmData = room === RTM_ROOMS.MAIN ? rtmMainData : rtmBreakoutData; - //start - updating user data in rtc - const userData = { - screenUid: screenUid, - //below thing for livestreaming - type: uid === parseInt(RECORDING_BOT_UID) ? 'bot' : 'rtc', - uid, - offline: false, - lastMessageTimeStamp: 0, - isHost: attr?.attributes?.isHost, - }; - updateRenderListState(uid, userData); - //end- updating user data in rtc - - //start - updating screenshare data in rtc - const screenShareUser = { - type: UserType.ScreenShare, - parentUid: uid, - }; - updateRenderListState(screenUid, screenShareUser); - //end - updating screenshare data in rtc - } catch (e) { - logger.error( - LogSource.AgoraSDK, - 'Event', - `Failed to retrive name of ${data.uid}`, - e, - ); - } - } - getname(); - }); - - engine.current.on('channelMemberLeft', (data: any) => { - logger.debug(LogSource.AgoraSDK, 'Event', 'channelMemberLeft', data); - // Chat of left user becomes undefined. So don't cleanup - const uid = data?.uid ? parseInt(data?.uid) : undefined; - if (!uid) return; - SDKEvents.emit('_rtm-left', uid); - // updating the rtc data - updateRenderListState(uid, { - offline: true, - }); - }); - - engine.current.addListener( - 'ChannelAttributesUpdated', - (attributeList: RtmChannelAttribute[]) => { - try { - attributeList.map((attribute: RtmChannelAttribute) => { - const {key, value, lastUpdateTs, lastUpdateUserId} = attribute; - const timestamp = getMessageTime(lastUpdateTs); - const sender = Platform.OS - ? get32BitUid(lastUpdateUserId) - : parseInt(lastUpdateUserId); - eventDispatcher( - { - evt: key, - value, - }, - sender, - timestamp, - ); - }); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - error, - ); - } - }, - ); - - engine.current.on('messageReceived', (evt: any) => { - logger.debug(LogSource.Events, 'CUSTOM_EVENTS', 'messageReceived', evt); - const {peerId, ts, text} = evt; - const [err, msg] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - const timestamp = getMessageTime(ts); - - const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId); - - try { - eventDispatcher(msg, sender, timestamp); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - err, - ); - } - }); - - engine.current.on('channelMessageReceived', evt => { - logger.debug( - LogSource.Events, - 'CUSTOM_EVENTS', - 'channelMessageReceived', - evt, - ); - - const {uid, channelId, text, ts} = evt; - //whiteboard upload - if (uid == 1010101) { - const [err, res] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - if (res?.data?.data?.images) { - LocalEventEmitter.emit( - LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, - res?.data?.data?.images, - ); - } - } else { - const [err, msg] = safeJsonParse(text); - if (err) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'JSON payload incorrect, Error while parsing the payload', - err, - ); - } - - const timestamp = getMessageTime(ts); - - const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid); - - if (channelId === rtcProps.channel) { - try { - eventDispatcher(msg, sender, timestamp); - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while dispatching through eventDispatcher', - error, - ); - } - } - } - }); - - await doLoginAndSetupRTM(); - }; - - const runQueuedEvents = async () => { - try { - while (!EventsQueue.isEmpty()) { - const currEvt = EventsQueue.dequeue(); - await eventDispatcher(currEvt.data, currEvt.uid, currEvt.ts); - } - } catch (error) { - logger.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while running queue events', - error, - ); - } - }; - - const eventDispatcher = async ( - data: { - evt: string; - value: string; - }, - sender: string, - ts: number, - ) => { - console.debug( - LogSource.Events, - 'CUSTOM_EVENTS', - 'inside eventDispatcher ', - data, + if (!rtmData) { + throw new Error( + `RTMConfigure: Invalid room prop '${room}' or missing context provider`, ); - - let evt = '', - value = {}; - - if (data.feat === 'WAITING_ROOM') { - if (data.etyp === 'REQUEST') { - const outputData = { - evt: `${data.feat}_${data.etyp}`, - payload: JSON.stringify({ - attendee_uid: data.data.data.attendee_uid, - attendee_screenshare_uid: data.data.data.attendee_screenshare_uid, - }), - persistLevel: 1, - source: 'core', - }; - const formattedData = JSON.stringify(outputData); - evt = data.feat + '_' + data.etyp; //rename if client side RTM meessage is to be sent for approval - value = formattedData; - } - if (data.etyp === 'RESPONSE') { - const outputData = { - evt: `${data.feat}_${data.etyp}`, - payload: JSON.stringify({ - approved: data.data.data.approved, - channelName: data.data.data.channel_name, - mainUser: data.data.data.mainUser, - screenShare: data.data.data.screenShare, - whiteboard: data.data.data.whiteboard, - chat: data.data.data?.chat, - }), - persistLevel: 1, - source: 'core', - }; - const formattedData = JSON.stringify(outputData); - evt = data.feat + '_' + data.etyp; - value = formattedData; - } - } else { - if ( - $config.ENABLE_WAITING_ROOM && - !isHostRef.current?.isHost && - waitingRoomStatusRef.current?.waitingRoomStatus !== - WaitingRoomStatus.APPROVED - ) { - if ( - data.evt === controlMessageEnum.muteAudio || - data.evt === controlMessageEnum.muteVideo - ) { - return; - } else { - evt = data.evt; - value = data.value; - } - } else { - evt = data.evt; - value = data.value; - } - } - - try { - const {payload, persistLevel, source} = JSON.parse(value); - // Step 1: Set local attributes - if (persistLevel === PersistanceLevel.Session) { - const rtmAttribute = {key: evt, value: value}; - await engine.current.addOrUpdateLocalUserAttributes([rtmAttribute]); - } - // Step 2: Emit the event - console.debug(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: '); - EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); - // Because async gets evaluated in a different order when in an sdk - if (evt === 'name') { - setTimeout(() => { - EventUtils.emitEvent(evt, source, { - payload, - persistLevel, - sender, - ts, - }); - }, 200); - } - } catch (error) { - console.error( - LogSource.Events, - 'CUSTOM_EVENTS', - 'error while emiting event:', - error, - ); - } - }; - - const end = async () => { - if (!callActive) { - return; - } - await RTMEngine.getInstance().destroy(); - logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); - if (isIOS() || isAndroid()) { - EventUtils.clear(); - } - setHasUserJoinedRTM(false); - logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); - }; - - useAsyncEffect(async () => { - //waiting room attendee -> rtm login will happen on page load - if ($config.ENABLE_WAITING_ROOM) { - //attendee - //for waiting room attendee rtm login will happen on mount - if (!isHost && !callActive) { - await init(); - } - //host - if ( - isHost && - ($config.AUTO_CONNECT_RTM || (!$config.AUTO_CONNECT_RTM && callActive)) - ) { - await init(); - } - } else { - //non waiting room case - //host and attendee - if ( - $config.AUTO_CONNECT_RTM || - (!$config.AUTO_CONNECT_RTM && callActive) - ) { - await init(); - } - } - return async () => { - await end(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rtcProps.channel, rtcProps.appId, callActive]); + } return ( {props.children} diff --git a/template/src/components/UserGlobalPreferenceProvider.tsx b/template/src/components/UserGlobalPreferenceProvider.tsx new file mode 100644 index 000000000..e3d7f281d --- /dev/null +++ b/template/src/components/UserGlobalPreferenceProvider.tsx @@ -0,0 +1,227 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, { + createContext, + useContext, + useCallback, + useRef, + useState, +} from 'react'; +import { + ToggleState, + PermissionState, + DefaultContentInterface, + ContentInterface, +} from '../../agora-rn-uikit'; +import {MUTE_LOCAL_TYPE} from '../utils/useMuteToggleLocal'; + +// RTM User Preferences interface - session-scoped preferences that survive room transitions +export interface UserGlobalPreferences { + audioMuted: boolean; // false = unmuted (0), true = muted (1) + videoMuted: boolean; // false = unmuted (0), true = muted (1) + virtualBackground?: { + type: 'blur' | 'image' | 'none'; + imageUrl?: string; + blurIntensity?: number; + }; +} + +// Default user preferences +export const DEFAULT_USER_PREFERENCES: UserGlobalPreferences = { + audioMuted: false, // Default unmuted (0 = unmuted) + videoMuted: false, // Default unmuted (0 = unmuted) + virtualBackground: { + type: 'none', + }, +}; + +interface UserGlobalPreferenceInterface { + userGlobalPreferences: UserGlobalPreferences; + syncUserPreferences: (prefs: Partial) => void; + applyUserPreferences: ( + currentUserData: ContentInterface, + toggleMuteFn: (type: number, action?: number) => Promise, + ) => Promise; +} + +const UserGlobalPreferenceContext = + createContext(null); + +interface UserGlobalPreferenceProviderProps { + children: React.ReactNode; +} + +export const UserGlobalPreferenceProvider: React.FC< + UserGlobalPreferenceProviderProps +> = ({children}) => { + // User preferences (survives room transitions) + const [userGlobalPreferences, setUserGlobalPreferences] = + useState(DEFAULT_USER_PREFERENCES); + console.log('UP: userGlobalPreferences changed: ', userGlobalPreferences); + + const hasAppliedPreferences = useRef(false); + + const syncUserPreferences = useCallback( + (prefs: Partial) => { + console.log('UserGlobalPreference: Syncing preferences', prefs); + setUserGlobalPreferences(prev => ({ + ...prev, + ...prefs, + })); + }, + [setUserGlobalPreferences], + ); + + const applyUserPreferences = useCallback( + async ( + currentUserData: DefaultContentInterface, + toggleMuteFn: ( + type: MUTE_LOCAL_TYPE, + action?: ToggleState, + ) => Promise, + ) => { + console.log('UP: 1', userGlobalPreferences); + // Only apply preferences once per component lifecycle + if (hasAppliedPreferences.current) { + console.log('UP: 2'); + console.log( + 'UserGlobalPreference: Preferences already applied, skipping', + ); + return; + } + console.log('UP: 3'); + try { + console.log( + 'UserGlobalPreference: Applying preferences', + userGlobalPreferences, + ); + console.log('UP: 4'); + + const currentAudioState = currentUserData.audio; + const currentVideoState = currentUserData.video; + const permissionStatus = currentUserData.permissionStatus; + const audioForceDisabled = currentUserData.audioForceDisabled; + const videoForceDisabled = currentUserData.videoForceDisabled; + + console.log('UP: 5', { + currentAudioState, + currentVideoState, + permissionStatus, + audioForceDisabled, + videoForceDisabled, + }); + + // Check if audio permissions are available and not force disabled + const hasAudioPermission = + (permissionStatus === PermissionState.GRANTED_FOR_CAM_AND_MIC || + permissionStatus === PermissionState.GRANTED_FOR_MIC_ONLY) && + !audioForceDisabled; + + // Check if video permissions are available and not force disabled + const hasVideoPermission = + (permissionStatus === PermissionState.GRANTED_FOR_CAM_AND_MIC || + permissionStatus === PermissionState.GRANTED_FOR_CAM_ONLY) && + !videoForceDisabled; + + // Apply audio mute preference only if user has audio permission and not force disabled + if (hasAudioPermission) { + const desiredAudioState = userGlobalPreferences.audioMuted + ? ToggleState.disabled + : ToggleState.enabled; + console.log('UP: 6', desiredAudioState); + + if (currentAudioState !== desiredAudioState) { + console.log('UP: 7 changed', currentAudioState, desiredAudioState); + + console.log( + `UP: UserGlobalPreference: Applying audio state: ${ + desiredAudioState === ToggleState.disabled ? 'muted' : 'unmuted' + }`, + desiredAudioState, + ); + await toggleMuteFn(MUTE_LOCAL_TYPE.audio, desiredAudioState); + } + } else { + console.log( + 'UP: Skipping audio preference - no audio permission or force disabled', + ); + } + + // Apply video mute preference only if user has video permission and not force disabled + if (hasVideoPermission) { + const desiredVideoState = userGlobalPreferences.videoMuted + ? ToggleState.disabled + : ToggleState.enabled; + console.log('UP: 8', currentVideoState, desiredVideoState); + + if (currentVideoState !== desiredVideoState) { + console.log('UP: 9 changed'); + + console.log( + `UserGlobalPreference: Applying video state: ${ + desiredVideoState === ToggleState.disabled ? 'muted' : 'unmuted' + }`, + ); + await toggleMuteFn(MUTE_LOCAL_TYPE.video, desiredVideoState); + } + } else { + console.log( + 'UP: Skipping video preference - no video permission or force disabled', + ); + } + + // Virtual background preferences will be handled by useVB hook + // since it reads from userGlobalPreferences state on component mount + + hasAppliedPreferences.current = true; + console.log('UserGlobalPreference: Preferences applied successfully'); + } catch (error) { + console.warn( + 'UserGlobalPreference: Failed to apply preferences:', + error, + ); + } + }, + [userGlobalPreferences], + ); + + // Reset the application flag when preferences change + // This allows re-application if preferences are updated + React.useEffect(() => { + hasAppliedPreferences.current = false; + }, [userGlobalPreferences]); + + const contextValue: UserGlobalPreferenceInterface = { + userGlobalPreferences, + syncUserPreferences, + applyUserPreferences, + }; + + return ( + + {children} + + ); +}; + +export const useUserGlobalPreferences = (): UserGlobalPreferenceInterface => { + const context = useContext(UserGlobalPreferenceContext); + if (!context) { + throw new Error( + 'useUserGlobalPreferences must be used within UserGlobalPreferenceProvider', + ); + } + return context; +}; + +export default UserGlobalPreferenceProvider; diff --git a/template/src/components/beauty-effect/useBeautyEffects.tsx b/template/src/components/beauty-effect/useBeautyEffects.tsx index b4e4017ec..48a584235 100644 --- a/template/src/components/beauty-effect/useBeautyEffects.tsx +++ b/template/src/components/beauty-effect/useBeautyEffects.tsx @@ -1,6 +1,5 @@ import {createHook} from 'customization-implementation'; -import React, {useState} from 'react'; -import {useEffect, useRef} from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import AgoraRTC, {ILocalVideoTrack} from 'agora-rtc-sdk-ng'; import BeautyExtension from 'agora-extension-beauty-effect'; import {useRoomInfo, useRtc} from 'customization-api'; @@ -77,19 +76,45 @@ const BeautyEffectProvider: React.FC = ({children}) => { const {RtcEngineUnsafe} = useRtc(); //@ts-ignore - const localVideoTrack = RtcEngineUnsafe?.localStream?.video; + const localVideoTrack: ILocalVideoTrack | undefined = + RtcEngineUnsafe?.localStream?.video; + + // βœ… useRef to persist timeout across renders + const timeoutRef = useRef | null>(null); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } if (!roomPreference?.disableVideoProcessors) { - if ($config.ENABLE_VIRTUAL_BACKGROUND) { - localVideoTrack - ?.pipe(beautyProcessor) - .pipe(vbProcessor) - .pipe(localVideoTrack?.processorDestination); - } else { - localVideoTrack - ?.pipe(beautyProcessor) - .pipe(localVideoTrack?.processorDestination); - } + /** + * Small delay to ensure the new track is stable + * when we move from main room to breakout room the track changes + * from live to ended instantly as the user audio or video preferences are applied + * It solves the error 'MediaStreamTrackProcessor': Input track cannot be ended' + */ + timeoutRef.current = setTimeout(() => { + const trackStatus = localVideoTrack?.getMediaStreamTrack()?.readyState; + if (trackStatus === 'live') { + try { + if ($config.ENABLE_VIRTUAL_BACKGROUND) { + localVideoTrack + ?.pipe(beautyProcessor) + .pipe(vbProcessor) + .pipe(localVideoTrack?.processorDestination); + } else { + localVideoTrack + ?.pipe(beautyProcessor) + .pipe(localVideoTrack?.processorDestination); + } + } catch (err) { + console.error('Error applying processors:', err); + } + } else { + console.warn('Track not live after delay, skipping pipe'); + } + }, 300); } useEffect(() => { @@ -113,6 +138,18 @@ const BeautyEffectProvider: React.FC = ({children}) => { lighteningContrastLevel, ]); + // Proper cleanup for both processor and timeout + useEffect(() => { + return () => { + beautyProcessor?.disable(); + beautyProcessor?.unpipe?.(); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + const removeBeautyEffect = async () => { await beautyProcessor.disable(); }; diff --git a/template/src/components/breakout-room/BreakoutRoomPanel.tsx b/template/src/components/breakout-room/BreakoutRoomPanel.tsx new file mode 100644 index 000000000..0093711ba --- /dev/null +++ b/template/src/components/breakout-room/BreakoutRoomPanel.tsx @@ -0,0 +1,58 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +import React from 'react'; +import {View} from 'react-native'; +import {isMobileUA, isWebInternal, useIsSmall} from '../../utils/common'; +import CommonStyles from '../CommonStyles'; +import {getGridLayoutName} from '../../pages/video-call/DefaultLayouts'; +import useCaptionWidth from '../../subComponents/caption/useCaptionWidth'; + +import {useLayout} from '../../utils/useLayout'; +import {useSidePanel} from '../../utils/useSidePanel'; +import {SidePanelType} from '../../subComponents/SidePanelEnum'; + +import BreakoutRoomView from './ui/BreakoutRoomView'; + +const BreakoutRoomPanel = () => { + const {setSidePanel} = useSidePanel(); + const isSmall = useIsSmall(); + const {currentLayout} = useLayout(); + const {transcriptHeight} = useCaptionWidth(); + + return ( + + { + setSidePanel(SidePanelType.None); + }} + /> + + ); +}; + +export default BreakoutRoomPanel; diff --git a/template/src/components/breakout-room/context/BreakoutRoomContext.tsx b/template/src/components/breakout-room/context/BreakoutRoomContext.tsx new file mode 100644 index 000000000..b36200330 --- /dev/null +++ b/template/src/components/breakout-room/context/BreakoutRoomContext.tsx @@ -0,0 +1,2372 @@ +import React, { + useContext, + useReducer, + useEffect, + useState, + useCallback, + useRef, +} from 'react'; +import {UidType} from '../../../../agora-rn-uikit'; +import {createHook} from 'customization-implementation'; +import {randomNameGenerator} from '../../../utils'; +import StorageContext from '../../StorageContext'; +import getUniqueID from '../../../utils/getUniqueID'; +import {logger, LogSource} from '../../../logger/AppBuilderLogger'; +import {useRoomInfo} from 'customization-api'; +import { + BreakoutGroupActionTypes, + BreakoutGroup, + BreakoutRoomState, + breakoutRoomReducer, + initialBreakoutRoomState, + RoomAssignmentStrategy, + ManualParticipantAssignment, + BreakoutRoomUser, +} from '../state/reducer'; +import {useLocalUid} from '../../../../agora-rn-uikit'; +import {useContent} from '../../../../customization-api'; +import events from '../../../rtm-events-api'; +import {BreakoutRoomAction, initialBreakoutGroups} from '../state/reducer'; +import {BreakoutRoomEventNames} from '../events/constants'; +import {BreakoutRoomSyncStateEventPayload} from '../state/types'; +import {IconsInterface} from '../../../atoms/CustomIcon'; +import Toast from '../../../../react-native-toast-message'; +import useBreakoutRoomExit from '../hooks/useBreakoutRoomExit'; +import {useDebouncedCallback} from '../../../utils/useDebouncedCallback'; +import {useLocation} from '../../../components/Router'; +import {useMainRoomUserDisplayName} from '../../../rtm/hooks/useMainRoomUserDisplayName'; +import { + RTMUserData, + useRTMGlobalState, +} from '../../../rtm/RTMGlobalStateProvider'; + +const HOST_BROADCASTED_OPERATIONS = [ + BreakoutGroupActionTypes.SET_ALLOW_PEOPLE_TO_SWITCH_ROOM, + BreakoutGroupActionTypes.CREATE_GROUP, + BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS, + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN, + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP, + BreakoutGroupActionTypes.CLOSE_GROUP, + BreakoutGroupActionTypes.CLOSE_ALL_GROUPS, + BreakoutGroupActionTypes.RENAME_GROUP, +] as const; + +const getSanitizedPayload = ( + payload: BreakoutGroup[], + defaultContentRef: any, + mainRoomRTMUsers: {[uid: number]: RTMUserData}, +) => { + return payload.map(({id, ...rest}) => { + const group = id !== undefined ? {...rest, id} : rest; + + // Filter out offline users from participants + const filteredGroup = { + ...group, + participants: { + hosts: group.participants.hosts.filter(uid => { + let user = mainRoomRTMUsers[uid]; + if (defaultContentRef[uid]) { + user = defaultContentRef[uid]; + } + if (user) { + return !user.offline && user.type === 'rtc'; + } + }), + attendees: group.participants.attendees.filter(uid => { + let user = mainRoomRTMUsers[uid]; + if (defaultContentRef[uid]) { + user = defaultContentRef[uid]; + } + if (user) { + return !user.offline && user.type === 'rtc'; + } + }), + }, + }; + + // Remove temp IDs for API payload + if (typeof id === 'string' && id.startsWith('temp')) { + const {id: _, ...withoutId} = filteredGroup; + return withoutId; + } + return filteredGroup; + }); +}; + +// const validateRollbackState = (state: BreakoutRoomState): boolean => { +// return ( +// Array.isArray(state.breakoutGroups) && +// typeof state.breakoutSessionId === 'string' && +// typeof state.canUserSwitchRoom === 'boolean' && +// state.breakoutGroups.every( +// group => +// typeof group.id === 'string' && +// typeof group.name === 'string' && +// Array.isArray(group.participants?.hosts) && +// Array.isArray(group.participants?.attendees), +// ) +// ); +// }; + +export const deepCloneBreakoutGroups = ( + groups: BreakoutGroup[] = [], +): BreakoutGroup[] => + groups.map(group => ({ + ...group, + participants: { + hosts: [...(group.participants?.hosts ?? [])], + attendees: [...(group.participants?.attendees ?? [])], + }, + })); + +const needsDeepCloning = (action: BreakoutRoomAction): boolean => { + const CLONING_REQUIRED_ACTIONS = [ + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP, + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN, + BreakoutGroupActionTypes.EXIT_GROUP, + BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.CLOSE_GROUP, + BreakoutGroupActionTypes.CLOSE_ALL_GROUPS, + BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS, + BreakoutGroupActionTypes.SYNC_STATE, + ]; + + return CLONING_REQUIRED_ACTIONS.includes(action.type as any); +}; + +export interface MemberDropdownOption { + type: 'move-to-main' | 'move-to-room' | 'make-presenter'; + icon: keyof IconsInterface; + title: string; + roomId?: string; + roomName?: string; + onOptionPress: () => void; +} + +interface BreakoutRoomPermissions { + // Room navigation + canJoinRoom: boolean; + canExitRoom: boolean; + canSwitchBetweenRooms: boolean; + // Media controls + canScreenshare: boolean; + canRaiseHands: boolean; + // Room management (host only) + canHostManageMainRoom: boolean; + canAssignParticipants: boolean; + canCreateRooms: boolean; + canMoveUsers: boolean; + canCloseRooms: boolean; + canMakePresenter: boolean; +} +const defaulBreakoutRoomPermission: BreakoutRoomPermissions = { + canJoinRoom: false, + canExitRoom: false, + canSwitchBetweenRooms: false, + canScreenshare: true, + canRaiseHands: false, + canHostManageMainRoom: false, + canAssignParticipants: false, + canCreateRooms: false, + canMoveUsers: false, + canCloseRooms: false, + canMakePresenter: false, +}; + +interface BreakoutRoomContextValue { + mainChannelId: string; + isBreakoutUILocked: boolean; + breakoutSessionId: BreakoutRoomState['breakoutSessionId']; + breakoutGroups: BreakoutRoomState['breakoutGroups']; + assignmentStrategy: RoomAssignmentStrategy; + canUserSwitchRoom: boolean; + toggleRoomSwitchingAllowed: (value: boolean) => void; + unassignedParticipants: {uid: UidType; user: BreakoutRoomUser}[]; + manualAssignments: ManualParticipantAssignment[]; + setManualAssignments: (assignments: ManualParticipantAssignment[]) => void; + clearManualAssignments: () => void; + createBreakoutRoomGroup: (name?: string) => void; + isUserInRoom: (room?: BreakoutGroup) => boolean; + joinRoom: (roomId: string, permissionAtCallTime?: boolean) => void; + exitRoom: (roomId?: string, permissionAtCallTime?: boolean) => Promise; + closeRoom: (roomId: string) => void; + closeAllRooms: () => void; + updateRoomName: (newRoomName: string, roomId: string) => void; + getAllRooms: () => BreakoutGroup[]; + getRoomMemberDropdownOptions: (memberUid: UidType) => MemberDropdownOption[]; + upsertBreakoutRoomAPI: (type: 'START' | 'UPDATE') => Promise; + checkIfBreakoutRoomSessionExistsAPI: () => Promise; + handleAssignParticipants: (strategy: RoomAssignmentStrategy) => void; + // Presenters + // onMakeMePresenter: ( + // action: 'start' | 'stop', + // shouldSendEvent?: boolean, + // ) => void; + // presenters: {uid: UidType; timestamp: number}[]; + // clearAllPresenters: () => void; + // State sync + handleBreakoutRoomSyncState: ( + data: BreakoutRoomSyncStateEventPayload['data'], + timestamp: number, + ) => void; + // Multi-host coordination handlers + handleHostOperationStart: ( + operationName: string, + hostUid: UidType, + hostName: string, + ) => void; + handleHostOperationEnd: ( + operationName: string, + hostUid: UidType, + hostName: string, + ) => void; + permissions: BreakoutRoomPermissions; + // Loading states + isBreakoutUpdateInFlight: boolean; + // Multi-host coordination + currentOperatingHostName?: string; + // State version for forcing re-computation in dependent hooks + breakoutRoomVersion: number; +} + +const BreakoutRoomContext = React.createContext({ + mainChannelId: '', + isBreakoutUILocked: false, + breakoutSessionId: undefined, + unassignedParticipants: [], + breakoutGroups: [], + assignmentStrategy: RoomAssignmentStrategy.NO_ASSIGN, + manualAssignments: [], + setManualAssignments: () => {}, + clearManualAssignments: () => {}, + canUserSwitchRoom: false, + toggleRoomSwitchingAllowed: () => {}, + handleAssignParticipants: () => {}, + createBreakoutRoomGroup: () => {}, + isUserInRoom: () => false, + joinRoom: () => {}, + exitRoom: async () => {}, + closeRoom: () => {}, + closeAllRooms: () => {}, + updateRoomName: () => {}, + getAllRooms: () => [], + getRoomMemberDropdownOptions: () => [], + upsertBreakoutRoomAPI: async () => {}, + checkIfBreakoutRoomSessionExistsAPI: async () => false, + // onMakeMePresenter: () => {}, + // presenters: [], + // clearAllPresenters: () => {}, + handleBreakoutRoomSyncState: () => {}, + // Multi-host coordination handlers + handleHostOperationStart: () => {}, + handleHostOperationEnd: () => {}, + permissions: {...defaulBreakoutRoomPermission}, + // Loading states + isBreakoutUpdateInFlight: false, + // Multi-host coordination + currentOperatingHostName: undefined, + // State version for forcing re-computation in dependent hooks + breakoutRoomVersion: 0, +}); + +const BreakoutRoomProvider = ({ + children, + mainChannel, + handleLeaveBreakout, +}: { + children: React.ReactNode; + mainChannel: string; + handleLeaveBreakout: () => void; +}) => { + const {store} = useContext(StorageContext); + const {defaultContent, activeUids} = useContent(); + const {mainRoomRTMUsers} = useRTMGlobalState(); + const localUid = useLocalUid(); + const { + data: {isHost, roomId: joinRoomId}, + } = useRoomInfo(); + const breakoutRoomExit = useBreakoutRoomExit(handleLeaveBreakout); + const [state, baseDispatch] = useReducer( + breakoutRoomReducer, + initialBreakoutRoomState, + ); + + const [isBreakoutUpdateInFlight, setBreakoutUpdateInFlight] = useState(false); + + // Parse URL to determine current mode + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const isBreakoutMode = searchParams.get('breakout') === 'true'; + // Main Room RTM data + const getDisplayName = useMainRoomUserDisplayName(); + + // Permissions: + const [permissions, setPermissions] = useState({ + ...defaulBreakoutRoomPermission, + }); + + // Store the last operation + const [currentOperatingHostName, setCurrentOperatingHostName] = useState< + string | undefined + >(undefined); + + // Timestamp Server + const lastProcessedServerTsRef = useRef(0); + // Self join guard (prevent stale reverts) (when self join happens) + const lastSelfJoinRef = useRef<{roomId: string; ts: number} | null>(null); + // Timestamp client tracking for event ordering client side + const lastSyncedTimestampRef = useRef(0); + const isBreakoutUILocked = + isBreakoutUpdateInFlight || !!currentOperatingHostName; + const lastSyncedSnapshotRef = useRef<{ + session_id: string; + switch_room: boolean; + assignment_type: string; + breakout_room: BreakoutGroup[]; + } | null>(null); + + // Breakout sync queue (latest-event-wins) + const breakoutSyncQueueRef = useRef<{ + latestTask: { + payload: BreakoutRoomSyncStateEventPayload['data']; + timestamp: number; + } | null; + isProcessing: boolean; + }>({ + latestTask: null, + isProcessing: false, + }); + + // Join Room pending intent + const [selfJoinRoomId, setSelfJoinRoomId] = useState(null); + + // Presenter + // const {isScreenshareActive, stopScreenshare} = useScreenshare(); + + // const [canIPresent, setICanPresent] = useState(false); + // Get presenters from custom RTM main room data (memoized to maintain stable reference) + // const presenters = React.useMemo( + // () => customRTMMainRoomData.breakout_room_presenters || [], + // [customRTMMainRoomData], + // ); + + // State version tracker to force dependent hooks to re-compute + const [breakoutRoomVersion, setBreakoutRoomVersion] = useState(0); + + // Refs to avoid stale closures in async callbacks + const stateRef = useRef(state); + const prevStateRef = useRef(state); + const isHostRef = useRef(isHost); + const defaultContentRef = useRef(defaultContent); + const isMountedRef = useRef(true); + + // Enhanced dispatch that tracks user actions + const [lastAction, setLastAction] = useState(null); + + const dispatch = useCallback((action: BreakoutRoomAction) => { + // Minimal action summary for Datadog + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[Base DISPATCH] Action -> ${action.type}`, + { + type: action.type, + payloadKeys: Object.keys(action?.payload || {}), + }, + ); + + if (needsDeepCloning(action)) { + // Only deep clone when necessary + prevStateRef.current = { + ...stateRef.current, + breakoutGroups: deepCloneBreakoutGroups( + stateRef.current.breakoutGroups, + ), + }; + } else { + // Shallow copy for non-participant actions + prevStateRef.current = { + ...stateRef.current, + breakoutGroups: [...stateRef.current.breakoutGroups], + }; + } + baseDispatch(action); + setLastAction(action); + }, []); + + useEffect(() => { + stateRef.current = state; + }, [state]); + useEffect(() => { + isHostRef.current = isHost; + }, [isHost]); + useEffect(() => { + defaultContentRef.current = defaultContent; + }, [defaultContent]); + useEffect(() => { + return () => { + isMountedRef.current = false; + + // // Clear presenter attribute on unmount if user is presenting + // if (canIPresent && !isHostRef.current) { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'Clearing presenter attribute on unmount', + // {localUid}, + // ); + + // // Send event to clear presenter status + // events.send( + // EventNames.BREAKOUT_PRESENTER_ATTRIBUTE, + // JSON.stringify({ + // uid: localUid, + // isPresenter: false, + // timestamp: Date.now(), + // }), + // PersistanceLevel.Sender, + // ); + // } + }; + }, [localUid]); + + // Timeouts + const timeoutsRef = useRef>>(new Set()); + + const safeSetTimeout = useCallback((fn: () => void, delay: number) => { + const id = setTimeout(() => { + fn(); + timeoutsRef.current.delete(id); // cleanup after execution + }, delay); + + timeoutsRef.current.add(id); + return id; + }, []); + + // Clear all timeouts + useEffect(() => { + const snapshot = timeoutsRef.current; + return () => { + snapshot.forEach(timeoutId => clearTimeout(timeoutId)); + snapshot.clear(); + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[CLEANUP] Cleared all pending timeouts', + ); + }; + }, []); + + // Toast duplication + const toastDedupeRef = useRef>(new Set()); + + const showDeduplicatedToast = useCallback((key: string, toastConfig: any) => { + if (toastDedupeRef.current.has(key)) { + return; + } + + toastDedupeRef.current.add(key); + Toast.show(toastConfig); + + safeSetTimeout(() => { + toastDedupeRef.current.delete(key); + }, toastConfig.visibilityTime || 3000); + }, []); + + // Multi-host coordination functions + const broadcastHostOperationStart = useCallback( + (operationName: string) => { + if (!isHostRef.current) { + return; + } + + const hostName = getDisplayName(localUid); + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[HOST] Broadcasting start for operation -> ${operationName}`, + {operation: operationName, hostName, hostUid: localUid}, + ); + + events.send( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_START, + JSON.stringify({ + operationName, + hostUid: localUid, + hostName, + timestamp: Date.now(), + }), + ); + }, + [localUid], + ); + + const broadcastHostOperationEnd = useCallback( + (operationName: string) => { + if (!isHostRef.current) { + return; + } + + const hostName = getDisplayName(localUid); + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[HOST] Broadcast end for operation -> ${operationName}`, + { + operation: operationName, + hostName, + hostUid: localUid, + }, + ); + + events.send( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_END, + JSON.stringify({ + operationName, + hostUid: localUid, + hostName, + timestamp: Date.now(), + }), + ); + }, + [localUid], + ); + + // Common operation lock for API-triggering actions with multi-host coordination + const acquireOperationLock = useCallback( + (operationName: string): boolean => { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[LOCK] Attempt acquire for operation -> ${operationName}`, + {operationName, inFlight: isBreakoutUpdateInFlight}, + ); + + if (isBreakoutUpdateInFlight) { + logger.warn( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[LOCK] Blocked as (Update BreakoutRoom API in flight) for operation -> ${operationName}`, + {blockedOperation: operationName}, + ); + return false; + } + + // Broadcast that this host is starting an operation + + setBreakoutUpdateInFlight(true); + broadcastHostOperationStart(operationName); + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[LOCK] Acquired for operation -> ${operationName}`, + { + operationName, + }, + ); + return true; + }, + [ + isBreakoutUpdateInFlight, + broadcastHostOperationStart, + setBreakoutUpdateInFlight, + ], + ); + + // Update unassigned participants + useEffect(() => { + if (!stateRef.current?.breakoutSessionId) { + return; + } + + // Filter users from defaultContent first, then check if they're in activeUids + // This follows the legacy RTM pattern: start with defaultContent, then filter by activeUids + const filteredParticipants = Object.entries(defaultContent) + .filter(([k, v]) => { + // Only include RTC users + if (v?.type !== 'rtc') { + return false; + } + // Exclude offline users + if (v?.offline) { + return false; + } + // Exclude screenshare UIDs (they typically have a parentUid) + if (v?.parentUid) { + return false; + } + // KEY CHECK: Only include users who are in activeUids (actually in the call) + const uid = parseInt(k); + if (activeUids.indexOf(uid) === -1) { + return false; + } + return true; + }) + .map(([k, v]) => { + const uid = parseInt(k); + + // Get additional RTM data if available for cross-room scenarios + const rtmUser = mainRoomRTMUsers[uid]; + const user = v || rtmUser; + // Create BreakoutRoomUser object with proper fallback + const breakoutRoomUser: BreakoutRoomUser = { + name: user?.name || rtmUser?.name || '', + isHost: user?.isHost === 'true', + }; + + return {uid, user: breakoutRoomUser}; + }); + + // Sort participants to show local user first + filteredParticipants.sort((a, b) => { + if (a.uid === localUid) { + return -1; + } + if (b.uid === localUid) { + return 1; + } + return 0; + }); + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[STATE] Update unassigned participants', + { + count: filteredParticipants.length, + filteredParticipants: filteredParticipants, + }, + ); + + // // Find offline users who are currently assigned to breakout rooms + // const currentlyAssignedUids = new Set(); + // stateRef.current.breakoutGroups.forEach(group => { + // group.participants.hosts.forEach(uid => currentlyAssignedUids.add(uid)); + // group.participants.attendees.forEach(uid => currentlyAssignedUids.add(uid)); + // }); + + // const offlineAssignedUsers = Array.from(currentlyAssignedUids).filter(uid => { + // const user = defaultContent[uid]; + // return !user || user.offline || user.type !== 'rtc'; + // }); + + // // Remove offline users from breakout rooms if any found + // if (offlineAssignedUsers.length > 0) { + // console.log('Removing offline users from breakout rooms:', offlineAssignedUsers); + // dispatch({ + // type: BreakoutGroupActionTypes.REMOVE_OFFLINE_USERS, + // payload: { + // offlineUserUids: offlineAssignedUsers, + // }, + // }); + // } + + // Update unassigned participants + dispatch({ + type: BreakoutGroupActionTypes.UPDATE_UNASSIGNED_PARTICIPANTS, + payload: {unassignedParticipants: filteredParticipants}, + }); + }, [ + defaultContent, + activeUids, + localUid, + dispatch, + state.breakoutSessionId, + mainRoomRTMUsers, + ]); + + // Increment Version when breakout data changes + useEffect(() => { + setBreakoutRoomVersion(prev => prev + 1); + }, [state.breakoutGroups]); + + // Check if there is already an active breakout session + // We can call this to trigger sync events + const checkIfBreakoutRoomSessionExistsAPI = + useCallback(async (): Promise => { + // Skip API call if roomId is not available or if API update is in progress + if (!joinRoomId?.host && !joinRoomId?.attendee) { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API: checkIfBreakoutRoomSessionExistsAPI] Skipped (no roomId available yet)', + ); + return false; + } + + if (isBreakoutUpdateInFlight) { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] Skipped (upsert in progress)', + ); + return false; + } + + const startTime = Date.now(); + const requestId = getUniqueID(); + const url = `${ + $config.BACKEND_ENDPOINT + }/v1/channel/breakout-room?passphrase=${ + isHostRef.current ? joinRoomId.host : joinRoomId.attendee + }`; + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] current sessionId and role', + { + isHost: isHostRef.current, + sessionId: stateRef.current.breakoutSessionId, + }, + ); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + authorization: store.token ? `Bearer ${store.token}` : '', + 'X-Request-Id': requestId, + 'X-Session-Id': logger.getSessionId(), + }, + }); + // Guard against component unmount after fetch + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] cancelled (unmounted)', + {requestId}, + ); + return false; + } + + const latency = Date.now() - startTime; + + // Log network request + logger.log( + LogSource.NetworkRest, + 'breakout-room', + 'GET breakout-room session', + {url, method: 'GET', status: response.status, latency, requestId}, + ); + if (!response.ok) { + throw new Error(`Failed with status ${response.status}`); + } + if (response.status === 204) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] No active session', + ); + return false; + } + + const data = await response.json(); + + if (data?.session_id) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] session exists got breakout data', + { + sessionId: data.session_id, + rooms: data?.breakout_room || 0, + roomCount: data?.breakout_room?.length || 0, + assignmentType: data?.assignment_type, + switchRoom: data?.switch_room, + }, + ); + return true; + } + return false; + } catch (error: any) { + const latency = Date.now() - startTime; + logger.error( + LogSource.NetworkRest, + 'breakout-room', + 'GET breakout-room session failed', + { + url, + method: 'GET', + error: error?.message, + latency, + requestId, + }, + ); + return false; + } + }, [isBreakoutUpdateInFlight, joinRoomId, store.token]); + + // Initial session check with delayed start + useEffect(() => { + if (!joinRoomId?.host && !joinRoomId?.attendee) { + return; + } + const loadInitialData = async () => { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API checkIfBreakoutRoomSessionExistsAPI] will be called , inside loadInitial data', + ); + await checkIfBreakoutRoomSessionExistsAPI(); + }; + + // Check if we just transitioned to breakout mode as that we can delay the call + // to check breakout api + const justEnteredBreakout = sessionStorage.getItem( + 'breakout_room_transition', + ); + const delay = justEnteredBreakout ? 3000 : 1200; + + if (justEnteredBreakout) { + sessionStorage.removeItem('breakout_room_transition'); + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[INIT] Using a bit of delay after breakout transition so it gives time for user to join the call', + {delay}, + ); + } + + const timeoutId = setTimeout(() => { + loadInitialData(); + }, delay); + + return () => { + clearTimeout(timeoutId); + }; + }, [joinRoomId, checkIfBreakoutRoomSessionExistsAPI]); + + // Upsert API + const upsertBreakoutRoomAPI = useCallback( + async (type: 'START' | 'UPDATE' = 'START', retryCount = 0) => { + type UpsertPayload = { + passphrase: string; + switch_room: boolean; + session_id: string; + assignment_type: RoomAssignmentStrategy; + breakout_room: ReturnType; + join_room_id?: string; + }; + + const startReqTs = Date.now(); + const requestId = getUniqueID(); + const url = `${$config.BACKEND_ENDPOINT}/v1/channel/breakout-room`; + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[API upsertBreakoutRoomAPI] Upsert start called with intent ->(${type})`, + { + type, + isHost: isHostRef.current, + sessionId: stateRef.current.breakoutSessionId, + roomCount: stateRef.current.breakoutGroups.length, + assignmentStrategy: stateRef.current.assignmentStrategy, + canSwitchRoom: stateRef.current.canUserSwitchRoom, + selfJoinRoomId, + }, + ); + + try { + const sessionId = + stateRef.current.breakoutSessionId || randomNameGenerator(6); + + const payload: UpsertPayload = { + passphrase: isHostRef.current ? joinRoomId.host : joinRoomId.attendee, + switch_room: stateRef.current.canUserSwitchRoom, + session_id: sessionId, + assignment_type: stateRef.current.assignmentStrategy, + breakout_room: + type === 'START' + ? getSanitizedPayload( + initialBreakoutGroups, + defaultContentRef, + mainRoomRTMUsers, + ) + : getSanitizedPayload( + stateRef.current.breakoutGroups, + defaultContentRef, + mainRoomRTMUsers, + ), + }; + + // Only add join_room_id if attendee has called this api(during join room) + if (selfJoinRoomId) { + payload.join_room_id = selfJoinRoomId; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: store.token ? `Bearer ${store.token}` : '', + 'X-Request-Id': requestId, + 'X-Session-Id': logger.getSessionId(), + }, + body: JSON.stringify(payload), + }); + + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API upsertBreakoutRoomAPI] Upsert cancelled (unmounted)', + {type, requestId}, + ); + return; + } + + const latency = Date.now() - startReqTs; + + logger.log( + LogSource.NetworkRest, + 'breakout-room', + 'POST breakout-room upsert', + { + url, + method: 'POST', + status: response.status, + latency, + requestId, + type, + payloadSize: JSON.stringify(payload).length, + }, + ); + + if (!response.ok) { + const msg = await response.text(); + + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API upsertBreakoutRoomAPI] Error text parsing cancelled (unmounted)', + {type, status: response.status, requestId}, + ); + return; + } + + throw new Error(`Breakout room creation failed: ${msg}`); + } else { + const data = await response.json(); + + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API upsertBreakoutRoomAPI] Upsert success cancelled (unmounted)', + {type, requestId}, + ); + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[API upsertBreakoutRoomAPI] Upsert success called with intent -> (${type})`, + { + type, + newSessionId: data?.session_id, + roomsUpdated: !!data?.breakout_room, + latency, + }, + ); + + if (type === 'START' && data?.session_id) { + dispatch({ + type: BreakoutGroupActionTypes.SET_SESSION_ID, + payload: {sessionId: data.session_id}, + }); + } + } + } catch (err: any) { + const latency = Date.now() - startReqTs; + const maxRetries = 3; + const isRetriableError = + err?.name === 'TypeError' || // Network errors + err?.message?.includes('fetch') || + err?.message?.includes('timeout') || + err?.response?.status >= 500; // Server errors + + logger.log( + LogSource.NetworkRest, + 'breakout-room', + 'POST breakout-room upsert failed', + { + url, + method: 'POST', + error: err?.message, + latency, + requestId, + type, + retryCount, + isRetriableError, + willRetry: retryCount < maxRetries && isRetriableError, + }, + ); + + // Retry logic for network/server errors + if (retryCount < maxRetries && isRetriableError) { + const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 5000); + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[API upsertBreakoutRoomAPI] Retrying upsert in ${retryDelay}ms`, + {retryCount: retryCount + 1, maxRetries, type}, + ); + + safeSetTimeout(() => { + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API upsertBreakoutRoomAPI] Retry cancelled (unmounted)', + {type, retryCount: retryCount + 1}, + ); + return; + } + upsertBreakoutRoomAPI(type, retryCount + 1); + }, retryDelay); + return; // Don't execute finally block on retry + } + + setSelfJoinRoomId(null); + } finally { + if (retryCount === 0) { + setSelfJoinRoomId(null); + } + } + }, + [ + joinRoomId.host, + store.token, + dispatch, + selfJoinRoomId, + joinRoomId.attendee, + mainRoomRTMUsers, + ], + ); + + const setManualAssignments = useCallback( + (assignments: ManualParticipantAssignment[]) => { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Set manual assignments', + {count: assignments.length}, + ); + dispatch({ + type: BreakoutGroupActionTypes.SET_MANUAL_ASSIGNMENTS, + payload: {assignments}, + }); + }, + [dispatch], + ); + + const clearManualAssignments = useCallback(() => { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Clear manual assignments', + ); + dispatch({ + type: BreakoutGroupActionTypes.CLEAR_MANUAL_ASSIGNMENTS, + }); + }, [dispatch]); + + const toggleRoomSwitchingAllowed = (value: boolean) => { + if (!acquireOperationLock('SET_ALLOW_PEOPLE_TO_SWITCH_ROOM')) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[ACTION] Toggle room switching with value ${value}`, + { + previousValue: stateRef.current.canUserSwitchRoom, + newValue: value, + isHost: isHostRef.current, + }, + ); + + dispatch({ + type: BreakoutGroupActionTypes.SET_ALLOW_PEOPLE_TO_SWITCH_ROOM, + payload: {canUserSwitchRoom: value}, + }); + }; + + const createBreakoutRoomGroup = () => { + if (!acquireOperationLock('CREATE_GROUP')) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Create new breakout room', + { + currentRoomCount: stateRef.current.breakoutGroups.length, + isHost: isHostRef.current, + sessionId: stateRef.current.breakoutSessionId, + }, + ); + + dispatch({type: BreakoutGroupActionTypes.CREATE_GROUP}); + }; + + const handleAssignParticipants = (strategy: RoomAssignmentStrategy) => { + if (stateRef.current.breakoutGroups.length === 0) { + Toast.show({ + type: 'info', + text1: 'No breakout rooms found.', + visibilityTime: 3000, + }); + logger.warn( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Assign participants blocked (no rooms)', + {strategy}, + ); + return; + } + + // Check for participants available for assignment based on strategy + const availableParticipants = + strategy === RoomAssignmentStrategy.AUTO_ASSIGN + ? stateRef.current.unassignedParticipants.filter( + participant => participant.uid !== localUid, + ) + : stateRef.current.unassignedParticipants; + + if (availableParticipants.length === 0) { + const message = + strategy === RoomAssignmentStrategy.AUTO_ASSIGN && + stateRef.current.unassignedParticipants.length > 0 + ? 'No other participants to assign. (Host is excluded from auto-assignment)' + : 'No participants left to assign.'; + + Toast.show({type: 'info', text1: message, visibilityTime: 3000}); + logger.warn( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Assign participants blocked (none available)', + {strategy}, + ); + return; + } + + if (!acquireOperationLock(`ASSIGN_${strategy}`)) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[ACTION] Assign participants with strategy ${strategy}`, + { + strategy, + unassignedCount: stateRef.current.unassignedParticipants.length, + roomCount: stateRef.current.breakoutGroups.length, + isHost: isHostRef.current, + }, + ); + + if (strategy === RoomAssignmentStrategy.AUTO_ASSIGN) { + dispatch({ + type: BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS, + payload: {localUid}, + }); + } + if (strategy === RoomAssignmentStrategy.MANUAL_ASSIGN) { + dispatch({type: BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS}); + } + if (strategy === RoomAssignmentStrategy.NO_ASSIGN) { + dispatch({type: BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS}); + } + }; + + const moveUserToMainRoom = (uid: UidType) => { + try { + if (!uid) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Move to main blocked (no uid)', + ); + return; + } + + // Check for API operation conflicts first + if (!acquireOperationLock('MOVE_PARTICIPANT_TO_MAIN')) { + return; + } + + // Use fresh state to avoid race conditions + const currentState = stateRef.current; + const currentGroup = currentState.breakoutGroups.find( + group => + group.participants.hosts.includes(uid) || + group.participants.attendees.includes(uid), + ); + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Move user to main', + { + userId: uid, + fromGroupId: currentGroup?.id, + fromGroupName: currentGroup?.name, + }, + ); + + if (currentGroup) { + dispatch({ + type: BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN, + payload: {uid, fromGroupId: currentGroup.id}, + }); + } + } catch (error: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ERROR] Move to main failed', + {userId: uid, error: error?.message}, + ); + } + }; + + const moveUserIntoGroup = (uid: UidType, toGroupId: string) => { + try { + if (!uid) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Move to group blocked (no uid)', + {toGroupId}, + ); + return; + } + + // Check for API operation conflicts first + if (!acquireOperationLock('MOVE_PARTICIPANT_TO_GROUP')) { + return; + } + + // Use fresh state to avoid race conditions + const currentState = stateRef.current; + const currentGroup = currentState.breakoutGroups.find( + group => + group.participants.hosts.includes(uid) || + group.participants.attendees.includes(uid), + ); + const targetGroup = currentState.breakoutGroups.find( + group => group.id === toGroupId, + ); + + if (!targetGroup) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Move to group blocked (target not found)', + {userId: uid, toGroupId}, + ); + return; + } + + // Determine if user is host + let isUserHost: boolean | undefined; + if (currentGroup) { + // User is moving from another breakout room + isUserHost = currentGroup.participants.hosts.includes(uid); + } else { + // User is moving from main room - check mainRoomRTMUsers + const rtmUser = mainRoomRTMUsers[uid]; + if (rtmUser) { + isUserHost = rtmUser.isHost === 'true'; + } + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Move user between groups', + { + userId: uid, + fromGroupId: currentGroup?.id, + fromGroupName: currentGroup?.name, + toGroupId, + toGroupName: targetGroup.name, + isUserHost, + }, + ); + // // Clean up presenter status if user is switching rooms + // const isPresenting = presenters.some(p => p.uid === uid); + // if (isPresenting) { + // setCustomRTMMainRoomData(prev => ({ + // ...prev, + // breakout_room_presenters: ( + // prev.breakout_room_presenters || [] + // ).filter((p: any) => p.uid !== uid), + // })); + + // // Notify the user that their presenter access was removed + // try { + // events.send( + // BreakoutRoomEventNames.BREAKOUT_ROOM_MAKE_PRESENTER, + // JSON.stringify({ + // uid: uid, + // timestamp: Date.now(), + // action: 'stop', + // }), + // PersistanceLevel.None, + // uid, + // ); + // } catch (error) { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'Error sending presenter stop event on room switch', + // {error: error.message}, + // ); + // } + // } + + dispatch({ + type: BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP, + payload: { + uid, + fromGroupId: currentGroup?.id, + toGroupId, + isHost: isUserHost, + }, + }); + } catch (error: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ERROR] Move to group failed', + {userId: uid, toGroupId, error: error?.message}, + ); + } + }; + + const isUserInRoom = useCallback( + (room?: BreakoutGroup): boolean => { + if (room) { + // Check specific room + return ( + room.participants.hosts.includes(localUid) || + room.participants.attendees.includes(localUid) + ); + } else { + // Check ALL rooms - is user in any room? + return stateRef.current.breakoutGroups.some( + group => + group.participants.hosts.includes(localUid) || + group.participants.attendees.includes(localUid), + ); + } + }, + [localUid, breakoutRoomVersion], + ); + + const findUserRoomId = (uid: UidType, groups: BreakoutGroup[] = []) => + groups.find(g => { + const hosts = Array.isArray(g?.participants?.hosts) + ? g.participants.hosts + : []; + const attendees = Array.isArray(g?.participants?.attendees) + ? g.participants.attendees + : []; + return hosts.includes(uid) || attendees.includes(uid); + })?.id ?? null; + + // Permissions recompute + useEffect(() => { + if (lastSyncedSnapshotRef.current) { + const current = lastSyncedSnapshotRef.current; + + const currentlyInRoom = !!findUserRoomId(localUid, current.breakout_room); + const hasAvailableRooms = current.breakout_room?.length > 0; + const allowAttendeeSwitch = current.switch_room; + + const nextPermissions: BreakoutRoomPermissions = { + canJoinRoom: + hasAvailableRooms && (isHostRef.current || allowAttendeeSwitch), + canExitRoom: isBreakoutMode && currentlyInRoom, + canSwitchBetweenRooms: + currentlyInRoom && + hasAvailableRooms && + (isHostRef.current || allowAttendeeSwitch), + canScreenshare: true, + canRaiseHands: !isHostRef.current && !!current.session_id, + canAssignParticipants: isHostRef.current && !currentlyInRoom, + canHostManageMainRoom: isHostRef.current, + canCreateRooms: isHostRef.current, + canMoveUsers: isHostRef.current, + canCloseRooms: + isHostRef.current && hasAvailableRooms && !!current.session_id, + canMakePresenter: isHostRef.current, + }; + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[PERMISSIONS] Recomputed', + { + currentlyInRoom, + hasAvailableRooms, + allowAttendeeSwitch, + isHost: isHostRef.current, + }, + ); + + setPermissions(nextPermissions); + } + }, [breakoutRoomVersion, isBreakoutMode, localUid]); + + const joinRoom = ( + toRoomId: string, + permissionAtCallTime = permissions.canJoinRoom, + ) => { + if (!permissionAtCallTime) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Join blocked (no permission)', + {toRoomId, currentPermission: permissions.canJoinRoom}, + ); + return; + } + if (!localUid) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Join blocked (no local user)', + {toRoomId}, + ); + return; + } + + const toRoomName = + stateRef.current.breakoutGroups.find(r => r.id === toRoomId)?.name || ''; + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[ACTION] User joining room ${toRoomName}`, + { + userId: localUid, + toRoomId, + toRoomName: toRoomName, + }, + ); + + lastSelfJoinRef.current = {roomId: toRoomId, ts: Date.now()}; + moveUserIntoGroup(localUid, toRoomId); + if (!isHostRef.current) { + setSelfJoinRoomId(toRoomId); + } + }; + + const exitRoom = useCallback( + async (permissionAtCallTime = permissions.canExitRoom) => { + // Use permission passed at call time to avoid race conditions + if (!permissionAtCallTime) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Exit blocked (no permission)', + {currentPermission: permissions.canExitRoom}, + ); + return; + } + + // If u are reciving or calling this tha means u will have + // valid data in defaultcontent as u cannot exit from the room + // you are not in + const localUser = defaultContentRef.current[localUid]; + + // // Clean up presenter status if user is presenting + // const isPresenting = presenters.some(p => p.uid === localUid); + // if (isPresenting) { + // setCustomRTMMainRoomData(prev => ({ + // ...prev, + // breakout_room_presenters: ( + // prev.breakout_room_presenters || [] + // ).filter((p: any) => p.uid !== localUid), + // })); + // setICanPresent(false); + // } + + try { + if (localUser) { + // Use breakout-specific exit (doesn't destroy main RTM) + await breakoutRoomExit(); + + // Guard against component unmount + if (!isMountedRef.current) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Exit cancelled (unmounted)', + {userId: localUid}, + ); + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Exit success', + {userId: localUid}, + ); + } + } catch (error: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ERROR] Exit room failed', + {userId: localUid, error: error?.message}, + ); + } + }, + [localUid, permissions.canExitRoom, breakoutRoomExit], + ); + + const closeRoom = (roomIdToClose: string) => { + if (!acquireOperationLock('CLOSE_GROUP')) { + return; + } + + const roomToClose = stateRef.current.breakoutGroups.find( + r => r.id === roomIdToClose, + ); + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[ACTION] Close room -> ${roomToClose?.name}`, + { + roomId: roomIdToClose, + roomName: roomToClose?.name, + participantCount: + (roomToClose?.participants.hosts.length || 0) + + (roomToClose?.participants.attendees.length || 0), + isHost: isHostRef.current, + }, + ); + + dispatch({ + type: BreakoutGroupActionTypes.CLOSE_GROUP, + payload: {groupId: roomIdToClose}, + }); + }; + + const closeAllRooms = () => { + if (!acquireOperationLock('CLOSE_ALL_GROUPS')) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Close all rooms', + { + roomCount: stateRef.current.breakoutGroups.length, + totalParticipants: stateRef.current.breakoutGroups.reduce( + (sum, room) => + sum + + room.participants.hosts.length + + room.participants.attendees.length, + 0, + ), + isHost: isHostRef.current, + sessionId: stateRef.current.breakoutSessionId, + }, + ); + + // Clear all presenters when closing all rooms + // clearAllPresenters(); + + dispatch({type: BreakoutGroupActionTypes.CLOSE_ALL_GROUPS}); + }; + + const updateRoomName = (newRoomName: string, roomIdToEdit: string) => { + if (!acquireOperationLock('RENAME_GROUP')) { + return; + } + + const roomToRename = stateRef.current.breakoutGroups.find( + r => r.id === roomIdToEdit, + ); + + logger.log(LogSource.Internals, 'BREAKOUT_ROOM', '[ACTION] Rename room', { + roomId: roomIdToEdit, + oldName: roomToRename?.name, + newName: newRoomName, + isHost: isHostRef.current, + }); + + dispatch({ + type: BreakoutGroupActionTypes.RENAME_GROUP, + payload: {newName: newRoomName, groupId: roomIdToEdit}, + }); + }; + + const getAllRooms = () => { + return stateRef.current.breakoutGroups.length > 0 + ? stateRef.current.breakoutGroups + : []; + }; + + // const isUserPresenting = useCallback( + // (uid?: UidType) => { + // if (uid !== undefined) { + // return presenters.some(presenter => presenter.uid === uid); + // } + // // fall back to current user + // return canIPresent; + // }, + // [presenters, canIPresent], + // ); + + // // User wants to start presenting + // const makePresenter = (uid: UidType, action: 'start' | 'stop') => { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // `Make presenter - ${action}`, + // { + // targetUserId: uid, + // action, + // isHost: isHostRef.current, + // }, + // ); + // if (!uid) { + // return; + // } + // try { + // const timestamp = Date.now(); + // // Host sends BREAKOUT_ROOM_MAKE_PRESENTER event to the attendee + // events.send( + // BreakoutRoomEventNames.BREAKOUT_ROOM_MAKE_PRESENTER, + // JSON.stringify({ + // uid: uid, + // timestamp, + // action, + // }), + // PersistanceLevel.None, + // uid, + // ); + + // // Host immediately updates their own customRTMMainRoomData + // if (action === 'start') { + // setCustomRTMMainRoomData(prev => { + // const currentPresenters = prev.breakout_room_presenters || []; + // // Check if already presenting to avoid duplicates + // const exists = currentPresenters.find( + // (presenter: any) => presenter.uid === uid, + // ); + // if (exists) { + // return prev; + // } + // return { + // ...prev, + // breakout_room_presenters: [...currentPresenters, {uid, timestamp}], + // }; + // }); + // } else if (action === 'stop') { + // setCustomRTMMainRoomData(prev => ({ + // ...prev, + // breakout_room_presenters: ( + // prev.breakout_room_presenters || [] + // ).filter((presenter: any) => presenter.uid !== uid), + // })); + // } + // } catch (error) { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'Error making user presenter', + // { + // targetUserId: uid, + // action, + // error: error.message, + // }, + // ); + // } + // }; + + // const onMakeMePresenter = useCallback( + // (action: 'start' | 'stop', shouldSendEvent: boolean = true) => { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // `User became presenter - ${action}`, + // ); + + // const timestamp = Date.now(); + + // // Send event only if requested (not when restoring from attribute) + // if (shouldSendEvent) { + // // Attendee sends BREAKOUT_PRESENTER_ATTRIBUTE event to persist their presenter status + // events.send( + // EventNames.BREAKOUT_PRESENTER_ATTRIBUTE, + // JSON.stringify({ + // uid: localUid, + // isPresenter: action === 'start', + // timestamp, + // }), + // PersistanceLevel.Sender, + // ); + // } + + // if (action === 'start') { + // setICanPresent(true); + // // Show toast notification when presenter permission is granted + // Toast.show({ + // type: 'success', + // text1: 'You can now present in this breakout room', + // visibilityTime: 3000, + // }); + // } else if (action === 'stop') { + // if (isScreenshareActive) { + // stopScreenshare(); + // } + // setICanPresent(false); + // // Show toast notification when presenter permission is removed + // Toast.show({ + // type: 'info', + // text1: 'Your presenter access has been removed', + // visibilityTime: 3000, + // }); + // } + // }, + // [isScreenshareActive, localUid], + // ); + + // const clearAllPresenters = useCallback(() => { + // setCustomRTMMainRoomData(prev => ({ + // ...prev, + // breakout_room_presenters: [], + // })); + // }, [setCustomRTMMainRoomData]); + + const getRoomMemberDropdownOptions = useCallback( + (memberUid: UidType) => { + const options: MemberDropdownOption[] = []; + if (!memberUid) { + return options; + } + + const currentRoom = stateRef.current.breakoutGroups.find( + group => + group.participants.hosts.includes(memberUid) || + group.participants.attendees.includes(memberUid), + ); + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[UI] Member dropdown options computed', + { + memberUid, + currentRoomId: currentRoom?.id, + roomCount: stateRef.current.breakoutGroups.length, + }, + ); + + options.push({ + icon: 'double-up-arrow', + type: 'move-to-main', + title: 'Move to Main Room', + onOptionPress: () => moveUserToMainRoom(memberUid), + }); + // Move to other breakout rooms (exclude current room) + stateRef.current.breakoutGroups + .filter(group => group.id !== currentRoom?.id) + .forEach(group => { + options.push({ + type: 'move-to-room', + icon: 'move-up', + title: `Shift to : ${group.name}`, + roomId: group.id, + roomName: group.name, + onOptionPress: () => moveUserIntoGroup(memberUid, group.id), + }); + }); + + // // Make presenter option is available only for host + // // and if the incoming member is also a host we dont + // // need to show this option as they can already present + // const isUserHost = + // currentRoom?.participants.hosts.includes(memberUid) || false; + // if (isUserHost) { + // return options; + // } + // if (isHostRef.current) { + // const userIsPresenting = isUserPresenting(memberUid); + // const title = userIsPresenting ? 'Stop presenter' : 'Make a Presenter'; + // const action = userIsPresenting ? 'stop' : 'start'; + // options.push({ + // type: 'make-presenter', + // icon: 'promote-filled', + // title, + // onOptionPress: () => makePresenter(memberUid, action), + // }); + // } + return options; + }, + // [isUserPresenting, presenters, breakoutRoomVersion], + [breakoutRoomVersion], + ); + + // ---- SYNC (EVENTS) ---- + + const _handleBreakoutRoomSyncState = useCallback( + async ( + payload: BreakoutRoomSyncStateEventPayload['data'], + timestamp: number, + ) => { + const {srcuid, data} = payload; + const { + session_id, + switch_room, + breakout_room, + assignment_type, + sts = 0, + } = data; + + logger.debug( + LogSource.Events, + 'RTM_EVENTS', + '[SYNC] Breakout sync event received', + { + srcuid, + session_id, + timestamp, + sts, + newRooms: breakout_room?.length || 0, + currentRooms: stateRef.current.breakoutGroups.length, + }, + ); + + // Global server ordering + if (sts <= lastProcessedServerTsRef.current) { + logger.warn( + LogSource.Events, + 'RTM_EVENTS', + '[SYNC] Ignored out-of-order state', + {sts, lastProcessedServerTs: lastProcessedServerTsRef.current}, + ); + return; + } + lastProcessedServerTsRef.current = sts; + + // Self-join race protection + if ( + lastSelfJoinRef.current && + Date.now() - lastSelfJoinRef.current.ts < 2000 && // 2s cooldown + !findUserRoomId(localUid, breakout_room) + ) { + logger.warn( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC] Ignored stale revert conflicting with recent self-join', + {recentJoinRoomId: lastSelfJoinRef.current.roomId}, + ); + return; + } + + // Local duplicate protection (client-side ordering) Skip events older than the last processed timestamp + if (timestamp && timestamp <= lastSyncedTimestampRef.current) { + logger.warn( + LogSource.Events, + 'RTM_EVENTS', + '[SYNC] Ignored old sync event', + { + timestamp, + lastProcessed: lastSyncedTimestampRef.current, + }, + ); + return; + } + + const prevSnapshot = lastSyncedSnapshotRef?.current; + const prevGroups = prevSnapshot?.breakout_room || []; + const prevSwitchRoom = prevSnapshot?.switch_room ?? true; + const prevRoomId = findUserRoomId(localUid, prevGroups); + const nextRoomId = findUserRoomId(localUid, breakout_room); + + // 1. !prevRoomId && nextRoomId = Main β†’ Breakout (joining) + // 2. prevRoomId && nextRoomId && prevRoomId !== nextRoomId = Breakout A β†’ Breakout B (switching) + // 3. prevRoomId && !nextRoomId = Breakout β†’ Main (leaving) + // 4. !prevRoomId && !nextRoomId = Main β†’ Main (no change) + + const userMovedBetweenRooms = + prevRoomId && nextRoomId && prevRoomId !== nextRoomId; + const userLeftBreakoutRoom = prevRoomId && !nextRoomId; + + const senderName = getDisplayName(srcuid); + + // ---- SCREEN SHARE CLEANUP ---- + // Stop screen share if user is moving between rooms or leaving breakout + // if ( + // (userMovedBetweenRooms || userLeftBreakoutRoom) && + // isScreenshareActive + // ) { + // stopScreenshare(); + // } + + // ---- PRIORITY ORDER ---- + // 1) All rooms closed + if (breakout_room.length === 0 && prevGroups.length > 0) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC][Condition 1] All rooms closed', + {prevRoomCount: prevGroups.length, srcuid, senderName}, + ); + + if (prevRoomId && isBreakoutMode) { + // Don't show toast if the user is the author + if (srcuid !== localUid) { + showDeduplicatedToast('all-rooms-closed', { + leadingIconName: 'close-room', + type: 'info', + text1: `Host: ${senderName} has closed all breakout rooms.`, + text2: 'Returning to the main room...', + visibilityTime: 3000, + }); + } + // Set transition flag - user will remount in main room and need fresh data + sessionStorage.setItem('breakout_room_transition', 'true'); + lastSyncedSnapshotRef.current = null; + return exitRoom(true); + } else { + // 2. User is in main room recevies just notification + // Don't show toast if the user is the author + if (srcuid !== localUid) { + showDeduplicatedToast('all-rooms-closed', { + leadingIconName: 'close-room', + type: 'info', + text1: `Host: ${senderName} has closed all breakout rooms`, + visibilityTime: 4000, + }); + } + } + } + + // 2. User's room deleted (they were in a room β†’ now not) + if (userLeftBreakoutRoom && isBreakoutMode) { + const prevRoom = prevGroups.find(r => r.id === prevRoomId); + const roomStillExists = breakout_room.some(r => r.id === prevRoomId); + // Case A: Room deleted + if (!roomStillExists) { + // Don't show toast if the user is the author + if (srcuid !== localUid) { + showDeduplicatedToast(`current-room-closed-${prevRoomId}`, { + leadingIconName: 'close-room', + type: 'error', + text1: `Host: ${senderName} has closed "${ + prevRoom?.name || '' + }" room.`, + text2: 'Returning to main room...', + visibilityTime: 3000, + }); + } + } else { + // Host removed user from room (handled here) + // (Room still exists for others, but you were unassigned) + // Don't show toast if the user is the author + if (srcuid !== localUid) { + showDeduplicatedToast(`moved-to-main-${prevRoomId}`, { + leadingIconName: 'arrow-up', + type: 'info', + text1: `Host: ${senderName} has moved you to main room.`, + visibilityTime: 3000, + }); + } + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC][Condition 2] User leaving breakout (to main)', + {prevRoomId, srcuid, senderName}, + ); + + // Set transition flag - user will remount in main room and need fresh data + sessionStorage.setItem('breakout_room_transition', 'true'); + lastSyncedSnapshotRef.current = null; + return exitRoom(true); + } + + // 3. User moved between breakout rooms + if (userMovedBetweenRooms) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC][Condition 3] User moved between breakout rooms', + {prevRoomId, nextRoomId, srcuid, senderName}, + ); + } + + // 4) Rooms switch control toggled + if (switch_room && !prevSwitchRoom) { + if (srcuid !== localUid) { + showDeduplicatedToast('switch-room-toggle', { + leadingIconName: 'open-room', + type: 'info', + text1: `Host:${senderName} has opened breakout rooms.`, + text2: 'Please choose a room to join.', + visibilityTime: 3000, + }); + } + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC][Condition 4] Switch room enabled', + {srcuid, senderName}, + ); + } + + // 5) Room renamed + prevGroups.forEach(prevRoom => { + const after = breakout_room.find(r => r.id === prevRoom.id); + if (after && after.name !== prevRoom.name) { + if (srcuid !== localUid) { + showDeduplicatedToast(`room-renamed-${after.id}`, { + type: 'info', + text1: `Host: ${senderName} has renamed room "${prevRoom.name}" to "${after.name}".`, + visibilityTime: 3000, + }); + } + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC][Condition 5] Room renamed', + {roomId: after.id, from: prevRoom.name, to: after.name}, + ); + } + }); + + // Apply new state + dispatch({ + type: BreakoutGroupActionTypes.SYNC_STATE, + payload: { + sessionId: session_id, + assignmentStrategy: assignment_type, + switchRoom: switch_room, + rooms: breakout_room, + }, + }); + + // Store the snap of this + lastSyncedSnapshotRef.current = payload.data; + lastSyncedTimestampRef.current = timestamp || Date.now(); + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[SYNC] State applied', + { + session_id, + rooms: breakout_room?.length || 0, + timestampApplied: lastSyncedTimestampRef.current, + }, + ); + }, + [ + dispatch, + exitRoom, + localUid, + showDeduplicatedToast, + getDisplayName, + isBreakoutMode, + ], + ); + + /** + * While Event 1 is processing… + * Event 2 arrives (ts=200) and Event 3 arrives (ts=300). + * Both will overwrite latestTask: + * Now, queue.latestTask only holds event 3, because event 2 was replaced before it could be picked up. + * Latest-event-wins queue: enqueue only the freshest by timestamp. + */ + + const enqueueBreakoutSyncEvent = useCallback( + (payload: BreakoutRoomSyncStateEventPayload['data'], timestamp: number) => { + const queue = breakoutSyncQueueRef.current; + + if ( + !queue.latestTask || + (timestamp && timestamp > queue.latestTask.timestamp) + ) { + queue.latestTask = {payload, timestamp}; + } + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Enqueued sync event', + { + latestTs: queue.latestTask?.timestamp, + currentlyProcessing: queue.isProcessing, + }, + ); + + processBreakoutSyncQueue(); + }, + [], + ); + + const processBreakoutSyncQueue = useCallback(async () => { + const queue = breakoutSyncQueueRef.current; + + // 1. If the queue is already being processed by another call, exit immediately. + if (queue.isProcessing) { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Already processing, skipping start', + ); + return; + } + + try { + // 2. "lock" the queue, so no second process can start. + queue.isProcessing = true; + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Processing started', + ); + + while (queue.latestTask) { + const {payload, timestamp} = queue.latestTask; + queue.latestTask = null; + + try { + await _handleBreakoutRoomSyncState(payload, timestamp); + } catch (err: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Error processing sync event', + {error: err?.message}, + ); + // Continue processing other events even if one fails + } + } + } catch (err: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Critical error', + {error: err?.message}, + ); + } finally { + // Always unlock the queue, even if there's an error + queue.isProcessing = false; + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[QUEUE] Processing finished', + ); + } + }, []); + + // Multi-host coordination handlers + const handleHostOperationStart = useCallback( + (operationName: string, hostUid: UidType, hostName: string) => { + // Only process if current user is also a host and it's not their own event + if (hostUid === localUid) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[HOST] Another host has started operation (lock UI) -> ${operationName}`, + {operationName, hostUid, hostName}, + ); + + setCurrentOperatingHostName(hostName); + }, + [localUid], + ); + + const handleHostOperationEnd = useCallback( + (operationName: string, hostUid: UidType, hostName: string) => { + // Only process if current user is also a host and it's not their own event + if (hostUid === localUid) { + return; + } + + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + `[HOST] Another host ended operation (unlock UI) ${operationName}`, + {operationName, hostUid, hostName}, + ); + + setCurrentOperatingHostName(undefined); + }, + [localUid], + ); + + // Debounced API for performance with multi-host coordination + const debouncedUpsertAPI = useDebouncedCallback( + async (type: 'START' | 'UPDATE', operationName?: string) => { + setBreakoutUpdateInFlight(true); + + try { + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API] Debounced upsert start for', + {type, operationName}, + ); + + await upsertBreakoutRoomAPI(type); + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API] Debounced upsert success', + {type, operationName}, + ); + + // Broadcast operation end after successful API call + if (operationName) { + broadcastHostOperationEnd(operationName); + } + } catch (error: any) { + logger.error( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[API] Debounced upsert failed', + {error: error?.message, type, operationName}, + ); + + // Broadcast operation end even on failure + if (operationName) { + broadcastHostOperationEnd(operationName); + } + } finally { + setBreakoutUpdateInFlight(false); + } + }, + 500, + ); + + // Action-based API triggering + useEffect(() => { + if (!lastAction || !lastAction.type) { + return; + } + + // Actions that should trigger API calls + const API_TRIGGERING_ACTIONS = [ + BreakoutGroupActionTypes.CREATE_GROUP, + BreakoutGroupActionTypes.RENAME_GROUP, + BreakoutGroupActionTypes.CLOSE_GROUP, + BreakoutGroupActionTypes.CLOSE_ALL_GROUPS, + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN, + BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP, + BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS, + BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS, + BreakoutGroupActionTypes.SET_ALLOW_PEOPLE_TO_SWITCH_ROOM, + BreakoutGroupActionTypes.EXIT_GROUP, + ]; + + // Host can always trigger API calls for any action + // Attendees can only trigger API when they self-join a room and switch_room is enabled + const attendeeSelfJoinAllowed = + stateRef.current.canUserSwitchRoom && + lastAction.type === BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP; + + const shouldCallAPI = + API_TRIGGERING_ACTIONS.includes(lastAction.type as any) && + (isHostRef.current || (!isHostRef.current && attendeeSelfJoinAllowed)); + + // Compute lastOperationName based on lastAction + const lastOperationName = HOST_BROADCASTED_OPERATIONS.includes( + lastAction?.type as any, + ) + ? lastAction?.type + : undefined; + + logger.debug( + LogSource.Internals, + 'BREAKOUT_ROOM', + '[ACTION] Post-dispatch evaluation', + { + actionType: lastAction.type, + shouldCallAPI, + attendeeSelfJoinAllowed, + lastOperationName, + }, + ); + + if (shouldCallAPI) { + debouncedUpsertAPI('UPDATE', lastOperationName); + } + }, [lastAction]); + + return ( + + {children} + + ); +}; + +const useBreakoutRoom = createHook(BreakoutRoomContext); + +export {useBreakoutRoom, BreakoutRoomProvider}; diff --git a/template/src/components/breakout-room/events/BreakoutRoomEventsConfigure.tsx b/template/src/components/breakout-room/events/BreakoutRoomEventsConfigure.tsx new file mode 100644 index 000000000..0ed265a50 --- /dev/null +++ b/template/src/components/breakout-room/events/BreakoutRoomEventsConfigure.tsx @@ -0,0 +1,272 @@ +import React, {useEffect, useRef} from 'react'; +import events from '../../../rtm-events-api'; +import {BreakoutRoomEventNames} from './constants'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import {BreakoutRoomSyncStateEventPayload} from '../state/types'; +import {useLocalUid} from '../../../../agora-rn-uikit'; +import {useRoomInfo} from '../../../components/room-info/useRoomInfo'; +import {logger, LogSource} from '../../../logger/AppBuilderLogger'; + +interface Props { + children: React.ReactNode; +} + +const BreakoutRoomEventsConfigure: React.FC = ({children}) => { + const { + // onMakeMePresenter, + handleBreakoutRoomSyncState, + handleHostOperationStart, + handleHostOperationEnd, + } = useBreakoutRoom(); + // const {setCustomRTMMainRoomData} = useRTMGlobalState(); + const localUid = useLocalUid(); + const { + data: {isHost}, + } = useRoomInfo(); + const isHostRef = React.useRef(isHost); + const localUidRef = React.useRef(localUid); + // const onMakeMePresenterRef = useRef(onMakeMePresenter); + const handleBreakoutRoomSyncStateRef = useRef(handleBreakoutRoomSyncState); + const handleHostOperationStartRef = useRef(handleHostOperationStart); + const handleHostOperationEndRef = useRef(handleHostOperationEnd); + + useEffect(() => { + isHostRef.current = isHost; + }, [isHost]); + useEffect(() => { + localUidRef.current = localUid; + }, [localUid]); + // useEffect(() => { + // onMakeMePresenterRef.current = onMakeMePresenter; + // }, [onMakeMePresenter]); + useEffect(() => { + handleBreakoutRoomSyncStateRef.current = handleBreakoutRoomSyncState; + }, [handleBreakoutRoomSyncState]); + useEffect(() => { + handleHostOperationStartRef.current = handleHostOperationStart; + }, [handleHostOperationStart]); + useEffect(() => { + handleHostOperationEndRef.current = handleHostOperationEnd; + }, [handleHostOperationEnd]); + + useEffect(() => { + // const handleMakePresenterEvent = (evtData: any) => { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'BREAKOUT_ROOM_MAKE_PRESENTER event received', + // evtData, + // ); + // try { + // const {payload} = evtData; + // const data = JSON.parse(payload); + // console.log('supriya-presenter handleMakePresenterEvent data: ', data); + // const {uid, action} = data; + + // // Only process if it's for the local user + // if (uid === localUidRef.current) { + // onMakeMePresenterRef.current(action); + // } + // } catch (error) { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'Error handling make presenter event', + // error, + // ); + // } + // }; + + const handleBreakoutRoomSyncStateEvent = (evtData: any) => { + console.log('BREAKOUT_ROOM_SYNC_STATE event recevied', evtData); + const {ts, payload} = evtData; + const data: BreakoutRoomSyncStateEventPayload = JSON.parse(payload); + if (data.data.act === 'SYNC_STATE') { + console.log( + 'supriya-state-sync ********* BREAKOUT_ROOM_SYNC_STATE event triggered ***************', + ); + handleBreakoutRoomSyncStateRef.current(data.data, ts); + } + }; + + const handleHostOperationStartEvent = (evtData: any) => { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + 'BREAKOUT_ROOM_HOST_OPERATION_START event received', + evtData, + ); + try { + const {sender, payload} = evtData; + // Ignore events from self + if (sender === `${localUidRef.current}`) { + return; + } + // // Only process if current user is also a host + // if (!isHostRef.current) { + // return; + // } + + const data = JSON.parse(payload); + const {operationName, hostUid, hostName} = data; + + handleHostOperationStartRef.current(operationName, hostUid, hostName); + } catch (error) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + 'Error handling host operation start event', + {error: error.message}, + ); + } + }; + + const handleHostOperationEndEvent = (evtData: any) => { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + 'BREAKOUT_ROOM_HOST_OPERATION_END event received', + evtData, + ); + try { + const {sender, payload} = evtData; + // Ignore events from self + if (sender === `${localUidRef.current}`) { + return; + } + // // Only process if current user is also a host + // if (!isHostRef.current) { + // return; + // } + + const data = JSON.parse(payload); + const {operationName, hostUid, hostName} = data; + + handleHostOperationEndRef.current(operationName, hostUid, hostName); + } catch (error) { + logger.log( + LogSource.Internals, + 'BREAKOUT_ROOM', + 'Error handling host operation end event', + {error: error.message}, + ); + } + }; + + // const handlePresenterAttributeEvent = (evtData: any) => { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'BREAKOUT_PRESENTER_ATTRIBUTE event received', + // evtData, + // ); + // try { + // const {payload} = evtData; + // const data = JSON.parse(payload); + // console.log('supriya-presenter handlePresenterAttributeEvent', data); + + // const {uid, isPresenter, timestamp} = data; + + // // If this is the local user's presenter attribute, restore their state + // // Pass shouldSendEvent: false to avoid sending the event again (infinite loop) + // if (uid === localUidRef.current && !isHostRef.current) { + // if (isPresenter) { + // onMakeMePresenterRef.current('start', false); + // } else { + // onMakeMePresenterRef.current('stop', false); + // } + // } + + // // Host updates customRTMMainRoomData with presenter status + // // This is mainly for syncing state when host rejoins and reads persisted attributes + // if (isHostRef.current) { + // if (isPresenter) { + // setCustomRTMMainRoomData(prev => { + // const currentPresenters = prev.breakout_room_presenters || []; + // // Check if already in the list (avoid duplicate from makePresenter) + // const exists = currentPresenters.find((p: any) => p.uid === uid); + // if (exists) { + // return prev; + // } + // return { + // ...prev, + // breakout_room_presenters: [ + // ...currentPresenters, + // {uid, timestamp}, + // ], + // }; + // }); + // } else { + // // Remove from presenters list + // setCustomRTMMainRoomData(prev => ({ + // ...prev, + // breakout_room_presenters: ( + // prev.breakout_room_presenters || [] + // ).filter((p: any) => p.uid !== uid), + // })); + // } + // } + // } catch (error) { + // logger.log( + // LogSource.Internals, + // 'BREAKOUT_ROOM', + // 'Error handling presenter attribute event', + // error, + // ); + // } + // }; + + // events.on( + // BreakoutRoomEventNames.BREAKOUT_ROOM_ANNOUNCEMENT, + // handleAnnouncementEvent, + // ); + // events.on( + // EventNames.BREAKOUT_PRESENTER_ATTRIBUTE, + // handlePresenterAttributeEvent, + // ); + // events.on( + // BreakoutRoomEventNames.BREAKOUT_ROOM_MAKE_PRESENTER, + // handleMakePresenterEvent, + // ); + events.on( + BreakoutRoomEventNames.BREAKOUT_ROOM_SYNC_STATE, + handleBreakoutRoomSyncStateEvent, + ); + events.on( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_START, + handleHostOperationStartEvent, + ); + events.on( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_END, + handleHostOperationEndEvent, + ); + + return () => { + // events.off(BreakoutRoomEventNames.BREAKOUT_ROOM_ANNOUNCEMENT); + // events.off( + // EventNames.BREAKOUT_PRESENTER_ATTRIBUTE, + // handlePresenterAttributeEvent, + // ); + // events.off( + // BreakoutRoomEventNames.BREAKOUT_ROOM_MAKE_PRESENTER, + // handleMakePresenterEvent, + // ); + events.off( + BreakoutRoomEventNames.BREAKOUT_ROOM_SYNC_STATE, + handleBreakoutRoomSyncStateEvent, + ); + events.off( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_START, + handleHostOperationStartEvent, + ); + events.off( + BreakoutRoomEventNames.BREAKOUT_ROOM_HOST_OPERATION_END, + handleHostOperationEndEvent, + ); + }; + }, []); + + return <>{children}; +}; + +export default BreakoutRoomEventsConfigure; diff --git a/template/src/components/breakout-room/events/constants.ts b/template/src/components/breakout-room/events/constants.ts new file mode 100644 index 000000000..b0714cee1 --- /dev/null +++ b/template/src/components/breakout-room/events/constants.ts @@ -0,0 +1,17 @@ +// 9. BREAKOUT ROOM +const BREAKOUT_ROOM_JOIN_DETAILS = 'BREAKOUT_ROOM_BREAKOUT_ROOM_JOIN_DETAILS'; +const BREAKOUT_ROOM_SYNC_STATE = 'BREAKOUT_ROOM_BREAKOUT_ROOM_STATE'; +const BREAKOUT_ROOM_ANNOUNCEMENT = 'BREAKOUT_ROOM_ANNOUNCEMENT'; +const BREAKOUT_ROOM_MAKE_PRESENTER = 'BREAKOUT_ROOM_MAKE_PRESENTER'; +const BREAKOUT_ROOM_HOST_OPERATION_START = 'BREAKOUT_ROOM_HOST_OPERATION_START'; +const BREAKOUT_ROOM_HOST_OPERATION_END = 'BREAKOUT_ROOM_HOST_OPERATION_END'; + +const BreakoutRoomEventNames = { + BREAKOUT_ROOM_JOIN_DETAILS, + BREAKOUT_ROOM_SYNC_STATE, + BREAKOUT_ROOM_ANNOUNCEMENT, + BREAKOUT_ROOM_MAKE_PRESENTER, + BREAKOUT_ROOM_HOST_OPERATION_START, + BREAKOUT_ROOM_HOST_OPERATION_END, +}; +export {BreakoutRoomEventNames}; diff --git a/template/src/components/breakout-room/hoc/BreakoutRoomNameRenderer.tsx b/template/src/components/breakout-room/hoc/BreakoutRoomNameRenderer.tsx new file mode 100644 index 000000000..54c98f889 --- /dev/null +++ b/template/src/components/breakout-room/hoc/BreakoutRoomNameRenderer.tsx @@ -0,0 +1,68 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useMemo} from 'react'; +import {useLocation} from '../../Router'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import {useLocalUid} from '../../../../agora-rn-uikit'; + +export interface BreakoutRoomInfo { + isInBreakoutMode: boolean; + breakoutRoomName: string; +} + +interface BreakoutRoomNameRendererProps { + children: (breakoutRoomInfo: BreakoutRoomInfo) => React.ReactNode; +} + +const BreakoutRoomNameRenderer: React.FC = ({ + children, +}) => { + const location = useLocation(); + const localUid = useLocalUid(); + const {breakoutGroups = [], breakoutRoomVersion} = useBreakoutRoom(); + + const breakoutRoomInfo = useMemo(() => { + let breakoutRoomName = ''; + let isInBreakoutMode = false; + let currentRoom = null; + + try { + const searchParams = new URLSearchParams(location.search); + isInBreakoutMode = searchParams.get('breakout') === 'true'; + + if (isInBreakoutMode) { + currentRoom = breakoutGroups?.find( + group => + group.participants?.hosts?.includes(localUid) || + group.participants?.attendees?.includes(localUid), + ); + + if (currentRoom?.name) { + breakoutRoomName = currentRoom.name; + } + } + } catch (error) { + // Safely handle cases where breakout context is not available + console.log('BreakoutRoomNameRenderer: Breakout context not available'); + } + + return { + isInBreakoutMode, + breakoutRoomName, + }; + }, [location.search, localUid, breakoutRoomVersion]); + + return <>{children(breakoutRoomInfo)}; +}; + +export default BreakoutRoomNameRenderer; diff --git a/template/src/components/breakout-room/hooks/useBreakoutRoomExit.ts b/template/src/components/breakout-room/hooks/useBreakoutRoomExit.ts new file mode 100644 index 000000000..7653e639f --- /dev/null +++ b/template/src/components/breakout-room/hooks/useBreakoutRoomExit.ts @@ -0,0 +1,49 @@ +import {useContext} from 'react'; +import {useHistory, useParams} from '../../../components/Router'; +import {useCaption, useContent, useSTTAPI} from 'customization-api'; + +const useBreakoutRoomExit = (handleLeaveBreakout?: () => void) => { + const history = useHistory(); + const {phrase} = useParams<{phrase: string}>(); + const {defaultContent} = useContent(); + const {stop: stopSTTAPI} = useSTTAPI(); + const {isSTTActive} = useCaption(); + + return async () => { + try { + // stopping STT on call end,if only last user is remaining in call + const usersInCall = Object.entries(defaultContent).filter( + item => + item[1].type === 'rtc' && + item[1].isHost === 'true' && + !item[1].offline, + ); + if (usersInCall.length === 1 && isSTTActive) { + console.log('Stopping stt api as only one host is in the call'); + stopSTTAPI().catch(error => { + console.log('Error stopping stt', error); + }); + } + + // Trigger exit transition if callback provided + if (handleLeaveBreakout) { + console.log('Triggering breakout room exit transition'); + handleLeaveBreakout(); + } else { + // Fallback: Navigate directly if no transition callback + history.push(`/${phrase}`); + } + } catch (error) { + // Fallback navigation on error + if (handleLeaveBreakout) { + handleLeaveBreakout(); + } else { + history.push(`/${phrase}`); + } + + throw error; // Re-throw so caller can handle + } + }; +}; + +export default useBreakoutRoomExit; diff --git a/template/src/components/breakout-room/state/reducer.ts b/template/src/components/breakout-room/state/reducer.ts new file mode 100644 index 000000000..66c9b1da9 --- /dev/null +++ b/template/src/components/breakout-room/state/reducer.ts @@ -0,0 +1,522 @@ +import {ContentInterface, UidType} from '../../../../agora-rn-uikit/src'; +import {randomNameGenerator} from '../../../utils'; + +export enum RoomAssignmentStrategy { + AUTO_ASSIGN = 'AUTO_ASSIGN', + MANUAL_ASSIGN = 'MANUAL_ASSIGN', + NO_ASSIGN = 'NO_ASSIGN', +} +export interface ManualParticipantAssignment { + uid: UidType; + roomId: string | null; // null means stay in main room + isHost: boolean; + isSelected: boolean; +} + +export interface BreakoutRoomUser { + name: string; + isHost: boolean; +} + +export interface BreakoutGroup { + id: string; + name: string; + participants: { + hosts: UidType[]; + attendees: UidType[]; + }; +} +export interface BreakoutRoomState { + breakoutSessionId: string; + breakoutGroups: BreakoutGroup[]; + unassignedParticipants: {uid: UidType; user: BreakoutRoomUser}[]; + manualAssignments: ManualParticipantAssignment[]; + assignmentStrategy: RoomAssignmentStrategy; + canUserSwitchRoom: boolean; +} + +export const initialBreakoutGroups = [ + { + name: 'Room 1', + id: `temp_${randomNameGenerator(6)}`, + participants: {hosts: [], attendees: []}, + }, + { + name: 'Room 2', + id: `temp_${randomNameGenerator(6)}`, + participants: {hosts: [], attendees: []}, + }, +]; + +export const initialBreakoutRoomState: BreakoutRoomState = { + breakoutSessionId: '', + assignmentStrategy: RoomAssignmentStrategy.NO_ASSIGN, + canUserSwitchRoom: true, + unassignedParticipants: [], + manualAssignments: [], + breakoutGroups: [], +}; + +export const BreakoutGroupActionTypes = { + // Initial state + SYNC_STATE: 'BREAKOUT_ROOM/SYNC_STATE', + // session + SET_SESSION_ID: 'BREAKOUT_ROOM/SET_SESSION_ID', + // Manual assignment strategy + SET_MANUAL_ASSIGNMENTS: 'BREAKOUT_ROOM/SET_MANUAL_ASSIGNMENTS', + CLEAR_MANUAL_ASSIGNMENTS: 'BREAKOUT_ROOM/CLEAR_MANUAL_ASSIGNMENTS', + // switch room + SET_ALLOW_PEOPLE_TO_SWITCH_ROOM: + 'BREAKOUT_ROOM/SET_ALLOW_PEOPLE_TO_SWITCH_ROOM', + // Group management + SET_GROUPS: 'BREAKOUT_ROOM/SET_GROUPS', + UPDATE_GROUPS_IDS: 'BREAKOUT_ROOM/UPDATE_GROUPS_IDS', + CREATE_GROUP: 'BREAKOUT_ROOM/CREATE_GROUP', + RENAME_GROUP: 'BREAKOUT_ROOM/RENAME_GROUP', + EXIT_GROUP: 'BREAKOUT_ROOM/EXIT_GROUP', + CLOSE_GROUP: 'BREAKOUT_ROOM/CLOSE_GROUP', + CLOSE_ALL_GROUPS: 'BREAKOUT_ROOM/CLOSE_ALL_GROUPS', + // Participants Assignment + UPDATE_UNASSIGNED_PARTICIPANTS: + 'BREAKOUT_ROOM/UPDATE_UNASSIGNED_PARTICIPANTS', + AUTO_ASSIGN_PARTICPANTS: 'BREAKOUT_ROOM/AUTO_ASSIGN_PARTICPANTS', + MANUAL_ASSIGN_PARTICPANTS: 'BREAKOUT_ROOM/MANUAL_ASSIGN_PARTICPANTS', + NO_ASSIGN_PARTICIPANTS: 'BREAKOUT_ROOM/NO_ASSIGN_PARTICIPANTS', + MOVE_PARTICIPANT_TO_MAIN: 'BREAKOUT_ROOM/MOVE_PARTICIPANT_TO_MAIN', + MOVE_PARTICIPANT_TO_GROUP: 'BREAKOUT_ROOM/MOVE_PARTICIPANT_TO_GROUP', +} as const; + +export type BreakoutRoomAction = + | { + type: typeof BreakoutGroupActionTypes.SYNC_STATE; + payload: { + sessionId: BreakoutRoomState['breakoutSessionId']; + switchRoom: BreakoutRoomState['canUserSwitchRoom']; + rooms: BreakoutRoomState['breakoutGroups']; + assignmentStrategy: BreakoutRoomState['assignmentStrategy']; + }; + } + | { + type: typeof BreakoutGroupActionTypes.SET_SESSION_ID; + payload: {sessionId: string}; + } + | { + type: typeof BreakoutGroupActionTypes.SET_MANUAL_ASSIGNMENTS; + payload: { + assignments: ManualParticipantAssignment[]; + }; + } + | { + type: typeof BreakoutGroupActionTypes.CLEAR_MANUAL_ASSIGNMENTS; + } + | { + type: typeof BreakoutGroupActionTypes.SET_ALLOW_PEOPLE_TO_SWITCH_ROOM; + payload: { + canUserSwitchRoom: boolean; + }; + } + | { + type: typeof BreakoutGroupActionTypes.SET_GROUPS; + payload: BreakoutGroup[]; + } + | { + type: typeof BreakoutGroupActionTypes.UPDATE_GROUPS_IDS; + payload: BreakoutGroup[]; + } + | {type: typeof BreakoutGroupActionTypes.CREATE_GROUP} + | { + type: typeof BreakoutGroupActionTypes.CLOSE_GROUP; + payload: { + groupId: string; + }; + } + | {type: typeof BreakoutGroupActionTypes.CLOSE_ALL_GROUPS} + | { + type: typeof BreakoutGroupActionTypes.RENAME_GROUP; + payload: { + newName: string; + groupId: string; + }; + } + | { + type: typeof BreakoutGroupActionTypes.EXIT_GROUP; + payload: { + user: ContentInterface; + fromGroupId: string; + }; + } + | { + type: typeof BreakoutGroupActionTypes.UPDATE_UNASSIGNED_PARTICIPANTS; + payload: { + unassignedParticipants: {uid: UidType; user: BreakoutRoomUser}[]; + }; + } + | { + type: typeof BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS; + payload: { + localUid: UidType; + }; + } + | { + type: typeof BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS; + } + | { + type: typeof BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS; + } + | { + type: typeof BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN; + payload: { + uid: UidType; + fromGroupId: string; + }; + } + | { + type: typeof BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP; + payload: { + uid: UidType; + fromGroupId: string; + toGroupId: string; + isHost?: boolean; + }; + }; + +export const breakoutRoomReducer = ( + state: BreakoutRoomState, + action: BreakoutRoomAction, +): BreakoutRoomState => { + switch (action.type) { + case BreakoutGroupActionTypes.SYNC_STATE: { + return { + ...state, + breakoutSessionId: action.payload.sessionId, + canUserSwitchRoom: action.payload.switchRoom, + assignmentStrategy: action.payload.assignmentStrategy, + breakoutGroups: action.payload.rooms.map(group => ({ + id: group.id, + name: group.name, + participants: { + hosts: [...new Set(group?.participants?.hosts ?? [])], + attendees: [...new Set(group?.participants?.attendees ?? [])], + }, + })), + }; + } + // group management cases + case BreakoutGroupActionTypes.SET_SESSION_ID: { + return {...state, breakoutSessionId: action.payload.sessionId}; + } + + case BreakoutGroupActionTypes.SET_GROUPS: { + return { + ...state, + breakoutGroups: action.payload.map(group => ({ + ...group, + participants: { + hosts: group.participants?.hosts ?? [], + attendees: group.participants?.attendees ?? [], + }, + })), + }; + } + + case BreakoutGroupActionTypes.UPDATE_GROUPS_IDS: { + return { + ...state, + breakoutGroups: action.payload.map(group => ({ + ...group, + participants: { + hosts: group.participants?.hosts ?? [], + attendees: group.participants?.attendees ?? [], + }, + })), + }; + } + + case BreakoutGroupActionTypes.UPDATE_UNASSIGNED_PARTICIPANTS: { + return { + ...state, + unassignedParticipants: action.payload.unassignedParticipants || [], + }; + } + + case BreakoutGroupActionTypes.SET_MANUAL_ASSIGNMENTS: + return { + ...state, + manualAssignments: action.payload.assignments, + }; + + case BreakoutGroupActionTypes.CLEAR_MANUAL_ASSIGNMENTS: + return { + ...state, + manualAssignments: [], + }; + + case BreakoutGroupActionTypes.MANUAL_ASSIGN_PARTICPANTS: + // Only applies when strategy is MANUAL + const updatedGroups = state.breakoutGroups.map(group => { + const roomAssignments = state.manualAssignments.filter( + assignment => assignment.roomId === group.id, + ); + + const hostsToAdd = roomAssignments + .filter(assignment => assignment.isHost) + .map(assignment => assignment.uid); + + const attendeesToAdd = roomAssignments + .filter(assignment => !assignment.isHost) + .map(assignment => assignment.uid); + + return { + ...group, + participants: { + hosts: [...group.participants.hosts, ...hostsToAdd], + attendees: [...group.participants.attendees, ...attendeesToAdd], + }, + }; + }); + + return { + ...state, + assignmentStrategy: RoomAssignmentStrategy.MANUAL_ASSIGN, // Update strategy + breakoutGroups: updatedGroups, + manualAssignments: [], // Clear after applying + }; + + case BreakoutGroupActionTypes.SET_ALLOW_PEOPLE_TO_SWITCH_ROOM: { + return { + ...state, + canUserSwitchRoom: action.payload.canUserSwitchRoom, + }; + } + + case BreakoutGroupActionTypes.NO_ASSIGN_PARTICIPANTS: { + return { + ...state, + assignmentStrategy: RoomAssignmentStrategy.NO_ASSIGN, // Update strategy + canUserSwitchRoom: true, + }; + } + + case BreakoutGroupActionTypes.AUTO_ASSIGN_PARTICPANTS: { + const roomAssignments = new Map< + string, + {hosts: UidType[]; attendees: UidType[]} + >(); + + // Initialize with existing participants for each room + state.breakoutGroups.forEach(room => { + roomAssignments.set(room.id, { + hosts: [...room.participants.hosts], + attendees: [...room.participants.attendees], + }); + }); + + let assignedParticipantUids: UidType[] = []; + // AUTO ASSIGN Simple round-robin assignment (no capacity limits) + // Exclude local user from auto assignment + const participantsToAssign = state.unassignedParticipants.filter( + participant => participant.uid !== action.payload.localUid, + ); + + let roomIndex = 0; + const roomIds = state.breakoutGroups.map(room => room.id); + participantsToAssign.forEach(participant => { + const currentRoomId = roomIds[roomIndex]; + const roomAssignment = roomAssignments.get(currentRoomId)!; + // Assign participant based on their isHost status (string "true"/"false") + if (participant.user?.isHost) { + roomAssignment.hosts.push(participant.uid); + } else { + roomAssignment.attendees.push(participant.uid); + } + // Move it to assigned list + assignedParticipantUids.push(participant.uid); + // Move to next room for round-robin + roomIndex = (roomIndex + 1) % roomIds.length; + }); + + // Update breakoutGroups with new assignments + const updatedBreakoutGroups = state.breakoutGroups.map(group => { + const roomParticipants = roomAssignments.get(group.id) || { + hosts: [], + attendees: [], + }; + return { + ...group, + participants: { + hosts: roomParticipants.hosts, + attendees: roomParticipants.attendees, + }, + }; + }); + + // Remove assigned participants from unassignedParticipants + const updatedUnassignedParticipants = state.unassignedParticipants.filter( + participant => !assignedParticipantUids.includes(participant.uid), + ); + + return { + ...state, + unassignedParticipants: updatedUnassignedParticipants, + assignmentStrategy: RoomAssignmentStrategy.AUTO_ASSIGN, + breakoutGroups: updatedBreakoutGroups, + }; + } + + case BreakoutGroupActionTypes.CREATE_GROUP: { + // Find the next available room number + const existingRoomNumbers = state.breakoutGroups + .map(room => { + const match = room.name.match(/^Room (\d+)$/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(num => num > 0); + + const nextRoomNumber = + existingRoomNumbers.length === 0 + ? 1 + : Math.max(...existingRoomNumbers) + 1; + + return { + ...state, + breakoutGroups: [ + ...state.breakoutGroups, + { + name: `Room ${nextRoomNumber}`, + id: `temp_${randomNameGenerator(6)}`, + participants: {hosts: [], attendees: []}, + }, + ], + }; + } + + case BreakoutGroupActionTypes.EXIT_GROUP: { + // Same logic as MOVE_PARTICIPANT_TO_MAIN but more explicit + const {user, fromGroupId} = action.payload; + return { + ...state, + breakoutGroups: state.breakoutGroups.map(group => { + if (group.id === fromGroupId) { + return { + ...group, + participants: { + hosts: group.participants.hosts.filter(uid => uid !== user.uid), + attendees: group.participants.attendees.filter( + uid => uid !== user.uid, + ), + }, + }; + } + return group; + }), + }; + } + + case BreakoutGroupActionTypes.CLOSE_GROUP: { + const {groupId} = action.payload; + return { + ...state, + breakoutGroups: state.breakoutGroups.filter( + room => room.id !== groupId, + ), + }; + } + + case BreakoutGroupActionTypes.CLOSE_ALL_GROUPS: { + return { + ...state, + breakoutGroups: [], + }; + } + + case BreakoutGroupActionTypes.RENAME_GROUP: { + const {groupId, newName} = action.payload; + return { + ...state, + breakoutGroups: state.breakoutGroups.map(group => + group.id === groupId ? {...group, name: newName} : group, + ), + }; + } + + case BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_MAIN: { + const {uid, fromGroupId} = action.payload; + return { + ...state, + breakoutGroups: state.breakoutGroups.map(group => { + // Remove participant from their current breakout group + if (fromGroupId && group.id === fromGroupId) { + return { + ...group, + participants: { + ...group.participants, + hosts: group.participants.hosts.filter(id => id !== uid), + attendees: group.participants.attendees.filter( + id => id !== uid, + ), + }, + }; + } + return group; + }), + }; + } + + case BreakoutGroupActionTypes.MOVE_PARTICIPANT_TO_GROUP: { + const {uid, fromGroupId, toGroupId, isHost} = action.payload; + + // Determine if user should be added as host or attendee + let shouldBeHost = false; + + if (isHost !== undefined) { + // Use explicit isHost flag if provided (from mainRoomRTMUsers for main room users) + shouldBeHost = isHost; + } else if (fromGroupId) { + // Fallback: check their role in the previous breakout group + const sourceGroup = state.breakoutGroups.find( + group => group.id === fromGroupId, + ); + shouldBeHost = sourceGroup?.participants.hosts.includes(uid) || false; + } + // If isHost is undefined and no fromGroupId, default to attendee + + return { + ...state, + breakoutGroups: state.breakoutGroups.map(group => { + // Remove from source group (if fromGroupId exists) + if (fromGroupId && group.id === fromGroupId) { + return { + ...group, + participants: { + ...group.participants, + hosts: group.participants.hosts.filter(id => id !== uid), + attendees: group.participants.attendees.filter( + id => id !== uid, + ), + }, + }; + } + // Add to target group with determined role + if (group.id === toGroupId) { + return { + ...group, + participants: { + ...group.participants, + hosts: shouldBeHost + ? [...group.participants.hosts, uid] + : group.participants.hosts, + attendees: !shouldBeHost + ? [...group.participants.attendees, uid] + : group.participants.attendees, + }, + }; + } + return group; + }), + }; + } + + default: + return state; + } +}; diff --git a/template/src/components/breakout-room/state/types.ts b/template/src/components/breakout-room/state/types.ts new file mode 100644 index 000000000..3223a1cc1 --- /dev/null +++ b/template/src/components/breakout-room/state/types.ts @@ -0,0 +1,54 @@ +import {BreakoutGroup, RoomAssignmentStrategy} from './reducer'; + +export type BreakoutGroupAssignStrategy = 'auto' | 'manual' | 'self-select'; + +export interface AssignOption { + label: string; + value: BreakoutGroupAssignStrategy; + description: string; +} + +export interface BreakoutChannelJoinEventPayload { + data: { + data: { + room_id: number; + room_name: string; + channel_name: string; + mainUser: { + rtc: string; + uid: number; + rtm: string; + }; + screenShare: { + rtc: string; + uid: number; + }; + chat: { + isGroupOwner: boolean; + groupId: string; + userToken: string; + }; + }; + act: 'CHAN_JOIN'; // e.g., "CHAN_JOIN" + srcuid: number; + }; +} + +export interface BreakoutRoomSyncStateEventPayload { + data: { + data: { + switch_room: boolean; + session_id: string; + assignment_type: RoomAssignmentStrategy; + breakout_room: BreakoutGroup[]; + sts: number; + }; + act: 'SYNC_STATE'; + srcuid: number; + }; +} +export interface BreakoutRoomAnnouncementEventPayload { + uid: string; + timestamp: string; + announcement: string; +} diff --git a/template/src/components/breakout-room/ui/BreakoutMeetingTitle.tsx b/template/src/components/breakout-room/ui/BreakoutMeetingTitle.tsx new file mode 100644 index 000000000..58aabac03 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutMeetingTitle.tsx @@ -0,0 +1,60 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import BreakoutRoomNameRenderer from '../hoc/BreakoutRoomNameRenderer'; +import ImageIcon from '../../../atoms/ImageIcon'; +import ThemeConfig from '../../../theme'; +import {trimText} from '../../../utils/common'; + +const BreakoutMeetingTitle: React.FC = () => { + return ( + + {({breakoutRoomName}) => + trimText(breakoutRoomName) ? ( + + + + {trimText(breakoutRoomName)} + + + ) : null + } + + ); +}; + +const styles = StyleSheet.create({ + breakoutRoomNameView: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + fontFamily: ThemeConfig.FontFamily.sansPro, + }, + breakoutRoomNameText: { + fontSize: ThemeConfig.FontSize.tiny, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + fontWeight: '600', + fontFamily: ThemeConfig.FontFamily.sansPro, + }, +}); + +export default BreakoutMeetingTitle; diff --git a/template/src/components/breakout-room/ui/BreakoutRoomActionMenu.tsx b/template/src/components/breakout-room/ui/BreakoutRoomActionMenu.tsx new file mode 100644 index 000000000..c613231ad --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomActionMenu.tsx @@ -0,0 +1,136 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useState, useRef, useEffect} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import ActionMenu, {ActionMenuItem} from '../../../atoms/ActionMenu'; +import IconButton from '../../../atoms/IconButton'; +import {calculatePosition} from '../../../utils/common'; +import {useRoomInfo} from 'customization-api'; + +interface RoomActionMenuProps { + onDeleteRoom: () => void; + onRenameRoom: () => void; +} + +const BreakoutRoomActionMenu: React.FC = ({ + onDeleteRoom, + onRenameRoom, +}) => { + const [actionMenuVisible, setActionMenuVisible] = useState(false); + const [isPosCalculated, setIsPosCalculated] = useState(false); + const [modalPosition, setModalPosition] = useState({}); + const {width: globalWidth, height: globalHeight} = useWindowDimensions(); + const moreIconRef = useRef(null); + + const { + data: {isHost}, + } = useRoomInfo(); + + // Build action menu items based on context + const actionMenuItems: ActionMenuItem[] = []; + + // ACTION - Only show for hosts + if (isHost) { + actionMenuItems.push({ + order: 1, + icon: 'pencil-filled', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.SECONDARY_ACTION_COLOR, + title: 'Rename Room', + onPress: () => { + onRenameRoom(); + setActionMenuVisible(false); + }, + }); + actionMenuItems.push({ + order: 2, + icon: 'delete', + iconColor: $config.SEMANTIC_ERROR, + textColor: $config.SEMANTIC_ERROR, + title: 'Delete Room', + onPress: () => { + onDeleteRoom(); + setActionMenuVisible(false); + }, + }); + } + + // Calculate position when menu becomes visible + useEffect(() => { + if (actionMenuVisible) { + moreIconRef?.current?.measure( + ( + _fx: number, + _fy: number, + localWidth: number, + localHeight: number, + px: number, + py: number, + ) => { + const data = calculatePosition({ + px, + py, + localWidth, + localHeight, + globalHeight, + globalWidth, + }); + setModalPosition(data); + setIsPosCalculated(true); + }, + ); + } + }, [actionMenuVisible]); + + // Don't render if no actions available + if (actionMenuItems.length === 0) { + return null; + } + + return ( + <> + + + { + setActionMenuVisible(true); + }} + /> + + + ); +}; + +export default BreakoutRoomActionMenu; diff --git a/template/src/components/breakout-room/ui/BreakoutRoomAnnouncementModal.tsx b/template/src/components/breakout-room/ui/BreakoutRoomAnnouncementModal.tsx new file mode 100644 index 000000000..e3af52aa8 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomAnnouncementModal.tsx @@ -0,0 +1,135 @@ +import React, {SetStateAction, Dispatch} from 'react'; +import {View, StyleSheet, TextInput} from 'react-native'; +import GenericModal from '../../common/GenericModal'; +import ThemeConfig from '../../../theme'; +import TertiaryButton from '../../../atoms/TertiaryButton'; + +interface BreakoutRoomAnnouncementModalProps { + setModalOpen: Dispatch>; + onAnnouncement: (text: string) => void; +} + +export default function BreakoutRoomAnnouncementModal( + props: BreakoutRoomAnnouncementModalProps, +) { + const {setModalOpen, onAnnouncement} = props; + const [announcement, setAnnouncement] = React.useState(''); + + const disabled = announcement.trim() === ''; + + return ( + setModalOpen(false)} + showCloseIcon={true} + title={'Announcement'} + cancelable={true} + contentContainerStyle={style.contentContainer}> + + + + + + + { + setModalOpen(false); + }} + /> + + + { + onAnnouncement(announcement); + }} + /> + + + + + ); +} + +const style = StyleSheet.create({ + contentContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + flexShrink: 0, + width: '100%', + maxWidth: 500, + height: 314, + }, + fullBody: { + width: '100%', + height: '100%', + flex: 1, + }, + mbody: { + padding: 12, + borderTopColor: $config.CARD_LAYER_3_COLOR, + borderTopWidth: 1, + borderBottomColor: $config.CARD_LAYER_3_COLOR, + borderBottomWidth: 1, + }, + mfooter: { + padding: 12, + gap: 12, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, + inputBox: { + fontSize: ThemeConfig.FontSize.normal, + fontWeight: '400', + color: $config.FONT_COLOR, + padding: 20, + lineHeight: 20, + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + }, + actionBtnText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + }, + cancelBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.SECONDARY_ACTION_COLOR, + backgroundColor: 'transparent', + }, + sendBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + disabledSendBtn: { + borderRadius: 4, + minWidth: 140, + backgroundColor: $config.SEMANTIC_NEUTRAL, + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomGroupSettings.tsx b/template/src/components/breakout-room/ui/BreakoutRoomGroupSettings.tsx new file mode 100644 index 000000000..7bdbbad33 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomGroupSettings.tsx @@ -0,0 +1,588 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useState} from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import IconButton from '../../../atoms/IconButton'; +import ThemeConfig from '../../../theme'; +import {UidType, useLocalUid} from '../../../../agora-rn-uikit'; +import UserAvatar from '../../../atoms/UserAvatar'; +import ImageIcon from '../../../atoms/ImageIcon'; +import {BreakoutGroup} from '../state/reducer'; +import BreakoutRoomActionMenu from './BreakoutRoomActionMenu'; +import BreakoutRoomMemberActionMenu from './BreakoutRoomMemberActionMenu'; +import TertiaryButton from '../../../atoms/TertiaryButton'; +import BreakoutRoomAnnouncementModal from './BreakoutRoomAnnouncementModal'; +import {useModal} from '../../../utils/useModal'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import BreakoutRoomRenameModal from './BreakoutRoomRenameModal'; +import {useMainRoomUserDisplayName} from '../../../rtm/hooks/useMainRoomUserDisplayName'; +import {useRoomInfo} from '../../room-info/useRoomInfo'; +import {useChatConfigure} from '../../chat/chatConfigure'; +import { + ChatMessageType, + SDKChatType, +} from '../../chat-messages/useChatMessages'; +import {isWeb} from '../../../utils/common'; +import {useRTMGlobalState} from '../../../rtm/RTMGlobalStateProvider'; +import {useRaiseHand} from '../../raise-hand'; +import Tooltip from '../../../atoms/Tooltip'; +import {useContent} from 'customization-api'; + +const BreakoutRoomGroupSettings = ({scrollOffset}) => { + const { + data: {isHost, uid, chat}, + } = useRoomInfo(); + const localUid = useLocalUid(); + const {defaultContent} = useContent(); + const {sendChatSDKMessage} = useChatConfigure(); + const {isUserHandRaised} = useRaiseHand(); + + const { + breakoutGroups, + isUserInRoom, + exitRoom, + joinRoom, + closeRoom, + updateRoomName, + canUserSwitchRoom, + permissions, + } = useBreakoutRoom(); + + const disableJoinBtn = !isHost && !canUserSwitchRoom; + + // Render room card + const {mainRoomRTMUsers} = useRTMGlobalState(); + // Use hook to get display names with fallback to main room users + const getDisplayName = useMainRoomUserDisplayName(); + + const { + modalOpen: isAnnoucementModalOpen, + setModalOpen: setAnnouncementModal, + } = useModal(); + const { + modalOpen: isRenameRoomModalOpen, + setModalOpen: setRenameRoomModalOpen, + } = useModal(); + + const [roomToEdit, setRoomToEdit] = useState<{id: string; name: string}>( + null, + ); + + const [expandedRooms, setExpandedRooms] = useState>(new Set()); + + const toggleRoomExpansion = (roomId: string) => { + const newExpanded = new Set(expandedRooms); + if (newExpanded.has(roomId)) { + newExpanded.delete(roomId); + } else { + newExpanded.add(roomId); + } + setExpandedRooms(newExpanded); + }; + + const renderMember = (memberUId: UidType) => { + // Check for offline status using mainRoomRTMUsers + const rtmMemberData = mainRoomRTMUsers[memberUId]; + const isOffline = rtmMemberData?.offline; + + return ( + + + + + + {getDisplayName(memberUId)} + + {isOffline && ( + (user is offline) + )} + + + + {!isOffline && ( + + {isUserHandRaised(memberUId) ? ( + + + + ) : ( + <> + )} + {permissions.canHostManageMainRoom && memberUId !== localUid ? ( + + + + ) : ( + <> + )} + + )} + + ); + }; + + const renderRoom = (room: BreakoutGroup) => { + const isExpanded = expandedRooms.has(room.id); + const memberCount = + room.participants.hosts.length + room.participants.attendees.length; + + return ( + + + + toggleRoomExpansion(room.id)} + /> + + + { + return {room.name}; + }} + /> + + + {memberCount > 0 ? memberCount : 'No'} Member + {memberCount !== 1 ? 's' : ''} + + + + + {isUserInRoom(room) ? ( + exitRoom(room.id)} + /> + ) : ( + joinRoom(room.id)} + /> + )} + {/* Only host can perform these actions */} + {permissions.canHostManageMainRoom ? ( + { + closeRoom(room.id); + }} + onRenameRoom={() => { + setRoomToEdit({id: room.id, name: room.name}); + setRenameRoomModalOpen(true); + }} + /> + ) : ( + <> + )} + + + + {/* Room Members (Expanded) */} + {isExpanded && ( + + {room.participants.hosts.length > 0 || + room.participants.attendees.length > 0 ? ( + <> + {/* Combine and sort members - local user first */} + {[...room.participants.hosts, ...room.participants.attendees] + .sort((a, b) => { + // Local user always comes first + if (a === localUid) { + return -1; + } + if (b === localUid) { + return 1; + } + return 0; // Keep others in original order + }) + .map(member => renderMember(member))} + + ) : ( + + + No members in this room + + + )} + + )} + + ); + }; + + const onRoomNameChange = (newName: string) => { + if (newName && roomToEdit?.id) { + updateRoomName(newName, roomToEdit.id); + setRoomToEdit(null); + setRenameRoomModalOpen(false); + } + }; + + const onAnnouncement = (announcement: string) => { + if (announcement) { + const option = { + chatType: SDKChatType.GROUP_CHAT, + type: ChatMessageType.TXT, + msg: `${announcement}`, + from: uid.toString(), + to: chat.group_id, + ext: { + from_platform: isWeb() ? 'web' : 'native', + announcement: { + sender: defaultContent[localUid]?.name, + heading: `${defaultContent[localUid]?.name} made an announcement.`, + }, + }, + }; + sendChatSDKMessage(option); + setAnnouncementModal(false); + } + }; + + return ( + + + + All Rooms + + {permissions.canHostManageMainRoom ? ( + + setAnnouncementModal(true)} + /> + + ) : ( + <> + )} + + + {breakoutGroups.length === 0 ? ( + + + The host hasn't created any breakout rooms yet + + + ) : ( + breakoutGroups.map(renderRoom) + )} + + {isAnnoucementModalOpen && ( + + )} + {isRenameRoomModalOpen && roomToEdit?.id && ( + group.name)} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'column', + // border: '2px solid red', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + // paddingHorizontal: 12, + paddingVertical: 16, + }, + headerLeft: {}, + headerTitle: { + fontWeight: '600', + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontFamily: ThemeConfig.FontFamily.sansPro, + }, + headerRight: { + display: 'flex', + marginLeft: 'auto', + alignItems: 'center', + justifyContent: 'center', + }, + headerActions: { + flexDirection: 'row', + gap: 6, + alignItems: 'center', + }, + body: { + display: 'flex', + flexDirection: 'column', + gap: 12, + // border: '1px solid yellow', + }, + roomGroupCard: { + display: 'flex', + flexDirection: 'column', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + borderRadius: 8, + }, + roomHeader: { + display: 'flex', + flexDirection: 'row', + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_2_COLOR, + alignItems: 'center', + padding: 12, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + roomHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + exitRoomBtn: { + backgroundColor: 'transparent', + borderColor: $config.SECONDARY_ACTION_COLOR, + height: 28, + }, + joinRoomBtn: { + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + height: 28, + }, + disabledBtn: { + backgroundColor: $config.SEMANTIC_NEUTRAL, + borderColor: $config.SEMANTIC_NEUTRAL, + height: 28, + }, + roomActionBtnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + fontWeight: '600', + }, + roomHeaderInfo: { + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + roomNameToolTipContainer: { + alignSelf: 'flex-start', + maxWidth: '100%', + }, + roomName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 14, + fontWeight: '600', + maxWidth: '100%', + }, + roomMemberCount: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 12, + fontWeight: '600', + }, + roomHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 'auto', + gap: 4, + }, + roomMembers: { + paddingHorizontal: 8, + paddingVertical: 12, + display: 'flex', + gap: 4, + alignSelf: 'stretch', + backgroundColor: $config.CARD_LAYER_1_COLOR, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + memberItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 8, + paddingRight: 16, + borderRadius: 9, + backgroundColor: $config.CARD_LAYER_3_COLOR, + minHeight: 64, + gap: 8, + }, + memberItemOffline: { + opacity: 0.5, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + memberInfo: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + gap: 8, + }, + memberNameContainer: { + flex: 1, + flexDirection: 'column', + }, + memberDragHandle: { + marginRight: 12, + width: 16, + alignItems: 'center', + }, + dragDots: { + width: 4, + height: 12, + borderRadius: 2, + }, + memberAvatar: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + hostAvatar: {}, + memberInitial: { + fontSize: 14, + fontWeight: '600', + }, + memberName: { + fontSize: ThemeConfig.FontSize.small, + lineHeight: 20, + fontWeight: '400', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + }, + memberNameOffline: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + memberOfflineStatus: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 14, + fontWeight: '400', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontStyle: 'italic', + marginTop: 2, + }, + memberMenu: { + padding: 8, + marginLeft: 'auto', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + memberRaiseHand: {}, + memberMenuMoreIcon: { + width: 24, + height: 24, + alignSelf: 'center', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 20, + }, + emptyRoom: { + alignItems: 'center', + paddingVertical: 16, + }, + emptyRoomPaddingHorizontal: { + paddingHorizontal: 12, + }, + emptyRoomText: { + fontSize: ThemeConfig.FontSize.small, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontStyle: 'italic', + }, + userAvatarContainer: { + width: 24, + height: 24, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + userAvatarOffline: { + backgroundColor: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + userAvatarText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 24, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + }, + userAvatarTextOffline: { + color: $config.BACKGROUND_COLOR + '80', + }, + expandIcon: { + width: 32, + height: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + shadowColor: $config.HARD_CODED_BLACK_COLOR, + }, +}); + +export default BreakoutRoomGroupSettings; diff --git a/template/src/components/breakout-room/ui/BreakoutRoomMainRoomUsers.tsx b/template/src/components/breakout-room/ui/BreakoutRoomMainRoomUsers.tsx new file mode 100644 index 000000000..50c6e0b77 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomMainRoomUsers.tsx @@ -0,0 +1,142 @@ +import React, {useMemo} from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import {useRTMGlobalState} from '../../../rtm/RTMGlobalStateProvider'; +import ThemeConfig from '../../../theme'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import UserAvatar from '../../../atoms/UserAvatar'; +import Tooltip from '../../../atoms/Tooltip'; +import {useString} from '../../../utils/useString'; +import {videoRoomUserFallbackText} from '../../../language/default-labels/videoCallScreenLabels'; + +interface MainRoomUser { + uid: number; + name: string; +} + +const BreakoutRoomMainRoomUsers = ({scrollOffset}) => { + const {mainRoomRTMUsers} = useRTMGlobalState(); + const {breakoutGroups, breakoutRoomVersion} = useBreakoutRoom(); + const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + + // Get all assigned users from breakout rooms + const assignedUserUids = useMemo(() => { + const assigned = new Set(); + breakoutGroups.forEach(group => { + group.participants.hosts.forEach(uid => assigned.add(uid)); + group.participants.attendees.forEach(uid => assigned.add(uid)); + }); + return assigned; + }, [breakoutRoomVersion]); + + // Filter main room users to only show those not assigned to breakout rooms + const mainRoomOnlyUsers = useMemo(() => { + const users: MainRoomUser[] = []; + + Object.entries(mainRoomRTMUsers).forEach(([uidStr, userData]) => { + const uid = parseInt(uidStr, 10); + + // Skip if user is assigned to a breakout room + if (assignedUserUids.has(uid)) { + return; + } + + // Only include RTC users who are online + if (userData.type === 'rtc' && !userData.offline) { + users.push({ + uid, + name: userData.name || remoteUserDefaultLabel, + }); + } + }); + + return users; + }, [mainRoomRTMUsers, assignedUserUids, breakoutRoomVersion]); + + return ( + + + Main Room ({mainRoomOnlyUsers.length}) + + {mainRoomOnlyUsers.map(user => ( + + { + return ( + + ); + }} + /> + + ))} + + {mainRoomOnlyUsers.length === 0 && ( + No users online in main room + )} + + + ); +}; + +export default BreakoutRoomMainRoomUsers; + +const style = StyleSheet.create({ + card: { + width: '100%', + padding: 16, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_2_COLOR, + borderColor: $config.CARD_LAYER_3_COLOR, + gap: 12, + }, + section: { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + title: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + fontWeight: '500', + opacity: 0.8, + }, + participantContainer: { + display: 'flex', + flexDirection: 'row', + gap: 5, + }, + emptyText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 16, + fontWeight: '400', + }, + userAvatarContainer: { + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + width: 24, + height: 24, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + userAvatarText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 24, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + display: 'flex', + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomMemberActionMenu.tsx b/template/src/components/breakout-room/ui/BreakoutRoomMemberActionMenu.tsx new file mode 100644 index 000000000..f474a15af --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomMemberActionMenu.tsx @@ -0,0 +1,122 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useState, useEffect, useRef} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import ActionMenu, {ActionMenuItem} from '../../../atoms/ActionMenu'; +import {calculatePosition} from '../../../utils/common'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import {UidType} from '../../../../agora-rn-uikit'; +import IconButton from '../../../atoms/IconButton'; + +interface BreakoutRoomMemberActionMenuProps { + memberUid: UidType; +} + +const BreakoutRoomMemberActionMenu: React.FC< + BreakoutRoomMemberActionMenuProps +> = ({memberUid}) => { + const [actionMenuVisible, setActionMenuVisible] = useState(false); + const [isPosCalculated, setIsPosCalculated] = useState(false); + const [modalPosition, setModalPosition] = useState({}); + const {width: globalWidth, height: globalHeight} = useWindowDimensions(); + const moreIconRef = useRef(null); + + const {getRoomMemberDropdownOptions} = useBreakoutRoom(); + + // Get member-specific dropdown options using breakout room context + const memberOptions = getRoomMemberDropdownOptions(memberUid); + + // Transform to ActionMenuItem format + const actionMenuItems: ActionMenuItem[] = memberOptions.map( + (option, index) => ({ + order: index + 1, + icon: option.icon, + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.SECONDARY_ACTION_COLOR, + title: option.title, + onPress: () => { + option.onOptionPress(); + setActionMenuVisible(false); + }, + }), + ); + + // Calculate position when menu becomes visible + useEffect(() => { + if (actionMenuVisible) { + moreIconRef?.current?.measure( + ( + _fx: number, + _fy: number, + localWidth: number, + localHeight: number, + px: number, + py: number, + ) => { + const data = calculatePosition({ + px, + py, + localWidth, + localHeight, + globalHeight, + globalWidth, + }); + setModalPosition(data); + setIsPosCalculated(true); + }, + ); + } + }, [actionMenuVisible]); + + // Don't render if no actions available + if (actionMenuItems.length === 0) { + return null; + } + + return ( + <> + + + { + setActionMenuVisible(true); + }} + /> + + + ); +}; + +export default BreakoutRoomMemberActionMenu; diff --git a/template/src/components/breakout-room/ui/BreakoutRoomParticipants.tsx b/template/src/components/breakout-room/ui/BreakoutRoomParticipants.tsx new file mode 100644 index 000000000..840ddaad7 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomParticipants.tsx @@ -0,0 +1,124 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import UserAvatar from '../../../atoms/UserAvatar'; +import {ContentInterface, UidType} from '../../../../agora-rn-uikit'; +import ThemeConfig from '../../../theme'; +import ImageIcon from '../../../atoms/ImageIcon'; +import Tooltip from '../../../atoms/Tooltip'; +import {BreakoutRoomUser} from '../state/reducer'; + +interface Props { + isHost?: boolean; + participants?: {uid: UidType; user: BreakoutRoomUser}[]; +} + +const BreakoutRoomParticipants: React.FC = ({ + participants, + isHost = false, +}) => { + return ( + <> + 0 ? {} : styles.titleLowOpacity, + ]}> + + + + + Main Room {isHost ? `(${participants.length} unassigned)` : ''} + + + + {participants.length > 0 ? ( + participants.map(item => ( + + { + return ( + + ); + }} + /> + + )) + ) : ( + + {isHost + ? 'No participants available for breakout rooms' + : 'No members'} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + titleLowOpacity: { + opacity: 0.2, + }, + titleContainer: { + display: 'flex', + flexDirection: 'row', + gap: 4, + alignItems: 'center', + }, + title: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + fontWeight: '500', + }, + participantContainer: { + display: 'flex', + flexDirection: 'row', + gap: 5, + }, + participantItem: {}, + userAvatarContainer: { + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + width: 24, + height: 24, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + userAvatarText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 12, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + display: 'flex', + }, + emptyStateText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + }, +}); + +export default BreakoutRoomParticipants; diff --git a/template/src/components/breakout-room/ui/BreakoutRoomRaiseHand.tsx b/template/src/components/breakout-room/ui/BreakoutRoomRaiseHand.tsx new file mode 100644 index 000000000..26bfdad5c --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomRaiseHand.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {View, StyleSheet, Text} from 'react-native'; +import ImageIcon from '../../../atoms/ImageIcon'; +import ThemeConfig from '../../../theme'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import {RaiseHandButton} from '../../raise-hand'; + +export default function BreakoutRoomRaiseHand() { + const {isUserInRoom} = useBreakoutRoom(); + return ( + + {!isUserInRoom() ? ( + + + + Please wait, the meeting host will assign you to a room shortly. + + + ) : ( + <> + )} + + + + + ); +} + +const style = StyleSheet.create({ + card: { + width: '100%', + padding: 16, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_2_COLOR, + borderColor: $config.CARD_LAYER_3_COLOR, + gap: 16, + }, + cardHeader: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + gap: 8, + }, + cardFooter: { + display: 'flex', + flex: 1, + width: '100%', + }, + infoText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + lineHeight: 16, + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomRenameModal.tsx b/template/src/components/breakout-room/ui/BreakoutRoomRenameModal.tsx new file mode 100644 index 000000000..0cb67f1b3 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomRenameModal.tsx @@ -0,0 +1,227 @@ +import React, {SetStateAction, Dispatch} from 'react'; +import {View, StyleSheet, TextInput, Text} from 'react-native'; +import GenericModal from '../../common/GenericModal'; +import ThemeConfig from '../../../theme'; +import TertiaryButton from '../../../atoms/TertiaryButton'; + +interface BreakoutRoomRenameModalProps { + setModalOpen: Dispatch>; + currentRoomName: string; + updateRoomName: (newName: string) => void; + existingRoomNames: string[]; +} + +export default function BreakoutRoomRenameModal( + props: BreakoutRoomRenameModalProps, +) { + const {currentRoomName, setModalOpen, updateRoomName, existingRoomNames} = + props; + const [roomName, setRoomName] = React.useState(currentRoomName); + + const MAX_ROOM_NAME_LENGTH = 30; + + // Helper function to normalize room name (trim and collapse multiple spaces) + const normalizeRoomName = (name: string) => { + return name.trim().replace(/\s+/g, ' '); + }; + + // Check if the normalized room name already exists in other rooms + const isDuplicateName = existingRoomNames.some(existingName => { + const normalizedExistingName = + normalizeRoomName(existingName).toLowerCase(); + const normalizedNewName = normalizeRoomName(roomName).toLowerCase(); + const normalizedCurrentName = + normalizeRoomName(currentRoomName).toLowerCase(); + + return ( + normalizedExistingName === normalizedNewName && + normalizedExistingName !== normalizedCurrentName + ); + }); + + const disabled = + roomName.trim() === '' || + roomName.trim().length > MAX_ROOM_NAME_LENGTH || + normalizeRoomName(roomName).toLowerCase() === + normalizeRoomName(currentRoomName).toLowerCase() || + isDuplicateName; + + return ( + setModalOpen(false)} + showCloseIcon={true} + title={'Rename Room'} + cancelable={true} + contentContainerStyle={style.contentContainer}> + + + + Room name + MAX_ROOM_NAME_LENGTH && + style.inputBoxError, + ]} + value={roomName} + onChangeText={setRoomName} + placeholder="Rename room..." + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + underlineColorAndroid="transparent" + maxLength={50} + /> + + MAX_ROOM_NAME_LENGTH || + isDuplicateName) && + style.characterCountError, + ]}> + {roomName.trim().length}/{MAX_ROOM_NAME_LENGTH} + + {roomName.trim().length > MAX_ROOM_NAME_LENGTH && ( + + Room name cannot exceed {MAX_ROOM_NAME_LENGTH} characters + + )} + {isDuplicateName && ( + + A room with this name already exists + + )} + + + + + + { + setModalOpen(false); + }} + /> + + + { + updateRoomName(normalizeRoomName(roomName)); + }} + /> + + + + + ); +} + +const style = StyleSheet.create({ + contentContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + flexShrink: 0, + width: '100%', + maxWidth: 500, + height: 272, + }, + fullBody: { + width: '100%', + height: '100%', + flex: 1, + }, + mbody: { + padding: 12, + borderTopColor: $config.CARD_LAYER_3_COLOR, + borderTopWidth: 1, + borderBottomColor: $config.CARD_LAYER_3_COLOR, + borderBottomWidth: 1, + }, + form: { + display: 'flex', + flexDirection: 'column', + gap: 12, + width: '100%', + }, + mfooter: { + padding: 12, + gap: 12, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, + label: { + fontSize: ThemeConfig.FontSize.small, + fontWeight: '500', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + lineHeight: 16, + }, + inputBox: { + fontSize: ThemeConfig.FontSize.normal, + fontWeight: '400', + color: $config.FONT_COLOR, + padding: 20, + lineHeight: 20, + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + outline: 'none', + }, + inputBoxError: { + borderColor: $config.SEMANTIC_ERROR, + }, + inputFooter: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 4, + }, + characterCount: { + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + }, + characterCountError: { + color: $config.SEMANTIC_ERROR, + }, + errorText: { + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + color: $config.SEMANTIC_ERROR, + }, + actionBtnText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + }, + cancelBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.SECONDARY_ACTION_COLOR, + backgroundColor: 'transparent', + }, + sendBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + disabledSendBtn: { + borderRadius: 4, + minWidth: 140, + backgroundColor: $config.SEMANTIC_NEUTRAL, + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomSettings.tsx b/template/src/components/breakout-room/ui/BreakoutRoomSettings.tsx new file mode 100644 index 000000000..e10181e69 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomSettings.tsx @@ -0,0 +1,140 @@ +import React, {useState} from 'react'; +import {View, StyleSheet, Text} from 'react-native'; +import BreakoutRoomParticipants from './BreakoutRoomParticipants'; +import SelectParticipantAssignmentStrategy from './SelectParticipantAssignmentStrategy'; +import Divider from '../../common/Dividers'; +import ThemeConfig from '../../../theme'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import Toggle from '../../../atoms/Toggle'; +import ParticipantManualAssignmentModal from './ParticipantManualAssignmentModal'; +import {useModal} from '../../../utils/useModal'; +import {RoomAssignmentStrategy} from '../state/reducer'; +import TertiaryButton from '../../../atoms/TertiaryButton'; + +export default function BreakoutRoomSettings() { + const { + unassignedParticipants, + assignmentStrategy, + handleAssignParticipants, + canUserSwitchRoom, + toggleRoomSwitchingAllowed, + } = useBreakoutRoom(); + + // Local dropdown state to prevent sync conflicts + const [localAssignmentStrategy, setLocalAssignmentStrategy] = + useState(assignmentStrategy); + + const disableAssignmentSelect = unassignedParticipants.length === 0; + const disableHandleAssignment = + disableAssignmentSelect || + localAssignmentStrategy === RoomAssignmentStrategy.NO_ASSIGN; + + const { + modalOpen: isManualAssignmentModalOpen, + setModalOpen: setManualAssignmentModalOpen, + } = useModal(); + + // Handle strategy change from dropdown + const handleStrategyChange = (newStrategy: RoomAssignmentStrategy) => { + setLocalAssignmentStrategy(newStrategy); + + // Immediately call API for NO_ASSIGN strategy + if (newStrategy === RoomAssignmentStrategy.NO_ASSIGN) { + handleAssignParticipants(newStrategy); + } + }; + + // Handle assign participants button click + const handleAssignClick = () => { + if (localAssignmentStrategy === RoomAssignmentStrategy.MANUAL_ASSIGN) { + // Open manual assignment modal + setManualAssignmentModalOpen(true); + } else { + // Handle other assignment strategies + handleAssignParticipants(localAssignmentStrategy); + } + }; + + return ( + + {/* Avatar list */} + + + + + + + + + + + + Allow people to switch rooms + + + + {isManualAssignmentModalOpen && ( + + )} + + ); +} + +const style = StyleSheet.create({ + card: { + width: '100%', + padding: 16, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_2_COLOR, + borderColor: $config.CARD_LAYER_3_COLOR, + gap: 12, + }, + section: { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + switchSection: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + label: { + fontWeight: '400', + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + color: $config.FONT_COLOR, + fontFamily: ThemeConfig.FontFamily.sansPro, + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomTransition.tsx b/template/src/components/breakout-room/ui/BreakoutRoomTransition.tsx new file mode 100644 index 000000000..21323928f --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomTransition.tsx @@ -0,0 +1,52 @@ +import React, {useEffect, useState} from 'react'; +import Loading from '../../../subComponents/Loading'; +import {View, StyleSheet} from 'react-native'; + +interface BreakoutRoomTransitionProps { + onTimeout: () => void; + direction?: 'enter' | 'exit'; +} + +const BreakoutRoomTransition = ({ + onTimeout, + direction = 'enter', +}: BreakoutRoomTransitionProps) => { + const [dots, setDots] = useState(''); + + useEffect(() => { + const interval = setInterval(() => { + setDots(prev => (prev.length >= 3 ? '' : prev + '.')); + }, 500); + + const timeout = setTimeout(onTimeout, 10000); // 10s timeout + + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, [onTimeout]); + + const transitionText = + direction === 'exit' ? 'Exiting breakout room' : 'Entering breakout room'; + + return ( + + + + ); +}; + +export default BreakoutRoomTransition; +const styles = StyleSheet.create({ + transitionContainer: { + height: '100%', + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/template/src/components/breakout-room/ui/BreakoutRoomView.tsx b/template/src/components/breakout-room/ui/BreakoutRoomView.tsx new file mode 100644 index 000000000..0a96da579 --- /dev/null +++ b/template/src/components/breakout-room/ui/BreakoutRoomView.tsx @@ -0,0 +1,193 @@ +import React, {useEffect, useState, useRef} from 'react'; +import {View, StyleSheet, ScrollView} from 'react-native'; +import {useRoomInfo} from '../../room-info/useRoomInfo'; +import {useBreakoutRoom} from './../context/BreakoutRoomContext'; +import BreakoutRoomSettings from './BreakoutRoomSettings'; +import BreakoutRoomGroupSettings from './BreakoutRoomGroupSettings'; +import ThemeConfig from '../../../theme'; +import TertiaryButton from '../../../atoms/TertiaryButton'; +import {BreakoutRoomHeader} from '../../../pages/video-call/SidePanelHeader'; +import BreakoutRoomRaiseHand from './BreakoutRoomRaiseHand'; +import BreakoutRoomMainRoomUsers from './BreakoutRoomMainRoomUsers'; +import Loading from '../../../subComponents/Loading'; + +interface Props { + closeSidePanel: () => void; +} +export default function BreakoutRoomView({closeSidePanel}: Props) { + const [isInitializing, setIsInitializing] = useState(true); + + const { + data: {isHost}, + } = useRoomInfo(); + + const scrollViewRef = useRef(null); + const [scrollOffset, setScrollOffset] = useState(0); + + const onScroll = event => { + setScrollOffset(event.nativeEvent.contentOffset.y); + }; + + const { + checkIfBreakoutRoomSessionExistsAPI, + createBreakoutRoomGroup, + upsertBreakoutRoomAPI, + closeAllRooms, + permissions, + isBreakoutUILocked, + } = useBreakoutRoom(); + + useEffect(() => { + const init = async () => { + try { + setIsInitializing(true); + const activeSession = await checkIfBreakoutRoomSessionExistsAPI(); + console.log('supriya-sync-queue activeSession: ', activeSession); + if (!activeSession && isHost) { + console.log( + 'supriya-sync-queue callubg upsertBreakoutRoomAPI: ', + activeSession, + ); + + await upsertBreakoutRoomAPI('START'); + } + } catch (error) { + console.error('Failed to check breakout session:', error); + } finally { + setIsInitializing(false); + } + }; + init(); + }, []); + + // Disable all actions when API is in flight or another host is operating + const disableAllActions = isBreakoutUILocked; + + return ( + <> + + + {isInitializing ? ( + + + + ) : ( + + {permissions?.canRaiseHands ? : <>} + {permissions?.canHostManageMainRoom && + permissions.canAssignParticipants ? ( + + ) : ( + + )} + + {permissions?.canHostManageMainRoom && + permissions?.canCreateRooms ? ( + createBreakoutRoomGroup()} + /> + ) : ( + <> + )} + + )} + + {!isInitializing && + permissions.canHostManageMainRoom && + permissions?.canCloseRooms ? ( + + + { + try { + closeAllRooms(); + closeSidePanel(); + } catch (error) { + console.error('Error while closing the room', error); + } + }} + text={'Close All Rooms'} + /> + + + ) : ( + <> + )} + + ); +} + +const style = StyleSheet.create({ + footer: { + width: '100%', + padding: 12, + height: 'auto', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + contentCenter: { + height: '100%', + justifyContent: 'center', + }, + contentStart: { + justifyContent: 'flex-start', + }, + pannelOuterBody: { + display: 'flex', + flex: 1, + }, + panelInnerBody: { + display: 'flex', + flex: 1, + padding: 12, + gap: 12, + }, + fullWidth: { + display: 'flex', + flex: 1, + }, + createBtnContainer: { + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + borderRadius: 8, + }, + createBtnText: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + lineHeight: 20, + fontWeight: '500', + fontSize: ThemeConfig.FontSize.normal, + }, + + disabledPannelInnerBody: { + opacity: 0.4, + pointerEvents: 'none', + }, +}); diff --git a/template/src/components/breakout-room/ui/ExitBreakoutRoomIconButton.tsx b/template/src/components/breakout-room/ui/ExitBreakoutRoomIconButton.tsx new file mode 100644 index 000000000..83bd80ae2 --- /dev/null +++ b/template/src/components/breakout-room/ui/ExitBreakoutRoomIconButton.tsx @@ -0,0 +1,79 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React from 'react'; +import IconButton, {IconButtonProps} from '../../../atoms/IconButton'; +import {useToolbarMenu} from '../../../utils/useMenu'; +import ToolbarMenuItem from '../../../atoms/ToolbarMenuItem'; +import {useToolbarProps} from '../../../atoms/ToolbarItem'; +import {useActionSheet} from '../../../utils/useActionSheet'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import BreakoutRoomNameRenderer from '../hoc/BreakoutRoomNameRenderer'; + +export interface ScreenshareButtonProps { + render?: (onPress: () => void) => JSX.Element; +} + +const ExitBreakoutRoomIconButton = (props: ScreenshareButtonProps) => { + const {label = null, onPress: onPressCustom = null} = useToolbarProps(); + const {isOnActionSheet, showLabel} = useActionSheet(); + const {exitRoom} = useBreakoutRoom(); + const {isToolbarMenuItem} = useToolbarMenu(); + + const onPress = () => { + exitRoom(); + }; + + return ( + + {({breakoutRoomName}) => { + const displayText = breakoutRoomName + ? `Exit ${breakoutRoomName}` + : 'Exit Room'; + + let iconButtonProps: IconButtonProps = { + onPress: onPressCustom || onPress, + iconProps: { + name: 'close-room', + tintColor: $config.SECONDARY_ACTION_COLOR, + }, + btnTextProps: { + textColor: $config.FONT_COLOR, + text: showLabel ? label || displayText : '', + }, + }; + + if (isOnActionSheet) { + iconButtonProps.btnTextProps.textStyle = { + color: $config.FONT_COLOR, + marginTop: 8, + fontSize: 12, + fontWeight: '400', + fontFamily: 'Source Sans Pro', + textAlign: 'center', + }; + } + iconButtonProps.isOnActionSheet = isOnActionSheet; + + return props?.render ? ( + props.render(onPress) + ) : isToolbarMenuItem ? ( + + ) : ( + + ); + }} + + ); +}; + +export default ExitBreakoutRoomIconButton; diff --git a/template/src/components/breakout-room/ui/ParticipantManualAssignmentModal.tsx b/template/src/components/breakout-room/ui/ParticipantManualAssignmentModal.tsx new file mode 100644 index 000000000..eab44d699 --- /dev/null +++ b/template/src/components/breakout-room/ui/ParticipantManualAssignmentModal.tsx @@ -0,0 +1,638 @@ +import React, {SetStateAction, Dispatch, useState} from 'react'; +import {View, StyleSheet, Text, ScrollView, TextInput} from 'react-native'; +import GenericModal from '../../common/GenericModal'; +import ThemeConfig from '../../../theme'; +import ImageIcon from '../../../atoms/ImageIcon'; +import Checkbox from '../../../atoms/Checkbox'; +import Dropdown from '../../../atoms/Dropdown'; +import UserAvatar from '../../../atoms/UserAvatar'; +import {useBreakoutRoom} from '../context/BreakoutRoomContext'; +import {UidType, useLocalUid} from '../../../../agora-rn-uikit'; +import TertiaryButton from '../../../atoms/TertiaryButton'; +import { + ManualParticipantAssignment, + RoomAssignmentStrategy, + BreakoutRoomUser, +} from '../state/reducer'; + +function EmptyParticipantsState() { + return ( + + + + + + No participants found + + + ); +} + +function ParticipantRow({ + participant, + assignment, + rooms, + onAssignmentChange, + onSelectionChange, + localUid, +}: { + participant: {uid: UidType; user: BreakoutRoomUser}; + assignment: ManualParticipantAssignment; + rooms: {label: string; value: string}[]; + onAssignmentChange: (uid: UidType, roomId: string | null) => void; + onSelectionChange: (uid: UidType) => void; + localUid: UidType; +}) { + const selectedValue = assignment?.roomId || 'unassigned'; + const displayName = + participant.uid === localUid + ? `${participant.user.name} (me)` + : participant.user.name; + + return ( + + + onSelectionChange(participant.uid)} + label={''} + checkBoxStyle={style.checkboxIcon} + /> + + + + + + {displayName} + + + + + + { + onAssignmentChange( + participant.uid, + value === 'unassigned' ? null : value, + ); + }} + containerStyle={style.dropdownContainer} + selectedValue={selectedValue} + /> + + + + ); +} + +interface ParticipantManualAssignmentModalProps { + setModalOpen: Dispatch>; +} + +export default function ParticipantManualAssignmentModal( + props: ParticipantManualAssignmentModalProps, +) { + const {setModalOpen} = props; + const localUid = useLocalUid(); + const { + getAllRooms, + unassignedParticipants, + manualAssignments, + setManualAssignments, + handleAssignParticipants, + } = useBreakoutRoom(); + + // Local state for assignments + const [searchQuery, setSearchQuery] = useState(''); + const [localAssignments, setLocalAssignments] = useState< + ManualParticipantAssignment[] + >(() => { + if (manualAssignments.length > 0) { + // Restore previous manual assignments + return manualAssignments; + } + + // Create new manual assignments for unassigned participants + return unassignedParticipants.map(participant => ({ + uid: participant.uid, + roomId: null, // Start unassigned + isHost: participant.user.isHost, + isSelected: false, + })); + }); + // Rooms dropdown options + const rooms = [ + {label: 'Unassigned', value: 'unassigned'}, + ...getAllRooms().map(item => ({label: item.name, value: item.id})), + ]; + + // Update room assignment + const updateManualAssignment = (uid: UidType, roomId: string | null) => { + const selectedParticipants = localAssignments.filter(a => a.isSelected); + const clickedParticipant = localAssignments.find(a => a.uid === uid); + + if (selectedParticipants.length > 1 && clickedParticipant?.isSelected) { + // BULK BEHAVIOR: If multiple selected and clicked one is selected, + // assign ALL selected participants to the same room + setLocalAssignments(prev => + prev.map(assignment => + assignment.isSelected + ? { + ...assignment, + roomId: roomId === 'unassigned' ? null : roomId, + isSelected: false, // Deselect after assignment + } + : assignment, + ), + ); + } else { + // INDIVIDUAL BEHAVIOR: Normal single assignment + setLocalAssignments(prev => + prev.map(assignment => + assignment.uid === uid + ? { + ...assignment, + roomId: roomId === 'unassigned' ? null : roomId, + isSelected: false, // Deselect this one too + } + : assignment, + ), + ); + } + }; + const handleRoomDropdownChange = (uid: UidType, roomId: string | null) => { + const clickedParticipant = localAssignments.find(a => a.uid === uid); + if (!clickedParticipant?.isSelected) { + // User clicked dropdown of non-selected participant + // Deselect everyone first, then assign + setLocalAssignments(prev => + prev.map(assignment => ({ + ...assignment, + isSelected: false, // Deselect all + roomId: + assignment.uid === uid + ? roomId === 'unassigned' + ? null + : roomId + : assignment.roomId, + })), + ); + } else { + // Use the bulk/individual logic + updateManualAssignment(uid, roomId); + } + }; + // Toggle selection for specific participant + const toggleParticipantSelection = (uid: UidType) => { + setLocalAssignments(prev => + prev.map(assignment => + assignment.uid === uid + ? {...assignment, isSelected: !assignment.isSelected} + : assignment, + ), + ); + }; + + const allSelected = + localAssignments.length > 0 && localAssignments.every(a => a.isSelected); + + // Select/deselect all + const toggleSelectAll = () => { + const areAllSelected = localAssignments.every(a => a.isSelected); + setLocalAssignments(prev => + prev.map(assignment => ({ + ...assignment, + isSelected: !areAllSelected, + })), + ); + }; + + // More descriptive Select All label + const getSelectAllLabel = () => { + if (selectedCount === 0) { + return 'Select All'; + } else if (allSelected) { + return 'Deselect All'; + } else { + return `Select All (${selectedCount}/${localAssignments.length})`; + } + }; + + const handleCancel = () => { + setModalOpen(false); + }; + + const handleSaveManualAssignments = () => { + setManualAssignments(localAssignments); + // Trigger the actual assignment after saving + handleAssignParticipants(RoomAssignmentStrategy.MANUAL_ASSIGN); + setModalOpen(false); + }; + + const selectedCount = localAssignments.filter(a => a.isSelected).length; + const unassignedCount = localAssignments.filter(a => !a.roomId).length; + + // Filter participants based on search query + const filteredParticipants = unassignedParticipants.filter(participant => { + const displayName = + participant.uid === localUid + ? `${participant.user.name} (me)` + : participant.user.name; + return displayName.toLowerCase().includes(searchQuery.toLowerCase()); + }); + + return ( + setModalOpen(false)} + showCloseIcon={true} + title="Assign Participants" + cancelable={false} + contentContainerStyle={style.contentContainer}> + + + {/* Search Bar */} + + + + + + + + {/* Participant Count */} + + + + + + {localAssignments.length} + + + ({unassignedCount} Unassigned) + + + + + {selectedCount > 0 && ( + + {selectedCount} of {localAssignments.length} participants + selected + + )} + + + + {/* Select All Controls */} + + + + + toggleSelectAll()} + label={''} + checkBoxStyle={style.checkboxIcon} + // label={getSelectAllLabel()} + /> + + + + Name + + + + + Room + + + + + {/* Participants List */} + + {filteredParticipants.length === 0 ? ( + + ) : ( + + {filteredParticipants.map(participant => { + const assignment = localAssignments.find( + a => a.uid === participant.uid, + ); + return ( + + ); + })} + + )} + + + + + + { + handleCancel(); + }} + /> + + + { + handleSaveManualAssignments(); + }} + /> + + + + + ); +} + +const style = StyleSheet.create({ + contentContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + flexShrink: 0, + width: '100%', + }, + fullBody: { + width: '100%', + height: '100%', + flex: 1, + }, + mbody: { + flex: 1, + padding: 20, + gap: 16, + borderTopColor: $config.CARD_LAYER_3_COLOR, + borderTopWidth: 1, + borderBottomColor: $config.CARD_LAYER_3_COLOR, + borderBottomWidth: 1, + }, + mfooter: { + padding: 12, + gap: 12, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + marginTop: 'auto', + backgroundColor: $config.CARD_LAYER_2_COLOR, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + // Search Container + searchContainer: { + position: 'relative', + width: '100%', + }, + searchIcon: { + position: 'absolute', + left: 8, + top: 12, + }, + searchInput: { + height: 36, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + borderRadius: 4, + paddingHorizontal: 12, + paddingLeft: 30, + fontSize: ThemeConfig.FontSize.small, + color: $config.FONT_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + }, + + // Participant Count + participantSummaryContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + participantCountContainer: { + display: 'flex', + flexDirection: 'row', + gap: 4, + alignItems: 'center', + }, + participantCountTextContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + participantCount: { + fontSize: ThemeConfig.FontSize.small, + fontWeight: '500', + lineHeight: 16, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + }, + participantCountLowOpacity: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + dropdownContainer: { + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: $config.CARD_LAYER_2_COLOR, + borderRadius: 4, + borderLeftWidth: 0, + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + }, + checkboxIconContainer: { + paddingRight: 24, + }, + checkboxIcon: { + borderColor: $config.SECONDARY_ACTION_COLOR, + borderRadius: 2, + width: 17, + height: 17, + }, + participantTable: { + flex: 1, + }, + participantTableHeader: { + display: 'flex', + flexShrink: 0, + alignItems: 'center', + flexDirection: 'row', + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + backgroundColor: $config.CARD_LAYER_2_COLOR, + paddingHorizontal: 8, + height: 40, + }, + participantTableHeaderRow: { + flex: 1, + alignSelf: 'stretch', + flexDirection: 'row', + }, + participantTableHeaderRowCell: { + flex: 1, + alignSelf: 'stretch', + justifyContent: 'center', + }, + participantTableHeaderRowCellText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + }, + // Participants List + participantsList: { + flex: 1, + padding: 8, + paddingBottom: 0, + backgroundColor: $config.BACKGROUND_COLOR, + }, + participantsScrollView: { + flex: 1, + }, + // Participant Row + participantRow: { + // display: 'flex', + // flexDirection: 'row', + // alignItems: 'center', + // justifyContent: 'space-between', + // paddingHorizontal: 16, + // paddingVertical: 12, + // borderBottomWidth: 1, + // borderBottomColor: $config.CARD_LAYER_3_COLOR + '40', + // minHeight: 60, + }, + participantBodyRow: { + display: 'flex', + alignSelf: 'stretch', + // minHeight: 50, + flexDirection: 'row', + paddingBottom: 8, + // paddingTop: 20, + }, + participantBodytRowCell: { + flex: 1, + alignSelf: 'center', + justifyContent: 'center', + gap: 10, + }, + checkboxCell: { + maxWidth: 50, + }, + participantInfo: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + flex: 1, + gap: 12, + }, + participantAvatar: { + width: 32, + height: 32, + borderRadius: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + participantAvatarText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 32, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + }, + participantName: { + flex: 1, + fontSize: ThemeConfig.FontSize.small, + fontWeight: '400', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + }, + participantDropdown: { + minWidth: 120, + }, + // Empty State + infotextContainer: { + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 32, + gap: 12, + }, + infoText: { + fontSize: ThemeConfig.FontSize.small, + fontWeight: '500', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + textAlign: 'center', + }, + // Buttons + actionBtnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + }, + cancelBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.SECONDARY_ACTION_COLOR, + backgroundColor: 'transparent', + }, + saveBtn: { + borderRadius: 4, + minWidth: 140, + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, +}); diff --git a/template/src/components/breakout-room/ui/SelectParticipantAssignmentStrategy.tsx b/template/src/components/breakout-room/ui/SelectParticipantAssignmentStrategy.tsx new file mode 100644 index 000000000..7ee59eb40 --- /dev/null +++ b/template/src/components/breakout-room/ui/SelectParticipantAssignmentStrategy.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import {Text, StyleSheet} from 'react-native'; +import {Dropdown} from 'customization-api'; +import ThemeConfig from '../../../theme'; +import {RoomAssignmentStrategy} from '../state/reducer'; + +interface Props { + selectedStrategy: RoomAssignmentStrategy; + onStrategyChange: (strategy: RoomAssignmentStrategy) => void; + disabled: boolean; +} + +const strategyList = [ + { + label: 'Auto-assign people to all rooms', + value: RoomAssignmentStrategy.AUTO_ASSIGN, + }, + { + label: 'Manually Assign participants', + value: RoomAssignmentStrategy.MANUAL_ASSIGN, + }, + { + label: 'Let people choose their rooms', + value: RoomAssignmentStrategy.NO_ASSIGN, + }, +]; +const SelectParticipantAssignmentStrategy: React.FC = ({ + selectedStrategy, + onStrategyChange, + disabled = false, +}) => { + return ( + <> + Assign participants to breakout rooms + { + onStrategyChange(value as RoomAssignmentStrategy); + }} + /> + + ); +}; + +const style = StyleSheet.create({ + label: { + fontWeight: '400', + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontFamily: ThemeConfig.FontFamily.sansPro, + }, +}); +export default SelectParticipantAssignmentStrategy; diff --git a/template/src/components/chat-messages/useChatMessages.tsx b/template/src/components/chat-messages/useChatMessages.tsx index 07c26984b..0d465add4 100644 --- a/template/src/components/chat-messages/useChatMessages.tsx +++ b/template/src/components/chat-messages/useChatMessages.tsx @@ -46,6 +46,11 @@ interface ChatMessagesProviderProps { children: React.ReactNode; callActive: boolean; } + +export enum ChatNotificationType { + ANNOUNCEMENT = 'ANNOUNCEMENT', +} + export enum ChatMessageType { /** * Text message. @@ -85,6 +90,10 @@ export enum ChatMessageType { COMBINE = 'combine', } +export interface AnnouncementMessage { + sender: string; + heading: string; +} export interface messageInterface { createdTimestamp: number; updatedTimestamp?: number; @@ -99,6 +108,7 @@ export interface messageInterface { reactions?: Reaction[]; replyToMsgId?: string; hide?: boolean; + announcement?: AnnouncementMessage; } export enum SDKChatType { @@ -122,6 +132,7 @@ export interface ChatOption { channel?: string; msg?: string; replyToMsgId?: string; + announcement?: AnnouncementMessage; }; url?: string; } @@ -178,6 +189,8 @@ interface ChatMessagesInterface { uid: string, isPrivateMessage?: boolean, msgType?: ChatMessageType, + forceStop?: boolean, + notificationType?: ChatNotificationType, ) => void; openPrivateChat: (toUid: UidType) => void; removeMessageFromStore: (msgId: string, isMsgRecalled: boolean) => void; @@ -277,11 +290,20 @@ const ChatMessagesProvider = (props: ChatMessagesProviderProps) => { //commented for v1 release //const fromText = useString('messageSenderNotificationLabel'); - const fromText = (name: string, msgType: ChatMessageType) => { + const fromText = ( + name: string, + msgType: ChatMessageType, + announcement?: AnnouncementText, + ) => { let text = ''; switch (msgType) { case ChatMessageType.TXT: - text = txtToastHeading?.current(name); + if (announcement?.text) { + text = `${announcement.sender}: made an announcement in public chat`; + } else { + text = txtToastHeading?.current(name); + } + break; case ChatMessageType.IMAGE: text = imgToastHeading?.current(name); @@ -329,12 +351,15 @@ const ChatMessagesProvider = (props: ChatMessagesProviderProps) => { //TODO: check why need - const updateRenderListState = ( - uid: number, - data: Partial, - ) => { - dispatch({type: 'UpdateRenderList', value: [uid, data]}); - }; + // If this is needed use syncUserState + // + // const updateRenderListState = ( + // uid: number, + // data: Partial, + // ) => { + // dispatch({type: 'UpdateRenderList', value: [uid, data]}); + // }; + // const addMessageToStore = (uid: UidType, body: messageInterface) => { setMessageStore((m: messageStoreInterface[]) => { @@ -353,6 +378,7 @@ const ChatMessagesProvider = (props: ChatMessagesProviderProps) => { fileName: body?.fileName, replyToMsgId: body?.replyToMsgId, hide: false, + announcement: body?.announcement, }, ]; }); @@ -520,6 +546,7 @@ const ChatMessagesProvider = (props: ChatMessagesProviderProps) => { isPrivateMessage: boolean = false, msgType: ChatMessageType, forceStop: boolean = false, + announcement: AnnouncementMessage, ) => { if (isUserBanedRef.current.isUserBaned) { return; @@ -684,17 +711,22 @@ const ChatMessagesProvider = (props: ChatMessagesProviderProps) => { primaryBtn: null, secondaryBtn: null, type: 'info', - leadingIconName: 'chat-nav', - text1: isPrivateMessage + leadingIconName: announcement ? 'announcement' : 'chat-nav', + text1: announcement + ? announcement.heading + : isPrivateMessage ? privateMessageLabel?.current() : //@ts-ignore - defaultContentRef.current.defaultContent[uidAsNumber]?.name + announcement?.sender + ? announcement.sender + : defaultContentRef.current.defaultContent[uidAsNumber]?.name ? fromText( trimText( //@ts-ignore defaultContentRef.current.defaultContent[uidAsNumber]?.name, ), msgType, + announcement, ) : '', text2: isPrivateMessage diff --git a/template/src/components/chat/chatConfigure.tsx b/template/src/components/chat/chatConfigure.tsx index 2a4a2cd69..799420341 100644 --- a/template/src/components/chat/chatConfigure.tsx +++ b/template/src/components/chat/chatConfigure.tsx @@ -7,6 +7,7 @@ import {useLocalUid} from '../../../agora-rn-uikit'; import {UidType, useContent} from 'customization-api'; import { ChatMessageType, + ChatNotificationType, ChatOption, SDKChatType, useChatMessages, @@ -255,7 +256,7 @@ const ChatConfigure = ({children}) => { ); } }, - // text message is recieved + // Text message is recieved, update receiver side ui onTextMessage: message => { if (message?.ext?.channel !== data?.channel) { return; @@ -272,11 +273,14 @@ const ChatConfigure = ({children}) => { if (message.chatType === SDKChatType.GROUP_CHAT) { // show to notifcation- group msg received + showMessageNotification( message.msg, fromUser, false, message.type, + false, + message.ext?.announcement, ); addMessageToStore(Number(fromUser), { msg: message.msg.replace(/^(\n)+|(\n)+$/g, ''), @@ -285,6 +289,7 @@ const ChatConfigure = ({children}) => { isDeleted: false, type: ChatMessageType.TXT, replyToMsgId: message.ext?.replyToMsgId, + announcement: message.ext?.announcement, }); } @@ -418,6 +423,7 @@ const ChatConfigure = ({children}) => { ext: option?.ext?.file_ext, fileName: option?.ext?.file_name, replyToMsgId: option?.ext?.replyToMsgId, + announcement: option?.ext?.announcement, }; // update local user message store diff --git a/template/src/components/common/Dividers.tsx b/template/src/components/common/Dividers.tsx new file mode 100644 index 000000000..185931ea8 --- /dev/null +++ b/template/src/components/common/Dividers.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import {View, StyleSheet} from 'react-native'; +import ThemeConfig from '../../theme'; + +interface DividerProps { + orientation?: 'horizontal' | 'vertical'; + marginTop?: number; + marginBottom?: number; + marginLeft?: number; + marginRight?: number; + thickness?: number; + color?: string; + length?: number | string; // only for vertical dividers +} + +const Divider: React.FC = ({ + orientation = 'horizontal', + marginTop = 8, + marginBottom = 8, + marginLeft = 0, + marginRight = 0, + thickness = 1, + color = $config.CARD_LAYER_4_COLOR, + length = '100%', +}) => { + const isHorizontal = orientation === 'horizontal'; + + const style = isHorizontal + ? { + height: thickness, + width: '100%', + backgroundColor: color, + marginTop, + marginBottom, + } + : { + width: thickness, + height: length, + backgroundColor: color, + marginLeft, + marginRight, + }; + + return ; +}; + +const styles = StyleSheet.create({ + base: { + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, +}); + +export default Divider; diff --git a/template/src/components/controls/toolbar-items/ExitBreakoutRoomToolbarItem.tsx b/template/src/components/controls/toolbar-items/ExitBreakoutRoomToolbarItem.tsx new file mode 100644 index 000000000..1de5be208 --- /dev/null +++ b/template/src/components/controls/toolbar-items/ExitBreakoutRoomToolbarItem.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ToolbarItem, {ToolbarItemProps} from '../../../atoms/ToolbarItem'; +import ExitBreakoutRoomIconButton from '../../breakout-room/ui/ExitBreakoutRoomIconButton'; + +export interface Props extends ToolbarItemProps {} + +export const ExitBreakoutRoomToolbarItem = (props: Props) => { + return ( + + + + ); +}; diff --git a/template/src/components/controls/useControlPermissionMatrix.tsx b/template/src/components/controls/useControlPermissionMatrix.tsx index 2daa52cf0..eada87fa9 100644 --- a/template/src/components/controls/useControlPermissionMatrix.tsx +++ b/template/src/components/controls/useControlPermissionMatrix.tsx @@ -3,7 +3,9 @@ import {useContext} from 'react'; import {ClientRoleType, PropsContext} from '../../../agora-rn-uikit/src'; import {useRoomInfo} from '../room-info/useRoomInfo'; import {joinRoomPreference} from '../../utils/useJoinRoom'; -import {isWeb} from '../../utils/common'; +import {isWeb, isWebInternal} from '../../utils/common'; +import {ENABLE_AUTH} from '../../auth/config'; +import {useBreakoutRoomInfo} from '../room-info/useSetBreakoutRoomInfo'; /** * ControlPermissionKey represents the different keys @@ -15,7 +17,12 @@ export type ControlPermissionKey = | 'participantControl' | 'screenshareControl' | 'settingsControl' - | 'viewAllTextTracks'; + | 'viewAllTextTracksControl' + | 'breakoutRoomControl' + | 'whiteboardControl' + | 'recordingControl' + | 'captionsControl' + | 'transcriptsControl'; /** * ControlPermissionRule defines the properties used to evaluate permission rules. @@ -24,6 +31,7 @@ export type ControlPermissionRule = { isHost: boolean; role: ClientRoleType; preference: joinRoomPreference; + isInBreakoutRoom: boolean; }; export const controlPermissionMatrix: Record< @@ -36,12 +44,30 @@ export const controlPermissionMatrix: Record< settingsControl: ({preference}) => !preference.disableSettings, screenshareControl: ({preference}) => $config.SCREEN_SHARING && !preference.disableScreenShare, - viewAllTextTracks: ({isHost}) => + + viewAllTextTracksControl: ({isHost, isInBreakoutRoom}) => isHost && $config.ENABLE_STT && $config.ENABLE_MEETING_TRANSCRIPT && $config.ENABLE_TEXT_TRACKS && - isWeb(), + isWeb() && + !isInBreakoutRoom, + whiteboardControl: ({isHost, isInBreakoutRoom}) => + isHost && $config.ENABLE_WHITEBOARD && isWebInternal() && !isInBreakoutRoom, + recordingControl: ({isHost, isInBreakoutRoom}) => + isHost && $config.CLOUD_RECORDING && !isInBreakoutRoom, + captionsControl: ({isInBreakoutRoom}) => + $config.ENABLE_STT && $config.ENABLE_CAPTION && !isInBreakoutRoom, + transcriptsControl: ({isInBreakoutRoom}) => + $config.ENABLE_MEETING_TRANSCRIPT && !isInBreakoutRoom, + breakoutRoomControl: () => + isWeb() && + $config.ENABLE_BREAKOUT_ROOM && + ENABLE_AUTH && + !$config.ENABLE_CONVERSATIONAL_AI && + !$config.EVENT_MODE && + !$config.RAISE_HAND && + !$config.ENABLE_WAITING_ROOM, }; export const useControlPermissionMatrix = ( @@ -49,12 +75,14 @@ export const useControlPermissionMatrix = ( ): boolean => { const {data: roomData, roomPreference} = useRoomInfo(); const {rtcProps} = useContext(PropsContext); + const {breakoutRoomChannelData} = useBreakoutRoomInfo(); // Build the permission rule context for the current user. const rule: ControlPermissionRule = { isHost: roomData?.isHost || false, role: rtcProps.role, preference: {...roomPreference}, + isInBreakoutRoom: breakoutRoomChannelData?.isBreakoutMode || false, }; // Retrieve the permission function for the given key and evaluate it. const permissionFn = controlPermissionMatrix[key]; diff --git a/template/src/components/participants/AllHostParticipants.tsx b/template/src/components/participants/AllHostParticipants.tsx index 4d537d7d5..73d992d02 100644 --- a/template/src/components/participants/AllHostParticipants.tsx +++ b/template/src/components/participants/AllHostParticipants.tsx @@ -73,12 +73,14 @@ export default function AllHostParticipants(props: any) { isAudienceUser={false} name={getParticipantName(localUid)} user={defaultContent[localUid]} - showControls={true} + showControls={props?.hideControls ? false : true} isHostUser={hostUids.indexOf(localUid) !== -1} key={localUid} isMobile={isMobile} handleClose={handleClose} updateActionSheet={updateActionSheet} + showBreakoutRoomMenu={props?.showBreakoutRoomMenu} + from={props?.from} /> {renderScreenShare(defaultContent[localUid])} @@ -104,12 +106,18 @@ export default function AllHostParticipants(props: any) { isAudienceUser={false} name={getParticipantName(uid)} user={defaultContent[uid]} - showControls={defaultContent[uid]?.type === 'rtc' && isHost} + showControls={ + defaultContent[uid]?.type === 'rtc' && + isHost && + (props?.hideControls ? false : true) + } isHostUser={hostUids.indexOf(uid) !== -1} key={uid} isMobile={isMobile} handleClose={handleClose} updateActionSheet={updateActionSheet} + showBreakoutRoomMenu={props?.showBreakoutRoomMenu} + from={props?.from} /> {renderScreenShare(defaultContent[uid])} diff --git a/template/src/components/participants/Participant.tsx b/template/src/components/participants/Participant.tsx index 3414521da..1cf416880 100644 --- a/template/src/components/participants/Participant.tsx +++ b/template/src/components/participants/Participant.tsx @@ -66,6 +66,12 @@ interface ParticipantInterface { updateActionSheet: (screenName: 'chat' | 'participants' | 'settings') => {}; uid?: UidType; screenUid?: UidType; + showBreakoutRoomMenu?: boolean; + from?: + | 'breakout-room' + | 'partcipant' + | 'screenshare-participant' + | 'video-tile'; } const Participant = (props: ParticipantInterface) => { @@ -106,7 +112,7 @@ const Participant = (props: ParticipantInterface) => { setActionMenuVisible={setActionMenuVisible} user={props.user} btnRef={moreIconRef} - from={'partcipant'} + from={props?.from || 'partcipant'} spotlightUid={spotlightUid} setSpotlightUid={setSpotlightUid} /> diff --git a/template/src/components/participants/UserActionMenuOptions.tsx b/template/src/components/participants/UserActionMenuOptions.tsx index 1e3343661..31ec81cdf 100644 --- a/template/src/components/participants/UserActionMenuOptions.tsx +++ b/template/src/components/participants/UserActionMenuOptions.tsx @@ -77,13 +77,21 @@ import { DEFAULT_ACTION_KEYS, UserActionMenuItemsConfig, } from '../../atoms/UserActionMenuPreset'; +import { + MemberDropdownOption, + useBreakoutRoom, +} from '../breakout-room/context/BreakoutRoomContext'; interface UserActionMenuOptionsOptionsProps { user: ContentInterface; actionMenuVisible: boolean; setActionMenuVisible: (actionMenuVisible: boolean) => void; btnRef: any; - from: 'partcipant' | 'screenshare-participant' | 'video-tile'; + from: + | 'partcipant' + | 'screenshare-participant' + | 'video-tile' + | 'breakout-room'; spotlightUid?: UidType; setSpotlightUid?: (uid: UidType) => void; items?: UserActionMenuItemsConfig; @@ -102,7 +110,8 @@ export default function UserActionMenuOptionsOptions( useState(false); const [actionMenuitems, setActionMenuitems] = useState([]); const {setSidePanel} = useSidePanel(); - const {user, actionMenuVisible, setActionMenuVisible, spotlightUid} = props; + const {user, actionMenuVisible, setActionMenuVisible, spotlightUid, from} = + props; const {currentLayout} = useLayout(); const {pinnedUid, activeUids, customContent, secondaryPinnedUid} = useContent(); @@ -729,6 +738,7 @@ export default function UserActionMenuOptionsOptions( secondaryPinnedUid, currentLayout, spotlightUid, + from, ]); const {width: globalWidth, height: globalHeight} = useWindowDimensions(); diff --git a/template/src/components/precall/joinWaitingRoomBtn.native.tsx b/template/src/components/precall/joinWaitingRoomBtn.native.tsx index 5abb48992..5ce305da9 100644 --- a/template/src/components/precall/joinWaitingRoomBtn.native.tsx +++ b/template/src/components/precall/joinWaitingRoomBtn.native.tsx @@ -39,6 +39,7 @@ import { waitingRoomHostNotJoined, waitingRoomUsersInCall, } from '../../language/default-labels/videoCallScreenLabels'; +import ChatContext from '../ChatContext'; export interface PreCallJoinWaitingRoomBtnProps { render?: ( @@ -58,6 +59,7 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { const waitingRoomUsersInCallText = useString(waitingRoomUsersInCall); let pollingTimeout = React.useRef(null); const {rtcProps} = useContext(PropsContext); + const {syncUserState} = useContext(ChatContext); const {setCallActive, callActive} = usePreCall(); const username = useGetName(); const {isJoinDataFetched, isInWaitingRoom} = useRoomInfo(); @@ -139,10 +141,11 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { if (callActive) return; // on approve/reject response from host, waiting room permission is reset // update waitinng room status on uid - dispatch({ - type: 'UpdateRenderList', - value: [localUid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [localUid, {isInWaitingRoom: false}], + // }); + syncUserState(localUid, {isInWaitingRoom: false}); if (approved) { setRoomInfo(prev => { @@ -219,10 +222,11 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { }); // add the waitingRoomStatus to the uid - dispatch({ - type: 'UpdateRenderList', - value: [localUid, {isInWaitingRoom: true}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [localUid, {isInWaitingRoom: true}], + // }); + syncUserState(localUid, {isInWaitingRoom: true}); // join request API to server, server will send RTM message to all hosts regarding request from this user, requestServerToJoinRoom(); diff --git a/template/src/components/precall/joinWaitingRoomBtn.tsx b/template/src/components/precall/joinWaitingRoomBtn.tsx index 8d5be8efd..52cb8176c 100644 --- a/template/src/components/precall/joinWaitingRoomBtn.tsx +++ b/template/src/components/precall/joinWaitingRoomBtn.tsx @@ -45,6 +45,7 @@ import { waitingRoomHostNotJoined, waitingRoomUsersInCall, } from '../../language/default-labels/videoCallScreenLabels'; +import ChatContext from '../ChatContext'; const audio = new Audio( 'https://dl.dropboxusercontent.com/s/1cdwpm3gca9mlo0/kick.mp3', @@ -84,7 +85,7 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { const localUid = useLocalUid(); const {dispatch} = useContext(DispatchContext); - + const {syncUserState} = useContext(ChatContext); const [buttonText, setButtonText] = React.useState( waitingRoomButton({ ready: isInWaitingRoom, @@ -144,10 +145,11 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { if (callActive) return; // on approve/reject response from host, waiting room permission is reset // update waitinng room status on uid - dispatch({ - type: 'UpdateRenderList', - value: [localUid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [localUid, {isInWaitingRoom: false}], + // }); + syncUserState(localUid, {isInWaitingRoom: false}); if (approved) { setRoomInfo(prev => { @@ -241,10 +243,12 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { //setCallActive(true); // add the waitingRoomStatus to the uid - dispatch({ - type: 'UpdateRenderList', - value: [localUid, {isInWaitingRoom: true}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [localUid, {isInWaitingRoom: true}], + // }); + syncUserState(localUid, {isInWaitingRoom: true}); + // Enter waiting rooom; setRoomInfo(prev => { return {...prev, isInWaitingRoom: true}; @@ -281,7 +285,7 @@ const JoinWaitingRoomBtn = (props: PreCallJoinWaitingRoomBtnProps) => { const title = buttonText; const onPress = () => onSubmit(); const disabled = $config.ENABLE_WAITING_ROOM_AUTO_REQUEST - ? !hasHostJoined || isInWaitingRoom || username?.trim() === '' + ? !hasHostJoined || isInWaitingRoom || username?.trim() === '' : isInWaitingRoom || username?.trim() === ''; return props?.render ? ( props.render(onPress, title, disabled) diff --git a/template/src/components/raise-hand/RaiseHandButton.tsx b/template/src/components/raise-hand/RaiseHandButton.tsx new file mode 100644 index 000000000..a87fd7af7 --- /dev/null +++ b/template/src/components/raise-hand/RaiseHandButton.tsx @@ -0,0 +1,50 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React from 'react'; +import {useRaiseHand} from './RaiseHandProvider'; +import {StyleSheet} from 'react-native'; +import ThemeConfig from '../../theme'; +import TertiaryButton from '../../atoms/TertiaryButton'; + +const RaiseHandButton: React.FC = () => { + const {isHandRaised, toggleHand} = useRaiseHand(); + + return ( + + ); +}; + +export default RaiseHandButton; + +const style = StyleSheet.create({ + raiseHandBtn: { + width: '100%', + borderRadius: 4, + borderColor: $config.SECONDARY_ACTION_COLOR, + backgroundColor: 'transparent', + }, + raiseHandBtnText: { + textAlign: 'center', + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + }, +}); diff --git a/template/src/components/raise-hand/RaiseHandProvider.tsx b/template/src/components/raise-hand/RaiseHandProvider.tsx new file mode 100644 index 000000000..da8a66747 --- /dev/null +++ b/template/src/components/raise-hand/RaiseHandProvider.tsx @@ -0,0 +1,308 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from 'react'; +import {useCurrentRoomInfo} from '../room-info/useCurrentRoomInfo'; +import {useLocalUid} from '../../../agora-rn-uikit'; +import events, {PersistanceLevel} from '../../rtm-events-api'; +import Toast from '../../../react-native-toast-message'; +import {useMainRoomUserDisplayName} from '../../rtm/hooks/useMainRoomUserDisplayName'; +import {EventNames} from '../../rtm-events'; +import {useRoomInfo} from '../room-info/useRoomInfo'; +import {useBreakoutRoomInfo} from '../room-info/useSetBreakoutRoomInfo'; + +interface RaiseHandData { + raised: boolean; + timestamp: number; +} + +interface RaiseHandState { + // State + raisedHands: Record; + isHandRaised: boolean; + isUserHandRaised: (uid: number) => boolean; + + // Actions + raiseHand: () => void; + lowerHand: () => void; + toggleHand: () => void; +} + +const RaiseHandContext = createContext({ + raisedHands: {}, + isHandRaised: false, + isUserHandRaised: () => false, + raiseHand: () => {}, + lowerHand: () => {}, + toggleHand: () => {}, +}); + +interface RaiseHandProviderProps { + children: React.ReactNode; +} + +export const RaiseHandProvider: React.FC = ({ + children, +}) => { + const [raisedHands, setRaisedHands] = useState>( + {}, + ); + const localUid = useLocalUid(); + const getDisplayName = useMainRoomUserDisplayName(); + const { + data: {channel: mainChannelId}, + } = useRoomInfo(); + const {isInBreakoutRoute} = useCurrentRoomInfo(); + const {breakoutRoomChannelData} = useBreakoutRoomInfo(); + + // Get current user's hand state + const isHandRaised = raisedHands[localUid]?.raised || false; + + // Detect room changes and lower hand if raised + useEffect(() => { + // Send RTM event to reset attribute only if hand is raised + return () => { + if (isHandRaised) { + events.send( + EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, + JSON.stringify({ + uid: localUid, + raised: false, + timestamp: Date.now(), + }), + PersistanceLevel.Sender, + ); + } + }; + }, [localUid, isHandRaised]); + + // Check if any user has hand raised + const isUserHandRaised = useCallback( + (uid: number): boolean => { + return raisedHands[uid]?.raised || false; + }, + [raisedHands], + ); + + // Raise hand action + const raiseHand = useCallback(() => { + if (isHandRaised) { + return; + } // Already raised + + const timestamp = Date.now(); + const userName = getDisplayName(localUid) || `User ${localUid}`; + + // Update local state immediately + setRaisedHands(prev => ({...prev, [localUid]: {raised: true, timestamp}})); + + // 1. Send RTM attribute event (for current room UI) + events.send( + EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, + JSON.stringify({ + uid: localUid, + raised: true, + timestamp, + }), + PersistanceLevel.Sender, + ); + + // 2. Send cross-room notification to main room (if in breakout room) + if (isInBreakoutRoute) { + try { + // Get current active channel to restore later + events.send( + EventNames.CROSS_ROOM_RAISE_HAND_NOTIFICATION, + JSON.stringify({ + type: 'raise_hand', + uid: localUid, + userName: userName, + roomName: breakoutRoomChannelData?.room_name || '', + timestamp, + }), + PersistanceLevel.None, + -1, // send in channel + mainChannelId, // send to main channel + ); + } catch (error) { + console.error( + 'Failed to send cross-room raise hand notification:', + error, + ); + } + } + + // Show toast notification + Toast.show({ + type: 'success', + leadingIconName: 'raise-hand', + text1: 'Hand raised', + visibilityTime: 2000, + }); + }, [ + isHandRaised, + localUid, + getDisplayName, + isInBreakoutRoute, + mainChannelId, + breakoutRoomChannelData?.room_name, + ]); + + // Lower hand action + const lowerHand = useCallback(() => { + if (!isHandRaised) { + return; + } // Already lowered + + // Update local state immediately (keep timestamp but set raised to false) + setRaisedHands(prev => ({ + ...prev, + [localUid]: {raised: false, timestamp: Date.now()}, + })); + + // Send RTM event + events.send( + EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, + JSON.stringify({ + uid: localUid, + raised: false, + timestamp: Date.now(), + }), + PersistanceLevel.Sender, + ); + + // // Show toast notification + // Toast.show({ + // type: 'info', + // text1: 'Hand lowered', + // visibilityTime: 2000, + // }); + }, [isHandRaised, localUid]); + + // Toggle hand action + const toggleHand = useCallback(() => { + if (isHandRaised) { + lowerHand(); + } else { + raiseHand(); + } + }, [isHandRaised, raiseHand, lowerHand]); + + // Listen for RTM events + useEffect(() => { + const handleRaiseHandEvent = (data: any) => { + try { + const {payload} = data; + const eventData = JSON.parse(payload); + const {uid, raised, timestamp} = eventData; + // Update raised hands state + setRaisedHands(prev => ({ + ...prev, + [uid]: {raised, timestamp}, + })); + + // Show toast for other users (not for self) + if (uid !== localUid) { + const userName = getDisplayName(uid) || `User ${uid}`; + Toast.show({ + leadingIconName: raised ? 'raise-hand' : 'lower-hand', + type: 'info', + text1: raised + ? `${userName} raised hand` + : `${userName} lowered hand`, + visibilityTime: 3000, + }); + } + } catch (error) { + console.error('Failed to process raise hand event:', error); + } + }; + + const handleCrossRoomNotification = (data: any) => { + try { + const {payload} = data; + const eventData = JSON.parse(payload); + const {type, uid, userName, roomName} = eventData; + + // Only show notifications for other users and only in main room + if (uid !== localUid && !isInBreakoutRoute) { + if (type === 'raise_hand') { + Toast.show({ + type: 'info', + leadingIconName: 'raise-hand', + text1: `${userName} raised hand in ${roomName}`, + visibilityTime: 4000, + }); + } + } + } catch (error) { + console.error('Failed to process cross-room notification:', error); + } + }; + + // Register event listeners + events.on(EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, handleRaiseHandEvent); + events.on( + EventNames.CROSS_ROOM_RAISE_HAND_NOTIFICATION, + handleCrossRoomNotification, + ); + + return () => { + // Cleanup event listeners + events.off( + EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, + handleRaiseHandEvent, + ); + events.off( + EventNames.CROSS_ROOM_RAISE_HAND_NOTIFICATION, + handleCrossRoomNotification, + ); + }; + }, [localUid, getDisplayName, isInBreakoutRoute]); + + // Clear raised hands when room changes (optional: could be handled by RTM attribute clearing) + useEffect(() => { + setRaisedHands({}); + }, []); + + const contextValue: RaiseHandState = { + raisedHands, + isHandRaised, + isUserHandRaised, + raiseHand, + lowerHand, + toggleHand, + }; + + return ( + + {children} + + ); +}; + +// Hook to use raise hand functionality +export const useRaiseHand = (): RaiseHandState => { + const context = useContext(RaiseHandContext); + if (!context) { + throw new Error('useRaiseHand must be used within RaiseHandProvider'); + } + return context; +}; + +export default RaiseHandProvider; diff --git a/template/src/components/raise-hand/index.ts b/template/src/components/raise-hand/index.ts new file mode 100644 index 000000000..9702b7c15 --- /dev/null +++ b/template/src/components/raise-hand/index.ts @@ -0,0 +1,14 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +export {default as RaiseHandProvider, useRaiseHand} from './RaiseHandProvider'; +export {default as RaiseHandButton} from './RaiseHandButton'; diff --git a/template/src/components/recordings/RecordingsDateTable.tsx b/template/src/components/recordings/RecordingsDateTable.tsx index 700f050bc..bfdf8acee 100644 --- a/template/src/components/recordings/RecordingsDateTable.tsx +++ b/template/src/components/recordings/RecordingsDateTable.tsx @@ -57,8 +57,9 @@ function RecordingsDateTable(props) { const [currentPage, setCurrentPage] = useState(defaultPageNumber); const {fetchRecordings} = useRecording(); - const canAccessAllTextTracks = - useControlPermissionMatrix('viewAllTextTracks'); + const canAccessAllTextTracks = useControlPermissionMatrix( + 'viewAllTextTracksControl', + ); // message for any download‐error popup const [errorSnack, setErrorSnack] = React.useState(); diff --git a/template/src/components/room-info/useCurrentRoomInfo.tsx b/template/src/components/room-info/useCurrentRoomInfo.tsx new file mode 100644 index 000000000..7c4132541 --- /dev/null +++ b/template/src/components/room-info/useCurrentRoomInfo.tsx @@ -0,0 +1,42 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import {useLocation} from '../Router'; +import {useRoomInfo} from './useRoomInfo'; +import {useBreakoutRoomInfo} from './useSetBreakoutRoomInfo'; + +export interface CurrentRoomInfoContextInterface { + isInBreakoutRoute: boolean; + channelId: string; +} + +export const useCurrentRoomInfo = (): CurrentRoomInfoContextInterface => { + const mainRoomInfo = useRoomInfo(); // Always call - keeps public API intact + const {breakoutRoomChannelData} = useBreakoutRoomInfo(); // Always call - follows Rules of Hooks + const location = useLocation(); + + const isBreakoutMode = + new URLSearchParams(location.search).get('breakout') === 'true'; + + // Return overlapping data structure + if (isBreakoutMode && breakoutRoomChannelData) { + return { + isInBreakoutRoute: true, + channelId: breakoutRoomChannelData.channelId, + }; + } + + return { + isInBreakoutRoute: false, + channelId: mainRoomInfo.data.channel, + }; +}; diff --git a/template/src/components/room-info/useSetBreakoutRoomInfo.tsx b/template/src/components/room-info/useSetBreakoutRoomInfo.tsx new file mode 100644 index 000000000..170cf55f7 --- /dev/null +++ b/template/src/components/room-info/useSetBreakoutRoomInfo.tsx @@ -0,0 +1,64 @@ +import React, {createContext, useContext, useState} from 'react'; +import {BreakoutChannelJoinEventPayload} from '../breakout-room/state/types'; + +type BreakoutRoomData = BreakoutChannelJoinEventPayload['data']['data'] & { + isBreakoutMode: boolean; +}; + +interface BreakoutRoomInfoContextValue { + breakoutRoomChannelData: BreakoutRoomData | null; + setBreakoutRoomChannelData: React.Dispatch< + React.SetStateAction + >; +} + +const BreakoutRoomInfoContext = createContext({ + breakoutRoomChannelData: null, + setBreakoutRoomChannelData: () => {}, +}); + +interface BreakoutRoomInfoProviderProps { + children: React.ReactNode; + initialData?: BreakoutRoomData | null; +} + +export const BreakoutRoomInfoProvider: React.FC< + BreakoutRoomInfoProviderProps +> = ({children, initialData = null}) => { + const [breakoutRoomChannelData, setBreakoutRoomChannelData] = + useState(initialData); + + return ( + + {children} + + ); +}; + +export const useBreakoutRoomInfo = () => { + return useContext(BreakoutRoomInfoContext); +}; + +export const useSetBreakoutRoomInfo = () => { + const {setBreakoutRoomChannelData} = useBreakoutRoomInfo(); + + const setBreakoutRoomChannelInfo = ( + breakoutJoinChannelDetails: BreakoutRoomData, + ) => { + const breakoutData: BreakoutRoomData = { + isBreakoutMode: true, + ...breakoutJoinChannelDetails, + }; + setBreakoutRoomChannelData(breakoutData); + }; + + const clearBreakoutRoomChannelInfo = () => { + setBreakoutRoomChannelData(null); + }; + + return { + setBreakoutRoomChannelInfo, + clearBreakoutRoomChannelInfo, + }; +}; diff --git a/template/src/components/useUserPreference.tsx b/template/src/components/useUserPreference.tsx index 598949ee2..97074c561 100644 --- a/template/src/components/useUserPreference.tsx +++ b/template/src/components/useUserPreference.tsx @@ -67,7 +67,7 @@ const UserPreferenceProvider = (props: { const {dispatch} = useContext(DispatchContext); const [uids, setUids] = useState({}); const {store, setStore} = useContext(StorageContext); - const {hasUserJoinedRTM} = useContext(ChatContext); + const {hasUserJoinedRTM, syncUserState} = useContext(ChatContext); const getInitialUsername = () => store?.displayName ? store.displayName : ''; const [displayName, setDisplayName] = useState(getInitialUsername()); @@ -91,7 +91,12 @@ const UserPreferenceProvider = (props: { ); const keys = Object.keys(users); if (users && keys && keys?.length) { - updateRenderListState(screenShareUidToUpdate, { + // updateRenderListState(screenShareUidToUpdate, { + // name: getScreenShareName( + // users[parseInt(keys[0])]?.name || userText, + // ), + // }); + syncUserState(screenShareUidToUpdate, { name: getScreenShareName( users[parseInt(keys[0])]?.name || userText, ), @@ -111,7 +116,13 @@ const UserPreferenceProvider = (props: { const value = JSON.parse(data?.payload); if (value) { if (value?.uid) { - updateRenderListState(value?.uid, { + // updateRenderListState(value?.uid, { + // name: + // String(value?.uid)[0] === '1' + // ? pstnUserLabel + // : value?.name || userText, + // }); + syncUserState(value?.uid, { name: String(value?.uid)[0] === '1' ? pstnUserLabel @@ -142,7 +153,11 @@ const UserPreferenceProvider = (props: { } } if (value?.screenShareUid) { - updateRenderListState(value?.screenShareUid, { + // updateRenderListState(value?.screenShareUid, { + // name: getScreenShareName(value?.name || userText), + // type: 'screenshare', + // }); + syncUserState(value?.screenShareUid, { name: getScreenShareName(value?.name || userText), type: 'screenshare', }); @@ -164,11 +179,17 @@ const UserPreferenceProvider = (props: { }); //update local state for user and screenshare - updateRenderListState(localUid, {name: displayName || userText}); - updateRenderListState(screenShareUid, { + // updateRenderListState(localUid, {name: displayName || userText}); + // updateRenderListState(screenShareUid, { + // name: getScreenShareName(displayName || userText), + // type: 'screenshare', + // }); + syncUserState(localUid, {name: displayName || userText}); + syncUserState(screenShareUid, { name: getScreenShareName(displayName || userText), type: 'screenshare', }); + console.log('user-attribute name check displayName 1', displayName); if (hasUserJoinedRTM && callActive) { //set local uids @@ -181,9 +202,14 @@ const UserPreferenceProvider = (props: { }, }; }); + console.log('user-attribute name check displayName 2', displayName); + + syncUserState(localUid, {name: displayName || userText}); } if (hasUserJoinedRTM) { + console.log('user-attribute name check displayName 3', displayName); + //update remote state for user and screenshare events.send( EventNames.NAME_ATTRIBUTE, @@ -199,12 +225,13 @@ const UserPreferenceProvider = (props: { } }, [displayName, hasUserJoinedRTM, callActive, isHost]); - const updateRenderListState = ( - uid: number, - data: Partial, - ) => { - dispatch({type: 'UpdateRenderList', value: [uid, data]}); - }; + // Below method is now replaced with syncUserState + // const updateRenderListState = ( + // uid: number, + // data: Partial, + // ) => { + // dispatch({type: 'UpdateRenderList', value: [uid, data]}); + // }; return ( { const {video: localVideoStatus} = useLocalUserInfo(); const isLocalVideoON = localVideoStatus === ToggleState.enabled; + const {syncUserPreferences} = useRtm(); + const { rtcProps: {callActive}, } = useContext(PropsContext); @@ -161,6 +164,21 @@ const VBProvider: React.FC = ({children}) => { } }, [vbMode, selectedImage, saveVB, previewVideoTrack, isLocalVideoON]); + /* Sync VB preferences to RTM (only saves in main room) */ + React.useEffect(() => { + try { + syncUserPreferences({ + virtualBackground: { + type: + vbMode === 'blur' ? 'blur' : vbMode === 'image' ? 'image' : 'none', + ...(vbMode === 'image' && selectedImage && {imageUrl: selectedImage}), + }, + }); + } catch (error) { + console.warn('Failed to sync VB preference:', error); + } + }, [vbMode, selectedImage, syncUserPreferences]); + /* Fetch Saved Images from IndexDB to show in VBPanel */ React.useEffect(() => { const fetchData = async () => { diff --git a/template/src/components/whiteboard/WhiteboardConfigure.tsx b/template/src/components/whiteboard/WhiteboardConfigure.tsx index 229c7525a..3a564e0fc 100644 --- a/template/src/components/whiteboard/WhiteboardConfigure.tsx +++ b/template/src/components/whiteboard/WhiteboardConfigure.tsx @@ -444,6 +444,33 @@ const WhiteboardConfigure: React.FC = props => { } }, [whiteboardActive]); + // Cleanup whiteboard on unmount + useEffect(() => { + return () => { + if ( + whiteboardRoom.current && + Object.keys(whiteboardRoom.current)?.length + ) { + try { + whiteboardRoom.current?.disconnect(); + whiteboardRoom.current?.bindHtmlElement(null); + logger.log( + LogSource.Internals, + 'WHITEBOARD', + 'Whiteboard disconnected on unmount', + ); + } catch (err) { + logger.error( + LogSource.Internals, + 'WHITEBOARD', + 'Error disconnecting whiteboard on unmount', + err, + ); + } + } + }; + }, []); + return ( ; [toolbarItemMicrophoneTooltipText]?: I18nBaseType; [toolbarItemCameraText]?: I18nBaseType; @@ -644,6 +648,7 @@ export interface I18nVideoCallScreenLabelsInterface { [sttLanguageChangeInProgress]?: I18nBaseType; [peoplePanelHeaderText]?: I18nBaseType; + [breakoutRoomPanelHeaderText]?: I18nBaseType; [chatPanelGroupTabText]?: I18nBaseType; [chatPanelPrivateTabText]?: I18nBaseType; @@ -879,6 +884,7 @@ export const VideoCallScreenLabels: I18nVideoCallScreenLabelsInterface = { [toolbarItemSettingText]: 'Settings', [toolbarItemLayoutText]: 'Layout', [toolbarItemInviteText]: 'Invite', + [toolbarItemBreakoutRoomText]: 'Breakout Rooms', [toolbarItemMicrophoneText]: deviceStatus => { switch (deviceStatus) { @@ -1044,6 +1050,7 @@ export const VideoCallScreenLabels: I18nVideoCallScreenLabelsInterface = { [sttLanguageChangeInProgress]: 'Language Change is in progress...', [peoplePanelHeaderText]: 'People', + [breakoutRoomPanelHeaderText]: 'Breakout Room', [chatPanelGroupTabText]: 'Public', [chatPanelPrivateTabText]: 'Private', diff --git a/template/src/logger/AppBuilderLogger.tsx b/template/src/logger/AppBuilderLogger.tsx index 98161371b..6c53f5a50 100644 --- a/template/src/logger/AppBuilderLogger.tsx +++ b/template/src/logger/AppBuilderLogger.tsx @@ -53,7 +53,13 @@ export enum LogSource { } type LogType = { - [LogSource.AgoraSDK]: 'Log' | 'API' | 'Event' | 'Service' | 'AI_AGENT'; + [LogSource.AgoraSDK]: + | 'Log' + | 'API' + | 'Event' + | 'Service' + | 'AI_AGENT' + | 'RTMConfigure'; [LogSource.Internals]: | 'AUTH' | 'CREATE_MEETING' @@ -81,7 +87,8 @@ type LogType = { | 'STORE' | 'GET_MEETING_PHRASE' | 'MUTE_PSTN' - | 'FULL_SCREEN'; + | 'FULL_SCREEN' + | 'BREAKOUT_ROOM'; [LogSource.NetworkRest]: | 'idp_login' | 'token_login' @@ -105,7 +112,8 @@ type LogType = { | 'recording_stop' | 'recordings_get' | 'recording_delete' - | 'ban_user'; + | 'ban_user' + | 'breakout-room'; [LogSource.Events]: 'CUSTOM_EVENTS' | 'RTM_EVENTS'; [LogSource.CustomizationAPI]: | 'Log' diff --git a/template/src/pages/VideoCall.tsx b/template/src/pages/VideoCall.tsx index 39bee5268..a53fd9845 100644 --- a/template/src/pages/VideoCall.tsx +++ b/template/src/pages/VideoCall.tsx @@ -9,8 +9,7 @@ information visit https://appbuilder.agora.io. ********************************************* */ -// @ts-nocheck -import React, {useState, useContext, useEffect, useRef} from 'react'; +import React, {useState, useContext, useEffect, useRef, useMemo} from 'react'; import {View, StyleSheet, Text} from 'react-native'; import { RtcConfigure, @@ -20,10 +19,12 @@ import { LocalUserContext, UidType, CallbacksInterface, + RtcPropsInterface, } from '../../agora-rn-uikit'; import styles from '../components/styles'; import {useParams, useHistory} from '../components/Router'; import RtmConfigure from '../components/RTMConfigure'; +import RTMConfigureMainRoomProvider from '../rtm/RTMConfigureMainRoomProvider'; import DeviceConfigure from '../components/DeviceConfigure'; import Logo from '../subComponents/Logo'; import {useHasBrandLogo, isMobileUA, isWebInternal} from '../utils/common'; @@ -79,62 +80,31 @@ import {LogSource, logger} from '../logger/AppBuilderLogger'; import {useCustomization} from 'customization-implementation'; import {BeautyEffectProvider} from '../components/beauty-effect/useBeautyEffects'; import {UserActionMenuProvider} from '../components/useUserActionMenu'; +import {RaiseHandProvider} from '../components/raise-hand'; import Toast from '../../react-native-toast-message'; import {AuthErrorCodes} from '../utils/common'; +import {BreakoutRoomProvider} from '../components/breakout-room/context/BreakoutRoomContext'; +import BreakoutRoomEventsConfigure from '../components/breakout-room/events/BreakoutRoomEventsConfigure'; +import {RTM_ROOMS} from '../rtm/constants'; -enum RnEncryptionEnum { - /** - * @deprecated - * 0: This mode is deprecated. - */ - None = 0, - /** - * 1: (Default) 128-bit AES encryption, XTS mode. - */ - AES128XTS = 1, - /** - * 2: 128-bit AES encryption, ECB mode. - */ - AES128ECB = 2, - /** - * 3: 256-bit AES encryption, XTS mode. - */ - AES256XTS = 3, - /** - * 4: 128-bit SM4 encryption, ECB mode. - * - * @since v3.1.2. - */ - SM4128ECB = 4, - /** - * 6: 256-bit AES encryption, GCM mode. - * - * @since v3.1.2. - */ - AES256GCM = 6, - - /** - * 7: 128-bit GCM encryption, GCM mode. - * - * @since v3.4.5 - */ - AES128GCM2 = 7, - /** - * 8: 256-bit GCM encryption, GCM mode. - * @since v3.1.2. - * Compared to AES256GCM encryption mode, AES256GCM2 encryption mode is more secure and requires you to set the salt (encryptionKdfSalt). - */ - AES256GCM2 = 8, +interface VideoCallProps { + callActive: boolean; + setCallActive: React.Dispatch>; + rtcProps: RtcPropsInterface; + setRtcProps: React.Dispatch>>; + callbacks: CallbacksInterface; + styleProps: any; } -const VideoCall: React.FC = () => { - const hasBrandLogo = useHasBrandLogo(); - const joiningLoaderLabel = useString(videoRoomStartingCallText)(); - const bannedUserText = useString(userBannedText)(); - - const {setGlobalErrorMessage} = useContext(ErrorContext); - const {awake, release} = useWakeLock(); - const {isRecordingBot} = useIsRecordingBot(); +const VideoCall = (videoCallProps: VideoCallProps) => { + const { + callActive, + setCallActive, + rtcProps, + setRtcProps, + callbacks, + styleProps, + } = videoCallProps; /** * Should we set the callscreen to active ?? * a) If Recording bot( i.e prop: recordingBot) is TRUE then it means, @@ -144,35 +114,10 @@ const VideoCall: React.FC = () => { * b) If Recording bot( i.e prop: recordingBot) is FALSE then we should set * the callActive depending upon the value of magic variable - $config.PRECALL */ - const shouldCallBeSetToActive = isRecordingBot - ? true - : $config.PRECALL - ? false - : true; - const [callActive, setCallActive] = useState(shouldCallBeSetToActive); const [isRecordingActive, setRecordingActive] = useState(false); - const [queryComplete, setQueryComplete] = useState(false); - const [waitingRoomAttendeeJoined, setWaitingRoomAttendeeJoined] = - useState(false); const [sttAutoStarted, setSttAutoStarted] = useState(false); const [recordingAutoStarted, setRecordingAutoStarted] = useState(false); - const {phrase} = useParams<{phrase: string}>(); - - const {store} = useContext(StorageContext); - const { - join: SdkJoinState, - microphoneDevice: sdkMicrophoneDevice, - cameraDevice: sdkCameraDevice, - clearState, - } = useContext(SdkApiContext); - - // commented for v1 release - const afterEndCall = useCustomization( - data => - data?.lifecycle?.useAfterEndCall && data?.lifecycle?.useAfterEndCall(), - ); - const {PrefereceWrapper} = useCustomization(data => { let components: { PrefereceWrapper: React.ComponentType; @@ -190,454 +135,162 @@ const VideoCall: React.FC = () => { return components; }); - const [rtcProps, setRtcProps] = React.useState({ - appId: $config.APP_ID, - channel: null, - uid: null, - token: null, - rtm: null, - screenShareUid: null, - screenShareToken: null, - profile: $config.PROFILE, - screenShareProfile: $config.SCREEN_SHARE_PROFILE, - dual: true, - encryption: $config.ENCRYPTION_ENABLED - ? {key: null, mode: RnEncryptionEnum.AES128GCM2, screenKey: null} - : false, - role: ClientRoleType.ClientRoleBroadcaster, - geoFencing: $config.GEO_FENCING, - audioRoom: $config.AUDIO_ROOM, - activeSpeaker: $config.ACTIVE_SPEAKER, - preferredCameraId: - sdkCameraDevice.deviceId || store?.activeDeviceId?.videoinput || null, - preferredMicrophoneId: - sdkMicrophoneDevice.deviceId || store?.activeDeviceId?.audioinput || null, - recordingBot: isRecordingBot ? true : false, - }); - - const history = useHistory(); - const currentMeetingPhrase = useRef(history.location.pathname); - - const useJoin = useJoinRoom(); - const {setRoomInfo} = useSetRoomInfo(); - const {isJoinDataFetched, data, isInWaitingRoom, waitingRoomStatus} = - useRoomInfo(); - - useEffect(() => { - if (!isJoinDataFetched) { - return; - } - logger.log(LogSource.Internals, 'SET_MEETING_DETAILS', 'Room details', { - user_id: data?.uid || '', - meeting_title: data?.meetingTitle || '', - channel_id: data?.channel, - host_id: data?.roomId?.host || '', - attendee_id: data?.roomId?.attendee || '', - }); - }, [isJoinDataFetched, data, phrase]); - - React.useEffect(() => { - return () => { - logger.debug( - LogSource.Internals, - 'VIDEO_CALL_ROOM', - 'Videocall unmounted', - ); - setRoomInfo(prevState => { - return { - ...RoomInfoDefaultValue, - loginToken: prevState?.loginToken, - }; - }); - if (awake) { - release(); - } - }; - }, []); - - useEffect(() => { - if (!SdkJoinState.phrase) { - useJoin(phrase, RoomInfoDefaultValue.roomPreference) - .then(() => { - logger.log( - LogSource.Internals, - 'JOIN_MEETING', - 'Join channel success', - ); - }) - .catch(error => { - const errorCode = error?.code; - if (AuthErrorCodes.indexOf(errorCode) !== -1 && isSDK()) { - SDKEvents.emit('unauthorized', error); - } - logger.error( - LogSource.Internals, - 'JOIN_MEETING', - 'Join channel error', - JSON.stringify(error || {}), - ); - setGlobalErrorMessage(error); - history.push('/'); - }); - } - }, []); - - useEffect(() => { - if (!isSDK() || !SdkJoinState.initialized) { - return; - } - const { - phrase: sdkMeetingPhrase, - meetingDetails: sdkMeetingDetails, - skipPrecall, - promise, - preference, - } = SdkJoinState; - - const sdkMeetingPath = `/${sdkMeetingPhrase}`; - - setCallActive(skipPrecall); - - if (sdkMeetingDetails) { - setQueryComplete(false); - setRoomInfo(roomInfo => { - return { - ...roomInfo, - isJoinDataFetched: true, - data: { - ...roomInfo.data, - ...sdkMeetingDetails, - }, - roomPreference: preference, - }; - }); - } else if (sdkMeetingPhrase) { - setQueryComplete(false); - currentMeetingPhrase.current = sdkMeetingPath; - useJoin(sdkMeetingPhrase, preference) - .then(() => { - logger.log( - LogSource.Internals, - 'JOIN_MEETING', - 'Join channel success', - ); - }) - .catch(error => { - const errorCode = error?.code; - if (AuthErrorCodes.indexOf(errorCode) !== -1 && isSDK()) { - SDKEvents.emit('unauthorized', error); - } - logger.error( - LogSource.Internals, - 'JOIN_MEETING', - 'Join channel error', - JSON.stringify(error || {}), - ); - setGlobalErrorMessage(error); - history.push('/'); - currentMeetingPhrase.current = ''; - promise.rej(error); - }); - } - }, [SdkJoinState]); - - React.useEffect(() => { - if ( - //isJoinDataFetched === true && (!queryComplete || !isInWaitingRoom) - //non waiting room - host/attendee - (!$config.ENABLE_WAITING_ROOM && - isJoinDataFetched === true && - !queryComplete) || - //waiting room - host - ($config.ENABLE_WAITING_ROOM && - isJoinDataFetched === true && - data.isHost && - !queryComplete) || - //waiting room - attendee - ($config.ENABLE_WAITING_ROOM && - isJoinDataFetched === true && - !data.isHost && - (!queryComplete || !isInWaitingRoom) && - !waitingRoomAttendeeJoined) - ) { - setRtcProps(prevRtcProps => ({ - ...prevRtcProps, - channel: data.channel, - uid: data.uid, - token: data.token, - rtm: data.rtmToken, - encryption: $config.ENCRYPTION_ENABLED - ? { - key: data.encryptionSecret, - mode: data.encryptionMode, - screenKey: data.encryptionSecret, - salt: data.encryptionSecretSalt, - } - : false, - screenShareUid: data.screenShareUid, - screenShareToken: data.screenShareToken, - role: data.isHost - ? ClientRoleType.ClientRoleBroadcaster - : ClientRoleType.ClientRoleAudience, - preventJoin: - !$config.ENABLE_WAITING_ROOM || - ($config.ENABLE_WAITING_ROOM && data.isHost) || - ($config.ENABLE_WAITING_ROOM && - !data.isHost && - waitingRoomStatus === WaitingRoomStatus.APPROVED) - ? false - : true, - })); - - if ( - $config.ENABLE_WAITING_ROOM && - !data.isHost && - waitingRoomStatus === WaitingRoomStatus.APPROVED - ) { - setWaitingRoomAttendeeJoined(true); - } - // 1. Store the display name from API - // if (data.username) { - // setUsername(data.username); - // } - setQueryComplete(true); - } - }, [isJoinDataFetched, data, queryComplete]); - - const callbacks: CallbacksInterface = { - // RtcLeft: () => {}, - // RtcJoined: () => { - // if (SdkJoinState.phrase && SdkJoinState.skipPrecall) { - // SdkJoinState.promise?.res(); - // } - // }, - EndCall: () => { - clearState('join'); - setTimeout(() => { - // TODO: These callbacks are being called twice - SDKEvents.emit('leave'); - if (afterEndCall) { - afterEndCall(data.isHost, history as unknown as History); - } else { - history.push('/'); - } - }, 0); - }, - UserJoined: (uid: UidType) => { - console.log('UIKIT Callback: UserJoined', uid); - SDKEvents.emit('rtc-user-joined', uid); - }, - UserOffline: (uid: UidType) => { - console.log('UIKIT Callback: UserOffline', uid); - SDKEvents.emit('rtc-user-left', uid); - }, - RemoteAudioStateChanged: (uid: UidType, status: 0 | 2) => { - console.log('UIKIT Callback: RemoteAudioStateChanged', uid, status); - if (status === 0) { - SDKEvents.emit('rtc-user-unpublished', uid, 'audio'); - } else { - SDKEvents.emit('rtc-user-published', uid, 'audio'); - } - }, - RemoteVideoStateChanged: (uid: UidType, status: 0 | 2) => { - console.log('UIKIT Callback: RemoteVideoStateChanged', uid, status); - if (status === 0) { - SDKEvents.emit('rtc-user-unpublished', uid, 'video'); - } else { - SDKEvents.emit('rtc-user-published', uid, 'video'); - } - }, - UserBanned(isBanned) { - console.log('UIKIT Callback: UserBanned', isBanned); - Toast.show({ - leadingIconName: 'alert', - type: 'error', - text1: bannedUserText, - visibilityTime: 3000, - }); - }, - }; - return ( - <> - {queryComplete ? ( - queryComplete || !callActive ? ( - <> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - + + + + - - - - - {!isMobileUA() && ( - - )} - - - - - - {callActive ? ( - - - - - - - - ) : $config.PRECALL ? ( - - - - ) : ( - <> - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - - {hasBrandLogo() && } - {joiningLoaderLabel} - - ) - ) : ( - <> - )} - + + {!isMobileUA() && ( + + )} + + + + {/* */} + + {callActive ? ( + + + + + + + + + + + + + + ) : $config.PRECALL ? ( + + + + ) : ( + <> + )} + + {/* */} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; -const styleProps = { - maxViewStyles: styles.temp, - minViewStyles: styles.temp, - localBtnContainer: styles.bottomBar, - localBtnStyles: { - muteLocalAudio: styles.localButton, - muteLocalVideo: styles.localButton, - switchCamera: styles.localButton, - endCall: styles.endCall, - fullScreen: styles.localButton, - recording: styles.localButton, - screenshare: styles.localButton, - }, - theme: $config.PRIMARY_ACTION_BRAND_COLOR, - remoteBtnStyles: { - muteRemoteAudio: styles.remoteButton, - muteRemoteVideo: styles.remoteButton, - remoteSwap: styles.remoteButton, - minCloseBtnStyles: styles.minCloseBtn, - liveStreamHostControlBtns: styles.liveStreamHostControlBtns, - }, - BtnStyles: styles.remoteButton, -}; //change these to inline styles or sth -const style = StyleSheet.create({ - full: { - flex: 1, - flexDirection: 'column', - overflow: 'hidden', - }, - videoView: videoView, - loader: { - flex: 1, - alignSelf: 'center', - justifyContent: 'center', - }, - loaderLogo: { - alignSelf: 'center', - justifyContent: 'center', - marginBottom: 30, - }, - loaderText: {fontWeight: '500', color: $config.FONT_COLOR}, -}); +// const style = StyleSheet.create({ +// full: { +// flex: 1, +// flexDirection: 'column', +// overflow: 'hidden', +// }, +// videoView: videoView, +// loader: { +// flex: 1, +// alignSelf: 'center', +// justifyContent: 'center', +// }, +// loaderLogo: { +// alignSelf: 'center', +// justifyContent: 'center', +// marginBottom: 30, +// }, +// loaderText: {fontWeight: '500', color: $config.FONT_COLOR}, +// }); export default VideoCall; diff --git a/template/src/pages/video-call/BreakoutVideoCall.tsx b/template/src/pages/video-call/BreakoutVideoCall.tsx new file mode 100644 index 000000000..279250349 --- /dev/null +++ b/template/src/pages/video-call/BreakoutVideoCall.tsx @@ -0,0 +1,213 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +import React, {useState, useEffect} from 'react'; +import { + RtcConfigure, + PropsProvider, + ChannelProfileType, + LocalUserContext, + RtcPropsInterface, +} from '../../../agora-rn-uikit'; +import RtmConfigure from '../../components/RTMConfigure'; +import RTMConfigureBreakoutRoomProvider from '../../rtm/RTMConfigureBreakoutRoomProvider'; +import DeviceConfigure from '../../components/DeviceConfigure'; +import {isMobileUA} from '../../utils/common'; +import {LiveStreamContextProvider} from '../../components/livestream'; +import ScreenshareConfigure from '../../subComponents/screenshare/ScreenshareConfigure'; +import {LayoutProvider} from '../../utils/useLayout'; +import {RecordingProvider} from '../../subComponents/recording/useRecording'; +import {SidePanelProvider} from '../../utils/useSidePanel'; +import {NetworkQualityProvider} from '../../components/NetworkQualityContext'; +import {ChatNotificationProvider} from '../../components/chat-notification/useChatNotification'; +import {ChatUIControlsProvider} from '../../components/chat-ui/useChatUIControls'; +import {ScreenShareProvider} from '../../components/contexts/ScreenShareContext'; +import {LiveStreamDataProvider} from '../../components/contexts/LiveStreamDataContext'; +import {VideoMeetingDataProvider} from '../../components/contexts/VideoMeetingDataContext'; +import {UserPreferenceProvider} from '../../components/useUserPreference'; +import EventsConfigure from '../../components/EventsConfigure'; +import PermissionHelper from '../../components/precall/PermissionHelper'; +import {FocusProvider} from '../../utils/useFocus'; +import {VideoCallProvider} from '../../components/useVideoCall'; +import {CaptionProvider} from '../../subComponents/caption/useCaption'; +import SdkMuteToggleListener from '../../components/SdkMuteToggleListener'; +import {NoiseSupressionProvider} from '../../app-state/useNoiseSupression'; +import {VideoQualityContextProvider} from '../../app-state/useVideoQuality'; +import {VBProvider} from '../../components/virtual-background/useVB'; +import {DisableChatProvider} from '../../components/disable-chat/useDisableChat'; +import {WaitingRoomProvider} from '../../components/contexts/WaitingRoomContext'; +import {ChatMessagesProvider} from '../../components/chat-messages/useChatMessages'; +import VideoCallScreenWrapper from './../video-call/VideoCallScreenWrapper'; +import {BeautyEffectProvider} from '../../components/beauty-effect/useBeautyEffects'; +import {UserActionMenuProvider} from '../../components/useUserActionMenu'; +import {RaiseHandProvider} from '../../components/raise-hand'; +import {BreakoutRoomProvider} from '../../components/breakout-room/context/BreakoutRoomContext'; +import {useSetBreakoutRoomInfo} from '../../components/room-info/useSetBreakoutRoomInfo'; +import {VideoCallContentProps} from './VideoCallContent'; +import BreakoutRoomEventsConfigure from '../../components/breakout-room/events/BreakoutRoomEventsConfigure'; +import {RTM_ROOMS} from '../../rtm/constants'; +import {BreakoutChannelJoinEventPayload} from '../../components/breakout-room/state/types'; + +interface BreakoutVideoCallProps extends VideoCallContentProps { + rtcProps: RtcPropsInterface; + breakoutJoinChannelDetails: BreakoutChannelJoinEventPayload['data']['data']; + onLeave: () => void; +} + +const BreakoutVideoCall: React.FC = ({ + rtcProps, + breakoutJoinChannelDetails, + onLeave, + callActive, + callbacks, + styleProps, +}) => { + const {setBreakoutRoomChannelInfo} = useSetBreakoutRoomInfo(); + const [isRecordingActive, setRecordingActive] = useState(false); + const [sttAutoStarted, setSttAutoStarted] = useState(false); + const [recordingAutoStarted, setRecordingAutoStarted] = useState(false); + const [breakoutRoomRTCProps, setBreakoutRoomRtcProps] = useState({ + ...rtcProps, + channel: breakoutJoinChannelDetails.channel_name, + uid: breakoutJoinChannelDetails.mainUser.uid as number, + token: breakoutJoinChannelDetails.mainUser.rtc, + rtm: breakoutJoinChannelDetails.mainUser.rtm, + screenShareUid: breakoutJoinChannelDetails?.screenShare.uid as number, + screenShareToken: breakoutJoinChannelDetails?.screenShare.rtc, + }); + + // Set breakout room data when component mounts + useEffect(() => { + setBreakoutRoomChannelInfo({ + isBreakoutMode: true, + ...breakoutJoinChannelDetails, + }); + }, [breakoutJoinChannelDetails]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + {!isMobileUA() && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default BreakoutVideoCall; diff --git a/template/src/pages/video-call/SidePanelHeader.tsx b/template/src/pages/video-call/SidePanelHeader.tsx index a173cdeac..d7f8a1626 100644 --- a/template/src/pages/video-call/SidePanelHeader.tsx +++ b/template/src/pages/video-call/SidePanelHeader.tsx @@ -38,6 +38,7 @@ import { vbPanelHeading, } from '../../language/default-labels/precallScreenLabels'; import { + breakoutRoomPanelHeaderText, chatPanelGroupTabText, chatPanelPrivateTabText, peoplePanelHeaderText, @@ -93,6 +94,22 @@ export const PeopleHeader = () => { ); }; +export const BreakoutRoomHeader = () => { + const headerText = useString(breakoutRoomPanelHeaderText)(); + const {setSidePanel} = useSidePanel(); + return ( + {headerText} + } + trailingIconName="close" + trailingIconOnPress={() => { + setSidePanel(SidePanelType.None); + }} + /> + ); +}; + export const ChatHeader = () => { const { unreadGroupMessageCount, diff --git a/template/src/pages/video-call/VideoCallContent.tsx b/template/src/pages/video-call/VideoCallContent.tsx new file mode 100644 index 000000000..cf336fcd0 --- /dev/null +++ b/template/src/pages/video-call/VideoCallContent.tsx @@ -0,0 +1,211 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useState, useEffect, useRef, useCallback} from 'react'; +import {useParams, useLocation, useHistory} from '../../components/Router'; +import events from '../../rtm-events-api'; +import {BreakoutChannelJoinEventPayload} from '../../components/breakout-room/state/types'; +import {CallbacksInterface, RtcPropsInterface} from '../../../agora-rn-uikit'; +import VideoCall from '../VideoCall'; +import BreakoutVideoCall from './BreakoutVideoCall'; +import {BreakoutRoomEventNames} from '../../components/breakout-room/events/constants'; +import BreakoutRoomTransition from '../../components/breakout-room/ui/BreakoutRoomTransition'; +import Toast from '../../../react-native-toast-message'; +import {useMainRoomUserDisplayName} from '../../rtm/hooks/useMainRoomUserDisplayName'; +import { + useSetBreakoutRoomInfo, + BreakoutRoomInfoProvider, +} from '../../components/room-info/useSetBreakoutRoomInfo'; + +export interface VideoCallContentProps { + callActive: boolean; + setCallActive: React.Dispatch>; + rtcProps: RtcPropsInterface; + setRtcProps: React.Dispatch>>; + callbacks: CallbacksInterface; + styleProps: any; +} + +const VideoCallContent: React.FC = props => { + const {phrase} = useParams<{phrase: string}>(); + const location = useLocation(); + const history = useHistory(); + + // Room Info: + const {setBreakoutRoomChannelInfo, clearBreakoutRoomChannelInfo} = + useSetBreakoutRoomInfo(); + + // Parse URL to determine current mode + const searchParams = new URLSearchParams(location.search); + const isBreakoutMode = searchParams.get('breakout') === 'true'; + + const breakoutTimeoutRef = useRef | null>(null); + + const mainRoomLocalUid = props.rtcProps.uid; + const getDisplayName = useMainRoomUserDisplayName(); + + // Breakout channel details (populated by RTM events) + const [breakoutJoinChannelDetails, setBreakoutJoinChannelDetails] = useState< + BreakoutChannelJoinEventPayload['data']['data'] | null + >(null); + + // Track transition direction for better UX + const [transitionDirection, setTransitionDirection] = useState< + 'enter' | 'exit' + >('exit'); + + // Listen for breakout room join events + useEffect(() => { + const handleBreakoutJoin = (evtData: any) => { + try { + // Clear any existing timeout + if (breakoutTimeoutRef.current) { + clearTimeout(breakoutTimeoutRef.current); + } + // Process the event payload + const {payload} = evtData; + const data: BreakoutChannelJoinEventPayload = JSON.parse(payload); + console.log('Breakout room join event received', data); + if (data?.data?.act === 'CHAN_JOIN') { + const {channel_name, mainUser, screenShare, chat, room_name} = + data.data.data; + + // Set transition flag - component will unmount/remount when entering breakout + sessionStorage.setItem('breakout_room_transition', 'true'); + console.log('Set breakout transition flag for channel join'); + + // Set breakout state active + history.push(`/${phrase}?breakout=true`); + setBreakoutJoinChannelDetails(null); + setTransitionDirection('enter'); // Set direction for entering + // Add state after a delay to show transitioning screen + breakoutTimeoutRef.current = setTimeout(() => { + setBreakoutJoinChannelDetails(prev => ({ + ...prev, + ...data.data.data, + })); + breakoutTimeoutRef.current = null; + }, 800); + let joinMessage = ''; + const sourceUid = data?.data?.srcuid; + const senderName = getDisplayName(sourceUid); + if (sourceUid === mainRoomLocalUid) { + joinMessage = `You have joined room "${room_name}".`; + } else { + joinMessage = `Host: ${senderName} has moved you to room "${room_name}".`; + } + setTimeout(() => { + Toast.show({ + leadingIconName: 'open-room', + type: 'success', + text1: joinMessage, + visibilityTime: 3000, + }); + }, 500); + } + } catch (error) { + console.error('Failed to process breakout join event'); + } + }; + + // Register breakout join event listener + events.on( + BreakoutRoomEventNames.BREAKOUT_ROOM_JOIN_DETAILS, + handleBreakoutJoin, + ); + + return () => { + // Cleanup event listener + events.off( + BreakoutRoomEventNames.BREAKOUT_ROOM_JOIN_DETAILS, + handleBreakoutJoin, + ); + }; + }, [ + phrase, + getDisplayName, + mainRoomLocalUid, + setBreakoutRoomChannelInfo, + history, + ]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (breakoutTimeoutRef.current) { + clearTimeout(breakoutTimeoutRef.current); + } + }; + }, []); + + // Handle leaving breakout room + const handleLeaveBreakout = useCallback(() => { + console.log('Leaving breakout room, returning to main room'); + + // Clear breakout room info to return to main room + clearBreakoutRoomChannelInfo(); + + // Set direction for exiting + setTransitionDirection('exit'); + // Clear breakout channel details to show transition + setBreakoutJoinChannelDetails(null); + // Navigate back to main room after a delay + setTimeout(() => { + history.push(`/${phrase}`); + }, 800); + }, [history, phrase, clearBreakoutRoomChannelInfo]); + + // Route protection: Prevent direct navigation to breakout route + useEffect(() => { + if (isBreakoutMode && !breakoutJoinChannelDetails) { + // If user navigated to breakout route without valid channel details, + // redirect to main room after a short delay to prevent infinite loops + const redirectTimer = setTimeout(() => { + console.log('Invalid breakout route access, redirecting to main room'); + history.replace(`/${phrase}`); // Use replace to prevent back navigation + }, 2000); // Give 2s for legitimate transitions + + return () => clearTimeout(redirectTimer); + } + }, [isBreakoutMode, breakoutJoinChannelDetails, history, phrase]); + + // Conditional rendering based on URL params + return ( + <> + {isBreakoutMode ? ( + breakoutJoinChannelDetails?.channel_name ? ( + // Breakout Room Mode - Fresh component instance + + + + ) : ( + { + setBreakoutJoinChannelDetails(null); + }} + /> + ) + ) : ( + // Main Room Mode - Fresh component instance + + )} + + ); +}; + +export default VideoCallContent; diff --git a/template/src/pages/video-call/VideoCallScreen.tsx b/template/src/pages/video-call/VideoCallScreen.tsx index 4093cc44e..06321398b 100644 --- a/template/src/pages/video-call/VideoCallScreen.tsx +++ b/template/src/pages/video-call/VideoCallScreen.tsx @@ -55,6 +55,7 @@ import {useIsRecordingBot} from '../../subComponents/recording/useIsRecordingBot import {ToolbarPresetProps} from '../../atoms/ToolbarPreset'; import CustomSidePanelView from '../../components/CustomSidePanel'; import {useControlPermissionMatrix} from '../../components/controls/useControlPermissionMatrix'; +import BreakoutRoomPanel from '../../components/breakout-room/BreakoutRoomPanel'; const VideoCallScreen = () => { useFindActiveSpeaker(); @@ -73,6 +74,7 @@ const VideoCallScreen = () => { VideocallComponent, BottombarComponent, ParticipantsComponent, + BreakoutRoomComponent, TranscriptComponent, CaptionComponent, VirtualBackgroundComponent, @@ -99,6 +101,7 @@ const VideoCallScreen = () => { CaptionComponent: React.ComponentType; VirtualBackgroundComponent: React.ComponentType; SettingsComponent: React.ComponentType; + BreakoutRoomComponent: React.ComponentType; TopbarComponent: React.ComponentType; VideocallBeforeView: React.ComponentType; VideocallAfterView: React.ComponentType; @@ -118,6 +121,7 @@ const VideoCallScreen = () => { CaptionComponent: CaptionContainer, VirtualBackgroundComponent: VBPanel, SettingsComponent: SettingsView, + BreakoutRoomComponent: BreakoutRoomPanel, VideocallAfterView: React.Fragment, VideocallBeforeView: React.Fragment, VideocallWrapper: React.Fragment, @@ -236,6 +240,15 @@ const VideoCallScreen = () => { data?.components?.videoCall.participantsPanel; } + if ( + data?.components?.videoCall.breakoutRoomPanel && + typeof data?.components?.videoCall.breakoutRoomPanel !== 'object' && + isValidReactComponent(data?.components?.videoCall.breakoutRoomPanel) + ) { + components.BreakoutRoomComponent = + data?.components?.videoCall.breakoutRoomPanel; + } + if ( data?.components?.videoCall.transcriptPanel && typeof data?.components?.videoCall.transcriptPanel !== 'object' && @@ -428,6 +441,11 @@ const VideoCallScreen = () => { ) : ( <> )} + {sidePanel === SidePanelType.BreakoutRoom ? ( + + ) : ( + <> + )} {sidePanel === SidePanelType.Transcript ? ( $config.ENABLE_MEETING_TRANSCRIPT ? ( diff --git a/template/src/pages/video-call/VideoCallScreenWrapper.tsx b/template/src/pages/video-call/VideoCallScreenWrapper.tsx index c8322837e..c53e2d45b 100644 --- a/template/src/pages/video-call/VideoCallScreenWrapper.tsx +++ b/template/src/pages/video-call/VideoCallScreenWrapper.tsx @@ -1,7 +1,6 @@ import React, {useContext, useEffect} from 'react'; import {PropsContext} from '../../../agora-rn-uikit'; import VideoCallScreen from '../video-call/VideoCallScreen'; -import {isWebInternal} from '../../utils/common'; import {useLocation} from '../../components/Router'; import {getParamFromURL} from '../../utils/common'; import {useUserPreference} from '../../components/useUserPreference'; diff --git a/template/src/pages/video-call/VideoCallStateWrapper.tsx b/template/src/pages/video-call/VideoCallStateWrapper.tsx new file mode 100644 index 000000000..c476cff43 --- /dev/null +++ b/template/src/pages/video-call/VideoCallStateWrapper.tsx @@ -0,0 +1,495 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +import React, {useState, useContext, useEffect, useRef} from 'react'; +import {View, StyleSheet, Text} from 'react-native'; +import {useCustomization} from 'customization-implementation'; +import { + ClientRoleType, + UidType, + CallbacksInterface, +} from '../../../agora-rn-uikit'; +import styles from '../../components/styles'; +import {ErrorContext} from '../../components/common/index'; +import {useWakeLock} from '../../components/useWakeLock'; +import {useParams, useHistory} from '../../components/Router'; +import StorageContext from '../../components/StorageContext'; +import {useSetRoomInfo} from '../../components/room-info/useSetRoomInfo'; +import {SdkApiContext} from '../../components/SdkApiContext'; +import { + useRoomInfo, + RoomInfoDefaultValue, + WaitingRoomStatus, +} from '../../components/room-info/useRoomInfo'; +import {useIsRecordingBot} from '../../subComponents/recording/useIsRecordingBot'; +import Logo from '../../subComponents/Logo'; +import SDKEvents from '../../utils/SdkEvents'; +import isSDK from '../../utils/isSDK'; +import {useHasBrandLogo} from '../../utils/common'; +import useJoinRoom from '../../utils/useJoinRoom'; +import {useString} from '../../utils/useString'; +import {AuthErrorCodes} from '../../utils/common'; +import { + userBannedText, + videoRoomStartingCallText, +} from '../../language/default-labels/videoCallScreenLabels'; +import {LogSource, logger} from '../../logger/AppBuilderLogger'; +import Toast from '../../../react-native-toast-message'; +import {RTMCoreProvider} from '../../rtm/RTMCoreProvider'; +import {videoView} from '../../../theme.json'; +import VideoCallContent from './VideoCallContent'; +import RTMGlobalStateProvider from '../../rtm/RTMGlobalStateProvider'; +import UserGlobalPreferenceProvider from '../../components/UserGlobalPreferenceProvider'; + +export enum RnEncryptionEnum { + /** + * @deprecated + * 0: This mode is deprecated. + */ + None = 0, + /** + * 1: (Default) 128-bit AES encryption, XTS mode. + */ + AES128XTS = 1, + /** + * 2: 128-bit AES encryption, ECB mode. + */ + AES128ECB = 2, + /** + * 3: 256-bit AES encryption, XTS mode. + */ + AES256XTS = 3, + /** + * 4: 128-bit SM4 encryption, ECB mode. + * + * @since v3.1.2. + */ + SM4128ECB = 4, + /** + * 6: 256-bit AES encryption, GCM mode. + * + * @since v3.1.2. + */ + AES256GCM = 6, + + /** + * 7: 128-bit GCM encryption, GCM mode. + * + * @since v3.4.5 + */ + AES128GCM2 = 7, + /** + * 8: 256-bit GCM encryption, GCM mode. + * @since v3.1.2. + * Compared to AES256GCM encryption mode, AES256GCM2 encryption mode is more secure and requires you to set the salt (encryptionKdfSalt). + */ + AES256GCM2 = 8, +} + +const VideoCallStateWrapper = () => { + const hasBrandLogo = useHasBrandLogo(); + const joiningLoaderLabel = useString(videoRoomStartingCallText)(); + const {isRecordingBot} = useIsRecordingBot(); + const {setRoomInfo} = useSetRoomInfo(); + const {setGlobalErrorMessage} = useContext(ErrorContext); + const bannedUserText = useString(userBannedText)(); + + /** + * Should we set the callscreen to active ?? + * a) If Recording bot( i.e prop: recordingBot) is TRUE then it means, + * the recording bot is accessing the screen - so YES we should set + * the callActive as true and we need not check for whether + * $config.PRECALL is enabled or not. + * b) If Recording bot( i.e prop: recordingBot) is FALSE then we should set + * the callActive depending upon the value of magic variable - $config.PRECALL + */ + const shouldCallBeSetToActive = isRecordingBot + ? true + : $config.PRECALL + ? false + : true; + const [callActive, setCallActive] = useState(shouldCallBeSetToActive); + const [queryComplete, setQueryComplete] = useState(false); + const [waitingRoomAttendeeJoined, setWaitingRoomAttendeeJoined] = + useState(false); + const {isJoinDataFetched, data, isInWaitingRoom, waitingRoomStatus} = + useRoomInfo(); + const {store} = useContext(StorageContext); + const { + join: SdkJoinState, + microphoneDevice: sdkMicrophoneDevice, + cameraDevice: sdkCameraDevice, + clearState, + } = useContext(SdkApiContext); + const useJoin = useJoinRoom(); + + const {phrase} = useParams<{phrase: string}>(); + const history = useHistory(); + const currentMeetingPhrase = useRef(history.location.pathname); + const {awake, release} = useWakeLock(); + + const [rtcProps, setRtcProps] = React.useState({ + appId: $config.APP_ID, + channel: null, + uid: null, + token: null, + rtm: null, + screenShareUid: null, + screenShareToken: null, + profile: $config.PROFILE, + screenShareProfile: $config.SCREEN_SHARE_PROFILE, + dual: true, + encryption: $config.ENCRYPTION_ENABLED + ? {key: null, mode: RnEncryptionEnum.AES128GCM2, screenKey: null} + : false, + role: ClientRoleType.ClientRoleBroadcaster, + geoFencing: $config.GEO_FENCING, + audioRoom: $config.AUDIO_ROOM, + activeSpeaker: $config.ACTIVE_SPEAKER, + preferredCameraId: + sdkCameraDevice.deviceId || store?.activeDeviceId?.videoinput || null, + preferredMicrophoneId: + sdkMicrophoneDevice.deviceId || store?.activeDeviceId?.audioinput || null, + recordingBot: isRecordingBot ? true : false, + }); + + React.useEffect(() => { + if ( + //isJoinDataFetched === true && (!queryComplete || !isInWaitingRoom) + //non waiting room - host/attendee + (!$config.ENABLE_WAITING_ROOM && + isJoinDataFetched === true && + !queryComplete) || + //waiting room - host + ($config.ENABLE_WAITING_ROOM && + isJoinDataFetched === true && + data.isHost && + !queryComplete) || + //waiting room - attendee + ($config.ENABLE_WAITING_ROOM && + isJoinDataFetched === true && + !data.isHost && + (!queryComplete || !isInWaitingRoom) && + !waitingRoomAttendeeJoined) + ) { + setRtcProps(prevRtcProps => ({ + ...prevRtcProps, + channel: data.channel, + uid: data.uid, + token: data.token, + rtm: data.rtmToken, + encryption: $config.ENCRYPTION_ENABLED + ? { + key: data.encryptionSecret, + mode: data.encryptionMode, + screenKey: data.encryptionSecret, + salt: data.encryptionSecretSalt, + } + : false, + screenShareUid: data.screenShareUid, + screenShareToken: data.screenShareToken, + role: data.isHost + ? ClientRoleType.ClientRoleBroadcaster + : ClientRoleType.ClientRoleAudience, + preventJoin: + !$config.ENABLE_WAITING_ROOM || + ($config.ENABLE_WAITING_ROOM && data.isHost) || + ($config.ENABLE_WAITING_ROOM && + !data.isHost && + waitingRoomStatus === WaitingRoomStatus.APPROVED) + ? false + : true, + })); + if ( + $config.ENABLE_WAITING_ROOM && + !data.isHost && + waitingRoomStatus === WaitingRoomStatus.APPROVED + ) { + setWaitingRoomAttendeeJoined(true); + } + // 1. Store the display name from API + // if (data.username) { + // setUsername(data.username); + // } + setQueryComplete(true); + } + }, [isJoinDataFetched, data, queryComplete]); + + useEffect(() => { + if (!isJoinDataFetched) { + return; + } + logger.log(LogSource.Internals, 'SET_MEETING_DETAILS', 'Room details', { + user_id: data?.uid || '', + meeting_title: data?.meetingTitle || '', + channel_id: data?.channel, + host_id: data?.roomId?.host || '', + attendee_id: data?.roomId?.attendee || '', + }); + }, [isJoinDataFetched, data, phrase]); + + // SDK related code + useEffect(() => { + if (!isSDK() || !SdkJoinState.initialized) { + return; + } + const { + phrase: sdkMeetingPhrase, + meetingDetails: sdkMeetingDetails, + skipPrecall, + promise, + preference, + } = SdkJoinState; + + const sdkMeetingPath = `/${sdkMeetingPhrase}`; + + setCallActive(skipPrecall); + + if (sdkMeetingDetails) { + setQueryComplete(false); + setRoomInfo(roomInfo => { + return { + ...roomInfo, + isJoinDataFetched: true, + data: { + ...roomInfo.data, + ...sdkMeetingDetails, + }, + roomPreference: preference, + }; + }); + } else if (sdkMeetingPhrase) { + setQueryComplete(false); + currentMeetingPhrase.current = sdkMeetingPath; + useJoin(sdkMeetingPhrase, preference) + .then(() => { + logger.log( + LogSource.Internals, + 'JOIN_MEETING', + 'Join channel success', + ); + }) + .catch(error => { + const errorCode = error?.code; + if (AuthErrorCodes.indexOf(errorCode) !== -1 && isSDK()) { + SDKEvents.emit('unauthorized', error); + } + logger.error( + LogSource.Internals, + 'JOIN_MEETING', + 'Join channel error', + JSON.stringify(error || {}), + ); + setGlobalErrorMessage(error); + history.push('/'); + currentMeetingPhrase.current = ''; + promise.rej(error); + }); + } + }, [SdkJoinState]); + + useEffect(() => { + if (!SdkJoinState?.phrase) { + useJoin(phrase, RoomInfoDefaultValue.roomPreference) + .then(() => { + logger.log( + LogSource.Internals, + 'JOIN_MEETING', + 'Join channel success', + ); + }) + .catch(error => { + const errorCode = error?.code; + if (AuthErrorCodes.indexOf(errorCode) !== -1 && isSDK()) { + SDKEvents.emit('unauthorized', error); + } + logger.error( + LogSource.Internals, + 'JOIN_MEETING', + 'Join channel error', + JSON.stringify(error || {}), + ); + setGlobalErrorMessage(error); + history.push('/'); + }); + } + }, []); + + React.useEffect(() => { + return () => { + logger.debug( + LogSource.Internals, + 'VIDEO_CALL_ROOM', + 'Videocall unmounted', + ); + setRoomInfo(prevState => { + return { + ...RoomInfoDefaultValue, + loginToken: prevState?.loginToken, + }; + }); + if (awake) { + release(); + } + }; + }, []); + + // commented for v1 release + const afterEndCall = useCustomization( + data => + data?.lifecycle?.useAfterEndCall && data?.lifecycle?.useAfterEndCall(), + ); + + const callbacks: CallbacksInterface = { + // RtcLeft: () => {}, + // RtcJoined: () => { + // if (SdkJoinState.phrase && SdkJoinState.skipPrecall) { + // SdkJoinState.promise?.res(); + // } + // }, + EndCall: () => { + clearState('join'); + setTimeout(() => { + // TODO: These callbacks are being called twice + SDKEvents.emit('leave'); + if (afterEndCall) { + afterEndCall(data.isHost, history as unknown as History); + } else { + history.push('/'); + } + }, 0); + }, + // @ts-ignore + UserJoined: (uid: UidType) => { + console.log('UIKIT Callback: UserJoined', uid); + SDKEvents.emit('rtc-user-joined', uid); + }, + // @ts-ignore + UserOffline: (uid: UidType) => { + console.log('UIKIT Callback: UserOffline', uid); + SDKEvents.emit('rtc-user-left', uid); + }, + // @ts-ignore + RemoteAudioStateChanged: (uid: UidType, status: 0 | 2) => { + console.log('UIKIT Callback: RemoteAudioStateChanged', uid, status); + if (status === 0) { + SDKEvents.emit('rtc-user-unpublished', uid, 'audio'); + } else { + SDKEvents.emit('rtc-user-published', uid, 'audio'); + } + }, + // @ts-ignore + RemoteVideoStateChanged: (uid: UidType, status: 0 | 2) => { + console.log('UIKIT Callback: RemoteVideoStateChanged', uid, status); + if (status === 0) { + SDKEvents.emit('rtc-user-unpublished', uid, 'video'); + } else { + SDKEvents.emit('rtc-user-published', uid, 'video'); + } + }, + // @ts-ignore + UserBanned(isBanned) { + console.log('UIKIT Callback: UserBanned', isBanned); + Toast.show({ + leadingIconName: 'alert', + type: 'error', + text1: bannedUserText, + visibilityTime: 3000, + }); + }, + }; + + return ( + <> + {queryComplete ? ( + queryComplete || !callActive ? ( + + + + + + + + ) : ( + + {hasBrandLogo() && } + {joiningLoaderLabel} + + ) + ) : ( + <> + )} + + ); +}; + +const styleProps = { + maxViewStyles: styles.temp, + minViewStyles: styles.temp, + localBtnContainer: styles.bottomBar, + localBtnStyles: { + muteLocalAudio: styles.localButton, + muteLocalVideo: styles.localButton, + switchCamera: styles.localButton, + endCall: styles.endCall, + fullScreen: styles.localButton, + recording: styles.localButton, + screenshare: styles.localButton, + }, + theme: $config.PRIMARY_ACTION_BRAND_COLOR, + remoteBtnStyles: { + muteRemoteAudio: styles.remoteButton, + muteRemoteVideo: styles.remoteButton, + remoteSwap: styles.remoteButton, + minCloseBtnStyles: styles.minCloseBtn, + liveStreamHostControlBtns: styles.liveStreamHostControlBtns, + }, + BtnStyles: styles.remoteButton, +}; +//change these to inline styles or sth +const style = StyleSheet.create({ + full: { + flex: 1, + flexDirection: 'column', + overflow: 'hidden', + }, + videoView: videoView, + loader: { + flex: 1, + alignSelf: 'center', + justifyContent: 'center', + }, + loaderLogo: { + alignSelf: 'center', + justifyContent: 'center', + marginBottom: 30, + }, + loaderText: {fontWeight: '500', color: $config.FONT_COLOR}, +}); + +export default VideoCallStateWrapper; diff --git a/template/src/rtm-events-api/Events.ts b/template/src/rtm-events-api/Events.ts index 90d0cdfa3..3cb63e305 100644 --- a/template/src/rtm-events-api/Events.ts +++ b/template/src/rtm-events-api/Events.ts @@ -11,9 +11,14 @@ */ ('use strict'); -import RtmEngine from 'agora-react-native-rtm'; +import {type RTMClient} from 'agora-react-native-rtm'; import RTMEngine from '../rtm/RTMEngine'; -import {EventUtils} from '../rtm-events'; +import { + EventUtils, + RTM_EVENT_SCOPE, + RTM_GLOBAL_SCOPE_EVENTS, + RTM_SESSION_SCOPE_EVENTS, +} from '../rtm-events'; import { ReceiverUid, EventCallback, @@ -23,6 +28,17 @@ import { } from './types'; import {adjustUID} from '../rtm/utils'; import {LogSource, logger} from '../logger/AppBuilderLogger'; +import {nativeChannelTypeMapping} from '../../bridge/rtm/web/Types'; + +function getRTMEventScope(eventName: string): RTM_EVENT_SCOPE { + if (RTM_GLOBAL_SCOPE_EVENTS.includes(eventName)) { + return RTM_EVENT_SCOPE.GLOBAL; + } + if (RTM_SESSION_SCOPE_EVENTS.includes(eventName)) { + return RTM_EVENT_SCOPE.SESSION; + } + return RTM_EVENT_SCOPE.LOCAL; +} class Events { private source: EventSource = EventSource.core; @@ -40,12 +56,24 @@ class Events { * @param {String} payload to be stored in rtm Attribute value * @api private */ - private _persist = async (evt: string, payload: string) => { - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; + private _persist = async (evt: string, payload: string, roomKey?: string) => { + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + const userId = RTMEngine.getInstance().localUid; try { + // const roomAwareKey = roomKey ? `${roomKey}__${evt}` : evt; + // console.log( + // 'session-attributes setting roomAwareKey as: ', + // roomAwareKey, + // evt, + // ); const rtmAttribute = {key: evt, value: payload}; // Step 1: Call RTM API to update local attributes - await rtmEngine.addOrUpdateLocalUserAttributes([rtmAttribute]); + await rtmEngine.storage.setUserMetadata( + {items: [rtmAttribute]}, + { + userId, + }, + ); } catch (error) { logger.error( LogSource.Events, @@ -68,8 +96,8 @@ class Events { `CUSTOM_EVENT_API Event name cannot be of type ${typeof evt}`, ); } - if (evt.trim() == '') { - throw Error(`CUSTOM_EVENT_API Name or function cannot be empty`); + if (evt.trim() === '') { + throw Error('CUSTOM_EVENT_API Name or function cannot be empty'); } return true; }; @@ -97,16 +125,23 @@ class Events { * * @param {Object} rtmPayload payload to be sent across * @param {ReceiverUid} toUid uid or uids[] of user + * @param {string} channelId optional specific channel ID, defaults to primary channel * @api private */ private _send = async ( rtmPayload: RTMAttributePayload, toUid?: ReceiverUid, + toChannelId?: string, ) => { - const to = typeof toUid == 'string' ? parseInt(toUid) : toUid; - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; + const to = typeof toUid === 'string' ? parseInt(toUid, 10) : toUid; const text = JSON.stringify(rtmPayload); + + if (!RTMEngine.getInstance().isEngineReady) { + throw new Error('RTM Engine is not ready. Call setLocalUID() first.'); + } + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + // Case 1: send to channel if ( typeof to === 'undefined' || @@ -119,8 +154,22 @@ class Events { 'case 1 executed - sending in channel', ); try { - const channelId = RTMEngine.getInstance().channelUid; - await rtmEngine.sendMessageByChannelId(channelId, text); + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'event is sent to targetChannelId ->', + toChannelId, + ); + if (!toChannelId || toChannelId.trim() === '') { + throw new Error( + 'Channel ID is not set. Cannot send channel messages.', + ); + } + await rtmEngine.publish(toChannelId, text, { + channelType: nativeChannelTypeMapping.MESSAGE, // 1 is message + // customType: 'PlainText', + // messageType: RtmMessageType.string, + }); } catch (error) { logger.error( LogSource.Events, @@ -140,10 +189,10 @@ class Events { ); const adjustedUID = adjustUID(to); try { - await rtmEngine.sendMessageToPeer({ - peerId: `${adjustedUID}`, - offline: false, - text, + await rtmEngine.publish(`${adjustedUID}`, text, { + channelType: nativeChannelTypeMapping.USER, // user + customType: 'PlainText', + messageType: 1, }); } catch (error) { logger.error( @@ -164,14 +213,32 @@ class Events { to, ); try { - for (const uid of to) { - const adjustedUID = adjustUID(uid); - await rtmEngine.sendMessageToPeer({ - peerId: `${adjustedUID}`, - offline: false, - text, - }); - } + const response = await Promise.allSettled( + to.map(uid => + rtmEngine.publish(`${adjustUID(uid)}`, text, { + channelType: nativeChannelTypeMapping.USER, + customType: 'PlainText', + messageType: 1, + }), + ), + ); + response.forEach((result, index) => { + const uid = to[index]; + if (result.status === 'rejected') { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + `Failed to publish to user ${uid}:`, + result.reason, + ); + } + }); + // for (const uid of to) { + // const adjustedUID = adjustUID(uid); + // await rtmEngine.publish(`${adjustedUID}`, text, { + // channelType: 3, // user + // }); + // } } catch (error) { logger.error( LogSource.Events, @@ -184,7 +251,10 @@ class Events { } }; - private _sendAsChannelAttribute = async (rtmPayload: RTMAttributePayload) => { + private _sendAsChannelAttribute = async ( + rtmPayload: RTMAttributePayload, + toChannelId?: string, + ) => { // Case 1: send to channel logger.debug( LogSource.Events, @@ -192,13 +262,28 @@ class Events { 'updating channel attributes', ); try { - const rtmEngine: RtmEngine = RTMEngine.getInstance().engine; - const channelId = RTMEngine.getInstance().channelUid; + // Validate if rtmengine is ready + if (!RTMEngine.getInstance().isEngineReady) { + throw new Error('RTM Engine is not ready. Call setLocalUID() first.'); + } + const rtmEngine: RTMClient = RTMEngine.getInstance().engine; + + if (!toChannelId) { + throw new Error('Channel ID is not set. Cannot send channel messages.'); + } + const rtmAttribute = [{key: rtmPayload.evt, value: rtmPayload.value}]; - // Step 1: Call RTM API to update local attributes - await rtmEngine.addOrUpdateChannelAttributes(channelId, rtmAttribute, { - enableNotificationToChannelMembers: true, - }); + await rtmEngine.storage.setChannelMetadata( + toChannelId, + nativeChannelTypeMapping.MESSAGE, + { + items: rtmAttribute, + }, + { + addUserId: true, + addTimeStamp: true, + }, + ); } catch (error) { logger.error( LogSource.Events, @@ -223,7 +308,8 @@ class Events { on = (eventName: string, listener: EventCallback): Function => { try { if (!this._validateEvt(eventName) || !this._validateListener(listener)) { - return; + // Return no-op function instead of undefined to prevent errors + return () => {}; } EventUtils.addListener(eventName, listener, this.source); console.log('CUSTOM_EVENT_API event listener registered', eventName); @@ -238,6 +324,8 @@ class Events { 'Error: events.on', error, ); + // Return no-op function on error to prevent undefined issues + return () => {}; } }; @@ -253,7 +341,11 @@ class Events { off = (eventName?: string, listener?: EventCallback) => { try { if (listener) { - if (this._validateListener(listener) && this._validateEvt(eventName)) { + if ( + eventName && + this._validateListener(listener) && + this._validateEvt(eventName) + ) { // listen off an event by eventName and listener //@ts-ignore EventUtils.removeListener(eventName, listener, this.source); @@ -287,6 +379,7 @@ class Events { * @param {String} payload (optional) Additional data to be sent along with the event. * @param {Enum} persistLevel (optional) set different levels of persistance. Default value is Level 1 * @param {ReceiverUid} receiver (optional) uid or uid array. Default mode sends message in channel. + * @param {String} channelId (optional) specific channel to send to, defaults to primary channel. * @api public * */ send = async ( @@ -294,17 +387,34 @@ class Events { payload: string = '', persistLevel: PersistanceLevel = PersistanceLevel.None, receiver: ReceiverUid = -1, + toChannelId?: string, ) => { - if (!this._validateEvt(eventName)) { - return; + try { + if (!this._validateEvt(eventName)) { + return; + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'Event validation failed', + error, + ); + return; // Don't throw - just log and return } + // Add meta data + let currentEventScope = getRTMEventScope(eventName); + let currentChannelId = RTMEngine.getInstance().getActiveChannelId(); + let currentRoomKey = RTMEngine.getInstance().getActiveChannelName(); + const persistValue = JSON.stringify({ payload, persistLevel, source: this.source, + _scope: currentEventScope, + _channelId: currentChannelId, }); - const rtmPayload: RTMAttributePayload = { evt: eventName, value: persistValue, @@ -315,9 +425,16 @@ class Events { persistLevel === PersistanceLevel.Session ) { try { - await this._persist(eventName, persistValue); + await this._persist( + eventName, + persistValue, + persistLevel === PersistanceLevel.Session + ? currentRoomKey + : undefined, + ); } catch (error) { logger.error(LogSource.Events, 'CUSTOM_EVENTS', 'persist error', error); + // don't throw - just log the error, application should continue running } } try { @@ -327,18 +444,20 @@ class Events { `sending event -> ${eventName}`, persistValue, ); + const targetChannelId = toChannelId || currentChannelId; if (persistLevel === PersistanceLevel.Channel) { - await this._sendAsChannelAttribute(rtmPayload); + await this._sendAsChannelAttribute(rtmPayload, targetChannelId); } else { - await this._send(rtmPayload, receiver); + await this._send(rtmPayload, receiver, targetChannelId); } } catch (error) { logger.error( LogSource.Events, 'CUSTOM_EVENTS', - 'sending event failed', + `Failed to send event '${eventName}' - event lost`, error, ); + // don't throw - just log the error, application should continue running } }; } diff --git a/template/src/rtm-events/constants.ts b/template/src/rtm-events/constants.ts index f65b84b4e..26e26f0f7 100644 --- a/template/src/rtm-events/constants.ts +++ b/template/src/rtm-events/constants.ts @@ -1,4 +1,7 @@ /** ***** EVENTS ACTIONS BEGINS***** */ + +import {BreakoutRoomEventNames} from '../components/breakout-room/events/constants'; + // 1. SCREENSHARE const SCREENSHARE_STARTED = 'SCREENSHARE_STARTED'; const SCREENSHARE_STOPPED = 'SCREENSHARE_STOPPED'; @@ -16,6 +19,7 @@ const RECORDING_STATE_ATTRIBUTE = 'recording_state'; const RECORDING_STARTED_BY_ATTRIBUTE = 'recording_started_by'; // 2. SCREENSHARE const SCREENSHARE_ATTRIBUTE = 'screenshare'; + // 2. LIVE STREAMING const RAISED_ATTRIBUTE = 'raised'; const ROLE_ATTRIBUTE = 'role'; @@ -40,6 +44,14 @@ const BOARD_COLOR_CHANGED = 'BOARD_COLOR_CHANGED'; const WHITEBOARD_LAST_IMAGE_UPLOAD_POSITION = 'WHITEBOARD_L_I_U_P'; const RECORDING_DELETED = 'RECORDING_DELETED'; const SPOTLIGHT_USER_CHANGED = 'SPOTLIGHT_USER_CHANGED'; +// 9. General raise hand +// Later on we will only have one raise hand i.e which will tied with livestream ad breakout +const BREAKOUT_RAISE_HAND_ATTRIBUTE = 'breakout_raise_hand'; +// 10. Cross-room raise hand notifications (messages, not attributes) +const CROSS_ROOM_RAISE_HAND_NOTIFICATION = 'cross_room_raise_hand_notification'; +// 11. Breakout room presenter attribute +const BREAKOUT_PRESENTER_ATTRIBUTE = 'breakout_presenter'; + const EventNames = { RECORDING_STATE_ATTRIBUTE, RECORDING_STARTED_BY_ATTRIBUTE, @@ -61,7 +73,34 @@ const EventNames = { WHITEBOARD_LAST_IMAGE_UPLOAD_POSITION, RECORDING_DELETED, SPOTLIGHT_USER_CHANGED, + BREAKOUT_RAISE_HAND_ATTRIBUTE, + CROSS_ROOM_RAISE_HAND_NOTIFICATION, + BREAKOUT_PRESENTER_ATTRIBUTE, }; /** ***** EVENT NAMES ENDS ***** */ -export {EventActions, EventNames}; +/** SCOPE OF EVENTS */ +const RTM_GLOBAL_SCOPE_EVENTS = [ + EventNames.NAME_ATTRIBUTE, + EventNames.CROSS_ROOM_RAISE_HAND_NOTIFICATION, + BreakoutRoomEventNames.BREAKOUT_ROOM_MAKE_PRESENTER, +]; +const RTM_SESSION_SCOPE_EVENTS = []; +// const RTM_SESSION_SCOPE_EVENTS = [ +// EventNames.RECORDING_STATE_ATTRIBUTE, +// EventNames.RECORDING_STARTED_BY_ATTRIBUTE, +// ]; + +enum RTM_EVENT_SCOPE { + GLOBAL = 'GLOBAL', // These event-attributes dont change ex: name, screenuid even when room chanes (main -> breakout) + SESSION = 'SESSION', // These event-attributes are stored as per channel but there needs to be persistance..when user returns to main room..he should have the state of that channel ex: recording, whiteboard active + LOCAL = 'LOCAL', // These event-attributes are specific to channel and can be reseted or removed, ex: raise_hand, screenshare +} + +export { + EventActions, + EventNames, + RTM_GLOBAL_SCOPE_EVENTS, + RTM_EVENT_SCOPE, + RTM_SESSION_SCOPE_EVENTS, +}; diff --git a/template/src/rtm/RTMConfigureBreakoutRoomProvider.tsx b/template/src/rtm/RTMConfigureBreakoutRoomProvider.tsx new file mode 100644 index 000000000..87daf9b24 --- /dev/null +++ b/template/src/rtm/RTMConfigureBreakoutRoomProvider.tsx @@ -0,0 +1,882 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the β€œMaterials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, { + useState, + useContext, + useEffect, + useRef, + createContext, + useCallback, +} from 'react'; +import { + type MessageEvent, + type PresenceEvent, + type SetOrUpdateUserMetadataOptions, + type StorageEvent, + type RTMClient, +} from 'agora-react-native-rtm'; +import { + ContentInterface, + DispatchContext, + useLocalUid, +} from '../../agora-rn-uikit'; +import {Platform} from 'react-native'; +import {isAndroid, isIOS} from '../utils/common'; +import {useContent} from 'customization-api'; +import { + safeJsonParse, + timeNow, + hasJsonStructure, + getMessageTime, + get32BitUid, + isEventForActiveChannel, +} from '../rtm/utils'; +import { + fetchChannelAttributesWithRetries, + clearRoomScopedUserAttributes, + processUserAttributeForQueue, +} from './rtm-presence-utils'; +import {EventUtils, EventsQueue} from '../rtm-events'; +import {PersistanceLevel} from '../rtm-events-api'; +import RTMEngine from '../rtm/RTMEngine'; +import {filterObject} from '../utils'; +import {useAsyncEffect} from '../utils/useAsyncEffect'; +import { + WaitingRoomStatus, + useRoomInfo, +} from '../components/room-info/useRoomInfo'; +import LocalEventEmitter, { + LocalEventsEnum, +} from '../rtm-events-api/LocalEvents'; +import {controlMessageEnum} from '../components/ChatContext'; +import {LogSource, logger} from '../logger/AppBuilderLogger'; +import {RECORDING_BOT_UID} from '../utils/constants'; +import { + nativeChannelTypeMapping, + nativePresenceEventTypeMapping, + nativeStorageEventTypeMapping, +} from '../../bridge/rtm/web/Types'; +import {useRTMCore} from '../rtm/RTMCoreProvider'; +import { + RTM_ROOMS, + RTM_EVENTS_ATTRIBUTES_TO_RESET_WHEN_ROOM_CHANGES, +} from './constants'; +import {useUserGlobalPreferences} from '../components/UserGlobalPreferenceProvider'; +import {ToggleState} from '../../agora-rn-uikit'; +import useMuteToggleLocal from '../utils/useMuteToggleLocal'; +import {useRtc} from 'customization-api'; +import { + fetchOnlineMembersWithRetries, + fetchUserAttributesWithRetries, + mapUserAttributesToState, +} from './rtm-presence-utils'; + +export enum UserType { + ScreenShare = 'screenshare', +} + +const eventTimeouts = new Map>(); + +// RTM Breakout Room Context +export interface RTMBreakoutRoomData { + hasUserJoinedRTM: boolean; + isInitialQueueCompleted: boolean; + onlineUsersCount: number; + rtmInitTimstamp: number; + syncUserState: (uid: number, data: Partial) => void; +} + +const RTMBreakoutRoomContext = createContext({ + hasUserJoinedRTM: false, + isInitialQueueCompleted: false, + onlineUsersCount: 0, + rtmInitTimstamp: 0, + syncUserState: () => {}, +}); + +export const useRTMConfigureBreakout = () => { + const context = useContext(RTMBreakoutRoomContext); + if (!context) { + throw new Error( + 'useRTMConfigureBreakout must be used within RTMConfigureBreakoutRoomProvider', + ); + } + return context; +}; + +interface RTMConfigureBreakoutRoomProviderProps { + callActive: boolean; + children: React.ReactNode; + currentChannel: string; +} + +const RTMConfigureBreakoutRoomProvider = ( + props: RTMConfigureBreakoutRoomProviderProps, +) => { + const rtmInitTimstamp = new Date().getTime(); + const localUid = useLocalUid(); + const {callActive, currentChannel} = props; + const {dispatch} = useContext(DispatchContext); + const {defaultContent, activeUids} = useContent(); + console.log('rudra-core-client: activeUids: ', activeUids); + const { + waitingRoomStatus, + data: {isHost}, + } = useRoomInfo(); + const {applyUserPreferences, syncUserPreferences} = + useUserGlobalPreferences(); + const toggleMute = useMuteToggleLocal(); + const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); + const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); + const [onlineUsersCount, setTotalOnlineUsers] = useState(0); + + // Track RTM connection state (equivalent to v1.5x connectionState check) + const {client, isLoggedIn, registerCallbacks, unregisterCallbacks} = + useRTMCore(); + const {rtcTracksReady} = useRtc(); + + /** + * Refs + */ + + const isRTMMounted = useRef(true); + + const hasInitRef = useRef(false); + const subscribeTimerRef: any = useRef(5); + const subscribeTimeoutRef = useRef | null>( + null, + ); + + const channelAttributesTimerRef: any = useRef(5); + const channelAttributesTimeoutRef = useRef | null>(null); + + const membersTimerRef: any = useRef(5); + const membersTimeoutRef = useRef | null>(null); + + const isHostRef = useRef({isHost: isHost}); + useEffect(() => { + isHostRef.current.isHost = isHost; + }, [isHost]); + + const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); + useEffect(() => { + waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; + }, [waitingRoomStatus]); + + const activeUidsRef = useRef({activeUids: activeUids}); + useEffect(() => { + activeUidsRef.current.activeUids = activeUids; + }, [activeUids]); + + const defaultContentRef = useRef(defaultContent); + useEffect(() => { + defaultContentRef.current = defaultContent; + }, [defaultContent]); + + // Apply user preferences when breakout room mounts + useEffect(() => { + if (rtcTracksReady) { + console.log('supriya-permissions', defaultContentRef.current[localUid]); + applyUserPreferences(defaultContentRef.current[localUid], toggleMute); + } + }, [rtcTracksReady]); + + // Sync current audio/video state audio video changes + useEffect(() => { + const userData = defaultContent[localUid]; + if (rtcTracksReady && userData) { + console.log('UP: syncing userData: ', userData); + const preferences = { + audioMuted: userData.audio === ToggleState.disabled, + videoMuted: userData.video === ToggleState.disabled, + }; + console.log('UP: saved preferences: ', preferences); + syncUserPreferences(preferences); + } + }, [defaultContent, localUid, syncUserPreferences, rtcTracksReady]); + + const syncUserState = useCallback( + (uid: number, data: Partial) => { + dispatch({type: 'UpdateRenderList', value: [uid, data]}); + }, + [dispatch], + ); + + // Set online users + React.useEffect(() => { + setTotalOnlineUsers( + Object.keys( + filterObject( + defaultContent, + ([k, v]) => + v?.type === 'rtc' && + !v.offline && + activeUidsRef.current.activeUids.indexOf(v?.uid) !== -1, + ), + ).length, + ); + }, [defaultContent, activeUids]); + + const init = async () => { + await subscribeChannel(); + await getMembersWithAttributes(); + await getChannelAttributes(); + const result = await RTMEngine.getInstance().engine.presence.whereNow( + `${localUid}`, + ); + console.log('rudra-core-client: user is now at channels ', result); + setHasUserJoinedRTM(true); + await runQueuedEvents(); + setIsInitialQueueCompleted(true); + logger.log(LogSource.AgoraSDK, 'Log', 'RTM queued events finished running'); + }; + + const subscribeChannel = async () => { + try { + if (RTMEngine.getInstance().allChannelIds.includes(currentChannel)) { + logger.debug( + LogSource.AgoraSDK, + 'Log', + '🚫 RTM already subscribed channel skipping', + currentChannel, + ); + const channelids = RTMEngine.getInstance().allChannelIds; + console.log('rudra-core-client: alreadt subscribed', channelids); + } else { + await client.subscribe(currentChannel, { + withMessage: true, + withPresence: true, + withMetadata: true, + withLock: false, + }); + logger.log(LogSource.AgoraSDK, 'API', 'RTM subscribeChannel', { + data: currentChannel, + }); + + // Set channel ID AFTER successful subscribe (like v1.5x) + console.log('setting primary channel', currentChannel); + RTMEngine.getInstance().addChannel(RTM_ROOMS.BREAKOUT, currentChannel); + RTMEngine.getInstance().setActiveChannelName(RTM_ROOMS.BREAKOUT); + + // Clear any pending retry timeout since we succeeded + if (subscribeTimeoutRef.current) { + clearTimeout(subscribeTimeoutRef.current); + subscribeTimeoutRef.current = null; + } + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM subscribeChannel failed..Trying again', + {error}, + ); + subscribeTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + subscribeTimerRef.current = Math.min(subscribeTimerRef.current * 2, 30); + subscribeChannel(); + }, subscribeTimerRef.current * 1000); + } + }; + + const getMembersWithAttributes = async () => { + try { + logger.log( + LogSource.AgoraSDK, + 'API', + 'RTM presence.getOnlineUsers(getMembers) start', + ); + console.log( + 'rudra-core-client: fetchOnlineMembersWithRetries inside breakout room ', + client, + currentChannel, + ); + const {allMembers, totalOccupancy} = await fetchOnlineMembersWithRetries( + client, + currentChannel, + { + onPage: async ({occupants, pageToken}) => { + console.log( + 'rudra-core-client: fetching user attributes for page: ', + pageToken, + occupants, + ); + await Promise.all( + occupants.map(async member => { + try { + const userAttributes = await fetchUserAttributesWithRetries( + client, + member.userId, + { + isMounted: () => isRTMMounted.current, + // πŸ‘ˆ called later if name arrives + onNameFound: retriedAttributesWithName => + mapUserAttributesToState( + retriedAttributesWithName, + member.userId, + syncUserState, + ), + }, + ); + console.log( + `rudra-core-client: getting user attributes for user ${member.userId}`, + userAttributes, + ); + mapUserAttributesToState( + userAttributes, + member.userId, + syncUserState, + ); + // setting screenshare data + // name of the screenUid, isActive: false, (when the user starts screensharing it becomes true) + // isActive to identify all active screenshare users in the call + userAttributes?.items?.forEach(item => { + processUserAttributeForQueue( + item, + member.userId, + RTM_ROOMS.BREAKOUT, + (eventKey, value, userId) => { + const data = {evt: eventKey, value}; + EventsQueue.enqueue({ + data, + uid: userId, + ts: timeNow(), + }); + }, + ); + }); + } catch (e) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `RTM Could not retrieve name of ${member.userId}`, + {error: e}, + ); + } + }), + ); + }, + }, + ); + console.log( + 'rudra-core-client: totalOccupancy', + allMembers, + totalOccupancy, + ); + logger.debug( + LogSource.AgoraSDK, + 'Log', + 'RTM fetched all data and user attr...RTM init done', + ); + membersTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (membersTimeoutRef.current) { + clearTimeout(membersTimeoutRef.current); + membersTimeoutRef.current = null; + } + } catch (error) { + membersTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + membersTimerRef.current = Math.min(membersTimerRef.current * 2, 30); + await getMembersWithAttributes(); + }, membersTimerRef.current * 1000); + } + }; + + const getChannelAttributes = async () => { + try { + await fetchChannelAttributesWithRetries( + client, + currentChannel, + eventData => EventsQueue.enqueue(eventData), + ); + channelAttributesTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (channelAttributesTimeoutRef.current) { + clearTimeout(channelAttributesTimeoutRef.current); + channelAttributesTimeoutRef.current = null; + } + } catch (error) { + channelAttributesTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + channelAttributesTimerRef.current = Math.min( + channelAttributesTimerRef.current * 2, + 30, + ); + getChannelAttributes(); + }, channelAttributesTimerRef.current * 1000); + } + }; + + const runQueuedEvents = async () => { + try { + while (!EventsQueue.isEmpty()) { + const currEvt = EventsQueue.dequeue(); + await eventDispatcher(currEvt.data, `${currEvt.uid}`, currEvt.ts); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while running queue events', + {error}, + ); + } + }; + + const eventDispatcher = async ( + data: { + evt: string; + value: string; + feat?: string; + etyp?: string; + }, + sender: string, + ts: number, + ) => { + console.log( + LogSource.Events, + 'CUSTOM_EVENTS', + 'inside eventDispatcher ', + data, + ); + console.log('supriya rtm [BREAKOUT] dispatcher: ', data); + + let evt = '', + value = ''; + + if (data?.feat === 'BREAKOUT_ROOM') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + data: data.data, + action: data.act, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } else if (data?.feat === 'WAITING_ROOM') { + if (data?.etyp === 'REQUEST') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + attendee_uid: data.data.data.attendee_uid, + attendee_screenshare_uid: data.data.data.attendee_screenshare_uid, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + if (data?.etyp === 'RESPONSE') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + approved: data.data.data.approved, + channelName: data.data.data.channel_name, + mainUser: data.data.data.mainUser, + screenShare: data.data.data.screenShare, + whiteboard: data.data.data.whiteboard, + chat: data.data.data?.chat, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + } else { + if ( + $config.ENABLE_WAITING_ROOM && + !isHostRef.current?.isHost && + waitingRoomStatusRef.current?.waitingRoomStatus !== + WaitingRoomStatus.APPROVED + ) { + if ( + data.evt === controlMessageEnum.muteAudio || + data.evt === controlMessageEnum.muteVideo + ) { + return; + } else { + evt = data.evt; + value = data.value; + } + } else { + evt = data.evt; + value = data.value; + } + } + + try { + let parsedValue; + try { + parsedValue = typeof value === 'string' ? JSON.parse(value) : value; + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'RTM Failed to parse event value in event dispatcher:', + {error}, + ); + return; + } + const {payload, persistLevel, source, _scope, _channelId} = parsedValue; + + console.log('supriya rtm [BREAKOUT] event data', data); + console.log( + 'supriya rtm [BREAKOUT] _scope and _channelId: ', + _scope, + _channelId, + currentChannel, + ); + // Filter if its for this channel + if (!isEventForActiveChannel(_scope, _channelId, currentChannel)) { + return; + } + + // Step 1: Set local attributes + if (persistLevel === PersistanceLevel.Session) { + // const roomKey = RTM_ROOMS.BREAKOUT; + // const roomAwareKey = `${roomKey}_${evt}`; + const rtmAttribute = {key: evt, value: value}; + const options: SetOrUpdateUserMetadataOptions = { + userId: `${localUid}`, + }; + await client.storage.setUserMetadata( + { + items: [rtmAttribute], + }, + options, + ); + } + // Step 2: Emit the event + console.log(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: ', evt); + EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); + // Because async gets evaluated in a different order when in an sdk + if (evt === 'name') { + // 1. Cancel existing timeout for this sender + if (eventTimeouts.has(sender)) { + clearTimeout(eventTimeouts.get(sender)!); + } + // 2. Create new timeout with tracking + const timeout = setTimeout(() => { + // 3. Guard against unmounted component + if (!isRTMMounted.current) { + return; + } + EventUtils.emitEvent(evt, source, { + payload, + persistLevel, + sender, + ts, + }); + // 4. Clean up after execution + eventTimeouts.delete(sender); + }, 200); + // 5. Track the timeout for cleanup + eventTimeouts.set(sender, timeout); + } + } catch (error) { + console.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while emiting event:', + {error}, + ); + } + }; + + // Listeners + const handleStorageEvent = (storage: StorageEvent) => { + // when remote user sets/updates metadata - 3 + if ( + storage.eventType === nativeStorageEventTypeMapping.SET || + storage.eventType === nativeStorageEventTypeMapping.UPDATE + ) { + const storageTypeStr = storage.storageType === 1 ? 'user' : 'channel'; + const eventTypeStr = storage.eventType === 2 ? 'SET' : 'UPDATE'; + logger.log( + LogSource.AgoraSDK, + 'Event', + `RTM storage event of type: [${eventTypeStr} ${storageTypeStr} metadata]`, + storage, + ); + try { + if (storage.data?.items && Array.isArray(storage.data.items)) { + storage.data.items.forEach(item => { + try { + if (!item || !item.key) { + logger.warn( + LogSource.Events, + 'CUSTOM_EVENTS', + 'Invalid storage item:', + item, + ); + return; + } + + const {key, value, authorUserId, updateTs} = item; + console.log('supriya-eventDispatcher item: ', item); + const timestamp = getMessageTime(updateTs); + const sender = Platform.OS + ? get32BitUid(authorUserId) + : parseInt(authorUserId, 10); + eventDispatcher( + { + evt: key, + value, + }, + `${sender}`, + timestamp, + ); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + `Failed to process storage item: ${JSON.stringify(item)}`, + {error}, + ); + } + }); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }; + + const handlePresenceEvent = async (presence: PresenceEvent) => { + if (presence.type === nativePresenceEventTypeMapping.REMOTE_JOIN) { + logger.log( + LogSource.AgoraSDK, + 'Event', + 'RTM presenceEvent of type [3 - remoteJoin] (channelMemberJoined)', + ); + const useAttributes = await fetchUserAttributesWithRetries( + client, + presence.publisher, + { + isMounted: () => isRTMMounted.current, + // This is called later if name arrives and hence we process that attribute + onNameFound: retriedAttributesWithName => + mapUserAttributesToState( + retriedAttributesWithName, + presence.publisher, + syncUserState, + ), + }, + ); + // This is called as soon as we receive any attributes + mapUserAttributesToState( + useAttributes, + presence.publisher, + syncUserState, + ); + } + // remoteLeaveChannel + if (presence.type === nativePresenceEventTypeMapping.REMOTE_LEAVE) { + logger.log( + LogSource.AgoraSDK, + 'Event', + 'RTM presenceEvent of type [4 - remoteLeave] (channelMemberLeft)', + presence, + ); + // Chat of left user becomes undefined. So don't cleanup + const uid = presence?.publisher + ? parseInt(presence.publisher, 10) + : undefined; + + if (!uid) { + return; + } + // updating the rtc data + syncUserState(uid, { + offline: true, + }); + } + }; + + const handleMessageEvent = (message: MessageEvent) => { + console.log('supriya current message channel: ', currentChannel); + console.log('supriya message event is', message); + // message - 1 (channel) + if (message.channelType === nativeChannelTypeMapping.MESSAGE) { + // here the channel name will be the channel name + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [1 - CHANNEL] (channelMessageReceived)', + message, + ); + const {publisher: uid, message: text, timestamp: ts} = message; + //whiteboard upload + if (parseInt(uid, 10) === 1010101) { + const [err, res] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + if (res?.data?.data?.images) { + LocalEventEmitter.emit( + LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, + res?.data?.data?.images, + ); + } + } else { + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid, 10); + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + } + + // message - 3 (user) + if (message.channelType === nativeChannelTypeMapping.USER) { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [3- USER] (messageReceived)', + message, + ); + // here the (message.channelname) channel name will be the to UID + const {publisher: peerId, timestamp: ts, message: text} = message; + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId, 10); + + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }; + + const unsubscribeAndCleanup = async ( + currentClient: RTMClient, + channel: string, + ) => { + try { + setHasUserJoinedRTM(false); + setIsInitialQueueCompleted(false); + currentClient.unsubscribe(channel); + RTMEngine.getInstance().removeChannel(RTM_ROOMS.BREAKOUT); + logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); + if (isIOS() || isAndroid()) { + EventUtils.clear(); + } + logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); + } catch (unsubscribeError) { + console.log('supriya error while unsubscribing: ', unsubscribeError); + } + }; + + useAsyncEffect(async () => { + try { + if (client && isLoggedIn && callActive && currentChannel) { + hasInitRef.current = true; + registerCallbacks(currentChannel, { + storage: handleStorageEvent, + presence: handlePresenceEvent, + message: handleMessageEvent, + }); + await init(); + } + } catch (error) { + logger.error(LogSource.AgoraSDK, 'Log', 'RTM init failed', {error}); + } + return async () => { + console.log('rudra-core-client: cleaning up for channel', currentChannel); + const currentClient = RTMEngine.getInstance().engine; + hasInitRef.current = false; + isRTMMounted.current = false; + // Clear all pending timeouts on unmount + for (const timeout of eventTimeouts.values()) { + clearTimeout(timeout); + } + eventTimeouts.clear(); + if (currentChannel) { + unregisterCallbacks(currentChannel); + } + if (currentClient && callActive && isLoggedIn) { + await unsubscribeAndCleanup(currentClient, currentChannel); + } + }; + }, [isLoggedIn, callActive, currentChannel, client]); + + const contextValue: RTMBreakoutRoomData = { + hasUserJoinedRTM, + isInitialQueueCompleted, + onlineUsersCount, + rtmInitTimstamp, + syncUserState, + }; + + return ( + + {props.children} + + ); +}; + +export default RTMConfigureBreakoutRoomProvider; diff --git a/template/src/rtm/RTMConfigureMainRoomProvider.tsx b/template/src/rtm/RTMConfigureMainRoomProvider.tsx new file mode 100644 index 000000000..f83d1a41d --- /dev/null +++ b/template/src/rtm/RTMConfigureMainRoomProvider.tsx @@ -0,0 +1,757 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, { + useState, + useContext, + useEffect, + useRef, + createContext, + useCallback, +} from 'react'; +import {type MessageEvent, type StorageEvent} from 'agora-react-native-rtm'; +import { + ContentInterface, + DispatchContext, + useLocalUid, +} from '../../agora-rn-uikit'; +import {Platform} from 'react-native'; +import {isAndroid, isIOS} from '../utils/common'; +import {useContent} from 'customization-api'; +import { + safeJsonParse, + getMessageTime, + get32BitUid, + isEventForActiveChannel, +} from './utils'; +import {EventUtils, EventsQueue} from '../rtm-events'; +import {PersistanceLevel} from '../rtm-events-api'; +import RTMEngine from './RTMEngine'; +import {filterObject} from '../utils'; +import {useAsyncEffect} from '../utils/useAsyncEffect'; +import { + WaitingRoomStatus, + useRoomInfo, +} from '../components/room-info/useRoomInfo'; +import LocalEventEmitter, { + LocalEventsEnum, +} from '../rtm-events-api/LocalEvents'; +import {controlMessageEnum} from '../components/ChatContext'; +import {LogSource, logger} from '../logger/AppBuilderLogger'; +import { + nativeChannelTypeMapping, + nativeStorageEventTypeMapping, +} from '../../bridge/rtm/web/Types'; +import {useRTMCore} from './RTMCoreProvider'; +import {RTMUserData, useRTMGlobalState} from './RTMGlobalStateProvider'; +import {useUserGlobalPreferences} from '../components/UserGlobalPreferenceProvider'; +import {ToggleState} from '../../agora-rn-uikit'; +import useMuteToggleLocal from '../utils/useMuteToggleLocal'; +import { + RTM_ROOMS, + RTM_EVENTS_ATTRIBUTES_TO_RESET_WHEN_ROOM_CHANGES, +} from './constants'; +import {useRtc} from 'customization-api'; +import { + fetchChannelAttributesWithRetries, + clearRoomScopedUserAttributes, +} from './rtm-presence-utils'; + +const eventTimeouts = new Map>(); + +// RTM Main Room Context +export interface RTMMainRoomData { + hasUserJoinedRTM: boolean; + isInitialQueueCompleted: boolean; + onlineUsersCount: number; + rtmInitTimstamp: number; + syncUserState: (uid: number, data: Partial) => void; +} + +const RTMMainRoomContext = createContext({ + hasUserJoinedRTM: false, + isInitialQueueCompleted: false, + onlineUsersCount: 0, + rtmInitTimstamp: 0, + syncUserState: () => {}, +}); + +export const useRTMConfigureMain = () => { + const context = useContext(RTMMainRoomContext); + if (!context) { + throw new Error( + 'useRTMConfigureMain must be used within RTMConfigureMainRoomProvider', + ); + } + return context; +}; + +interface RTMConfigureMainRoomProviderProps { + callActive: boolean; + currentChannel: string; + children: React.ReactNode; +} + +const RTMConfigureMainRoomProvider: React.FC< + RTMConfigureMainRoomProviderProps +> = ({callActive, currentChannel, children}) => { + const rtmInitTimstamp = new Date().getTime(); + const {dispatch} = useContext(DispatchContext); + const {defaultContent, activeUids} = useContent(); + const { + waitingRoomStatus, + data: {isHost}, + } = useRoomInfo(); + const localUid = useLocalUid(); + const {applyUserPreferences, syncUserPreferences} = + useUserGlobalPreferences(); + const toggleMute = useMuteToggleLocal(); + const {rtcTracksReady} = useRtc(); + const [hasUserJoinedRTM, setHasUserJoinedRTM] = useState(false); + const [isInitialQueueCompleted, setIsInitialQueueCompleted] = useState(false); + const [onlineUsersCount, setTotalOnlineUsers] = useState(0); + + // RTM + const {client, isLoggedIn} = useRTMCore(); + const {mainRoomRTMUsers, setMainRoomRTMUsers} = useRTMGlobalState(); + + // Main channel message registration (RTMConfigureMainRoom is always for main channel) + const { + registerMainChannelMessageHandler, + unregisterMainChannelMessageHandler, + registerMainChannelStorageHandler, + unregisterMainChannelStorageHandler, + } = useRTMGlobalState(); + + // refs + const isRTMMounted = useRef(true); + const channelAttributesTimerRef: any = useRef(5); + const channelAttributesTimeoutRef = useRef | null>(null); + + const isHostRef = useRef({isHost: isHost}); + useEffect(() => { + isHostRef.current.isHost = isHost; + }, [isHost]); + + const waitingRoomStatusRef = useRef({waitingRoomStatus: waitingRoomStatus}); + useEffect(() => { + waitingRoomStatusRef.current.waitingRoomStatus = waitingRoomStatus; + }, [waitingRoomStatus]); + + const activeUidsRef = useRef({activeUids: activeUids}); + useEffect(() => { + activeUidsRef.current.activeUids = activeUids; + }, [activeUids]); + + const defaultContentRef = useRef(defaultContent); + + useEffect(() => { + defaultContentRef.current = defaultContent; + }, [defaultContent]); + + // Set online users + React.useEffect(() => { + setTotalOnlineUsers( + Object.keys( + filterObject( + defaultContent, + ([k, v]) => + v?.type === 'rtc' && + !v.offline && + activeUidsRef.current.activeUids.indexOf(v?.uid) !== -1, + ), + ).length, + ); + }, [defaultContent, activeUids]); + + // Set user preferences when main room mounts + useEffect(() => { + if (rtcTracksReady && localUid) { + console.log( + 'UP: trackesready', + JSON.stringify(defaultContentRef.current[localUid]), + ); + applyUserPreferences(defaultContentRef.current[localUid], toggleMute); + } + }, [rtcTracksReady, localUid]); + + // Sync current audio/video state audio video changes + useEffect(() => { + const userData = defaultContent[localUid]; + if (rtcTracksReady && userData) { + console.log('UP: syncing userData: ', userData); + const preferences = { + audioMuted: userData.audio === ToggleState.disabled, + videoMuted: userData.video === ToggleState.disabled, + }; + console.log('UP: saved preferences: ', preferences); + syncUserPreferences(preferences); + } + }, [defaultContent, localUid, syncUserPreferences, rtcTracksReady]); + + // Set Main room specific syncUserState function + const syncUserState = useCallback( + (uid: number, data: any) => { + // Extract only RTM-related fields that are actually passed + const rtmData: Partial = {}; + + // Only add fields if they exist in the passed data + if ('name' in data) { + rtmData.name = data.name; + } + if ('screenUid' in data) { + rtmData.screenUid = data.screenUid; + } + if ('offline' in data) { + rtmData.offline = data.offline; + } + if ('lastMessageTimeStamp' in data) { + rtmData.lastMessageTimeStamp = data.lastMessageTimeStamp; + } + if ('isInWaitingRoom' in data) { + rtmData.isInWaitingRoom = data.isInWaitingRoom; + } + if ('isHost' in data) { + rtmData.isHost = data.isHost; + } + if ('type' in data) { + rtmData.type = data.type; + } + if ('parentUid' in data) { + rtmData.parentUid = data.parentUid; + } + if ('uid' in data) { + rtmData.uid = data.uid; + } + // Only update if we have RTM data to update + if (Object.keys(rtmData).length > 0) { + setMainRoomRTMUsers(prev => { + return { + ...prev, + [uid]: { + ...prev[uid], + ...rtmData, + }, + }; + }); + } + }, + [setMainRoomRTMUsers], + ); + + useEffect(() => { + Object.entries(mainRoomRTMUsers).forEach(([uidStr, rtmUser]) => { + const uid = parseInt(uidStr, 10); + + let userData: Partial = {}; + // screenshare RTM data + if (rtmUser.type === 'screenshare') { + userData = { + // RTM data + name: rtmUser.name || '', + parentUid: rtmUser.parentUid || 0, + type: rtmUser.type, + }; + } else { + userData = { + // user RTM data + name: rtmUser.name || '', + screenUid: rtmUser.screenUid || 0, + offline: !!rtmUser.offline, + lastMessageTimeStamp: rtmUser.lastMessageTimeStamp || 0, + isInWaitingRoom: rtmUser?.isInWaitingRoom || false, + isHost: rtmUser.isHost, + type: rtmUser.type, + }; + } + + // Dispatch directly for each user + dispatch({type: 'UpdateRenderList', value: [uid, userData]}); + }); + }, [mainRoomRTMUsers, dispatch]); + + const rehydrateSessionAttributes = async () => { + try { + const uid = localUid.toString(); + const attr = await client.storage.getUserMetadata({userId: uid}); + console.log('supriya-wasInBreakoutRoom: attr: ', attr); + + if (!attr?.items) { + return; + } + + attr.items.forEach(item => { + try { + // Check if this is a room-aware session attribute for current room + // if (item.key && item.key.startsWith(`${RTM_ROOMS.MAIN}__`)) { + const parsed = JSON.parse(item.value); + if (parsed.persistLevel === PersistanceLevel.Session) { + // Put into queuue + const data = {evt: item.key, value: item.value}; + EventsQueue.enqueue({ + data, + uid, + ts: Date.now(), + }); + } + // } + } catch (e) { + console.log('Failed to rehydrate session attribute', item.key, e); + } + }); + } catch (error) { + console.log('Failed to rehydrate session attributes', error); + } + }; + + const init = async () => { + // Set main room as active channel when this provider mounts again active + const currentActiveChannel = RTMEngine.getInstance().getActiveChannelName(); + const wasInBreakoutRoom = currentActiveChannel === RTM_ROOMS.BREAKOUT; + + if (currentActiveChannel !== RTM_ROOMS.MAIN) { + RTMEngine.getInstance().setActiveChannelName(RTM_ROOMS.MAIN); + } + // Clear room-scoped RTM attributes to ensure fresh state + await clearRoomScopedUserAttributes( + client, + RTM_EVENTS_ATTRIBUTES_TO_RESET_WHEN_ROOM_CHANGES, + ); + + // // Rehydrate session attributes ONLY when returning from breakout room + if (wasInBreakoutRoom) { + await rehydrateSessionAttributes(); + } + + await getChannelAttributes(); + + setHasUserJoinedRTM(true); + await runQueuedEvents(); + setIsInitialQueueCompleted(true); + }; + + const getChannelAttributes = async () => { + try { + await fetchChannelAttributesWithRetries( + client, + currentChannel, + eventData => EventsQueue.enqueue(eventData), + ); + channelAttributesTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (channelAttributesTimeoutRef.current) { + clearTimeout(channelAttributesTimeoutRef.current); + channelAttributesTimeoutRef.current = null; + } + } catch (error) { + console.log( + 'rudra-core-client: RTM getchannelattributes failed..Trying again', + error, + ); + channelAttributesTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + channelAttributesTimerRef.current = Math.min( + channelAttributesTimerRef.current * 2, + 30, + ); + getChannelAttributes(); + }, channelAttributesTimerRef.current * 1000); + } + }; + + const runQueuedEvents = async () => { + try { + while (!EventsQueue.isEmpty()) { + const currEvt = EventsQueue.dequeue(); + console.log('supriya-session inside queue currEvt: ', currEvt); + await eventDispatcher(currEvt.data, `${currEvt.uid}`, currEvt.ts); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while running queue events', + {error}, + ); + } + }; + + const eventDispatcher = async ( + data: { + evt: string; + value: string; + feat?: string; + etyp?: string; + }, + sender: string, + ts: number, + ) => { + console.log( + LogSource.Events, + 'CUSTOM_EVENTS', + 'inside eventDispatcher ', + data, + ); + + let evt = '', + value = ''; + + if (data?.feat === 'BREAKOUT_ROOM') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + data: data.data, + action: data.act, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } else if (data?.feat === 'WAITING_ROOM') { + if (data?.etyp === 'REQUEST') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + attendee_uid: data.data.data.attendee_uid, + attendee_screenshare_uid: data.data.data.attendee_screenshare_uid, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + if (data?.etyp === 'RESPONSE') { + const outputData = { + evt: `${data.feat}_${data.etyp}`, + payload: JSON.stringify({ + approved: data.data.data.approved, + channelName: data.data.data.channel_name, + mainUser: data.data.data.mainUser, + screenShare: data.data.data.screenShare, + whiteboard: data.data.data.whiteboard, + chat: data.data.data?.chat, + }), + persistLevel: 1, + source: 'core', + }; + const formattedData = JSON.stringify(outputData); + evt = data.feat + '_' + data.etyp; + value = formattedData; + } + } else { + if ( + $config.ENABLE_WAITING_ROOM && + !isHostRef.current?.isHost && + waitingRoomStatusRef.current?.waitingRoomStatus !== + WaitingRoomStatus.APPROVED + ) { + if ( + data.evt === controlMessageEnum.muteAudio || + data.evt === controlMessageEnum.muteVideo + ) { + return; + } else { + evt = data.evt; + value = data.value; + } + } else { + evt = data.evt; + value = data.value; + } + } + + try { + let parsedValue; + try { + parsedValue = typeof value === 'string' ? JSON.parse(value) : value; + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'RTM Failed to parse event value in event dispatcher:', + {error}, + ); + return; + } + const {payload, persistLevel, source, _scope, _channelId} = parsedValue; + console.log( + 'supriya-session-attributes [MAIN] _scope and _channelId: ', + source, + _scope, + _channelId, + currentChannel, + payload, + ); + // Filter if its for this channel + if (!isEventForActiveChannel(_scope, _channelId, currentChannel)) { + console.log('supriya-session-attributes SKIPPING', payload); + return; + } + + // Step 2: Emit the event (no metadata persistence - handled by RTMGlobalStateProvider) + console.log(LogSource.Events, 'CUSTOM_EVENTS', 'emiting event..: ', evt); + EventUtils.emitEvent(evt, source, {payload, persistLevel, sender, ts}); + // Because async gets evaluated in a different order when in an sdk + if (evt === 'name') { + // 1. Cancel existing timeout for this sender + if (eventTimeouts.has(sender)) { + clearTimeout(eventTimeouts.get(sender)!); + } + // 2. Create new timeout with tracking + const timeout = setTimeout(() => { + // 3. Guard against unmounted component + if (!isRTMMounted.current) { + return; + } + EventUtils.emitEvent(evt, source, { + payload, + persistLevel, + sender, + ts, + }); + // 4. Clean up after execution + eventTimeouts.delete(sender); + }, 200); + // 5. Track the timeout for cleanup + eventTimeouts.set(sender, timeout); + } + } catch (error) { + console.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while emiting event:', + {error}, + ); + } + }; + + // Listeners + const handleMainChannelStorageEvent = (storage: StorageEvent) => { + // when remote user sets/updates metadata - 3 + if ( + storage.eventType === nativeStorageEventTypeMapping.SET || + storage.eventType === nativeStorageEventTypeMapping.UPDATE + ) { + const storageTypeStr = storage.storageType === 1 ? 'user' : 'channel'; + const eventTypeStr = storage.eventType === 2 ? 'SET' : 'UPDATE'; + logger.log( + LogSource.AgoraSDK, + 'Event', + `RTM storage event of type: [${eventTypeStr} ${storageTypeStr} metadata]`, + storage, + ); + try { + if (storage.data?.items && Array.isArray(storage.data.items)) { + storage.data.items.forEach(item => { + try { + if (!item || !item.key) { + logger.warn( + LogSource.Events, + 'CUSTOM_EVENTS', + 'Invalid storage item:', + item, + ); + return; + } + + const {key, value, authorUserId, updateTs} = item; + const timestamp = getMessageTime(updateTs); + const sender = Platform.OS + ? get32BitUid(authorUserId) + : parseInt(authorUserId, 10); + eventDispatcher( + { + evt: key, + value, + }, + `${sender}`, + timestamp, + ); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + `Failed to process storage item: ${JSON.stringify(item)}`, + {error}, + ); + } + }); + } + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }; + + const handleMainChannelMessageEvent = (message: MessageEvent) => { + console.log('supriya current message channel: ', currentChannel); + console.log('supriya message event is', message); + // message - 1 (channel) + if (message.channelType === nativeChannelTypeMapping.MESSAGE) { + // here the channel name will be the channel name + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [1 - CHANNEL] (channelMessageReceived)', + message, + ); + const {publisher: uid, message: text, timestamp: ts} = message; + //whiteboard upload + if (parseInt(uid, 10) === 1010101) { + const [err, res] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + if (res?.data?.data?.images) { + LocalEventEmitter.emit( + LocalEventsEnum.WHITEBOARD_FILE_UPLOAD, + res?.data?.data?.images, + ); + } + } else { + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + const sender = Platform.OS ? get32BitUid(uid) : parseInt(uid, 10); + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + } + + // message - 3 (user) + if (message.channelType === nativeChannelTypeMapping.USER) { + logger.debug( + LogSource.Events, + 'CUSTOM_EVENTS', + 'messageEvent of type [3- USER] (messageReceived)', + message, + ); + // here the (message.channelname) channel name will be the to UID + const {publisher: peerId, timestamp: ts, message: text} = message; + const [err, msg] = safeJsonParse(text); + if (err) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'JSON payload incorrect, Error while parsing the payload', + {error: err}, + ); + } + + const timestamp = getMessageTime(ts); + + const sender = isAndroid() ? get32BitUid(peerId) : parseInt(peerId, 10); + + try { + eventDispatcher(msg, `${sender}`, timestamp); + } catch (error) { + logger.error( + LogSource.Events, + 'CUSTOM_EVENTS', + 'error while dispatching through eventDispatcher', + {error}, + ); + } + } + }; + + useAsyncEffect(async () => { + try { + if (client && isLoggedIn && currentChannel) { + // RTM initialization logic: + // - Waiting room attendees: Connect immediately + // - Waiting room hosts: Wait for callActive + // - Non-waiting room: Everyone waits for callActive + const shouldInit = + callActive || ($config.ENABLE_WAITING_ROOM && !isHost); + if (shouldInit) { + registerMainChannelMessageHandler(handleMainChannelMessageEvent); + registerMainChannelStorageHandler(handleMainChannelStorageEvent); + await init(); + } + } + } catch (error) { + logger.error(LogSource.AgoraSDK, 'Log', 'RTM init failed', {error}); + } + return async () => { + isRTMMounted.current = false; + logger.log(LogSource.AgoraSDK, 'API', 'RTM destroy done'); + unregisterMainChannelMessageHandler(); + unregisterMainChannelStorageHandler(); + if (isIOS() || isAndroid()) { + EventUtils.clear(); + } + // Clear all pending timeouts on unmount + for (const timeout of eventTimeouts.values()) { + clearTimeout(timeout); + } + eventTimeouts.clear(); + // Clear timer-based retry timeouts + if (channelAttributesTimeoutRef.current) { + clearTimeout(channelAttributesTimeoutRef.current); + channelAttributesTimeoutRef.current = null; + } + setHasUserJoinedRTM(false); + setIsInitialQueueCompleted(false); + logger.debug(LogSource.AgoraSDK, 'Log', 'RTM cleanup done'); + }; + }, [client, isLoggedIn, callActive, currentChannel, isHost]); + + // Provide context data to children + const contextValue: RTMMainRoomData = { + hasUserJoinedRTM, + isInitialQueueCompleted, + onlineUsersCount, + rtmInitTimstamp, + syncUserState, + }; + + return ( + + {children} + + ); +}; + +export default RTMConfigureMainRoomProvider; diff --git a/template/src/rtm/RTMCoreProvider.tsx b/template/src/rtm/RTMCoreProvider.tsx new file mode 100644 index 000000000..44a8eae02 --- /dev/null +++ b/template/src/rtm/RTMCoreProvider.tsx @@ -0,0 +1,419 @@ +import React, { + useRef, + useState, + useEffect, + createContext, + useContext, + useCallback, + useMemo, +} from 'react'; +import type { + RTMClient, + LinkStateEvent, + MessageEvent, + PresenceEvent, + StorageEvent, + Metadata, + SetOrUpdateUserMetadataOptions, + RtmLinkState, +} from 'agora-react-native-rtm'; +import {UidType} from '../../agora-rn-uikit'; +import RTMEngine from '../rtm/RTMEngine'; +import {isWeb, isWebInternal} from '../utils/common'; +import isSDK from '../utils/isSDK'; +import {useAsyncEffect} from '../utils/useAsyncEffect'; +import {nativeLinkStateMapping} from '../../bridge/rtm/web/Types'; +import {RTMStatusBanner} from './RTMStatusBanner'; + +// ---- Helpers ---- // +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +async function loginWithBackoff( + rtmClient: RTMClient, + token: string, + onAttempt?: (n: number) => void, + maxAttempts = 5, +) { + let attempt = 0; + while (attempt <= maxAttempts) { + try { + try { + await rtmClient.logout(); + } catch {} + await delay(300); + await rtmClient.login({token}); + return; // success + } catch (e: any) { + attempt += 1; + onAttempt?.(attempt); + + if (attempt > maxAttempts) { + const errorMsg = `RTM login failed: ${e?.message ?? e}`; + throw new Error(errorMsg); + } + const backoff = + Math.min(5000 * 2 ** (attempt - 1), 60_000) + + Math.floor(Math.random() * 300); + await delay(backoff); + } + } +} + +// ---- Context ---- // +type MessageCallback = (message: MessageEvent) => void; +type PresenceCallback = (presence: PresenceEvent) => void; +type StorageCallback = (storage: StorageEvent) => void; + +interface EventCallbacks { + message?: MessageCallback; + presence?: PresenceCallback; + storage?: StorageCallback; +} + +interface RTMContextType { + client: RTMClient | null; + connectionState: RtmLinkState; + error: Error | null; + isLoggedIn: boolean; + registerCallbacks: (channelName: string, callbacks: EventCallbacks) => void; + unregisterCallbacks: (channelName: string) => void; +} + +const RTMContext = createContext({ + client: null, + connectionState: nativeLinkStateMapping.IDLE, + error: null, + isLoggedIn: false, + registerCallbacks: () => {}, + unregisterCallbacks: () => {}, +}); + +interface RTMCoreProviderProps { + children: React.ReactNode; + userInfo: { + localUid: UidType; + screenShareUid: UidType; + isHost: boolean; + rtmToken?: string; + }; +} + +export const RTMCoreProvider: React.FC = ({ + userInfo, + children, +}) => { + const [client, setClient] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [connectionState, setConnectionState] = useState(0); + console.log('supriya-rtm connectionState: ', connectionState); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + const cleaningRef = useRef(false); + const callbackRegistry = useRef>(new Map()); + const errorRef = useRef(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Sync error ref with state to prevent state clearing + useEffect(() => { + errorRef.current = error; + }, [error]); + + // Keep error persistent if we have a failed state + useEffect(() => { + if ( + connectionState === nativeLinkStateMapping.FAILED && + !error && + errorRef.current + ) { + setError(errorRef.current); + } + }, [connectionState, error]); + + // Memoize userInfo + const stableUserInfo = useMemo( + () => ({ + localUid: userInfo.localUid, + screenShareUid: userInfo.screenShareUid, + isHost: userInfo.isHost, + rtmToken: userInfo.rtmToken, + }), + [ + userInfo.localUid, + userInfo.screenShareUid, + userInfo.isHost, + userInfo.rtmToken, + ], + ); + + const setAttribute = useCallback(async (rtmClient: RTMClient, userInfo) => { + const rtmAttributes = [ + {key: 'screenUid', value: String(userInfo.screenShareUid)}, + {key: 'isHost', value: String(userInfo.isHost)}, + ]; + try { + const data: Metadata = {items: rtmAttributes}; + const options: SetOrUpdateUserMetadataOptions = { + userId: `${userInfo.localUid}`, + }; + // await rtmClient.storage.removeUserMetadata(); + await rtmClient.storage.setUserMetadata(data, options); + } catch (setAttributeError) { + console.log('setAttributeError: ', setAttributeError); + } + }, []); + + const registerCallbacks = useCallback( + (channelName: string, callbacks: EventCallbacks) => { + callbackRegistry.current.set(channelName, callbacks); + }, + [], + ); + + const unregisterCallbacks = useCallback((channelName: string) => { + callbackRegistry.current.delete(channelName); + }, []); + + // Global event listeners + useEffect(() => { + if (!client || !userInfo?.localUid) { + return; + } + const handleGlobalStorageEvent = (storage: StorageEvent) => { + console.log( + 'rudra-core-client ********************** ---StorageEvent event: ', + storage, + callbackRegistry, + ); + // Distribute to all registered callbacks + callbackRegistry.current.forEach((callbacks, channelName) => { + try { + callbacks.storage?.(storage); + } catch (globalStorageCbError) { + console.log('globalStorageCbError: ', globalStorageCbError); + } + }); + }; + + const handleGlobalPresenceEvent = (presence: PresenceEvent) => { + console.log( + 'rudra-core-client @@@@@@@@@@@@@@@@@@@@@@@ ---PresenceEvent: ', + presence, + callbackRegistry, + ); + // Distribute to all registered callbacks + callbackRegistry.current.forEach((callbacks, channelName) => { + try { + callbacks.presence?.(presence); + } catch (globalPresenceCbError) { + console.log('globalPresenceCbError: ', globalPresenceCbError); + } + }); + }; + + const handleGlobalMessageEvent = (message: MessageEvent) => { + console.log( + 'rudra-core-client ######################## ---MessageEvent event: ', + message, + ); + if (String(userInfo.localUid) === message.publisher) { + console.log( + 'rudra-core-client ######################## SKIPPING this message event as it is local', + message, + callbackRegistry, + ); + return; + } + // Distribute to all registered callbacks + callbackRegistry.current.forEach((callbacks, channelName) => { + try { + callbacks.message?.(message); + } catch (globalMessageCbError) { + console.log('globalMessageCbError: ', globalMessageCbError); + } + }); + }; + + client.addEventListener('storage', handleGlobalStorageEvent); + client.addEventListener('presence', handleGlobalPresenceEvent); + client.addEventListener('message', handleGlobalMessageEvent); + + return () => { + // Remove global event listeners + client.removeEventListener('storage', handleGlobalStorageEvent); + client.removeEventListener('presence', handleGlobalPresenceEvent); + client.removeEventListener('message', handleGlobalMessageEvent); + }; + }, [client, userInfo?.localUid]); + + // Link state listener for reconnects + useEffect(() => { + if (!client) { + return; + } + + const onLink = async (evt: LinkStateEvent) => { + setConnectionState(evt.currentState); + + if (evt.currentState === nativeLinkStateMapping.FAILED) { + setIsLoggedIn(false); + // Set error if we're in FAILED state and don't have one + if (!errorRef.current) { + const failedError = new Error('RTM connection failed'); + errorRef.current = failedError; + setError(failedError); + } + } else if (evt.currentState === nativeLinkStateMapping.DISCONNECTED) { + setIsLoggedIn(false); + if (stableUserInfo.rtmToken) { + try { + await loginWithBackoff(client, stableUserInfo.rtmToken); + if (!mountedRef.current) { + return; + } + setIsLoggedIn(true); + // Clear error only after successful login + errorRef.current = null; + setError(null); + } catch (err: any) { + if (!mountedRef.current) { + return; + } + errorRef.current = err; + setError(err); + } + } + } else if (evt.currentState === nativeLinkStateMapping.CONNECTED) { + setIsLoggedIn(true); + // Clear error on successful connection + errorRef.current = null; + setError(null); + } + }; + + client.addEventListener('linkState', onLink); + return () => { + client.removeEventListener('linkState', onLink); + }; + }, [client, stableUserInfo, setAttribute]); + + // Initialize RTM + useEffect(() => { + if (client) { + return; + } + + (async () => { + // 1, Check if engine is already connected + // 2. Initialize RTM Engine + if (!RTMEngine.getInstance()?.isEngineReady) { + RTMEngine.getInstance().setLocalUID(stableUserInfo.localUid); + } + const rtmClient = RTMEngine.getInstance().engine; + if (!rtmClient) { + throw new Error('Failed to create RTM client'); + } + // 3. Set client after successful setup + setClient(rtmClient); + + try { + if (stableUserInfo.rtmToken) { + await loginWithBackoff(rtmClient, stableUserInfo.rtmToken); + await setAttribute(rtmClient, stableUserInfo); + if (!mountedRef.current) { + return; + } + setIsLoggedIn(true); + } + } catch (err: any) { + if (!mountedRef.current) { + return; + } + errorRef.current = err; + setError(err); + } + })(); + }, [client, stableUserInfo, setAttribute]); + + // Refresh attributes if userInfo changes while logged in + useEffect(() => { + if (client && isLoggedIn && stableUserInfo.rtmToken) { + setAttribute(client, stableUserInfo).catch(console.warn); + } + }, [client, isLoggedIn, stableUserInfo, setAttribute]); + + const cleanupRTM = useCallback(async () => { + if (cleaningRef.current) { + return; + } + cleaningRef.current = true; + try { + const engine = RTMEngine.getInstance(); + if (engine?.engine) { + console.log('RTM cleanup: destroying engine...'); + await engine.destroy(); + console.log('RTM cleanup: engine destroyed.'); + } + setClient(null); + } catch (err) { + console.error('RTM cleanup failed:', err); + } finally { + cleaningRef.current = false; + } + }, []); + + useAsyncEffect(() => { + return async () => { + // Cleanup + console.log('supriya-rtm-lifecycle cleanup'); + await cleanupRTM(); + }; + }, []); + + // Browser unload cleanup + useEffect(() => { + if ( + !$config.ENABLE_CONVERSATIONAL_AI && + isWebInternal() && + isWeb() && + !isSDK() + ) { + const handleBrowserClose = (ev: BeforeUnloadEvent) => { + ev.preventDefault(); + ev.returnValue = 'Are you sure you want to exit?'; + }; + const handleRTMCleanup = () => { + cleanupRTM(); + }; + window.addEventListener('beforeunload', handleBrowserClose); + window.addEventListener('pagehide', handleRTMCleanup); + return () => { + window.removeEventListener('beforeunload', handleBrowserClose); + window.removeEventListener('pagehide', handleRTMCleanup); + }; + } + }, [cleanupRTM]); + + return ( + + {/* */} + {children} + + ); +}; + +export const useRTMCore = () => useContext(RTMContext); diff --git a/template/src/rtm/RTMEngine.ts b/template/src/rtm/RTMEngine.ts index e1a79ad6e..91e683498 100644 --- a/template/src/rtm/RTMEngine.ts +++ b/template/src/rtm/RTMEngine.ts @@ -10,78 +10,303 @@ ********************************************* */ -import RtmEngine from 'agora-react-native-rtm'; +import { + createAgoraRtmClient, + RtmConfig, + type RTMClient, +} from 'agora-react-native-rtm'; import {isAndroid, isIOS} from '../utils/common'; +import {RTM_ROOMS} from './constants'; class RTMEngine { - engine!: RtmEngine; + private _engine?: RTMClient; private localUID: string = ''; - private channelId: string = ''; - + // track multiple named channels (e.g., "main": "channelId", "breakout": "channelId") + private channelMap: Map = new Map(); + // track current active channel for default operations + private activeChannelName: string = RTM_ROOMS.MAIN; private static _instance: RTMEngine | null = null; + private constructor() { + if (RTMEngine._instance) { + return RTMEngine._instance; + } + RTMEngine._instance = this; + return RTMEngine._instance; + } + + /** Get the singleton instance */ public static getInstance() { + // We are only creating the instance but not creating the rtm client yet if (!RTMEngine._instance) { - return new RTMEngine(); + RTMEngine._instance = new RTMEngine(); } return RTMEngine._instance; } - private async createClientInstance() { - await this.engine.createClient($config.APP_ID); + /** Sets UID and initializes the client if needed */ + setLocalUID(localUID: string | number) { + if (localUID == null) { + throw new Error( + '[RTMEngine] setLocalUID: localUID cannot be null or undefined', + ); + } + const newUID = String(localUID).trim(); + if (!newUID) { + throw new Error('[RTMEngine] setLocalUID: localUID cannot be empty'); + } + if (this._engine && this.localUID !== newUID) { + throw new Error( + `[RTMEngine] setLocalUID: Cannot change UID from '${this.localUID}' to '${newUID}'. Call destroy() first.`, + ); + } + this.localUID = newUID; + if (!this._engine) { + console.info( + `[RTMEngine] setLocalUID: Initializing client for UID: ${this.localUID}`, + ); + this.createClientInstance(); + } else { + console.info( + `[RTMEngine] setLocalUID: UID already initialized: ${this.localUID}`, + ); + } } - private async destroyClientInstance() { - await this.engine.logout(); - if (isIOS() || isAndroid()) { - await this.engine.destroyClient(); + addChannel(name: string, channelID: string) { + if (!name || typeof name !== 'string' || name.trim() === '') { + throw new Error( + '[RTMEngine] addChannel: name must be a non-empty string', + ); + } + if ( + !channelID || + typeof channelID !== 'string' || + channelID.trim() === '' + ) { + throw new Error( + '[RTMEngine] addChannel: channelID must be a non-empty string', + ); } + console.info( + `[RTMEngine] addChannel: Added channel '${name}' β†’ ${channelID}`, + ); + this.channelMap.set(name, channelID); + this.setActiveChannelName(name); } - private constructor() { - if (RTMEngine._instance) { - return RTMEngine._instance; + removeChannel(name: string) { + console.info(`[RTMEngine] removeChannel: Removing channel '${name}'`); + this.channelMap.delete(name); + } + + get localUid() { + return this.localUID; + } + + getChannelId(name?: string): string { + // Default to active channel if no name provided + const channelName = name || this.activeChannelName; + return this.channelMap.get(channelName) || ''; + } + + get allChannelIds(): string[] { + return Array.from(this.channelMap.values()).filter( + channel => channel.trim() !== '', + ); + } + + hasChannel(name: string): boolean { + return this.channelMap.has(name); + } + + getChannelNames(): string[] { + return Array.from(this.channelMap.keys()); + } + + /** Set the active channel for default operations */ + setActiveChannelName(name: string): void { + if (!name || typeof name !== 'string' || name.trim() === '') { + throw new Error( + '[RTMEngine] setActiveChannelName: name must be a non-empty string', + ); } - RTMEngine._instance = this; - this.engine = new RtmEngine(); - this.localUID = ''; - this.channelId = ''; - this.createClientInstance(); + if (!this.hasChannel(name)) { + throw new Error( + `[RTMEngine] setActiveChannelName: Channel '${name}' not found. Add it first with addChannel().`, + ); + } + this.activeChannelName = name; + console.info( + `[RTMEngine] setActiveChannelName: Active channel set β†’ '${name}' (${this.getChannelId( + name, + )})`, + ); + } - return RTMEngine._instance; + /** Get the current active channel ID */ + getActiveChannelId(): string { + return this.getChannelId(this.activeChannelName); } - setLocalUID(localUID: string) { - this.localUID = localUID; + /** Get the current active channel name */ + getActiveChannelName(): string { + return this.activeChannelName; } - setChannelId(channelID: string) { - this.channelId = channelID; + /** Check if the specified channel is currently active */ + isActiveChannel(name: string): boolean { + return this.activeChannelName === name; } - setLoginInfo(localUID: string, channelID: string) { - this.localUID = localUID; - this.channelId = channelID; + /** Engine readiness flag */ + get isEngineReady() { + return !!this._engine && !!this.localUID; } - get localUid() { - return this.localUID; + /** Access the RTMClient instance */ + get engine(): RTMClient { + this.ensureEngineReady(); + return this._engine!; } - get channelUid() { - return this.channelId; + private ensureEngineReady() { + if (!this.isEngineReady) { + throw new Error( + '[RTMEngine] ensureEngineReady: not ready. Call setLocalUID() first.', + ); + } } - async destroy() { + /** Create the Agora RTM client */ + private createClientInstance() { try { - await this.destroyClientInstance(); - if (isIOS() || isAndroid()) { - RTMEngine._instance = null; + if (!this.localUID || this.localUID.trim() === '') { + throw new Error( + '[RTMEngine] createClientInstance: Cannot create RTM client: localUID is not set', + ); + } + if (!$config.APP_ID) { + throw new Error( + '[RTMEngine] createClientInstance: Cannot create RTM client: APP_ID is not configured', + ); + } + const rtmConfig = new RtmConfig({ + appId: $config.APP_ID, + userId: this.localUID, + }); + this._engine = createAgoraRtmClient(rtmConfig); + console.info( + `[RTMEngine] createClientInstance: RTM client created for UID: ${this.localUID}`, + ); + } catch (error) { + const contextError = new Error( + `[RTMEngine] createClientInstance: Failed to create RTM client instance for userId: ${ + this.localUID + }, appId: ${$config.APP_ID}. Error: ${error.message || error}`, + ); + console.error('[RTMEngine] createClientInstance: error:', contextError); + throw contextError; + } + } + + private async destroyClientInstance() { + if (!this._engine) { + return; + } + console.group( + `[RTMEngine] destroyClientInstance: Destroying client for UID: ${this.localUID}`, + ); + console.info( + '[RTMEngine] destroyClientInstance: Unsubscribing from channels:', + this.allChannelIds, + ); + + // 1. Unsubscribe from all tracked channels + for (const channel of this.allChannelIds) { + try { + await this._engine.unsubscribe(channel); + console.info( + `[RTMEngine] destroyClientInstance: Unsubscribed from ${channel}`, + ); + } catch (err) { + console.warn( + `[RTMEngine] destroyClientInstance: Unsubscribe failed: ${channel}`, + err, + ); } + } + // 2. Remove user metadata + try { + await this._engine?.storage.removeUserMetadata(); + console.info('[RTMEngine] destroyClientInstance: User metadata removed'); + } catch (err) { + console.warn( + '[RTMEngine] destroyClientInstance: Failed to remove user metadata', + err, + ); + } + + // 3. Remove all listeners + try { + this._engine.removeAllListeners?.(); + console.info('[RTMEngine] destroyClientInstance: All listeners removed'); + } catch (err) { + console.warn( + '[RTMEngine] destroyClientInstance: Failed to remove listeners', + err, + ); + } + + // 4. Logout and release resources + try { + await this._engine.logout(); + console.info( + '[RTMEngine] destroyClientInstance: Logged out successfully', + ); + if (isAndroid() || isIOS()) { + this._engine.release(); + console.info( + '[RTMEngine] destroyClientInstance: Released native resources', + ); + } + } catch (logoutErrorState) { + // Logout of Signaling + const {operation, reason, errorCode} = logoutErrorState; + console.log( + `[RTMEngine] destroyClientInstance: ${operation} logged out failed, the error code is ${errorCode}, because of: ${reason}.`, + ); + console.warn( + '[RTMEngine] destroyClientInstance: Logout/release failed', + logoutErrorState, + ); + } + } + + /** Fully destroy the singleton and cleanup */ + public async destroy() { + try { + console.info( + `[RTMEngine] destroy: Destroy called for UID: ${this.localUID}`, + ); + if (!this._engine) { + return; + } + await this.destroyClientInstance(); + console.info('[RTMEngine] destroy: Cleanup complete'); + + console.info('[RTMEngine] destroy: clearing channels', this.channelMap); + this.channelMap.clear(); + // Reset state this.localUID = ''; - this.channelId = ''; + this.activeChannelName = RTM_ROOMS.MAIN; + this._engine = undefined; + RTMEngine._instance = null; + console.info('[RTMEngine] destroy: Singleton reset'); } catch (error) { - console.log('Error destroying instance error: ', error); + console.error('[RTMEngine] destroy: Error during destroy:', error); + // Don't re-throw - destruction should be a best-effort cleanup + // Re-throwing could prevent proper cleanup in calling code } } } diff --git a/template/src/rtm/RTMGlobalStateProvider.tsx b/template/src/rtm/RTMGlobalStateProvider.tsx new file mode 100644 index 000000000..db83d6739 --- /dev/null +++ b/template/src/rtm/RTMGlobalStateProvider.tsx @@ -0,0 +1,706 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import React, {useState, useEffect, useRef} from 'react'; +import { + type PresenceEvent, + type StorageEvent, + type SetOrUpdateUserMetadataOptions, + type MessageEvent, +} from 'agora-react-native-rtm'; +import {timeNow, hasJsonStructure} from '../rtm/utils'; +import {EventsQueue} from '../rtm-events'; +import {PersistanceLevel} from '../rtm-events-api'; +import RTMEngine from '../rtm/RTMEngine'; +import {useRTMCore} from './RTMCoreProvider'; +import {RtcPropsInterface, UidType} from '../../agora-rn-uikit'; +import { + nativePresenceEventTypeMapping, + nativeStorageEventTypeMapping, +} from '../../bridge/rtm/web/Types'; +import {RTM_ROOMS} from './constants'; +import { + fetchOnlineMembersWithRetries, + fetchUserAttributesWithRetries, + mapUserAttributesToState, + fetchChannelAttributesWithRetries, + processUserAttributeForQueue, +} from './rtm-presence-utils'; +import {SDKEvents} from '../utils/eventEmitter'; + +export enum UserType { + ScreenShare = 'screenshare', +} + +// RTM-specific user data interface +export interface RTMUserData { + uid?: UidType; + screenUid?: number; // Screen sharing UID reference (stored in RTM user metadata) + parentUid?: UidType; // Only available for screenshare + type: 'rtc' | 'screenshare' | 'bot'; + name?: string; // User's display name (stored in RTM user metadata) + offline: boolean; // User online/offline status (managed through RTM presence events) + lastMessageTimeStamp: number; // Timestamp of last message (RTM message tracking) + isInWaitingRoom?: boolean; // Waiting room status (RTM-based feature state) + isHost: string; // Host privileges (stored in RTM user metadata as 'isHost') +} + +interface RTMGlobalStateProviderProps { + children: React.ReactNode; + rtmLoginInfo: { + uid: UidType; + channel: string; + }; +} + +// Context for message and storage handler registration +const RTMGlobalStateContext = React.createContext<{ + mainRoomRTMUsers: {[uid: number]: RTMUserData}; + setMainRoomRTMUsers: React.Dispatch< + React.SetStateAction<{[uid: number]: RTMUserData}> + >; + // Custom state for developer features (main room scope, cross-room accessible) + customRTMMainRoomData: {[key: string]: any}; + setCustomRTMMainRoomData: React.Dispatch< + React.SetStateAction<{[key: string]: any}> + >; + registerMainChannelMessageHandler: ( + handler: (message: MessageEvent) => void, + ) => void; + unregisterMainChannelMessageHandler: () => void; + registerMainChannelStorageHandler: ( + handler: (storage: StorageEvent) => void, + ) => void; + unregisterMainChannelStorageHandler: () => void; +}>({ + mainRoomRTMUsers: {}, + setMainRoomRTMUsers: () => {}, + customRTMMainRoomData: {}, + setCustomRTMMainRoomData: () => {}, + registerMainChannelMessageHandler: () => {}, + unregisterMainChannelMessageHandler: () => {}, + registerMainChannelStorageHandler: () => {}, + unregisterMainChannelStorageHandler: () => {}, +}); + +const RTMGlobalStateProvider: React.FC = ({ + children, + rtmLoginInfo, +}) => { + const mainChannelName = rtmLoginInfo.channel; + const localUid = rtmLoginInfo.uid; + const {client, isLoggedIn, registerCallbacks, unregisterCallbacks} = + useRTMCore(); + // Main room RTM users (RTM-specific data only) + const [mainRoomRTMUsers, setMainRoomRTMUsers] = useState<{ + [uid: number]: RTMUserData; + }>({}); + + // Custom state for developer features (main room scope, cross-room accessible) + const [customRTMMainRoomData, setCustomRTMMainRoomData] = useState<{ + [key: string]: any; + }>({}); + useEffect(() => { + console.log('mainRoomRTMUsers user-attributes changed', mainRoomRTMUsers); + }, [mainRoomRTMUsers]); + + // Timeout Refs + const isRTMMounted = useRef(true); + const hasInitRef = useRef(false); + + const subscribeTimerRef: any = useRef(5); + const subscribeTimeoutRef = useRef | null>( + null, + ); + + const channelAttributesTimerRef: any = useRef(5); + const channelAttributesTimeoutRef = useRef | null>(null); + + const membersTimerRef: any = useRef(5); + const membersTimeoutRef = useRef | null>(null); + + // Message handler registration for main channel + const messageHandlerRef = useRef<((message: MessageEvent) => void) | null>( + null, + ); + const registerMainChannelMessageHandler = ( + handler: (message: MessageEvent) => void, + ) => { + console.log( + 'rudra-core-client: RTM registering main channel message handler', + ); + if (messageHandlerRef.current) { + console.warn( + 'RTMGlobalStateProvider: Overwriting an existing main channel message handler!', + ); + } + messageHandlerRef.current = handler; + }; + const unregisterMainChannelMessageHandler = () => { + console.log( + 'rudra-core-client: RTM unregistering main channel message handler', + ); + messageHandlerRef.current = null; + }; + + // Storage handler registration for main channel + const storageHandlerRef = useRef<((storage: StorageEvent) => void) | null>( + null, + ); + const registerMainChannelStorageHandler = ( + handler: (storage: StorageEvent) => void, + ) => { + console.log( + 'rudra-core-client: RTM registering main channel storage handler', + ); + if (storageHandlerRef.current) { + console.warn( + 'RTMGlobalStateProvider: Overwriting an existing main channel storage handler!', + ); + } + storageHandlerRef.current = handler; + }; + const unregisterMainChannelStorageHandler = () => { + console.log( + 'rudra-core-client: RTM unregistering main channel storage handler', + ); + storageHandlerRef.current = null; + }; + + // Update main rtm users state + const updateMainRoomUser = (uid: number, data: RTMUserData) => { + setMainRoomRTMUsers(prev => ({ + ...prev, + [uid]: {...(prev[uid] || {}), ...data}, + })); + }; + + // Init cycle starts + const init = async () => { + try { + console.log('rudra-core-client: Starting RTM init for main channel'); + await subscribeChannel(); + await getMembersWithAttributes(); + await getChannelAttributes(); + console.log('rudra-core-client: RTM init completed successfully'); + } catch (error) { + console.log('rudra-core-client: RTM init failed', error); + } + }; + + const subscribeChannel = async () => { + try { + if (RTMEngine.getInstance().allChannelIds.includes(mainChannelName)) { + console.log('rudra- main channel already subsribed'); + } else { + console.log('rudra- subscribing...'); + await client.subscribe(mainChannelName, { + withMessage: true, + withPresence: true, + withMetadata: true, + withLock: false, + }); + console.log('rudra- subscribed main channel', mainChannelName); + + RTMEngine.getInstance().addChannel(RTM_ROOMS.MAIN, mainChannelName); + RTMEngine.getInstance().setActiveChannelName(RTM_ROOMS.MAIN); + SDKEvents.emit('_rtm-joined', mainChannelName); + + subscribeTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (subscribeTimeoutRef.current) { + clearTimeout(subscribeTimeoutRef.current); + subscribeTimeoutRef.current = null; + } + console.log('rudra- added to rtm engine'); + } + } catch (error) { + console.log( + 'rudra-core-client: RTM subscribeChannel failed..Trying again', + error, + ); + subscribeTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + subscribeTimerRef.current = Math.min(subscribeTimerRef.current * 2, 30); + subscribeChannel(); + }, subscribeTimerRef.current * 1000); + } + }; + + const getMembersWithAttributes = async () => { + try { + console.log( + 'rudra-core-client: RTM presence.getOnlineUsers(getMembers) start', + ); + const {allMembers, totalOccupancy} = await fetchOnlineMembersWithRetries( + client, + mainChannelName, + { + onPage: async ({occupants, total, pageToken}) => { + console.log( + 'rudra-core-client: fetching user attributes for page: ', + pageToken, + occupants, + ); + await Promise.all( + occupants.map(async member => { + try { + const userAttributes = await fetchUserAttributesWithRetries( + client, + member.userId, + { + isMounted: () => isRTMMounted.current, + // called later if name arrives + onNameFound: async retryAttr => + mapUserAttributesToState( + retryAttr, + member.userId, + updateMainRoomUser, + ), + }, + ); + console.log( + `supriya rtm backoffAttributes user-attributes for ${member.userId}`, + userAttributes, + ); + mapUserAttributesToState( + userAttributes, + member.userId, + updateMainRoomUser, + ); + + userAttributes?.items?.forEach(item => { + processUserAttributeForQueue( + item, + member.userId, + RTM_ROOMS.MAIN, + (eventKey, value, userId) => { + const data = {evt: eventKey, value}; + console.log( + 'supriya-session-test adding to queue', + data, + ); + EventsQueue.enqueue({ + data, + uid: userId, + ts: timeNow(), + }); + }, + ); + }); + } catch (e) { + console.log( + 'rudra-core-client: RTM Could not retrieve name of', + member.userId, + e, + ); + } + }), + ); + }, + }, + ); + + membersTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (membersTimeoutRef.current) { + clearTimeout(membersTimeoutRef.current); + membersTimeoutRef.current = null; + } + console.log( + 'rudra-core-client: RTM fetched all data and user attr...RTM init done', + ); + // }); + } catch (error) { + membersTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + membersTimerRef.current = Math.min(membersTimerRef.current * 2, 30); + await getMembersWithAttributes(); + }, membersTimerRef.current * 1000); + } + }; + + const getChannelAttributes = async () => { + try { + await fetchChannelAttributesWithRetries( + client, + mainChannelName, + eventData => EventsQueue.enqueue(eventData), + ); + channelAttributesTimerRef.current = 5; + // Clear any pending retry timeout since we succeeded + if (channelAttributesTimeoutRef.current) { + clearTimeout(channelAttributesTimeoutRef.current); + channelAttributesTimeoutRef.current = null; + } + } catch (error) { + console.log( + 'rudra-core-client: RTM getchannelattributes failed..Trying again', + error, + ); + channelAttributesTimeoutRef.current = setTimeout(async () => { + // Cap the timer to prevent excessive delays (max 30 seconds) + channelAttributesTimerRef.current = Math.min( + channelAttributesTimerRef.current * 2, + 30, + ); + getChannelAttributes(); + }, channelAttributesTimerRef.current * 1000); + } + }; + + // Listeners + const handleMainChannelPresenceEvent = async (presence: PresenceEvent) => { + console.log( + 'rudra-core-client: RTM presence event received for different channel', + presence.channelName, + 'expected:', + mainChannelName, + ); + if (presence.channelName !== mainChannelName) { + console.log( + 'rudra-core-client: RTM presence event received for different channel', + presence.channelName, + 'expected:', + mainChannelName, + ); + return; + } + + // remoteJoinChannel + if (presence.type === nativePresenceEventTypeMapping.REMOTE_JOIN) { + console.log( + 'rudra-core-client: RTM main room user joined', + presence.publisher, + ); + try { + const userAttributes = await fetchUserAttributesWithRetries( + client, + presence.publisher, + { + isMounted: () => isRTMMounted.current, + // This is called later if name arrives and hence we process that attribute + onNameFound: retriedAttributes => + mapUserAttributesToState( + retriedAttributes, + presence.publisher, + updateMainRoomUser, + ), + }, + ); + // This is called as soon as we receive any attributes + mapUserAttributesToState( + userAttributes, + presence.publisher, + updateMainRoomUser, + ); + } catch (error) { + console.log( + 'rudra-core-client: RTM Failed to process user who joined main room', + presence.publisher, + error, + ); + } + } + + // remoteLeaveChannel + if (presence.type === nativePresenceEventTypeMapping.REMOTE_LEAVE) { + console.log( + 'rudra-core-client: RTM main room user left', + presence.publisher, + ); + const uid = presence?.publisher + ? parseInt(presence.publisher, 10) + : undefined; + + if (!uid) { + return; + } + SDKEvents.emit('_rtm-left', uid); + // Mark user as offline (matching legacy channelMemberLeft behavior) + setMainRoomRTMUsers(prev => { + const updated = {...prev}; + + if (updated[uid]) { + updated[uid] = { + ...updated[uid], + offline: true, + }; + } + + // Also mark screenshare as offline if exists + // const screenUid = prev[uid]?.screenUid; + // if (screenUid && updated[screenUid]) { + // updated[screenUid] = { + // ...updated[screenUid], + // offline: true, + // }; + // } + + console.log( + 'rudra-core-client: RTM marked user as offline in main room', + uid, + ); + return updated; + }); + } + }; + + const handleMainChannelStorageEvent = async (storage: StorageEvent) => { + console.log( + 'rudra-core-client: RTM global storage event received', + storage, + ); + + // Only handle SET/UPDATE events for metadata persistence + if ( + storage.eventType === nativeStorageEventTypeMapping.SET || + storage.eventType === nativeStorageEventTypeMapping.UPDATE + ) { + const storageTypeStr = storage.storageType === 1 ? 'user' : 'channel'; + const eventTypeStr = storage.eventType === 2 ? 'SET' : 'UPDATE'; + console.log( + `rudra-core-client: RTM processing ${eventTypeStr} ${storageTypeStr} metadata`, + ); + + // // STEP 1: Handle metadata persistence FIRST (core RTM functionality) + // try { + // if (storage.data?.items && Array.isArray(storage.data.items)) { + // for (const item of storage.data.items) { + // try { + // if (!item || !item.key) { + // console.log( + // 'rudra-core-client: RTM invalid storage item:', + // item, + // ); + // continue; + // } + + // const {key, value, authorUserId, updateTs} = item; + + // // Parse the value to check persistLevel + // let parsedValue; + // try { + // parsedValue = + // typeof value === 'string' ? JSON.parse(value) : value; + // } catch (parseError) { + // console.log( + // 'rudra-core-client: RTM failed to parse storage event value:', + // parseError, + // ); + // continue; + // } + + // const {persistLevel} = parsedValue; + + // // Handle metadata persistence for Session level events + // if (persistLevel === PersistanceLevel.Session) { + // console.log( + // 'rudra-core-client: RTM setting user metadata for key:', + // key, + // ); + + // const rtmAttribute = {key: key, value: value}; + // const options: SetOrUpdateUserMetadataOptions = { + // userId: `${localUid}`, + // }; + + // try { + // await client.storage.setUserMetadata( + // {items: [rtmAttribute]}, + // options, + // ); + // console.log( + // 'rudra-core-client: RTM successfully set user metadata for key:', + // key, + // ); + // } catch (setMetadataError) { + // console.log( + // 'rudra-core-client: RTM failed to set user metadata:', + // setMetadataError, + // ); + // } + // } + // } catch (itemError) { + // console.log( + // 'rudra-core-client: RTM failed to process storage item:', + // item, + // itemError, + // ); + // } + // } + // } + // } catch (error) { + // console.log( + // 'rudra-core-client: RTM error processing storage event:', + // error, + // ); + // } + + // STEP 2: Forward to application logic AFTER metadata persistence + if (storageHandlerRef.current) { + try { + storageHandlerRef.current(storage); + console.log( + 'rudra-core-client: RTM forwarded storage event to registered handler', + ); + } catch (error) { + console.log( + 'rudra-core-client: RTM error forwarding storage event:', + error, + ); + } + } + } + }; + + const handleMainChannelMessageEvent = async (message: MessageEvent) => { + console.log( + 'rudra-core-client: RTM main channel message event received', + message, + ); + + // Check if this is a SESSION-level event and persist it + try { + if (hasJsonStructure(message.message)) { + const parsed = JSON.parse(message.message); + const {evt, value} = parsed; + + if (value && hasJsonStructure(value)) { + const parsedValue = JSON.parse(value); + const {persistLevel, _channelId} = parsedValue; + + // If this is a SESSION-level event from main channel, store it on local user's attributes + if ( + persistLevel === PersistanceLevel.Session && + _channelId === mainChannelName + ) { + // const roomAwareKey = `${RTM_ROOMS.MAIN}__${evt}`; + const rtmAttribute = {key: evt, value: value}; + + const options: SetOrUpdateUserMetadataOptions = { + userId: `${localUid}`, + }; + await client.storage.setUserMetadata( + {items: [rtmAttribute]}, + options, + ); + // console.log( + // 'rudra-core-client: Stored SESSION attribute cross-room', + // roomAwareKey, + // ); + } + } + } + } catch (error) { + console.log( + 'rudra-core-client: RTM error storing session attribute:', + error, + ); + } + + // Forward to registered message handler (RTMConfigure) + if (messageHandlerRef.current) { + try { + messageHandlerRef.current(message); + console.log( + 'rudra-core-client: RTM forwarded message event to registered handler', + ); + } catch (error) { + console.log( + 'rudra-core-client: RTM error forwarding message event:', + error, + ); + } + } else { + console.log( + 'rudra-core-client: RTM no message handler registered for main channel', + ); + } + }; + + // Main initialization effect + useEffect(() => { + if (!client || !isLoggedIn || !mainChannelName || hasInitRef.current) { + return; + } + hasInitRef.current = true; + console.log('RTMGlobalStateProvider: Client ready, starting init'); + // Register presence, storage, and message event callbacks for main channel + registerCallbacks(mainChannelName, { + presence: handleMainChannelPresenceEvent, + storage: handleMainChannelStorageEvent, + message: handleMainChannelMessageEvent, + }); + console.log( + 'rudra-core-client: RTM registered presence, storage, and message callbacks for main channel', + ); + init(); + return () => { + console.log('rudra-core-client: main state cleanup'); + hasInitRef.current = false; + isRTMMounted.current = false; + if (mainChannelName) { + unregisterCallbacks(mainChannelName); + if (RTMEngine.getInstance().hasChannel(mainChannelName)) { + client?.unsubscribe(mainChannelName).catch(() => {}); + RTMEngine.getInstance().removeChannel(RTM_ROOMS.MAIN); + } + } + + // Clear timer-based retry timeouts + if (subscribeTimeoutRef.current) { + clearTimeout(subscribeTimeoutRef.current); + subscribeTimeoutRef.current = null; + } + if (membersTimeoutRef.current) { + clearTimeout(membersTimeoutRef.current); + membersTimeoutRef.current = null; + } + if (channelAttributesTimeoutRef.current) { + clearTimeout(channelAttributesTimeoutRef.current); + channelAttributesTimeoutRef.current = null; + } + }; + }, [client, isLoggedIn, mainChannelName]); + + return ( + + {children} + + ); +}; + +// Hook to use main channel message registration +export const useRTMGlobalState = () => { + const context = React.useContext(RTMGlobalStateContext); + if (!context) { + throw new Error( + 'useRTMMainChannel must be used within RTMGlobalStateProvider', + ); + } + return context; +}; + +export default RTMGlobalStateProvider; diff --git a/template/src/rtm/RTMStatusBanner.tsx b/template/src/rtm/RTMStatusBanner.tsx new file mode 100644 index 000000000..74acd206d --- /dev/null +++ b/template/src/rtm/RTMStatusBanner.tsx @@ -0,0 +1,99 @@ +import React, {useState, useEffect} from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import {useRTMCore} from './RTMCoreProvider'; +import {nativeLinkStateMapping} from '../../bridge/rtm/web/Types'; + +export const RTMStatusBanner = () => { + const {connectionState, error, isLoggedIn} = useRTMCore(); + + // internal debounced copy of the state to prevent flicker + const [visibleState, setVisibleState] = useState(connectionState); + const [visibleError, setVisibleError] = useState(error); + + useEffect(() => { + const timeout = setTimeout(() => { + setVisibleState(connectionState); + setVisibleError(error); + }, 700); // debounce 700 ms + return () => clearTimeout(timeout); + }, [connectionState, error]); + + // Don't show banner if connected and logged in with no errors + if ( + visibleState === nativeLinkStateMapping.CONNECTED && + isLoggedIn && + !visibleError + ) { + return null; + } + + let message = ''; + let isError = false; + + if (visibleError || visibleState === nativeLinkStateMapping.FAILED) { + // Login failed - critical error + message = + 'RTM connection failed. App might not work correctly. Retrying...'; + isError = true; + } else if (visibleState === nativeLinkStateMapping.DISCONNECTED) { + // RTM disconnected in the middle of the call - retrying + message = 'RTM disconnected β€” retrying connection...'; + isError = false; + } else if ( + visibleState === nativeLinkStateMapping.IDLE || + visibleState === nativeLinkStateMapping.CONNECTING + ) { + // RTM is idle or connecting + message = 'RTM connecting...'; + isError = false; + } else if (!isLoggedIn) { + // Not logged in but no explicit error yet + message = 'RTM connecting...'; + isError = false; + } + + // Don't render banner if there's no message to show + if (!message) { + return null; + } + + return ( + + + {message} + + + ); +}; + +const styles = StyleSheet.create({ + banner: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + paddingVertical: 8, + paddingHorizontal: 16, + zIndex: 9999, + alignItems: 'center', + justifyContent: 'center', + }, + error: { + backgroundColor: '#f8d7da', + }, + warning: { + backgroundColor: '#fff3cd', + }, + text: { + fontSize: 14, + fontWeight: '500', + textAlign: 'center', + }, + errorText: { + color: '#721c24', + }, + warningText: { + color: '#856404', + }, +}); diff --git a/template/src/rtm/constants.ts b/template/src/rtm/constants.ts new file mode 100644 index 000000000..babee888e --- /dev/null +++ b/template/src/rtm/constants.ts @@ -0,0 +1,12 @@ +import {EventNames} from '../rtm-events'; + +export enum RTM_ROOMS { + BREAKOUT = 'BREAKOUT', + MAIN = 'MAIN', +} + +// RTM attributes to reset when room changes +export const RTM_EVENTS_ATTRIBUTES_TO_RESET_WHEN_ROOM_CHANGES = [ + EventNames.RAISED_ATTRIBUTE, // (livestream) + EventNames.BREAKOUT_RAISE_HAND_ATTRIBUTE, // Breakout room raise hand ( will be made into independent) +] as const; diff --git a/template/src/rtm/hooks/useMainRoomUserDisplayName.ts b/template/src/rtm/hooks/useMainRoomUserDisplayName.ts new file mode 100644 index 000000000..d353a299a --- /dev/null +++ b/template/src/rtm/hooks/useMainRoomUserDisplayName.ts @@ -0,0 +1,45 @@ +/* +******************************************** + Copyright Β© 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the "Materials") are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.'s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ + +import {useCallback} from 'react'; +import {videoRoomUserFallbackText} from '../../language/default-labels/videoCallScreenLabels'; +import {useString} from '../../utils/useString'; +import {UidType} from '../../../agora-rn-uikit'; +import {useRTMGlobalState} from '../RTMGlobalStateProvider'; +import {useContent} from 'customization-api'; + +/** + * Hook to get user display names with fallback to main room RTM users + * This ensures users in breakout rooms can see names of users in other rooms + */ +export const useMainRoomUserDisplayName = () => { + const {mainRoomRTMUsers} = useRTMGlobalState(); + const {defaultContent} = useContent(); + const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + + const sanitize = (name?: string) => name?.trim() || undefined; + + /** + * useCallback ensures the returned function updates whenever + * defaultContent or mainRoomRTMUsers change + */ + return useCallback( + (uid: UidType): string => { + return ( + sanitize(defaultContent?.[uid]?.name) || + sanitize(mainRoomRTMUsers?.[uid]?.name) || + remoteUserDefaultLabel + ); + }, + [defaultContent, mainRoomRTMUsers, remoteUserDefaultLabel], + ); +}; diff --git a/template/src/rtm/rtm-presence-utils.ts b/template/src/rtm/rtm-presence-utils.ts new file mode 100644 index 000000000..487d63af0 --- /dev/null +++ b/template/src/rtm/rtm-presence-utils.ts @@ -0,0 +1,344 @@ +import React from 'react'; +import {backOff} from 'exponential-backoff'; +import {LogSource, logger} from '../logger/AppBuilderLogger'; +import { + type GetUserMetadataResponse as NativeGetUserMetadataResponse, + type GetOnlineUsersResponse as NativeGetOnlineUsersResponse, + type GetChannelMetadataResponse, + type RTMClient, + type UserState, + type MetadataItem, +} from 'agora-react-native-rtm'; +import {RTMUserData} from './RTMGlobalStateProvider'; +import {RECORDING_BOT_UID} from '../utils/constants'; +import {hasJsonStructure} from '../rtm/utils'; +import {nativeChannelTypeMapping} from '../../bridge/rtm/web/Types'; + +export const fetchOnlineMembersWithRetries = async ( + client: RTMClient, + channelName: string, + { + onPage, // πŸ‘ˆ callback so caller can process each page as soon as it's ready + }: { + onPage?: (page: { + occupants: UserState[]; + total: number; + pageToken?: string; + }) => void | Promise; + } = {}, +) => { + let allMembers: any[] = []; + let nextPage: string | undefined; + let totalOccupancy = 0; + + const fetchPage = async (pageNumber?: string) => { + return backOff( + async () => { + const result: NativeGetOnlineUsersResponse = + await client.presence.getOnlineUsers( + channelName, + nativeChannelTypeMapping.MESSAGE, + {page: pageNumber}, // cursor for pagination + ); + return result; + }, + { + numOfAttempts: 3, + retry: (e, attempt) => { + console.warn( + `[RTM] Page fetch failed (attempt ${attempt}/3). Retrying…`, + e, + ); + return attempt < 3; // πŸ‘ˆ stop retrying after 3rd attempt + }, + }, + ); + }; + + do { + try { + const result = await fetchPage(nextPage); + const {totalOccupancy: total, occupants, nextPage: next} = result; + if (occupants) { + allMembers = allMembers.concat(occupants); + // process this page immediately + await onPage?.({occupants, total, pageToken: nextPage}); + } + totalOccupancy = total; + nextPage = next; + console.log( + `[RTM] Fetched ${allMembers.length}/${totalOccupancy} users, nextPage=${nextPage}`, + ); + } catch (fetchPageError) { + console.error(`[RTM] Page ${nextPage || 'first'} failed`, fetchPageError); + // πŸ‘‰ Skip to next page if this one keeps failing + nextPage = undefined; + } + } while (nextPage && nextPage.trim() !== ''); + + return {allMembers, totalOccupancy}; +}; + +export const fetchUserAttributesWithRetries = async ( + client: RTMClient, + userId: string, + opts?: { + isMounted?: () => boolean; // <-- injected check + onNameFound?: (attr: NativeGetUserMetadataResponse) => void; + }, +): Promise => { + return backOff( + async () => { + console.log( + 'rudra-core-client: RTM fetching getUserMetadata for member', + userId, + ); + + // Fetch attributes + const attr: NativeGetUserMetadataResponse = + await client.storage.getUserMetadata({userId}); + console.log('[user attributes', attr); + + // 1. Check if attributes exist + if (!attr || !attr.items || attr.items.length === 0) { + console.log('rudra-core-client: RTM attributes for member not found'); + throw new Error('No attribute items found'); + } + console.log('sup-attribute-check user-attributes', attr); + + // 2. Partial update allowed (screenUid, isHost, etc.) + const hasAny = attr.items.some(i => i.value); + if (!hasAny) { + throw new Error('No usable user-attributes yet'); + } + console.log('sup-attribute-check user-attributes hasAny', hasAny); + + // 3. If name exists, return immediately + const hasNameAttribute = attr.items.find( + i => i.key === 'name' && i.value, + ); + console.log('sup-attribute-check user-attributes name', hasNameAttribute); + if (hasNameAttribute) { + return attr; + } + // 4. Background retry for name only + (async () => { + await backOff( + async () => { + // πŸ”’ Stop if unmounted + if (opts?.isMounted && !opts?.isMounted) { + throw new Error(`Component unmounted while retrying ${userId}`); + } + console.log( + 'sup-attribute-check user-attributes inside name backoff', + ); + + const retriedAttributes: NativeGetUserMetadataResponse = + await client.storage.getUserMetadata({userId}); + console.log( + 'sup-attribute-check user-attributes retriedAttributes', + retriedAttributes, + ); + + const hasNameAttributeRetry = retriedAttributes.items.find( + i => i.key === 'name' && i.value, + ); + console.log( + 'sup-attribute-check user-attributes hasNameAttributeRetry', + hasNameAttributeRetry, + ); + + if (!hasNameAttributeRetry) { + throw new Error('user-attributes Name still not found'); + } + + if (opts?.isMounted) { + console.log( + 'sup-attribute-check user-attributes onNameFound', + retriedAttributes, + ); + opts?.onNameFound?.(retriedAttributes); + } + return retriedAttributes; + }, + { + retry: () => true, + maxDelay: Infinity, // No maximum delay limit for name + numOfAttempts: Infinity, // Infinite attempts for name + }, + ).catch(() => { + console.log( + `Name not found for ${userId} within 30s, giving up further retries`, + ); + }); + })(); + + return attr; + }, + { + retry: (e, idx) => { + logger.debug( + LogSource.AgoraSDK, + 'Log', + `[retrying] Attempt ${idx}. Fetching ${userId}'s name`, + e, + ); + return true; + }, + }, + ); +}; + +export const mapUserAttributesToState = ( + attr: NativeGetUserMetadataResponse, + userId: string, + updateFn: (uid: number, userData: Partial) => void, +) => { + try { + const uid = parseInt(userId, 10); + const screenUidItem = attr?.items?.find(item => item.key === 'screenUid'); + const isHostItem = attr?.items?.find(item => item.key === 'isHost'); + const nameItem = attr?.items?.find(item => item.key === 'name'); + const screenUid = screenUidItem?.value + ? parseInt(screenUidItem.value, 10) + : undefined; + + let userName = ''; + if (nameItem?.value) { + try { + const parsedValue = JSON.parse(nameItem.value); + const payloadString = parsedValue.payload; + if (payloadString) { + const payload = JSON.parse(payloadString); + userName = payload.name; + } + } catch { + // ignore parse errors + } + } + + // --- Update main user RTM data + const userData: RTMUserData = { + uid, + type: uid === parseInt(RECORDING_BOT_UID, 10) ? 'bot' : 'rtc', + screenUid, + name: userName, + offline: false, + isHost: isHostItem?.value || 'false', + lastMessageTimeStamp: 0, + }; + + updateFn(uid, userData); + + // --- Update screenshare RTM data if present + if (screenUid) { + const screenShareData: RTMUserData = { + type: 'screenshare', + parentUid: uid, + }; + updateFn(screenUid, screenShareData); + } + } catch (e) { + console.log('RTM Failed to process user data for', userId, e); + } +}; + +export const fetchChannelAttributesWithRetries = async ( + client: RTMClient, + channelName: string, + updateFn?: (eventData: {data: any; uid: string; ts: number}) => void, +) => { + try { + await client.storage + .getChannelMetadata(channelName, nativeChannelTypeMapping.MESSAGE) + .then(async (data: GetChannelMetadataResponse) => { + for (const item of data.items) { + try { + const {key, value, authorUserId, updateTs} = item; + if (hasJsonStructure(value as string)) { + const evtData = { + evt: key, + value, + }; + updateFn?.({ + data: evtData, + uid: authorUserId, + ts: updateTs, + }); + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + `RTM Failed to process channel attribute item: ${JSON.stringify( + item, + )}`, + {error}, + ); + } + } + }); + } catch (error) {} +}; + +export const clearRoomScopedUserAttributes = async ( + client: RTMClient, + attributeKeys: readonly string[], +) => { + try { + await client?.storage.removeUserMetadata({ + data: { + items: attributeKeys.map(key => ({ + key, + value: '', + })), + }, + }); + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'RTMConfigure', + 'Failed to clear room-scoped attributes', + {error}, + ); + } +}; + +export const processUserAttributeForQueue = ( + item: MetadataItem, + userId: string, + currentRoomKey: string, + onProcessedEvent: (eventKey: string, value: string, userId: string) => void, +) => { + try { + if (hasJsonStructure(item.value as string)) { + let eventKey = item.key; + try { + // const parsedValue = JSON.parse(item.value); + // if (parsedValue.persistLevel === PersistanceLevel.Session) { + // const strippedKey = stripRoomPrefixFromEventKey( + // item.key, + // currentRoomKey, + // ); + // if (strippedKey === null) { + // console.log( + // 'Skipping SESSION attribute for different room:', + // item.key, + // ); + // return; + // } + // eventKey = strippedKey; + // } + + onProcessedEvent(eventKey, item.value, userId); + } catch (e) {} + } + } catch (error) { + logger.error( + LogSource.AgoraSDK, + 'Log', + 'RTM Failed to process user attribute item', + {error}, + ); + } +}; diff --git a/template/src/rtm/utils.ts b/template/src/rtm/utils.ts index 87e226da0..07645696a 100644 --- a/template/src/rtm/utils.ts +++ b/template/src/rtm/utils.ts @@ -1,5 +1,9 @@ +import {RTM_EVENT_SCOPE} from '../rtm-events'; + export const hasJsonStructure = (str: string) => { - if (typeof str !== 'string') return false; + if (typeof str !== 'string') { + return false; + } try { const result = JSON.parse(str); const type = Object.prototype.toString.call(result); @@ -42,3 +46,66 @@ export const get32BitUid = (peerId: string) => { arr[0] = parseInt(peerId); return arr[0]; }; + +export function isEventForActiveChannel( + scope: RTM_EVENT_SCOPE, + eventChannelId: string | undefined, + currentChannel: string, +): boolean { + // Case 1: Events without scope/channel β†’ assume SERVER event β†’ always pass + if (!scope && !eventChannelId) { + console.log( + 'isEventForActiveChannel: passing server event (no scope/channel)', + ); + return true; + } + + // Case 2: Global events always pass + if (scope === RTM_EVENT_SCOPE.GLOBAL) { + return true; + } + + // Case 3: Local and Session scope must match current channel + if ( + (scope === RTM_EVENT_SCOPE.LOCAL || scope === RTM_EVENT_SCOPE.SESSION) && + eventChannelId !== currentChannel + ) { + console.log( + `isEventForActiveChannel: skipped ${scope.toLowerCase()} event (expected=${currentChannel}, got=${eventChannelId})`, + ); + return false; + } + // Default: allow + return true; +} + +// export function stripRoomPrefixFromEventKey( +// eventKey: string, +// currentRoomKey: string, +// ): string | null { +// // Event key +// if (!eventKey) { +// return eventKey; +// } + +// // Only handle room-aware keys +// if (!eventKey.startsWith(`${currentRoomKey}__`)) { +// return eventKey; +// } + +// // Format: room____ +// const parts = eventKey.split('__'); +// const [roomKey, ...evtParts] = parts; + +// // If the roomKey matches current room, strip and return event name +// if (roomKey === currentRoomKey) { +// return evtParts.join('__'); +// } + +// // If the roomKey is "MAIN" or "BREAKOUT" but doesn't match current room β†’ skip +// if (roomKey === RTM_ROOMS.MAIN || roomKey === RTM_ROOMS.BREAKOUT) { +// return null; +// } + +// return null; +// } diff --git a/template/src/subComponents/ChatBubble.tsx b/template/src/subComponents/ChatBubble.tsx index 68ede02d1..14fab0627 100644 --- a/template/src/subComponents/ChatBubble.tsx +++ b/template/src/subComponents/ChatBubble.tsx @@ -52,6 +52,7 @@ import {useChatConfigure} from '../../src/components/chat/chatConfigure'; import Tooltip from '../../src/atoms/Tooltip'; import {MoreMessageOptions} from './chat/ChatQuickActionsMenu'; import {EMessageStatus} from '../../src/ai-agent/components/AgentControls/message'; +import {useMainRoomUserDisplayName} from '../rtm/hooks/useMainRoomUserDisplayName'; type AttachmentBubbleProps = { fileName: string; @@ -362,6 +363,7 @@ const ChatBubble = (props: ChatBubbleProps) => { //commented for v1 release //const remoteUserDefaultLabel = useString('remoteUserDefaultLabel')(); const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + const getDisplayName = useMainRoomUserDisplayName(); const getUsername = () => { if (isLocal) { @@ -370,9 +372,7 @@ const ChatBubble = (props: ChatBubbleProps) => { if (remoteUIConfig?.username) { return trimText(remoteUIConfig?.username); } - return defaultContent[uid]?.name - ? trimText(defaultContent[uid].name) - : remoteUserDefaultLabel; + return getDisplayName(uid); }; return props?.render ? ( diff --git a/template/src/subComponents/ChatContainer.tsx b/template/src/subComponents/ChatContainer.tsx index 994563bfd..51e81cd0e 100644 --- a/template/src/subComponents/ChatContainer.tsx +++ b/template/src/subComponents/ChatContainer.tsx @@ -27,7 +27,7 @@ import { } from 'react-native'; import {RFValue} from 'react-native-responsive-fontsize'; import ChatBubble from './ChatBubble'; -import {ChatBubbleProps} from '../components/ChatContext'; +import ChatContext, {ChatBubbleProps} from '../components/ChatContext'; import { DispatchContext, ContentInterface, @@ -59,6 +59,7 @@ import { } from '../language/default-labels/videoCallScreenLabels'; import CommonStyles from '../components/CommonStyles'; import PinnedMessage from './chat/PinnedMessage'; +import ChatAnnouncementView from './chat/ChatAnnouncementView'; /** * Chat container is the component which renders all the chat messages @@ -71,6 +72,7 @@ const ChatContainer = (props?: { const info1 = useString(groupChatWelcomeContent); const [scrollToEnd, setScrollToEnd] = useState(false); const {dispatch} = useContext(DispatchContext); + const {syncUserState} = useContext(ChatContext); const [grpUnreadCount, setGrpUnreadCount] = useState(0); const [privateUnreadCount, setPrivateUnreadCount] = useState(0); const {defaultContent} = useContent(); @@ -118,16 +120,18 @@ const ChatContainer = (props?: { }); //Once message is seen, reset lastMessageTimeStamp. //so whoever has unread count will show in the top of participant list - updateRenderListState(privateChatUser, {lastMessageTimeStamp: 0}); + // updateRenderListState(privateChatUser, {lastMessageTimeStamp: 0}); + syncUserState(privateChatUser, {lastMessageTimeStamp: 0}); } }, [privateChatUser]); - const updateRenderListState = ( - uid: number, - data: Partial, - ) => { - dispatch({type: 'UpdateRenderList', value: [uid, data]}); - }; + // We will be using syncRoomusers + // const updateRenderListState = ( + // uid: number, + // data: Partial, + // ) => { + // dispatch({type: 'UpdateRenderList', value: [uid, data]}); + // }; const onScroll = event => { setScrollOffset(event.nativeEvent.contentOffset.y); @@ -249,7 +253,13 @@ const ChatContainer = (props?: { ) : null} - {!message?.hide ? ( + + {message?.announcement ? ( + + ) : !message?.hide ? ( { + const onPress = async () => { logger.log( LogSource.Internals, 'LOCAL_MUTE', @@ -95,7 +95,7 @@ function LocalAudioMute(props: LocalAudioMuteProps) { permissionDenied, }, ); - localMute(MUTE_LOCAL_TYPE.audio); + await localMute(MUTE_LOCAL_TYPE.audio); }; const audioLabel = permissionDenied ? micButtonLabel(I18nDeviceStatus.PERMISSION_DENIED) diff --git a/template/src/subComponents/LocalVideoMute.tsx b/template/src/subComponents/LocalVideoMute.tsx index 14c086012..45582e647 100644 --- a/template/src/subComponents/LocalVideoMute.tsx +++ b/template/src/subComponents/LocalVideoMute.tsx @@ -79,7 +79,7 @@ function LocalVideoMute(props: LocalVideoMuteProps) { ); const lstooltip = useString(livestreamingCameraTooltipText); - const onPress = () => { + const onPress = async () => { //if screensharing is going on native - to turn on video screenshare should be turn off //show confirm popup to stop the screenshare logger.log( @@ -91,7 +91,7 @@ function LocalVideoMute(props: LocalVideoMuteProps) { permissionDenied, }, ); - localMute(MUTE_LOCAL_TYPE.video); + await localMute(MUTE_LOCAL_TYPE.video); }; const isVideoEnabled = local.video === ToggleState.enabled; diff --git a/template/src/subComponents/SidePanelEnum.tsx b/template/src/subComponents/SidePanelEnum.tsx index 0ac6cec07..f784930cc 100644 --- a/template/src/subComponents/SidePanelEnum.tsx +++ b/template/src/subComponents/SidePanelEnum.tsx @@ -16,4 +16,5 @@ export enum SidePanelType { Settings = 'Settings', Transcript = 'Transcript', VirtualBackground = 'VirtualBackground', + BreakoutRoom = 'BreakoutRoom', } diff --git a/template/src/subComponents/caption/useCaption.tsx b/template/src/subComponents/caption/useCaption.tsx index a924d4d2c..756f4d2ee 100644 --- a/template/src/subComponents/caption/useCaption.tsx +++ b/template/src/subComponents/caption/useCaption.tsx @@ -72,7 +72,7 @@ export const CaptionContext = React.createContext<{ }); interface CaptionProviderProps { - callActive: boolean; + callActive?: boolean; children: React.ReactNode; } diff --git a/template/src/subComponents/chat/ChatAnnouncementView.tsx b/template/src/subComponents/chat/ChatAnnouncementView.tsx new file mode 100644 index 000000000..ba5531d1a --- /dev/null +++ b/template/src/subComponents/chat/ChatAnnouncementView.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {View, StyleSheet, Text} from 'react-native'; +import ImageIcon from '../../atoms/ImageIcon'; +import ThemeConfig from '../../theme'; +import {AnnouncementMessage} from '../../components/chat-messages/useChatMessages'; + +export default function ChatAnnouncementView({ + message, + announcement, +}: { + message: string; + announcement: AnnouncementMessage; +}) { + return ( + + + + + Message from host: {announcement.sender} + + {message} + + + ); +} + +const style = StyleSheet.create({ + announcementView: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'flex-start', + marginRight: 12, + marginLeft: 12, + marginBottom: 4, + marginTop: 16, + gap: 6, + }, + announcementMessage: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + announcementMessageHeader: { + fontSize: ThemeConfig.FontSize.tiny, + fontStyle: 'normal', + fontFamily: ThemeConfig.FontFamily.sansPro, + color: '#099DFD', + fontWeight: '700', + lineHeight: 14, + }, + announcementMessageBody: { + fontSize: ThemeConfig.FontSize.tiny, + fontStyle: 'italic', + fontFamily: ThemeConfig.FontFamily.sansPro, + color: '#099DFD', + fontWeight: '400', + lineHeight: 14, + }, +}); diff --git a/template/src/subComponents/chat/ChatSendButton.tsx b/template/src/subComponents/chat/ChatSendButton.tsx index 9c1ef8594..89c3722c8 100644 --- a/template/src/subComponents/chat/ChatSendButton.tsx +++ b/template/src/subComponents/chat/ChatSendButton.tsx @@ -71,6 +71,7 @@ export const handleChatSend = ({ return; } + // Text message sent update sender side ui const sendTextMessage = () => { const option = { chatType: selectedUserId diff --git a/template/src/subComponents/screenshare/ScreenshareButton.tsx b/template/src/subComponents/screenshare/ScreenshareButton.tsx index d34b607ac..db5171d4e 100644 --- a/template/src/subComponents/screenshare/ScreenshareButton.tsx +++ b/template/src/subComponents/screenshare/ScreenshareButton.tsx @@ -25,6 +25,7 @@ import { toolbarItemShareText, } from '../../language/default-labels/videoCallScreenLabels'; import {useToolbarProps} from '../../atoms/ToolbarItem'; +import {useBreakoutRoom} from '../../components/breakout-room/context/BreakoutRoomContext'; /** * A component to start and stop screen sharing on web clients. * Screen sharing is not yet implemented on mobile platforms. @@ -53,6 +54,11 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { const {setShowStartScreenSharePopup} = useVideoCall(); const screenShareButtonLabel = useString(toolbarItemShareText); const lstooltip = useString(livestreamingShareTooltipText); + const {permissions} = useBreakoutRoom(); + // In the main room (default case), permissions come from the main room state. + // If the user is in a breakout room, retrieve permissions from the breakout room instead. + const canScreenshareInBreakoutRoom = permissions.canScreenshare; + const onPress = () => { if (isScreenshareActive) { stopScreenshare(); @@ -105,6 +111,16 @@ const ScreenshareButton = (props: ScreenshareButtonProps) => { iconButtonProps.disabled = true; } + if (!canScreenshareInBreakoutRoom) { + iconButtonProps.iconProps = { + ...iconButtonProps.iconProps, + tintColor: $config.SEMANTIC_NEUTRAL, + showWarningIcon: true, + }; + iconButtonProps.toolTipMessage = 'cannot screenshare'; + iconButtonProps.disabled = true; + } + return props?.render ? ( props.render(onPress, isScreenshareActive) ) : isToolbarMenuItem ? ( diff --git a/template/src/subComponents/screenshare/ScreenshareConfigure.native.tsx b/template/src/subComponents/screenshare/ScreenshareConfigure.native.tsx index b94449df4..61ce79867 100644 --- a/template/src/subComponents/screenshare/ScreenshareConfigure.native.tsx +++ b/template/src/subComponents/screenshare/ScreenshareConfigure.native.tsx @@ -319,7 +319,7 @@ export const ScreenshareConfigure = (props: {children: React.ReactNode}) => { 'Trying to start native screenshare', ); if (video) { - localMute(MUTE_LOCAL_TYPE.video); + await localMute(MUTE_LOCAL_TYPE.video); } try { await engine.current.startScreenCapture({ diff --git a/template/src/subComponents/waiting-rooms/WaitingRoomControls.tsx b/template/src/subComponents/waiting-rooms/WaitingRoomControls.tsx index bc82fe6de..5b80d8b16 100644 --- a/template/src/subComponents/waiting-rooms/WaitingRoomControls.tsx +++ b/template/src/subComponents/waiting-rooms/WaitingRoomControls.tsx @@ -14,12 +14,14 @@ import { peoplePanelWaitingRoomRequestApprovalBtnTxt, peoplePanelWaitingRoomRequestDenyBtnTxt, } from '../../../src/language/default-labels/videoCallScreenLabels'; +import ChatContext from '../../components/ChatContext'; const WaitingRoomButton = props => { const {uid, screenUid, isAccept} = props; const {approval} = useWaitingRoomAPI(); const localUid = useLocalUid(); const {dispatch} = useContext(DispatchContext); + const {syncUserState} = useContext(ChatContext); const {waitingRoomRef} = useWaitingRoomContext(); const admintext = useString(peoplePanelWaitingRoomRequestApprovalBtnTxt)(); const denytext = useString(peoplePanelWaitingRoomRequestDenyBtnTxt)(); @@ -40,10 +42,11 @@ const WaitingRoomButton = props => { Toast.hide(); } - dispatch({ - type: 'UpdateRenderList', - value: [uid, {isInWaitingRoom: false}], - }); + // dispatch({ + // type: 'UpdateRenderList', + // value: [uid, {isInWaitingRoom: false}], + // }); + syncUserState(uid, {isInWaitingRoom: false}); if (waitingRoomRef.current) { waitingRoomRef.current[uid] = approved ? 'APPROVED' : 'REJECTED'; diff --git a/template/src/utils/useDebouncedCallback.tsx b/template/src/utils/useDebouncedCallback.tsx new file mode 100644 index 000000000..bcafd938f --- /dev/null +++ b/template/src/utils/useDebouncedCallback.tsx @@ -0,0 +1,20 @@ +import {useRef, useCallback} from 'react'; + +export function useDebouncedCallback void>( + fn: T, + delay: number, +): (...args: Parameters) => void { + const timeoutRef = useRef | null>(null); + + const debounced = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => fn(...args), delay); + }, + [fn, delay], + ); + + return debounced; +} diff --git a/template/src/utils/useEndCall.ts b/template/src/utils/useEndCall.ts index 1a5cc0ad4..11f8504c1 100644 --- a/template/src/utils/useEndCall.ts +++ b/template/src/utils/useEndCall.ts @@ -9,7 +9,6 @@ import { import {PropsContext, DispatchContext} from '../../agora-rn-uikit'; import {useHistory} from '../components/Router'; import {stopForegroundService} from '../subComponents/LocalEndCall'; -import RTMEngine from '../rtm/RTMEngine'; import {ENABLE_AUTH} from '../auth/config'; import {useAuth} from '../auth/AuthProvider'; import {useChatConfigure} from '../components/chat/chatConfigure'; @@ -69,7 +68,6 @@ const useEndCall = () => { if ($config.CHAT) { deleteChatUser(); } - RTMEngine.getInstance().engine.leaveChannel(rtcProps.channel); if (!ENABLE_AUTH) { // await authLogout(); await authLogin(); diff --git a/template/src/utils/useMuteToggleLocal.ts b/template/src/utils/useMuteToggleLocal.ts index f887abc24..46267adcb 100644 --- a/template/src/utils/useMuteToggleLocal.ts +++ b/template/src/utils/useMuteToggleLocal.ts @@ -86,14 +86,16 @@ function useMuteToggleLocal() { ); // Enable UI + const newAudioState = + localAudioState === ToggleState.enabled + ? ToggleState.disabled + : ToggleState.enabled; + dispatch({ type: 'LocalMuteAudio', - value: [ - localAudioState === ToggleState.enabled - ? ToggleState.disabled - : ToggleState.enabled, - ], + value: [newAudioState], }); + handleQueue(); } catch (e) { dispatch({ @@ -152,14 +154,16 @@ function useMuteToggleLocal() { ); } // Enable UI + const newVideoState = + localVideoState === ToggleState.enabled + ? ToggleState.disabled + : ToggleState.enabled; + dispatch({ type: 'LocalMuteVideo', - value: [ - localVideoState === ToggleState.enabled - ? ToggleState.disabled - : ToggleState.enabled, - ], + value: [newVideoState], }); + handleQueue(); } catch (e) { dispatch({ diff --git a/voice-chat.config.json b/voice-chat.config.json index dd2b1659b..4f9f9d710 100644 --- a/voice-chat.config.json +++ b/voice-chat.config.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-voice-chat-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": false, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": false, "RAISE_HAND": false, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": true, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#FFFFFF", "FONT_COLOR": "#FFFFFF", - "BG": "", "BACKGROUND_COLOR": "#111111", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#00000040", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#333333", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#242529", "TOOLBAR_COLOR": "#111111", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false } diff --git a/voice-chat.config.light.json b/voice-chat.config.light.json index 29a08c699..73223b116 100644 --- a/voice-chat.config.light.json +++ b/voice-chat.config.light.json @@ -1,42 +1,70 @@ { - "PRODUCT_ID": "helloworld", - "APP_NAME": "HelloWorld", - "LOGO": "data:image/png;base64,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", - "ICON": "logoSquare.png", - "APP_ID": "a569f8fb0309417780b793786b534a86", - "PROJECT_ID": "49c705c1c9efb71000d7", - "FRONTEND_ENDPOINT": "https://app-builder-core-voice-chat-light-git-preprod-agoraio.vercel.app", - "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", - "PSTN": true, - "PRECALL": true, - "CHAT": true, - "CLOUD_RECORDING": true, - "RECORDING_MODE": "WEB", - "SCREEN_SHARING": false, - "LANDING_SUB_HEADING": "The Real-Time Engagement Platform", + "APP_ID": "a32ad7a7333e40dbaccdeff5d543ef56", "ENCRYPTION_ENABLED": true, "ENABLE_GOOGLE_OAUTH": false, + "ENABLE_APPLE_OAUTH": false, "ENABLE_SLACK_OAUTH": false, "ENABLE_MICROSOFT_OAUTH": false, - "ENABLE_APPLE_OAUTH": false, "GOOGLE_CLIENT_ID": "", "MICROSOFT_CLIENT_ID": "", "SLACK_CLIENT_ID": "", "APPLE_CLIENT_ID": "", - "PROFILE": "720p_3", - "SCREEN_SHARE_PROFILE": "1080p_2", + "PROJECT_ID": "2b8279fa91bda33fcf84", + "RECORDING_MODE": "MIX", + "APP_CERTIFICATE": "", + "CUSTOMER_ID": "", + "CUSTOMER_CERTIFICATE": "", + "BUCKET_NAME": "appbuilder-dev-qa-test-recording", + "BUCKET_ACCESS_KEY": "", + "BUCKET_ACCESS_SECRET": "", + "GOOGLE_CLIENT_SECRET": "", + "MICROSOFT_CLIENT_SECRET": "", + "SLACK_CLIENT_SECRET": "", + "APPLE_PRIVATE_KEY": "", + "APPLE_KEY_ID": "", + "APPLE_TEAM_ID": "", + "PSTN_EMAIL": "", + "PSTN_ACCOUNT": "", + "PSTN_PASSWORD": "", + "RECORDING_REGION": 3, + "GEO_FENCING": true, + "LOG_ENABLED": true, "EVENT_MODE": false, "RAISE_HAND": false, - "LOG_ENABLED": true, - "GEO_FENCING": true, - "GEO_FENCING_INCLUDE_AREA": "GLOBAL", - "GEO_FENCING_EXCLUDE_AREA": "CHINA", "AUDIO_ROOM": true, + "PRODUCT_ID": "breakoutroomfeaturetesting", + "APP_NAME": "BreakoutRoomFeatureTesting", + "LOGO": "https://dl-prod.rteappbuilder.com/tluafed/5TWpUqsdC0BH6lPJ.jpeg", + "ICON": "", + "PRIMARY_COLOR": "#00AEFC", + "FRONTEND_ENDPOINT": "2b8279fa91bda33fcf84-7y25qrqsf-agoraio.vercel.app", + "BACKEND_ENDPOINT": "https://managedservices-preprod.rteappbuilder.com", + "PSTN": false, + "PRECALL": true, + "CHAT": true, + "CHAT_ORG_NAME": "61394961", + "CHAT_APP_NAME": "1610292", + "CHAT_URL": "https://a61.chat.agora.io", + "CLOUD_RECORDING": true, + "SCREEN_SHARING": true, + "LANDING_SUB_HEADING": "The Real-Time Engagement Platform for meaningful human connections.", + "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", + "PRIMARY_FONT_COLOR": "#363636", + "SECONDARY_FONT_COLOR": "#FFFFFF", + "SENTRY_DSN": "", + "PROFILE": "480p_8", + "SCREEN_SHARE_PROFILE": "1080p_2", + "DISABLE_LANDSCAPE_MODE": true, + "ENABLE_IDP_AUTH": true, + "ENABLE_TOKEN_AUTH": false, + "ENABLE_STT": true, + "ENABLE_TEXT_TRACKS": false, + "ENABLE_CONVERSATIONAL_AI": false, + "ICON_TEXT": true, "PRIMARY_ACTION_BRAND_COLOR": "#099DFD", "PRIMARY_ACTION_TEXT_COLOR": "#FFFFFF", "SECONDARY_ACTION_COLOR": "#19394D", "FONT_COLOR": "#333333", - "BG": "https://dbudicf5k4as1.cloudfront.net/10/Artboard.png", "BACKGROUND_COLOR": "#FFFFFF", "VIDEO_AUDIO_TILE_COLOR": "#222222", "VIDEO_AUDIO_TILE_OVERLAY_COLOR": "#80808080", @@ -54,30 +82,24 @@ "CARD_LAYER_4_COLOR": "#FFFFFF", "CARD_LAYER_5_COLOR": "#808080", "HARD_CODED_BLACK_COLOR": "#000000", - "ICON_TEXT": true, "ICON_BG_COLOR": "#EBF1F5", "TOOLBAR_COLOR": "#FFFFFF00", "ACTIVE_SPEAKER": true, - "ENABLE_TOKEN_AUTH": false, - "ENABLE_IDP_AUTH": false, - "ENABLE_STT": true, - "ENABLE_CAPTION": true, - "ENABLE_MEETING_TRANSCRIPT": true, + "WHITEBOARD_APPIDENTIFIER": "WUjVACgwEe2QlOX96Oc4TA/DXlhL5JAksoOSQ", + "WHITEBOARD_REGION": "us-sv", "ENABLE_NOISE_CANCELLATION": true, "ENABLE_VIRTUAL_BACKGROUND": true, "ENABLE_WHITEBOARD": true, - "ENABLE_WHITEBOARD_FILE_UPLOAD": false, - "ENABLE_WAITING_ROOM": true, - "WHITEBOARD_APPIDENTIFIER": "EEJBQPVbEe2Bao8ZShuoHQ/hgB5eo0qcDbVig", - "WHITEBOARD_REGION": "us-sv", - "CHAT_ORG_NAME": "41754367", - "CHAT_APP_NAME": "1042822", - "CHAT_URL": "https://a41.chat.agora.io", - "ENABLE_NOISE_CANCELLATION_BY_DEFAULT": true, - "DISABLE_LANDSCAPE_MODE": false, + "ENABLE_WHITEBOARD_FILE_UPLOAD": true, + "ENABLE_CHAT_NOTIFICATION": true, + "ENABLE_CHAT_OPTION": true, + "ENABLE_WAITING_ROOM": false, + "ENABLE_WAITING_ROOM_AUTO_APPROVAL": false, + "ENABLE_WAITING_ROOM_AUTO_REQUEST": false, "STT_AUTO_START": false, "CLOUD_RECORDING_AUTO_START": false, - "ENABLE_SPOTLIGHT": false, - "AUTO_CONNECT_RTM": false, - "ENABLE_TEXT_TRACKS": false + "AI_LAYOUT": "LAYOUT_TYPE_1", + "AI_AGENTS": null, + "SDK_CODEC": "vp8", + "ENABLE_BREAKOUT_ROOM": false }