Compare commits

...

35 Commits

Author SHA1 Message Date
Florian Rival
3ef2e4167e Move line height in text editor dialog 2025-08-07 18:45:50 +02:00
Florian Rival
71ea1995dd Fix flow 2025-08-07 18:43:31 +02:00
Florian Rival
d7248cb48e Revert BBText 2025-08-07 18:41:00 +02:00
Florian Rival
a08441eb9f Add missing types 2025-08-07 18:21:06 +02:00
Florian Rival
8014a59ea3 feat: add line height property to text objects 2025-08-07 18:09:35 +02:00
Florian Rival
902a30a9f8 Update capability user-friendly name and fix test
Don't show in changelog
2025-08-07 16:55:52 +02:00
Florian Rival
8669b94fb0 Improve project information sent to AI with the game resolution
Don't show in changelog
2025-08-05 14:29:58 +02:00
Florian Rival
7fb08aea62 Fix default light effects not added for scenes created by AI 2025-08-04 21:35:52 +02:00
Florian Rival
e7a1548b0e Fix default UI layer position for AI 2025-08-04 15:36:33 +02:00
Florian Rival
bdcb6f0533 Improve extension descriptions 2025-08-04 15:36:21 +02:00
github-actions[bot]
97849ce6f1 Update translations [skip ci] (#7760)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-08-01 18:05:51 +02:00
Clément Pasteau
d1c937caf4 Allow redeeming a code from the profile page directly (#7761) 2025-08-01 17:56:43 +02:00
Florian Rival
5ffe6279a2 Fix warning 2025-08-01 16:08:28 +02:00
Clément Pasteau
9260e2b77a Improve bundle listing (#7759)
Do not show in changelog
2025-08-01 15:44:16 +02:00
D8H
593465e2ec Optimize event-function calls (#7758) 2025-08-01 15:19:26 +02:00
github-actions[bot]
8820350760 Update translations [skip ci] (#7756)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-08-01 14:12:21 +02:00
Florian Rival
7e1668229a Change how bundle price is calculated to avoid API calls (#7757) 2025-08-01 13:50:26 +02:00
D8H
387b96b9a0 Allow anchors to set the wrapping width of bitmap texts and BBCode texts (#7755) 2025-08-01 11:47:10 +02:00
github-actions[bot]
5f52d786c6 Update translations [skip ci] (#7749)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2025-07-31 14:54:34 +02:00
Clément Pasteau
e0db597f9d A new Bundle to get started with GDevelop is now available (#7751)
* including multiple game templates and asset packs
* including a redemption code for a premium subscription
* including multiple official game dev courses
2025-07-31 14:18:05 +02:00
Florian Rival
41b0315ec6 Improve rating banner for course chapters 2025-07-30 18:49:27 +02:00
Florian Rival
a930a4085e Add basic button to rate premium course chapter 2025-07-30 14:56:46 +02:00
Florian Rival
d0dbbfac07 Add "StrReplaceOne" and "StrReplaceAll" expressions (#7750) 2025-07-30 10:10:29 +02:00
Florian Rival
3dc24b46f4 Fix warning
Don't show in changelog
2025-07-28 13:54:12 +02:00
Florian Rival
8e44a357b4 Fix Android build and player authentication sometimes not working (#7748)
- Player authentication window could not open if no action/condition related to player authentication was used
- Fix Android build by using an updated dependency for opening the authentication window
2025-07-28 12:55:58 +02:00
Florian Rival
dd462310cc Reduce network requests at startup by lazily loading course chapters when opened 2025-07-26 16:37:38 +02:00
Florian Rival
a1935fa0cd Reduce a bit more unnecessary fetches for course chapters
Don't show in changelog
2025-07-26 14:38:54 +02:00
Florian Rival
b45c57246b Add animation names to inspected object properties for AI 2025-07-26 14:21:44 +02:00
Florian Rival
c481ecd6b5 Bump newIDE version 2025-07-25 15:47:07 +02:00
github-actions[bot]
e0898dd9b0 Update translations [skip ci] (#7737)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-07-25 13:40:41 +02:00
Clément Pasteau
5561334efa Fix Send icon color (#7745) 2025-07-25 13:33:02 +02:00
Florian Rival
6c4bb4f79e Fix selection of face orientation in 3D Cube editor 2025-07-25 10:04:14 +02:00
D8H
8b2d2e2fe7 Fix "edit children" drop-down menu item activation (#7744)
- don't show in changelog
2025-07-24 14:04:25 +02:00
Florian Rival
49d128c964 Display "Ask AI" as a separate pane (or drawer on small screens) (#7738) 2025-07-24 13:20:52 +02:00
D8H
f24d1e0916 Add a deprecation message for custom objects using old "configuration overriding" (#7742) 2025-07-24 13:19:23 +02:00
236 changed files with 8870 additions and 2207 deletions

View File

@@ -18,21 +18,21 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsAnimatableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("AnimatableCapability",
_("Animatable capability"),
_("Animate objects."),
_("Objects with animations"),
_("Actions and conditions for objects having animations (sprite, 3D models...)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Animatable capability"))
extension.AddInstructionOrExpressionGroupMetadata(_("Objects with animations"))
.SetIcon("res/actions/animation24.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Animations and images"))
.SetIcon("res/actions/animation24.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"AnimatableBehavior",
_("Animatable capability"),
_("Objects with animations"),
"Animation",
_("Animate objects."),
_("Actions and conditions for objects having animations (sprite, 3D models...).."),
"",
"res/actions/animation24.png",
"AnimatableBehavior",

View File

@@ -18,8 +18,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsEffectExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("EffectCapability",
_("Effect capability"),
_("Apply visual effects to objects."),
_("Objects with effects"),
_("Actions/conditions to enable/disable and change parameters of visual effects applied on objects."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
@@ -28,9 +28,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsEffectExtension(
gd::BehaviorMetadata& aut = extension.AddBehavior(
"EffectBehavior",
_("Effect capability"),
_("Objects with effects"),
"Effect",
_("Apply visual effects to objects."),
_("Actions/conditions to enable/disable and change parameters of visual effects applied on objects."),
"",
"res/actions/effect_black.svg",
"EffectBehavior",

View File

@@ -18,8 +18,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsFlippableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("FlippableCapability",
_("Flippable capability"),
_("Flip objects."),
_("Flippable objects"),
_("Actions/conditions for objects which can be flipped horizontally or vertically."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
@@ -28,9 +28,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsFlippableExtension(
gd::BehaviorMetadata& aut = extension.AddBehavior(
"FlippableBehavior",
_("Flippable capability"),
_("Flippable objects"),
"Flippable",
_("Flip objects."),
_("Actions/conditions for objects which can be flipped horizontally or vertically."),
"",
"res/actions/flipX24.png",
"FlippableBehavior",

View File

@@ -18,27 +18,30 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsOpacityExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("OpacityCapability",
_("Opacity capability"),
_("Change the object opacity."),
_("Objects with opacity"),
_("Action/condition/expression to change or "
"check the opacity of an object (0-255)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Opacity capability"))
extension.AddInstructionOrExpressionGroupMetadata(_("Objects with opacity"))
.SetIcon("res/actions/opacity24.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Visibility"))
.SetIcon("res/actions/opacity24.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"OpacityBehavior",
_("Opacity capability"),
"Opacity",
_("Change the object opacity."),
"",
"res/actions/opacity24.png",
"OpacityBehavior",
std::make_shared<gd::Behavior>(),
std::make_shared<gd::BehaviorsSharedData>())
.SetHidden();
gd::BehaviorMetadata& aut =
extension
.AddBehavior("OpacityBehavior",
_("Objects with opacity"),
"Opacity",
_("Action/condition/expression to change or check the "
"opacity of an object (0-255)."),
"",
"res/actions/opacity24.png",
"OpacityBehavior",
std::make_shared<gd::Behavior>(),
std::make_shared<gd::BehaviorsSharedData>())
.SetHidden();
aut.AddExpressionAndConditionAndAction(
"number",
@@ -52,8 +55,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsOpacityExtension(
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "OpacityBehavior")
.UseStandardParameters(
"number", gd::ParameterOptions::MakeNewOptions().SetDescription(
_("Opacity (0-255)")))
"number",
gd::ParameterOptions::MakeNewOptions().SetDescription(
_("Opacity (0-255)")))
.SetFunctionName("setOpacity")
.SetGetter("getOpacity");
aut.GetAllExpressions()["Value"].SetGroup("");

View File

@@ -16,11 +16,13 @@ namespace gd {
void GD_CORE_API BuiltinExtensionsImplementer::ImplementsResizableExtension(
gd::PlatformExtension &extension) {
extension
.SetExtensionInformation("ResizableCapability",
_("Resizable capability"),
_("Change the object dimensions."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionInformation(
"ResizableCapability",
_("Resizable objects"),
_("Change or compare the size (width/height) of an object which can "
"be resized (i.e: most objects)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Size")).SetIcon(
"res/actions/scale24_black.png");
@@ -28,9 +30,10 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsResizableExtension(
gd::BehaviorMetadata &aut =
extension
.AddBehavior("ResizableBehavior",
_("Resizable capability"),
_("Resizable objects"),
"Resizable",
_("Change the object dimensions."),
_("Change or compare the size (width/height) of an "
"object which can be resized (i.e: most objects)."),
"",
"res/actions/scale24_black.png",
"ResizableBehavior",

View File

@@ -18,27 +18,30 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsScalableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("ScalableCapability",
_("Scalable capability"),
_("Change the object scale."),
_("Scalable objects"),
_("Actions/conditions/expression to change or "
"check the scale of an object (default: 1)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Scalable capability"))
.SetIcon("res/actions/scale24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Size"))
extension.AddInstructionOrExpressionGroupMetadata(_("Scalable objects"))
.SetIcon("res/actions/scale24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Size")).SetIcon(
"res/actions/scale24_black.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"ScalableBehavior",
_("Scalable capability"),
"Scale",
_("Change the object scale."),
"",
"res/actions/scale24_black.png",
"ResizableBehavior",
std::make_shared<gd::Behavior>(),
std::make_shared<gd::BehaviorsSharedData>())
.SetHidden();
gd::BehaviorMetadata& aut =
extension
.AddBehavior("ScalableBehavior",
_("Scalable objects"),
"Scale",
_("Actions/conditions/expression to change or check the "
"scale of an object (default: 1)."),
"",
"res/actions/scale24_black.png",
"ResizableBehavior",
std::make_shared<gd::Behavior>(),
std::make_shared<gd::BehaviorsSharedData>())
.SetHidden();
aut.AddExpressionAndConditionAndAction(
"number",

View File

@@ -18,17 +18,17 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTextContainerExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("TextContainerCapability",
_("Text capability"),
_("Objects containing a text"),
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Text capability"))
extension.AddInstructionOrExpressionGroupMetadata(_("Objects containing a text"))
.SetIcon("res/conditions/text24_black.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"TextContainerBehavior",
_("Text capability"),
_("Objects containing a text"),
"Text",
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"",

View File

@@ -16,7 +16,9 @@ BuiltinExtensionsImplementer::ImplementsCommonConversionsExtension(
.SetExtensionInformation(
"BuiltinCommonConversions",
_("Conversion"),
"Expressions to convert number, texts and quantities.",
"Expressions to convert numbers to string, strings to numbers, "
"angles (degrees from/to radians) and a GDevelop variable to/from a "
"JSON string.",
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/all-features/common-conversions");
@@ -41,7 +43,7 @@ BuiltinExtensionsImplementer::ImplementsCommonConversionsExtension(
extension
.AddStrExpression("LargeNumberToString",
_("Number > Text ( without scientific notation )"),
_("Number > Text (without scientific notation)"),
_("Convert the result of the expression to text, "
"without using the scientific notation"),
"",
@@ -72,7 +74,8 @@ BuiltinExtensionsImplementer::ImplementsCommonConversionsExtension(
_("Convert a variable to JSON"),
_("JSON"),
"res/conditions/toujours24_black.png")
.AddParameter("variable", _("The variable to be stringified"),
.AddParameter("variable",
_("The variable to be stringified"),
"AllowUndeclaredVariable");
// Deprecated

View File

@@ -15,10 +15,11 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsKeyboardExtension(
.SetExtensionInformation(
"BuiltinKeyboard",
_("Keyboard"),
_("Allows your game to respond to keyboard input. Note that this "
_("Conditions to check keys pressed on a keyboard. Note that this "
"does not work with on-screen keyboard on touch devices: use "
"instead conditions related to touch when making a game for "
"mobile/touchscreen devices."),
"instead mouse/touch conditions when making a game for "
"mobile/touchscreen devices or when making a new game from "
"scratch."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/all-features/keyboard")
@@ -84,7 +85,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsKeyboardExtension(
"res/conditions/keyboard.png")
.AddCodeOnlyParameter("currentScene", "");
extension
extension
.AddCondition("AnyKeyReleased",
_("Any key released"),
_("Check if any key is released"),

View File

@@ -16,8 +16,11 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
.SetExtensionInformation(
"BuiltinMouse",
_("Mouse and touch"),
"Conditions and actions to handle either the mouse or touches on "
"touchscreen. By default, conditions related to the mouse will also "
"Conditions, actions and expressions to handle either the mouse or "
"touches on a touchscreen. Notably: cursor position, mouse wheel, "
"mouse buttons, touch positions, started/end touches, etc...\n"
"\n"
"By default, conditions related to the mouse will also "
"handle the touches - so that it's easier to handle both in your "
"game. You can disable this behavior if you want to handle them "
"separately in different events.",
@@ -273,28 +276,26 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
.SetHidden();
extension
.AddCondition(
"MouseButtonFromTextPressed",
_("Mouse button pressed or touch held"),
_("Check if the specified mouse button is pressed or "
"if a touch is in contact with the screen."),
_("Touch or _PARAM1_ mouse button is down"),
"",
"res/conditions/mouse24.png",
"res/conditions/mouse.png")
.AddCondition("MouseButtonFromTextPressed",
_("Mouse button pressed or touch held"),
_("Check if the specified mouse button is pressed or "
"if a touch is in contact with the screen."),
_("Touch or _PARAM1_ mouse button is down"),
"",
"res/conditions/mouse24.png",
"res/conditions/mouse.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("mouseButton", _("Button to check"))
.MarkAsSimple();
extension
.AddCondition(
"MouseButtonFromTextReleased",
_("Mouse button released"),
_("Check if the specified mouse button was released."),
_("Touch or _PARAM1_ mouse button is released"),
"",
"res/conditions/mouse24.png",
"res/conditions/mouse.png")
.AddCondition("MouseButtonFromTextReleased",
_("Mouse button released"),
_("Check if the specified mouse button was released."),
_("Touch or _PARAM1_ mouse button is released"),
"",
"res/conditions/mouse24.png",
"res/conditions/mouse.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("mouseButton", _("Button to check"))
.MarkAsSimple();

View File

@@ -15,8 +15,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsNetworkExtension(
.SetExtensionInformation(
"BuiltinNetwork",
_("Network"),
_("Features to send web requests, communicate with external \"APIs\" "
"and other network related tasks."),
_("Actions to send web requests, communicate with external \"APIs\" "
"and other network related tasks. Also contains an action to open "
"a URL on the device browser."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/all-features/network")

View File

@@ -4,8 +4,8 @@
* reserved. This project is released under the MIT License.
*/
#include "AllBuiltinExtensions.h"
#include "GDCore/Tools/Localization.h"
#include "GDCore/Extensions/Metadata/MultipleInstructionMetadata.h"
#include "GDCore/Tools/Localization.h"
using namespace std;
namespace gd {
@@ -16,7 +16,11 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension(
.SetExtensionInformation(
"BuiltinScene",
_("Scene"),
_("Actions and conditions to manipulate the scenes during the game."),
_("Actions/conditions to change the current scene (or pause it and "
"launch another one, or go back to the previous one), check if a "
"scene or the game has just started/resumed, preload assets of a "
"scene, get the current scene name or loading progress, quit the "
"game, set background color, or disable input when focus is lost."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("" /*TODO: Add a documentation page for this */);
@@ -166,25 +170,28 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension(
.AddCodeOnlyParameter("currentScene", "");
extension
.AddAction("PrioritizeLoadingOfScene",
_("Preload scene"),
_("Preload a scene resources as soon as possible in background."),
_("Preload scene _PARAM1_ in background"),
"",
"res/actions/hourglass_black.svg",
"res/actions/hourglass_black.svg")
.AddAction(
"PrioritizeLoadingOfScene",
_("Preload scene"),
_("Preload a scene resources as soon as possible in background."),
_("Preload scene _PARAM1_ in background"),
"",
"res/actions/hourglass_black.svg",
"res/actions/hourglass_black.svg")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Name of the new scene"))
.MarkAsAdvanced();
extension.AddExpressionAndCondition("number",
"SceneLoadingProgress",
_("Scene loading progress"),
_("The progress of resources loading in background for a scene (between 0 and 1)."),
_("_PARAM1_ loading progress"),
_(""),
"res/actions/hourglass_black.svg")
extension
.AddExpressionAndCondition("number",
"SceneLoadingProgress",
_("Scene loading progress"),
_("The progress of resources loading in "
"background for a scene (between 0 and 1)."),
_("_PARAM1_ loading progress"),
_(""),
"res/actions/hourglass_black.svg")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Scene name"))
@@ -192,13 +199,14 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension(
.MarkAsAdvanced();
extension
.AddCondition("AreSceneAssetsLoaded",
_("Scene preloaded"),
_("Check if scene resources have finished to load in background."),
_("Scene _PARAM1_ was preloaded in background"),
"",
"res/actions/hourglass_black.svg",
"res/actions/hourglass_black.svg")
.AddCondition(
"AreSceneAssetsLoaded",
_("Scene preloaded"),
_("Check if scene resources have finished to load in background."),
_("Scene _PARAM1_ was preloaded in background"),
"",
"res/actions/hourglass_black.svg",
"res/actions/hourglass_black.svg")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Scene name"))

View File

@@ -15,12 +15,13 @@ namespace gd {
void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSpriteExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("Sprite",
_("Sprite"),
_("Sprite are animated object which can be used "
"for most elements of a game."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionInformation(
"Sprite",
_("Sprite"),
_("Sprite are animated objects which can be used "
"for most elements of a 2D game."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects/sprite");
extension.AddInstructionOrExpressionGroupMetadata(_("Sprite"))
.SetIcon("CppPlatform/Extensions/spriteicon.png");
@@ -30,7 +31,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSpriteExtension(
.AddObject<SpriteObject>("Sprite",
_("Sprite"),
_("Animated object which can be used for "
"most elements of a game."),
"most elements of a 2D game."),
"CppPlatform/Extensions/spriteicon.png")
.SetCategoryFullName(_("General"))
.SetOpenFullEditorLabel(_("Edit animations"))
@@ -645,11 +646,12 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSpriteExtension(
"res/actions/sprite.png")
.AddParameter("object", _("Object"), "Sprite");
obj.AddExpression("AnimationFrameCount",
_("Number of frames"),
_("Number of frames in the current animation of the object"),
_("Animations and images"),
"res/actions/sprite.png")
obj.AddExpression(
"AnimationFrameCount",
_("Number of frames"),
_("Number of frames in the current animation of the object"),
_("Animations and images"),
"res/actions/sprite.png")
.AddParameter("object", _("Object"), "Sprite");
// Deprecated

View File

@@ -16,7 +16,8 @@ BuiltinExtensionsImplementer::ImplementsStringInstructionsExtension(
.SetExtensionInformation(
"BuiltinStringInstructions",
_("Text manipulation"),
"Provides expressions to manipulate strings (also called texts).",
"Provides expressions to manipulate strings (also called texts): new "
"line, upper/lowercase, substring, find, replace, etc...",
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("" /*TODO: Add a documentation page for this */);
@@ -191,7 +192,8 @@ BuiltinExtensionsImplementer::ImplementsStringInstructionsExtension(
"res/conditions/toujours24_black.png")
.AddParameter("string", _("Text in which the replacement must be done"))
.AddParameter("string", _("Text to find inside the first text"))
.AddParameter("string", _("Replacement to put instead of the text to find"));
.AddParameter("string",
_("Replacement to put instead of the text to find"));
extension
.AddStrExpression("StrReplaceAll",
@@ -199,10 +201,11 @@ BuiltinExtensionsImplementer::ImplementsStringInstructionsExtension(
_("Replace all occurrences of a text by another."),
"",
"res/conditions/toujours24_black.png")
.AddParameter("string", _("Text in which the replacement(s) must be done"))
.AddParameter("string",
_("Text in which the replacement(s) must be done"))
.AddParameter("string", _("Text to find inside the first text"))
.AddParameter("string", _("Replacement to put instead of the text to find"));
.AddParameter("string",
_("Replacement to put instead of the text to find"));
}
} // namespace gd

View File

@@ -15,9 +15,12 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTimeExtension(
.SetExtensionInformation(
"BuiltinTime",
_("Timers and time"),
"Actions and conditions to run timers, get the current time or "
"modify the time scale (speed at which the game is running - useful "
"for slow motion effects).",
"Actions and conditions to start, pause or reset scene timers, "
"modify the time scale (speed at which the game "
"is running - useful for slow motion effects). Also contains an "
"action that wait for a delay before running the next actions and "
"sub-events and expressions to read the time scale, time delta of "
"the last frame or timer elapsed time.",
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/all-features/timers-and-time");
@@ -192,26 +195,28 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTimeExtension(
extension
.AddExpression("TimerElapsedTime",
_("Scene timer value"),
_("Value of a scene timer"),
_("Value of a scene timer (in seconds)"),
"",
"res/actions/time.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("identifier", _("Timer's name"), "sceneTimer");
extension
.AddExpression("TimeFromStart",
_("Time elapsed since the beginning of the scene"),
_("Time elapsed since the beginning of the scene"),
"",
"res/actions/time.png")
.AddExpression(
"TimeFromStart",
_("Time elapsed since the beginning of the scene (in seconds)."),
_("Time elapsed since the beginning of the scene (in seconds)."),
"",
"res/actions/time.png")
.AddCodeOnlyParameter("currentScene", "");
extension
.AddExpression("TempsDebut",
_("Time elapsed since the beginning of the scene"),
_("Time elapsed since the beginning of the scene"),
"",
"res/actions/time.png")
.AddExpression(
"TempsDebut",
_("Time elapsed since the beginning of the scene (in seconds)."),
_("Time elapsed since the beginning of the scene (in seconds)."),
"",
"res/actions/time.png")
.SetHidden()
.AddCodeOnlyParameter("currentScene", "");
@@ -226,16 +231,21 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTimeExtension(
extension
.AddExpression("Time",
_("Current time"),
_("Current time"),
_("Gives the current time"),
"",
"res/actions/time.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter(
"stringWithSelector",
_("Hour: hour - Minutes: min - Seconds: sec - Day of month: "
"mday - Months since January: mon - Year since 1900: year - Days "
"since Sunday: wday - Days since Jan 1st: yday - Timestamp (ms): "
"timestamp\""),
_("- Hour of the day: \"hour\"\n"
"- Minutes: \"min\"\n"
"- Seconds: \"sec\"\n"
"- Day of month: \"mday\"\n"
"- Months since January: \"mon\"\n"
"- Year since 1900: \"year\"\n"
"- Days since Sunday: \"wday\"\n"
"- Days since Jan 1st: \"yday\"\n"
"- Timestamp (ms): \"timestamp\""),
"[\"hour\", \"min\", \"sec\", \"mon\", \"year\", \"wday\", \"mday\", "
"\"yday\", \"timestamp\"]");
}

View File

@@ -15,16 +15,17 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsWindowExtension(
.SetExtensionInformation(
"BuiltinWindow",
_("Game window and resolution"),
"Provides actions and conditions to manipulate the game window. "
"Actions and conditions to manipulate the game window or change how "
"the game is resized according to the screen size. "
"Depending on the platform on which the game is running, not all of "
"these features can be applied.",
"these features can be applied.\n"
"Also contains expressions to read the screen size.",
"Florian Rival",
"Open source (MIT License)")
.SetCategory("User interface")
.SetExtensionHelpPath("/all-features/window");
extension
.AddInstructionOrExpressionGroupMetadata(
_("Game window and resolution"))
.AddInstructionOrExpressionGroupMetadata(_("Game window and resolution"))
.SetIcon("res/actions/window24.png");
extension

View File

@@ -242,7 +242,7 @@ module.exports = {
.addObject(
'Model3DObject',
_('3D Model'),
_('An animated 3D model.'),
_('An animated 3D model, useful for most elements of a 3D game.'),
'JsPlatform/Extensions/3d_model.svg',
new gd.Model3DObjectConfiguration()
)

View File

@@ -383,6 +383,10 @@ namespace gdjs {
return this._renderer.getHeight();
}
override setWidth(width: float): void {
this.setWrappingWidth(width);
}
override getDrawableY(): float {
return (
this.getY() -

View File

@@ -426,6 +426,10 @@ namespace gdjs {
return this._renderer.getHeight();
}
override setWidth(width: float): void {
this.setWrappingWidth(width);
}
override getDrawableY(): float {
return (
this.getY() -

View File

@@ -21,7 +21,9 @@ module.exports = {
.setExtensionInformation(
'FileSystem',
_('File system'),
_('Access the filesystem of the operating system.'),
_(
'Access the filesystem of the operating system - only works on native, desktop games exported to Windows, Linux or macOS.'
),
'Matthias Meike',
'Open source (MIT License)'
)

View File

@@ -5,23 +5,25 @@ Copyright (c) 2008-2016 Florian Rival (Florian.Rival@gmail.com)
This project is released under the MIT License.
*/
#include <iostream>
#include "GDCore/Extensions/PlatformExtension.h"
#include "GDCore/Tools/Localization.h"
#include <iostream>
void DeclareInventoryExtension(gd::PlatformExtension& extension) {
extension.SetExtensionInformation(
"Inventory",
_("Inventories"),
_("Provides actions and conditions to add an inventory to your game, "
"with items in memory."),
"Florian Rival",
"Open source (MIT License)")
extension
.SetExtensionInformation(
"Inventory",
_("Inventories"),
_("Actions and conditions to store named inventories in memory, "
"with items (indexed by their name), a count for each of them, "
"a maximum count and an equipped state. Can be loaded/saved "
"from/to a GDevelop variable."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/all-features/inventory")
.SetCategory("Game mechanic");
extension
.AddInstructionOrExpressionGroupMetadata(_("Inventories"))
extension.AddInstructionOrExpressionGroupMetadata(_("Inventories"))
.SetIcon("CppPlatform/Extensions/Inventoryicon.png");
extension
@@ -164,14 +166,15 @@ void DeclareInventoryExtension(gd::PlatformExtension& extension) {
.SetFunctionName("InventoryTools::IsEquipped");
extension
.AddAction("SerializeToVariable",
_("Save an inventory in a scene variable"),
_("Save all the items of the inventory in a scene variable, so that "
"it can be restored later."),
_("Save inventory _PARAM1_ in variable _PARAM2_"),
_("Variables"),
"CppPlatform/Extensions/Inventoryicon.png",
"CppPlatform/Extensions/Inventoryicon.png")
.AddAction(
"SerializeToVariable",
_("Save an inventory in a scene variable"),
_("Save all the items of the inventory in a scene variable, so that "
"it can be restored later."),
_("Save inventory _PARAM1_ in variable _PARAM2_"),
_("Variables"),
"CppPlatform/Extensions/Inventoryicon.png",
"CppPlatform/Extensions/Inventoryicon.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("string", _("Inventory name"))
@@ -204,13 +207,14 @@ void DeclareInventoryExtension(gd::PlatformExtension& extension) {
.SetFunctionName("InventoryTools::Count");
extension
.AddExpression("Maximum",
_("Item maximum"),
_("Get the maximum of an item in the inventory, or 0 if it is unlimited"),
"",
"CppPlatform/Extensions/Inventoryicon.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("string", _("Inventory name"))
.AddParameter("string", _("Item name"))
.SetFunctionName("InventoryTools::Maximum");
.AddExpression("Maximum",
_("Item maximum"),
_("Get the maximum of an item in the inventory, or 0 if "
"it is unlimited"),
"",
"CppPlatform/Extensions/Inventoryicon.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("string", _("Inventory name"))
.AddParameter("string", _("Item name"))
.SetFunctionName("InventoryTools::Maximum");
}

View File

@@ -21,7 +21,9 @@ module.exports = {
.setExtensionInformation(
'Leaderboards',
_('Leaderboards'),
_('Allow your game to send scores to your leaderboards.'),
_(
'Allow your game to send scores to your leaderboards (anonymously or from the logged-in player) or display existing leaderboards to the player.'
),
'Florian Rival',
'Open source (MIT License)'
)
@@ -30,6 +32,12 @@ module.exports = {
.addInstructionOrExpressionGroupMetadata(_('Leaderboards'))
.setIcon('JsPlatform/Extensions/leaderboard.svg');
extension
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addAction(
'SavePlayerScore',

View File

@@ -22,7 +22,7 @@ module.exports = {
'Lighting',
_('Lights'),
'This provides a light object, and a behavior to mark other objects as being obstacles for the lights. This is a great way to create a special atmosphere to your game, along with effects, make it more realistic or to create gameplays based on lights.',
'This provides a 2D light object, and a behavior to mark other 2D objects as being obstacles for the lights. This is a great way to create a special atmosphere to your game, along with effects, make it more realistic or to create gameplays based on lights.',
'Harsimran Virk',
'MIT'
)
@@ -51,7 +51,7 @@ module.exports = {
_('Light Obstacle Behavior'),
'LightObstacleBehavior',
_(
'Flag objects as being obstacles to light. The light emitted by light objects will be stopped by the object.'
'Flag objects as being obstacles to 2D lights. The light emitted by light objects will be stopped by the object. This does not work on 3D objects and 3D games.'
),
'',
'CppPlatform/Extensions/lightObstacleIcon32.png',
@@ -164,7 +164,7 @@ module.exports = {
'LightObject',
_('Light'),
_(
'Displays a light on the scene, with a customizable radius and color. Add then the Light Obstacle behavior to the objects that must act as obstacle to the lights.'
'Displays a 2D light on the scene, with a customizable radius and color. Add then the Light Obstacle behavior to the objects that must act as obstacle to the lights.'
),
'CppPlatform/Extensions/lightIcon32.png',
lightObject

View File

@@ -239,7 +239,7 @@ namespace gdjs {
instanceContainer: gdjs.RuntimeInstanceContainer,
objectsLists: Hashtable<gdjs.RuntimeObject[]>,
obj: gdjs.RuntimeObject | null,
eventsFunctionContext: EventsFunctionContext | undefined
eventsFunctionContext: EventsFunctionContext | null | undefined
) {
if (obj === null) {
return false;

View File

@@ -21,7 +21,9 @@ module.exports = {
.setExtensionInformation(
'Multiplayer',
_('Multiplayer'),
_('Allow players to connect to lobbies and play together.'),
_(
'This allows players to join online lobbies and synchronize gameplay across devices without needing to manage servers or networking.\n\nUse the "Open game lobbies" action to let players join a game, and use conditions like "Lobby game has just started" to begin gameplay. Add the "Multiplayer object" behavior to game objects that should be synchronized, and assign or change their ownership using player numbers. Variables and game state (like scenes, scores, or timers) are automatically synced by the host, with options to change ownership or disable sync when needed. Common multiplayer logic —like handling joins/leaves, collisions, and host migration— is supported out-of-the-box for up to 8 players per game.'
),
'Florian Rival',
'Open source (MIT License)'
)
@@ -31,6 +33,12 @@ module.exports = {
.addInstructionOrExpressionGroupMetadata(_('Multiplayer'))
.setIcon('JsPlatform/Extensions/multiplayer.svg');
extension
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addStrExpression(
'CurrentLobbyID',

View File

@@ -35,7 +35,7 @@ module.exports = {
.addDependency()
.setName('Safari View Controller Cordova plugin')
.setDependencyType('cordova')
.setExportName('cordova-plugin-safariviewcontroller');
.setExportName('@gdevelop/cordova-plugin-safariviewcontroller');
extension
.addAction(

View File

@@ -834,34 +834,51 @@ namespace gdjs {
authWindowOptions,
});
if (typeof SafariViewController === 'undefined') {
logger.error(
'Cordova plugin SafariViewController is not installed.'
);
resolve('errored');
return;
}
SafariViewController.isAvailable(function (available: boolean) {
if (available) {
SafariViewController.show(
{
url: targetUrl,
hidden: false,
animated: true,
transition: 'slide',
enterReaderModeIfAvailable: false,
barColor: '#000000',
tintColor: '#ffffff',
controlTintColor: '#ffffff',
},
function (result: any) {
// Other events are `opened` and `loaded`.
if (result.event === 'closed') {
resolve('dismissed');
}
},
function (error: any) {
logger.log('Error opening webview: ' + JSON.stringify(error));
resolve('errored');
}
if (!available) {
logger.error(
'Cordova plugin SafariViewController is installed but not available'
);
} else {
logger.error('Plugin SafariViewController is not available');
resolve('errored');
return;
}
logger.info(
'Opening authentication window for Cordova with SafariViewController.'
);
SafariViewController.show(
{
url: targetUrl,
hidden: false,
animated: true,
transition: 'slide',
enterReaderModeIfAvailable: false,
barColor: '#000000',
tintColor: '#ffffff',
controlTintColor: '#ffffff',
},
function (result: any) {
// Other events are `opened` and `loaded`.
if (result.event === 'closed') {
resolve('dismissed');
}
},
function (error: any) {
logger.log(
'Error opening authentication window: ' +
JSON.stringify(error)
);
resolve('errored');
}
);
});
}
);

View File

@@ -15,7 +15,7 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
.SetExtensionInformation(
"PrimitiveDrawing",
_("Shape painter"),
_("This provides an object that can be used to draw arbitrary shapes "
_("An object that can be used to draw arbitrary 2D shapes "
"on the screen using events."),
"Florian Rival and Aurélien Vivet",
"Open source (MIT License)")
@@ -28,7 +28,7 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
.AddObject<ShapePainterObject>(
"Drawer", //"Drawer" is kept for compatibility with GD<=3.6.76
_("Shape painter"),
_("Allows you to draw simple shapes on the screen using the "
_("Allows to draw simple 2D shapes on the screen using the "
"events."),
"CppPlatform/Extensions/primitivedrawingicon.png")
.SetCategoryFullName(_("Advanced"))
@@ -125,11 +125,11 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
.SetFunctionName("DrawEllipse");
obj.AddAction("FilletRectangle",
_("Fillet Rectangle"),
_("Draw a fillet rectangle on screen"),
_("Draw from _PARAM1_;_PARAM2_ to _PARAM3_;_PARAM4_ a fillet "
"rectangle (fillet: _PARAM5_)"
"with _PARAM0_"),
_("Fillet Rectangle"),
_("Draw a fillet rectangle on screen"),
_("Draw from _PARAM1_;_PARAM2_ to _PARAM3_;_PARAM4_ a fillet "
"rectangle (fillet: _PARAM5_)"
"with _PARAM0_"),
_("Drawing"),
"res/actions/filletRectangle24.png",
"res/actions/filletRectangle.png")
@@ -142,7 +142,6 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
.AddParameter("expression", _("Fillet (in pixels)"))
.SetFunctionName("DrawFilletRectangle");
obj.AddAction("RoundedRectangle",
_("Rounded rectangle"),
_("Draw a rounded rectangle on screen"),
@@ -170,54 +169,53 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
_("Drawing"),
"res/actions/chamferRectangle24.png",
"res/actions/chamferRectangle.png")
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("Left X position"))
.AddParameter("expression", _("Top Y position"))
.AddParameter("expression", _("Right X position"))
.AddParameter("expression", _("Bottom Y position"))
.AddParameter("expression", _("Chamfer (in pixels)"))
.SetFunctionName("DrawChamferRectangle");
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("Left X position"))
.AddParameter("expression", _("Top Y position"))
.AddParameter("expression", _("Right X position"))
.AddParameter("expression", _("Bottom Y position"))
.AddParameter("expression", _("Chamfer (in pixels)"))
.SetFunctionName("DrawChamferRectangle");
obj.AddAction("Torus",
_("Torus"),
_("Draw a torus on screen"),
_("Draw at _PARAM1_;_PARAM2_ a torus with "
"inner radius: _PARAM3_, outer radius: _PARAM4_ and "
"with start arc angle: _PARAM5_°, end angle: _PARAM6_° "
"with _PARAM0_"),
_("Drawing"),
"res/actions/torus24.png",
"res/actions/torus.png")
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("X position of center"))
.AddParameter("expression", _("Y position of center"))
.AddParameter("expression", _("Inner Radius (in pixels)"))
.AddParameter("expression", _("Outer Radius (in pixels)"))
.AddParameter("expression", _("Start Arc (in degrees)"))
.AddParameter("expression", _("End Arc (in degrees)"))
.SetFunctionName("DrawTorus");
_("Torus"),
_("Draw a torus on screen"),
_("Draw at _PARAM1_;_PARAM2_ a torus with "
"inner radius: _PARAM3_, outer radius: _PARAM4_ and "
"with start arc angle: _PARAM5_°, end angle: _PARAM6_° "
"with _PARAM0_"),
_("Drawing"),
"res/actions/torus24.png",
"res/actions/torus.png")
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("X position of center"))
.AddParameter("expression", _("Y position of center"))
.AddParameter("expression", _("Inner Radius (in pixels)"))
.AddParameter("expression", _("Outer Radius (in pixels)"))
.AddParameter("expression", _("Start Arc (in degrees)"))
.AddParameter("expression", _("End Arc (in degrees)"))
.SetFunctionName("DrawTorus");
obj.AddAction("RegularPolygon",
_("Regular Polygon"),
_("Draw a regular polygon on screen"),
_("Draw at _PARAM1_;_PARAM2_ a regular polygon with _PARAM3_ sides and radius: "
_("Draw at _PARAM1_;_PARAM2_ a regular polygon with _PARAM3_ "
"sides and radius: "
"_PARAM4_ (rotation: _PARAM5_) "
"with _PARAM0_"),
_("Drawing"),
"res/actions/regularPolygon24.png",
"res/actions/regularPolygon.png")
_("Drawing"),
"res/actions/regularPolygon24.png",
"res/actions/regularPolygon.png")
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("X position of center"))
.AddParameter("expression", _("Y position of center"))
.AddParameter("expression",
_("Number of sides of the polygon (minimum: 3)"))
.AddParameter("expression", _("Radius (in pixels)"))
.AddParameter("expression", _("Rotation (in degrees)"))
.SetFunctionName("DrawRegularPolygon");
.AddParameter("object", _("Shape Painter object"), "Drawer")
.AddParameter("expression", _("X position of center"))
.AddParameter("expression", _("Y position of center"))
.AddParameter("expression",
_("Number of sides of the polygon (minimum: 3)"))
.AddParameter("expression", _("Radius (in pixels)"))
.AddParameter("expression", _("Rotation (in degrees)"))
.SetFunctionName("DrawRegularPolygon");
obj.AddAction(
"Star",

View File

@@ -13,7 +13,8 @@ void DeclareSystemInfoExtension(gd::PlatformExtension& extension) {
.SetExtensionInformation(
"SystemInfo",
_("System information"),
_("Get information about the system and device running the game."),
_("Conditions to check if the device has a touchscreen, is a mobile, "
"or if the game runs as a preview."),
"Florian Rival",
"Open source (MIT License)")
.SetCategory("Advanced");

View File

@@ -449,6 +449,16 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
.AddParameter("object", _("Object"), "Text")
.UseStandardParameters("number", gd::ParameterOptions::MakeNewOptions());
obj.AddExpressionAndConditionAndAction("number",
"LineHeight",
_("Line height"),
_("the line height of a text object"),
_("the line height"),
"",
"res/actions/font24.png")
.AddParameter("object", _("Object"), "Text")
.UseStandardParameters("number", gd::ParameterOptions::MakeNewOptions());
// Support for deprecated "Size" actions/conditions:
obj.AddDuplicatedAction("Size", "Text::SetFontSize").SetHidden();
obj.AddDuplicatedCondition("Size", "Text::FontSize").SetHidden();

View File

@@ -96,6 +96,14 @@ class TextObjectJsExtension : public gd::PlatformExtension {
.SetFunctionName("setOutlineThickness")
.SetGetter("getOutlineThickness");
GetAllExpressionsForObject("TextObject::Text")["LineHeight"]
.SetFunctionName("getLineHeight");
GetAllConditionsForObject("TextObject::Text")["TextObject::Text::LineHeight"]
.SetFunctionName("getLineHeight");
GetAllActionsForObject("TextObject::Text")["TextObject::Text::SetLineHeight"]
.SetFunctionName("setLineHeight")
.SetGetter("getLineHeight");
GetAllActionsForObject("TextObject::Text")["TextObject::ShowShadow"]
.SetFunctionName("showShadow");
GetAllConditionsForObject("TextObject::Text")["TextObject::Text::IsShadowEnabled"]

View File

@@ -20,6 +20,7 @@ using namespace std;
TextObject::TextObject()
: text("Text"),
characterSize(20),
lineHeight(0),
fontName(""),
smoothed(true),
bold(false),
@@ -50,6 +51,10 @@ bool TextObject::UpdateProperty(const gd::String& propertyName,
characterSize = newValue.To<double>();
return true;
}
if (propertyName == "lineHeight") {
lineHeight = newValue.To<double>();
return true;
}
if (propertyName == "font") {
fontName = newValue;
return true;
@@ -129,6 +134,13 @@ std::map<gd::String, gd::PropertyDescriptor> TextObject::GetProperties() const {
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetGroup(_("Font"));
objectProperties["lineHeight"]
.SetValue(gd::String::From(lineHeight))
.SetType("number")
.SetLabel(_("Line height"))
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetGroup(_("Font"));
objectProperties["font"]
.SetValue(fontName)
.SetType("resource")
@@ -271,6 +283,7 @@ void TextObject::DoUnserializeFrom(gd::Project& project,
SetCharacterSize(content.GetChild("characterSize", 0, "CharacterSize")
.GetValue()
.GetInt());
SetLineHeight(content.GetDoubleAttribute("lineHeight", 0));
smoothed = content.GetBoolAttribute("smoothed");
bold = content.GetBoolAttribute("bold");
italic = content.GetBoolAttribute("italic");
@@ -339,6 +352,7 @@ void TextObject::DoSerializeTo(gd::SerializerElement& element) const {
content.AddChild("textAlignment").SetValue(GetTextAlignment());
content.AddChild("verticalTextAlignment").SetValue(GetVerticalTextAlignment());
content.AddChild("characterSize").SetValue(GetCharacterSize());
content.AddChild("lineHeight").SetValue(GetLineHeight());
content.AddChild("color").SetValue(GetColor());
content.SetAttribute("smoothed", smoothed);

View File

@@ -49,6 +49,12 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
*/
inline double GetCharacterSize() const { return characterSize; };
/** \brief Change the line height. */
inline void SetLineHeight(double value) { lineHeight = value; };
/** \brief Get the line height. */
inline double GetLineHeight() const { return lineHeight; };
/** \brief Return the name of the font resource used for the text.
*/
inline const gd::String& GetFontName() const { return fontName; };
@@ -120,6 +126,7 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
gd::String text;
double characterSize;
double lineHeight;
gd::String fontName;
bool smoothed;
bool bold, italic, underlined;

View File

@@ -86,6 +86,7 @@ namespace gdjs {
? style.dropShadowDistance + style.dropShadowBlur
: 0;
style.padding = Math.ceil(this._object._padding + extraPaddingForShadow);
style.lineHeight = this._object._lineHeight;
// Prevent spikey outlines by adding a miter limit
style.miterLimit = 3;

View File

@@ -22,6 +22,8 @@ namespace gdjs {
text: string;
textAlignment: string;
verticalTextAlignment: string;
/** The line height */
lineHeight: float;
isOutlineEnabled: boolean;
outlineThickness: float;
@@ -62,6 +64,7 @@ namespace gdjs {
sha: float;
shb: float;
pad: integer;
lh: float;
};
export type TextObjectNetworkSyncData = ObjectNetworkSyncData &
@@ -101,6 +104,8 @@ namespace gdjs {
_shadowAngle: float;
_shadowBlur: float;
_lineHeight: float;
_padding: integer = 5;
_str: string;
_renderer: gdjs.TextRuntimeObjectRenderer;
@@ -139,6 +144,7 @@ namespace gdjs {
this._shadowDistance = content.shadowDistance;
this._shadowBlur = content.shadowBlurRadius;
this._shadowAngle = content.shadowAngle;
this._lineHeight = content.lineHeight || 0;
this._renderer = new gdjs.TextRuntimeObjectRenderer(
this,
@@ -149,7 +155,7 @@ namespace gdjs {
this.onCreated();
}
updateFromObjectData(
override updateFromObjectData(
oldObjectData: TextObjectData,
newObjectData: TextObjectData
): boolean {
@@ -211,10 +217,13 @@ namespace gdjs {
if (oldContent.shadowBlurRadius !== newContent.shadowBlurRadius) {
this.setShadowBlurRadius(newContent.shadowBlurRadius);
}
if ((oldContent.lineHeight || 0) !== (newContent.lineHeight || 0)) {
this.setLineHeight(newContent.lineHeight || 0);
}
return true;
}
getNetworkSyncData(): TextObjectNetworkSyncData {
override getNetworkSyncData(): TextObjectNetworkSyncData {
return {
...super.getNetworkSyncData(),
str: this._str,
@@ -238,11 +247,12 @@ namespace gdjs {
shd: this._shadowDistance,
sha: this._shadowAngle,
shb: this._shadowBlur,
lh: this._lineHeight,
pad: this._padding,
};
}
updateFromNetworkSyncData(
override updateFromNetworkSyncData(
networkSyncData: TextObjectNetworkSyncData
): void {
super.updateFromNetworkSyncData(networkSyncData);
@@ -312,28 +322,30 @@ namespace gdjs {
if (networkSyncData.shb !== undefined) {
this.setShadowBlurRadius(networkSyncData.shb);
}
if (networkSyncData.lh !== undefined) {
this.setLineHeight(networkSyncData.lh);
}
if (networkSyncData.pad !== undefined) {
this.setPadding(networkSyncData.pad);
}
}
getRendererObject() {
override getRendererObject() {
return this._renderer.getRendererObject();
}
update(instanceContainer: gdjs.RuntimeInstanceContainer): void {
override update(instanceContainer: gdjs.RuntimeInstanceContainer): void {
this._renderer.ensureUpToDate();
}
onDestroyed(): void {
override onDestroyed(): void {
super.onDestroyed();
this._renderer.destroy();
}
/**
* Initialize the extra parameters that could be set for an instance.
*/
extraInitializationFromInitialInstance(initialInstanceData: InstanceData) {
override extraInitializationFromInitialInstance(
initialInstanceData: InstanceData
) {
if (initialInstanceData.customSize) {
this.setWrappingWidth(initialInstanceData.width);
this.setWrapping(true);
@@ -353,27 +365,17 @@ namespace gdjs {
this._renderer.updatePosition();
}
/**
* Set object position on X axis.
*/
setX(x: float): void {
override setX(x: float): void {
super.setX(x);
this._updateTextPosition();
}
/**
* Set object position on Y axis.
*/
setY(y: float): void {
override setY(y: float): void {
super.setY(y);
this._updateTextPosition();
}
/**
* Set the angle of the object.
* @param angle The new angle of the object
*/
setAngle(angle: float): void {
override setAngle(angle: float): void {
super.setAngle(angle);
this._renderer.updateAngle();
}
@@ -455,6 +457,22 @@ namespace gdjs {
this._renderer.updateStyle();
}
/**
* Get the line height of the text.
*/
getLineHeight(): float {
return this._lineHeight;
}
/**
* Set the line height of the text.
* @param value The new line height for the text.
*/
setLineHeight(value: float): void {
this._lineHeight = value;
this._renderer.updateStyle();
}
/**
* Set the name of the resource to use for the font.
* @param fontResourceName The name of the font resource.
@@ -499,14 +517,14 @@ namespace gdjs {
/**
* Get width of the text.
*/
getWidth(): float {
override getWidth(): float {
return this._wrapping ? this._wrappingWidth : this._renderer.getWidth();
}
/**
* Get height of the text.
*/
getHeight(): float {
override getHeight(): float {
return this._renderer.getHeight();
}
@@ -685,11 +703,11 @@ namespace gdjs {
}
}
setWidth(width: float): void {
override setWidth(width: float): void {
this.setWrappingWidth(width);
}
getDrawableY(): float {
override getDrawableY(): float {
return (
this.getY() -
(this._verticalTextAlignment === 'center'

View File

@@ -17,7 +17,7 @@ void DeclareTiledSpriteObjectExtension(gd::PlatformExtension& extension) {
.SetExtensionInformation(
"TiledSpriteObject",
_("Tiled Sprite Object"),
"Displays an image in a repeating pattern over an area. Useful for "
"Displays a 2D image in a repeating pattern over an area. Useful for "
"making backgrounds, including background that are scrolling when "
"the camera moves. This is more performant than using multiple "
"Sprite objects.",

View File

@@ -16,8 +16,11 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
.SetExtensionInformation(
"TopDownMovementBehavior",
_("Top-down movement"),
_("Allows to move objects in either 4 or 8 directions, with the "
"keyboard or using events."),
_("Allows to move an object in either 4 or 8 directions, with the "
"keyboard (default), a virtual stick (for this, also add the "
"\"Top-down multitouch controller mapper\" behavior and a"
"\"Multitouch Joystick\" object), gamepad or manually using "
"events."),
"Florian Rival",
"Open source (MIT License)")
.SetCategory("Movement")
@@ -26,17 +29,17 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
extension.AddInstructionOrExpressionGroupMetadata(_("Top-down movement"))
.SetIcon("CppPlatform/Extensions/topdownmovementicon16.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"TopDownMovementBehavior",
_("Top-down movement (4 or 8 directions)"),
"TopDownMovement",
_("Move objects left, up, right, and "
"down (and, optionally, diagonally)."),
"",
"CppPlatform/Extensions/topdownmovementicon.png",
"TopDownMovementBehavior",
std::make_shared<TopDownMovementBehavior>(),
std::make_shared<gd::BehaviorsSharedData>());
gd::BehaviorMetadata& aut =
extension.AddBehavior("TopDownMovementBehavior",
_("Top-down movement (4 or 8 directions)"),
"TopDownMovement",
_("Move objects left, up, right, and "
"down (and, optionally, diagonally)."),
"",
"CppPlatform/Extensions/topdownmovementicon.png",
"TopDownMovementBehavior",
std::make_shared<TopDownMovementBehavior>(),
std::make_shared<gd::BehaviorsSharedData>());
aut.AddAction("SimulateLeftKey",
_("Simulate left key press"),
@@ -119,7 +122,8 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
aut.AddAction("SimulateStick",
_("Simulate stick control"),
_("Simulate a stick control."),
_("Simulate a stick control for _PARAM0_ with a _PARAM2_ angle and a _PARAM3_ force"),
_("Simulate a stick control for _PARAM0_ with a _PARAM2_ angle "
"and a _PARAM3_ force"),
_("Top-down controls"),
"res/conditions/keyboard24.png",
"res/conditions/keyboard.png")
@@ -130,25 +134,28 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
.MarkAsAdvanced()
.SetFunctionName("SimulateStick");
aut.AddScopedCondition("IsUsingControl",
_("Control pressed or simulated"),
_("A control was applied from a default control or simulated by an action."),
_("_PARAM0_ has the _PARAM2_ key pressed or simulated"),
_("Top-down state"),
"res/conditions/keyboard24.png",
"res/conditions/keyboard.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
.AddParameter("stringWithSelector",
aut.AddScopedCondition(
"IsUsingControl",
_("Control pressed or simulated"),
_("A control was applied from a default control or simulated by an "
"action."),
_("_PARAM0_ has the _PARAM2_ key pressed or simulated"),
_("Top-down state"),
"res/conditions/keyboard24.png",
"res/conditions/keyboard.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
.AddParameter("stringWithSelector",
_("Key"),
"[\"Left\", \"Right\", \"Up\", \"Down\", \"Stick\"]")
.MarkAsAdvanced();
.MarkAsAdvanced();
aut.AddExpression("StickAngle",
_("Stick angle"),
_("Return the angle of the simulated stick input (in degrees)"),
_("Top-down controls"),
"CppPlatform/Extensions/topdownmovementicon16.png")
aut.AddExpression(
"StickAngle",
_("Stick angle"),
_("Return the angle of the simulated stick input (in degrees)"),
_("Top-down controls"),
"CppPlatform/Extensions/topdownmovementicon16.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior");
@@ -361,19 +368,19 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
.MarkAsAdvanced()
.SetHidden()
.SetFunctionName("GetAngle");
aut.AddScopedCondition(
"IsMovementAngleAround",
_("Angle of movement"),
_("Compare the angle of the top-down movement of the object."),
_("Angle of movement of _PARAM0_ is _PARAM2_ ± _PARAM3_°"),
aut.AddScopedCondition(
"IsMovementAngleAround",
_("Angle of movement"),
_("Compare the angle of the top-down movement of the object."),
_("Angle of movement of _PARAM0_ is _PARAM2_ ± _PARAM3_°"),
_("Top-down state"),
"CppPlatform/Extensions/topdownmovementicon24.png",
"CppPlatform/Extensions/topdownmovementicon16.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
.AddParameter("expression", _("Angle (in degrees)"))
.AddParameter("expression", _("Tolerance (in degrees)"));
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
.AddParameter("expression", _("Angle (in degrees)"))
.AddParameter("expression", _("Tolerance (in degrees)"));
aut.AddCondition("XVelocity",
_("Speed on X axis"),

View File

@@ -1228,14 +1228,11 @@ gd::String EventsCodeGenerator::GenerateParameterCodes(
// Code only parameter type
else if (metadata.GetType() == "objectsContext") {
argOutput =
"(typeof eventsFunctionContext !== 'undefined' ? eventsFunctionContext "
": runtimeScene)";
HasProjectAndLayout() ? "runtimeScene" : "eventsFunctionContext";
}
// Code only parameter type
else if (metadata.GetType() == "eventsFunctionContext") {
argOutput =
"(typeof eventsFunctionContext !== 'undefined' ? eventsFunctionContext "
": undefined)";
argOutput = HasProjectAndLayout() ? "null" : "eventsFunctionContext";
} else
return gd::EventsCodeGenerator::GenerateParameterCodes(
parameter,

View File

@@ -21,6 +21,9 @@ AdvancedExtension::AdvancedExtension() {
.SetCustomCodeGenerator([](gd::Instruction& instruction,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
gd::String expressionCode =
gd::ExpressionCodeGenerator::GenerateExpressionCode(
codeGenerator,
@@ -28,15 +31,16 @@ AdvancedExtension::AdvancedExtension() {
"number",
instruction.GetParameter(0).GetPlainString());
return "if (typeof eventsFunctionContext !== 'undefined') { "
"eventsFunctionContext.returnValue = " +
expressionCode + "; }";
return "eventsFunctionContext.returnValue = " + expressionCode + ";";
});
GetAllActions()["SetReturnString"]
.SetCustomCodeGenerator([](gd::Instruction& instruction,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
gd::String expressionCode =
gd::ExpressionCodeGenerator::GenerateExpressionCode(
codeGenerator,
@@ -44,29 +48,31 @@ AdvancedExtension::AdvancedExtension() {
"string",
instruction.GetParameter(0).GetPlainString());
return "if (typeof eventsFunctionContext !== 'undefined') { "
"eventsFunctionContext.returnValue = " +
expressionCode + "; }";
return "eventsFunctionContext.returnValue = " + expressionCode + ";";
});
GetAllActions()["SetReturnBoolean"]
.SetCustomCodeGenerator([](gd::Instruction& instruction,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
// This is duplicated from EventsCodeGenerator::GenerateParameterCodes
gd::String parameter = instruction.GetParameter(0).GetPlainString();
gd::String booleanCode =
(parameter == "True" || parameter == "Vrai") ? "true" : "false";
return "if (typeof eventsFunctionContext !== 'undefined') { "
"eventsFunctionContext.returnValue = " +
booleanCode + "; }";
return "eventsFunctionContext.returnValue = " + booleanCode + ";";
});
GetAllActions()["CopyArgumentToVariable"]
.SetCustomCodeGenerator([](gd::Instruction &instruction,
gd::EventsCodeGenerator &codeGenerator,
gd::EventsCodeGenerationContext &context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
// This is duplicated from EventsCodeGenerator::GenerateParameterCodes
gd::String parameter = instruction.GetParameter(0).GetPlainString();
gd::String variable =
@@ -74,17 +80,17 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator, context, "scenevar", instruction.GetParameter(1),
"");
return "if (typeof eventsFunctionContext !== 'undefined') {\n"
"gdjs.Variable.copy(eventsFunctionContext.getArgument(" +
parameter + "), " + variable +
", false);\n"
"}\n";
return "gdjs.Variable.copy(eventsFunctionContext.getArgument(" +
parameter + "), " + variable + ", false);\n";
});
GetAllActions()["CopyArgumentToVariable2"]
.SetCustomCodeGenerator([](gd::Instruction &instruction,
gd::EventsCodeGenerator &codeGenerator,
gd::EventsCodeGenerationContext &context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
// This is duplicated from EventsCodeGenerator::GenerateParameterCodes
gd::String parameter = instruction.GetParameter(0).GetPlainString();
gd::String variable =
@@ -92,17 +98,17 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator, context, "variable", instruction.GetParameter(1),
"");
return "if (typeof eventsFunctionContext !== 'undefined') {\n"
"gdjs.Variable.copy(eventsFunctionContext.getArgument(" +
parameter + "), " + variable +
", false);\n"
"}\n";
return "gdjs.Variable.copy(eventsFunctionContext.getArgument(" +
parameter + "), " + variable + ", false);\n";
});
GetAllActions()["CopyVariableToArgument"]
.SetCustomCodeGenerator([](gd::Instruction &instruction,
gd::EventsCodeGenerator &codeGenerator,
gd::EventsCodeGenerationContext &context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
// This is duplicated from EventsCodeGenerator::GenerateParameterCodes
gd::String parameter = instruction.GetParameter(0).GetPlainString();
gd::String variable =
@@ -110,17 +116,18 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator, context, "scenevar", instruction.GetParameter(1),
"");
return "if (typeof eventsFunctionContext !== 'undefined') {\n"
"gdjs.Variable.copy(" +
variable + ", eventsFunctionContext.getArgument(" + parameter +
"), false);\n"
"}\n";
return "gdjs.Variable.copy(" + variable +
", eventsFunctionContext.getArgument(" + parameter +
"), false);\n";
});
GetAllActions()["CopyVariableToArgument2"]
.SetCustomCodeGenerator([](gd::Instruction &instruction,
gd::EventsCodeGenerator &codeGenerator,
gd::EventsCodeGenerationContext &context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("");
}
// This is duplicated from EventsCodeGenerator::GenerateParameterCodes
gd::String parameter = instruction.GetParameter(0).GetPlainString();
gd::String variable =
@@ -128,17 +135,18 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator, context, "variable", instruction.GetParameter(1),
"");
return "if (typeof eventsFunctionContext !== 'undefined') {\n"
"gdjs.Variable.copy(" +
variable + ", eventsFunctionContext.getArgument(" + parameter +
"), false);\n"
"}\n";
return "gdjs.Variable.copy(" + variable +
", eventsFunctionContext.getArgument(" + parameter +
"), false);\n";
});
GetAllConditions()["GetArgumentAsBoolean"]
.SetCustomCodeGenerator([](gd::Instruction& instruction,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("false");
}
gd::String parameterNameCode =
gd::ExpressionCodeGenerator::GenerateExpressionCode(
codeGenerator,
@@ -146,10 +154,8 @@ AdvancedExtension::AdvancedExtension() {
"string",
instruction.GetParameter(0).GetPlainString());
gd::String valueCode =
gd::String(instruction.IsInverted() ? "!" : "") +
"(typeof eventsFunctionContext !== 'undefined' ? "
"!!eventsFunctionContext.getArgument(" +
parameterNameCode + ") : false)";
gd::String(instruction.IsInverted() ? "!" : "!!") +
"eventsFunctionContext.getArgument(" + parameterNameCode + ")";
gd::String outputCode =
codeGenerator.GenerateUpperScopeBooleanFullName("isConditionTrue", context) +
" = " + valueCode + ";\n";
@@ -161,6 +167,9 @@ AdvancedExtension::AdvancedExtension() {
.SetCustomCodeGenerator([](const std::vector<gd::Expression>& parameters,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("0");
}
gd::String parameterNameCode =
gd::ExpressionCodeGenerator::GenerateExpressionCode(
codeGenerator,
@@ -168,9 +177,8 @@ AdvancedExtension::AdvancedExtension() {
"string",
!parameters.empty() ? parameters[0].GetPlainString() : "");
return "(typeof eventsFunctionContext !== 'undefined' ? "
"Number(eventsFunctionContext.getArgument(" +
parameterNameCode + ")) || 0 : 0)";
return "(Number(eventsFunctionContext.getArgument(" + parameterNameCode +
")) || 0)";
});
GetAllStrExpressions()["GetArgumentAsString"]
@@ -178,6 +186,9 @@ AdvancedExtension::AdvancedExtension() {
.SetCustomCodeGenerator([](const std::vector<gd::Expression>& parameters,
gd::EventsCodeGenerator& codeGenerator,
gd::EventsCodeGenerationContext& context) {
if (codeGenerator.HasProjectAndLayout()) {
return gd::String("\"\"");
}
gd::String parameterNameCode =
gd::ExpressionCodeGenerator::GenerateExpressionCode(
codeGenerator,
@@ -185,9 +196,8 @@ AdvancedExtension::AdvancedExtension() {
"string",
!parameters.empty() ? parameters[0].GetPlainString() : "");
return "(typeof eventsFunctionContext !== 'undefined' ? \"\" + "
"eventsFunctionContext.getArgument(" +
parameterNameCode + ") : \"\")";
return "\"\" + eventsFunctionContext.getArgument(" + parameterNameCode +
")";
});
GetAllConditions()["CompareArgumentAsNumber"]
@@ -210,12 +220,13 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator.GenerateUpperScopeBooleanFullName("isConditionTrue", context);
return resultingBoolean + " = " +
gd::String(instruction.IsInverted() ? "!" : "") +
gd::String(instruction.IsInverted() ? "!" : "") + "(" +
codeGenerator.GenerateRelationalOperation(
operatorString,
"((typeof eventsFunctionContext !== 'undefined' ? "
"Number(eventsFunctionContext.getArgument(" +
parameterNameCode + ")) || 0 : 0)",
codeGenerator.HasProjectAndLayout()
? "0"
: "(Number(eventsFunctionContext.getArgument(" +
parameterNameCode + ")) || 0)",
operandCode) +
");\n";
});
@@ -240,12 +251,13 @@ AdvancedExtension::AdvancedExtension() {
codeGenerator.GenerateUpperScopeBooleanFullName("isConditionTrue", context);
return resultingBoolean + " = " +
gd::String(instruction.IsInverted() ? "!" : "") +
gd::String(instruction.IsInverted() ? "!" : "") + "(" +
codeGenerator.GenerateRelationalOperation(
operatorString,
"((typeof eventsFunctionContext !== 'undefined' ? "
"\"\" + eventsFunctionContext.getArgument(" +
parameterNameCode + ") : \"\")",
codeGenerator.HasProjectAndLayout()
? "\"\""
: "(\"\" + eventsFunctionContext.getArgument(" +
parameterNameCode + "))",
operandCode) +
");\n";
});

View File

@@ -821,8 +821,7 @@ CommonInstructionsExtension::CommonInstructionsExtension() {
}
if (!codeGenerator.HasProjectAndLayout()) {
functionParameters += ", eventsFunctionContext";
callArguments += ", typeof eventsFunctionContext !== \'undefined\' ? "
"eventsFunctionContext : undefined";
callArguments += ", eventsFunctionContext";
}
// Generate the function code

View File

@@ -43,6 +43,10 @@ StringInstructionsExtension::StringInstructionsExtension() {
"gdjs.evtTools.string.strFindLastFrom");
GetAllExpressions()["StrFindLastFrom"].SetFunctionName(
"gdjs.evtTools.string.strFindLastFrom");
GetAllStrExpressions()["StrReplaceOne"].SetFunctionName(
"gdjs.evtTools.string.strReplaceOne");
GetAllStrExpressions()["StrReplaceAll"].SetFunctionName(
"gdjs.evtTools.string.strReplaceAll");
StripUnimplementedInstructionsAndExpressions();
}

View File

@@ -3728,6 +3728,8 @@ interface TextObject {
[Const, Ref] DOMString GetText();
void SetCharacterSize(double size);
double GetCharacterSize();
void SetLineHeight(double value);
double GetLineHeight();
void SetFontName([Const] DOMString string);
[Const, Ref] DOMString GetFontName();
boolean IsBold();

View File

@@ -550,13 +550,13 @@ describe('libGD.js - GDJS related tests', function () {
// GetArgumentAsString("MyString") should be generated code to query and cast as a string
// the argument
expect(code).toMatch(
'(typeof eventsFunctionContext !== \'undefined\' ? "" + eventsFunctionContext.getArgument("MyString") : "")'
'("" + eventsFunctionContext.getArgument("MyString"))'
);
// GetArgumentAsNumber("MyNumber") should be generated code to query and cast as a string
// the argument
expect(code).toMatch(
'(typeof eventsFunctionContext !== \'undefined\' ? Number(eventsFunctionContext.getArgument("MyNumber")) || 0 : 0)'
'(Number(eventsFunctionContext.getArgument("MyNumber")) || 0)'
);
// The loop is using a counter somewhere

View File

@@ -2758,6 +2758,8 @@ export class TextObject extends ObjectConfiguration {
getText(): string;
setCharacterSize(size: number): void;
getCharacterSize(): number;
setLineHeight(value: number): void;
getLineHeight(): number;
setFontName(string: string): void;
getFontName(): string;
isBold(): boolean;

View File

@@ -5,6 +5,8 @@ declare class gdTextObject extends gdObjectConfiguration {
getText(): string;
setCharacterSize(size: number): void;
getCharacterSize(): number;
setLineHeight(value: number): void;
getLineHeight(): number;
setFontName(string: string): void;
getFontName(): string;
isBold(): boolean;

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ import { ChatBubble } from './ChatBubble';
import { Line } from '../../UI/Grid';
import { ChatMarkdownText } from './ChatMarkdownText';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import { getFunctionCallToFunctionCallOutputMap } from './AiRequestUtils';
import { getFunctionCallToFunctionCallOutputMap } from '../AiRequestUtils';
import { FunctionCallRow } from './FunctionCallRow';
import IconButton from '../../UI/IconButton';
import Like from '../../UI/CustomSvgIcons/Like';

View File

@@ -29,7 +29,7 @@ import { getHelpLink } from '../../Utils/HelpLink';
import Window from '../../Utils/Window';
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
import { type EditorCallbacks } from '../../EditorFunctions';
import { getFunctionCallsToProcess } from './AiRequestUtils';
import { getFunctionCallsToProcess } from '../AiRequestUtils';
import CircularProgress from '../../UI/CircularProgress';
import TwoStatesButton from '../../UI/TwoStatesButton';
import Help from '../../UI/CustomSvgIcons/Help';
@@ -68,6 +68,7 @@ type Props = {
|}) => void,
onSendMessage: (options: {|
userMessage: string,
createdSceneNames?: Array<string>,
|}) => Promise<void>,
onSendFeedback: (
aiRequestId: string,
@@ -79,7 +80,10 @@ type Props = {
hasOpenedProject: boolean,
isAutoProcessingFunctionCalls: boolean,
setAutoProcessFunctionCalls: boolean => void,
onStartNewChat: (mode: 'chat' | 'agent') => void,
onStartOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
initialMode?: 'chat' | 'agent',
onProcessFunctionCalls: (
@@ -123,12 +127,17 @@ const getQuotaOrCreditsText = ({
<>
{increaseQuotaOffering === 'subscribe' ? (
<Trans>
Get GDevelop premium to get more free requests every month.
Get GDevelop premium to get more free requests every day.
</Trans>
) : quota.period === '30days' ? (
<Trans>
These are parts of your GDevelop premium membership ({quota.max}{' '}
free requests per month).
</Trans>
) : (
<Trans>
These are parts of your GDevelop premium membership ({quota.max}{' '}
free requests per month).
free requests per day).
</Trans>
)}{' '}
<Trans>Free requests do not consume credits on your account.</Trans>
@@ -248,7 +257,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onStartNewAiRequest,
onSendMessage,
onSendFeedback,
onStartNewChat,
onStartOrOpenChat,
initialMode,
quota,
increaseQuotaOffering,
@@ -426,7 +435,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<RobotIcon rotating size={40} />
</Line>
<Column noMargin alignItems="center">
<Text size="bold-title">
<Text size="bold-title" align="center">
{newAiRequestMode === 'agent' ? (
<Trans>What do you want to make?</Trans>
) : (
@@ -439,7 +448,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
value={newAiRequestMode}
leftButton={{
icon: <Hammer fontSize="small" />,
label: <Trans>Build for me (beta)</Trans>,
label: <Trans>Build for me</Trans>,
value: 'agent',
}}
rightButton={{
@@ -546,6 +555,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
</>
) : null}
</ColumnStackLayout>
<Spacer />
<Column justifyContent="center">
{newAiRequestMode === 'agent' ? (
<Text size="body-small" color="secondary" align="center" noMargin>
@@ -647,7 +657,12 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
This request is for another project.{' '}
<Link
href="#"
onClick={() => onStartNewChat(aiRequest.mode || 'chat')}
onClick={() =>
onStartOrOpenChat({
mode: aiRequest.mode || 'chat',
aiRequestId: null,
})
}
>
Start a new chat
</Link>{' '}

View File

@@ -0,0 +1,229 @@
// @flow
import * as React from 'react';
import {
getAiRequest,
type AiRequest,
} from '../Utils/GDevelopServices/Generation';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
type EditorFunctionCallResultsStorage = {|
getEditorFunctionCallResults: (
aiRequestId: string
) => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => void,
clearEditorFunctionCallResults: (aiRequestId: string) => void,
|};
const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage => {
const [
editorFunctionCallResultsPerRequest,
setEditorFunctionCallResultsPerRequest,
] = React.useState<{
[aiRequestId: string]: Array<EditorFunctionCallResult>,
}>({});
return {
getEditorFunctionCallResults: React.useCallback(
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
editorFunctionCallResultsPerRequest[aiRequestId] || null,
[editorFunctionCallResultsPerRequest]
),
addEditorFunctionCallResults: React.useCallback(
(
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => {
const existingEditorFunctionCallResults = (
editorFunctionCallResultsPerRequest[aiRequestId] || []
).filter(existingEditorFunctionCallResult => {
return !editorFunctionCallResults.some(
editorFunctionCallResult => {
return (
editorFunctionCallResult.call_id ===
existingEditorFunctionCallResult.call_id
);
}
);
});
return {
...editorFunctionCallResultsPerRequest,
[aiRequestId]: [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
],
};
}
);
},
[]
),
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => ({
...editorFunctionCallResultsPerRequest,
[aiRequestId]: null,
})
);
}, []),
};
};
type AiRequestStorage = {|
aiRequests: { [string]: AiRequest },
updateAiRequest: (aiRequestId: string, aiRequest: AiRequest) => void,
refreshAiRequest: (aiRequestId: string) => Promise<void>,
isSendingAiRequest: (aiRequestId: string | null) => boolean,
getLastSendError: (aiRequestId: string | null) => ?Error,
setSendingAiRequest: (aiRequestId: string | null, isSending: boolean) => void,
setLastSendError: (aiRequestId: string | null, lastSendError: ?Error) => void,
|};
type AiRequestSendState = {|
isSending: boolean,
lastSendError: ?Error,
|};
export const useAiRequestsStorage = (): AiRequestStorage => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
{}
);
const updateAiRequest = React.useCallback(
(aiRequestId: string, aiRequest: AiRequest) => {
setAiRequests(aiRequests => ({
...aiRequests,
[aiRequestId]: aiRequest,
}));
},
[]
);
const refreshAiRequest = React.useCallback(
async (aiRequestId: string) => {
if (!profile) return;
try {
const updatedAiRequest = await getAiRequest(getAuthorizationHeader, {
userId: profile.id,
aiRequestId: aiRequestId,
});
updateAiRequest(updatedAiRequest.id, updatedAiRequest);
} catch (error) {
console.error(
'Error while background refreshing AI request - ignoring:',
error
);
}
},
[getAuthorizationHeader, profile, updateAiRequest]
);
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{
[string]: AiRequestSendState,
}>({});
const isSendingAiRequest = React.useCallback(
(aiRequestId: string | null) =>
!!aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].isSending,
[aiRequestSendStates]
);
const getLastSendError = React.useCallback(
(aiRequestId: string | null) =>
(aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
null,
[aiRequestSendStates]
);
const setSendingAiRequest = React.useCallback(
(aiRequestId: string | null, isSending: boolean) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending,
lastSendError: null,
},
}));
},
[setAiRequestSendStates]
);
const setLastSendError = React.useCallback(
(aiRequestId: string | null, lastSendError: ?Error) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending: false,
lastSendError,
},
}));
},
[setAiRequestSendStates]
);
return {
aiRequests,
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
setSendingAiRequest,
setLastSendError,
getLastSendError,
};
};
type AiRequestContextState = {|
aiRequestStorage: AiRequestStorage,
editorFunctionCallResultsStorage: EditorFunctionCallResultsStorage,
|};
export const AiRequestContext = React.createContext<AiRequestContextState>({
aiRequestStorage: {
aiRequests: {},
updateAiRequest: () => {},
refreshAiRequest: async () => {},
isSendingAiRequest: () => false,
getLastSendError: () => null,
setSendingAiRequest: () => {},
setLastSendError: () => {},
},
editorFunctionCallResultsStorage: {
getEditorFunctionCallResults: () => [],
addEditorFunctionCallResults: () => {},
clearEditorFunctionCallResults: () => {},
},
});
type AiRequestProviderProps = {|
children: React.Node,
|};
export const AiRequestProvider = ({ children }: AiRequestProviderProps) => {
const editorFunctionCallResultsStorage = useEditorFunctionCallResultsStorage();
const aiRequestStorage = useAiRequestsStorage();
const state = React.useMemo(
() => ({
aiRequestStorage,
editorFunctionCallResultsStorage,
}),
[aiRequestStorage, editorFunctionCallResultsStorage]
);
return (
<AiRequestContext.Provider value={state}>
{children}
</AiRequestContext.Provider>
);
};

View File

@@ -3,8 +3,8 @@ import {
type AiRequest,
type AiRequestMessageAssistantFunctionCall,
type AiRequestFunctionCallOutput,
} from '../../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
} from '../Utils/GDevelopServices/Generation';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
export const getFunctionCallToFunctionCallOutputMap = ({
aiRequest,

View File

@@ -36,7 +36,7 @@ import { type EditorCallbacks } from '../EditorFunctions';
import {
getFunctionCallOutputsFromEditorFunctionCallResults,
getFunctionCallsToProcess,
} from './AiRequestChat/AiRequestUtils';
} from './AiRequestUtils';
import { useStableUpToDateRef } from '../Utils/UseStableUpToDateCallback';
import { useTriggerAtNextRender } from '../Utils/useTriggerAtNextRender';
import { type NewProjectSetup } from '../ProjectCreation/NewProjectSetupDialog';
@@ -52,66 +52,10 @@ import {
import { useCreateAiProjectDialog } from './UseCreateAiProjectDialog';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { AiRequestContext } from './AiRequestContext';
const gd: libGDevelop = global.gd;
const useEditorFunctionCallResultsPerRequest = () => {
const [
editorFunctionCallResultsPerRequest,
setEditorFunctionCallResultsPerRequest,
] = React.useState<{
[aiRequestId: string]: Array<EditorFunctionCallResult>,
}>({});
return {
getEditorFunctionCallResults: React.useCallback(
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
editorFunctionCallResultsPerRequest[aiRequestId] || null,
[editorFunctionCallResultsPerRequest]
),
addEditorFunctionCallResults: React.useCallback(
(
aiRequestId: string,
editorFunctionCallResults: EditorFunctionCallResult[]
) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => {
const existingEditorFunctionCallResults = (
editorFunctionCallResultsPerRequest[aiRequestId] || []
).filter(existingEditorFunctionCallResult => {
return !editorFunctionCallResults.some(
editorFunctionCallResult => {
return (
editorFunctionCallResult.call_id ===
existingEditorFunctionCallResult.call_id
);
}
);
});
return {
...editorFunctionCallResultsPerRequest,
[aiRequestId]: [
...existingEditorFunctionCallResults,
...editorFunctionCallResults,
],
};
}
);
},
[]
),
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
setEditorFunctionCallResultsPerRequest(
editorFunctionCallResultsPerRequest => ({
...editorFunctionCallResultsPerRequest,
[aiRequestId]: null,
})
);
}, []),
};
};
const useProcessFunctionCalls = ({
i18n,
project,
@@ -129,7 +73,9 @@ const useProcessFunctionCalls = ({
resourceManagementProps: ResourceManagementProps,
editorCallbacks: EditorCallbacks,
selectedAiRequest: ?AiRequest,
onSendEditorFunctionCallResults: () => Promise<void>,
onSendEditorFunctionCallResults: (
options: null | {| createdSceneNames: Array<string> |}
) => Promise<void>,
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
addEditorFunctionCallResults: (
string,
@@ -196,7 +142,7 @@ const useProcessFunctionCalls = ({
}))
);
const editorFunctionCallResults = await processEditorFunctionCalls({
const { results, createdSceneNames } = await processEditorFunctionCalls({
project,
editorCallbacks,
functionCalls: functionCalls.map(functionCall => ({
@@ -216,14 +162,13 @@ const useProcessFunctionCalls = ({
searchAndInstallAsset,
});
addEditorFunctionCallResults(
selectedAiRequest.id,
editorFunctionCallResults
);
addEditorFunctionCallResults(selectedAiRequest.id, results);
// We may have processed everything, so try to send the results
// to the backend.
triggerSendEditorFunctionCallResults();
triggerSendEditorFunctionCallResults({
createdSceneNames,
});
},
[
project,
@@ -280,52 +225,20 @@ const useProcessFunctionCalls = ({
};
};
type AiRequestSendState = {|
isSending: boolean,
lastSendError: ?Error,
|};
export const useAiRequests = () => {
export const useSelectedAiRequest = ({
initialAiRequestId,
}: {|
initialAiRequestId: string | null,
|}) => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const { aiRequestStorage } = React.useContext(AiRequestContext);
const { aiRequests, updateAiRequest } = aiRequestStorage;
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
{}
);
const [selectedAiRequestId, setSelectedAiRequestId] = React.useState<
string | null
>(null);
const updateAiRequest = React.useCallback(
(aiRequestId: string, aiRequest: AiRequest) => {
setAiRequests(aiRequests => ({
...aiRequests,
[aiRequestId]: aiRequest,
}));
},
[]
);
const refreshAiRequest = React.useCallback(
async (aiRequestId: string) => {
if (!profile) return;
try {
const updatedAiRequest = await getAiRequest(getAuthorizationHeader, {
userId: profile.id,
aiRequestId: aiRequestId,
});
updateAiRequest(updatedAiRequest.id, updatedAiRequest);
} catch (error) {
console.error(
'Error while background refreshing AI request - ignoring:',
error
);
}
},
[getAuthorizationHeader, profile, updateAiRequest]
);
>(initialAiRequestId);
const selectedAiRequest =
(selectedAiRequestId && aiRequests[selectedAiRequestId]) || null;
@@ -375,59 +288,10 @@ export const useAiRequests = () => {
]
);
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{
[string]: AiRequestSendState,
}>({});
const isSendingAiRequest = React.useCallback(
(aiRequestId: string | null) =>
!!aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].isSending,
[aiRequestSendStates]
);
const getLastSendError = React.useCallback(
(aiRequestId: string | null) =>
(aiRequestSendStates[aiRequestId || ''] &&
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
null,
[aiRequestSendStates]
);
const setSendingAiRequest = React.useCallback(
(aiRequestId: string | null, isSending: boolean) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending,
lastSendError: null,
},
}));
},
[setAiRequestSendStates]
);
const setLastSendError = React.useCallback(
(aiRequestId: string | null, lastSendError: ?Error) => {
const aiRequestIdToSet: string = aiRequestId || '';
setAiRequestSendStates(aiRequestSendStates => ({
...aiRequestSendStates,
[aiRequestIdToSet]: {
isSending: false,
lastSendError,
},
}));
},
[setAiRequestSendStates]
);
return {
selectedAiRequest,
selectedAiRequestId,
setSelectedAiRequestId,
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
setSendingAiRequest,
setLastSendError,
getLastSendError,
};
};
@@ -438,6 +302,8 @@ const styles = {
flexDirection: 'column',
alignItems: 'center',
minWidth: 0,
overflowY: 'scroll',
overflowX: 'hidden',
},
chatContainer: {
flex: 1,
@@ -483,7 +349,13 @@ type Props = {|
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
mode?: 'chat' | 'agent' | null,
initialMode: 'chat' | 'agent' | null,
initialAiRequestId: string | null,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
|};
export type AskAiEditorInterface = {|
@@ -499,7 +371,10 @@ export type AskAiEditorInterface = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
startNewChat: (mode: 'chat' | 'agent') => void,
startOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
|};
export type NewAiRequestOptions = {|
@@ -525,7 +400,9 @@ export const AskAiEditor = React.memo<Props>(
onOpenLayout,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
mode,
initialMode,
initialAiRequestId,
onOpenAskAi,
}: Props,
ref
) => {
@@ -540,13 +417,9 @@ export const AskAiEditor = React.memo<Props>(
selectedAiRequest,
selectedAiRequestId,
setSelectedAiRequestId,
updateAiRequest,
refreshAiRequest,
setSendingAiRequest,
isSendingAiRequest,
getLastSendError,
setLastSendError,
} = useAiRequests();
} = useSelectedAiRequest({
initialAiRequestId,
});
const upToDateSelectedAiRequestId = useStableUpToDateRef(
selectedAiRequestId
);
@@ -558,26 +431,30 @@ export const AskAiEditor = React.memo<Props>(
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
const [newChatMode, setNewChatMode] = React.useState<'chat' | 'agent'>(
mode || 'agent'
initialMode || 'agent'
);
// Update newChatMode when mode prop changes
React.useEffect(
() => {
if (mode) {
setNewChatMode(mode);
if (initialMode) {
setNewChatMode(initialMode);
}
},
[mode]
[initialMode]
);
const canStartNewChat = !!selectedAiRequestId;
const onStartNewChat = React.useCallback(
(mode: 'chat' | 'agent') => {
setSelectedAiRequestId(null);
if (mode) {
setNewChatMode(mode);
}
const onStartOrOpenChat = React.useCallback(
({
mode,
aiRequestId,
}: {|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => {
setSelectedAiRequestId(aiRequestId);
setNewChatMode(mode);
},
[setSelectedAiRequestId]
);
@@ -590,11 +467,23 @@ export const AskAiEditor = React.memo<Props>(
setIsHistoryOpen(false);
}, []);
const {
aiRequestStorage,
editorFunctionCallResultsStorage,
} = React.useContext(AiRequestContext);
const {
getEditorFunctionCallResults,
addEditorFunctionCallResults,
clearEditorFunctionCallResults,
} = useEditorFunctionCallResultsPerRequest();
} = editorFunctionCallResultsStorage;
const {
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
getLastSendError,
setSendingAiRequest,
setLastSendError,
} = aiRequestStorage;
const {
createAiProject,
@@ -606,14 +495,14 @@ export const AskAiEditor = React.memo<Props>(
if (setToolbar) {
setToolbar(
<Toolbar
onStartNewChat={onStartNewChat}
onStartOrOpenChat={onStartOrOpenChat}
canStartNewChat={canStartNewChat}
onOpenHistory={onOpenHistory}
/>
);
}
},
[setToolbar, onStartNewChat, canStartNewChat, onOpenHistory]
[setToolbar, onStartOrOpenChat, canStartNewChat, onOpenHistory]
);
React.useEffect(updateToolbar, [updateToolbar]);
@@ -626,7 +515,7 @@ export const AskAiEditor = React.memo<Props>(
onSceneObjectEdited: noop,
onSceneObjectsDeleted: noop,
onSceneEventsModifiedOutsideEditor: noop,
startNewChat: onStartNewChat,
startOrOpenChat: onStartOrOpenChat,
}));
const aiRequestChatRef = React.useRef<AiRequestChatInterface | null>(
@@ -820,6 +709,7 @@ export const AskAiEditor = React.memo<Props>(
updateAiRequest,
createAiProject,
newAiRequestOptions,
onOpenAskAi,
]
);
@@ -838,7 +728,13 @@ export const AskAiEditor = React.memo<Props>(
// Send the results of the function call outputs, if any, and the user message (if any).
const onSendMessage = React.useCallback(
async ({ userMessage }: {| userMessage: string |}) => {
async ({
userMessage,
createdSceneNames,
}: {|
userMessage: string,
createdSceneNames?: Array<string>,
|}) => {
if (
!profile ||
!selectedAiRequestId ||
@@ -950,9 +846,25 @@ export const AskAiEditor = React.memo<Props>(
// Ignore limits refresh error.
}
}
if (selectedAiRequest && createdSceneNames) {
onOpenAskAi({
mode: selectedAiRequest.mode || 'agent',
aiRequestId: selectedAiRequestId,
paneIdentifier: 'right',
});
createdSceneNames.forEach(sceneName => {
onOpenLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: true,
focusWhenOpened: 'scene',
});
});
}
},
[
profile,
selectedAiRequest,
selectedAiRequestId,
isSendingAiRequest,
getEditorFunctionCallResults,
@@ -968,12 +880,15 @@ export const AskAiEditor = React.memo<Props>(
onRefreshLimits,
project,
hasFunctionsCallsToProcess,
onOpenAskAi,
onOpenLayout,
]
);
const onSendEditorFunctionCallResults = React.useCallback(
() =>
(options: null | {| createdSceneNames: Array<string> |}) =>
onSendMessage({
userMessage: '',
createdSceneNames: options ? options.createdSceneNames : [],
}),
[onSendMessage]
);
@@ -1067,7 +982,7 @@ export const AskAiEditor = React.memo<Props>(
}}
i18n={i18n}
editorCallbacks={editorCallbacks}
onStartNewChat={onStartNewChat}
onStartOrOpenChat={onStartOrOpenChat}
/>
</div>
</Paper>
@@ -1116,7 +1031,13 @@ export const renderAskAiEditorContainer = (
props.onSceneEventsModifiedOutsideEditor
}
onExtensionInstalled={props.onExtensionInstalled}
mode={props.extraEditorProps && props.extraEditorProps.mode}
initialMode={
(props.extraEditorProps && props.extraEditorProps.mode) || null
}
initialAiRequestId={
(props.extraEditorProps && props.extraEditorProps.aiRequestId) || null
}
onOpenAskAi={props.onOpenAskAi}
/>
)}
</I18n>

View File

@@ -247,7 +247,7 @@ export const AskAiHistory = ({
return (
<Drawer
open={open}
anchor="left"
anchor="right"
onClose={onClose}
PaperProps={{
style: styles.drawer,
@@ -260,6 +260,7 @@ export const AskAiHistory = ({
<ColumnStackLayout expand noMargin>
<DrawerTopBar
title={<Trans>AI Chat History</Trans>}
drawerAnchor="right"
id="ai-chat-history-drawer-top-bar"
onClose={onClose}
/>

View File

@@ -3,18 +3,21 @@ import { Trans, t } from '@lingui/macro';
import * as React from 'react';
import { ToolbarGroup } from '../UI/Toolbar';
import AddIcon from '../UI/CustomSvgIcons/Add';
import HistoryIcon from '../UI/CustomSvgIcons/History';
import RaisedButton from '../UI/RaisedButton';
import ChatBubblesIcon from '../UI/CustomSvgIcons/ChatBubbles';
import ResponsiveRaisedButton from '../UI/ResponsiveRaisedButton';
import IconButton from '../UI/IconButton';
type Props = {|
onStartNewChat: (mode: 'chat' | 'agent') => void,
onStartOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
|}) => void,
canStartNewChat: boolean,
onOpenHistory: () => void,
|};
export const Toolbar = ({
onStartNewChat,
onStartOrOpenChat,
canStartNewChat,
onOpenHistory,
}: Props) => {
@@ -27,16 +30,24 @@ export const Toolbar = ({
tooltip={t`View history`}
onClick={onOpenHistory}
>
<HistoryIcon />
<ChatBubblesIcon />
</IconButton>
</ToolbarGroup>
<ToolbarGroup lastChild>
<RaisedButton
<ResponsiveRaisedButton
primary
onClick={() => onStartNewChat('agent')}
onClick={() =>
onStartOrOpenChat({
mode: 'agent',
aiRequestId: null,
})
}
icon={<AddIcon />}
label={<Trans>Start a new chat</Trans>}
disabled={!canStartNewChat}
style={{
flexShrink: 0,
}}
/>
</ToolbarGroup>
</>

View File

@@ -9,6 +9,7 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
export type AssetStorePageState = {|
@@ -17,6 +18,7 @@ export type AssetStorePageState = {|
openedAssetShortHeader: ?AssetShortHeader,
openedPrivateAssetPackListingData: ?PrivateAssetPackListingData,
openedPrivateGameTemplateListingData: ?PrivateGameTemplateListingData,
openedBundleListingData: ?BundleListingData,
selectedFolders: Array<string>,
filtersState: FiltersState,
pageBreakIndex?: ?number,
@@ -53,6 +55,11 @@ export type NavigationState = {|
storeSearchText: boolean,
clearSearchText: boolean,
|}) => void,
openBundleInformationPage: ({|
bundleListingData: BundleListingData,
storeSearchText: boolean,
clearSearchText: boolean,
|}) => void,
openAssetDetailPage: ({|
assetShortHeader: AssetShortHeader,
storeSearchText: boolean,
@@ -76,6 +83,7 @@ export const assetStoreHomePageState: AssetStorePageState = {
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
selectedFolders: [],
filtersState: noFilter,
displayAssets: false,
@@ -87,6 +95,7 @@ const searchPageState: AssetStorePageState = {
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
selectedFolders: [],
filtersState: noFilter,
displayAssets: true,
@@ -99,6 +108,7 @@ export const isHomePage = (pageState: AssetStorePageState) => {
!pageState.openedPrivateAssetPackListingData &&
!pageState.openedPrivateGameTemplateListingData &&
!pageState.openedAssetPack &&
!pageState.openedBundleListingData &&
pageState.filtersState === noFilter &&
!pageState.displayAssets)
);
@@ -109,7 +119,8 @@ export const isSearchResultPage = (pageState: AssetStorePageState) => {
!isHomePage(pageState) &&
!pageState.openedAssetShortHeader &&
!pageState.openedPrivateAssetPackListingData &&
!pageState.openedPrivateGameTemplateListingData
!pageState.openedPrivateGameTemplateListingData &&
!pageState.openedBundleListingData
);
};
@@ -133,6 +144,7 @@ export const AssetStoreNavigatorContext = React.createContext<NavigationState>({
openPackPage: () => {},
openPrivateAssetPackInformationPage: () => {},
openPrivateGameTemplateInformationPage: () => {},
openBundleInformationPage: () => {},
openAssetDetailPage: () => {},
navigateInsideFolder: string => {},
goBackToFolderIndex: number => {},
@@ -169,7 +181,12 @@ export const AssetStoreNavigatorStateProvider = (
});
return newCurrentPage;
}
return previousPages[0];
if (previousPages.length === 1) {
// If we are already on the root page, do nothing.
return previousPages[0];
}
// If there are no previous pages, return the home page state.
return assetStoreHomePageState;
},
openHome: () => {
setHistory({ previousPages: [assetStoreHomePageState] });
@@ -244,6 +261,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
displayAssets: true,
filtersState: {
chosenCategory: {
@@ -271,6 +289,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -315,6 +334,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: assetPack,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
displayAssets: true,
filtersState: {
chosenCategory: {
@@ -373,6 +393,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: privateAssetPackListingData,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -418,6 +439,7 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
@@ -463,6 +485,53 @@ export const AssetStoreNavigatorStateProvider = (
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: privateGameTemplateListingData,
openedBundleListingData: null,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],
},
],
};
});
if (clearSearchText) setSearchText('');
},
openBundleInformationPage: ({
bundleListingData,
storeSearchText,
clearSearchText,
}: {|
bundleListingData: BundleListingData,
storeSearchText: boolean,
clearSearchText: boolean,
|}) => {
setHistory(previousHistory => {
const currentPage =
previousHistory.previousPages[
previousHistory.previousPages.length - 1
];
const currentPageWithSearchText = {
...currentPage,
searchText: storeSearchText ? searchText : '',
};
const previousPagesWithoutCurrentPage = previousHistory.previousPages.slice(
0,
previousHistory.previousPages.length - 1
);
const previousPages = [
...previousPagesWithoutCurrentPage,
currentPageWithSearchText,
];
return {
...previousHistory,
previousPages: [
...previousPages,
{
openedAssetShortHeader: null,
openedShopCategory: null,
openedAssetPack: null,
openedPrivateAssetPackListingData: null,
openedPrivateGameTemplateListingData: null,
openedBundleListingData: bundleListingData,
filtersState: noFilter,
displayAssets: false,
selectedFolders: [],

View File

@@ -1,4 +1,5 @@
// @flow
import * as React from 'react';
import {
type PublicAssetPacks,
type PrivateAssetPack,
@@ -7,7 +8,18 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
getArchivedBundleListingData,
getArchivedPrivateGameTemplateListingData,
getArchivedPrivateAssetPackListingData,
} from '../Utils/GDevelopServices/Shop';
import {
PrivateAssetPackTile,
PublicAssetPackTile,
PrivateGameTemplateTile,
BundleTile,
} from './ShopTiles';
import { mergeArraysPerGroup } from '../Utils/Array';
/**
* A simple slug generator that allows to link to asset packs on
@@ -119,3 +131,366 @@ export const getPrivateGameTemplateListingDataFromUserFriendlySlug = ({
return null;
};
export const getBundleListingDataFromUserFriendlySlug = ({
bundleListingDatas,
userFriendlySlug,
}: {|
bundleListingDatas: Array<BundleListingData>,
userFriendlySlug: string,
|}): ?BundleListingData => {
const bundleId = getIdFromPrivateProductUserFriendlySlug(userFriendlySlug);
const bundleListingData = bundleListingDatas.find(
bundle => bundleId === bundle.id
);
if (bundleListingData) return bundleListingData;
return null;
};
export const getBundleTiles = ({
allBundleListingDatas,
displayedBundleListingDatas,
onBundleSelection,
receivedBundles,
openedShopCategory,
hasAssetFiltersApplied,
}: {|
allBundleListingDatas: ?Array<BundleListingData>,
displayedBundleListingDatas: ?Array<BundleListingData>,
onBundleSelection: ?(BundleListingData) => void,
receivedBundles: ?Array<any>,
openedShopCategory?: ?string,
hasAssetFiltersApplied?: boolean,
|}): Array<React.Node> => {
if (
!allBundleListingDatas ||
!displayedBundleListingDatas ||
!onBundleSelection ||
hasAssetFiltersApplied
)
return [];
const bundleTiles: Array<React.Node> = [];
const ownedBundleTiles: Array<React.Node> = [];
displayedBundleListingDatas
.filter(
bundleListingData =>
!openedShopCategory ||
bundleListingData.categories.includes(openedShopCategory)
)
.forEach(bundleListingData => {
const isBundleOwned =
!!receivedBundles &&
!!receivedBundles.find(bundle => bundle.id === bundleListingData.id);
const tile = (
<BundleTile
bundleListingData={bundleListingData}
onSelect={() => {
onBundleSelection(bundleListingData);
}}
owned={isBundleOwned}
key={bundleListingData.id}
/>
);
if (isBundleOwned) {
ownedBundleTiles.push(tile);
} else {
bundleTiles.push(tile);
}
});
// Handle archived bundles that are owned by the user.
// These are bundles that are not listed in the shop anymore,
// but that the user has already purchased.
const archivedOwnedBundleTiles = (receivedBundles || [])
.filter(
bundle =>
!allBundleListingDatas.find(
bundleListingData => bundleListingData.id === bundle.id
)
)
.map(bundle => {
const archivedBundleListingData = getArchivedBundleListingData({
bundle,
});
return (
<BundleTile
bundleListingData={archivedBundleListingData}
onSelect={() => {
onBundleSelection(archivedBundleListingData);
}}
owned={true}
key={bundle.id}
/>
);
});
return [...ownedBundleTiles, ...archivedOwnedBundleTiles, ...bundleTiles];
};
export const getGameTemplateTiles = ({
allPrivateGameTemplateListingDatas,
displayedPrivateGameTemplateListingDatas,
onPrivateGameTemplateSelection,
receivedGameTemplates,
openedShopCategory,
hasAssetFiltersApplied,
hasAssetPackFiltersApplied,
onlyShowAssets,
}: {|
allPrivateGameTemplateListingDatas: ?Array<PrivateGameTemplateListingData>,
displayedPrivateGameTemplateListingDatas: ?Array<PrivateGameTemplateListingData>,
onPrivateGameTemplateSelection: ?(PrivateGameTemplateListingData) => void,
receivedGameTemplates: ?Array<any>,
openedShopCategory?: ?string,
hasAssetFiltersApplied?: boolean,
hasAssetPackFiltersApplied?: boolean,
onlyShowAssets?: boolean,
|}): Array<React.Node> => {
if (
!allPrivateGameTemplateListingDatas ||
!displayedPrivateGameTemplateListingDatas ||
!onPrivateGameTemplateSelection ||
hasAssetFiltersApplied ||
hasAssetPackFiltersApplied ||
onlyShowAssets
)
return [];
const gameTemplateTiles: Array<React.Node> = [];
const ownedGameTemplateTiles: Array<React.Node> = [];
const filteredGameTemplates = displayedPrivateGameTemplateListingDatas.filter(
privateGameTemplateListingData =>
!openedShopCategory || openedShopCategory === 'game-template'
);
filteredGameTemplates.forEach(privateGameTemplateListingData => {
const isGameTemplateOwned =
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
);
const tile = (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(privateGameTemplateListingData);
}}
owned={isGameTemplateOwned}
key={privateGameTemplateListingData.id}
/>
);
if (isGameTemplateOwned) {
ownedGameTemplateTiles.push(tile);
} else {
gameTemplateTiles.push(tile);
}
});
// Handle archived game templates that are owned by the user.
// These are game templates that are not listed in the shop anymore,
// but that the user has already purchased.
const archivedOwnedGameTemplateTiles = (receivedGameTemplates || [])
.filter(
gameTemplate =>
!allPrivateGameTemplateListingDatas.find(
privateGameTemplateListingData =>
privateGameTemplateListingData.id === gameTemplate.id
)
)
.map(gameTemplate => {
const archivedGameTemplateListingData = getArchivedPrivateGameTemplateListingData(
{
gameTemplate,
}
);
return (
<PrivateGameTemplateTile
privateGameTemplateListingData={archivedGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(archivedGameTemplateListingData);
}}
owned={true}
key={gameTemplate.id}
/>
);
});
return [
...ownedGameTemplateTiles,
...archivedOwnedGameTemplateTiles,
...gameTemplateTiles,
];
};
export const getPublicAssetPackTiles = ({
publicAssetPacks,
onPublicAssetPackSelection,
openedShopCategory,
hasAssetFiltersApplied,
}: {|
publicAssetPacks: ?(PublicAssetPack[]),
onPublicAssetPackSelection: ?(PublicAssetPack) => void,
openedShopCategory?: ?string,
hasAssetFiltersApplied?: boolean,
|}): Array<React.Node> => {
if (
!publicAssetPacks ||
!onPublicAssetPackSelection ||
hasAssetFiltersApplied
)
return [];
const filteredAssetPacks = publicAssetPacks.filter(
assetPack =>
!openedShopCategory || assetPack.categories.includes(openedShopCategory)
);
return filteredAssetPacks.map((assetPack, index) => (
<PublicAssetPackTile
assetPack={assetPack}
onSelect={() => onPublicAssetPackSelection(assetPack)}
key={`${assetPack.tag}-${index}`}
/>
));
};
export const getAssetPackTiles = ({
allPrivateAssetPackListingDatas,
displayedPrivateAssetPackListingDatas,
onPrivateAssetPackSelection,
publicAssetPackTiles,
receivedAssetPacks,
openedShopCategory,
hasAssetFiltersApplied,
}: {|
allPrivateAssetPackListingDatas: ?Array<PrivateAssetPackListingData>,
displayedPrivateAssetPackListingDatas: ?Array<PrivateAssetPackListingData>,
onPrivateAssetPackSelection: ?(PrivateAssetPackListingData) => void,
publicAssetPackTiles?: Array<React.Node>,
receivedAssetPacks: ?Array<any>,
openedShopCategory?: ?string,
hasAssetFiltersApplied?: boolean,
|}): {|
allAssetPackStandAloneTiles: Array<React.Node>,
allAssetPackBundleTiles: Array<React.Node>,
|} => {
if (
!allPrivateAssetPackListingDatas ||
!displayedPrivateAssetPackListingDatas ||
hasAssetFiltersApplied
) {
return {
allAssetPackStandAloneTiles: [],
allAssetPackBundleTiles: [],
};
}
const privateAssetPackStandAloneTiles: Array<React.Node> = [];
const privateOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
const privateAssetPackBundleTiles: Array<React.Node> = [];
const privateOwnedAssetPackBundleTiles: Array<React.Node> = [];
const filteredAssetPacks = displayedPrivateAssetPackListingDatas.filter(
assetPackListingData =>
!openedShopCategory ||
assetPackListingData.categories.includes(openedShopCategory)
);
!!onPrivateAssetPackSelection &&
filteredAssetPacks.forEach(assetPackListingData => {
const isPackOwned =
!!receivedAssetPacks &&
!!receivedAssetPacks.find(pack => pack.id === assetPackListingData.id);
const tile = (
<PrivateAssetPackTile
assetPackListingData={assetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(assetPackListingData);
}}
owned={isPackOwned}
key={assetPackListingData.id}
/>
);
if (
assetPackListingData.includedListableProductIds &&
!!assetPackListingData.includedListableProductIds.length
) {
if (isPackOwned) {
privateOwnedAssetPackBundleTiles.push(tile);
} else {
privateAssetPackBundleTiles.push(tile);
}
} else {
if (isPackOwned) {
privateOwnedAssetPackStandAloneTiles.push(tile);
} else {
privateAssetPackStandAloneTiles.push(tile);
}
}
});
// Handle archived asset packs that are owned by the user.
// These are asset packs that are not listed in the shop anymore,
// but that the user has already purchased.
const archivedOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
const archivedOwnedAssetPackBundleTiles: Array<React.Node> = [];
!!onPrivateAssetPackSelection &&
(receivedAssetPacks || [])
.filter(
assetPack =>
!allPrivateAssetPackListingDatas.find(
privateAssetPackListingData =>
privateAssetPackListingData.id === assetPack.id
)
)
.forEach(assetPack => {
const archivedAssetPackListingData = getArchivedPrivateAssetPackListingData(
{
assetPack,
}
);
const tile = (
<PrivateAssetPackTile
assetPackListingData={archivedAssetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(archivedAssetPackListingData);
}}
owned={true}
key={assetPack.id}
/>
);
if (
archivedAssetPackListingData.includedListableProductIds &&
!!archivedAssetPackListingData.includedListableProductIds.length
) {
archivedOwnedAssetPackBundleTiles.push(tile);
} else {
archivedOwnedAssetPackStandAloneTiles.push(tile);
}
});
const allAssetPackBundleTiles = [
...privateOwnedAssetPackBundleTiles,
...archivedOwnedAssetPackBundleTiles,
...privateAssetPackBundleTiles,
];
const allAssetPackStandAloneTiles = [
...privateOwnedAssetPackStandAloneTiles,
...archivedOwnedAssetPackStandAloneTiles,
...mergeArraysPerGroup(
privateAssetPackStandAloneTiles,
publicAssetPackTiles || [],
2,
1
),
];
return { allAssetPackStandAloneTiles, allAssetPackBundleTiles };
};

View File

@@ -191,7 +191,7 @@ function AssetSwappingDialog({
>
<AssetStore
ref={assetStore}
hideGameTemplates
onlyShowAssets
assetSwappedObject={object}
minimalUI={minimalUI}
/>

View File

@@ -10,6 +10,7 @@ import type {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import { Line, Column } from '../UI/Grid';
import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView';
@@ -18,18 +19,18 @@ import {
type WindowSizeType,
} from '../UI/Responsive/ResponsiveWindowMeasurer';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { mergeArraysPerGroup } from '../Utils/Array';
import {
CategoryTile,
PrivateAssetPackTile,
PublicAssetPackTile,
PrivateGameTemplateTile,
} from './ShopTiles';
import { CategoryTile } from './ShopTiles';
import { useDebounce } from '../Utils/UseDebounce';
import PromotionsSlideshow from '../Promotions/PromotionsSlideshow';
import { ColumnStackLayout } from '../UI/Layout';
import { EarnCredits } from '../GameDashboard/Wallet/EarnCredits';
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
import {
getBundleTiles,
getGameTemplateTiles,
getAssetPackTiles,
getPublicAssetPackTiles,
} from './AssetStoreUtils';
const cellSpacing = 10;
@@ -161,12 +162,14 @@ type Props = {|
publicAssetPacks: PublicAssetPacks,
privateAssetPackListingDatas: Array<PrivateAssetPackListingData>,
privateGameTemplateListingDatas: Array<PrivateGameTemplateListingData>,
bundleListingDatas: Array<BundleListingData>,
onPublicAssetPackSelection: PublicAssetPack => void,
onPrivateAssetPackSelection: PrivateAssetPackListingData => void,
onPrivateGameTemplateSelection: PrivateGameTemplateListingData => void,
onBundleSelection: BundleListingData => void,
onCategorySelection: string => void,
openedShopCategory: string | null,
hideGameTemplates?: boolean,
onlyShowAssets?: boolean,
displayPromotions?: boolean,
onOpenProfile?: () => void,
|};
@@ -177,12 +180,14 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
publicAssetPacks: { starterPacks },
privateAssetPackListingDatas,
privateGameTemplateListingDatas,
bundleListingDatas,
onPublicAssetPackSelection,
onPrivateAssetPackSelection,
onPrivateGameTemplateSelection,
onBundleSelection,
onCategorySelection,
openedShopCategory,
hideGameTemplates,
onlyShowAssets,
displayPromotions,
onOpenProfile,
}: Props,
@@ -192,6 +197,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
const {
receivedAssetPacks,
receivedGameTemplates,
receivedBundles,
badges,
achievements,
} = React.useContext(AuthenticatedUserContext);
@@ -220,7 +226,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
Object.entries(shopCategories).map(
// $FlowExpectedError - Object.entries does not infer well the type of the value.
([id, { title, imageSource, imageAlt }]) =>
hideGameTemplates && id === 'game-template' ? null : (
onlyShowAssets && id === 'game-template' ? null : (
<CategoryTile
// This id would be more appropriate if it was shop-category-...
// but it is kept as is to avoid breaking some guided lessons using this
@@ -236,124 +242,71 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
/>
)
),
[onCategorySelection, hideGameTemplates]
[onCategorySelection, onlyShowAssets]
);
const openedShopCategoryTitle = openedShopCategory
? shopCategories[openedShopCategory].title
: null;
const starterPacksTiles: Array<React.Node> = starterPacks
.filter(
assetPack =>
!openedShopCategory ||
assetPack.categories.includes(openedShopCategory)
)
.map((assetPack, index) => (
<PublicAssetPackTile
assetPack={assetPack}
onSelect={() => onPublicAssetPackSelection(assetPack)}
key={`${assetPack.tag}-${index}`}
/>
));
const publicPackTiles: Array<React.Node> = React.useMemo(
() =>
getPublicAssetPackTiles({
publicAssetPacks: starterPacks,
onPublicAssetPackSelection,
openedShopCategory,
}),
[starterPacks, onPublicAssetPackSelection, openedShopCategory]
);
const { allStandAloneTiles, allBundleTiles } = React.useMemo(
() => {
const privateAssetPackStandAloneTiles: Array<React.Node> = [];
const privateOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
const privateAssetPackBundleTiles: Array<React.Node> = [];
const privateOwnedAssetPackBundleTiles: Array<React.Node> = [];
privateAssetPackListingDatas
.filter(
assetPackListingData =>
!openedShopCategory ||
assetPackListingData.categories.includes(openedShopCategory)
)
.forEach(assetPackListingData => {
const isPackOwned =
!!receivedAssetPacks &&
!!receivedAssetPacks.find(
pack => pack.id === assetPackListingData.id
);
const tile = (
<PrivateAssetPackTile
assetPackListingData={assetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(assetPackListingData);
}}
owned={isPackOwned}
key={assetPackListingData.id}
/>
);
if (
assetPackListingData.includedListableProductIds &&
!!assetPackListingData.includedListableProductIds.length
) {
if (isPackOwned) {
privateOwnedAssetPackBundleTiles.push(tile);
} else {
privateAssetPackBundleTiles.push(tile);
}
} else {
if (isPackOwned) {
privateOwnedAssetPackStandAloneTiles.push(tile);
} else {
privateAssetPackStandAloneTiles.push(tile);
}
}
});
const allBundleTiles = [
...privateOwnedAssetPackBundleTiles, // Display owned bundles first.
...privateAssetPackBundleTiles,
];
const allStandAloneTiles = [
...privateOwnedAssetPackStandAloneTiles, // Display owned packs first.
...mergeArraysPerGroup(
privateAssetPackStandAloneTiles,
starterPacksTiles,
2,
1
),
];
return { allStandAloneTiles, allBundleTiles };
},
const {
allAssetPackStandAloneTiles,
allAssetPackBundleTiles,
} = React.useMemo(
() =>
getAssetPackTiles({
allPrivateAssetPackListingDatas: privateAssetPackListingDatas,
displayedPrivateAssetPackListingDatas: privateAssetPackListingDatas,
onPrivateAssetPackSelection,
publicAssetPackTiles: publicPackTiles,
receivedAssetPacks,
openedShopCategory,
}),
[
privateAssetPackListingDatas,
openedShopCategory,
onPrivateAssetPackSelection,
starterPacksTiles,
publicPackTiles,
receivedAssetPacks,
]
);
const allBundleTiles = React.useMemo(
() =>
getBundleTiles({
allBundleListingDatas: bundleListingDatas,
displayedBundleListingDatas: bundleListingDatas,
onBundleSelection,
receivedBundles,
openedShopCategory,
}),
[
bundleListingDatas,
openedShopCategory,
onBundleSelection,
receivedBundles,
]
);
const gameTemplateTiles = React.useMemo(
() => {
// Only show game templates if the category is not set or is set to "game-template".
return privateGameTemplateListingDatas
.filter(
privateGameTemplateListingData =>
!openedShopCategory || openedShopCategory === 'game-template'
)
.map((privateGameTemplateListingData, index) => (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(privateGameTemplateListingData);
}}
owned={
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
)
}
key={privateGameTemplateListingData.id}
/>
));
},
() =>
getGameTemplateTiles({
allPrivateGameTemplateListingDatas: privateGameTemplateListingDatas,
displayedPrivateGameTemplateListingDatas: privateGameTemplateListingDatas,
onPrivateGameTemplateSelection,
receivedGameTemplates,
openedShopCategory,
}),
[
privateGameTemplateListingDatas,
openedShopCategory,
@@ -363,10 +316,10 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
);
const {
displayedList: displayedStandAloneTiles,
displayedList: displayedAssetPackStandAloneTiles,
onShowMore: onShowMoreStandAloneTiles,
} = useProgressiveReveal({
list: allStandAloneTiles,
list: allAssetPackStandAloneTiles,
numberPerPage: 25,
});
@@ -416,12 +369,12 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
)}
</ColumnStackLayout>
) : null}
{allBundleTiles.length ? (
{allBundleTiles.length && !onlyShowAssets ? (
<>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Bundles</Trans>
<Trans>GDevelop Bundles</Trans>
</Text>
</Line>
</Column>
@@ -435,6 +388,25 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</GridList>
</>
) : null}
{allAssetPackBundleTiles.length ? (
<>
<Column noMargin>
<Line>
<Text size="block-title">
<Trans>Asset pack bundles</Trans>
</Text>
</Line>
</Column>
<GridList
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{allAssetPackBundleTiles}
</GridList>
</>
) : null}
{openedShopCategoryTitle && (
<Column noMargin>
<Line>
@@ -442,7 +414,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
</Line>
</Column>
)}
{!hideGameTemplates && (
{!onlyShowAssets && (
<>
{!openedShopCategoryTitle && (
<Column noMargin>
@@ -478,7 +450,7 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{displayedStandAloneTiles}
{displayedAssetPackStandAloneTiles}
</GridList>
</ScrollView>
);

View File

@@ -13,6 +13,7 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import { NoResultPlaceholder } from './NoResultPlaceholder';
import GridList from '@material-ui/core/GridList';
@@ -24,14 +25,7 @@ import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import PlaceholderError from '../UI/PlaceholderError';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { mergeArraysPerGroup } from '../Utils/Array';
import {
AssetCardTile,
AssetFolderTile,
PrivateAssetPackTile,
PrivateGameTemplateTile,
PublicAssetPackTile,
} from './ShopTiles';
import { AssetCardTile, AssetFolderTile } from './ShopTiles';
import PrivateAssetPackAudioFilesDownloadButton from './PrivateAssets/PrivateAssetPackAudioFilesDownloadButton';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import { Column, LargeSpacer, Line } from '../UI/Grid';
@@ -52,6 +46,14 @@ import HelpIcon from '../UI/HelpIcon';
import { OwnedProductLicense } from './ProductLicense/ProductLicenseOptions';
import { getUserProductPurchaseUsageType } from './ProductPageHelper';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { BundleStoreContext } from './Bundles/BundleStoreContext';
import {
getBundleTiles,
getGameTemplateTiles,
getAssetPackTiles,
getPublicAssetPackTiles,
} from './AssetStoreUtils';
import { LARGE_WIDGET_SIZE } from '../MainFrame/EditorContainers/HomePage/CardWidget';
const ASSETS_DISPLAY_LIMIT = 60;
@@ -139,9 +141,13 @@ export const getAssetShortHeadersToDisplay = (
};
const cellSpacing = 10;
const MAX_COLUMNS = getShopItemsColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
grid: {
margin: '0 2px', // Remove the default margin of the grid but keep the horizontal padding for focus outline.
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
@@ -229,6 +235,7 @@ type Props = {|
assetShortHeaders: ?Array<AssetShortHeader>,
privateAssetPackListingDatas?: ?Array<PrivateAssetPackListingData>,
privateGameTemplateListingDatas?: ?Array<PrivateGameTemplateListingData>,
bundleListingDatas?: ?Array<BundleListingData>,
publicAssetPacks?: ?Array<PublicAssetPack>,
onOpenDetails: (assetShortHeader: AssetShortHeader) => void,
noResultsPlaceHolder?: React.Node,
@@ -243,6 +250,7 @@ type Props = {|
onPrivateGameTemplateSelection?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleSelection?: (bundleListingData: BundleListingData) => void,
onFolderSelection?: (folderTag: string) => void,
onGoBackToFolderIndex?: (folderIndex: number) => void,
noScroll?: boolean,
@@ -251,7 +259,7 @@ type Props = {|
// Or it can display arbitrary content, like the list of assets in a pack, or similar assets,
// then currentPage is null.
currentPage?: AssetStorePageState,
hideGameTemplates?: boolean,
onlyShowAssets?: boolean,
hideDetails?: boolean,
|};
@@ -263,15 +271,17 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
noResultsPlaceHolder,
privateAssetPackListingDatas,
privateGameTemplateListingDatas,
bundleListingDatas,
publicAssetPacks,
onPrivateAssetPackSelection,
onPublicAssetPackSelection,
onPrivateGameTemplateSelection,
onBundleSelection,
onFolderSelection,
onGoBackToFolderIndex,
noScroll,
currentPage,
hideGameTemplates,
onlyShowAssets,
hideDetails,
}: Props,
ref
@@ -289,11 +299,19 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
const {
error: gameTemplateStoreError,
fetchGameTemplates,
privateGameTemplateListingDatas: allPrivateGameTemplateListingDatas,
} = React.useContext(PrivateGameTemplateStoreContext);
const {
error: bundleStoreError,
fetchBundles,
bundleListingDatas: allBundleListingDatas,
} = React.useContext(BundleStoreContext);
const {
receivedAssetPacks,
receivedGameTemplates,
receivedBundles,
assetPackPurchases,
bundlePurchases,
} = React.useContext(AuthenticatedUserContext);
const [
authorPublicProfile,
@@ -343,11 +361,13 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
() => {
fetchAssetsAndFilters();
fetchGameTemplates();
fetchBundles();
},
[fetchAssetsAndFilters, fetchGameTemplates]
[fetchAssetsAndFilters, fetchGameTemplates, fetchBundles]
);
const shopError = assetStoreError || gameTemplateStoreError;
const shopError =
assetStoreError || gameTemplateStoreError || bundleStoreError;
const hasAssetPackFiltersApplied = React.useMemo(
// When a pack is opened, the asset pack filters are not hidden, but not relevant either.
@@ -497,144 +517,76 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
]
);
const publicPacksTiles: Array<React.Node> = React.useMemo(
() => {
if (
!publicAssetPacks ||
!onPublicAssetPackSelection ||
// Don't show public packs if filtering on assets.
hasAssetFiltersApplied
)
return [];
return publicAssetPacks.map((assetPack, index) => (
<PublicAssetPackTile
assetPack={assetPack}
onSelect={() => onPublicAssetPackSelection(assetPack)}
key={`${assetPack.tag}-${index}`}
/>
));
},
const publicPackTiles: Array<React.Node> = React.useMemo(
() =>
getPublicAssetPackTiles({
publicAssetPacks,
onPublicAssetPackSelection,
hasAssetFiltersApplied,
}),
[publicAssetPacks, onPublicAssetPackSelection, hasAssetFiltersApplied]
);
const { allStandAlonePackTiles, allBundlePackTiles } = React.useMemo(
() => {
const privateAssetPackStandAloneTiles: Array<React.Node> = [];
const privateOwnedAssetPackStandAloneTiles: Array<React.Node> = [];
const privateAssetPackBundleTiles: Array<React.Node> = [];
const privateOwnedAssetPackBundleTiles: Array<React.Node> = [];
if (
!privateAssetPackListingDatas ||
!receivedAssetPacks ||
// Don't show private packs if filtering on assets.
hasAssetFiltersApplied
) {
return {
allStandAlonePackTiles: [],
allBundlePackTiles: [],
};
}
!!onPrivateAssetPackSelection &&
privateAssetPackListingDatas.forEach(assetPackListingData => {
const isPackOwned =
!!receivedAssetPacks &&
!!receivedAssetPacks.find(
pack => pack.id === assetPackListingData.id
);
const tile = (
<PrivateAssetPackTile
assetPackListingData={assetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(assetPackListingData);
}}
owned={isPackOwned}
key={assetPackListingData.id}
/>
);
if (
assetPackListingData.includedListableProductIds &&
!!assetPackListingData.includedListableProductIds.length
) {
if (isPackOwned) {
privateOwnedAssetPackBundleTiles.push(tile);
} else {
privateAssetPackBundleTiles.push(tile);
}
} else {
if (isPackOwned) {
privateOwnedAssetPackStandAloneTiles.push(tile);
} else {
privateAssetPackStandAloneTiles.push(tile);
}
}
});
const allBundlePackTiles = [
...privateOwnedAssetPackBundleTiles, // Display owned bundles first.
...privateAssetPackBundleTiles,
];
const allStandAlonePackTiles = [
...privateOwnedAssetPackStandAloneTiles, // Display owned packs first.
...mergeArraysPerGroup(
privateAssetPackStandAloneTiles,
publicPacksTiles,
2,
1
),
];
return { allStandAlonePackTiles, allBundlePackTiles };
},
const {
allAssetPackStandAloneTiles,
allAssetPackBundleTiles,
} = React.useMemo(
() =>
getAssetPackTiles({
allPrivateAssetPackListingDatas,
displayedPrivateAssetPackListingDatas: privateAssetPackListingDatas,
onPrivateAssetPackSelection,
publicAssetPackTiles: publicPackTiles,
receivedAssetPacks,
hasAssetFiltersApplied,
}),
[
allPrivateAssetPackListingDatas,
privateAssetPackListingDatas,
onPrivateAssetPackSelection,
publicPacksTiles,
publicPackTiles,
receivedAssetPacks,
hasAssetFiltersApplied,
]
);
const gameTemplateTiles = React.useMemo(
() => {
if (
!privateGameTemplateListingDatas ||
!onPrivateGameTemplateSelection ||
// Don't show private game templates if filtering on assets.
hasAssetFiltersApplied ||
// Don't show private game templates if filtering on asset packs.
hasAssetPackFiltersApplied ||
hideGameTemplates
)
return [];
return privateGameTemplateListingDatas.map(
(privateGameTemplateListingData, index) => (
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onPrivateGameTemplateSelection(privateGameTemplateListingData);
}}
owned={
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
pack => pack.id === privateGameTemplateListingData.id
)
}
key={privateGameTemplateListingData.id}
/>
)
);
},
() =>
getGameTemplateTiles({
allPrivateGameTemplateListingDatas,
displayedPrivateGameTemplateListingDatas: privateGameTemplateListingDatas,
onPrivateGameTemplateSelection,
receivedGameTemplates,
hasAssetFiltersApplied,
hasAssetPackFiltersApplied,
onlyShowAssets,
}),
[
allPrivateGameTemplateListingDatas,
privateGameTemplateListingDatas,
onPrivateGameTemplateSelection,
receivedGameTemplates,
hasAssetFiltersApplied,
hasAssetPackFiltersApplied,
hideGameTemplates,
onlyShowAssets,
]
);
const bundleTiles = React.useMemo(
() =>
getBundleTiles({
allBundleListingDatas: allBundleListingDatas,
displayedBundleListingDatas: bundleListingDatas,
onBundleSelection,
receivedBundles,
hasAssetFiltersApplied,
}),
[
allBundleListingDatas,
bundleListingDatas,
onBundleSelection,
receivedBundles,
hasAssetFiltersApplied,
]
);
@@ -697,15 +649,27 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
getUserProductPurchaseUsageType({
productId:
openedAssetPack && openedAssetPack.id ? openedAssetPack.id : null,
receivedProducts: receivedAssetPacks,
productPurchases: assetPackPurchases,
allProductListingDatas: allPrivateAssetPackListingDatas,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
productPurchases: [
...(assetPackPurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(allPrivateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
assetPackPurchases,
bundlePurchases,
openedAssetPack,
allPrivateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
]
);
@@ -739,6 +703,20 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
scrollView={scrollView.current}
/>
)}
{!openedAssetPack && bundleTiles.length && pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
<GridList
cols={getShopItemsColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{bundleTiles}
</GridList>
</Column>
</Line>
) : null}
{!openedAssetPack &&
gameTemplateTiles.length &&
pageBreakIndex === 0 ? (
@@ -756,7 +734,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
</Line>
) : null}
{!openedAssetPack &&
allBundlePackTiles.length &&
allAssetPackBundleTiles.length &&
pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
@@ -766,13 +744,13 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{allBundlePackTiles}
{allAssetPackBundleTiles}
</GridList>
</Column>
</Line>
) : null}
{!openedAssetPack &&
allStandAlonePackTiles.length &&
allAssetPackStandAloneTiles.length &&
pageBreakIndex === 0 ? (
<Line>
<Column noMargin expand>
@@ -782,7 +760,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
cellHeight="auto"
spacing={cellSpacing}
>
{allStandAlonePackTiles}
{allAssetPackStandAloneTiles}
</GridList>
</Column>
</Line>
@@ -974,9 +952,11 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
// No assets to show.
!assetTiles.length &&
// No bundles to show.
!allBundlePackTiles.length &&
!bundleTiles.length &&
// No asset pack bundles to show.
!allAssetPackBundleTiles.length &&
// No packs to show.
!allStandAlonePackTiles.length &&
!allAssetPackStandAloneTiles.length &&
// no templates to show.
!gameTemplateTiles.length &&
(!openedAssetPack ||

View File

@@ -0,0 +1,689 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import {
type BundleListingData,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getBundle,
type Bundle,
type Course,
} from '../../Utils/GDevelopServices/Asset';
import Text from '../../UI/Text';
import { Trans } from '@lingui/macro';
import AlertMessage from '../../UI/AlertMessage';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import FlatButton from '../../UI/FlatButton';
import {
ResponsiveLineStackLayout,
LineStackLayout,
ColumnStackLayout,
} from '../../UI/Layout';
import { Column, LargeSpacer, Line, Spacer } from '../../UI/Grid';
import {
getUserPublicProfile,
type UserPublicProfile,
} from '../../Utils/GDevelopServices/User';
import Link from '../../UI/Link';
import ResponsiveMediaGallery from '../../UI/ResponsiveMediaGallery';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { sendBundleBuyClicked } from '../../Utils/Analytics/EventSender';
import { MarkdownText } from '../../UI/MarkdownText';
import ScrollView from '../../UI/ScrollView';
import { shouldUseAppStoreProduct } from '../../Utils/AppStorePurchases';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import Avatar from '@material-ui/core/Avatar';
import GridList from '@material-ui/core/GridList';
import { BundleStoreContext } from './BundleStoreContext';
import {
getBundlesContainingProductTiles,
getOtherProductsFromSameAuthorTiles,
getProductMediaItems,
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../ProductPageHelper';
import SecureCheckout from '../SecureCheckout/SecureCheckout';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import BundlePurchaseDialog from './BundlePurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { getCreditsAmountFromId } from '../CreditsPackages/CreditsPackageStoreContext';
import Coin from '../../Credits/Icons/Coin';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import RedemptionCodesDialog from '../../RedemptionCode/RedemptionCodesDialog';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainer: {
flex: 2,
},
leftColumnContainerMobile: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainerMobile: {
flex: 1,
},
avatar: {
width: 20,
height: 20,
},
ownedTag: {
padding: '4px 8px',
borderRadius: 4,
color: 'black',
},
playIcon: {
width: 20,
height: 20,
},
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
type Props = {|
bundleListingData: BundleListingData,
bundleListingDatasFromSameCreator?: ?Array<BundleListingData>,
receivedCourses: ?Array<Course>,
onBundleOpen: BundleListingData => void,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen: (
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onCourseOpen: CourseListingData => void,
simulateAppStoreProduct?: boolean,
|};
const BundleInformationPage = ({
bundleListingData,
bundleListingDatasFromSameCreator,
receivedCourses,
onBundleOpen,
onGameTemplateOpen,
onAssetPackOpen,
onCourseOpen,
simulateAppStoreProduct,
}: Props) => {
const { id, name, sellerId } = bundleListingData;
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
receivedBundles,
bundlePurchases,
receivedGameTemplates,
receivedAssetPacks,
} = React.useContext(AuthenticatedUserContext);
const [bundle, setBundle] = React.useState<?Bundle>(null);
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const [isFetching, setIsFetching] = React.useState<boolean>(false);
const { openUserPublicProfile } = React.useContext(PublicProfileContext);
const [
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const {
windowSize,
isLandscape,
isMediumScreen,
isMobile,
} = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const isOwningAnotherVariant = React.useMemo(
() => {
if (!bundle || isAlreadyReceived || !receivedBundles) return false;
// Another bundle older version of that bundle can be owned.
// We look at the tag to determine if the bundle is the same.
return !!receivedBundles.find(
receivedBundle => receivedBundle.tag === bundle.tag
);
},
[bundle, isAlreadyReceived, receivedBundles]
);
const additionalProductThumbnailsIncludedInBundle: string[] = React.useMemo(
() => {
const productsIncludedInBundle = getProductsIncludedInBundle({
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
});
if (!productsIncludedInBundle) return [];
const additionalThumbnails = productsIncludedInBundle
.map(product => (product.thumbnailUrls || []).slice(0, 2))
.reduce((acc, thumbnails) => acc.concat(thumbnails), []);
return additionalThumbnails;
},
[
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
bundleListingData,
]
);
const productsIncludedInBundleTiles = React.useMemo(
() =>
getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
...(receivedCourses || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
}),
[
bundle,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
receivedCourses,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
bundleListingData,
]
);
const bundlesContainingBundleTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: bundle,
productListingData: bundleListingData,
productListingDatas: bundleListingDatas,
receivedProducts: receivedBundles,
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
bundle,
bundleListingData,
bundleListingDatas,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
const otherBundlesFromTheSameAuthorTiles = React.useMemo(
() =>
getOtherProductsFromSameAuthorTiles({
otherProductListingDatasFromSameCreator: bundleListingDatasFromSameCreator,
currentProductListingData: bundleListingData,
receivedProducts: receivedBundles,
onProductOpen: onBundleOpen,
}),
[
bundleListingDatasFromSameCreator,
bundleListingData,
receivedBundles,
onBundleOpen,
]
);
React.useEffect(
() => {
(async () => {
setIsFetching(true);
try {
const [bundle, profile] = await Promise.all([
getBundle(id),
getUserPublicProfile(sellerId),
]);
setBundle(bundle);
setSellerPublicProfile(profile);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (extractedStatusAndCode && extractedStatusAndCode.status === 404) {
setErrorText(
<Trans>
Bundle not found - An error occurred, please try again later.
</Trans>
);
} else {
setErrorText(
<Trans>An error occurred, please try again later.</Trans>
);
}
} finally {
setIsFetching(false);
}
})();
},
[id, sellerId]
);
const onClickBuy = React.useCallback(
async () => {
if (!bundle || isOwningAnotherVariant) return;
if (isAlreadyReceived) {
onBundleOpen(bundleListingData);
return;
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[
bundle,
bundleListingData,
isAlreadyReceived,
isOwningAnotherVariant,
onBundleOpen,
]
);
const mediaItems = React.useMemo(
() =>
getProductMediaItems({
product: bundle,
productListingData: bundleListingData,
shouldSimulateAppStoreProduct: simulateAppStoreProduct,
additionalThumbnails: additionalProductThumbnailsIncludedInBundle,
}),
[
bundle,
bundleListingData,
simulateAppStoreProduct,
additionalProductThumbnailsIncludedInBundle,
]
);
const includedCreditsAmount = React.useMemo(
() =>
(bundleListingData.includedListableProducts || [])
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
),
[bundleListingData]
);
const includedRedemptionCodes = React.useMemo(
() => bundleListingData.includedRedemptionCodes || [],
[bundleListingData]
);
return (
<I18n>
{({ i18n }) => (
<>
{errorText ? (
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
) : isFetching ? (
<Column expand alignItems="center" justifyContent="center">
<PlaceholderLoader />
</Column>
) : bundle && sellerPublicProfile ? (
<Column noOverflowParent expand noMargin>
<ScrollView autoHideScrollbar style={styles.scrollview}>
<ResponsiveLineStackLayout
noColumnMargin
noMargin
// Force the columns to wrap on tablets and small screens.
forceMobileLayout={isMediumScreen}
// Prevent it to wrap when in landscape mode on small screens.
noResponsiveLandscape
useLargeSpacer
>
<div
style={
isMobile
? styles.leftColumnContainerMobile
: styles.leftColumnContainer
}
>
<ResponsiveMediaGallery
mediaItems={mediaItems}
altTextTemplate={`Bundle ${name} preview image {mediaIndex}`}
horizontalOuterMarginToEatOnMobile={8}
/>
</div>
<div
style={
isMobile
? styles.rightColumnContainerMobile
: styles.rightColumnContainer
}
>
<ColumnStackLayout noMargin>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<Text noMargin size="title">
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
{isAlreadyReceived && (
<div
style={{
...styles.ownedTag,
backgroundColor:
gdevelopTheme.statusIndicator.success,
}}
>
<Text color="inherit" noMargin>
<Trans>OWNED</Trans>
</Text>
</div>
)}
</LineStackLayout>
<LineStackLayout noMargin alignItems="center">
<Avatar
src={sellerPublicProfile.iconUrl}
style={styles.avatar}
/>
<Text displayInlineAsSpan size="sub-title">
<Link
onClick={() =>
openUserPublicProfile({
userId: sellerPublicProfile.id,
callbacks: {
onAssetPackOpen,
onGameTemplateOpen,
},
})
}
href="#"
>
{sellerPublicProfile.username || ''}
</Link>
</Text>
</LineStackLayout>
<Spacer />
{isOwningAnotherVariant ? (
<AlertMessage kind="warning">
<Trans>
You own an older version of this bundle. Browse the
store to access it!
</Trans>
</AlertMessage>
) : !isAlreadyReceived ? (
<>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
{!errorText && (
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
/>
)}
</>
) : null}
<Text size="body2" displayInlineAsSpan>
<MarkdownText
source={selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
allowParagraphs
/>
</Text>
{includedRedemptionCodes.length > 0 && (
<ColumnStackLayout noMargin>
{includedRedemptionCodes.map(
(includedRedemptionCode, index) => (
<LineStackLayout
noMargin
alignItems="center"
key={`${
includedRedemptionCode.givenSubscriptionPlanId
}-${index}`}
>
{getPlanIcon({
planId:
includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription included
</Trans>
</Text>
</LineStackLayout>
)
)}
{isAlreadyReceived && (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my codes</Trans>}
onClick={() =>
setIsRedemptionCodesDialogOpen(true)
}
/>
</Line>
)}
</ColumnStackLayout>
)}
{includedCreditsAmount > 0 && (
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text>
<Trans>
{includedCreditsAmount} credits included
</Trans>
</Text>
</LineStackLayout>
)}
</ColumnStackLayout>
</div>
</ResponsiveLineStackLayout>
{bundlesContainingBundleTiles &&
bundlesContainingBundleTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingBundleTiles}
<LargeSpacer />
</ColumnStackLayout>
</>
) : null}
{productsIncludedInBundleTiles && (
<>
<Line>
<Text size="block-title">
<Trans>Included in this bundle</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsIncludedInBundleTiles}
</GridList>
</Line>
</>
)}
{otherBundlesFromTheSameAuthorTiles &&
otherBundlesFromTheSameAuthorTiles.length > 0 && (
<>
<Line>
<Text size="block-title">
<Trans>Similar bundles</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{otherBundlesFromTheSameAuthorTiles}
</GridList>
</Line>
</>
)}
</ScrollView>
</Column>
) : null}
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</>
)}
</I18n>
);
};
export default BundleInformationPage;

View File

@@ -0,0 +1,532 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import Divider from '@material-ui/core/Divider';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../UI/Layout';
import Chip from '../../UI/Chip';
import Text from '../../UI/Text';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { Column, LargeSpacer, Line, Spacer } from '../../UI/Grid';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import Paper from '../../UI/Paper';
import RaisedButton from '../../UI/RaisedButton';
import Skeleton from '@material-ui/lab/Skeleton';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import {
getArchivedBundleListingData,
type BundleListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getProductsIncludedInBundle,
getUserProductPurchaseUsageType,
} from '../ProductPageHelper';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import { BundleStoreContext } from './BundleStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { renderProductPrice } from '../ProductPriceTag';
import {
CreditsPackageStoreContext,
getCreditsAmountFromId,
} from '../CreditsPackages/CreditsPackageStoreContext';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import Store from '../../UI/CustomSvgIcons/Store';
import Hammer from '../../UI/CustomSvgIcons/Hammer';
import School from '../../UI/CustomSvgIcons/School';
import Coin from '../../Credits/Icons/Coin';
import Sparkle from '../../UI/CustomSvgIcons/Sparkle';
import { renderEstimatedTotalPriceFormatted } from './Utils';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
const highlightColor = '#6CF9F7';
const mobilePadding = 8;
const desktopPadding = 16;
const styles = {
container: { display: 'flex', borderRadius: 8 },
leftColumn: {
display: 'flex',
flex: 1,
flexDirection: 'column',
gap: 8,
justifyContent: 'space-between',
},
bundlePreviewContainer: {
flex: 1,
background:
'linear-gradient(90deg, rgba(59, 247, 244, 0.1) 0%, rgba(255, 188, 87, 0.1) 100%)',
borderLeft: `2px solid ${highlightColor}`,
},
discountChip: { height: 24, backgroundColor: '#F03F18', color: 'white' },
ownedChip: { height: 24, backgroundColor: '#8BE7C4', color: 'black' },
imageContainer: {
display: 'flex',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
image: { width: '100%', aspectRatio: '16 / 9' },
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
};
const CourseTile = ({
courseListingData,
isAlreadyReceived,
}: {|
courseListingData: CourseListingData,
isAlreadyReceived: boolean,
|}) => {
return (
<I18n>
{({ i18n }) => (
<ColumnStackLayout expand>
<div style={styles.imageContainer}>
<img
src={courseListingData.thumbnailUrls[0]}
style={styles.image}
alt={courseListingData.name}
/>
</div>
<Line noMargin>
<Text noMargin>{courseListingData.name}</Text>
</Line>
{!isAlreadyReceived && (
<Line noMargin>
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderProductPrice({
i18n,
productListingData: courseListingData,
usageType: 'default',
plainText: true,
})}
</span>
</Text>
</Line>
)}
</ColumnStackLayout>
)}
</I18n>
);
};
const BundlePreviewTile = ({
bundleListingData,
}: {
bundleListingData: ?BundleListingData,
}) => {
const { isMobile } = useResponsiveWindowSize();
if (!bundleListingData) return null;
const includedListableProducts =
bundleListingData.includedListableProducts || [];
const includedRedemptionCodes =
bundleListingData.includedRedemptionCodes || [];
const numberOfAssetPacks = includedListableProducts.filter(
product => product.productType === 'ASSET_PACK'
).length;
const numberOfGameTemplates = includedListableProducts.filter(
product => product.productType === 'GAME_TEMPLATE'
).length;
const numberOfCourses = includedListableProducts.filter(
product => product.productType === 'COURSE'
).length;
const totalCredits = includedListableProducts
.filter(product => product.productType === 'CREDITS_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
);
return (
<div
style={{
...styles.bundlePreviewContainer,
padding: isMobile ? mobilePadding : desktopPadding,
margin: isMobile ? -mobilePadding : -desktopPadding,
marginLeft: isMobile ? -mobilePadding : 0,
marginTop: isMobile ? 0 : -desktopPadding,
}}
>
<ColumnStackLayout expand>
<LineStackLayout noMargin alignItems="center">
<Sparkle style={{ color: highlightColor }} />
<Text noMargin size="block-title">
<Trans>This bundle includes:</Trans>
</Text>
</LineStackLayout>
<Spacer />
{numberOfAssetPacks > 0 && (
<LineStackLayout noMargin alignItems="center">
<Store />
<Text noMargin>
{numberOfAssetPacks === 1 ? (
<Trans>{numberOfAssetPacks} Asset Pack</Trans>
) : (
<Trans>{numberOfAssetPacks} Asset Packs</Trans>
)}
</Text>
</LineStackLayout>
)}
{numberOfGameTemplates > 0 && (
<LineStackLayout noMargin alignItems="center">
<Hammer />
<Text noMargin>
{numberOfGameTemplates === 1 ? (
<Trans>{numberOfGameTemplates} Game Template</Trans>
) : (
<Trans>{numberOfGameTemplates} Game Templates</Trans>
)}
</Text>
</LineStackLayout>
)}
{numberOfCourses > 0 && (
<LineStackLayout noMargin alignItems="center">
<School />
<Text noMargin>
{numberOfCourses === 1 ? (
<Trans>{numberOfCourses} Course</Trans>
) : (
<Trans>{numberOfCourses} Courses</Trans>
)}
</Text>
</LineStackLayout>
)}
{totalCredits > 0 && (
<LineStackLayout noMargin alignItems="center">
<Coin />
<Text noMargin>
<Trans>{totalCredits} Credits</Trans>
</Text>
</LineStackLayout>
)}
{includedRedemptionCodes.length > 0 &&
includedRedemptionCodes.map((redemptionCode, index) => (
<Line
noMargin
alignItems="center"
key={`${redemptionCode.givenSubscriptionPlanId}-${index}`}
>
{getPlanIcon({
planId: redemptionCode.givenSubscriptionPlanId,
logoSize: 15,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
redemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
redemptionCode.givenSubscriptionPlanId
)}
subscription
</Trans>
</Text>
</Line>
))}
</ColumnStackLayout>
</div>
);
};
const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
if (windowSize === 'xlarge') return 6;
if (windowSize === 'large') return 4;
if (windowSize === 'medium') return 3;
return 2;
};
type Props = {|
onDisplayBundle: (bundleListingData: BundleListingData) => void,
|};
const BundlePreviewBanner = ({ onDisplayBundle }: Props) => {
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const numberOfTilesToDisplay = getColumnsFromWindowSize(windowSize) - 1; // Reserve one tile for the bundle preview.
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { creditsPackageListingDatas } = React.useContext(
CreditsPackageStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { bundlePurchases, receivedBundles } = authenticatedUser;
// For the moment, we either display:
// - the first bundle in the list if none are owned.
// - the first owned bundle (as a listing data if still listed, or as an archived listing data otherwise)
// TODO: improve that logic when we'll have more bundles.
const bundleListingData: BundleListingData | null = React.useMemo(
() => {
if (!bundleListingDatas || !receivedBundles) return null;
if (receivedBundles.length === 0) {
return bundleListingDatas[0]; // Display the first bundle if none are owned.
}
const receivedBundle = receivedBundles[0];
const bundleListingData = bundleListingDatas.find(
bundleListingData => bundleListingData.id === receivedBundle.id
);
if (bundleListingData) {
return bundleListingData; // Display the first owned bundle that is still listed.
}
// If this bundle is not listed anymore, get an archived listing data for that bundle.
return getArchivedBundleListingData({
bundle: receivedBundle,
});
},
[bundleListingDatas, receivedBundles]
);
const userBundlePurchaseUsageType = React.useMemo(
() => {
if (!bundleListingData) return null;
return getUserProductPurchaseUsageType({
productId: bundleListingData.id,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
});
},
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const productListingDatasIncludedInBundle = React.useMemo(
() =>
bundleListingData
? getProductsIncludedInBundle({
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
...(creditsPackageListingDatas || []),
],
productListingData: bundleListingData,
})
: null,
[
bundleListingData,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
creditsPackageListingDatas,
]
);
const redemptionCodesIncludedInBundle = React.useMemo(
() =>
bundleListingData
? bundleListingData.includedRedemptionCodes || []
: null,
[bundleListingData]
);
const courseTiles = React.useMemo(
() => {
if (isMobile && !isLandscape) {
return null; // Don't display course tiles on mobile, they take too much space.
}
if (!productListingDatasIncludedInBundle) {
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => (
<React.Fragment key={`skeleton-${index}`}>
{index > 0 &&
(isMobile && !isLandscape ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
))}
{index > 0 && <Spacer />}
<Column expand>
<Skeleton height={140} />
<Skeleton height={20} />
<Skeleton height={20} />
<LargeSpacer />
</Column>
</React.Fragment>
));
}
const coursesIncludedInBundle = productListingDatasIncludedInBundle.filter(
productListingData => productListingData.productType === 'COURSE'
);
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => {
const courseListingData: ?CourseListingData =
// $FlowFixMe
coursesIncludedInBundle[index];
if (!courseListingData) {
return <div style={{ flex: 1 }} key={`empty-tile-${index}`} />;
}
return (
<React.Fragment key={`course-${courseListingData.id}`}>
{index > 0 && (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
)}
{index > 0 && <Spacer />}
<CourseTile
courseListingData={courseListingData}
isAlreadyReceived={isAlreadyReceived}
/>
</React.Fragment>
);
});
},
[
isMobile,
isLandscape,
numberOfTilesToDisplay,
productListingDatasIncludedInBundle,
isAlreadyReceived,
]
);
return (
<I18n>
{({ i18n }) => (
<Paper
background="medium"
variant="outlined"
style={{
...styles.container,
padding: isMobile ? mobilePadding : desktopPadding,
}}
>
<Column expand noMargin>
<ResponsiveLineStackLayout
noResponsiveLandscape
noMargin
noColumnMargin
noOverflowParent
>
<div
style={{
...styles.leftColumn,
width: isMobile && !isLandscape ? '100%' : undefined,
}}
>
<ColumnStackLayout noMargin>
<Line noMargin>
{bundleListingData ? (
<Chip
label={
isAlreadyReceived ? (
<Trans>Owned</Trans>
) : (
<Trans>Discount</Trans>
)
}
style={
isAlreadyReceived
? styles.ownedChip
: styles.discountChip
}
/>
) : (
<Skeleton variant="rect" height={20} />
)}
</Line>
<Spacer />
{bundleListingData ? (
<Text noMargin size="block-title">
{bundleListingData.nameByLocale
? selectMessageByLocale(
i18n,
bundleListingData.nameByLocale
)
: bundleListingData.name}
</Text>
) : (
<Skeleton height={30} />
)}
{bundleListingData ? (
<Text noMargin>
{bundleListingData.descriptionByLocale
? selectMessageByLocale(
i18n,
bundleListingData.descriptionByLocale
)
: bundleListingData.description}
</Text>
) : (
<Skeleton height={30} />
)}
</ColumnStackLayout>
{bundleListingData ? (
<ColumnStackLayout noMargin>
{!isAlreadyReceived && (
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
})}
</span>
</Text>
)}
<RaisedButton
primary
label={
isAlreadyReceived ? (
<Trans>Browse bundle</Trans>
) : (
<Trans>
Buy for{' '}
{renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
plainText: true,
})}
</Trans>
)
}
onClick={() => onDisplayBundle(bundleListingData)}
/>
</ColumnStackLayout>
) : (
<ColumnStackLayout noMargin>
<Skeleton variant="rect" height={40} />
</ColumnStackLayout>
)}
</div>
{courseTiles}
<BundlePreviewTile bundleListingData={bundleListingData} />
</ResponsiveLineStackLayout>
</Column>
</Paper>
)}
</I18n>
);
};
export default BundlePreviewBanner;

View File

@@ -0,0 +1,373 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { type BundleListingData } from '../../Utils/GDevelopServices/Shop';
import Dialog, { DialogPrimaryButton } from '../../UI/Dialog';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import CreateProfile from '../../Profile/CreateProfile';
import Text from '../../UI/Text';
import { useInterval } from '../../Utils/UseInterval';
import { getPurchaseCheckoutUrl } from '../../Utils/GDevelopServices/Shop';
import Window from '../../Utils/Window';
import { Line, Spacer } from '../../UI/Grid';
import CircularProgress from '../../UI/CircularProgress';
import BackgroundText from '../../UI/BackgroundText';
import Mark from '../../UI/CustomSvgIcons/Mark';
import FlatButton from '../../UI/FlatButton';
import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
import {
shouldUseAppStoreProduct,
purchaseAppStoreProduct,
} from '../../Utils/AppStorePurchases';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import PasswordPromptDialog from '../PasswordPromptDialog';
type Props = {|
bundleListingData: BundleListingData,
usageType: string,
onClose: () => void,
simulateAppStoreProduct?: boolean,
|};
const BundlePurchaseDialog = ({
bundleListingData,
usageType,
onClose,
simulateAppStoreProduct,
}: Props) => {
const {
profile,
onOpenLoginDialog,
onOpenCreateAccountDialog,
receivedBundles,
onPurchaseSuccessful,
onRefreshBundlePurchases,
bundlePurchases,
} = React.useContext(AuthenticatedUserContext);
const [isPurchasing, setIsPurchasing] = React.useState(false);
const [
isCheckingPurchasesAfterLogin,
setIsCheckingPurchasesAfterLogin,
] = React.useState(!receivedBundles);
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
const [
displayPasswordPrompt,
setDisplayPasswordPrompt,
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const { showAlert } = useAlertDialog();
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const onStartPurchase = async () => {
if (!profile) return;
setDisplayPasswordPrompt(false);
// Purchase with the App Store.
if (shouldUseOrSimulateAppStoreProduct) {
try {
setIsPurchasing(true);
await purchaseAppStoreProduct(bundleListingData.appStoreProductId);
} finally {
setIsPurchasing(false);
}
return;
}
const price = bundleListingData.prices.find(
price => price.usageType === usageType
);
if (!price) {
console.error('Unable to find the price for the usage type', usageType);
await showAlert({
title: t`An error happened`,
message: t`Unable to find the price for this bundle. Please try again later.`,
});
return;
}
// Purchase with web.
try {
setIsPurchasing(true);
const checkoutUrl = getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
});
Window.openExternalURL(checkoutUrl);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (
extractedStatusAndCode &&
extractedStatusAndCode.status === 403 &&
extractedStatusAndCode.code === 'auth/wrong-password'
) {
await showAlert({
title: t`Operation not allowed`,
message: t`The password you entered is incorrect. Please try again.`,
});
} else {
console.error('Unable to get the checkout URL', error);
await showAlert({
title: t`An error happened`,
message: t`Unable to get the checkout URL. Please try again later.`,
});
}
setIsPurchasing(false);
} finally {
setPassword('');
}
};
const onWillPurchase = () => {
// Password is required in dev environment only so that one cannot freely purchase bundles.
if (Window.isDev()) setDisplayPasswordPrompt(true);
else onStartPurchase();
};
React.useEffect(
() => {
onWillPurchase();
},
// Launch the start process directly when the dialog is opened, to avoid an extra click.
// eslint-disable-next-line
[]
);
// This effect will be triggered when the bundle purchases change,
// to check if the user has just bought the product.
React.useEffect(
() => {
const checkIfPurchaseIsDone = async () => {
if (
isPurchasing &&
bundlePurchases &&
bundlePurchases.find(
userPurchase => userPurchase.productId === bundleListingData.id
)
) {
// We found the purchase, the user has bought the bundle.
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
await onPurchaseSuccessful();
}
};
checkIfPurchaseIsDone();
},
[
isPurchasing,
bundlePurchases,
bundleListingData,
onPurchaseSuccessful,
onRefreshBundlePurchases,
]
);
useInterval(
() => {
onRefreshBundlePurchases();
},
isPurchasing ? 3900 : null
);
// Listen to the received bundle, to know when a user has just logged in and the received bundles have been loaded.
// In this case, start a timeout to remove the loader and give some time for the store to refresh.
React.useEffect(
() => {
let timeoutId;
(async () => {
if (receivedBundles) {
timeoutId = setTimeout(
() => setIsCheckingPurchasesAfterLogin(false),
3000
);
}
})();
return () => {
clearTimeout(timeoutId);
};
},
[receivedBundles]
);
// If the user has received this particular bundle, either:
// - they just logged in, and already have it, so we close the dialog.
// - they just bought it, we display the success message.
React.useEffect(
() => {
if (receivedBundles) {
const receivedBundle = receivedBundles.find(
bundle => bundle.id === bundleListingData.id
);
if (receivedBundle) {
if (isPurchasing) {
setIsPurchasing(false);
setPurchaseSuccessful(true);
} else if (!purchaseSuccessful) {
onClose();
}
}
}
},
[
receivedBundles,
bundleListingData,
isPurchasing,
onClose,
isCheckingPurchasesAfterLogin,
purchaseSuccessful,
]
);
const dialogContents = !profile
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
<Trans>
The purchase will be linked to your account once done.
</Trans>
</Text>
</ColumnStackLayout>
</>
) : (
<>
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
<Spacer />
<Line justifyContent="center">
<BackgroundText>
<Trans>
Once you're done, come back to GDevelop and the bundle will be
added to your account automatically.
</Trans>
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
),
};
const allowPurchase =
profile &&
!isPurchasing &&
!purchaseSuccessful &&
!isCheckingPurchasesAfterLogin;
const dialogActions = [
<FlatButton
key="cancel"
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
/>,
allowPurchase ? (
<DialogPrimaryButton
key="continue"
primary
label={<Trans>Continue</Trans>}
onClick={onWillPurchase}
/>
) : null,
];
return (
<>
<Dialog
title={<Trans>{bundleListingData.name}</Trans>}
maxWidth="sm"
open
onRequestClose={onClose}
actions={dialogActions}
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}
<Text size="sub-title">{dialogContents.subtitle}</Text>
</LineStackLayout>
{dialogContents.content}
</Dialog>
{displayPasswordPrompt && (
<PasswordPromptDialog
onApply={onStartPurchase}
onClose={() => setDisplayPasswordPrompt(false)}
passwordValue={password}
setPasswordValue={setPassword}
/>
)}
</>
);
};
export default BundlePurchaseDialog;

View File

@@ -0,0 +1,265 @@
// @flow
import * as React from 'react';
import { type FiltersState } from '../../UI/Search/FiltersChooser';
import { type Filters } from '../../Utils/GDevelopServices/Filters';
import { useSearchItem } from '../../UI/Search/UseSearchItem';
import {
listListedBundles,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import { capitalize } from 'lodash';
import { AssetStoreNavigatorContext } from '../AssetStoreNavigator';
import { getBundleListingDataFromUserFriendlySlug } from '../AssetStoreUtils';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
import { t } from '@lingui/macro';
import { sendBundleInformationOpened } from '../../Utils/Analytics/EventSender';
import { BUNDLES_FETCH_TIMEOUT } from '../../Utils/GlobalFetchTimeouts';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
const getBundleListingDataSearchTerms = (bundle: BundleListingData) =>
bundle.name + '\n' + bundle.description + '\n' + bundle.categories.join('\n');
type BundleStoreState = {|
bundleFilters: ?Filters,
fetchBundles: () => void,
bundleListingDatas: ?Array<BundleListingData>,
error: ?Error,
shop: {
bundleListingDatasSearchResults: ?Array<BundleListingData>,
filtersState: FiltersState,
setInitialBundleUserFriendlySlug: string => void,
},
|};
export const initialBundleStoreState: BundleStoreState = {
bundleFilters: null,
fetchBundles: () => {},
bundleListingDatas: null,
error: null,
shop: {
bundleListingDatasSearchResults: null,
filtersState: {
chosenFilters: new Set(),
addFilter: () => {},
removeFilter: () => {},
chosenCategory: null,
setChosenCategory: () => {},
},
setInitialBundleUserFriendlySlug: (
initialBundleUserFriendlySlug: string
) => {},
},
};
export const BundleStoreContext = React.createContext<BundleStoreState>(
initialBundleStoreState
);
type BundleStoreStateProviderProps = {|
children: React.Node,
|};
export const BundleStoreStateProvider = ({
children,
}: BundleStoreStateProviderProps) => {
const shopNavigationState = React.useContext(AssetStoreNavigatorContext);
const {
searchText: shopSearchText,
setSearchText: setShopSearchText,
} = shopNavigationState;
const { limits } = React.useContext(AuthenticatedUserContext);
const [bundleFilters, setBundleFilters] = React.useState<?Filters>(null);
const [error, setError] = React.useState<?Error>(null);
const [
bundleListingDatas,
setBundleListingDatas,
] = React.useState<?Array<BundleListingData>>(null);
const [
initialBundleUserFriendlySlug,
setInitialBundleUserFriendlySlug,
] = React.useState<?string>(null);
const initialBundleOpened = React.useRef<boolean>(false);
const isLoading = React.useRef<boolean>(false);
const { showAlert } = useAlertDialog();
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const fetchBundles = React.useCallback(
() => {
// If the bundles are already loaded, don't load them again.
if (isLoading.current || bundleListingDatas) return;
(async () => {
setError(null);
isLoading.current = true;
try {
const fetchedBundleListingDatas = await listListedBundles();
console.info(
`Loaded ${
fetchedBundleListingDatas ? fetchedBundleListingDatas.length : 0
} bundles from the store.`
);
setBundleListingDatas(fetchedBundleListingDatas);
const defaultTags = fetchedBundleListingDatas.reduce(
(allCategories, bundleListingData) => {
return allCategories.concat(
bundleListingData.categories.map(category =>
capitalize(category)
)
);
},
[]
);
const uniqueDefaultTags = Array.from(new Set(defaultTags));
const bundleFilters: Filters = {
allTags: [],
defaultTags: uniqueDefaultTags,
tagsTree: [],
};
setBundleFilters(bundleFilters);
} catch (error) {
console.error(`Unable to load the bundles from the store:`, error);
setError(error);
}
isLoading.current = false;
})();
},
[bundleListingDatas]
);
// When the bundles are loaded,
// open the bundle with the slug that was asked to be initially loaded.
React.useEffect(
() => {
if (!initialBundleUserFriendlySlug || initialBundleOpened.current) {
// If there is no initial bundle or
// if the bundle was already opened, don't re-open it again even
// if the effect run again.
return;
}
if (bundleListingDatas && initialBundleUserFriendlySlug) {
initialBundleOpened.current = true;
// Open the information page of a the bundle.
const bundleListingData = getBundleListingDataFromUserFriendlySlug({
bundleListingDatas,
userFriendlySlug: initialBundleUserFriendlySlug,
});
if (bundleListingData) {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'web-link',
});
shopNavigationState.openBundleInformationPage({
bundleListingData,
storeSearchText: true,
clearSearchText: false,
});
initialBundleOpened.current = false; // Allow to open the bundle again if the effect run again.
setInitialBundleUserFriendlySlug(null);
return;
}
showAlert({
title: t`Bundle not found`,
message: t`The link to the bundle you've followed seems outdated. Why not take a look at the other bundles in the store?`,
});
}
},
[
bundleListingDatas,
shopNavigationState,
showAlert,
initialBundleUserFriendlySlug,
]
);
React.useEffect(
() => {
if (isLoading.current) return;
const timeoutId = setTimeout(() => {
console.info('Pre-fetching bundles from the store...');
fetchBundles();
}, BUNDLES_FETCH_TIMEOUT);
return () => clearTimeout(timeoutId);
},
[fetchBundles]
);
const bundleListingDatasById = React.useMemo(
() => {
if (!bundleListingDatas) {
return null;
}
const bundleListingDatasById = {};
if (hidePremiumProducts) return bundleListingDatasById;
bundleListingDatas.forEach(bundleListingData => {
const id = bundleListingData.id;
if (bundleListingDatasById[id]) {
console.warn(`Multiple bundles with the same id: ${id}`);
}
bundleListingDatasById[id] = bundleListingData;
});
return bundleListingDatasById;
},
[bundleListingDatas, hidePremiumProducts]
);
const currentPage = shopNavigationState.getCurrentPage();
const bundleListingDatasSearchResultsForShop: ?Array<BundleListingData> = useSearchItem(
bundleListingDatasById,
getBundleListingDataSearchTerms,
shopSearchText,
currentPage.filtersState.chosenCategory,
currentPage.filtersState.chosenFilters
);
const BundleStoreState = React.useMemo(
() => ({
bundleListingDatas: hidePremiumProducts ? [] : bundleListingDatas,
error,
bundleFilters,
fetchBundles,
shop: {
bundleListingDatasSearchResults: hidePremiumProducts
? []
: bundleListingDatasSearchResultsForShop,
searchText: shopSearchText,
setSearchText: setShopSearchText,
filtersState: currentPage.filtersState,
setInitialBundleUserFriendlySlug,
},
}),
[
hidePremiumProducts,
bundleListingDatas,
error,
bundleFilters,
fetchBundles,
bundleListingDatasSearchResultsForShop,
shopSearchText,
setShopSearchText,
currentPage.filtersState,
]
);
return (
<BundleStoreContext.Provider value={BundleStoreState}>
{children}
</BundleStoreContext.Provider>
);
};

View File

@@ -0,0 +1,142 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import {
type BundleListingData,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
type CreditsPackageListingData,
type IncludedRedemptionCode,
} from '../../Utils/GDevelopServices/Shop';
export const renderEstimatedTotalPriceFormatted = ({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {
i18n: I18nType,
bundleListingData: ?BundleListingData,
productListingDatasIncludedInBundle: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>,
redemptionCodesIncludedInBundle: ?Array<IncludedRedemptionCode>,
}): ?string => {
let totalPrice = 0;
if (
!bundleListingData ||
!productListingDatasIncludedInBundle ||
!redemptionCodesIncludedInBundle
)
return null;
const productPrices = bundleListingData.prices;
const bundlePrice = productPrices.find(
price => price.usageType === 'default'
);
const currencyCode = bundlePrice ? bundlePrice.currency : 'USD';
const currencySymbol = currencyCode === 'USD' ? '$' : '€';
for (const product of bundleListingData.includedListableProducts || []) {
if (product.productType === 'ASSET_PACK') {
const listedAssetPack =
productListingDatasIncludedInBundle.find(
assetPack => assetPack.id === product.productId
) || null;
if (listedAssetPack) {
const price = listedAssetPack.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'GAME_TEMPLATE') {
const listedGameTemplate =
productListingDatasIncludedInBundle.find(
gameTemplate => gameTemplate.id === product.productId
) || null;
if (listedGameTemplate) {
const price = listedGameTemplate.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'COURSE') {
const listedCourse = productListingDatasIncludedInBundle.find(
course => course.id === product.productId
);
if (listedCourse) {
const price = listedCourse.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'BUNDLE') {
const listedBundle = productListingDatasIncludedInBundle.find(
bundle => bundle.id === product.productId
);
if (listedBundle) {
const price = listedBundle.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
} else if (product.productType === 'CREDITS_PACKAGE') {
const listedCreditsPackage =
productListingDatasIncludedInBundle.find(
creditsPackage => creditsPackage.id === product.productId
) || null;
if (listedCreditsPackage) {
const price = listedCreditsPackage.prices.find(
price => price.usageType === product.usageType
);
totalPrice += price ? price.value : 0;
}
}
}
if (redemptionCodesIncludedInBundle.length > 0) {
for (const redemptionCode of redemptionCodesIncludedInBundle) {
const planId = redemptionCode.givenSubscriptionPlanId;
if (planId) {
let estimatedAmountInCents = null;
if (redemptionCode.estimatedPrices) {
const estimatedPrice = redemptionCode.estimatedPrices.find(
price => price.currency === currencyCode
);
if (estimatedPrice) {
estimatedAmountInCents = estimatedPrice.value;
}
}
// If no estimated price is provided, guess a mostly correct value
// for backward compatibility.
if (estimatedAmountInCents === null) {
const monthlyEstimatedAmountInCents =
planId === 'gdevelop_silver'
? 599
: planId === 'gdevelop_gold'
? 1099
: planId === 'gdevelop_startup'
? 3499
: 0;
estimatedAmountInCents =
monthlyEstimatedAmountInCents *
Math.max(1, Math.round(redemptionCode.durationInDays / 30));
}
totalPrice += estimatedAmountInCents || 0;
}
}
}
return `${currencySymbol} ${i18n
.number(totalPrice / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.replace(/\D00$/, '')}`;
};

View File

@@ -640,7 +640,7 @@ function NewObjectDialog({
}
>
{currentTab === 'asset-store' && (
<AssetStore ref={assetStore} hideGameTemplates />
<AssetStore ref={assetStore} onlyShowAssets />
)}
{currentTab === 'new-object' &&
(selectedCustomObjectEnumeratedMetadata &&

View File

@@ -6,6 +6,7 @@ import {
redeemPrivateAssetPack,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
getCalloutToGetSubscriptionOrClaimAssetPack,
} from '../../Utils/GDevelopServices/Shop';
import type { MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
@@ -65,6 +66,7 @@ import RaisedButton from '../../UI/RaisedButton';
import PrivateAssetPackPurchaseDialog from './PrivateAssetPackPurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 10;
@@ -164,11 +166,9 @@ type Props = {|
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData,
options?: {|
forceProductPage?: boolean,
|}
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen: (bundleListingData: BundleListingData) => void,
simulateAppStoreProduct?: boolean,
|};
@@ -177,17 +177,21 @@ const PrivateAssetPackInformationPage = ({
privateAssetPackListingDatasFromSameCreator,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
simulateAppStoreProduct,
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const { id, name, sellerId } = privateAssetPackListingData;
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { showAlert } = useAlertDialog();
const {
receivedAssetPacks,
receivedBundles,
profile,
limits,
assetPackPurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
subscription,
@@ -234,15 +238,27 @@ const PrivateAssetPackInformationPage = ({
productId: privateAssetPackListingData
? privateAssetPackListingData.id
: null,
receivedProducts: receivedAssetPacks,
productPurchases: assetPackPurchases,
allProductListingDatas: privateAssetPackListingDatas,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
productPurchases: [
...(assetPackPurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
assetPackPurchases,
bundlePurchases,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
]
);
const isAlreadyReceived = !!userAssetPackPurchaseUsageType;
@@ -251,17 +267,21 @@ const PrivateAssetPackInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
productListingDatas: [...(privateAssetPackListingDatas || [])],
productListingData: privateAssetPackListingData,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
receivedProducts: [...(receivedAssetPacks || [])],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingDatas,
receivedAssetPacks,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateAssetPackListingData,
]
);
@@ -270,16 +290,30 @@ const PrivateAssetPackInformationPage = ({
() =>
getBundlesContainingProductTiles({
product: assetPack,
productListingDatas: privateAssetPackListingDatas,
receivedProducts: receivedAssetPacks,
onProductOpen: product =>
productListingData: privateAssetPackListingData,
productListingDatas: [
...(privateAssetPackListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
assetPack,
privateAssetPackListingData,
privateAssetPackListingDatas,
bundleListingDatas,
receivedAssetPacks,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);

View File

@@ -5,6 +5,7 @@ import {
buyProductWithCredits,
type PrivateGameTemplateListingData,
type PrivateAssetPackListingData,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getPrivateGameTemplate,
@@ -60,6 +61,7 @@ import PrivateGameTemplatePurchaseDialog from './PrivateGameTemplatePurchaseDial
import PasswordPromptDialog from '../PasswordPromptDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import { BundleStoreContext } from '../Bundles/BundleStoreContext';
const cellSpacing = 10;
@@ -123,8 +125,16 @@ const styles = {
type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
privateGameTemplateListingDatasFromSameCreator?: ?Array<PrivateGameTemplateListingData>,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen?: PrivateAssetPackListingData => void,
onAssetPackOpen?: (
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onGameTemplateOpen: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCreateWithGameTemplate?: PrivateGameTemplateListingData => void,
simulateAppStoreProduct?: boolean,
|};
@@ -134,6 +144,7 @@ const PrivateGameTemplateInformationPage = ({
privateGameTemplateListingDatasFromSameCreator,
onGameTemplateOpen,
onAssetPackOpen,
onBundleOpen,
onCreateWithGameTemplate,
simulateAppStoreProduct,
}: Props) => {
@@ -141,11 +152,14 @@ const PrivateGameTemplateInformationPage = ({
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const {
receivedGameTemplates,
receivedBundles,
profile,
limits,
gameTemplatePurchases,
bundlePurchases,
getAuthorizationHeader,
onOpenLoginDialog,
} = React.useContext(AuthenticatedUserContext);
@@ -191,15 +205,27 @@ const PrivateGameTemplateInformationPage = ({
productId: privateGameTemplateListingData
? privateGameTemplateListingData.id
: null,
receivedProducts: receivedGameTemplates,
productPurchases: gameTemplatePurchases,
allProductListingDatas: privateGameTemplateListingDatas,
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
productPurchases: [
...(gameTemplatePurchases || []),
...(bundlePurchases || []),
],
allProductListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
}),
[
gameTemplatePurchases,
bundlePurchases,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
]
);
const isAlreadyReceived = !!userGameTemplatePurchaseUsageType;
@@ -208,33 +234,55 @@ const PrivateGameTemplateInformationPage = ({
() =>
getProductsIncludedInBundleTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
productListingDatas: [...(privateGameTemplateListingDatas || [])],
productListingData: privateGameTemplateListingData,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
receivedProducts: [...(receivedGameTemplates || [])],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingDatas,
receivedGameTemplates,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
privateGameTemplateListingData,
]
);
const bundlesContainingPackTiles = React.useMemo(
const bundlesContainingTemplateTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: gameTemplate,
productListingDatas: privateGameTemplateListingDatas,
receivedProducts: receivedGameTemplates,
onProductOpen: onGameTemplateOpen,
productListingData: privateGameTemplateListingData,
productListingDatas: [
...(privateGameTemplateListingDatas || []),
...(bundleListingDatas || []),
],
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
],
onPrivateAssetPackOpen: onAssetPackOpen
? product => onAssetPackOpen(product, { forceProductPage: true })
: undefined,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
gameTemplate,
privateGameTemplateListingData,
privateGameTemplateListingDatas,
bundleListingDatas,
receivedGameTemplates,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
@@ -589,12 +637,12 @@ const PrivateGameTemplateInformationPage = ({
/>
</Text>
</Column>
{bundlesContainingPackTiles &&
bundlesContainingPackTiles.length ? (
{bundlesContainingTemplateTiles &&
bundlesContainingTemplateTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingPackTiles}
{bundlesContainingTemplateTiles}
<LargeSpacer />
</ColumnStackLayout>
</>

View File

@@ -4,17 +4,23 @@ import { type I18n as I18nType } from '@lingui/core';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
type CreditsPackageListingData,
type Purchase,
} from '../Utils/GDevelopServices/Shop';
import {
type PrivateAssetPack,
type PrivateGameTemplate,
type Bundle,
type Course,
} from '../Utils/GDevelopServices/Asset';
import {
PrivateAssetPackTile,
PrivateGameTemplateTile,
BundleTile,
PromoBundleCard,
CourseTile,
} from './ShopTiles';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { shouldUseAppStoreProduct } from '../Utils/AppStorePurchases';
@@ -27,10 +33,14 @@ import { renderProductPrice } from './ProductPriceTag';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
import { Column } from '../UI/Grid';
import { type MediaItem } from '../UI/ResponsiveMediaGallery';
export const getOtherProductsFromSameAuthorTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
otherProductListingDatasFromSameCreator,
currentProductListingData,
@@ -83,6 +93,16 @@ export const getOtherProductsFromSameAuthorTiles = <
/>
);
}
if (productListingDataFromSameCreator.productType === 'BUNDLE') {
return (
<BundleTile
bundleListingData={productListingDataFromSameCreator}
key={productListingDataFromSameCreator.id}
onSelect={() => onProductOpen(productListingDataFromSameCreator)}
owned={isProductOwned}
/>
);
}
console.error(
'Unexpected product type:',
@@ -93,27 +113,81 @@ export const getOtherProductsFromSameAuthorTiles = <
.filter(Boolean);
};
export const getBundlesContainingProductTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
export const getBundlesContainingProduct = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle | Course
>({
product,
productListingDatas,
}: {|
product: U,
productListingDatas: Array<T>,
|}): T[] => {
// A bundle can either be:
// - an ASSET_PACK or GAME_TEMPLATE that includes other product ids
// - a BUNDLE that includes other products
const bundlesContainingProduct = productListingDatas
.filter(
productListingData =>
(productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(product.id)) ||
(productListingData.productType === 'BUNDLE' &&
productListingData.includedListableProducts &&
productListingData.includedListableProducts.some(
includedProduct => includedProduct.productId === product.id
))
)
// Show types 'BUNDLE' first.
.sort((a, b) => {
if (a.productType === 'BUNDLE' && b.productType !== 'BUNDLE') {
return -1;
}
if (a.productType !== 'BUNDLE' && b.productType === 'BUNDLE') {
return 1;
}
return 0;
});
return bundlesContainingProduct;
};
export const getBundlesContainingProductTiles = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
product,
productListingData,
productListingDatas,
receivedProducts,
onProductOpen,
onPrivateAssetPackOpen,
onPrivateGameTemplateOpen,
onBundleOpen,
}: {|
product: ?U,
productListingData: T,
productListingDatas: ?Array<T>,
receivedProducts: ?Array<U>,
onProductOpen: (product: T) => void,
onPrivateAssetPackOpen?: (
assetPackListingData: PrivateAssetPackListingData
) => void,
onPrivateGameTemplateOpen?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
|}): ?Array<React.Node> => {
if (!product || !productListingDatas) return null;
const bundlesContainingProduct = productListingDatas.filter(
productListingData =>
productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(product.id)
);
const bundlesContainingProduct = getBundlesContainingProduct({
product,
productListingDatas,
});
if (!bundlesContainingProduct.length) return null;
@@ -132,65 +206,157 @@ export const getBundlesContainingProductTiles = <
)
);
const allTiles = ownedBundlesContainingProduct
.map(bundleContainingProduct => {
return (
<PromoBundleCard
productListingData={bundleContainingProduct}
onSelect={() => onProductOpen(bundleContainingProduct)}
owned
key={bundleContainingProduct.id}
/>
);
})
.concat(
notOwnedBundlesContainingProduct.map(bundleContainingProduct => {
const allProductsWithOwnedStatus = [
...ownedBundlesContainingProduct.map(bundleContainingProduct => ({
product: bundleContainingProduct,
owned: true,
})),
...notOwnedBundlesContainingProduct.map(bundleContainingProduct => ({
product: bundleContainingProduct,
owned: false,
})),
];
return allProductsWithOwnedStatus.map(
({ product: bundleContainingProduct, owned }) => {
if (bundleContainingProduct.productType === 'ASSET_PACK') {
if (!onPrivateAssetPackOpen) {
console.error(
'Trying to render a promo ASSET_PACK tile without onPrivateAssetPackOpen handler.'
);
return null;
}
return (
<PromoBundleCard
productListingData={bundleContainingProduct}
onSelect={() => onProductOpen(bundleContainingProduct)}
owned={false}
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onPrivateAssetPackOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
})
);
}
return allTiles;
if (bundleContainingProduct.productType === 'GAME_TEMPLATE') {
if (!onPrivateGameTemplateOpen) {
console.error(
'Trying to render a promo GAME_TEMPLATE tile without onPrivateGameTemplateOpen handler.'
);
return null;
}
return (
<PromoBundleCard
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onPrivateGameTemplateOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
}
if (bundleContainingProduct.productType === 'BUNDLE') {
if (!onBundleOpen) {
console.error(
'Trying to render a promo BUNDLE tile without onBundleOpen handler.'
);
return null;
}
return (
<PromoBundleCard
bundleProductListingData={bundleContainingProduct}
includedProductListingData={productListingData}
onSelect={() => onBundleOpen(bundleContainingProduct)}
owned={owned}
key={bundleContainingProduct.id}
/>
);
}
console.error(
'Unexpected product type for Promo Tile:',
bundleContainingProduct.productType
);
return null;
}
);
};
export const getProductsIncludedInBundleTiles = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
export const getProductsIncludedInBundle = <
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>({
productListingData,
productListingDatas,
}: {|
productListingDatas: Array<T>,
productListingData: T,
|}): ?(T[]) => {
const includedProductIds =
productListingData.includedListableProductIds ||
(productListingData.productType === 'BUNDLE' &&
productListingData.includedListableProducts &&
productListingData.includedListableProducts.map(
includedProduct => includedProduct.productId
));
if (!includedProductIds) return null;
return productListingDatas.filter(productListingData =>
includedProductIds.includes(productListingData.id)
);
};
export const getProductsIncludedInBundleTiles = ({
product,
productListingDatas,
productListingData,
receivedProducts,
onProductOpen,
onPrivateAssetPackOpen,
onPrivateGameTemplateOpen,
onBundleOpen,
onCourseOpen,
}: {|
product: ?U,
productListingDatas: ?Array<T>,
productListingData: T,
receivedProducts: ?Array<U>,
onProductOpen: (product: T) => void,
product: ?PrivateAssetPack | PrivateGameTemplate | Bundle | Course,
productListingDatas: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
>,
productListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData,
receivedProducts: ?Array<
PrivateAssetPack | PrivateGameTemplate | Bundle | Course
>,
onPrivateAssetPackOpen?: (
assetPackListingData: PrivateAssetPackListingData
) => void,
onPrivateGameTemplateOpen?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCourseOpen?: (courseListingData: CourseListingData) => void,
|}): ?Array<React.Node> => {
if (!product || !productListingDatas) return null;
const includedProductIds = productListingData.includedListableProductIds;
if (!includedProductIds) return null;
const productsIncludedInBundle = getProductsIncludedInBundle({
productListingData,
productListingDatas,
});
return includedProductIds
.map(includedProductId => {
const includedProductListingData = productListingDatas.find(
privateProductListingData =>
privateProductListingData.id === includedProductId
);
if (!includedProductListingData) {
console.warn(`Included product ${includedProductId} not found`);
return null;
}
if (!productsIncludedInBundle || !productsIncludedInBundle.length) {
return null;
}
return productsIncludedInBundle
.map(includedProductListingData => {
const isProductOwned =
!!receivedProducts &&
!!receivedProducts.find(
@@ -198,22 +364,70 @@ export const getProductsIncludedInBundleTiles = <
);
if (includedProductListingData.productType === 'GAME_TEMPLATE') {
if (!onPrivateGameTemplateOpen) {
console.error(
'Trying to render a GAME_TEMPLATE tile without onPrivateGameTemplateOpen handler.'
);
return null;
}
return (
<PrivateGameTemplateTile
privateGameTemplateListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onProductOpen(includedProductListingData)}
onSelect={() =>
onPrivateGameTemplateOpen(includedProductListingData)
}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'ASSET_PACK') {
if (!onPrivateAssetPackOpen) {
console.error(
'Trying to render an ASSET_PACK tile without onPrivateAssetPackOpen handler.'
);
return null;
}
return (
<PrivateAssetPackTile
assetPackListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onProductOpen(includedProductListingData)}
onSelect={() => onPrivateAssetPackOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'BUNDLE') {
if (!onBundleOpen) {
console.error(
'Trying to render a BUNDLE tile without onBundleOpen handler.'
);
return null;
}
return (
<BundleTile
bundleListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onBundleOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
}
if (includedProductListingData.productType === 'COURSE') {
if (!onCourseOpen) {
console.error(
'Trying to render a COURSE tile without onCourseOpen handler.'
);
return null;
}
return (
<CourseTile
courseListingData={includedProductListingData}
key={includedProductListingData.id}
onSelect={() => onCourseOpen(includedProductListingData)}
owned={isProductOwned}
/>
);
@@ -234,8 +448,11 @@ export const getProductsIncludedInBundleTiles = <
// In case the user has both, we consider the product purchase as the
// most important one.
export const getUserProductPurchaseUsageType = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
productId,
receivedProducts,
@@ -262,22 +479,36 @@ export const getUserProductPurchaseUsageType = <
);
if (!productPurchase) {
// It is possible the user has the product as part of a bundle.
const productBundleListingData = allProductListingDatas.find(
productListingData =>
productListingData.includedListableProductIds &&
productListingData.includedListableProductIds.includes(productId)
const bundlesIncludingProduct = getBundlesContainingProduct({
product: currentReceivedProduct,
productListingDatas: allProductListingDatas,
});
if (!bundlesIncludingProduct.length) return null;
// We look at all the purchases of the bundles that include the product.
const receivedProductBundlePurchases = productPurchases.filter(
productPurchase =>
bundlesIncludingProduct.some(
bundleListingData =>
bundleListingData.id === productPurchase.productId
)
);
if (productBundleListingData) {
const receivedProductBundlePurchase = productPurchases.find(
productPurchase =>
productPurchase.productId === productBundleListingData.id
);
if (receivedProductBundlePurchase) {
return receivedProductBundlePurchase.usageType;
}
if (!receivedProductBundlePurchases.length) {
return null;
}
return null;
// We don't really know which usage type to return, so we look at the first purchase.
if (bundlesIncludingProduct[0].productType === 'BUNDLE') {
// In a bundle, we look for the usage type of the included product.
const includedProduct = (
bundlesIncludingProduct[0].includedListableProducts || []
).find(includedProduct => includedProduct.productId === productId);
return includedProduct ? includedProduct.usageType : null;
}
// Otherwise, we return the usage type of the purchase. (when included in an ASSET_PACK or GAME_TEMPLATE)
return receivedProductBundlePurchases[0].usageType;
}
return productPurchase.usageType;
@@ -288,6 +519,7 @@ export const PurchaseProductButtons = <
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
| BundleListingData
>({
productListingData,
selectedUsageType,
@@ -305,17 +537,20 @@ export const PurchaseProductButtons = <
i18n: I18nType,
isAlreadyReceived: boolean,
onClickBuy: () => void | Promise<void>,
onClickBuyWithCredits: () => void | Promise<void>,
onClickBuyWithCredits?: () => void | Promise<void>,
|}) => {
const { authenticated } = React.useContext(AuthenticatedUserContext);
const shouldUseOrSimulateAppStoreProduct =
simulateAppStoreProduct || shouldUseAppStoreProduct();
const productType = productListingData.productType.toLowerCase();
let creditPrice = productListingData.creditPrices.find(
price => price.usageType === selectedUsageType
);
if (!creditPrice) {
let creditPrice =
productListingData.productType !== 'BUNDLE'
? productListingData.creditPrices.find(
price => price.usageType === selectedUsageType
)
: null;
if (!creditPrice && productListingData.productType !== 'BUNDLE') {
// We're probably switching from one product to another, and the usage type is not available.
// Let's reset it.
onUsageTypeChange(productListingData.prices[0].usageType);
@@ -339,7 +574,7 @@ export const PurchaseProductButtons = <
plainText: true,
});
return shouldUseOrSimulateAppStoreProduct ? (
return shouldUseOrSimulateAppStoreProduct && creditPrice ? (
<LineStackLayout>
<RaisedButton
primary
@@ -358,13 +593,15 @@ export const PurchaseProductButtons = <
</LineStackLayout>
) : (
<LineStackLayout>
<FlatButton
primary
label={<Trans>Buy for {creditPrice.amount} credits</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
leftIcon={<Coin fontSize="small" />}
/>
{creditPrice && (
<FlatButton
primary
label={<Trans>Buy for {creditPrice.amount} credits</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
leftIcon={<Coin fontSize="small" />}
/>
)}
<RaisedButton
primary
label={<Trans>Buy for {formattedProductPriceText}</Trans>}
@@ -408,39 +645,45 @@ export const OpenProductButton = <
};
export const getProductMediaItems = <
T: PrivateAssetPackListingData | PrivateGameTemplateListingData,
U: PrivateAssetPack | PrivateGameTemplate
T:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData,
U: PrivateAssetPack | PrivateGameTemplate | Bundle
>({
productListingData,
product,
additionalThumbnails,
shouldSimulateAppStoreProduct,
}: {|
productListingData: T,
product: ?U,
additionalThumbnails?: string[],
shouldSimulateAppStoreProduct?: boolean,
|}) => {
|}): MediaItem[] => {
if (!product) return [];
const shouldUseOrSimulateAppStoreProduct =
shouldSimulateAppStoreProduct || shouldUseAppStoreProduct();
const mediaItems = [
{
kind: 'image',
url:
(shouldUseOrSimulateAppStoreProduct &&
productListingData.appStoreThumbnailUrls &&
productListingData.appStoreThumbnailUrls[0]) ||
// Deduplicate in case we have the same image in the thumbnailUrls and previewImageUrls.
const uniqueImageUrls: string[] = [
...new Set([
(shouldUseOrSimulateAppStoreProduct &&
productListingData.appStoreThumbnailUrls &&
productListingData.appStoreThumbnailUrls[0]) ||
productListingData.thumbnailUrls[0],
},
...product.previewImageUrls.map(url => ({
kind: 'image',
url,
})),
...product.previewImageUrls,
...(additionalThumbnails || []),
]),
];
const uniqueMediaItems: MediaItem[] = uniqueImageUrls.map((url: string) => ({
kind: 'image',
url,
}));
if (product.previewSoundUrls) {
mediaItems.push(
uniqueMediaItems.push(
...product.previewSoundUrls.map(url => ({
kind: 'audio',
url,
@@ -448,5 +691,5 @@ export const getProductMediaItems = <
);
}
return mediaItems;
return uniqueMediaItems;
};

View File

@@ -9,6 +9,7 @@ import {
type PrivateGameTemplateListingData,
type CreditsPackageListingData,
type CourseListingData,
type BundleListingData,
} from '../Utils/GDevelopServices/Shop';
import {
shouldUseAppStoreProduct,
@@ -20,7 +21,6 @@ import Text from '../UI/Text';
import { Column } from '../UI/Grid';
import CheckCircle from '../UI/CustomSvgIcons/CheckCircle';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import type { GDevelopTheme } from '../UI/Theme';
const styles = {
icon: {
@@ -45,7 +45,8 @@ type FormatProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
i18n: I18nType,
usageType?: string,
plainText?: boolean,
@@ -59,10 +60,11 @@ export const renderProductPrice = ({
plainText,
showBothPrices,
}: FormatProps): React.Node => {
// Only use the app store product if it's a credits package.
// For Credits packages & Bundles, on mobile, only show the app store product price.
if (
shouldUseAppStoreProduct() &&
productListingData.productType === 'CREDITS_PACKAGE'
(productListingData.productType === 'CREDITS_PACKAGE' ||
productListingData.productType === 'BUNDLE')
) {
const appStoreProduct = getAppStoreProduct(
productListingData.appStoreProductId
@@ -70,21 +72,15 @@ export const renderProductPrice = ({
return appStoreProduct ? appStoreProduct.price : '';
}
const creditPrices =
productListingData.productType !== 'CREDITS_PACKAGE'
? productListingData.creditPrices || []
: [];
const creditPrices = productListingData.creditPrices || [];
const creditPrice = usageType
? creditPrices.find(price => price.usageType === usageType)
: creditPrices.length > 0
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for non-credits packages.
if (
shouldUseAppStoreProduct() &&
productListingData.productType !== 'CREDITS_PACKAGE'
) {
// If we're on mobile, only show credits prices for other packages.
if (shouldUseAppStoreProduct()) {
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -163,23 +159,17 @@ type ProductPriceOrOwnedProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
i18n: I18nType,
gdevelopTheme: GDevelopTheme,
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
|};
export const getProductPriceOrOwnedLabel = ({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
export const OwnedLabel = () => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<LineStackLayout noMargin alignItems="center">
<CheckCircle
style={{
@@ -190,6 +180,18 @@ export const getProductPriceOrOwnedLabel = ({
<Trans>Owned</Trans>
</Text>
</LineStackLayout>
);
};
export const getProductPriceOrOwnedLabel = ({
i18n,
productListingData,
usageType,
owned,
showBothPrices,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<OwnedLabel />
) : (
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
@@ -200,7 +202,8 @@ type ProductPriceTagProps = {|
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CreditsPackageListingData
| CourseListingData,
| CourseListingData
| BundleListingData,
usageType?: string,
/**
* To be used when the component is over an element for which
@@ -216,13 +219,11 @@ const ProductPriceTag = ({
withOverlay,
owned,
}: ProductPriceTagProps) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => {
const label = getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData,
usageType,
owned,

View File

@@ -9,6 +9,8 @@ import {
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
@@ -17,7 +19,10 @@ import { textEllipsisStyle } from '../UI/TextEllipsis';
import { Column, Line, Spacer } from '../UI/Grid';
import Text from '../UI/Text';
import { Trans } from '@lingui/macro';
import ProductPriceTag, { renderProductPrice } from './ProductPriceTag';
import ProductPriceTag, {
OwnedLabel,
renderProductPrice,
} from './ProductPriceTag';
import { AssetCard } from './AssetCard';
import FolderIcon from '../UI/CustomSvgIcons/Folder';
import FlatButton from '../UI/FlatButton';
@@ -28,6 +33,7 @@ import Skeleton from '@material-ui/lab/Skeleton';
import EmptyMessage from '../UI/EmptyMessage';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CardWidget } from '../MainFrame/EditorContainers/HomePage/CardWidget';
import { selectMessageByLocale } from '../Utils/i18n/MessageByLocale';
const styles = {
priceTagContainer: {
@@ -304,13 +310,20 @@ export const PrivateAssetPackTile = ({
};
export const PromoBundleCard = ({
productListingData,
bundleProductListingData,
includedProductListingData,
onSelect,
owned,
}: {|
productListingData:
bundleProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData,
| PrivateGameTemplateListingData
| BundleListingData,
includedProductListingData:
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| CourseListingData
| BundleListingData,
onSelect: () => void,
owned: boolean,
|}) => {
@@ -328,52 +341,44 @@ export const PromoBundleCard = ({
<ResponsiveLineStackLayout expand noMargin noResponsiveLandscape>
<div style={styles.promoImageContainer}>
<CorsAwareImage
key={productListingData.name}
key={bundleProductListingData.name}
style={{
...styles.previewImage,
...styles.promoImage,
}}
src={productListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${productListingData.name}`}
src={bundleProductListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${
bundleProductListingData.name
}`}
loading="lazy"
/>
</div>
<Column expand alignItems="flex-start" justifyContent="center">
<Text color="primary" size="section-title">
{!owned ? (
<Trans>Get {productListingData.description}!</Trans>
) : productListingData.productType === 'ASSET_PACK' ? (
<Trans>You already own this pack!</Trans>
<Trans>Get {bundleProductListingData.name}!</Trans>
) : (
<Trans>You already own this template!</Trans>
<Trans>
You already own {bundleProductListingData.name}!
</Trans>
)}
</Text>
<Text color="primary" size="body2">
{!owned ? (
productListingData.productType === 'ASSET_PACK' ? (
<Trans>
This pack is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
This template is included in this bundle for{' '}
{renderProductPrice({
i18n,
productListingData,
plainText: true,
})}
!
</Trans>
)
<Trans>
{includedProductListingData.name} is included in this
bundle for{' '}
{renderProductPrice({
i18n,
productListingData: bundleProductListingData,
plainText: true,
})}
!
</Trans>
) : (
<Trans>
It is included in the bundle {productListingData.name}.
{includedProductListingData.name} is included in the
bundle {bundleProductListingData.name}.
</Trans>
)}
</Text>
@@ -511,6 +516,136 @@ export const PrivateGameTemplateTile = ({
);
};
export const CourseTile = ({
courseListingData,
onSelect,
style,
owned,
disabled,
}: {|
courseListingData: CourseListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={courseListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={courseListingData.thumbnailUrls[0]}
alt={`Preview image of course ${courseListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
productListingData={courseListingData}
withOverlay
owned={owned}
/>
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2" noMargin={isMobile}>
{courseListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
);
};
export const BundleTile = ({
bundleListingData,
onSelect,
style,
owned,
disabled,
}: {|
bundleListingData: BundleListingData,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
owned: boolean,
disabled?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<I18n>
{({ i18n }) => (
<GridListTile style={style}>
<CardWidget
onClick={!disabled ? onSelect : undefined}
size="large"
disabled={disabled}
noBorder
>
<Column noMargin expand noOverflowParent>
<CorsAwareImage
key={bundleListingData.name}
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={bundleListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${bundleListingData.name}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
{bundleListingData ? (
<ProductPriceTag
productListingData={bundleListingData}
withOverlay
owned={owned}
/>
) : (
<OwnedLabel />
)}
</div>
<Column>
{isMobile && <Spacer />}
<Line justifyContent="flex-start" noMargin>
<Text
style={styles.packTitle}
size="body2"
noMargin={isMobile}
>
{bundleListingData.nameByLocale
? selectMessageByLocale(
i18n,
bundleListingData.nameByLocale
)
: bundleListingData.name}
</Text>
</Line>
</Column>
</Column>
</CardWidget>
</GridListTile>
)}
</I18n>
);
};
export const ExampleTile = ({
exampleShortHeader,
onSelect,

View File

@@ -11,6 +11,8 @@ import {
sendAssetOpened,
sendAssetPackInformationOpened,
sendAssetPackOpened,
sendBundleInformationOpened,
sendCourseInformationOpened,
sendGameTemplateInformationOpened,
} from '../Utils/Analytics/EventSender';
import {
@@ -18,12 +20,15 @@ import {
type PublicAssetPack,
type PublicAssetPacks,
type PrivateAssetPack,
type Course,
doesAssetPackContainAudio,
isAssetPackAudioOnly,
} from '../Utils/GDevelopServices/Asset';
import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
type CourseListingData,
} from '../Utils/GDevelopServices/Shop';
import { type SearchBarInterface } from '../UI/SearchBar';
import { AssetStoreFilterPanel } from './AssetStoreFilterPanel';
@@ -61,14 +66,18 @@ import PrivateGameTemplateInformationPage from './PrivateGameTemplates/PrivateGa
import { PrivateGameTemplateStoreContext } from './PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetSwappingAssetStoreSearchFilter } from './AssetStoreSearchFilter';
import { delay } from '../Utils/Delay';
import { BundleStoreContext } from './Bundles/BundleStoreContext';
import BundleInformationPage from './Bundles/BundleInformationPage';
type Props = {|
hideGameTemplates?: boolean, // TODO: if we add more options, use an array instead.
onlyShowAssets?: boolean, // TODO: if we add more options, use an array instead.
displayPromotions?: boolean,
onOpenPrivateGameTemplateListingData?: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onOpenProfile?: () => void,
receivedCourses?: ?Array<Course>,
onCourseOpen?: (courseId: string) => void,
assetSwappedObject?: ?gdObject,
minimalUI?: boolean,
|};
@@ -105,10 +114,12 @@ const identifyAssetPackKind = ({
export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
(
{
hideGameTemplates,
onlyShowAssets,
displayPromotions,
onOpenPrivateGameTemplateListingData,
onOpenProfile,
receivedCourses,
onCourseOpen,
assetSwappedObject,
minimalUI,
}: Props,
@@ -175,6 +186,13 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
fetchGameTemplates,
shop: { privateGameTemplateListingDatasSearchResults },
} = React.useContext(PrivateGameTemplateStoreContext);
const {
bundleListingDatas,
error: bundleStoreError,
fetchBundles,
shop: { bundleListingDatasSearchResults },
} = React.useContext(BundleStoreContext);
const currentPage = shopNavigationState.getCurrentPage();
const {
openedAssetPack,
@@ -182,6 +200,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
openedShopCategory,
openedPrivateAssetPackListingData,
openedPrivateGameTemplateListingData,
openedBundleListingData,
filtersState,
} = currentPage;
const isOnHomePage = isHomePage(currentPage);
@@ -235,11 +254,13 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
() => {
fetchAssetsAndFilters();
fetchGameTemplates();
fetchBundles();
},
[fetchAssetsAndFilters, fetchGameTemplates]
[fetchAssetsAndFilters, fetchGameTemplates, fetchBundles]
);
const storeError = assetStoreError || privateGameTemplateStoreError;
const storeError =
assetStoreError || privateGameTemplateStoreError || bundleStoreError;
const reApplySearchTextIfNeeded = React.useCallback(
(page: AssetStorePageState): boolean => {
@@ -257,6 +278,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
!openedAssetShortHeader && // Don't show filters on asset page.
!openedPrivateAssetPackListingData && // Don't show filters on private asset pack information page.
!openedPrivateGameTemplateListingData && // Don't show filters on private game template information page.
!openedBundleListingData && // Don't show filters on bundle information page.
!(
openedAssetPack &&
openedAssetPack.content &&
@@ -473,6 +495,35 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
[saveScrollPosition, shopNavigationState]
);
const selectBundle = React.useCallback(
(bundleListingData: BundleListingData) => {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'store',
});
saveScrollPosition();
shopNavigationState.openBundleInformationPage({
bundleListingData,
storeSearchText: true,
clearSearchText: true,
});
},
[saveScrollPosition, shopNavigationState]
);
const selectCourse = React.useCallback(
(courseListingData: CourseListingData) => {
sendCourseInformationOpened({
courseName: courseListingData.name,
courseId: courseListingData.id,
source: 'store',
});
if (onCourseOpen) onCourseOpen(courseListingData.id);
},
[onCourseOpen]
);
const selectShopCategory = React.useCallback(
(category: string) => {
saveScrollPosition();
@@ -535,9 +586,10 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
React.useEffect(
() => {
if (
hideGameTemplates &&
onlyShowAssets &&
(openedShopCategory === gameTemplatesCategoryId ||
openedPrivateGameTemplateListingData)
openedPrivateGameTemplateListingData ||
openedBundleListingData)
) {
shopNavigationState.openHome();
}
@@ -545,7 +597,8 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
[
openedShopCategory,
openedPrivateGameTemplateListingData,
hideGameTemplates,
openedBundleListingData,
onlyShowAssets,
shopNavigationState,
]
);
@@ -633,7 +686,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
<Column expand useFullHeight noMargin>
<SearchBar
placeholder={
hideGameTemplates ? t`Search assets` : t`Search the shop`
onlyShowAssets ? t`Search assets` : t`Search the shop`
}
value={searchText}
onChange={(newValue: string) => {
@@ -762,7 +815,8 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
</PlaceholderError>
) : publicAssetPacks &&
privateAssetPackListingDatas &&
privateGameTemplateListingDatas ? (
privateGameTemplateListingDatas &&
bundleListingDatas ? (
<AssetsHome
ref={assetsHome}
publicAssetPacks={publicAssetPacks}
@@ -770,12 +824,14 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
privateGameTemplateListingDatas={
privateGameTemplateListingDatas
}
bundleListingDatas={bundleListingDatas}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onCategorySelection={selectShopCategory}
openedShopCategory={openedShopCategory}
hideGameTemplates={hideGameTemplates}
onlyShowAssets={onlyShowAssets}
displayPromotions={displayPromotions}
onOpenProfile={onOpenProfile}
/>
@@ -797,6 +853,9 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
? []
: privateGameTemplateListingDatasSearchResults
}
bundleListingDatas={
assetSwappedObject ? [] : bundleListingDatasSearchResults
}
assetShortHeaders={assetShortHeadersSearchResults}
ref={assetsList}
error={storeError}
@@ -804,10 +863,11 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onFolderSelection={selectFolder}
onGoBackToFolderIndex={goBackToFolderIndex}
currentPage={shopNavigationState.getCurrentPage()}
hideGameTemplates={hideGameTemplates}
onlyShowAssets={onlyShowAssets}
hideDetails={!!assetSwappedObject && !!minimalUI}
/>
) : // Do not show the asset details if we're swapping an asset.
@@ -826,6 +886,7 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
privateAssetPackListingData={openedPrivateAssetPackListingData}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateAssetPackListingDatasFromSameCreator={
privateAssetPackListingDatasFromSameCreator
}
@@ -843,10 +904,20 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
}}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateGameTemplateListingDatasFromSameCreator={
privateGameTemplateListingDatasFromSameCreator
}
/>
) : !!openedBundleListingData ? (
<BundleInformationPage
bundleListingData={openedBundleListingData}
receivedCourses={receivedCourses}
onBundleOpen={selectBundle}
onGameTemplateOpen={selectPrivateGameTemplate}
onAssetPackOpen={selectPrivateAssetPack}
onCourseOpen={selectCourse}
/>
) : null}
{canShowFiltersPanel && (
<ResponsivePaperOrDrawer

View File

@@ -144,18 +144,18 @@ const ChapterTile = ({
type Props = {|
course: ?Course,
courseChapters: ?(CourseChapter[]),
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onDisplayCourse: boolean => void,
onDisplayCourse: () => void,
|};
const CoursePreviewBanner = ({
course,
courseChapters,
getCourseChapters,
getCourseCompletion,
getCourseChapterCompletion,
onDisplayCourse,
@@ -168,6 +168,7 @@ const CoursePreviewBanner = ({
const chapterTiles = React.useMemo(
() => {
const courseChapters = course ? getCourseChapters(course.id) : null;
if (!course || !courseChapters) {
return new Array(numberOfTilesToDisplay).fill(0).map((_, index) => {
return (
@@ -256,7 +257,7 @@ const CoursePreviewBanner = ({
},
[
course,
courseChapters,
getCourseChapters,
getCourseChapterCompletion,
numberOfTilesToDisplay,
gdevelopTheme,
@@ -315,7 +316,7 @@ const CoursePreviewBanner = ({
{({ i18n }) => (
<Paper background="medium" variant="outlined">
<ButtonBase
onClick={() => onDisplayCourse(true)}
onClick={onDisplayCourse}
component="div"
style={
isMobile && !isLandscape
@@ -457,7 +458,7 @@ const CoursePreviewBanner = ({
</LineStackLayout>
<RaisedButton
primary
disabled={!course || !courseChapters}
disabled={!course}
label={
!courseCompletion ||
courseCompletion.percentage === 0 ? (
@@ -468,7 +469,7 @@ const CoursePreviewBanner = ({
<Trans>Keep learning</Trans>
)
}
onClick={() => onDisplayCourse(true)}
onClick={onDisplayCourse}
/>
</ResponsiveLineStackLayout>
</Paper>

View File

@@ -56,10 +56,12 @@ export const processEditorFunctionCalls = async ({
ignore,
ensureExtensionInstalled,
searchAndInstallAsset,
}: ProcessEditorFunctionCallsOptions): Promise<
Array<EditorFunctionCallResult>
> => {
}: ProcessEditorFunctionCallsOptions): Promise<{|
results: Array<EditorFunctionCallResult>,
createdSceneNames: Array<string>,
|}> => {
const results: Array<EditorFunctionCallResult> = [];
const createdSceneNames: Array<string> = [];
for (const functionCall of functionCalls) {
const call_id = functionCall.call_id;
@@ -147,12 +149,8 @@ export const processEditorFunctionCalls = async ({
});
if (success && args) {
if (typeof args.scene_name === 'string') {
editorCallbacks.onOpenLayout(args.scene_name, {
openEventsEditor: true,
openSceneEditor: true,
focusWhenOpened: 'none',
});
if (name === 'create_scene' && typeof args.scene_name === 'string') {
createdSceneNames.push(args.scene_name);
}
}
} catch (error) {
@@ -165,5 +163,5 @@ export const processEditorFunctionCalls = async ({
}
}
return results;
return { results, createdSceneNames };
};

View File

@@ -43,6 +43,10 @@ type SimplifiedScene = {|
|};
type SimplifiedProject = {|
properties: {|
gameResolutionWidth: number,
gameResolutionHeight: number,
|},
globalObjects: Array<SimplifiedObject>,
globalObjectGroups: Array<SimplifiedObjectGroup>,
scenes: Array<SimplifiedScene>,
@@ -328,6 +332,10 @@ export const makeSimplifiedProjectBuilder = (gd: libGDevelop) => {
// Filter extensions to only include extensions from the project.
const simplifiedProject: SimplifiedProject = {
properties: {
gameResolutionWidth: project.getGameResolutionWidth(),
gameResolutionHeight: project.getGameResolutionHeight(),
},
globalObjects,
globalObjectGroups: getSimplifiedObjectGroups(
project.getObjects().getObjectGroups(),

View File

@@ -58,6 +58,10 @@ describe('SimplifiedProject', () => {
},
],
"globalVariables": Array [],
"properties": Object {
"gameResolutionHeight": 600,
"gameResolutionWidth": 800,
},
"scenes": Array [
Object {
"instancesOnSceneDescription": "On the scene, there are:

View File

@@ -22,6 +22,10 @@ import { type SimplifiedBehavior } from './SimplifiedProject/SimplifiedProject';
import { ColumnStackLayout } from '../UI/Layout';
import Text from '../UI/Text';
import { applyVariableChange } from './ApplyVariableChange';
import {
addDefaultLightToAllLayers,
addDefaultLightToLayer,
} from '../ProjectCreation/CreateProject';
const gd: libGDevelop = global.gd;
@@ -59,6 +63,7 @@ export type EditorFunctionGenericOutput = {|
sharedProperties?: any,
instances?: any,
behaviors?: Array<SimplifiedBehavior>,
animationNames?: string,
generatedEventsErrorDiagnostics?: string,
aiGeneratedEventId?: string,
|};
@@ -515,6 +520,7 @@ const inspectObjectProperties: EditorFunction = {
})
.filter(Boolean);
// Also include information about behaviors:
const behaviors = object
.getAllBehaviorNames()
.toJSArray()
@@ -526,12 +532,29 @@ const inspectObjectProperties: EditorFunction = {
};
});
return {
// Also include information about animations:
const animationNames = mapFor(
0,
objectConfiguration.getAnimationsCount(),
i => {
return (
objectConfiguration.getAnimationName(i) ||
`(animation without name, animation index is: ${i})`
);
}
);
const output: EditorFunctionGenericOutput = {
success: true,
objectName: object_name,
properties,
behaviors,
};
if (animationNames.length > 0) {
output.animationNames = animationNames.join(', ');
}
return output;
},
};
@@ -2325,7 +2348,8 @@ const createScene: EditorFunction = {
if (project.hasLayoutNamed(scene_name)) {
const scene = project.getLayout(scene_name);
if (include_ui_layer && !scene.hasLayerNamed('UI')) {
scene.insertNewLayer('UI', 1);
scene.insertNewLayer('UI', 0);
addDefaultLightToLayer(scene.getLayer('UI'));
return makeGenericSuccess(
`Scene with name "${scene_name}" already exists, no need to re-create it. A layer called "UI" was added to it.`
);
@@ -2347,6 +2371,7 @@ const createScene: EditorFunction = {
);
scene.setBackgroundColor(colorAsRgb[0], colorAsRgb[1], colorAsRgb[2]);
}
addDefaultLightToAllLayers(scene);
return makeGenericSuccess(
include_ui_layer

View File

@@ -3,8 +3,10 @@
import * as React from 'react';
import InAppTutorialOrchestrator from './InAppTutorialOrchestrator';
import { type EditorIdentifier } from '../Utils/GDevelopServices/InAppTutorial';
import { type EditorTabsState } from '../MainFrame/EditorTabs/EditorTabsHandler';
import { getCurrentTab } from '../MainFrame/EditorTabs/EditorTabsHandler';
import {
getCurrentTabForPane,
type EditorTabsState,
} from '../MainFrame/EditorTabs/EditorTabsHandler';
type Props = {|
editorTabs: EditorTabsState,
@@ -21,7 +23,7 @@ const useInAppTutorialOrchestrator = ({ editorTabs }: Props) => {
React.useEffect(
() => {
const currentTab = getCurrentTab(editorTabs);
const currentTab = getCurrentTabForPane(editorTabs, 'center');
if (!currentTab) {
setCurrentEditor(null);
return;

View File

@@ -33,7 +33,7 @@ describe('EnumerateInstructions', () => {
expect.objectContaining({
displayedName: 'Animation finished',
fullGroupName:
'General Animatable capability Animations and images',
'General Objects with animations Animations and images',
type: 'AnimatableCapability::AnimatableBehavior::HasAnimationEnded',
})
);

View File

@@ -29,6 +29,7 @@ export type EditorContainerExtraProps = {|
// Ask AI
mode?: 'chat' | 'agent',
aiRequestId?: string | null,
|};
export type SceneEventsOutsideEditorChanges = {|
@@ -43,7 +44,7 @@ export type RenderEditorContainerProps = {|
fileMetadata: ?FileMetadata,
storageProvider: StorageProvider,
setToolbar: (?React.Node) => void,
hideTabsTitleBarAndEditorToolbar: (hidden: boolean) => void,
setGamesPlatformFrameShown: ({| shown: boolean, isMobile: boolean |}) => void,
// Some optional extra props to pass to the rendered editor
extraEditorProps: ?EditorContainerExtraProps,
@@ -86,7 +87,11 @@ export type RenderEditorContainerProps = {|
variantName: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
// Events function management:
onLoadEventsFunctionsExtensions: () => Promise<void>,

View File

@@ -0,0 +1,303 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import SectionContainer, { SectionRow } from '../SectionContainer';
import { Column, Line } from '../../../../UI/Grid';
import BundlePageHeader from './BundlePageHeader';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import type { CourseCompletion } from '../UseCourses';
import {
getBundle,
type Bundle,
type Course,
} from '../../../../Utils/GDevelopServices/Asset';
import {
type PrivateAssetPackListingData,
type BundleListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors';
import { Trans } from '@lingui/macro';
import AlertMessage from '../../../../UI/AlertMessage';
import {
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
} from '../../../../AssetStore/ProductPageHelper';
import { PrivateGameTemplateStoreContext } from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { GridList, GridListTile } from '@material-ui/core';
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import Text from '../../../../UI/Text';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import CourseCard from './CourseCard';
const getColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 6;
default:
return 3;
}
};
const cellSpacing = 10;
const MAX_COLUMNS = getColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
};
type Props = {|
bundleListingData: BundleListingData,
onBack: () => void,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
onBundleOpen: BundleListingData => void,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen: (
privateAssetPackListingData: PrivateAssetPackListingData
) => void,
onCourseOpen: CourseListingData => void,
courses: ?Array<Course>,
receivedCourses: ?Array<Course>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
|};
const BundlePage = ({
bundleListingData,
onBack,
getSubscriptionPlansWithPricingSystems,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
courses,
receivedCourses,
getCourseCompletion,
}: Props) => {
const { windowSize, isLandscape } = useResponsiveWindowSize();
const { bundleListingDatas } = React.useContext(BundleStoreContext); // If archived, should use the one passed.
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const {
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
} = React.useContext(AuthenticatedUserContext);
const [bundle, setBundle] = React.useState<?Bundle>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const courseAndTheirListingDataIncludedInBundle = React.useMemo(
(): Array<{|
course: Course,
courseListingData: CourseListingData,
|}> | null => {
if (!bundle || !bundleListingData || !courses) return null;
const productListingDatasInBundle = getProductsIncludedInBundle({
productListingData: bundleListingData,
productListingDatas: [...(listedCourses || [])],
});
if (!productListingDatasInBundle) return null;
// $FlowIgnore - Flow doesn't understand that we have filtered the products to only include courses.
const courseListingDatasInBundle: CourseListingData[] = productListingDatasInBundle.filter(
productListingData => productListingData.productType === 'COURSE'
);
return (courseListingDatasInBundle || [])
.map(courseListingData => {
const course = courses.find(
course => course.id === courseListingData.id
);
if (!course) return null;
return {
course,
courseListingData,
};
})
.filter(Boolean);
},
[bundle, bundleListingData, listedCourses, courses]
);
const productsExceptCoursesIncludedInBundleTiles = React.useMemo(
() =>
bundle && bundleListingData
? getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
],
onPrivateAssetPackOpen: onAssetPackOpen,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
})
: null,
[
bundle,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
bundleListingData,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
]
);
React.useEffect(
() => {
(async () => {
try {
const bundle = await getBundle(bundleListingData.id);
setBundle(bundle);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
);
if (extractedStatusAndCode && extractedStatusAndCode.status === 404) {
setErrorText(
<Trans>
Bundle not found - An error occurred, please try again later.
</Trans>
);
} else {
setErrorText(
<Trans>An error occurred, please try again later.</Trans>
);
}
}
})();
},
[bundleListingData.id]
);
if (errorText) {
return (
<SectionContainer flexBody backAction={onBack}>
<SectionRow expand>
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
</SectionRow>
</SectionContainer>
);
}
if (!bundleListingData || !bundle) {
return (
<SectionContainer flexBody>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (
<SectionContainer
applyTopSpacingAsMarginOnChildrenContainer
backAction={onBack}
>
<Column noOverflowParent noMargin>
<BundlePageHeader
bundleListingData={bundleListingData}
bundle={bundle}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
/>
</Column>
{courseAndTheirListingDataIncludedInBundle &&
courseAndTheirListingDataIncludedInBundle.length > 0 && (
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{courseAndTheirListingDataIncludedInBundle.map(
({ course, courseListingData }) => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onCourseOpen(courseListingData);
}}
/>
</GridListTile>
);
}
)}
</GridList>
</Line>
)}
{productsExceptCoursesIncludedInBundleTiles && (
<>
<Line>
<Text size="block-title">
<Trans>Also included in this bundle</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsExceptCoursesIncludedInBundleTiles}
</GridList>
</Line>
</>
)}
</SectionContainer>
)}
</I18n>
);
};
export default BundlePage;

View File

@@ -0,0 +1,388 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type Bundle } from '../../../../Utils/GDevelopServices/Asset';
import { type BundleListingData } from '../../../../Utils/GDevelopServices/Shop';
import { SectionRow } from '../SectionContainer';
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { Column, Line } from '../../../../UI/Grid';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale';
import { renderProductPrice } from '../../../../AssetStore/ProductPriceTag';
import {
getProductsIncludedInBundle,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
import { sendBundleBuyClicked } from '../../../../Utils/Analytics/EventSender';
import BundlePurchaseDialog from '../../../../AssetStore/Bundles/BundlePurchaseDialog';
import RedemptionCodesDialog from '../../../../RedemptionCode/RedemptionCodesDialog';
import { renderEstimatedTotalPriceFormatted } from '../../../../AssetStore/Bundles/Utils';
import { PrivateGameTemplateStoreContext } from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import {
CreditsPackageStoreContext,
getCreditsAmountFromId,
} from '../../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import CourseStoreContext from '../../../../Course/CourseStoreContext';
import SecureCheckout from '../../../../AssetStore/SecureCheckout/SecureCheckout';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../../../Profile/Subscription/PlanCard';
import FlatButton from '../../../../UI/FlatButton';
import Coin from '../../../../Credits/Icons/Coin';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { formatDurationOfRedemptionCode } from '../../../../RedemptionCode/Utils';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
image: { width: 300, aspectRatio: '16 / 9' },
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
const ResponsiveDivider = () => {
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
return isMobile || isMediumScreen ? (
<Column noMargin>
<Divider orientation="horizontal" />
</Column>
) : (
<Line noMargin>
<Divider orientation="vertical" />
</Line>
);
};
type Props = {|
bundleListingData: BundleListingData,
bundle: Bundle,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
simulateAppStoreProduct?: boolean,
|};
const BundlePageHeader = ({
bundle,
bundleListingData,
getSubscriptionPlansWithPricingSystems,
simulateAppStoreProduct,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { creditsPackageListingDatas } = React.useContext(
CreditsPackageStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { receivedBundles, bundlePurchases } = authenticatedUser;
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const productListingDatasIncludedInBundle = React.useMemo(
() =>
bundleListingData &&
bundleListingDatas &&
privateGameTemplateListingDatas &&
privateAssetPackListingDatas &&
listedCourses &&
creditsPackageListingDatas
? getProductsIncludedInBundle({
productListingDatas: [
...bundleListingDatas,
...privateGameTemplateListingDatas,
...privateAssetPackListingDatas,
...listedCourses,
...creditsPackageListingDatas,
],
productListingData: bundleListingData,
})
: null,
[
bundleListingData,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
creditsPackageListingDatas,
]
);
const subscriptionPlansWithPricingSystems = getSubscriptionPlansWithPricingSystems();
const redemptionCodesIncludedInBundle = React.useMemo(
() =>
bundleListingData
? bundleListingData.includedRedemptionCodes || []
: null,
[bundleListingData]
);
const includedCreditsAmount = React.useMemo(
() =>
(bundleListingData.includedListableProducts || [])
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
),
[bundleListingData]
);
const onClickBuy = React.useCallback(
async () => {
if (!bundle) return;
if (isAlreadyReceived) {
return;
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[bundle, bundleListingData, isAlreadyReceived]
);
return (
<I18n>
{({ i18n }) => (
<>
<SectionRow>
<Paper background="dark" variant="outlined" style={{ padding: 16 }}>
<ColumnStackLayout noMargin>
<ResponsiveLineStackLayout
noMargin
alignItems="center"
justifyContent="flex-start"
forceMobileLayout={isMediumScreen}
expand
>
<div style={styles.imageContainer}>
<img
src={bundle.previewImageUrls[0]}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout expand justifyContent="flex-start">
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
<Line noMargin>
<Text noMargin>
{selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
</Text>
</Line>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
forceMobileLayout={isMediumScreen}
>
{redemptionCodesIncludedInBundle &&
redemptionCodesIncludedInBundle.length > 0 && (
<ColumnStackLayout noMargin expand>
{redemptionCodesIncludedInBundle.map(
(includedRedemptionCode, index) => (
<LineStackLayout
noMargin
alignItems="center"
key={`${
includedRedemptionCode.givenSubscriptionPlanId
}-${index}`}
>
{getPlanIcon({
planId:
includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription included
</Trans>
</Text>
</LineStackLayout>
)
)}
{isAlreadyReceived && (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my codes</Trans>}
onClick={() =>
setIsRedemptionCodesDialogOpen(true)
}
/>
</Line>
)}
</ColumnStackLayout>
)}
{includedCreditsAmount > 0 && (
<Column justifyContent="center" expand noMargin>
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text>
<Trans>
{includedCreditsAmount} credits included
</Trans>
</Text>
</LineStackLayout>
</Column>
)}
<ResponsiveDivider />
</ResponsiveLineStackLayout>
{!isAlreadyReceived && (
<Paper background="medium" style={{ padding: 16 }}>
{!!bundleListingData && (
<ResponsiveLineStackLayout
justifyContent="space-between"
noMargin
>
{!isMobile && !isMediumScreen && (
<Column noMargin justifyContent="center">
<LineStackLayout noMargin>
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
subscriptionPlansWithPricingSystems,
})}
</span>
</Text>
<div
style={{
color: gdevelopTheme.text.color.secondary,
}}
>
{renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
})}
</div>
</LineStackLayout>
</Column>
)}
<ResponsiveLineStackLayout
noMargin
forceMobileLayout={isMediumScreen}
>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
/>
</ResponsiveLineStackLayout>
</ResponsiveLineStackLayout>
)}
</Paper>
)}
</ColumnStackLayout>
</Paper>
</SectionRow>
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</>
)}
</I18n>
);
};
export default BundlePageHeader;

View File

@@ -234,7 +234,6 @@ const CourseCard = ({
/>
{getProductPriceOrOwnedLabel({
i18n,
gdevelopTheme,
productListingData: courseListingData,
usageType: 'default',
showBothPrices: 'column',

View File

@@ -7,6 +7,7 @@ import { I18n } from '@lingui/react';
import {
type CourseChapter,
type Course,
getCourseChapterRatingUrl,
} from '../../../../Utils/GDevelopServices/Asset';
import { type CourseListingData } from '../../../../Utils/GDevelopServices/Shop';
import SectionContainer from '../SectionContainer';
@@ -15,7 +16,7 @@ import TextBasedCourseChapterView from '../../../../Course/TextBasedCourseChapte
import Paper from '../../../../UI/Paper';
import Text from '../../../../UI/Text';
import { textEllipsisStyle } from '../../../../UI/TextEllipsis';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { Column, LargeSpacer, Line, Spacer } from '../../../../UI/Grid';
import Lock from '../../../../UI/CustomSvgIcons/Lock';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import Help from '../../../../UI/CustomSvgIcons/Help';
@@ -34,7 +35,10 @@ import {
AccordionBody,
AccordionHeader,
} from '../../../../UI/Accordion';
import CourseSectionHeader from './CourseSectionHeader';
import CoursePageHeader from './CoursePageHeader';
import Window from '../../../../Utils/Window';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
import { RatingBanner } from './RatingBanner';
const styles = {
desktopContainer: { display: 'flex', gap: 16 },
@@ -115,10 +119,14 @@ type Props = {|
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
simulateAppStoreProduct?: boolean,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
|};
const CourseSection = ({
const CoursePage = ({
course,
courseChapters,
onOpenTemplateFromCourseChapter,
@@ -134,6 +142,11 @@ const CourseSection = ({
simulateAppStoreProduct,
onOpenAskAi,
}: Props) => {
const { profile } = React.useContext(AuthenticatedUserContext);
const userId = (profile && profile.id) || null;
const {
values: { language },
} = React.useContext(PreferencesContext);
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const { showAlertMessage, values } = React.useContext(PreferencesContext);
const { isMobile, isLandscape } = useResponsiveWindowSize();
@@ -319,7 +332,7 @@ const CourseSection = ({
}
>
<Column noOverflowParent noMargin>
<CourseSectionHeader
<CoursePageHeader
course={course}
onBuyCourseWithCredits={onBuyCourseWithCredits}
onBuyCourse={onBuyCourse}
@@ -340,54 +353,73 @@ const CourseSection = ({
</AlertMessage>
</Line>
)}
{courseChapters.map((chapter: CourseChapter, index) =>
chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
{courseChapters.map((chapter: CourseChapter, index) => (
<ColumnStackLayout expand noOverflowParent noMargin>
{chapter.videoUrl ? (
<VideoBasedCourseChapterView
chapterIndex={index}
course={course}
courseChapter={chapter}
onOpenTemplate={() => {
onOpenTemplateFromCourseChapter(chapter);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
onOpenTemplateFromCourseChapter(chapter, templateId);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
)}
{!chapter.isLocked && (
<RatingBanner
disabled={!userId}
onClick={() => {
if (!userId) return;
const url = getCourseChapterRatingUrl({
userId,
courseId: course.id,
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
) : (
<TextBasedCourseChapterView
chapterIndex={index}
course={course}
// $FlowIgnore - Flow does not conclude this chapter can only be text-based.
courseChapter={chapter}
onOpenTemplate={(templateId?: string) => {
onOpenTemplateFromCourseChapter(chapter, templateId);
}}
onCompleteTask={onCompleteTask}
isTaskCompleted={isTaskCompleted}
getChapterCompletion={getChapterCompletion}
key={chapter.id}
onClickUnlock={onClickUnlock}
ref={_ref => {
if (_ref) {
chapterTitleRefs.current[index] = {
chapterId: chapter.id,
ref: _ref,
};
}
}}
/>
)
)}
language,
});
Window.openExternalURL(url);
}}
/>
)}
<LargeSpacer />
</ColumnStackLayout>
))}
<div style={styles.footer} />
</Column>
{isMobile && !isLandscape ? null : (
@@ -432,7 +464,13 @@ const CourseSection = ({
<RaisedButton
primary
label={<Trans>Ask the AI</Trans>}
onClick={() => onOpenAskAi('chat')}
onClick={() =>
onOpenAskAi({
mode: 'chat',
aiRequestId: null,
paneIdentifier: 'right',
})
}
/>
</ColumnStackLayout>
</Paper>
@@ -483,4 +521,4 @@ const CourseSection = ({
);
};
export default CourseSection;
export default CoursePage;

View File

@@ -31,9 +31,10 @@ import Window from '../../../../Utils/Window';
import { PurchaseProductButtons } from '../../../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../../../Utils/AppStorePurchases';
import { Divider } from '@material-ui/core';
import classes from './CourseSectionHeader.module.css';
import classes from './CoursePageHeader.module.css';
import Gold from '../../../../Profile/Subscription/Icons/Gold';
import Chip from '../../../../UI/Chip';
import SecureCheckout from '../../../../AssetStore/SecureCheckout/SecureCheckout';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
@@ -95,7 +96,7 @@ type Props = {|
simulateAppStoreProduct?: boolean,
|};
const CourseSectionHeader = ({
const CoursePageHeader = ({
course,
onBuyCourseWithCredits,
onBuyCourse,
@@ -271,20 +272,30 @@ const CourseSectionHeader = ({
</div>
</Column>
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() => onBuyCourse(course, password, i18n)}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
<ResponsiveLineStackLayout
noMargin
forceMobileLayout={isMediumScreen}
>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
<PurchaseProductButtons
i18n={i18n}
productListingData={courseListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={
shouldUseOrSimulateAppStoreProduct
}
isAlreadyReceived={!course.isLocked}
onClickBuy={() =>
onBuyCourse(course, password, i18n)
}
onClickBuyWithCredits={() =>
onWillBuyWithCredits(i18n)
}
/>
</ResponsiveLineStackLayout>
</ResponsiveLineStackLayout>
)}
</Paper>
@@ -313,4 +324,4 @@ const CourseSectionHeader = ({
);
};
export default CourseSectionHeader;
export default CoursePageHeader;

View File

@@ -7,6 +7,7 @@ import {
type Course,
type CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import type { BundleListingData } from '../../../../Utils/GDevelopServices/Shop';
import CoursePreviewBanner from '../../../../Course/CoursePreviewBanner';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import { Line } from '../../../../UI/Grid';
@@ -16,6 +17,8 @@ import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWin
import { LARGE_WIDGET_SIZE } from '../CardWidget';
import CourseCard from './CourseCard';
import { getColumnsFromWindowSize } from './Utils';
import BundlePreviewBanner from '../../../../AssetStore/Bundles/BundlePreviewBanner';
import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext';
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
@@ -34,8 +37,9 @@ type Props = {|
onBack: () => void,
courses: ?Array<Course>,
onSelectCourse: (courseId: string) => void,
onSelectBundle: (bundleListingData: BundleListingData) => void,
previewedCourse: ?Course,
previewedCourseChapters: ?Array<CourseChapter>,
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
@@ -47,13 +51,23 @@ const CoursesPage = ({
onBack,
courses,
onSelectCourse,
onSelectBundle,
previewedCourse,
previewedCourseChapters,
getCourseChapters,
getCourseChapterCompletion,
getCourseCompletion,
}: Props) => {
const { listedCourses } = React.useContext(CourseStoreContext);
const { windowSize, isLandscape } = useResponsiveWindowSize();
const numberOfItemsOnOneRow = getColumnsFromWindowSize(
windowSize,
isLandscape
);
const { limits } = React.useContext(AuthenticatedUserContext);
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
return (
<I18n>
@@ -65,7 +79,7 @@ const CoursesPage = ({
<SectionRow>
<CoursePreviewBanner
course={previewedCourse}
courseChapters={previewedCourseChapters}
getCourseChapters={getCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onDisplayCourse={() => {
@@ -77,13 +91,13 @@ const CoursesPage = ({
<SectionRow>
<Line>
<GridList
cols={getColumnsFromWindowSize(windowSize, isLandscape)}
cols={numberOfItemsOnOneRow}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses && listedCourses
? courses.map(course => {
? courses.slice(0, numberOfItemsOnOneRow).map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
@@ -113,6 +127,42 @@ const CoursesPage = ({
</GridList>
</Line>
</SectionRow>
{!hidePremiumProducts && (
<SectionRow>
<BundlePreviewBanner onDisplayBundle={onSelectBundle} />
</SectionRow>
)}
{courses && listedCourses && courses.length > numberOfItemsOnOneRow && (
<SectionRow>
<Line>
<GridList
cols={numberOfItemsOnOneRow}
style={styles.grid}
cellHeight="auto"
spacing={ITEMS_SPACING * 2}
>
{courses.slice(numberOfItemsOnOneRow).map(course => {
const completion = getCourseCompletion(course.id);
const courseListingData = listedCourses.find(
listedCourse => listedCourse.id === course.id
);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onSelectCourse(course.id);
}}
/>
</GridListTile>
);
})}
</GridList>
</Line>
</SectionRow>
)}
</SectionContainer>
)}
</I18n>

View File

@@ -7,13 +7,9 @@ import Window from '../../../../Utils/Window';
import { Trans } from '@lingui/macro';
import TranslateIcon from '@material-ui/icons/Translate';
import { ColumnStackLayout, LineStackLayout } from '../../../../UI/Layout';
import { type HomeTab } from '../HomePageMenu';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import SectionContainer, { SectionRow } from '../SectionContainer';
import type {
Course,
CourseChapter,
} from '../../../../Utils/GDevelopServices/Asset';
import type { Course } from '../../../../Utils/GDevelopServices/Asset';
import type { CourseCompletion, CourseChapterCompletion } from '../UseCourses';
import FlatButton from '../../../../UI/FlatButton';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
@@ -35,9 +31,13 @@ import CourseStoreContext from '../../../../Course/CourseStoreContext';
import TutorialsRow from './TutorialsRow';
import { getColumnsFromWindowSize, type LearnCategory } from './Utils';
import ExampleStore from '../../../../AssetStore/ExampleStore';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import {
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import Carousel from '../../../../UI/Carousel';
import BundlePreviewBanner from '../../../../AssetStore/Bundles/BundlePreviewBanner';
const NUMBER_OF_SCROLLS = 2; // Number of times the carousel can be scrolled to see all items.
const MAX_COLUMNS = getColumnsFromWindowSize('xlarge', true);
@@ -59,19 +59,21 @@ const styles = {
};
type Props = {|
onTabChange: (tab: HomeTab) => void,
onSelectCategory: (category: LearnCategory) => void,
selectInAppTutorial: (tutorialId: string) => void,
previewedCourse: ?Course,
courses: ?(Course[]),
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string) => void,
onSelectBundle: (bundleListingData: BundleListingData) => void,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
getCourseChapterCompletion: (
courseId: string,
chapterId: string
) => CourseChapterCompletion | null,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
@@ -80,13 +82,11 @@ type Props = {|
|};
const MainPage = ({
onTabChange,
onSelectCategory,
selectInAppTutorial,
previewedCourse,
courses,
previewedCourseChapters,
onSelectCourse,
onSelectBundle,
getCourseCompletion,
getCourseChapterCompletion,
onOpenAskAi,
@@ -107,6 +107,11 @@ const MainPage = ({
isMediumScreen,
} = useResponsiveWindowSize();
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const displayedCourses = React.useMemo(
() => {
if (!courses) return null;
@@ -233,6 +238,11 @@ const MainPage = ({
/>
</Line>
</SectionRow>
{!hidePremiumProducts && (
<SectionRow>
<BundlePreviewBanner onDisplayBundle={onSelectBundle} />
</SectionRow>
)}
<SectionRow>
<LineStackLayout
justifyContent="space-between"
@@ -391,7 +401,13 @@ const MainPage = ({
color="success"
label={<Trans>Ask the AI</Trans>}
rightIcon={<ArrowRight />}
onClick={() => onOpenAskAi('chat')}
onClick={() =>
onOpenAskAi({
mode: 'chat',
aiRequestId: null,
paneIdentifier: 'center',
})
}
/>
</ColumnStackLayout>
</Line>

View File

@@ -0,0 +1,44 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import {
ColumnStackLayout,
ResponsiveLineStackLayout,
} from '../../../../UI/Layout';
import Text from '../../../../UI/Text';
import classes from './RatingBanner.module.css';
import FlatButton from '../../../../UI/FlatButton';
import StarForButton from '../../../../UI/CustomSvgIcons/StarForButton';
type Props = {|
onClick: () => void,
disabled: boolean,
|};
export const RatingBanner = ({ onClick, disabled }: Props) => {
return (
<div className={classes.container}>
<ResponsiveLineStackLayout
expand
justifyContent="space-between"
alignItems="center"
>
<ColumnStackLayout noMargin>
<Text size="block-title" noMargin>
<Trans>Help us improve our learning content</Trans>
</Text>
<Text size="body2" noMargin>
<Trans>How would you rate this chapter?</Trans>
</Text>
</ColumnStackLayout>
<FlatButton
primary
label={<Trans>Rate chapter</Trans>}
leftIcon={<StarForButton />}
disabled={disabled}
onClick={onClick}
/>
</ResponsiveLineStackLayout>
</div>
);
};

View File

@@ -0,0 +1,10 @@
.container {
border-radius: 5px;
background: linear-gradient(
0deg,
var(--theme-primary-light) -80%,
transparent
);
display: flex;
padding: 8px 20px;
}

View File

@@ -1,10 +1,10 @@
// @flow
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
import { type HomeTab } from '../HomePageMenu';
import {
type CourseListingData,
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../../../../Utils/GDevelopServices/Shop';
import MainPage from './MainPage';
import TutorialsCategoryPage from './TutorialsCategoryPage';
@@ -13,7 +13,7 @@ import { TutorialContext } from '../../../../Tutorial/TutorialContext';
import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import CourseSection from './CourseSection';
import CoursePage from './CoursePage';
import type {
CourseChapter,
Course,
@@ -25,20 +25,26 @@ import InAppTutorialsPage from './InAppTutorialsPage';
import CoursesPage from './CoursesPage';
import { type LearnCategory } from './Utils';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import BundlePage from './BundlePage';
import RouterContext from '../../../RouterContext';
import {
sendBundleInformationOpened,
sendCourseInformationOpened,
} from '../../../../Utils/Analytics/EventSender';
import { BundleStoreContext } from '../../../../AssetStore/Bundles/BundleStoreContext';
type Props = {|
onTabChange: (tab: HomeTab) => void,
selectInAppTutorial: (tutorialId: string) => void,
selectedCategory: LearnCategory,
onSelectCategory: LearnCategory => void,
onOpenTemplateFromTutorial: string => Promise<void>,
onOpenTemplateFromCourseChapter: CourseChapter => Promise<void>,
previewedCourse: ?Course,
previewedCourseChapters: ?(CourseChapter[]),
onSelectCourse: (courseId: string | null) => void,
course: ?Course,
courses: ?(Course[]),
courseChapters: ?(CourseChapter[]),
getCourseChapters: (courseId: string) => ?Array<CourseChapter>,
onCompleteCourseTask: (
chapterId: string,
taskIndex: number,
@@ -62,26 +68,30 @@ type Props = {|
) => Promise<void>,
purchasingCourseListingData: ?CourseListingData,
setPurchasingCourseListingData: (CourseListingData | null) => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
onOpenNewProjectSetupDialog: () => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
receivedCourses: ?Array<Course>,
|};
const LearnSection = ({
onTabChange,
selectInAppTutorial,
selectedCategory,
onSelectCategory,
onOpenTemplateFromTutorial,
onOpenTemplateFromCourseChapter,
previewedCourse,
previewedCourseChapters,
onSelectCourse,
course,
courseChapters,
getCourseChapters,
courses,
onCompleteCourseTask,
isCourseTaskCompleted,
@@ -95,17 +105,61 @@ const LearnSection = ({
onOpenNewProjectSetupDialog,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
getSubscriptionPlansWithPricingSystems,
receivedCourses,
}: Props) => {
const { fetchTutorials } = React.useContext(TutorialContext);
const { fetchBundles } = React.useContext(BundleStoreContext);
const { navigateToRoute } = React.useContext(RouterContext);
const [
selectedBundleListingData,
setSelectedBundleListingData,
] = React.useState<?BundleListingData>(null);
const onOpenBundle = React.useCallback(
(bundleListingData: BundleListingData) => {
sendBundleInformationOpened({
bundleName: bundleListingData.name,
bundleId: bundleListingData.id,
source: 'learn',
});
setSelectedBundleListingData(bundleListingData);
},
[setSelectedBundleListingData]
);
const onOpenCourse = React.useCallback(
(courseId: string | null) => {
if (courseId && courses) {
const course = courses.find(c => c.id === courseId);
if (course && course.isLocked) {
// Only send the event if the course is not owned.
sendCourseInformationOpened({
courseName: course.titleByLocale['en'],
courseId: courseId,
source: 'learn',
});
}
}
onSelectCourse(courseId);
},
[onSelectCourse, courses]
);
React.useEffect(
() => {
fetchTutorials();
fetchBundles();
},
[fetchTutorials]
// Fetch tutorials and bundles only once when the component mounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (course) {
const courseChapters = getCourseChapters(course.id);
if (!courseChapters) {
return (
<SectionContainer flexBody>
@@ -117,7 +171,7 @@ const LearnSection = ({
}
return (
<CourseSection
<CoursePage
course={course}
courseChapters={courseChapters}
onBack={() => {
@@ -139,15 +193,40 @@ const LearnSection = ({
);
}
if (selectedBundleListingData) {
return (
<BundlePage
bundleListingData={selectedBundleListingData}
onBack={() => setSelectedBundleListingData(null)}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
onAssetPackOpen={privateAssetPackListingData => {
// Ideally we would open it in the Learn Section,
// but asset packs are not supported in the Learn Section yet.
navigateToRoute('store', {
'asset-pack': `product-${privateAssetPackListingData.id}`,
});
}}
onGameTemplateOpen={onSelectPrivateGameTemplateListingData}
onBundleOpen={onOpenBundle}
onCourseOpen={courseListingData => {
onOpenCourse(courseListingData.id);
}}
courses={courses}
receivedCourses={receivedCourses}
getCourseCompletion={getCourseCompletion}
/>
);
}
return !selectedCategory ? (
<MainPage
onTabChange={onTabChange}
onSelectCategory={onSelectCategory}
selectInAppTutorial={selectInAppTutorial}
courses={courses}
onSelectCourse={onSelectCourse}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
onSelectCourse={onOpenCourse}
onSelectBundle={onOpenBundle}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
onOpenAskAi={onOpenAskAi}
@@ -168,9 +247,10 @@ const LearnSection = ({
<CoursesPage
onBack={() => onSelectCategory(null)}
courses={courses}
onSelectCourse={onSelectCourse}
onSelectCourse={onOpenCourse}
onSelectBundle={onOpenBundle}
previewedCourse={previewedCourse}
previewedCourseChapters={previewedCourseChapters}
getCourseChapters={getCourseChapters}
getCourseCompletion={getCourseCompletion}
getCourseChapterCompletion={getCourseChapterCompletion}
/>
@@ -179,7 +259,7 @@ const LearnSection = ({
onBack={() => onSelectCategory('all-tutorials')}
category={selectedCategory}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onSelectCourse={onSelectCourse}
onSelectCourse={onOpenCourse}
/>
);
};

View File

@@ -1,13 +1,9 @@
// @flow
import * as React from 'react';
import GDevelopThemeContext from '../../../../UI/Theme/GDevelopThemeContext';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import {
homepageDesktopMenuBarWidth,
homepageMediumMenuBarWidth,
homepageMobileMenuHeight,
} from '../HomePageMenuBar';
import { homepageMobileMenuHeight } from '../HomePageMenuBar';
import Paper from '../../../../UI/Paper';
import { type IframePosition } from './UseGamesPlatformFrame';
export const GAMES_PLATFORM_IFRAME_ID = 'games-platform-frame';
@@ -33,12 +29,17 @@ type Props = {|
loaded: boolean,
visible: boolean,
initialGameId: ?string,
iframePosition: ?IframePosition,
|};
const GamesPlatformFrame = ({ initialGameId, loaded, visible }: Props) => {
const GamesPlatformFrame = ({
initialGameId,
loaded,
visible,
iframePosition,
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const paletteType = gdevelopTheme.palette.type;
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
// Use a ref to store the initial game id, as we don't want to trigger a re-render
// when the game id changes.
@@ -64,19 +65,19 @@ const GamesPlatformFrame = ({ initialGameId, loaded, visible }: Props) => {
[loaded, initialGameId]
);
const titleBarAndToolbarHeight = isMobile ? 0 : 37 + 40;
const containerTop = isMobile
? 0 // Always top of the screen on small screens.
: `calc(${titleBarAndToolbarHeight}px + var(--safe-area-inset-top))`;
const containerBottom = isMobile ? homepageMobileMenuHeight : 0;
const containerLeft = isMobile
? 0
: isMediumScreen
? homepageMediumMenuBarWidth
: homepageDesktopMenuBarWidth;
const containerWidth = `calc(100% - ${containerLeft}px`;
const containerHeight = `calc(100% - ${titleBarAndToolbarHeight +
containerBottom}px - ${isMobile ? '0px' : 'var(--safe-area-inset-top)'})`;
// In this component, do not use useResponsiveWindowSize. The position
// of the iframe is read from iframePosition - which is set by the component
// responsible for positioning the iframe (typically, the "Play section" component).
// The iframe position can change according to the size of the pane containing it -
// which is not always the same as the size of the top level window.
const containerTop =
iframePosition && !iframePosition.isMobile ? iframePosition.top : 0;
const containerLeft = iframePosition ? iframePosition.left : 0;
const containerWidth = iframePosition ? iframePosition.width : '100%';
const containerHeight =
iframePosition && !iframePosition.isMobile
? iframePosition.height
: `calc(100% - ${homepageMobileMenuHeight}px)`;
// We wrap the iframe in a paper, as its content has a transparent background,
// and we don't want what's behind the iframe to be visible.

View File

@@ -163,12 +163,21 @@ const sendSoftKeyboardOffsetToFrame = async (offset: number) => {
}
};
export type IframePosition = {|
isMobile: boolean,
top: number,
left: number,
width: number,
height: number,
|};
export type GamesPlatformFrameTools = {|
startTimeoutToUnloadIframe: () => void,
loadIframeOrRemoveTimeout: () => void,
iframeLoaded: boolean,
iframeVisible: boolean,
iframeErrored: boolean,
updateIframePosition: (position: IframePosition) => void,
renderGamesPlatformFrame: () => React.Node,
|};
@@ -185,6 +194,9 @@ const useGamesPlatformFrame = ({
const [iframeVisible, setIframeVisible] = React.useState(false);
const [iframeLoaded, setIframeLoaded] = React.useState(false);
const [iframeErrored, setIframeErrored] = React.useState(false);
const [iframePosition, setIframePosition] = React.useState<?IframePosition>(
null
);
const [lastGameId, setLastGameId] = React.useState<?string>(null);
const timeoutToUnloadIframe = React.useRef<?TimeoutID>(null);
const { openUserPublicProfile } = React.useContext(PublicProfileContext);
@@ -504,9 +516,26 @@ const useGamesPlatformFrame = ({
initialGameId={lastGameId}
loaded={loadIframeInDOM}
visible={iframeVisible}
iframePosition={iframePosition}
/>
),
[lastGameId, loadIframeInDOM, iframeVisible]
[lastGameId, loadIframeInDOM, iframeVisible, iframePosition]
);
const updateIframePosition = React.useCallback(
(position: IframePosition) => {
if (
!iframePosition ||
iframePosition.isMobile !== position.isMobile ||
iframePosition.top !== position.top ||
iframePosition.left !== position.left ||
iframePosition.width !== position.width ||
iframePosition.height !== position.height
) {
setIframePosition(position);
}
},
[setIframePosition, iframePosition]
);
const gamesPlatformFrameTools = React.useMemo(
@@ -516,6 +545,7 @@ const useGamesPlatformFrame = ({
iframeLoaded,
iframeVisible,
iframeErrored,
updateIframePosition,
renderGamesPlatformFrame,
}),
[
@@ -524,6 +554,7 @@ const useGamesPlatformFrame = ({
iframeLoaded,
iframeVisible,
iframeErrored,
updateIframePosition,
renderGamesPlatformFrame,
]
);

View File

@@ -7,24 +7,51 @@ import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import PlaceholderError from '../../../../UI/PlaceholderError';
import { sendPlaySectionOpened } from '../../../../Utils/Analytics/EventSender';
import { type GamesPlatformFrameTools } from './UseGamesPlatformFrame';
import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
type Props = {|
gamesPlatformFrameTools: GamesPlatformFrameTools,
|};
const styles = {
iframeTarget: { position: 'absolute', inset: 0, pointerEvents: 'none' },
};
const PlaySection = ({ gamesPlatformFrameTools }: Props) => {
const { isMobile } = useResponsiveWindowSize();
const {
iframeLoaded,
iframeErrored,
loadIframeOrRemoveTimeout,
updateIframePosition,
} = gamesPlatformFrameTools;
// At each render, communicate the iframe position to the games frame.
// If on a "mobile" screen (either a real mobile, or a desktop that has a small
// window, or an editor tab that is too narrow), communicate this so that the
// iframe can be shown from the top of the screen.
const iframePositionRef = React.useRef<?HTMLDivElement>(null);
React.useLayoutEffect(() => {
if (iframePositionRef.current) {
const rect = iframePositionRef.current.getBoundingClientRect();
updateIframePosition({
isMobile,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
}
});
React.useEffect(() => {
sendPlaySectionOpened();
}, []);
// Iframe will be displayed here if loaded.
return iframeLoaded ? null : (
return iframeLoaded ? (
<div style={styles.iframeTarget} ref={iframePositionRef} />
) : (
<SectionContainer flexBody>
<SectionRow expand>
{iframeErrored ? (

View File

@@ -10,6 +10,7 @@ import { AssetStoreContext } from '../../../../AssetStore/AssetStoreContext';
import AssetPackInstallDialog from '../../../../AssetStore/AssetPackInstallDialog';
import { enumerateAssetStoreIds } from '../../../../AssetStore/EnumerateAssetStoreIds';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type Course } from '../../../../Utils/GDevelopServices/Asset';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import { getAssetShortHeadersToDisplay } from '../../../../AssetStore/AssetsList';
import { AssetStoreNavigatorContext } from '../../../../AssetStore/AssetStoreNavigator';
@@ -22,6 +23,8 @@ type Props = {|
) => void,
onOpenProfile: () => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
onCourseOpen: (courseId: string) => void,
receivedCourses?: ?Array<Course>,
|};
const StoreSection = ({
@@ -30,6 +33,8 @@ const StoreSection = ({
onOpenPrivateGameTemplateListingData,
onOpenProfile,
onExtensionInstalled,
onCourseOpen,
receivedCourses,
}: Props) => {
const [
isAssetPackDialogInstallOpen,
@@ -84,6 +89,8 @@ const StoreSection = ({
}
displayPromotions
onOpenProfile={onOpenProfile}
receivedCourses={receivedCourses}
onCourseOpen={onCourseOpen}
/>
{(openedAssetPack || openedAssetShortHeader) && (
<Line justifyContent="flex-end">

View File

@@ -614,7 +614,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
>
<LineStackLayout noMargin alignItems="center">
{getPlanIcon({
subscriptionPlan: userSubscriptionPlanWithPricingSystems,
planId: userSubscriptionPlanWithPricingSystems.id,
logoSize: 20,
})}
<Text noMargin>

View File

@@ -35,20 +35,75 @@ export type CourseCompletion = {|
chapters: number,
|};
const useCourses = () => {
type ReadyUser = {|
userStatus: 'ready' | 'not-ready',
userId: string | null,
userSubscriptionPlanId: string | null,
userCoursePurchasesCount: number | null,
userBundlePurchaseCount: number | null,
|};
/**
* Help to reduce the number of re-fetches of the courses by
* debouncing the user changes.
*/
const useReadyUser = () => {
const {
profile,
limits,
subscription,
coursePurchases,
bundlePurchases,
loginState,
} = React.useContext(AuthenticatedUserContext);
const [readyUser, setReadyUser] = React.useState<ReadyUser>({
userStatus: 'not-ready',
userId: null,
userSubscriptionPlanId: null,
userCoursePurchasesCount: null,
userBundlePurchaseCount: null,
});
React.useEffect(
() => {
if (loginState !== 'done') {
return;
}
setReadyUser({
userStatus: 'ready',
userId: profile ? profile.id : null,
userSubscriptionPlanId: subscription ? subscription.planId : null,
userCoursePurchasesCount: coursePurchases ? coursePurchases.length : 0,
userBundlePurchaseCount: bundlePurchases ? bundlePurchases.length : 0,
});
},
[profile, loginState, subscription, coursePurchases, bundlePurchases]
);
return readyUser;
};
const noCourseChapters: {
[courseId: string]: CourseChapter[],
} = {};
const useCourses = () => {
const {
userStatus,
userId,
userSubscriptionPlanId,
userCoursePurchasesCount,
userBundlePurchaseCount,
} = useReadyUser();
const {
limits,
getAuthorizationHeader,
onOpenLoginDialog,
loginState,
} = React.useContext(AuthenticatedUserContext);
const {
values: { language },
} = React.useContext(PreferencesContext);
const userLanguage2LetterCode = language.split('_')[0].toLowerCase();
const [courses, setCourses] = React.useState<?(Course[])>(null);
const { listedCourses } = React.useContext(CourseStoreContext);
@@ -84,28 +139,36 @@ const useCourses = () => {
updateUserCourseProgress
);
const [areChaptersReady, setAreChaptersReady] = React.useState<boolean>(
const [areCoursesFetched, setAreCoursesFetched] = React.useState<boolean>(
false
);
const [chaptersByCourseId, setChaptersByCourseId] = React.useState<{|
[courseId: string]: CourseChapter[],
|}>({});
const [
chaptersByCourseIdByUserId,
setChaptersByCourseIdByUserId,
] = React.useState<{
[userId: string]: { [courseId: string]: CourseChapter[] },
}>({
'': noCourseChapters,
});
// Extract those values to avoid unnecessary effect calls.
const userId = profile ? profile.id : null;
const userSubscriptionPlanId = subscription ? subscription.planId : null;
const userCoursePurchasesCount = coursePurchases ? coursePurchases.length : 0;
const hidePremiumProducts =
!!limits &&
!!limits.capabilities.classrooms &&
limits.capabilities.classrooms.hidePremiumProducts;
const fetchCourses = React.useCallback(
async (): Promise<Array<Course>> => {
const fetchedCourses = await listCourses(getAuthorizationHeader, {
userId,
});
setCourses(fetchedCourses);
return fetchedCourses;
const displayedCourses = fetchedCourses.filter(
course => !hidePremiumProducts || !course.isLocked
);
setCourses(displayedCourses);
return displayedCourses;
},
[userId, getAuthorizationHeader]
[userId, getAuthorizationHeader, hidePremiumProducts]
);
const onSelectCourse = React.useCallback(
@@ -124,11 +187,15 @@ const useCourses = () => {
const fetchCourseChapters = React.useCallback(
async (courseId: string) => {
try {
console.info(
`Fetching chapters for course ${courseId} for userId=${userId ||
'null'}.`
);
const [fetchedChapters, userProgress] = await Promise.all([
listCourseChapters(getAuthorizationHeader, {
courseId,
userId,
lang: userLanguage2LetterCode,
language,
}),
(async () => {
if (userId) {
@@ -147,9 +214,13 @@ const useCourses = () => {
...currentProgressByCourseId,
[courseId]: userProgress,
}));
setChaptersByCourseId(currentChaptersByCourseId => ({
...currentChaptersByCourseId,
[courseId]: fetchedChapters,
const userIdOrEmpty: string = userId || '';
setChaptersByCourseIdByUserId(currentChaptersByCourseIdByUserId => ({
...currentChaptersByCourseIdByUserId,
[userIdOrEmpty]: {
...currentChaptersByCourseIdByUserId[userIdOrEmpty],
[courseId]: fetchedChapters,
},
}));
} catch (error) {
console.error(
@@ -158,7 +229,7 @@ const useCourses = () => {
);
}
},
[getAuthorizationHeader, userId, userLanguage2LetterCode]
[getAuthorizationHeader, userId, language]
);
const onCompleteTask = React.useCallback(
@@ -228,9 +299,12 @@ const useCourses = () => {
[userCourseProgress]
);
const chaptersByCourseId =
chaptersByCourseIdByUserId[userId || ''] || noCourseChapters;
const getChapterCompletion = React.useCallback(
(courseId: string, chapterId: string): CourseChapterCompletion | null => {
const chapters = chaptersByCourseId[courseId];
const chapters = chaptersByCourseId ? chaptersByCourseId[courseId] : null;
if (!chapters) return null;
const chapter = chapters.find(chapter => chapter.id === chapterId);
@@ -262,14 +336,14 @@ const useCourses = () => {
const course = courses.find(course => course.id === courseId);
if (!course) return null;
const chapters = chaptersByCourseId[courseId];
if (!chapters) return null;
const chaptersCount = course.chaptersTargetCount;
const courseProgress = userProgressByCourseId[courseId];
if (!courseProgress)
return { percentage: 0, completedChapters: 0, chapters: chaptersCount };
const chapters = chaptersByCourseId ? chaptersByCourseId[courseId] : null;
if (!chapters) return null;
let completion = 0;
let completedChapters = 0;
const chapterProportion = 1 / chaptersCount;
@@ -432,35 +506,67 @@ const useCourses = () => {
React.useEffect(
() => {
(async () => {
if (userSubscriptionPlanId || userCoursePurchasesCount) {
// Just to trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course.
if (userStatus !== 'ready') {
return;
}
// Check the loginState, to avoid fetching courses just before the user logs in.
if (loginState === 'done') {
const fetchedCourses = await fetchCourses();
await Promise.all(
fetchedCourses.map(course => fetchCourseChapters(course.id))
);
setAreChaptersReady(true);
console.info(`Fetching all courses for userId=${userId || 'null'}.`);
if (
userSubscriptionPlanId ||
userCoursePurchasesCount ||
userBundlePurchaseCount
) {
// Trigger a re-fetch of the courses when the user subscription changes,
// or when the user purchases a course or when the user logs in/out.
}
if (userId) {
const userIdOrEmpty: string = userId || '';
// we empty the chapters fetched for the user to ensure they are re-fetched
// and up-to-date (notably in case subscription changed or purchase count changed).
setChaptersByCourseIdByUserId(currentChaptersByCourseIdByUserId => ({
...currentChaptersByCourseIdByUserId,
[userIdOrEmpty]: noCourseChapters,
}));
}
await fetchCourses();
setAreCoursesFetched(true);
})();
},
[
fetchCourses,
fetchCourseChapters,
userSubscriptionPlanId,
userCoursePurchasesCount,
loginState,
userBundlePurchaseCount,
userId,
userStatus,
]
);
// This callback will change (triggering re-renders)
// anytime the chapters are fetched for a course for a user.
const getCourseChapters = React.useCallback(
(courseId: string) => {
if (chaptersByCourseId[courseId] !== undefined) {
return chaptersByCourseId[courseId];
}
// Chapter is not loaded yet, fetch it. Note that we could store
// the promise to avoid fetching it multiple times.
fetchCourseChapters(courseId);
return null;
},
[chaptersByCourseId, fetchCourseChapters]
);
const selectedCourse = React.useMemo(
() => {
if (!selectedCourseId || !courses || !areChaptersReady) return null;
if (!selectedCourseId || !courses) return null;
return courses.find(course => course.id === selectedCourseId) || null;
},
[selectedCourseId, courses, areChaptersReady]
[selectedCourseId, courses]
);
return {
@@ -468,8 +574,8 @@ const useCourses = () => {
fetchCourses,
onSelectCourse,
selectedCourse,
courseChaptersByCourseId: chaptersByCourseId,
areChaptersReady,
getCourseChapters,
areCoursesFetched,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,

View File

@@ -41,6 +41,8 @@ import { type GamesPlatformFrameTools } from './PlaySection/UseGamesPlatformFram
import { type CourseChapter } from '../../../Utils/GDevelopServices/Asset';
import useCourses from './UseCourses';
import PreferencesContext from '../../Preferences/PreferencesContext';
import useSubscriptionPlans from '../../../Utils/UseSubscriptionPlans';
import { BundleStoreContext } from '../../../AssetStore/Bundles/BundleStoreContext';
const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => {
if (
@@ -85,6 +87,7 @@ const styles = {
},
scrollableContainer: {
display: 'flex',
position: 'relative',
marginLeft: 0,
marginRight: 0,
flexDirection: 'column',
@@ -104,7 +107,7 @@ type Props = {|
projectItemName: ?string,
project: ?gdProject,
setToolbar: (?React.Node) => void,
hideTabsTitleBarAndEditorToolbar: (hidden: boolean) => void,
setGamesPlatformFrameShown: ({| shown: boolean, isMobile: boolean |}) => void,
storageProviders: Array<StorageProvider>,
// Games
@@ -132,7 +135,11 @@ type Props = {|
selectInAppTutorial: (tutorialId: string) => void,
onOpenPreferences: () => void,
onOpenAbout: () => void,
onOpenAskAi: (mode: 'chat' | 'agent') => void,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
// Project creation
onOpenNewProjectSetupDialog: () => void,
@@ -191,7 +198,7 @@ export const HomePage = React.memo<Props>(
onOpenProfile,
onCreateProjectFromExample,
setToolbar,
hideTabsTitleBarAndEditorToolbar,
setGamesPlatformFrameShown,
selectInAppTutorial,
onOpenPreferences,
onOpenAbout,
@@ -253,9 +260,9 @@ export const HomePage = React.memo<Props>(
const {
courses,
selectedCourse,
courseChaptersByCourseId,
getCourseChapters,
onSelectCourse,
areChaptersReady,
areCoursesFetched,
onCompleteTask,
isTaskCompleted,
getChapterCompletion,
@@ -268,6 +275,10 @@ export const HomePage = React.memo<Props>(
const [learnCategory, setLearnCategory] = React.useState<LearnCategory>(
null
);
const { getSubscriptionPlansWithPricingSystems } = useSubscriptionPlans({
authenticatedUser,
includeLegacy: false,
});
const { isMobile } = useResponsiveWindowSize();
const {
@@ -287,6 +298,10 @@ export const HomePage = React.memo<Props>(
const { setInitialPackUserFriendlySlug } = React.useContext(
AssetStoreContext
);
const {
fetchBundles,
shop: { setInitialBundleUserFriendlySlug },
} = React.useContext(BundleStoreContext);
const openedGame = React.useMemo(
() =>
!openedGameId || !games
@@ -314,8 +329,11 @@ export const HomePage = React.memo<Props>(
routeArguments['game-template']
);
}
if (routeArguments['bundle']) {
setInitialBundleUserFriendlySlug(routeArguments['bundle']);
}
// Remove the arguments so that the asset store is not opened again.
removeRouteArguments(['asset-pack', 'game-template']);
removeRouteArguments(['asset-pack', 'game-template', 'bundle']);
} else if (requestedTab === 'manage') {
const gameId = routeArguments['game-id'];
if (gameId) {
@@ -332,7 +350,7 @@ export const HomePage = React.memo<Props>(
}
} else if (requestedTab === 'learn') {
const courseId = routeArguments['course-id'];
if (!areChaptersReady) {
if (!areCoursesFetched) {
// Do not process requested tab before courses are ready.
return;
}
@@ -348,8 +366,9 @@ export const HomePage = React.memo<Props>(
removeRouteArguments,
setInitialPackUserFriendlySlug,
setInitialGameTemplateUserFriendlySlug,
setInitialBundleUserFriendlySlug,
games,
areChaptersReady,
areCoursesFetched,
]
);
@@ -359,8 +378,14 @@ export const HomePage = React.memo<Props>(
fetchExamplesAndFilters();
fetchGameTemplates();
fetchTutorials();
fetchBundles();
},
[fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates]
[
fetchExamplesAndFilters,
fetchTutorials,
fetchGameTemplates,
fetchBundles,
]
);
// Fetch user cloud projects when home page becomes active
@@ -420,19 +445,19 @@ export const HomePage = React.memo<Props>(
React.useLayoutEffect(
() => {
// Hide the toolbars when on mobile in the "play" tab.
if (activeTab === 'play' && isMobile) {
hideTabsTitleBarAndEditorToolbar(true);
if (activeTab === 'play') {
setGamesPlatformFrameShown({ shown: true, isMobile });
} else {
hideTabsTitleBarAndEditorToolbar(false);
setGamesPlatformFrameShown({ shown: false, isMobile });
updateToolbar();
}
// Ensure we show it again when the tab changes.
return () => {
hideTabsTitleBarAndEditorToolbar(false);
setGamesPlatformFrameShown({ shown: false, isMobile });
};
},
[updateToolbar, activeTab, hideTabsTitleBarAndEditorToolbar, isMobile]
[updateToolbar, activeTab, setGamesPlatformFrameShown, isMobile]
);
const forceUpdateEditor = React.useCallback(() => {
@@ -542,7 +567,6 @@ export const HomePage = React.memo<Props>(
)}
{activeTab === 'learn' && (
<LearnSection
onTabChange={setActiveTab}
selectInAppTutorial={selectInAppTutorial}
onOpenTemplateFromTutorial={onOpenTemplateFromTutorial}
onOpenTemplateFromCourseChapter={
@@ -553,17 +577,8 @@ export const HomePage = React.memo<Props>(
onSelectCourse={onSelectCourse}
courses={courses}
previewedCourse={premiumCourse}
previewedCourseChapters={
premiumCourse
? courseChaptersByCourseId[premiumCourse.id]
: null
}
course={selectedCourse}
courseChapters={
selectedCourse
? courseChaptersByCourseId[selectedCourse.id]
: null
}
getCourseChapters={getCourseChapters}
onCompleteCourseTask={onCompleteTask}
isCourseTaskCompleted={isTaskCompleted}
getCourseChapterCompletion={getChapterCompletion}
@@ -580,6 +595,14 @@ export const HomePage = React.memo<Props>(
onSelectPrivateGameTemplateListingData
}
onSelectExampleShortHeader={onSelectExampleShortHeader}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
receivedCourses={
courses
? courses.filter(course => !course.isLocked)
: undefined
}
/>
)}
{activeTab === 'play' && (
@@ -596,6 +619,15 @@ export const HomePage = React.memo<Props>(
}
onOpenProfile={onOpenProfile}
onExtensionInstalled={onExtensionInstalled}
onCourseOpen={(courseId: string) => {
onSelectCourse(courseId);
setActiveTab('learn');
}}
receivedCourses={
courses
? courses.filter(course => !course.isLocked)
: undefined
}
/>
)}
{activeTab === 'team-view' &&
@@ -650,7 +682,7 @@ export const renderHomePageContainer = (
isActive={props.isActive}
projectItemName={props.projectItemName}
setToolbar={props.setToolbar}
hideTabsTitleBarAndEditorToolbar={props.hideTabsTitleBarAndEditorToolbar}
setGamesPlatformFrameShown={props.setGamesPlatformFrameShown}
canOpen={props.canOpen}
onChooseProject={props.onChooseProject}
onOpenRecentFile={props.onOpenRecentFile}

View File

@@ -4,13 +4,7 @@ import * as React from 'react';
import { makeDragSourceAndDropTarget } from '../../UI/DragAndDrop/DragSourceAndDropTarget';
import { ScreenTypeMeasurer } from '../../UI/Responsive/ScreenTypeMeasurer';
import { ColumnDropIndicator } from './DropIndicator';
import {
type EditorTabsState,
type EditorTab,
getEditors,
getCurrentTabIndex,
getCurrentTab,
} from './EditorTabsHandler';
import { type EditorTab } from './EditorTabsHandler';
import {
ClosableTabs,
ClosableTab,
@@ -26,7 +20,8 @@ const DragSourceAndDropTarget = makeDragSourceAndDropTarget<EditorTab>(
type DraggableEditorTabsProps = {|
hideLabels?: boolean,
editorTabs: EditorTabsState,
editors: Array<EditorTab>,
currentTab: EditorTab | null,
onClickTab: (index: number) => void,
onCloseTab: (editor: EditorTab) => void,
onCloseOtherTabs: (editor: EditorTab) => void,
@@ -46,7 +41,8 @@ const homeTabApproximateWidth = 35;
export function DraggableEditorTabs({
hideLabels,
editorTabs,
editors,
currentTab,
onClickTab,
onCloseTab,
onCloseOtherTabs,
@@ -61,8 +57,6 @@ export function DraggableEditorTabs({
useOnResize(useForceUpdate());
const { windowSize } = useResponsiveWindowSize();
const currentTab = getCurrentTab(editorTabs);
React.useEffect(
() => {
if (!currentTab) return;
@@ -83,9 +77,11 @@ export function DraggableEditorTabs({
<ClosableTabs
hideLabels={hideLabels}
renderTabs={({ containerWidth }) => {
const editors = getEditors(editorTabs);
const hasHomeTab =
editors.length > 0 && editors[0].key === 'start page';
return editors.map((editorTab, id) => {
const isCurrentTab = getCurrentTabIndex(editorTabs) === id;
const isCurrentTab = currentTab === editorTab;
// Maximum width of a tab is the width so that all tabs can fit it,
// unless on a small screen, where we want to avoid compressing tabs too much
@@ -94,7 +90,10 @@ export function DraggableEditorTabs({
const maxWidth = Math.max(
minimumMaxWidth,
// The home tab is special because it's just an icon.
(containerWidth - homeTabApproximateWidth) / (editors.length - 1)
hasHomeTab
? (containerWidth - homeTabApproximateWidth) /
(editors.length - 1)
: containerWidth / editors.length
);
return (

View File

@@ -53,8 +53,12 @@ export type EditorTab = {|
|};
export type EditorTabsState = {|
editors: Array<EditorTab>,
currentTab: number,
panes: {
[paneIdentifier: string]: {|
editors: Array<EditorTab>,
currentTab: number,
|},
},
|};
export type EditorKind =
@@ -82,6 +86,7 @@ export type EditorTabsPersistedState = {|
|};
export type EditorOpeningOptions = {|
paneIdentifier: string,
label?: string,
icon?: React.Node,
renderCustomIcon?: ?(brightness: number) => React.Node,
@@ -126,8 +131,20 @@ export const getEditorTabMetadata = (
export const getEditorTabsInitialState = (): EditorTabsState => {
return {
editors: [],
currentTab: 0,
panes: {
left: {
editors: [],
currentTab: 0,
},
center: {
editors: [],
currentTab: 0,
},
right: {
editors: [],
currentTab: 0,
},
},
};
};
@@ -144,17 +161,28 @@ export const openEditorTab = (
extraEditorProps,
dontFocusTab,
closable,
paneIdentifier,
}: EditorOpeningOptions
): EditorTabsState => {
const existingEditorId = findIndex(
state.editors,
editor => editor.key === key
);
if (existingEditorId !== -1) {
return {
...state,
currentTab: dontFocusTab ? state.currentTab : existingEditorId,
};
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
const existingEditorId = findIndex(
pane.editors,
editor => editor.key === key
);
if (existingEditorId !== -1) {
return {
...state,
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
currentTab: dontFocusTab ? pane.currentTab : existingEditorId,
},
},
};
}
}
const editorTab: EditorTab = {
@@ -170,46 +198,82 @@ export const openEditorTab = (
closable: typeof closable === 'undefined' ? true : !!closable,
};
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
return {
...state,
editors:
// Make sure the home page is always the first tab.
key === 'start page'
? [editorTab, ...state.editors]
: [...state.editors, editorTab],
currentTab: dontFocusTab ? state.currentTab : state.editors.length,
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
editors:
// Make sure the home page is always the first tab.
key === 'start page'
? [editorTab, ...pane.editors]
: [...pane.editors, editorTab],
currentTab: dontFocusTab ? pane.currentTab : pane.editors.length,
},
},
};
};
export const changeCurrentTab = (
state: EditorTabsState,
paneIdentifier: string,
newTabId: number
): EditorTabsState => {
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
return {
...state,
currentTab: Math.max(0, Math.min(newTabId, state.editors.length - 1)),
panes: {
...state.panes,
[paneIdentifier]: {
...pane,
currentTab: Math.max(0, Math.min(newTabId, pane.editors.length - 1)),
},
},
};
};
export const isStartPageTabPresent = (state: EditorTabsState): boolean => {
return state.editors.some(editor => editor.key === 'start page');
return hasEditorTabOpenedWithKey(state, 'start page');
};
export const closeTabsExceptIf = (
state: EditorTabsState,
keepPredicate: (editorTab: EditorTab) => boolean
) => {
const currentEditorTab = getCurrentTab(state);
const remainingEditors = state.editors.filter(keepPredicate);
return changeCurrentTab(
{
...state,
editors: remainingEditors,
},
// Keep the focus on the current editor tab, or if it was closed
// go back to the first tab.
remainingEditors.indexOf(currentEditorTab) || 0
);
let newState = { ...state };
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
throw new Error(`Pane with identifier "${paneIdentifier}" is not valid.`);
}
const currentEditorTab = pane.editors[pane.currentTab] || null;
const paneRemainingEditors = pane.editors.filter(keepPredicate);
const currentEditorTabNewIndex = paneRemainingEditors.indexOf(
currentEditorTab
);
newState.panes[paneIdentifier] = {
...pane,
editors: paneRemainingEditors,
// Keep the focus on the current editor tab, or if it was closed
// go back to the first tab.
currentTab:
currentEditorTabNewIndex === -1 ? 0 : currentEditorTabNewIndex,
};
}
return newState;
};
export const closeAllEditorTabs = (state: EditorTabsState): EditorTabsState => {
@@ -233,16 +297,27 @@ export const closeOtherEditorTabs = (
);
};
export const getEditors = (state: EditorTabsState): Array<EditorTab> => {
return state.editors;
export const getEditorsForPane = (
state: EditorTabsState,
paneIdentifier: string
): Array<EditorTab> => {
return state.panes[paneIdentifier].editors || [];
};
export const getCurrentTabIndex = (state: EditorTabsState): number => {
return state.currentTab;
export const getCurrentTabIndexForPane = (
state: EditorTabsState,
paneIdentifier: string
): number => {
const pane = state.panes[paneIdentifier];
return pane.currentTab || 0;
};
export const getCurrentTab = (state: EditorTabsState): EditorTab => {
return state.editors[state.currentTab];
export const getCurrentTabForPane = (
state: EditorTabsState,
paneIdentifier: string
): EditorTab | null => {
const pane = state.panes[paneIdentifier];
return pane.editors[pane.currentTab] || null;
};
export const closeProjectTabs = (
@@ -261,15 +336,22 @@ export const closeProjectTabs = (
* to the project.
*/
export const saveUiSettings = (state: EditorTabsState) => {
state.editors.forEach(editorTab => {
if (
editorTab.editorRef &&
(editorTab.editorRef instanceof SceneEditorContainer ||
editorTab.editorRef instanceof ExternalLayoutEditorContainer)
) {
editorTab.editorRef.saveUiSettings();
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
continue;
}
});
pane.editors.forEach(editorTab => {
if (
editorTab.editorRef &&
(editorTab.editorRef instanceof SceneEditorContainer ||
editorTab.editorRef instanceof ExternalLayoutEditorContainer)
) {
editorTab.editorRef.saveUiSettings();
}
});
}
};
/**
@@ -277,13 +359,20 @@ export const saveUiSettings = (state: EditorTabsState) => {
* to editors with changes to commit them (like modified extensions).
*/
export const notifyPreviewOrExportWillStart = (state: EditorTabsState) => {
state.editors.forEach(editorTab => {
const editor = editorTab.editorRef;
if (editor instanceof EventsFunctionsExtensionEditorContainer) {
editor.previewOrExportWillStart();
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
if (!pane) {
continue;
}
});
pane.editors.forEach(editorTab => {
const editor = editorTab.editorRef;
if (editor instanceof EventsFunctionsExtensionEditorContainer) {
editor.previewOrExportWillStart();
}
});
}
};
export const closeLayoutTabs = (state: EditorTabsState, layout: gdLayout) => {
@@ -405,14 +494,21 @@ export const closeEventsBasedObjectVariantTab = (
export const getEventsFunctionsExtensionEditor = (
state: EditorTabsState,
eventsFunctionsExtension: gdEventsFunctionsExtension
): ?{| editor: EventsFunctionsExtensionEditorContainer, tabIndex: number |} => {
for (let tabIndex = 0; tabIndex < state.editors.length; ++tabIndex) {
const editor = state.editors[tabIndex].editorRef;
if (
editor instanceof EventsFunctionsExtensionEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension
) {
return { editor, tabIndex };
): ?{|
editor: EventsFunctionsExtensionEditorContainer,
paneIdentifier: string,
tabIndex: number,
|} => {
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
for (let tabIndex = 0; tabIndex < pane.editors.length; ++tabIndex) {
const editor = pane.editors[tabIndex].editorRef;
if (
editor instanceof EventsFunctionsExtensionEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension
) {
return { editor, paneIdentifier, tabIndex };
}
}
}
@@ -424,16 +520,23 @@ export const getCustomObjectEditor = (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variantName: string
): ?{| editor: CustomObjectEditorContainer, tabIndex: number |} => {
for (let tabIndex = 0; tabIndex < state.editors.length; ++tabIndex) {
const editor = state.editors[tabIndex].editorRef;
if (
editor instanceof CustomObjectEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension &&
editor.getEventsBasedObject() === eventsBasedObject &&
editor.getVariantName() === variantName
) {
return { editor, tabIndex };
): ?{|
editor: CustomObjectEditorContainer,
paneIdentifier: string,
tabIndex: number,
|} => {
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
for (let tabIndex = 0; tabIndex < pane.editors.length; ++tabIndex) {
const editor = pane.editors[tabIndex].editorRef;
if (
editor instanceof CustomObjectEditorContainer &&
editor.getEventsFunctionsExtension() === eventsFunctionsExtension &&
editor.getEventsBasedObject() === eventsBasedObject &&
editor.getVariantName() === variantName
) {
return { editor, paneIdentifier, tabIndex };
}
}
}
@@ -442,6 +545,7 @@ export const getCustomObjectEditor = (
export const moveTabToTheRightOfHoveredTab = (
editorTabsState: EditorTabsState,
paneIdentifier: string,
movingTabIndex: number,
hoveredTabIndex: number
): EditorTabsState => {
@@ -450,21 +554,32 @@ export const moveTabToTheRightOfHoveredTab = (
const destinationIndex =
movingTabIndex > hoveredTabIndex ? hoveredTabIndex + 1 : hoveredTabIndex;
return moveTabToPosition(editorTabsState, movingTabIndex, destinationIndex);
return moveTabToPosition(
editorTabsState,
paneIdentifier,
movingTabIndex,
destinationIndex
);
};
export const moveTabToPosition = (
editorTabsState: EditorTabsState,
paneIdentifier: string,
fromIndex: number,
toIndex: number
): EditorTabsState => {
const currentEditorTabs = [...getEditors(editorTabsState)];
const movingTab = currentEditorTabs[fromIndex];
currentEditorTabs.splice(fromIndex, 1);
currentEditorTabs.splice(toIndex, 0, movingTab);
const paneNewEditorTabs = [
...getEditorsForPane(editorTabsState, paneIdentifier),
];
const movingTab = paneNewEditorTabs[fromIndex];
paneNewEditorTabs.splice(fromIndex, 1);
paneNewEditorTabs.splice(toIndex, 0, movingTab);
let currentTabIndex = getCurrentTabIndex(editorTabsState);
let currentTabNewIndex = currentTabIndex;
let currentTabIndex = getCurrentTabIndexForPane(
editorTabsState,
paneIdentifier
);
let paneNewCurrentTab = currentTabIndex;
const movingTabIsCurrentTab = fromIndex === currentTabIndex;
const tabIsMovedFromLeftToRightOfCurrentTab =
@@ -472,25 +587,86 @@ export const moveTabToPosition = (
const tabIsMovedFromRightToLeftOfCurrentTab =
fromIndex > currentTabIndex && toIndex <= currentTabIndex;
if (movingTabIsCurrentTab) currentTabNewIndex = toIndex;
else if (tabIsMovedFromLeftToRightOfCurrentTab) currentTabNewIndex -= 1;
else if (tabIsMovedFromRightToLeftOfCurrentTab) currentTabNewIndex += 1;
if (movingTabIsCurrentTab) paneNewCurrentTab = toIndex;
else if (tabIsMovedFromLeftToRightOfCurrentTab) paneNewCurrentTab -= 1;
else if (tabIsMovedFromRightToLeftOfCurrentTab) paneNewCurrentTab += 1;
return { editors: currentEditorTabs, currentTab: currentTabNewIndex };
return {
...editorTabsState,
panes: {
...editorTabsState.panes,
[paneIdentifier]: {
...editorTabsState.panes[paneIdentifier],
editors: paneNewEditorTabs,
currentTab: paneNewCurrentTab,
},
},
};
};
export const hasEditorTabOpenedWithKey = (
export const getEditorTabOpenedWithKey = (
editorTabsState: EditorTabsState,
key: string
) => {
return !!editorTabsState.editors.find(editor => editor.key === key);
): {|
paneIdentifier: string,
editorTab: EditorTab,
|} | null => {
for (const paneIdentifier in editorTabsState.panes) {
const pane = editorTabsState.panes[paneIdentifier];
const editorTab = pane && pane.editors.find(editor => editor.key === key);
if (editorTab) {
return { editorTab, paneIdentifier };
}
}
return null;
};
const hasEditorTabOpenedWithKey = (
editorTabsState: EditorTabsState,
key: string
): boolean => {
return getEditorTabOpenedWithKey(editorTabsState, key) !== null;
};
export const getOpenedAskAiEditor = (
state: EditorTabsState
): AskAiEditorInterface | null => {
const editor = state.editors.find(editor => editor.key === 'ask-ai');
): null | {|
askAiEditor: AskAiEditorInterface,
editorTab: EditorTab,
paneIdentifier: string,
|} => {
const currentEditorTabAndPaneIdentifier = getEditorTabOpenedWithKey(
state,
'ask-ai'
);
if (!currentEditorTabAndPaneIdentifier) return null;
// $FlowFixMe - the key ensures that the editor is an AskAiEditorInterface.
return (editor && editor.editorRef) || null;
return {
// $FlowFixMe - the key ensures that the editor is an AskAiEditorInterface.
askAiEditor: currentEditorTabAndPaneIdentifier.editorTab.editorRef,
editorTab: currentEditorTabAndPaneIdentifier.editorTab,
paneIdentifier: currentEditorTabAndPaneIdentifier.paneIdentifier,
};
};
export const getAllEditorTabs = (state: EditorTabsState): Array<EditorTab> => {
const allEditors = [];
for (const paneIdentifier in state.panes) {
const pane = state.panes[paneIdentifier];
allEditors.push(...pane.editors);
}
return allEditors;
};
export const hasEditorsInPane = (
state: EditorTabsState,
paneIdentifier: string
): boolean => {
const pane = state.panes[paneIdentifier];
if (!pane) {
return false;
}
return pane.editors.length > 0;
};

View File

@@ -23,7 +23,9 @@ type Props = {|
name: string,
dontFocusTab?: boolean,
project?: ?gdProject,
paneIdentifier?: 'left' | 'center' | 'right' | null,
mode?: 'chat' | 'agent',
aiRequestId?: string | null,
|}) => EditorOpeningOptions,
|};
@@ -77,11 +79,12 @@ const useEditorTabsStateSaving = ({
} = React.useContext(PreferencesContext);
const saveEditorState = React.useCallback(
() => {
// TODO: adapt for saving multiple panes.
// Do not save the state if the user is on the start page
if (!currentProjectId || editorTabs.currentTab === 0) return;
if (!currentProjectId || editorTabs.panes.center.currentTab === 0) return;
const editorState = {
currentTab: editorTabs.currentTab,
editors: editorTabs.editors
currentTab: editorTabs.panes.center.currentTab,
editors: editorTabs.panes.center.editors
.filter(editor => editor.key !== 'start page')
.map(getEditorTabMetadata),
};
@@ -175,9 +178,10 @@ const useEditorTabsStateSaving = ({
}
newEditorTabs = changeCurrentTab(
newEditorTabs,
'center',
shouldOpenSavedCurrentTab
? editorState.editorTabs.currentTab
: newEditorTabs.editors.length >= 1
: newEditorTabs.panes.center.editors.length >= 1
? 1
: 0
);

View File

@@ -0,0 +1,699 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import TabsTitlebar from './TabsTitlebar';
import Toolbar, { type ToolbarInterface } from './Toolbar';
import { TabContentContainer } from '../UI/ClosableTabs';
import { DraggableEditorTabs } from './EditorTabs/DraggableEditorTabs';
import CommandsContextScopedProvider from '../CommandPalette/CommandsScopedContext';
import ErrorBoundary, {
getEditorErrorBoundaryProps,
} from '../UI/ErrorBoundary';
import {
getEditorsForPane,
getCurrentTabIndexForPane,
getCurrentTabForPane,
type EditorTabsState,
type EditorTab,
getEditorTabOpenedWithKey,
changeCurrentTab,
closeEditorTab,
closeOtherEditorTabs,
closeAllEditorTabs,
moveTabToTheRightOfHoveredTab,
saveUiSettings,
} from './EditorTabs/EditorTabsHandler';
import { type PreviewState } from './PreviewState';
import { type SceneEventsOutsideEditorChanges } from './EditorContainers/BaseEditor';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
import { type GamesList } from '../GameDashboard/UseGamesList';
import { type GamesPlatformFrameTools } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
import {
type FileMetadata,
type FileMetadataAndStorageProviderName,
} from '../ProjectsStorage';
import UnsavedChangesContext from './UnsavedChangesContext';
import { type OpenedVersionStatus } from '../VersionHistory';
import { type StorageProvider } from '../ProjectsStorage';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { type PrivateGameTemplateListingData } from '../Utils/GDevelopServices/Shop';
import { type CourseChapter } from '../Utils/GDevelopServices/Asset';
import { type NewProjectSetup } from '../ProjectCreation/NewProjectSetupDialog';
import { type EventsFunctionsExtensionsState } from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import { type ShareTab } from '../ExportAndShare/ShareDialog';
import { SpecificDimensionsWindowSizeProvider } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { MuiThemeOnlyProvider } from '../UI/Theme/FullThemeProvider';
import useForceUpdate from '../Utils/UseForceUpdate';
import useOnResize from '../Utils/UseOnResize';
import DrawerTopBar from '../UI/DrawerTopBar';
import { type FloatingPaneState } from './PanesContainer';
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
minWidth: 0,
},
};
export type EditorTabsPaneCommonProps = {|
editorTabs: EditorTabsState,
currentProject: ?gdProject,
currentFileMetadata: ?FileMetadata,
canSave: boolean,
isSavingProject: boolean,
isSharingEnabled: boolean,
hasPreviewsRunning: boolean,
previewState: PreviewState,
checkedOutVersionStatus: ?OpenedVersionStatus,
canDoNetworkPreview: boolean,
gamesPlatformFrameTools: GamesPlatformFrameTools,
// Callbacks from MainFrame
toggleProjectManager: () => void,
saveProject: () => Promise<void>,
openShareDialog: (tab?: ShareTab) => void,
launchDebuggerAndPreview: () => void,
launchNewPreview: (?{ numberOfWindows: number }) => Promise<void>,
launchNetworkPreview: () => Promise<void>,
launchHotReloadPreview: () => Promise<void>,
launchPreviewWithDiagnosticReport: () => Promise<void>,
setPreviewOverride: (override: {|
isPreviewOverriden: boolean,
overridenPreviewLayoutName: ?string,
overridenPreviewExternalLayoutName: ?string,
|}) => void,
openVersionHistoryPanel: () => void,
onQuitVersionHistory: () => Promise<void>,
onOpenAskAi: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
paneIdentifier: 'left' | 'center' | 'right' | null,
|}) => void,
getStorageProvider: () => StorageProvider,
setPreviewedLayout: (layoutName: ?string) => void,
openExternalEvents: (name: string) => void,
openLayout: (
name: string,
options?: {|
openEventsEditor: boolean,
openSceneEditor: boolean,
focusWhenOpened:
| 'scene-or-events-otherwise'
| 'scene'
| 'events'
| 'none',
|}
) => void,
openTemplateFromTutorial: (tutorialId: string) => Promise<void>,
openTemplateFromCourseChapter: (
courseChapter: CourseChapter,
templateId?: string
) => Promise<void>,
previewDebuggerServer: ?any,
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
resourceManagementProps: ResourceManagementProps,
onCreateEventsFunction: (
extensionName: string,
eventsFunction: gdEventsFunction,
editorIdentifier:
| 'scene-events-editor'
| 'extension-events-editor'
| 'external-events-editor'
) => void,
openInstructionOrExpression: (
extension: gdPlatformExtension,
type: string
) => void,
onOpenCustomObjectEditor: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variantName: string
) => void,
onRenamedEventsBasedObject: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
oldName: string,
newName: string
) => void,
onDeletedEventsBasedObject: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
name: string
) => void,
openObjectEvents: (extensionName: string, objectName: string) => void,
canOpen: boolean,
openOpenFromStorageProviderDialog: () => void,
openFromFileMetadataWithStorageProvider: (
file: FileMetadataAndStorageProviderName
) => Promise<void>,
openNewProjectDialog: () => void,
openProjectManager: (open: boolean) => void,
askToCloseProject: () => Promise<boolean>,
closeProject: () => Promise<void>,
onSelectExampleShortHeader: ({|
exampleShortHeader: ?ExampleShortHeader,
preventBackHome?: boolean,
|}) => void,
onSelectPrivateGameTemplateListingData: ({|
privateGameTemplateListingData: ?PrivateGameTemplateListingData,
preventBackHome?: boolean,
|}) => void,
createEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
createProjectFromExample: (
exampleShortHeader: ExampleShortHeader,
newProjectSetup: NewProjectSetup,
i18n: I18nType,
isQuickCustomization?: boolean
) => Promise<void>,
onOpenProfileDialog: () => void,
openLanguageDialog: (open: boolean) => void,
openPreferencesDialog: (open: boolean) => void,
openAboutDialog: (open: boolean) => void,
selectInAppTutorial: (tutorialId: string) => void,
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
isProjectClosedSoAvoidReloadingExtensions: boolean,
renameResourcesInProject: (
project: gdProject,
renames: { [string]: string }
) => void,
openBehaviorEvents: (extensionName: string, behaviorName: string) => void,
onExtractAsExternalLayout: (name: string) => void,
onOpenEventBasedObjectEditor: (
extensionName: string,
eventsBasedObjectName: string
) => void,
onOpenEventBasedObjectVariantEditor: (
extensionName: string,
eventsBasedObjectName: string,
variantName: string
) => void,
deleteEventsBasedObjectVariant: (
eventsFunctionsExtension: gdEventsFunctionsExtension,
eventsBasedObject: gdEventsBasedObject,
variant: gdEventsBasedObjectVariant
) => void,
onEventsBasedObjectChildrenEdited: (
eventsBasedObject: gdEventsBasedObject
) => void,
onSceneObjectEdited: (
scene: gdLayout,
objectWithContext: ObjectWithContext
) => void,
onSceneObjectsDeleted: (scene: gdLayout) => void,
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
gamesList: GamesList,
setEditorTabs: (editorTabs: EditorTabsState) => void,
|};
type Props = {|
...EditorTabsPaneCommonProps,
onSetPointerEventsNone: (enablePointerEventsNone: boolean) => void,
paneIdentifier: string,
isLeftMostPane: boolean,
isRightMostPane: boolean,
isDrawer: boolean,
areSidePanesDrawers: boolean,
onSetPaneDrawerState: (
paneIdentifier: string,
newState: FloatingPaneState
) => void,
|};
const EditorTabsPane = React.forwardRef<Props, {||}>((props, ref) => {
const {
editorTabs,
currentProject,
currentFileMetadata,
canSave,
isSavingProject,
isSharingEnabled,
hasPreviewsRunning,
previewState,
checkedOutVersionStatus,
canDoNetworkPreview,
gamesPlatformFrameTools,
toggleProjectManager,
saveProject,
openShareDialog,
launchDebuggerAndPreview,
launchNewPreview,
launchNetworkPreview,
launchHotReloadPreview,
launchPreviewWithDiagnosticReport,
setPreviewOverride,
openVersionHistoryPanel,
onQuitVersionHistory,
onOpenAskAi,
getStorageProvider,
setPreviewedLayout,
openExternalEvents,
openLayout,
openTemplateFromTutorial,
openTemplateFromCourseChapter,
previewDebuggerServer,
hotReloadPreviewButtonProps,
resourceManagementProps,
onCreateEventsFunction,
openInstructionOrExpression,
onOpenCustomObjectEditor,
onRenamedEventsBasedObject,
onDeletedEventsBasedObject,
openObjectEvents,
canOpen,
openOpenFromStorageProviderDialog,
openFromFileMetadataWithStorageProvider,
openNewProjectDialog,
openProjectManager,
askToCloseProject,
closeProject,
onSelectExampleShortHeader,
onSelectPrivateGameTemplateListingData,
createEmptyProject,
createProjectFromExample,
onOpenProfileDialog,
openLanguageDialog,
openPreferencesDialog,
openAboutDialog,
selectInAppTutorial,
eventsFunctionsExtensionsState,
isProjectClosedSoAvoidReloadingExtensions,
renameResourcesInProject,
openBehaviorEvents,
onExtractAsExternalLayout,
onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor,
deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited,
onSceneObjectEdited,
onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor,
onExtensionInstalled,
gamesList,
setEditorTabs,
onSetPointerEventsNone,
paneIdentifier,
isLeftMostPane,
isRightMostPane,
isDrawer,
onSetPaneDrawerState,
areSidePanesDrawers,
} = props;
const toolbarRef = React.useRef<?ToolbarInterface>(null);
const unsavedChanges = React.useContext(UnsavedChangesContext);
const askAiPaneIdentifier = getEditorTabOpenedWithKey(editorTabs, 'ask-ai');
const containerRef = React.useRef<?HTMLDivElement>(null);
const [
tabsTitleBarAndEditorToolbarHidden,
setTabsTitleBarAndEditorToolbarHidden,
] = React.useState(false);
const onSetGamesPlatformFrameShown = React.useCallback(
({ shown, isMobile }: {| shown: boolean, isMobile: boolean |}) => {
onSetPointerEventsNone(shown);
setTabsTitleBarAndEditorToolbarHidden(shown && isMobile);
},
[onSetPointerEventsNone]
);
// Internal editor toolbar management
const setEditorToolbar = React.useCallback(
(editorToolbar: ?React.Node, isCurrentTab: boolean = true) => {
if (!toolbarRef.current || !isCurrentTab) return;
toolbarRef.current.setEditorToolbar(editorToolbar || null);
},
[]
);
const updateToolbar = React.useCallback(
() => {
const editorTab = getCurrentTabForPane(editorTabs, paneIdentifier);
if (!editorTab || !editorTab.editorRef) {
setEditorToolbar(null);
return;
}
editorTab.editorRef.updateToolbar();
},
[editorTabs, setEditorToolbar, paneIdentifier]
);
React.useEffect(
() => {
updateToolbar();
},
[updateToolbar]
);
// Tab management functions
const onEditorTabActivated = React.useCallback(
(editorTab: EditorTab) => {
updateToolbar();
// Ensure the editors shown on the screen are updated. This is for
// example useful if global objects have been updated in another editor.
if (editorTab.editorRef) {
editorTab.editorRef.forceUpdateEditor();
}
},
[updateToolbar]
);
const onChangeEditorTab = React.useCallback(
(value: number) => {
const newEditorTabs = changeCurrentTab(editorTabs, paneIdentifier, value);
setEditorTabs(newEditorTabs);
const newCurrentTab = getCurrentTabForPane(newEditorTabs, paneIdentifier);
if (newCurrentTab) {
onEditorTabActivated(newCurrentTab);
}
},
[editorTabs, setEditorTabs, onEditorTabActivated, paneIdentifier]
);
const onCloseEditorTab = React.useCallback(
(editorTab: EditorTab) => {
saveUiSettings(editorTabs);
setEditorTabs(closeEditorTab(editorTabs, editorTab));
},
[editorTabs, setEditorTabs]
);
const onCloseOtherEditorTabs = React.useCallback(
(editorTab: EditorTab) => {
saveUiSettings(editorTabs);
setEditorTabs(closeOtherEditorTabs(editorTabs, editorTab));
},
[editorTabs, setEditorTabs]
);
const onCloseAllEditorTabs = React.useCallback(
() => {
saveUiSettings(editorTabs);
setEditorTabs(closeAllEditorTabs(editorTabs));
},
[editorTabs, setEditorTabs]
);
const onDropEditorTab = React.useCallback(
(fromIndex: number, toHoveredIndex: number) => {
setEditorTabs(
moveTabToTheRightOfHoveredTab(
editorTabs,
paneIdentifier,
fromIndex,
toHoveredIndex
)
);
},
[editorTabs, paneIdentifier, setEditorTabs]
);
const paneEditorTabs = getEditorsForPane(editorTabs, paneIdentifier);
const currentTab = getCurrentTabForPane(editorTabs, paneIdentifier);
// Use a layout effect to read the pane width and height, which is then used
// to communicate to children editors the dimensions of their "window" (the pane).
// The layout effect ensures that we get the pane width and height after the pane has been rendered
// but before the browser repaints the component.
const [paneWidth, setPaneWidth] = React.useState<number | null>(null);
const [paneHeight, setPaneHeight] = React.useState<number | null>(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
const container = containerRef.current;
if (container) {
if (
container.clientWidth !== paneWidth ||
container.clientHeight !== paneHeight
) {
setPaneWidth(container.clientWidth);
setPaneHeight(container.clientHeight);
}
}
});
useOnResize(useForceUpdate()); // Ensure the pane is re-rendered when the window is resized.
const onOpenAskAiFromTitlebar = React.useCallback(
() => {
if (
askAiPaneIdentifier &&
askAiPaneIdentifier.paneIdentifier === 'right'
) {
onSetPaneDrawerState('right', 'open');
return;
}
onOpenAskAi({
mode: 'agent',
aiRequestId: null,
paneIdentifier: currentProject ? 'right' : 'center',
});
},
[askAiPaneIdentifier, onOpenAskAi, currentProject, onSetPaneDrawerState]
);
return (
<div style={styles.container} ref={containerRef}>
{isDrawer ? (
<DrawerTopBar
drawerAnchor={isRightMostPane ? 'right' : 'left'}
title={'Ask AI'}
id={paneIdentifier + '-top-bar'}
onClose={() => onSetPaneDrawerState(paneIdentifier, 'closed')}
/>
) : (
<TabsTitlebar
isLeftMostPane={isLeftMostPane}
isRightMostPane={isRightMostPane}
displayMenuIcon={paneIdentifier === 'center'}
hidden={tabsTitleBarAndEditorToolbarHidden}
toggleProjectManager={toggleProjectManager}
renderTabs={(onEditorTabHovered, onEditorTabClosing) => (
<DraggableEditorTabs
hideLabels={false}
editors={paneEditorTabs}
currentTab={currentTab}
onClickTab={onChangeEditorTab}
onCloseTab={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseEditorTab(editorTab);
}}
onCloseOtherTabs={(editorTab: EditorTab) => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseOtherEditorTabs(editorTab);
}}
onCloseAll={() => {
// Call onEditorTabClosing before to ensure any tooltip is removed before the tab is closed.
onEditorTabClosing();
onCloseAllEditorTabs();
}}
onTabActivated={onEditorTabActivated}
onDropTab={onDropEditorTab}
onHoverTab={onEditorTabHovered}
/>
)}
displayAskAi={
!askAiPaneIdentifier
? // If Ask AI is closed, display the button on the right most part of the window.
isRightMostPane
: // If it's open, only show it if it's in a drawer pane.
areSidePanesDrawers &&
askAiPaneIdentifier.paneIdentifier !== 'center'
}
onAskAiClicked={onOpenAskAiFromTitlebar}
/>
)}
<Toolbar
ref={toolbarRef}
hidden={tabsTitleBarAndEditorToolbarHidden}
showProjectButtons={
!['start page', 'debugger', 'ask-ai', null].includes(
currentTab ? currentTab.key : null
)
}
canSave={canSave}
onSave={saveProject}
openShareDialog={() =>
openShareDialog(/* leave the dialog decide which tab to open */)
}
isSharingEnabled={isSharingEnabled}
onOpenDebugger={launchDebuggerAndPreview}
hasPreviewsRunning={hasPreviewsRunning}
onPreviewWithoutHotReload={launchNewPreview}
onNetworkPreview={launchNetworkPreview}
onHotReloadPreview={launchHotReloadPreview}
onLaunchPreviewWithDiagnosticReport={launchPreviewWithDiagnosticReport}
canDoNetworkPreview={canDoNetworkPreview}
setPreviewOverride={setPreviewOverride}
isPreviewEnabled={
!!currentProject && currentProject.getLayoutsCount() > 0
}
previewState={previewState}
onOpenVersionHistory={openVersionHistoryPanel}
checkedOutVersionStatus={checkedOutVersionStatus}
onQuitVersionHistory={onQuitVersionHistory}
canQuitVersionHistory={!isSavingProject}
/>
<SpecificDimensionsWindowSizeProvider
innerWidth={paneWidth}
innerHeight={paneHeight}
>
<MuiThemeOnlyProvider>
{paneEditorTabs.map((editorTab, id) => {
const isCurrentTab =
getCurrentTabIndexForPane(editorTabs, paneIdentifier) === id;
const errorBoundaryProps = getEditorErrorBoundaryProps(
editorTab.key
);
return (
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary
componentTitle={errorBoundaryProps.componentTitle}
scope={errorBoundaryProps.scope}
>
{editorTab.renderEditorContainer({
isActive: isCurrentTab,
extraEditorProps: editorTab.extraEditorProps,
project: currentProject,
fileMetadata: currentFileMetadata,
storageProvider: getStorageProvider(),
ref: editorRef => (editorTab.editorRef = editorRef),
setToolbar: editorToolbar =>
setEditorToolbar(editorToolbar, isCurrentTab),
setGamesPlatformFrameShown: onSetGamesPlatformFrameShown,
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenAskAi,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) => {
openLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: false,
focusWhenOpened: 'events',
});
},
onOpenLayout: openLayout,
onOpenTemplateFromTutorial: openTemplateFromTutorial,
onOpenTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer,
hotReloadPreviewButtonProps,
resourceManagementProps,
onSave: saveProject,
canSave,
onCreateEventsFunction,
openInstructionOrExpression,
onOpenCustomObjectEditor: onOpenCustomObjectEditor,
onRenamedEventsBasedObject: onRenamedEventsBasedObject,
onDeletedEventsBasedObject: onDeletedEventsBasedObject,
openObjectEvents,
unsavedChanges: unsavedChanges,
canOpen,
onChooseProject: () =>
openOpenFromStorageProviderDialog(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onOpenNewProjectSetupDialog: openNewProjectDialog,
onOpenProjectManager: () => openProjectManager(true),
onOpenVersionHistory: openVersionHistoryPanel,
askToCloseProject,
closeProject,
onSelectExampleShortHeader: exampleShortHeader => {
onSelectExampleShortHeader({
exampleShortHeader,
preventBackHome: true,
});
},
onSelectPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onOpenPrivateGameTemplateListingData: privateGameTemplateListingData => {
onSelectPrivateGameTemplateListingData({
privateGameTemplateListingData,
preventBackHome: true,
});
},
onCreateEmptyProject: createEmptyProject,
onCreateProjectFromExample: createProjectFromExample,
onOpenProfile: onOpenProfileDialog,
onOpenLanguageDialog: () => openLanguageDialog(true),
onOpenPreferences: () => openPreferencesDialog(true),
onOpenAbout: () => openAboutDialog(true),
selectInAppTutorial: selectInAppTutorial,
onLoadEventsFunctionsExtensions: async () => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
return eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
currentProject
);
},
onReloadEventsFunctionsExtensionMetadata: extension => {
if (isProjectClosedSoAvoidReloadingExtensions) {
return;
}
eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensionMetadata(
currentProject,
extension
);
},
onDeleteResource: (
resource: gdResource,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
},
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => {
if (currentProject)
renameResourcesInProject(currentProject, {
[resource.getName()]: newName,
});
cb(true);
},
openBehaviorEvents: openBehaviorEvents,
onExtractAsExternalLayout: onExtractAsExternalLayout,
onExtractAsEventBasedObject: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor,
onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor,
onDeleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant,
onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited,
onSceneObjectEdited: onSceneObjectEdited,
onSceneObjectsDeleted: onSceneObjectsDeleted,
onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor,
onExtensionInstalled: onExtensionInstalled,
gamesList,
gamesPlatformFrameTools,
})}
</ErrorBoundary>
</CommandsContextScopedProvider>
</TabContentContainer>
);
})}
</MuiThemeOnlyProvider>
</SpecificDimensionsWindowSizeProvider>
</div>
);
});
export default EditorTabsPane;

Some files were not shown because too many files have changed in this diff Show More