diff --git a/Extensions/Leaderboards/JsExtension.js b/Extensions/Leaderboards/JsExtension.js index 360c10823694..1e5a11321db7 100644 --- a/Extensions/Leaderboards/JsExtension.js +++ b/Extensions/Leaderboards/JsExtension.js @@ -34,7 +34,9 @@ module.exports = { .addAction( 'SavePlayerScore', _('Save player score'), - _("Save the player's score to the given leaderboard."), + _( + "Save the player's score to the given leaderboard. If the player is connected, the score will be attached to the connected player (unless disabled)." + ), _( 'Send to leaderboard _PARAM1_ the score _PARAM2_ with player name: _PARAM3_' ), @@ -60,6 +62,12 @@ module.exports = { .getCodeExtraInformation() .setIncludeFile('Extensions/Leaderboards/sha256.js') .addIncludeFile('Extensions/Leaderboards/leaderboardstools.js') + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationcomponents.js' + ) + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationtools.js' + ) .setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore') .setAsyncFunctionName('gdjs.evtTools.leaderboards.savePlayerScore'); @@ -87,11 +95,38 @@ module.exports = { .getCodeExtraInformation() .setIncludeFile('Extensions/Leaderboards/sha256.js') .addIncludeFile('Extensions/Leaderboards/leaderboardstools.js') + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationcomponents.js' + ) + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationtools.js' + ) .setFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore') .setAsyncFunctionName( 'gdjs.evtTools.leaderboards.saveConnectedPlayerScore' ); + extension + .addAction( + 'SetPreferSendConnectedPlayerScore', + _('Always attach scores to the connected player'), + _( + 'Set if the score sent to a leaderboard is always attached to the connected player - if any. This is on by default.' + ), + _('Always attach the score to the connected player: _PARAM1_'), + _('Setup'), + 'JsPlatform/Extensions/leaderboard.svg', + 'JsPlatform/Extensions/leaderboard.svg' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter('yesorno', _('Enable?'), '', false) + .setHelpPath('/all-features/leaderboards') + .getCodeExtraInformation() + .setIncludeFile('Extensions/Leaderboards/leaderboardstools.js') + .setFunctionName( + 'gdjs.evtTools.leaderboards.setPreferSendConnectedPlayerScore' + ); + extension .addCondition( 'HasLastSaveErrored', @@ -273,6 +308,12 @@ module.exports = { .setHelpPath('/all-features/leaderboards') .getCodeExtraInformation() .setIncludeFile('Extensions/Leaderboards/leaderboardstools.js') + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationcomponents.js' + ) + .addIncludeFile( + 'Extensions/PlayerAuthentication/playerauthenticationtools.js' + ) .setFunctionName('gdjs.evtTools.leaderboards.displayLeaderboard'); extension diff --git a/Extensions/Leaderboards/leaderboardstools.ts b/Extensions/Leaderboards/leaderboardstools.ts index d13ddbf266cc..f40aa6c4deb2 100644 --- a/Extensions/Leaderboards/leaderboardstools.ts +++ b/Extensions/Leaderboards/leaderboardstools.ts @@ -6,6 +6,7 @@ namespace gdjs { export namespace evtTools { export namespace leaderboards { let _hasPlayerJustClosedLeaderboardView = false; + let _preferSendConnectedPlayerScore = true; gdjs.registerRuntimeScenePostEventsCallback(() => { // Set it back to false for the next frame. @@ -24,6 +25,14 @@ namespace gdjs { return shaObj.getHash('B64'); }; + const leaderboardHostBaseUrl = 'https://gd.games'; + // const leaderboardHostBaseUrl = 'http://localhost:4000'; + + type PublicLeaderboardEntry = { + id: string; + claimSecret?: string; + }; + /** * Hold the state of the save of a score for a leaderboard. */ @@ -45,7 +54,7 @@ namespace gdjs { private _lastSavedPlayerId: string | null = null; /** The id of the entry in the leaderboard, for the last score saved with success. */ - lastSavedLeaderboardEntryId: string | null = null; + lastSavedLeaderboardEntry: PublicLeaderboardEntry | null = null; /** Last error that happened when saving the score (useful if `hasScoreSavingErrored` is true). */ lastSaveError: string | null = null; @@ -114,7 +123,7 @@ namespace gdjs { playerId?: string; score: number; }): { - closeSaving: (leaderboardEntryId: string | null) => void; + closeSaving: (leaderboardEntry: PublicLeaderboardEntry) => void; closeSavingWithError(errorCode: string); } { if (this._isAlreadySavingThisScore({ playerName, playerId, score })) { @@ -159,7 +168,7 @@ namespace gdjs { if (playerId) this._currentlySavingPlayerId = playerId; return { - closeSaving: (leaderboardEntryId) => { + closeSaving: (leaderboardEntry) => { if (savingPromise !== this.lastSavingPromise) { logger.info( 'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.' @@ -174,7 +183,7 @@ namespace gdjs { this._lastSavedScore = this._currentlySavingScore; this._lastSavedPlayerName = this._currentlySavingPlayerName; this._lastSavedPlayerId = this._currentlySavingPlayerId; - this.lastSavedLeaderboardEntryId = leaderboardEntryId; + this.lastSavedLeaderboardEntry = leaderboardEntry; this.hasScoreBeenSaved = true; resolveSavingPromise(); @@ -214,7 +223,7 @@ namespace gdjs { let _leaderboardViewIframeLoading: boolean = false; let _leaderboardViewIframeLoaded: boolean = false; let _errorTimeoutId: NodeJS.Timeout | null = null; - let _leaderboardViewClosingCallback: + let _leaderboardMessageListener: | ((event: MessageEvent) => void) | null = null; @@ -288,7 +297,7 @@ namespace gdjs { authenticatedPlayerData?: { playerId: string; playerToken: string }; score: number; runtimeScene: gdjs.RuntimeScene; - }) { + }): Promise { const rootApi = runtimeScene .getGame() .isUsingGDevelopDevelopmentEnvironment() @@ -339,18 +348,18 @@ namespace gdjs { throw errorCode; } - let leaderboardEntryId: string | null = null; try { const leaderboardEntry = await response.json(); - leaderboardEntryId = leaderboardEntry.id; + return leaderboardEntry; } catch (error) { logger.warn( 'An error occurred when reading response but score has been saved:', error ); - } - return leaderboardEntryId; + const errorCode = 'SAVED_ENTRY_CANT_BE_READ'; + throw errorCode; + } } catch (error) { logger.error('Error while submitting a leaderboard score:', error); const errorCode = 'REQUEST_NOT_SENT'; @@ -359,13 +368,27 @@ namespace gdjs { } }; + export const setPreferSendConnectedPlayerScore = ( + runtimeScene: gdjs.RuntimeScene, + enable: boolean + ) => { + _preferSendConnectedPlayerScore = enable; + }; + export const savePlayerScore = ( runtimeScene: gdjs.RuntimeScene, leaderboardId: string, score: float, playerName: string - ) => - new gdjs.PromiseTask( + ) => { + if ( + _preferSendConnectedPlayerScore && + gdjs.playerAuthentication.isAuthenticated() + ) { + return saveConnectedPlayerScore(runtimeScene, leaderboardId, score); + } + + return new gdjs.PromiseTask( (async () => { const scoreSavingState = (_scoreSavingStateByLeaderboard[ leaderboardId @@ -380,13 +403,13 @@ namespace gdjs { } = scoreSavingState.startSaving({ playerName, score }); try { - const leaderboardEntryId = await saveScore({ + const leaderboardEntry = await saveScore({ leaderboardId, playerName, score, runtimeScene, }); - closeSaving(leaderboardEntryId); + closeSaving(leaderboardEntry); } catch (errorCode) { closeSavingWithError(errorCode); } @@ -395,6 +418,7 @@ namespace gdjs { } })() ); + }; export const saveConnectedPlayerScore = ( runtimeScene: gdjs.RuntimeScene, @@ -551,7 +575,64 @@ namespace gdjs { displayLoader: boolean, event: MessageEvent ) { - switch (event.data) { + const messageId = + typeof event.data === 'string' ? event.data : event.data.id; + switch (messageId) { + case 'playerAuthenticated': + gdjs.playerAuthentication.login({ + runtimeScene, + userId: event.data.userId, + username: event.data.username, + userToken: event.data.userToken, + }); + break; + case 'openPlayerAuthentication': + gdjs.playerAuthentication + .openAuthenticationWindow(runtimeScene) + .promise.then(({ status }) => { + if ( + !_leaderboardViewIframe || + !_leaderboardViewIframe.contentWindow + ) { + logger.warn( + 'Unable to transmit the new login status to the leaderboard view.' + ); + return; + } + + if (status === 'errored') { + _leaderboardViewIframe.contentWindow.postMessage( + { + id: 'onPlayerAuthenticationErrored', + }, + leaderboardHostBaseUrl + ); + return; + } + + const playerId = gdjs.playerAuthentication.getUserId(); + const playerToken = gdjs.playerAuthentication.getUserToken(); + if (status === 'dismissed' || !playerId || !playerToken) { + _leaderboardViewIframe.contentWindow.postMessage( + { + id: 'onPlayerAuthenticationDismissed', + }, + leaderboardHostBaseUrl + ); + return; + } + + _leaderboardViewIframe.contentWindow.postMessage( + { + id: 'onPlayerAuthenticated', + playerId, + playerUsername: gdjs.playerAuthentication.getUsername(), + playerToken: playerToken, + }, + leaderboardHostBaseUrl + ); + }); + break; case 'closeLeaderboardView': _hasPlayerJustClosedLeaderboardView = true; closeLeaderboardView(runtimeScene); @@ -599,7 +680,7 @@ namespace gdjs { 'Leaderboard page did not send message in time. Closing leaderboard view.' ); } - }, 5000); + }, 15000); }; const displayLoaderInLeaderboardView = function ( @@ -701,15 +782,15 @@ namespace gdjs { }); } - // If a save is being done for this leaderboard, wait for it to end so that the `lastSavedLeaderboardEntryId` + // If a save is being done for this leaderboard, wait for it to end so that the `lastSavedLeaderboardEntry` // can be saved and then used to show the player score. const scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId]; if (scoreSavingState && scoreSavingState.lastSavingPromise) { await scoreSavingState.lastSavingPromise; } - const lastSavedLeaderboardEntryId = scoreSavingState - ? scoreSavingState.lastSavedLeaderboardEntryId + const lastSavedLeaderboardEntry = scoreSavingState + ? scoreSavingState.lastSavedLeaderboardEntry : null; const gameId = gdjs.projectData.properties.projectUuid; @@ -720,13 +801,30 @@ namespace gdjs { const searchParams = new URLSearchParams(); searchParams.set('inGameEmbedded', 'true'); if (isDev) searchParams.set('dev', 'true'); - if (lastSavedLeaderboardEntryId) + if (lastSavedLeaderboardEntry) { searchParams.set( 'playerLeaderboardEntryId', - lastSavedLeaderboardEntryId + lastSavedLeaderboardEntry.id ); + if (lastSavedLeaderboardEntry.claimSecret) { + searchParams.set( + 'playerLeaderboardEntryClaimSecret', + lastSavedLeaderboardEntry.claimSecret + ); + } + } + const playerId = gdjs.playerAuthentication.getUserId(); + const playerToken = gdjs.playerAuthentication.getUserToken(); + if (playerId && playerToken) { + searchParams.set('playerId', playerId); + searchParams.set('playerToken', playerToken); + searchParams.set( + 'playerUsername', + gdjs.playerAuthentication.getUsername() + ); + } - const targetUrl = `https://gd.games/games/${gameId}/leaderboard/${leaderboardId}?${searchParams}`; + const targetUrl = `${leaderboardHostBaseUrl}/games/${gameId}/leaderboard/${leaderboardId}?${searchParams}`; try { const isAvailable = await checkLeaderboardAvailability(targetUrl); @@ -772,7 +870,7 @@ namespace gdjs { targetUrl ); if (typeof window !== 'undefined') { - _leaderboardViewClosingCallback = (event: MessageEvent) => { + _leaderboardMessageListener = (event: MessageEvent) => { receiveMessageFromLeaderboardView( runtimeScene, displayLoader, @@ -781,7 +879,7 @@ namespace gdjs { }; (window as any).addEventListener( 'message', - _leaderboardViewClosingCallback, + _leaderboardMessageListener, true ); } @@ -836,10 +934,10 @@ namespace gdjs { if (typeof window !== 'undefined') { (window as any).removeEventListener( 'message', - _leaderboardViewClosingCallback, + _leaderboardMessageListener, true ); - _leaderboardViewClosingCallback = null; + _leaderboardMessageListener = null; } domElementContainer.removeChild(_leaderboardViewIframe); _leaderboardViewIframe = null; diff --git a/Extensions/PlayerAuthentication/JsExtension.js b/Extensions/PlayerAuthentication/JsExtension.js index ddc367468c94..45574008f0bc 100644 --- a/Extensions/PlayerAuthentication/JsExtension.js +++ b/Extensions/PlayerAuthentication/JsExtension.js @@ -100,7 +100,10 @@ module.exports = { .addIncludeFile( 'Extensions/PlayerAuthentication/playerauthenticationtools.js' ) - .setFunctionName('gdjs.playerAuthentication.openAuthenticationWindow'); + .setFunctionName('gdjs.playerAuthentication.openAuthenticationWindow') + .setAsyncFunctionName( + 'gdjs.playerAuthentication.openAuthenticationWindow' + ); extension .addCondition( diff --git a/Extensions/PlayerAuthentication/playerauthenticationcomponents.ts b/Extensions/PlayerAuthentication/playerauthenticationcomponents.ts index f01280eb2ec1..fadc02ab78bd 100644 --- a/Extensions/PlayerAuthentication/playerauthenticationcomponents.ts +++ b/Extensions/PlayerAuthentication/playerauthenticationcomponents.ts @@ -5,18 +5,25 @@ namespace gdjs { platform, isGameRegistered, }: { - platform: 'cordova' | 'electron' | 'web'; + platform: + | 'cordova' + | 'cordova-websocket' + | 'electron' + | 'web-iframe' + | 'web'; isGameRegistered: boolean; }) => isGameRegistered ? { title: 'Logging in...', text1: - platform === 'cordova' + platform === 'cordova' || platform === 'cordova-websocket' ? "One moment, we're opening a window for you to log in." : "One moment, we're opening a new page with your web browser for you to log in.", text2: - 'If the window did not open, please check your pop-up blocker and click the button below to try again.', + platform === 'cordova' || platform === 'cordova-websocket' + ? '' + : 'If the window did not open, please check your pop-up blocker and click the button below to try again.', } : { title: 'Publish your game!', @@ -166,9 +173,14 @@ namespace gdjs { */ export const addAuthenticationTextsToLoadingContainer = ( loaderContainer: HTMLDivElement, - platform, - isGameRegistered, - wikiOpenAction + platform: + | 'cordova' + | 'cordova-websocket' + | 'electron' + | 'web-iframe' + | 'web', + isGameRegistered: boolean, + wikiOpenAction: (() => void) | null ) => { const textContainer: HTMLDivElement = document.createElement('div'); textContainer.id = 'authentication-container-texts'; diff --git a/Extensions/PlayerAuthentication/playerauthenticationtools.ts b/Extensions/PlayerAuthentication/playerauthenticationtools.ts index a5f1e9c38ba0..3847404e91c8 100644 --- a/Extensions/PlayerAuthentication/playerauthenticationtools.ts +++ b/Extensions/PlayerAuthentication/playerauthenticationtools.ts @@ -15,7 +15,7 @@ namespace gdjs { // Authentication display let _authenticationWindow: Window | null = null; // For Web. - let _authenticationInAppWindow: Window | null = null; // For Cordova. + let _authenticationInAppWindow: any | null = null; // For Cordova. let _authenticationRootContainer: HTMLDivElement | null = null; let _authenticationLoaderContainer: HTMLDivElement | null = null; let _authenticationIframeContainer: HTMLDivElement | null = null; @@ -28,11 +28,10 @@ namespace gdjs { let _authenticationMessageCallback: | ((event: MessageEvent) => void) | null = null; - let _cordovaAuthenticationMessageCallback: - | ((event: MessageEvent) => void) - | null = null; let _websocket: WebSocket | null = null; + type AuthenticationWindowStatus = 'logged' | 'errored' | 'dismissed'; + // Ensure that the condition "just logged in" is valid only for one frame. gdjs.registerRuntimeScenePostEventsCallback(() => { _justLoggedIn = false; @@ -43,13 +42,15 @@ namespace gdjs { // Then send a message to the parent iframe to say that the player auth is ready. gdjs.registerFirstRuntimeSceneLoadedCallback( (runtimeScene: RuntimeScene) => { - if (getPlatform(runtimeScene) !== 'web') { + if (getPlayerAuthPlatform(runtimeScene) !== 'web') { // Automatic authentication is only valid when the game is hosted on GDevelop games platform. return; } removeAuthenticationCallbacks(); // Remove any callback that could have been registered before. _authenticationMessageCallback = (event: MessageEvent) => { - receiveAuthenticationMessage(runtimeScene, event, { + receiveAuthenticationMessage({ + runtimeScene, + event, checkOrigin: true, }); }; @@ -98,18 +99,40 @@ namespace gdjs { }; /** - * Helper returning the platform. + * Get the platform running the game, which changes how the authentication + * window is opened. */ - const getPlatform = ( + const getPlayerAuthPlatform = ( runtimeScene: RuntimeScene - ): 'electron' | 'cordova' | 'web' => { + ): 'electron' | 'cordova' | 'cordova-websocket' | 'web-iframe' | 'web' => { const runtimeGame = runtimeScene.getGame(); const electron = runtimeGame.getRenderer().getElectron(); if (electron) { + // This can be a: + // - Preview in GDevelop desktop app. + // - Desktop game running on Electron. return 'electron'; } - if (typeof cordova !== 'undefined') return 'cordova'; + // This can be a: + // - Preview in GDevelop mobile app (iOS only) + if (shouldAuthenticationUseIframe(runtimeScene)) return 'web-iframe'; + + if (typeof cordova !== 'undefined') { + if (cordova.platformId === 'ios') { + // The game is an iOS app. + return 'cordova-websocket'; + } + + // The game is an Android app. + return 'cordova'; + } + + // This can be a: + // - Preview in GDevelop web-app + // - Preview in Gdevelop mobile app (Android only) + // - Web game (gd.games or any website/server) accessed via a desktop browser... + // - Or a web game accessed via a mobile browser (Android/iOS). return 'web'; }; @@ -117,7 +140,8 @@ namespace gdjs { * Check if, in some exceptional cases, we allow authentication * to be done through a iframe. * This is usually discouraged as the user can't verify that the authentication - * window is a genuine one. It's only to be used in trusted contexts. + * window is a genuine one. It's only to be used in trusted contexts (e.g: + * preview in the GDevelop mobile app). */ const shouldAuthenticationUseIframe = (runtimeScene: RuntimeScene) => { const runtimeGameOptions = runtimeScene.getGame().getAdditionalOptions(); @@ -279,7 +303,10 @@ namespace gdjs { const cleanUpAuthWindowAndCallbacks = (runtimeScene: RuntimeScene) => { removeAuthenticationContainer(runtimeScene); clearAuthenticationWindowTimeout(); + + // If there is a websocket communication (electron, cordova iOS), close it. if (_websocket) { + logger.info('Closing authentication websocket connection.'); _websocket.close(); _websocket = null; } @@ -334,16 +361,17 @@ namespace gdjs { } }; - /** - * When the game receives the authentication result, close all the - * authentication windows, display the notification and focus on the game. - */ - const handleLoggedInEvent = function ( - runtimeScene: gdjs.RuntimeScene, - userId: string, - username: string | null, - userToken: string - ) { + export const login = ({ + runtimeScene, + userId, + username, + userToken, + }: { + runtimeScene: gdjs.RuntimeScene; + userId: string; + username: string | null; + userToken: string; + }) => { saveAuthKeyToStorage({ userId, username, userToken }); cleanUpAuthWindowAndCallbacks(runtimeScene); removeAuthenticationBanner(runtimeScene); @@ -363,18 +391,23 @@ namespace gdjs { domElementContainer, _username || 'Anonymous' ); - focusOnGame(runtimeScene); }; /** * Reads the event sent by the authentication window and * display the appropriate banner. */ - const receiveAuthenticationMessage = function ( - runtimeScene: gdjs.RuntimeScene, - event: MessageEvent, - { checkOrigin }: { checkOrigin: boolean } - ) { + const receiveAuthenticationMessage = function ({ + runtimeScene, + event, + checkOrigin, + onDone, + }: { + runtimeScene: gdjs.RuntimeScene; + event: MessageEvent; + checkOrigin: boolean; + onDone?: (status: 'logged' | 'errored' | 'dismissed') => void; + }) { const allowedOrigins = ['https://liluo.io', 'https://gd.games']; // Check origin of message. @@ -394,12 +427,14 @@ namespace gdjs { throw new Error('Malformed message.'); } - handleLoggedInEvent( + login({ runtimeScene, - event.data.body.userId, - event.data.body.username, - event.data.body.token - ); + userId: event.data.body.userId, + username: event.data.body.username, + userToken: event.data.body.token, + }); + focusOnGame(runtimeScene); + if (onDone) onDone('logged'); break; } case 'alreadyAuthenticated': { @@ -452,7 +487,7 @@ namespace gdjs { runtimeScene: gdjs.RuntimeScene ) => { clearAuthenticationWindowTimeout(); - const time = 12 * 60 * 1000; // 12 minutes, in case the user needs time to authenticate. + const time = 15 * 60 * 1000; // 15 minutes, in case the user needs time to authenticate. _authenticationTimeoutId = setTimeout(() => { logger.info( 'Authentication window did not send message in time. Closing it.' @@ -487,6 +522,7 @@ namespace gdjs { const onOpenAuthenticationWindow = () => { openAuthenticationWindow(runtimeScene); }; + return _userToken ? authComponents.computeAuthenticatedBanner( onOpenAuthenticationWindow, @@ -558,6 +594,84 @@ namespace gdjs { ); }; + const setupWebsocketForAuthenticationWindow = ( + runtimeScene: gdjs.RuntimeScene, + onOpenAuthenticationWindow: (options: { + connectionId: string; + resolve: (AuthenticationWindowStatus) => void; + }) => void + ) => + new Promise((resolve) => { + let hasFinishedAlready = false; + const wsPlayApi = runtimeScene + .getGame() + .isUsingGDevelopDevelopmentEnvironment() + ? 'wss://api-ws-dev.gdevelop.io/play' + : 'wss://api-ws.gdevelop.io/play'; + _websocket = new WebSocket(wsPlayApi); + _websocket.onopen = () => { + logger.info('Opened authentication websocket connection.'); + // When socket is open, ask for the connectionId, so that we can open the authentication window. + if (_websocket) { + _websocket.send(JSON.stringify({ action: 'getConnectionId' })); + } + }; + _websocket.onerror = () => { + logger.info('Error in authentication websocket connection.'); + if (!hasFinishedAlready) { + hasFinishedAlready = true; + resolve('errored'); + } + handleAuthenticationError( + runtimeScene, + 'Error while connecting to the authentication server.' + ); + }; + _websocket.onclose = () => { + logger.info('Closing authentication websocket connection.'); + if (!hasFinishedAlready) { + hasFinishedAlready = true; + resolve('dismissed'); + } + }; + _websocket.onmessage = (event) => { + if (event.data) { + const messageContent = JSON.parse(event.data); + switch (messageContent.type) { + case 'authenticationResult': { + const messageData = messageContent.data; + + login({ + runtimeScene, + userId: messageData.userId, + username: messageData.username, + userToken: messageData.token, + }); + focusOnGame(runtimeScene); + + hasFinishedAlready = true; + resolve('logged'); + break; + } + case 'connectionId': { + const messageData = messageContent.data; + const connectionId = messageData.connectionId; + if (!connectionId) { + logger.error('No WebSocket connectionId received'); + hasFinishedAlready = true; + resolve('errored'); + return; + } + + logger.info('WebSocket connectionId received.'); + onOpenAuthenticationWindow({ connectionId, resolve }); + break; + } + } + } + }; + }); + /** * Helper to handle authentication window on Electron. * We open a new window, and create a websocket to know when the user is logged in. @@ -565,75 +679,67 @@ namespace gdjs { const openAuthenticationWindowForElectron = ( runtimeScene: gdjs.RuntimeScene, gameId: string - ) => { - const wsPlayApi = runtimeScene - .getGame() - .isUsingGDevelopDevelopmentEnvironment() - ? 'wss://api-ws-dev.gdevelop.io/play' - : 'wss://api-ws.gdevelop.io/play'; - _websocket = new WebSocket(wsPlayApi); - _websocket.onopen = () => { - // When socket is open, ask for the connectionId, so that we can open the authentication window. - if (_websocket) { - _websocket.send(JSON.stringify({ action: 'getConnectionId' })); - } - }; - _websocket.onerror = () => { - handleAuthenticationError( - runtimeScene, - 'Error while connecting to the authentication server.' - ); - }; - _websocket.onmessage = (event) => { - if (event.data) { - const messageContent = JSON.parse(event.data); - switch (messageContent.type) { - case 'authenticationResult': { - const messageData = messageContent.data; - handleLoggedInEvent( - runtimeScene, - messageData.userId, - messageData.username, - messageData.token - ); - break; - } - case 'connectionId': { - const messageData = messageContent.data; - const connectionId = messageData.connectionId; - if (!connectionId) { - logger.error('No connectionId received'); - return; - } + ) => + setupWebsocketForAuthenticationWindow( + runtimeScene, + ({ connectionId }) => { + const targetUrl = getAuthWindowUrl({ + runtimeGame: runtimeScene.getGame(), + gameId, + connectionId, + }); - const targetUrl = getAuthWindowUrl({ - runtimeGame: runtimeScene.getGame(), - gameId, - connectionId, - }); + const electron = runtimeScene.getGame().getRenderer().getElectron(); + const openWindow = () => electron.shell.openExternal(targetUrl); - const electron = runtimeScene - .getGame() - .getRenderer() - .getElectron(); - const openWindow = () => electron.shell.openExternal(targetUrl); + openWindow(); - openWindow(); + // Add the link to the window in case a popup blocker is preventing the window from opening. + if (_authenticationTextContainer) { + authComponents.addAuthenticationUrlToTextsContainer( + openWindow, + _authenticationTextContainer + ); + } + } + ); - // Add the link to the window in case a popup blocker is preventing the window from opening. - if (_authenticationTextContainer) { - authComponents.addAuthenticationUrlToTextsContainer( - openWindow, - _authenticationTextContainer - ); - } + /** + * Helper to handle authentication window on Cordova on iOS. + * We open an InAppBrowser window, and listen to the websocket to know when the user is logged in. + */ + const openAuthenticationWindowForCordovaWithWebSocket = ( + runtimeScene: gdjs.RuntimeScene, + gameId: string + ) => + setupWebsocketForAuthenticationWindow( + runtimeScene, + ({ connectionId, resolve }) => { + const targetUrl = getAuthWindowUrl({ + runtimeGame: runtimeScene.getGame(), + gameId, + connectionId, + }); - break; - } + _authenticationInAppWindow = cordova.InAppBrowser.open( + targetUrl, + 'authentication', + 'location=yes,toolbarcolor=#000000,hidenavigationbuttons=yes,closebuttoncolor=#FFFFFF' // location=yes is important to show the URL bar to the user. + ); + if (!_authenticationInAppWindow) { + resolve('errored'); + return; } + + _authenticationInAppWindow.addEventListener( + 'exit', + () => { + resolve('dismissed'); + }, + true + ); } - }; - }; + ); /** * Helper to handle authentication window on Cordova. @@ -642,32 +748,52 @@ namespace gdjs { const openAuthenticationWindowForCordova = ( runtimeScene: gdjs.RuntimeScene, gameId: string - ) => { - const targetUrl = getAuthWindowUrl({ - runtimeGame: runtimeScene.getGame(), - gameId, - }); + ) => + new Promise((resolve) => { + const targetUrl = getAuthWindowUrl({ + runtimeGame: runtimeScene.getGame(), + gameId, + }); - _authenticationInAppWindow = cordova.InAppBrowser.open( - targetUrl, - 'authentication', - 'location=yes' // location=yes is important to show the URL bar to the user. - ); - // Listen to messages posted on the authentication window, so that we can - // know when the user is authenticated. - if (_authenticationInAppWindow) { - _cordovaAuthenticationMessageCallback = (event: MessageEvent) => { - receiveAuthenticationMessage(runtimeScene, event, { - checkOrigin: false, // For Cordova we don't check the origin, as the message is read from the InAppBrowser directly. - }); - }; + _authenticationInAppWindow = cordova.InAppBrowser.open( + targetUrl, + 'authentication', + 'location=yes,toolbarcolor=#000000,hidenavigationbuttons=yes,closebuttoncolor=#FFFFFF' // location=yes is important to show the URL bar to the user. + ); + if (!_authenticationInAppWindow) { + resolve('errored'); + return; + } + + // Listen to messages posted on the authentication window, so that we can + // know when the user is authenticated. + let isDoneAlready = false; _authenticationInAppWindow.addEventListener( 'message', - _cordovaAuthenticationMessageCallback, + (event: MessageEvent) => { + receiveAuthenticationMessage({ + runtimeScene, + event, + checkOrigin: false, // For Cordova we don't check the origin, as the message is read from the InAppBrowser directly. + onDone: (status) => { + if (isDoneAlready) return; + isDoneAlready = true; + resolve(status); + }, + }); + }, true ); - } - }; + _authenticationInAppWindow.addEventListener( + 'exit', + () => { + if (isDoneAlready) return; + isDoneAlready = true; + resolve('dismissed'); + }, + true + ); + }); /** * Helper to handle authentication window on web. @@ -676,37 +802,56 @@ namespace gdjs { const openAuthenticationWindowForWeb = ( runtimeScene: gdjs.RuntimeScene, gameId: string - ) => { - // If we're on a browser, open a new window. - const targetUrl = getAuthWindowUrl({ - runtimeGame: runtimeScene.getGame(), - gameId, - }); - - // Listen to messages posted by the authentication window, so that we can - // know when the user is authenticated. - _authenticationMessageCallback = (event: MessageEvent) => { - receiveAuthenticationMessage(runtimeScene, event, { - checkOrigin: true, + ) => + new Promise((resolve) => { + // If we're on a browser, open a new window. + const targetUrl = getAuthWindowUrl({ + runtimeGame: runtimeScene.getGame(), + gameId, }); - }; - window.addEventListener('message', _authenticationMessageCallback, true); - - const left = screen.width / 2 - 500 / 2; - const top = screen.height / 2 - 600 / 2; - const windowFeatures = `left=${left},top=${top},width=500,height=600`; - const openWindow = () => - window.open(targetUrl, 'authentication', windowFeatures); - _authenticationWindow = openWindow(); - - // Add the link to the window in case a popup blocker is preventing the window from opening. - if (_authenticationTextContainer) { - authComponents.addAuthenticationUrlToTextsContainer( - openWindow, - _authenticationTextContainer + + // Listen to messages posted by the authentication window, so that we can + // know when the user is authenticated. + let isDoneAlready = false; + _authenticationMessageCallback = (event: MessageEvent) => { + receiveAuthenticationMessage({ + runtimeScene, + event, + checkOrigin: true, + onDone: (status) => { + if (isDoneAlready) return; + isDoneAlready = true; + resolve(status); + }, + }); + }; + window.addEventListener( + 'message', + _authenticationMessageCallback, + true ); - } - }; + + const left = screen.width / 2 - 500 / 2; + const top = screen.height / 2 - 600 / 2; + const windowFeatures = `left=${left},top=${top},width=500,height=600`; + const openWindow = () => { + _authenticationWindow = window.open( + targetUrl, + 'authentication', + windowFeatures + ); + }; + + openWindow(); + + // Add the link to the window in case a popup blocker is preventing the window from opening. + if (_authenticationTextContainer) { + authComponents.addAuthenticationUrlToTextsContainer( + openWindow, + _authenticationTextContainer + ); + } + }); /** * Helper to handle authentication iframe on web. @@ -715,143 +860,197 @@ namespace gdjs { const openAuthenticationIframeForWeb = ( runtimeScene: gdjs.RuntimeScene, gameId: string - ) => { - if ( - !_authenticationIframeContainer || - !_authenticationLoaderContainer || - !_authenticationTextContainer - ) { - console.error( - "Can't open an authentication iframe - no iframe container, loader container or text container was opened for it." - ); - return; - } - - const targetUrl = getAuthWindowUrl({ - runtimeGame: runtimeScene.getGame(), - gameId, - }); + ) => + new Promise((resolve) => { + if ( + !_authenticationIframeContainer || + !_authenticationLoaderContainer || + !_authenticationTextContainer + ) { + console.error( + "Can't open an authentication iframe - no iframe container, loader container or text container was opened for it." + ); + return; + } - // Listen to messages posted by the authentication window, so that we can - // know when the user is authenticated. - _authenticationMessageCallback = (event: MessageEvent) => { - receiveAuthenticationMessage(runtimeScene, event, { - checkOrigin: true, + const targetUrl = getAuthWindowUrl({ + runtimeGame: runtimeScene.getGame(), + gameId, }); - }; - window.addEventListener('message', _authenticationMessageCallback, true); - authComponents.displayIframeInsideAuthenticationContainer( - _authenticationIframeContainer, - _authenticationLoaderContainer, - _authenticationTextContainer, - targetUrl - ); - }; + // Listen to messages posted by the authentication window, so that we can + // know when the user is authenticated. + _authenticationMessageCallback = (event: MessageEvent) => { + receiveAuthenticationMessage({ + runtimeScene, + event, + checkOrigin: true, + onDone: resolve, + }); + }; + window.addEventListener( + 'message', + _authenticationMessageCallback, + true + ); + + authComponents.displayIframeInsideAuthenticationContainer( + _authenticationIframeContainer, + _authenticationLoaderContainer, + _authenticationTextContainer, + targetUrl + ); + }); /** * Action to display the authentication window to the user. */ - export const openAuthenticationWindow = function ( + export const openAuthenticationWindow = ( runtimeScene: gdjs.RuntimeScene - ) { - // Create the authentication container for the player to wait. - const domElementContainer = runtimeScene - .getGame() - .getRenderer() - .getDomElementContainer(); - if (!domElementContainer) { - handleAuthenticationError( - runtimeScene, - "The div element covering the game couldn't be found, the authentication window cannot be displayed." - ); - return; - } + ): gdjs.PromiseTask<{ status: 'logged' | 'errored' | 'dismissed' }> => + new gdjs.PromiseTask( + new Promise((resolve) => { + // Create the authentication container for the player to wait. + const domElementContainer = runtimeScene + .getGame() + .getRenderer() + .getDomElementContainer(); + if (!domElementContainer) { + handleAuthenticationError( + runtimeScene, + "The div element covering the game couldn't be found, the authentication window cannot be displayed." + ); + resolve({ status: 'errored' }); + return; + } - const onAuthenticationContainerDismissed = () => { - cleanUpAuthWindowAndCallbacks(runtimeScene); - displayAuthenticationBanner(runtimeScene); - }; + const _gameId = gdjs.projectData.properties.projectUuid; + if (!_gameId) { + handleAuthenticationError( + runtimeScene, + 'The game ID is missing, the authentication window cannot be opened.' + ); + resolve({ status: 'errored' }); + return; + } - const _gameId = gdjs.projectData.properties.projectUuid; - if (!_gameId) { - handleAuthenticationError( - runtimeScene, - 'The game ID is missing, the authentication window cannot be opened.' - ); - return; - } + let isDimissedAlready = false; + const onAuthenticationContainerDismissed = () => { + cleanUpAuthWindowAndCallbacks(runtimeScene); + displayAuthenticationBanner(runtimeScene); + + isDimissedAlready = true; + resolve({ status: 'dismissed' }); + }; + + // If the banner is displayed, hide it, so that it can be shown again if the user closes the window. + if (_authenticationBanner) _authenticationBanner.style.opacity = '0'; + + const playerAuthPlatform = getPlayerAuthPlatform(runtimeScene); + const { + rootContainer, + loaderContainer, + iframeContainer, + } = authComponents.computeAuthenticationContainer( + onAuthenticationContainerDismissed + ); + _authenticationRootContainer = rootContainer; + _authenticationLoaderContainer = loaderContainer; + _authenticationIframeContainer = iframeContainer; + + // Display the authentication window right away, to show a loader + // while the call for game registration is happening. + domElementContainer.appendChild(_authenticationRootContainer); + + // If the game is registered, open the authentication window. + // Otherwise, open the window indicating that the game is not registered. + (async () => { + const isGameRegistered = await checkIfGameIsRegistered( + runtimeScene.getGame(), + _gameId + ); - // If the banner is displayed, hide it, so that it can be shown again if the user closes the window. - if (_authenticationBanner) _authenticationBanner.style.opacity = '0'; + if (_authenticationLoaderContainer) { + const electron = runtimeScene + .getGame() + .getRenderer() + .getElectron(); + const wikiOpenAction = electron + ? () => + electron.shell.openExternal( + 'https://wiki.gdevelop.io/gdevelop5/publishing/web' + ) + : null; // Only show a link if we're on electron. + + _authenticationTextContainer = authComponents.addAuthenticationTextsToLoadingContainer( + _authenticationLoaderContainer, + playerAuthPlatform, + isGameRegistered, + wikiOpenAction + ); + } + if (!isGameRegistered) return; - const platform = getPlatform(runtimeScene); - const { - rootContainer, - loaderContainer, - iframeContainer, - } = authComponents.computeAuthenticationContainer( - onAuthenticationContainerDismissed - ); - _authenticationRootContainer = rootContainer; - _authenticationLoaderContainer = loaderContainer; - _authenticationIframeContainer = iframeContainer; - - // Display the authentication window right away, to show a loader - // while the call for game registration is happening. - domElementContainer.appendChild(_authenticationRootContainer); - - // If the game is registered, open the authentication window. - // Otherwise, open the window indicating that the game is not registered. - checkIfGameIsRegistered(runtimeScene.getGame(), _gameId) - .then((isGameRegistered) => { - if (_authenticationLoaderContainer) { - const electron = runtimeScene.getGame().getRenderer().getElectron(); - const wikiOpenAction = electron - ? () => - electron.shell.openExternal( - 'https://wiki.gdevelop.io/gdevelop5/publishing/web' - ) - : null; // Only show a link if we're on electron. - - _authenticationTextContainer = authComponents.addAuthenticationTextsToLoadingContainer( - _authenticationLoaderContainer, - platform, - isGameRegistered, - wikiOpenAction - ); - } - if (isGameRegistered) { startAuthenticationWindowTimeout(runtimeScene); // Based on which platform the game is running, we open the authentication window // with a different window, with or without a websocket. - switch (platform) { + let status: AuthenticationWindowStatus; + switch (playerAuthPlatform) { case 'electron': - openAuthenticationWindowForElectron(runtimeScene, _gameId); + // This can be a: + // - Preview in GDevelop desktop app. + // - Desktop game running on Electron. + status = await openAuthenticationWindowForElectron( + runtimeScene, + _gameId + ); break; case 'cordova': - openAuthenticationWindowForCordova(runtimeScene, _gameId); + // The game is an Android app. + status = await openAuthenticationWindowForCordova( + runtimeScene, + _gameId + ); + break; + case 'cordova-websocket': + // The game is an iOS app. + status = await openAuthenticationWindowForCordovaWithWebSocket( + runtimeScene, + _gameId + ); + break; + case 'web-iframe': + // This can be a: + // - Preview in GDevelop mobile app (iOS only) + status = await openAuthenticationIframeForWeb( + runtimeScene, + _gameId + ); break; case 'web': default: - if (shouldAuthenticationUseIframe(runtimeScene)) { - openAuthenticationIframeForWeb(runtimeScene, _gameId); - } else { - openAuthenticationWindowForWeb(runtimeScene, _gameId); - } + // This can be a: + // - Preview in GDevelop web-app + // - Preview in Gdevelop mobile app (Android only) + // - Web game (gd.games or any website/server) accessed via a desktop browser... + // - Or a web game accessed via a mobile browser (Android/iOS). + status = await openAuthenticationWindowForWeb( + runtimeScene, + _gameId + ); break; } - } + + if (isDimissedAlready) return; + if (status === 'dismissed') { + onAuthenticationContainerDismissed(); + } + + resolve({ status }); + })(); }) - .catch((error) => { - handleAuthenticationError( - runtimeScene, - 'Error while checking if the game is registered.' - ); - logger.error(error); - }); - }; + ); /** * Condition to check if the window is open, so that the game can be paused in the background. @@ -901,8 +1100,6 @@ namespace gdjs { true ); _authenticationMessageCallback = null; - // No need to detach the callback from the InAppBrowser, as it's destroyed when the window is closed. - _cordovaAuthenticationMessageCallback = null; } }; diff --git a/GDJS/Runtime/AsyncTasksManager.ts b/GDJS/Runtime/AsyncTasksManager.ts index a7c1f1158642..7528ae9e95ac 100644 --- a/GDJS/Runtime/AsyncTasksManager.ts +++ b/GDJS/Runtime/AsyncTasksManager.ts @@ -93,11 +93,11 @@ namespace gdjs { /** * A task that resolves with a promise. */ - export class PromiseTask extends AsyncTask { + export class PromiseTask extends AsyncTask { private isResolved: boolean = false; - promise: Promise; + promise: Promise; - constructor(promise: Promise) { + constructor(promise: Promise) { super(); this.promise = promise .catch((error) => { @@ -107,9 +107,14 @@ If you are using JavaScript promises in an asynchronous action, make sure to add Otherwise, report this as a bug on the GDevelop forums! ${error ? 'The following error was thrown: ' + error : ''}` ); + + // @ts-ignore + return undefined as ResultType; }) - .then(() => { + .then((result) => { this.isResolved = true; + + return result; }); } diff --git a/GDJS/Runtime/Cordova/config.xml b/GDJS/Runtime/Cordova/config.xml index ff0993a90c67..b7cdfe0797ba 100644 --- a/GDJS/Runtime/Cordova/config.xml +++ b/GDJS/Runtime/Cordova/config.xml @@ -12,6 +12,10 @@ + + + + diff --git a/GDJS/Runtime/Cordova/www/index.html b/GDJS/Runtime/Cordova/www/index.html index cfdcd7794792..f81d646f3340 100644 --- a/GDJS/Runtime/Cordova/www/index.html +++ b/GDJS/Runtime/Cordova/www/index.html @@ -8,7 +8,7 @@ /* Prevent copy paste for all elements except text fields */ * { -webkit-user-select:none; -webkit-tap-highlight-color:rgba(255, 255, 255, 0); } input, textarea { -webkit-user-select:text; } - body { background-color:black; color:white } + body { background-color:black; } /* GDJS_CUSTOM_STYLE */ diff --git a/newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardSortOptionsDialog.js b/newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardOptionsDialog.js similarity index 52% rename from newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardSortOptionsDialog.js rename to newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardOptionsDialog.js index 513f094d72b0..bb5497b948d1 100644 --- a/newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardSortOptionsDialog.js +++ b/newIDE/app/src/GameDashboard/LeaderboardAdmin/LeaderboardOptionsDialog.js @@ -12,37 +12,50 @@ import SelectOption from '../../UI/SelectOption'; import Text from '../../UI/Text'; import TextField from '../../UI/TextField'; -import { type LeaderboardSortOption } from '../../Utils/GDevelopServices/Play'; +import { + type LeaderboardSortOption, + type Leaderboard, +} from '../../Utils/GDevelopServices/Play'; import { Column, LargeSpacer, Line } from '../../UI/Grid'; import HelpButton from '../../UI/HelpButton'; import Checkbox from '../../UI/Checkbox'; import { FormHelperText } from '@material-ui/core'; import { MarkdownText } from '../../UI/MarkdownText'; +import SemiControlledTextField from '../../UI/SemiControlledTextField'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import GetSubscriptionCard from '../../Profile/Subscription/GetSubscriptionCard'; -type SortOptions = {| +export type LeaderboardOptions = {| sort: LeaderboardSortOption, extremeAllowedScore: ?number, + autoPlayerNamePrefix: string, + ignoreCustomPlayerNames: boolean, + disableLoginInLeaderboard: boolean, |}; type Props = { open: boolean, - sort: LeaderboardSortOption, - extremeAllowedScore?: number, - onSave: SortOptions => Promise, + leaderboard: Leaderboard, + onSave: LeaderboardOptions => Promise, onClose: () => void, }; const extremeAllowedScoreMax = Number.MAX_SAFE_INTEGER; const extremeAllowedScoreMin = Number.MIN_SAFE_INTEGER; -function LeaderboardSortOptionsDialog({ +function LeaderboardOptionsDialog({ open, onClose, onSave, - sort, - extremeAllowedScore, + leaderboard, }: Props) { const [isLoading, setIsLoading] = React.useState(false); + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const canDisableLoginInLeaderboard = + (authenticatedUser.limits && + authenticatedUser.limits.capabilities.leaderboards + .canDisableLoginInLeaderboard) || + false; const [ extremeAllowedScoreError, @@ -51,16 +64,29 @@ function LeaderboardSortOptionsDialog({ const [ displayExtremeAllowedScoreInput, setDisplayExtremeAllowedScoreInput, - ] = React.useState(extremeAllowedScore !== undefined); + ] = React.useState(leaderboard.extremeAllowedScore !== undefined); const [ extremeAllowedScoreValue, setExtremeAllowedScoreValue, - ] = React.useState(extremeAllowedScore || 0); + ] = React.useState(leaderboard.extremeAllowedScore || 0); const [sortOrder, setSortOrder] = React.useState( - sort || 'ASC' + leaderboard.sort || 'ASC' ); + const [ + autoPlayerNamePrefix, + setAutoPlayerNamePrefix, + ] = React.useState(leaderboard.autoPlayerNamePrefix || ''); + const [ + ignoreCustomPlayerNames, + setIgnoreCustomPlayerNames, + ] = React.useState(!!leaderboard.ignoreCustomPlayerNames); + const [ + disableLoginInLeaderboard, + setDisableLoginInLeaderboard, + ] = React.useState(!!leaderboard.disableLoginInLeaderboard); + const onSaveSettings = async (i18n: I18nType) => { if (displayExtremeAllowedScoreInput) { if (extremeAllowedScoreValue > extremeAllowedScoreMax) { @@ -92,6 +118,9 @@ function LeaderboardSortOptionsDialog({ extremeAllowedScore: displayExtremeAllowedScoreInput ? extremeAllowedScoreValue : null, + autoPlayerNamePrefix, + ignoreCustomPlayerNames, + disableLoginInLeaderboard, }; await onSave(sortOrderSettings); }; @@ -179,40 +208,95 @@ function LeaderboardSortOptionsDialog({ ) } /> - - {displayExtremeAllowedScoreInput && ( - - - - Minimum score - ) : ( - Maximum score - ) - } - value={extremeAllowedScoreValue} - errorText={extremeAllowedScoreError} - min={extremeAllowedScoreMin} - max={extremeAllowedScoreMax} - onChange={(e, newValue: string) => { - if (!!extremeAllowedScoreError) { - setExtremeAllowedScoreError(null); + {displayExtremeAllowedScoreInput && ( + + + + Minimum score + ) : ( + Maximum score + ) } + value={extremeAllowedScoreValue} + errorText={extremeAllowedScoreError} + min={extremeAllowedScoreMin} + max={extremeAllowedScoreMax} + onChange={(e, newValue: string) => { + if (!!extremeAllowedScoreError) { + setExtremeAllowedScoreError(null); + } - setExtremeAllowedScoreValue(parseFloat(newValue)); - }} - /> - - - )} + setExtremeAllowedScoreValue(parseFloat(newValue)); + }} + /> + + + )} + + Connected players + + Disable login buttons in leaderboard} + checked={disableLoginInLeaderboard} + disabled={!canDisableLoginInLeaderboard} + onCheck={(e, checked) => setDisableLoginInLeaderboard(checked)} + tooltipOrHelperText={ + + If activated, players won't be able to log in and claim a + score just sent without being already logged in to the game. + + } + /> + {!canDisableLoginInLeaderboard && ( + + + + + + Get a pro subscription to get full leaderboard + customization. + + + + + + )} + + Anonymous players + + + Player name prefix (for auto-generated player names) + + } + fullWidth + maxLength={40} + value={autoPlayerNamePrefix} + onChange={text => setAutoPlayerNamePrefix(text)} + /> + Enforce only auto-generated player names} + checked={ignoreCustomPlayerNames} + onCheck={(e, checked) => setIgnoreCustomPlayerNames(checked)} + tooltipOrHelperText={ + + If checked, player names will always be auto-generated, even + if the game sent a custom name. Helpful if you're having a + leaderboard where you want full anonymity. + + } + /> + )} ); } -export default LeaderboardSortOptionsDialog; +export default LeaderboardOptionsDialog; diff --git a/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js b/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js index 03518eaf274b..436aa772e448 100644 --- a/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js +++ b/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js @@ -28,8 +28,6 @@ import Refresh from '../../UI/CustomSvgIcons/Refresh'; import Trash from '../../UI/CustomSvgIcons/Trash'; import Visibility from '../../UI/CustomSvgIcons/Visibility'; import VisibilityOff from '../../UI/CustomSvgIcons/VisibilityOff'; -import Lock from '../../UI/CustomSvgIcons/Lock'; -import LockOpen from '../../UI/CustomSvgIcons/LockOpen'; import Copy from '../../UI/CustomSvgIcons/Copy'; import PlaceholderLoader from '../../UI/PlaceholderLoader'; @@ -62,8 +60,9 @@ import Text from '../../UI/Text'; import { GameRegistration } from '../GameRegistration'; import LeaderboardAppearanceDialog from './LeaderboardAppearanceDialog'; import FlatButton from '../../UI/FlatButton'; -import LeaderboardSortOptionsDialog from './LeaderboardSortOptionsDialog'; -import { type LeaderboardSortOption } from '../../Utils/GDevelopServices/Play'; +import LeaderboardOptionsDialog, { + type LeaderboardOptions, +} from './LeaderboardOptionsDialog'; import { formatScore } from '../../Leaderboard/LeaderboardScoreFormatter'; import Toggle from '../../UI/Toggle'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; @@ -90,8 +89,6 @@ type ApiError = {| | 'leaderboardNameUpdate' | 'leaderboardSortUpdate' | 'leaderboardVisibilityUpdate' - | 'leaderboardAutoPlayerNamePrefixUpdate' - | 'leaderboardIgnoreCustomPlayerNamesUpdate' | 'leaderboardPrimaryUpdate' | 'leaderboardAppearanceUpdate' | 'leaderboardPlayerUnicityDisplayChoiceUpdate' @@ -132,10 +129,6 @@ const getApiError = (payload: LeaderboardUpdatePayload): ApiError => ({ ? 'leaderboardSortUpdate' : payload.visibility ? 'leaderboardVisibilityUpdate' - : payload.ignoreCustomPlayerNames !== undefined - ? 'leaderboardIgnoreCustomPlayerNamesUpdate' - : payload.autoPlayerNamePrefix !== undefined - ? 'leaderboardAutoPlayerNamePrefixUpdate' : payload.primary ? 'leaderboardPrimaryUpdate' : payload.customizationSettings @@ -156,16 +149,6 @@ const getApiError = (payload: LeaderboardUpdatePayload): ApiError => ({ An error occurred when updating the visibility of the leaderboard, please close the dialog, come back and try again. - ) : payload.ignoreCustomPlayerNames !== undefined ? ( - - An error occurred when updating the handling of player names of the - leaderboard, please close the dialog, come back and try again. - - ) : payload.autoPlayerNamePrefix !== undefined ? ( - - An error occurred when updating the handling of player names of the - leaderboard, please close the dialog, come back and try again. - ) : payload.primary ? ( An error occurred when setting the leaderboard as default, please close @@ -222,28 +205,16 @@ export const LeaderboardAdmin = ({ const authenticatedUser = React.useContext(AuthenticatedUserContext); const { limits } = authenticatedUser; - const [ - isEditingSortOptions, - setIsEditingSortOptions, - ] = React.useState(false); + const [isEditingOptions, setIsEditingOptions] = React.useState( + false + ); const [isEditingName, setIsEditingName] = React.useState(false); - const [ - isEditingAutoPlayerNamePrefix, - setIsEditingAutoPlayerNamePrefix, - ] = React.useState(false); const [isRequestPending, setIsRequestPending] = React.useState( false ); const [newName, setNewName] = React.useState(''); const [newNameError, setNewNameError] = React.useState(null); - const [ - newAutoPlayerNamePrefix, - setNewAutoPlayerNamePrefix, - ] = React.useState(''); const newNameTextFieldRef = React.useRef(null); - const newAutoPlayerNamePrefixTextFieldRef = React.useRef( - null - ); const [apiError, setApiError] = React.useState(null); const [ displayGameRegistration, @@ -295,8 +266,6 @@ export const LeaderboardAdmin = ({ try { await updateLeaderboard(payload); if (payload.name) setIsEditingName(false); - if (payload.autoPlayerNamePrefix !== undefined) - setIsEditingAutoPlayerNamePrefix(false); } catch (err) { console.error('An error occurred when updating leaderboard', err); setApiError(getApiError(payload)); @@ -772,7 +741,7 @@ export const LeaderboardAdmin = ({ ) : null, secondaryAction: ( setIsEditingSortOptions(true)} + onClick={() => setIsEditingOptions(true)} tooltip={t`Edit`} edge="end" disabled={isRequestPending || isEditingName} @@ -835,148 +804,22 @@ export const LeaderboardAdmin = ({ ), }, { - key: 'ignoreCustomPlayerNames', - avatar: currentLeaderboard.ignoreCustomPlayerNames ? ( - - ) : ( - - ), + key: 'options', + avatar: , text: ( - - - {currentLeaderboard.ignoreCustomPlayerNames ? ( - Ignore unauthenticated player usernames - ) : ( - Allow unauthenticated player usernames - )} - - + + Configuration + ), - secondaryText: - apiError && - apiError.action === 'leaderboardIgnoreCustomPlayerNamesUpdate' ? ( - - {apiError.message} - - ) : null, + secondaryText: null, secondaryAction: ( { - await onUpdateLeaderboard(i18n, { - ignoreCustomPlayerNames: !currentLeaderboard.ignoreCustomPlayerNames, - }); - }} - tooltip={ - currentLeaderboard.ignoreCustomPlayerNames - ? t`Change to allow custom player usernames` - : t`Change to ignore custom player usernames` - } + onClick={() => setIsEditingOptions(true)} + tooltip={t`Edit`} edge="end" disabled={isRequestPending || isEditingName} > - - - ), - }, - { - key: 'autoPlayerNamePrefix', - avatar: , - text: isEditingAutoPlayerNamePrefix ? ( - - setNewAutoPlayerNamePrefix(text)} - onKeyPress={event => { - if (shouldValidate(event) && !isRequestPending) { - onUpdateLeaderboard(i18n, { - autoPlayerNamePrefix: newAutoPlayerNamePrefix, - }); - } - }} - disabled={isRequestPending} - /> - {!isRequestPending && ( - <> - - { - setIsEditingAutoPlayerNamePrefix(false); - }} - > - - - - )} - - ) : ( - - - {currentLeaderboard.autoPlayerNamePrefix || - i18n._('No custom prefix for auto-generated player names')} - - - ), - secondaryText: - apiError && - apiError.action === 'leaderboardAutoPlayerNamePrefixUpdate' ? ( - - {apiError.message} - - ) : null, - secondaryAction: ( - { - if (isEditingAutoPlayerNamePrefix) { - onUpdateLeaderboard(i18n, { - autoPlayerNamePrefix: newAutoPlayerNamePrefix, - }); - } else { - setNewAutoPlayerNamePrefix( - currentLeaderboard.autoPlayerNamePrefix || '' - ); - setIsEditingAutoPlayerNamePrefix(true); - } - }} - tooltip={ - isEditingAutoPlayerNamePrefix - ? t`Save` - : t`Change the default prefix for player names` - } - disabled={isRequestPending} - edge="end" - id={ - isEditingAutoPlayerNamePrefix - ? 'save-autoPlayerNamePrefix-button' - : 'edit-autoPlayerNamePrefix-button' - } - > - {isEditingAutoPlayerNamePrefix ? ( - isRequestPending ? ( - - ) : ( - - ) - ) : ( - - )} + ), }, @@ -985,7 +828,7 @@ export const LeaderboardAdmin = ({ avatar: , text: ( - Leaderboard appearance + Appearance ), secondaryText: @@ -1290,24 +1133,20 @@ export const LeaderboardAdmin = ({ }} /> ) : null} - {isEditingSortOptions && currentLeaderboard ? ( - setIsEditingSortOptions(false)} - onSave={async (sortOptions: {| - sort: LeaderboardSortOption, - extremeAllowedScore: ?number, - |}) => { + onClose={() => setIsEditingOptions(false)} + onSave={async (options: LeaderboardOptions) => { try { await onUpdateLeaderboard(i18n, { - ...sortOptions, + ...options, }); } finally { - setIsEditingSortOptions(false); + setIsEditingOptions(false); } }} - sort={currentLeaderboard.sort} - extremeAllowedScore={currentLeaderboard.extremeAllowedScore} + leaderboard={currentLeaderboard} /> ) : null} diff --git a/newIDE/app/src/GameEngineFinder/BrowserS3GDJSFinder.js b/newIDE/app/src/GameEngineFinder/BrowserS3GDJSFinder.js index f89ea496b720..b34ff971589a 100644 --- a/newIDE/app/src/GameEngineFinder/BrowserS3GDJSFinder.js +++ b/newIDE/app/src/GameEngineFinder/BrowserS3GDJSFinder.js @@ -49,9 +49,9 @@ export const findGDJS = ( let gdjsRoot = `https://resources.gdevelop-app.com/GDJS-${getIDEVersion()}`; // If you want to test your local changes to the game engine on the local web-app, - // run `npx serve --cors` (or another CORS enabled http server on port 5000) + // run `npx serve --cors -p 5001` (or another CORS enabled http server on port 5001) // in `newIDE/app/resources/GDJS` and uncomment this line: - // gdjsRoot = `http://localhost:5000`; + // gdjsRoot = `http://localhost:5001`; return Promise.all( filesToDownload[fileSet].map(relativeFilePath => { diff --git a/newIDE/app/src/Utils/GDevelopServices/Play.js b/newIDE/app/src/Utils/GDevelopServices/Play.js index 0886b12774bf..e2c584c5cae1 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Play.js +++ b/newIDE/app/src/Utils/GDevelopServices/Play.js @@ -66,6 +66,7 @@ export type Leaderboard = {| extremeAllowedScore?: number, ignoreCustomPlayerNames?: boolean, autoPlayerNamePrefix?: string, + disableLoginInLeaderboard?: string, |}; export type LeaderboardUpdatePayload = {| @@ -78,6 +79,7 @@ export type LeaderboardUpdatePayload = {| extremeAllowedScore?: number | null, ignoreCustomPlayerNames?: boolean, autoPlayerNamePrefix?: string, + disableLoginInLeaderboard?: boolean, |}; export type LeaderboardEntry = {| diff --git a/newIDE/app/src/Utils/GDevelopServices/Usage.js b/newIDE/app/src/Utils/GDevelopServices/Usage.js index 77669e12e33d..14f80905618c 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Usage.js +++ b/newIDE/app/src/Utils/GDevelopServices/Usage.js @@ -69,6 +69,7 @@ export type Capabilities = {| canMaximumCountPerGameBeIncreased: boolean, themeCustomizationCapabilities: 'NONE' | 'BASIC' | 'FULL', canUseCustomCss: boolean, + canDisableLoginInLeaderboard: boolean, }, privateTutorials?: { allowedIdPrefixes: Array, diff --git a/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js b/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js index ef1a3c6be954..a9aff8317191 100644 --- a/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js +++ b/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js @@ -2361,6 +2361,7 @@ export const limitsForNoSubscriptionUser: Limits = { canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'NONE', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, }, quotas: { @@ -2404,6 +2405,7 @@ export const limitsForSilverUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'BASIC', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, }, quotas: { @@ -2447,6 +2449,7 @@ export const limitsForGoldUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'FULL', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, }, quotas: { @@ -2490,6 +2493,7 @@ export const limitsForTeacherUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'FULL', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, privateTutorials: { allowedIdPrefixes: ['education-curriculum-'], @@ -2541,6 +2545,7 @@ export const limitsForStudentUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'FULL', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, classrooms: { hidePlayTab: true, @@ -2589,6 +2594,7 @@ export const limitsForStartupUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, + canDisableLoginInLeaderboard: true, }, }, quotas: { @@ -2632,6 +2638,7 @@ export const limitsForBusinessUser: Limits = { canMaximumCountPerGameBeIncreased: false, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, + canDisableLoginInLeaderboard: true, }, }, quotas: { @@ -2675,6 +2682,7 @@ export const limitsReached: Limits = { canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'BASIC', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, }, quotas: { @@ -2718,6 +2726,7 @@ export const limitsForNoSubscriptionUserWithCredits: Limits = { canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'NONE', canUseCustomCss: false, + canDisableLoginInLeaderboard: false, }, }, quotas: { diff --git a/newIDE/app/src/stories/componentStories/Leaderboard/LeaderboardSortOptionsDialog.stories.js b/newIDE/app/src/stories/componentStories/Leaderboard/LeaderboardSortOptionsDialog.stories.js index a5dc37344e08..364b2212c64f 100644 --- a/newIDE/app/src/stories/componentStories/Leaderboard/LeaderboardSortOptionsDialog.stories.js +++ b/newIDE/app/src/stories/componentStories/Leaderboard/LeaderboardSortOptionsDialog.stories.js @@ -3,20 +3,47 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; import paperDecorator from '../../PaperDecorator'; -import LeaderboardSortOptionsDialog from '../../../GameDashboard/LeaderboardAdmin/LeaderboardSortOptionsDialog'; +import LeaderboardOptionsDialog from '../../../GameDashboard/LeaderboardAdmin/LeaderboardOptionsDialog'; +import { type Leaderboard } from '../../../Utils/GDevelopServices/Play'; +import { fakeStartupAuthenticatedUser } from '../../../fixtures/GDevelopServicesTestData'; +import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; export default { - title: 'Leaderboard/LeaderboardSortOptionsDialog', - component: LeaderboardSortOptionsDialog, + title: 'Leaderboard/LeaderboardOptionsDialog', + component: LeaderboardOptionsDialog, decorators: [paperDecorator], }; +const fakeLeaderboard: Leaderboard = { + id: 'fake-leaderboard-id', + gameId: 'fake-game-id', + name: 'My leaderboard', + sort: 'ASC', + startDatetime: '123', + playerUnicityDisplayChoice: 'FREE', + visibility: 'PUBLIC', +}; + export const Default = () => ( - action('onClose')()} - onSave={() => action('onSave')()} + onSave={action('onSave')} sort={'ASC'} extremeAllowedScore={undefined} + leaderboard={fakeLeaderboard} /> ); + +export const WithProSubscription = () => ( + + action('onClose')()} + onSave={action('onSave')} + sort={'ASC'} + extremeAllowedScore={undefined} + leaderboard={fakeLeaderboard} + /> + +);