diff --git a/.zed/settings.json b/.zed/settings.json index 489ce49..dbdbcb4 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -43,6 +43,7 @@ }, "completion": { "enabled": true, + "enableFragmentAutocomplete": false, "imports": { "enabled": true, "separateGroupsWithLine": true, @@ -66,12 +67,12 @@ "enabled": true, "download_api_documentation": true, "download_definitions": true + }, + "fflags": { + "enable_by_default": true, + "enable_new_solver": false, + "sync": true } - // "fflags": { - // "enable_by_default": true, - // "enable_new_solver": true, - // "sync": true - // } } } } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b5fa3fa..0c8145f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -525,9 +525,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1061,6 +1061,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -1375,9 +1396,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -1388,9 +1409,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -1398,9 +1419,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1408,9 +1429,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -1421,18 +1442,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.67" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941c102b3f0c15b6d72a53205e09e6646aafcf2991e18412cc331dbac1806bc0" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" dependencies = [ "async-trait", "cast", @@ -1452,9 +1473,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.67" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26bd6570f39bb1440fd8f01b63461faaf2a3f6078a508e4e54efa99363108d2" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" dependencies = [ "proc-macro2", "quote", @@ -1463,9 +1484,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c29582b14d5bf030b02fa232b9b57faf2afc322d2c61964dd80bad02bf76207" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" [[package]] name = "wasm-encoder" @@ -1516,9 +1537,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -1638,9 +1659,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.7.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" dependencies = [ "async-trait", "axum", @@ -1657,6 +1678,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", + "strum", "tokio", "url", "wasm-bindgen", @@ -1669,13 +1691,14 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.7.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" dependencies = [ "async-trait", "proc-macro2", "quote", + "strum", "syn", "wasm-bindgen", "wasm-bindgen-futures", @@ -1685,9 +1708,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.7.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" dependencies = [ "cfg-if", "js-sys", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7f05ae1..d8e025f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,8 +8,8 @@ authors = ["Brooke Rhodes "] crate-type = ["cdylib", "rlib"] [dependencies] -worker = { version = "0.7", features = ['http', 'axum'] } -worker-macros = { version = "0.7", features = ['http'] } +worker = { version = "0.8", features = ['http', 'axum'] } +worker-macros = { version = "0.8", features = ['http'] } axum = { version = "0.8", features = [ 'json', 'query', diff --git a/plugin/src/ActivityPreview/ActivityDetails.luau b/plugin/src/ActivityPreview/ActivityDetails.luau index 047db76..166c779 100644 --- a/plugin/src/ActivityPreview/ActivityDetails.luau +++ b/plugin/src/ActivityPreview/ActivityDetails.luau @@ -23,23 +23,23 @@ local function ActivityDetails(props: Props) local nextOrder = createNextOrder() return e(View, { - tag = "auto-xy col gap-xxsmall", + tag = "size-full-0 auto-y col gap-xxsmall", LayoutOrder = props.LayoutOrder, }, { Name = e(Text, { - tag = "auto-xy text-title-medium content-emphasis", + tag = "size-full-0 auto-y text-align-x-left text-wrap text-title-medium content-emphasis", Text = props.activity.name, LayoutOrder = nextOrder(), }), Details = props.activity.details and e(Text, { - tag = "auto-xy text-body-small content-default", + tag = "size-full-0 auto-y text-align-x-left text-wrap text-body-small content-default", Text = props.activity.details, LayoutOrder = nextOrder(), }), State = props.activity.state and e(Text, { - tag = "auto-xy text-body-small content-default", + tag = "size-full-0 auto-y text-align-x-left text-wrap text-body-small content-default", Text = props.activity.state, LayoutOrder = nextOrder(), }), diff --git a/plugin/src/ActivityPreview/ActivityImage.luau b/plugin/src/ActivityPreview/ActivityImage.luau index e0702c7..41ebb40 100644 --- a/plugin/src/ActivityPreview/ActivityImage.luau +++ b/plugin/src/ActivityPreview/ActivityImage.luau @@ -7,7 +7,7 @@ local ReactCharm = require(Packages.ReactCharm) local AppAssetsStore = require(Plugin.Source.Api.AppAssetsStore) local Types = require(script.Parent.Types) -local WebImage = require(script.Parent.WebImage) +local WebImage = require(Plugin.Source.Common.WebImage) local View = Foundation.View local Tooltip = Foundation.Tooltip diff --git a/plugin/src/ActivityPreview/ActivityPreview.luau b/plugin/src/ActivityPreview/ActivityPreview.luau index f702017..fa01699 100644 --- a/plugin/src/ActivityPreview/ActivityPreview.luau +++ b/plugin/src/ActivityPreview/ActivityPreview.luau @@ -27,7 +27,7 @@ local function ActivityPreview(props: Props) local nextOrder = createNextOrder() return e(View, { - tag = "size-full-0 auto-y bg-surface-300 col padding-small gap-medium radius-small stroke-emphasis stroke-thick", + tag = "size-full-0 auto-y bg-surface-300 clip col padding-small gap-medium radius-small stroke-emphasis stroke-thick", LayoutOrder = props.LayoutOrder, }, { Shadow = Flags.isShadowAvailable and e("UIShadow", { @@ -42,7 +42,10 @@ local function ActivityPreview(props: Props) }), Activity = e(View, { - tag = "auto-xy row gap-large padding-x-xsmall align-y-center", + tag = "size-full-0 auto-y row gap-large padding-x-xsmall align-y-top", + sizeConstraint = { + MaxSize = Vector2.new(math.huge, 300), + }, LayoutOrder = nextOrder(), }, { ActivityImage = e(ActivityImage, { @@ -51,7 +54,7 @@ local function ActivityPreview(props: Props) }), DetailsBlock = e(View, { - tag = "auto-xy col gap-xsmall", + tag = "grow auto-y col gap-xsmall", LayoutOrder = nextOrder(), }, { ActivityDetails = e(ActivityDetails, { diff --git a/plugin/src/AddAccountFlow/AddAccountFlow.luau b/plugin/src/AddAccountFlow/AddAccountFlow.luau index 386104e..56bddc9 100644 --- a/plugin/src/AddAccountFlow/AddAccountFlow.luau +++ b/plugin/src/AddAccountFlow/AddAccountFlow.luau @@ -37,7 +37,7 @@ local function AddAccountFlow(props: Props) return e(Sheet.Root, { isOpen = props.isOpen, preferCenterSheet = true, - size = DialogSize.Small, + size = DialogSize.Medium, onClose = props.onClose, }, { Header = e(Sheet.Header, { diff --git a/plugin/src/MainScreen/Section.luau b/plugin/src/Common/Section.luau similarity index 94% rename from plugin/src/MainScreen/Section.luau rename to plugin/src/Common/Section.luau index 84cfd78..3f0cc6e 100644 --- a/plugin/src/MainScreen/Section.luau +++ b/plugin/src/Common/Section.luau @@ -18,7 +18,7 @@ local e = React.createElement local useState = React.useState type Props = { - title: string, + title: string?, hasDivider: boolean, canToggleExpand: boolean, LayoutOrder: number?, @@ -36,10 +36,10 @@ local function Section(props: Props) LayoutOrder = props.LayoutOrder, }, { LayoutContainer = e(View, { - tag = "size-full-0 auto-y col gap-small padding-x-small padding-y-medium", + tag = "size-full-0 auto-y col gap-small padding-y-medium", LayoutOrder = nextOrder(), }, { - HeaderBlock = e(View, { + HeaderBlock = props.title and e(View, { tag = "size-full-0 auto-y row radius-small align-y-center padding-x-small padding-y-xsmall", LayoutOrder = nextOrder(), onActivated = if props.canToggleExpand diff --git a/plugin/src/ActivityPreview/WebImage.luau b/plugin/src/Common/WebImage.luau similarity index 61% rename from plugin/src/ActivityPreview/WebImage.luau rename to plugin/src/Common/WebImage.luau index 1a090ea..d89f4e3 100644 --- a/plugin/src/ActivityPreview/WebImage.luau +++ b/plugin/src/Common/WebImage.luau @@ -37,52 +37,55 @@ local function WebImage(props: Props) local isBig = props.size == "big" useEffect(function() - if imageData == nil then - setHasError(true) - return - end - local editableImage - local success, err = pcall(function() - local decoded = png.decode(imageData) - - local size = Vector2.new(decoded.width, decoded.height) - editableImage = AssetService:CreateEditableImage({ - Size = size, - }) - - editableImage:WritePixelsBuffer(Vector2.zero, size, decoded.pixels) - - local editableImageContent = Content.fromObject(editableImage) - if Flags.isDataModelContentApiAvailable then - -- TODO: Remove the type cast when this API has proper types - local result, content = (AssetService :: any):CreateDataModelContentAsync(editableImageContent) - -- TODO Luau: This enum is too new for the LSP to type check - if result == (Enum :: any).CreateContentResult.Success then - setImageContent(content) - editableImage:Destroy() - editableImage = nil :: any + local thread = task.spawn(function() + if imageData == nil then + setHasError(true) + return + end + + local success, err = pcall(function() + local decoded = png.decode(imageData) + + local size = Vector2.new(decoded.width, decoded.height) + editableImage = AssetService:CreateEditableImage({ + Size = size, + }) + + editableImage:WritePixelsBuffer(Vector2.zero, size, decoded.pixels) + + local editableImageContent = Content.fromObject(editableImage) + if Flags.isDataModelContentApiAvailable then + -- TODO: Remove the type cast when this API has proper types + local result, content = (AssetService :: any):CreateDataModelContentAsync(editableImageContent) + -- TODO Luau: This enum is too new for the LSP to type check + if result == (Enum :: any).CreateContentResult.Success then + setImageContent(content) + editableImage:Destroy() + editableImage = nil :: any + else + setImageContent(editableImageContent) + end else setImageContent(editableImageContent) end + + -- TODO: Migrate to the static content API once available. Should save + -- a bit of memory. + end) + + if not success then + setHasError(true) + Logger:Warn("Error while rendering web image", { + error = err, + }) else - setImageContent(editableImageContent) + setHasError(false) end - - -- TODO: Migrate to the static content API once available. Should save - -- a bit of memory. end) - if not success then - setHasError(true) - Logger:Warn("Error while rendering web image", { - error = err, - }) - else - setHasError(false) - end - return function() + task.cancel(thread) if editableImage then editableImage:Destroy() end diff --git a/plugin/src/Common/__tests__/interpolate.test.luau b/plugin/src/Common/__tests__/interpolate.test.luau new file mode 100644 index 0000000..c846b1b --- /dev/null +++ b/plugin/src/Common/__tests__/interpolate.test.luau @@ -0,0 +1,76 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Common = Plugin.Source.Common +local Packages = Plugin.Packages +local JestGlobals = require(Packages.Dev.JestGlobals) + +local describe = JestGlobals.describe +local expect = JestGlobals.expect +local it = JestGlobals.it + +local interpolate = require(Common.interpolate) + +describe("interpolate", function() + it("substitutes a single placeholder", function() + expect(interpolate("Hello {name}!", { name = "World" })).toBe("Hello World!") + end) + + it("substitutes multiple placeholders", function() + expect(interpolate("{a} and {b}", { a = "one", b = "two" })).toBe("one and two") + end) + + it("repeats the same placeholder", function() + expect(interpolate("{x}-{x}", { x = "yes" })).toBe("yes-yes") + end) + + it("preserves unknown placeholders verbatim", function() + expect(interpolate("{a} and {b}", { a = "yes" })).toBe("yes and {b}") + end) + + it("returns the input unchanged when no placeholders are present", function() + expect(interpolate("plain text", { name = "World" })).toBe("plain text") + end) + + it("returns an empty string for empty input", function() + expect(interpolate("", { name = "World" })).toBe("") + end) + + it("ignores braces around invalid keys", function() + expect(interpolate("{ spaced }", { spaced = "no" })).toBe("{ spaced }") + expect(interpolate("{}", {})).toBe("{}") + expect(interpolate("{a-b}", { ["a-b"] = "x" })).toBe("{a-b}") + end) + + it("leaves an unmatched opening brace literal", function() + expect(interpolate("hello {", {})).toBe("hello {") + end) + + it("supports underscores and digits in keys", function() + expect(interpolate("{place_id_1}", { place_id_1 = "12345" })).toBe("12345") + end) + + it("escapes braces with a backslash", function() + expect(interpolate("\\{name\\}", { name = "ignored" })).toBe("{name}") + end) + + it("escapes a backslash with a backslash", function() + expect(interpolate("path C:\\\\dir", {})).toBe("path C:\\dir") + end) + + it("treats a trailing backslash as a literal", function() + expect(interpolate("text\\", {})).toBe("text\\") + end) + + it("emits both characters when a backslash precedes a non-special char", function() + expect(interpolate("\\n", {})).toBe("n") + expect(interpolate("a\\b", {})).toBe("ab") + end) + + it("substitutes around escaped braces", function() + expect(interpolate("\\{{name}\\}", { name = "World" })).toBe("{World}") + end) + + it("substitutes adjacent placeholders", function() + expect(interpolate("{a}{b}", { a = "X", b = "Y" })).toBe("XY") + end) +end) diff --git a/plugin/src/Common/interpolate.luau b/plugin/src/Common/interpolate.luau new file mode 100644 index 0000000..13d6478 --- /dev/null +++ b/plugin/src/Common/interpolate.luau @@ -0,0 +1,63 @@ +--[[ + Interpolates placeholders in a string with values from a dictionary. + + Placeholders use `{key}` syntax, where `key` matches against alphanumeric + characters and underscores. Unmatched placeholders are left intact so users + see exactly what they typed when a key isn't recognised. + + Backslash escapes let users emit literal braces or backslashes: + + - `\{` and `\}` produce literal `{` and `}` (so the token isn't substituted) + - `\\` produces a literal `\` + - A trailing `\` at the end of input is treated as a literal `\` + - `\` followed by any other character emits both characters verbatim + + ```luau + interpolate("Hello {name}!", { name = "World" }) -- "Hello World!" + interpolate("{a} and {b}", { a = "yes" }) -- "yes and {b}" + interpolate("\\{name\\}", { name = "ignored" }) -- "{name}" + interpolate("path: C:\\\\dir", {}) -- "path: C:\\dir" + ``` +]] +local function interpolate(input: string, vars: { [string]: string }): string + local out: { string } = {} + local i = 1 + local length = #input + + while i <= length do + local char = string.sub(input, i, i) + + if char == "\\" then + if i == length then + table.insert(out, "\\") + i += 1 + else + table.insert(out, string.sub(input, i + 1, i + 1)) + i += 2 + end + elseif char == "{" then + local closeIndex = string.find(input, "}", i + 1, true) + local key = if closeIndex then string.match(input, "^{([%w_]+)}", i) else nil + + if key and closeIndex then + local value = vars[key] + if value == nil then + table.insert(out, "{" .. key .. "}") + else + table.insert(out, value) + end + i = (closeIndex :: number) + 1 + else + table.insert(out, char) + i += 1 + end + else + table.insert(out, char) + i += 1 + end + end + + return table.concat(out) +end + +return interpolate diff --git a/plugin/src/ActivityPreview/useWebImage.luau b/plugin/src/Common/useWebImage.luau similarity index 95% rename from plugin/src/ActivityPreview/useWebImage.luau rename to plugin/src/Common/useWebImage.luau index df3c689..34cfb74 100644 --- a/plugin/src/ActivityPreview/useWebImage.luau +++ b/plugin/src/Common/useWebImage.luau @@ -45,7 +45,7 @@ local function useWebImage(url: string): buffer? end local contentType = result.Headers["content-type"] or "" - if not string.find(contentType, "image/png", 1, true) then + if not string.find(string.lower(contentType), "image/png", 1, true) then Logger:Warn("Downloaded image wasn't a PNG", { url = url, contentType = contentType, diff --git a/plugin/src/CustomProfile/ActivityFields.luau b/plugin/src/CustomProfile/ActivityFields.luau new file mode 100644 index 0000000..55f3288 --- /dev/null +++ b/plugin/src/CustomProfile/ActivityFields.luau @@ -0,0 +1,110 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactCharm = require(Packages.ReactCharm) +local ReactUtils = require(Packages.ReactUtils) +local Sift = require(Packages.Sift) + +local ProfileContext = require(script.Parent.ProfileContext) +local Section = require(Plugin.Source.Common.Section) + +local View = Foundation.View +local Checkbox = Foundation.Checkbox +local TextInput = Foundation.TextInput +local InputSize = Foundation.Enums.InputSize + +local createNextOrder = ReactUtils.createNextOrder +local useSignalBinding = ReactCharm.useSignalBinding +local useSignalState = ReactCharm.useSignalState + +local e = React.createElement + +type CustomProfileData = ProfileContext.CustomProfileData + +type Props = { + LayoutOrder: number?, +} + +local function ActivityFields(props: Props) + local nextOrder = createNextOrder() + local profileStore = ProfileContext.useProfile().profileStore + + local currentProfile = useSignalState(profileStore.getProfile) + local profileBinding = useSignalBinding(profileStore.getProfile) + local showPlaceIcon = if currentProfile.activity.showPlaceIcon == nil + then true + else currentProfile.activity.showPlaceIcon + + return e(Section, { + title = "Activity Fields", + canToggleExpand = true, + hasDivider = true, + LayoutOrder = props.LayoutOrder, + }, { + LayoutBlock = e(View, { + tag = "size-full-0 auto-y col gap-medium", + LayoutOrder = nextOrder(), + }, { + GameIconToggle = e(Checkbox, { + label = "Show game icon", + isChecked = showPlaceIcon, + size = InputSize.Small, + LayoutOrder = nextOrder(), + onActivated = function() + profileStore.setProfile(function(prev) + -- Read from `prev` so concurrent updates don't desync; treat + -- the absence of an explicit value as the default (true). + local current = if prev.activity.showPlaceIcon == nil then true else prev.activity.showPlaceIcon + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showPlaceIcon = not current, + }, + }) + end) + end, + }), + + Details = e(TextInput, { + text = profileBinding:map(function(profile: CustomProfileData) + return profile.activity.details or "" + end), + label = "Line 1", + size = InputSize.Small, + width = UDim.new(1, 0), + LayoutOrder = nextOrder(), + onChanged = function(text: string) + profileStore.setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + details = if text == "" then Sift.None else text, + }, + }) + end) + end, + }), + + State = e(TextInput, { + text = profileBinding:map(function(profile: CustomProfileData) + return profile.activity.state or "" + end), + label = "Line 2", + size = InputSize.Small, + width = UDim.new(1, 0), + LayoutOrder = nextOrder(), + onChanged = function(text: string) + profileStore.setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + state = if text == "" then Sift.None else text, + }, + }) + end) + end, + }), + }), + }) +end + +return React.memo(ActivityFields) diff --git a/plugin/src/CustomProfile/CustomProfile.luau b/plugin/src/CustomProfile/CustomProfile.luau new file mode 100644 index 0000000..69f5a5e --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfile.luau @@ -0,0 +1,137 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactUtils = require(Packages.ReactUtils) + +local ActivityFields = require(script.Parent.ActivityFields) +local GameOverride = require(script.Parent.GameOverride) +local ModalStore = require(Plugin.Source.Modal.ModalStore) +local PreviewBlock = require(script.Parent.PreviewBlock) +local ProfileActions = require(script.Parent.ProfileActions) +local ProfileContext = require(script.Parent.ProfileContext) +local ProfileName = require(script.Parent.ProfileName) +local Title = require(script.Parent.Title) + +local View = Foundation.View +local Sheet = Foundation.Sheet +local DialogSize = Foundation.Enums.DialogSize + +local createNextOrder = ReactUtils.createNextOrder +local useCallback = React.useCallback + +local e = React.createElement + +type Props = { + isOpen: boolean, + existingProfileId: string?, + onClose: (result: any?) -> (), +} + +type SheetProps = { + isOpen: boolean, + isNewProfile: boolean, + onClose: (result: any?) -> (), +} + +local function CustomProfileSheet(props: SheetProps) + local nextOrder = createNextOrder() + local profile = ProfileContext.useProfile() + + -- Wraps Sheet.Root's onClose (fired by the X button or backdrop dismiss) so + -- we can prompt for confirmation when the user has unsaved edits. ProfileActions's + -- Save/Delete buttons call props.onClose directly and intentionally bypass this + -- guard because they resolve the dirty state themselves. + -- + -- Sheet.Root also fires onClose at the end of its programmatic close + -- animation (triggered when isOpen flips false), e.g. after the user already + -- confirmed discard or after Delete. In that case props.isOpen is already + -- false so we short-circuit; otherwise we'd re-prompt against the still-dirty + -- store and pop the dialog twice. + local handleClose = useCallback(function() + if not props.isOpen then + return + end + + if not profile.profileStore.isChanged() then + props.onClose() + return + end + + task.spawn(function() + local accepted = ModalStore.get().openAsync("confirmation", { + priority = 2, + props = { + title = "Discard unsaved changes?", + body = "Your edits to this profile will be lost.", + }, + }) + if accepted then + props.onClose() + end + end) + end, { props.isOpen, props.onClose, profile.profileStore } :: { any }) + + return e(Sheet.Root, { + isOpen = props.isOpen, + preferCenterSheet = true, + snapPoints = { 0.95 }, + size = DialogSize.Medium, + onClose = handleClose, + }, { + Header = e(Sheet.Header, { + hasCloseAffordance = true, + }, { + Title = e(Title, { + isNewProfile = props.isNewProfile, + LayoutOrder = nextOrder(), + }), + }), + + Content = e(Sheet.Content, nil, { + Container = e(View, { + tag = "size-full-0 auto-y col padding-y-small", + LayoutOrder = nextOrder(), + }, { + PreviewBlock = e(PreviewBlock, { + LayoutOrder = nextOrder(), + }), + + ProfileName = e(ProfileName, { + LayoutOrder = nextOrder(), + }), + + ActivityFields = e(ActivityFields, { + LayoutOrder = nextOrder(), + }), + + GameOverride = e(GameOverride, { + LayoutOrder = nextOrder(), + }), + }), + }), + + Actions = e(ProfileActions, { + isNewProfile = props.isNewProfile, + onClose = props.onClose, + }), + }) +end + +local function CustomProfile(props: Props) + local isNewProfile = props.existingProfileId == nil + + return e(ProfileContext.Provider, { + isOpen = props.isOpen, + existingProfileId = props.existingProfileId, + }, { + Sheet = e(CustomProfileSheet, { + isOpen = props.isOpen, + isNewProfile = isNewProfile, + onClose = props.onClose, + }), + }) +end + +return CustomProfile diff --git a/plugin/src/CustomProfile/GameOverride/GameOverride.luau b/plugin/src/CustomProfile/GameOverride/GameOverride.luau new file mode 100644 index 0000000..8b27ba8 --- /dev/null +++ b/plugin/src/CustomProfile/GameOverride/GameOverride.luau @@ -0,0 +1,79 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactCharm = require(Packages.ReactCharm) +local ReactUtils = require(Packages.ReactUtils) + +local OverridePreview = require(script.Parent.OverridePreview) +local ProfileContext = require(script.Parent.Parent.ProfileContext) +local Section = require(Plugin.Source.Common.Section) + +local View = Foundation.View +local NumberInput = Foundation.NumberInput +local InputSize = Foundation.Enums.InputSize +local NumberInputControlsVariant = Foundation.Enums.NumberInputControlsVariant +local OnChangeCallbackReason = Foundation.Enums.OnChangeCallbackReason +type OnChangeCallbackReason = Foundation.OnChangeCallbackReason + +local createNextOrder = ReactUtils.createNextOrder +local useSignalState = ReactCharm.useSignalState + +local e = React.createElement + +type Props = { + LayoutOrder: number?, +} + +local function GameOverride(props: Props) + local nextOrder = createNextOrder() + local profile = ProfileContext.useProfile() + + local override = useSignalState(profile.profileStore.getOverrideLookupState) + + return e(Section, { + title = "Game Override", + canToggleExpand = true, + hasDivider = false, + LayoutOrder = props.LayoutOrder, + }, { + LayoutBlock = e(View, { + tag = "size-full-0 auto-y col gap-medium", + LayoutOrder = nextOrder(), + }, { + PlaceIdInput = e(NumberInput, { + label = "Place ID", + hint = "Optionally override the game displayed on your activity. If unset, the place you're currently editing will be used instead.", + value = override.placeId or 0, + onChanged = function(placeId: number, reason: OnChangeCallbackReason) + -- NumberInput re-fires onChanged with its `value` prop on focus loss. + -- While our lookup debounce is in flight `value` collapses to 0, + -- which would normalise to "" and wipe the override. Keyboard + -- events already capture every real edit, so we can safely + -- ignore FocusLost. + if reason == OnChangeCallbackReason.FocusLost then + return + end + profile.profileStore.setOverrideLookupInput(if placeId == 0 then "" else tostring(placeId)) + end, + formatAsString = function(placeId: number) + return if override.status == "loading" + then "Loading..." + elseif placeId == 0 then "None" + else tostring(placeId) + end, + controlsVariant = NumberInputControlsVariant.None, + size = InputSize.Small, + width = UDim.new(1, 0), + LayoutOrder = nextOrder(), + }), + + OverridePreview = e(OverridePreview, { + LayoutOrder = nextOrder(), + }), + }), + }) +end + +return React.memo(GameOverride) diff --git a/plugin/src/CustomProfile/GameOverride/OverridePreview.luau b/plugin/src/CustomProfile/GameOverride/OverridePreview.luau new file mode 100644 index 0000000..7f2e262 --- /dev/null +++ b/plugin/src/CustomProfile/GameOverride/OverridePreview.luau @@ -0,0 +1,110 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactCharm = require(Packages.ReactCharm) +local ReactUtils = require(Packages.ReactUtils) + +local ProfileContext = require(script.Parent.Parent.ProfileContext) +local WebImage = require(Plugin.Source.Common.WebImage) + +local View = Foundation.View +local Text = Foundation.Text +local Skeleton = Foundation.Skeleton +local Icon = Foundation.Icon +local IconName = Foundation.Enums.IconName +local IconSize = Foundation.Enums.IconSize +local Radius = Foundation.Enums.Radius + +local createNextOrder = ReactUtils.createNextOrder +local useSignalState = ReactCharm.useSignalState + +local e = React.createElement + +type Props = { + LayoutOrder: number?, +} + +local function OverridePreview(props: Props) + local nextOrder = createNextOrder() + local profile = ProfileContext.useProfile() + + local override = useSignalState(profile.profileStore.getOverrideLookupState) + + local placeDetails = override.context and override.context.onlinePlaceDetails + local iconWebUrl = placeDetails and placeDetails.iconWebUrl + + return e(View, { + tag = "size-full-1200 bg-shift-100 radius-small row gap-medium padding-x-medium align-y-center", + LayoutOrder = props.LayoutOrder, + }, { + IconBlock = e(View, { + tag = "size-900-900 bg-shift-100 radius-circle row align-x-center align-y-center", + LayoutOrder = nextOrder(), + }, { + LoadingSkeleton = (override.status == "loading") and e(Skeleton, { + radius = Radius.Circle, + LayoutOrder = nextOrder(), + }), + + Icon = ( + override.status == "idle" + or override.status == "error" + or (override.status == "ready" and iconWebUrl == nil) + ) + and e(Icon, { + name = if override.status == "idle" + then IconName.EyeSlash + elseif override.status == "error" then IconName.TriangleExclamation + elseif iconWebUrl == nil then IconName.CameraSlash + else IconName.TriangleExclamation, + size = IconSize.Small, + LayoutOrder = nextOrder(), + }), + + GameIcon = (override.status == "ready" and iconWebUrl) and e(WebImage, { + url = iconWebUrl, + size = "small", + LayoutOrder = nextOrder(), + }), + }), + + TextBlock = e(View, { + tag = { + ["size-0-full grow col align-y-center"] = true, + ["gap-xsmall"] = override.status == "loading", + }, + LayoutOrder = nextOrder(), + }, { + Title = if override.status == "loading" + then e(Skeleton, { + Size = UDim2.new(0.4, 0, 0, 14), + radius = Radius.Small, + LayoutOrder = nextOrder(), + }) + else e(Text, { + tag = "size-full-0 auto-y text-wrap text-align-x-left text-title-small content-default", + Text = if override.status == "ready" + then "Overriding Activity" + elseif override.status == "idle" then "No override set" + else "We ran into an issue", + LayoutOrder = nextOrder(), + }), + + Subtitle = if override.status == "loading" + then e(Skeleton, { + Size = UDim2.new(0.5, 0, 0, 12), + radius = Radius.Small, + LayoutOrder = nextOrder(), + }) + else override.status ~= "idle" and e(Text, { + tag = "size-full-0 auto-y text-wrap text-align-x-left text-body-small content-muted", + Text = if placeDetails then placeDetails.placeName else override.error or "Unknown error", + LayoutOrder = nextOrder(), + }), + }), + }) +end + +return OverridePreview diff --git a/plugin/src/CustomProfile/GameOverride/init.luau b/plugin/src/CustomProfile/GameOverride/init.luau new file mode 100644 index 0000000..9d5396e --- /dev/null +++ b/plugin/src/CustomProfile/GameOverride/init.luau @@ -0,0 +1 @@ +return require(script.GameOverride) diff --git a/plugin/src/CustomProfile/PreviewBlock.luau b/plugin/src/CustomProfile/PreviewBlock.luau new file mode 100644 index 0000000..78735fd --- /dev/null +++ b/plugin/src/CustomProfile/PreviewBlock.luau @@ -0,0 +1,72 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactCharm = require(Packages.ReactCharm) +local ReactUtils = require(Packages.ReactUtils) + +local ActivityPreview = require(Plugin.Source.ActivityPreview) +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local ProfileContext = require(script.Parent.ProfileContext) +local buildCustomActivity = require(Plugin.Source.PresenceManager.ProfileStore.buildCustomActivity) + +type ActivityConfig = buildCustomActivity.ActivityConfig + +local View = Foundation.View + +local createNextOrder = ReactUtils.createNextOrder +local useSignalState = ReactCharm.useSignalState +local useMemo = React.useMemo + +local e = React.createElement + +type Props = { + LayoutOrder: number?, +} + +local function PreviewBlock(props: Props) + local nextOrder = createNextOrder() + local profile = ProfileContext.useProfile() + local placeDetailsStore = PlaceDetailsStore.get() + + local currentActivityComputed = useMemo(function() + return Charm.computed(function(): ActivityConfig? + local customProfile = profile.profileStore.getProfile() + local overridePlaceId = customProfile.activity.overridePlaceId + local placeContext = if overridePlaceId and overridePlaceId > 0 + then placeDetailsStore.getContextByPlaceId(overridePlaceId) + else placeDetailsStore.getCurrentContext() + + if placeContext == nil then + return nil + end + + return buildCustomActivity(placeContext, customProfile) + end) + end, { profile.profileStore :: any, placeDetailsStore }) + + local currentActivity = useSignalState(currentActivityComputed) + + return e(View, { + tag = "size-full-0 auto-y col align-x-center padding-bottom-small", + LayoutOrder = props.LayoutOrder, + }, { + PreviewContainer = currentActivity and e(View, { + tag = "auto-y", + Size = UDim2.fromScale(0.8, 0), + sizeConstraint = { + MaxSize = Vector2.new(300, math.huge), + }, + LayoutOrder = nextOrder(), + }, { + Preview = e(ActivityPreview, { + activity = currentActivity, + LayoutOrder = nextOrder(), + }), + }), + }) +end + +return React.memo(PreviewBlock) diff --git a/plugin/src/CustomProfile/ProfileActions.luau b/plugin/src/CustomProfile/ProfileActions.luau new file mode 100644 index 0000000..b4dcfc3 --- /dev/null +++ b/plugin/src/CustomProfile/ProfileActions.luau @@ -0,0 +1,67 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactUtils = require(Packages.ReactUtils) + +local ModalStore = require(Plugin.Source.Modal.ModalStore) +local ProfileContext = require(script.Parent.ProfileContext) + +local Sheet = Foundation.Sheet +local Button = Foundation.Button +local IconButton = Foundation.IconButton +local ButtonVariant = Foundation.Enums.ButtonVariant +local InputSize = Foundation.Enums.InputSize +local IconName = Foundation.Enums.IconName +local FillBehavior = Foundation.Enums.FillBehavior + +local createNextOrder = ReactUtils.createNextOrder + +local e = React.createElement + +type Props = { + isNewProfile: boolean, + onClose: (result: any?) -> (), +} + +local function ProfileActions(props: Props) + local nextOrder = createNextOrder() + local profile = ProfileContext.useProfile() + + return e(Sheet.Actions, nil, { + Save = e(Button, { + text = if props.isNewProfile then "Create Profile" else "Save Profile", + size = InputSize.Small, + variant = ButtonVariant.Emphasis, + fillBehavior = FillBehavior.Fill, + LayoutOrder = nextOrder(), + onActivated = function() + profile.profileStore.save({ activate = props.isNewProfile }) + props.onClose(true) + end, + }), + + Delete = (not props.isNewProfile) and e(IconButton, { + icon = IconName.TrashCan, + size = InputSize.Small, + LayoutOrder = nextOrder(), + onActivated = function() + local accepted = ModalStore.get().openAsync("confirmation", { + priority = 2, + props = { + title = "Are you sure?", + body = "This action cannot be undone.", + }, + }) + + if accepted then + profile.profileStore.delete() + props.onClose(false) + end + end, + }), + }) +end + +return ProfileActions diff --git a/plugin/src/CustomProfile/ProfileContext.luau b/plugin/src/CustomProfile/ProfileContext.luau new file mode 100644 index 0000000..1837cc9 --- /dev/null +++ b/plugin/src/CustomProfile/ProfileContext.luau @@ -0,0 +1,91 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local React = require(Packages.React) + +local createCustomProfileStore = require(script.Parent.createCustomProfileStore) +export type CustomProfileStore = createCustomProfileStore.CustomProfileStore +export type CustomProfileData = createCustomProfileStore.CustomProfileData + +local e = React.createElement +local useState = React.useState +local useEffect = React.useEffect +local useMemo = React.useMemo +local useRef = React.useRef +local useContext = React.useContext + +export type ProfileContext = { + profileStore: CustomProfileStore, +} + +local ProfileContext = React.createContext<>(nil) +ProfileContext.displayName = "ProfileContext" + +local function ProfileContextProvider(props: { + isOpen: boolean, + existingProfileId: string?, + children: React.ReactNode, +}) + local profileStore, setProfileStore = useState<>(function() + -- We have to fill the state on first render + return createCustomProfileStore(props.existingProfileId) + end) + + -- Track the most recently committed store so we can dispose of it before + -- swapping in a new one, even across the React render/commit boundary. + local activeStoreRef = useRef(profileStore) + -- Track whether the sheet was previously open so we can recreate the store + -- only on a closed -> open transition, not on every prop change. + local prevIsOpenRef = useRef(props.isOpen) + + useEffect(function() + local isOpening = props.isOpen and not prevIsOpenRef.current + prevIsOpenRef.current = props.isOpen + + if not isOpening then + return + end + + local nextStore = createCustomProfileStore(props.existingProfileId) + local previousStore = activeStoreRef.current + activeStoreRef.current = nextStore + setProfileStore(nextStore) + + if previousStore and previousStore ~= nextStore then + previousStore.destroy() + end + end, { props.isOpen :: any, props.existingProfileId }) + + useEffect(function() + return function() + local store = activeStoreRef.current + if store then + store.destroy() + activeStoreRef.current = nil :: any + end + end + end, {}) + + local value = useMemo(function(): ProfileContext + return { + profileStore = profileStore, + } + end, { profileStore }) + + return e(ProfileContext.Provider, { + value = value, + }, props.children) +end + +local function useProfile(): ProfileContext + local context = useContext(ProfileContext) + if context == nil then + error("useProfile must be used within a ProfileContextProvider") + end + return context +end + +return { + Provider = ProfileContextProvider, + useProfile = useProfile, +} diff --git a/plugin/src/CustomProfile/ProfileName.luau b/plugin/src/CustomProfile/ProfileName.luau new file mode 100644 index 0000000..88d39a9 --- /dev/null +++ b/plugin/src/CustomProfile/ProfileName.luau @@ -0,0 +1,56 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactCharm = require(Packages.ReactCharm) +local ReactUtils = require(Packages.ReactUtils) +local Sift = require(Packages.Sift) + +local ProfileContext = require(script.Parent.ProfileContext) +local Section = require(Plugin.Source.Common.Section) + +local TextInput = Foundation.TextInput +local InputSize = Foundation.Enums.InputSize + +local createNextOrder = ReactUtils.createNextOrder +local useSignalBinding = ReactCharm.useSignalBinding + +local e = React.createElement + +type CustomProfileData = ProfileContext.CustomProfileData + +type Props = { + LayoutOrder: number?, +} + +local function ProfileName(props: Props) + local nextOrder = createNextOrder() + local profileStore = ProfileContext.useProfile().profileStore + local profileBinding = useSignalBinding(profileStore.getProfile) + + return e(Section, { + canToggleExpand = false, + hasDivider = true, + LayoutOrder = props.LayoutOrder, + }, { + ProfileName = e(TextInput, { + text = profileBinding:map(function(profile: CustomProfileData) + return profile.name + end), + label = "Profile Name", + size = InputSize.Small, + width = UDim.new(1, 0), + LayoutOrder = nextOrder(), + onChanged = function(text: string) + profileStore.setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + name = text, + }) + end) + end, + }), + }) +end + +return ProfileName diff --git a/plugin/src/CustomProfile/Title.luau b/plugin/src/CustomProfile/Title.luau new file mode 100644 index 0000000..d8ce2f5 --- /dev/null +++ b/plugin/src/CustomProfile/Title.luau @@ -0,0 +1,58 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) +local ReactUtils = require(Packages.ReactUtils) + +local View = Foundation.View +local Text = Foundation.Text +local Icon = Foundation.Icon +local IconName = Foundation.Enums.IconName +local IconSize = Foundation.Enums.IconSize +local useTokens = Foundation.Hooks.useTokens + +local createNextOrder = ReactUtils.createNextOrder + +local e = React.createElement + +type Props = { + isNewProfile: boolean, + LayoutOrder: number?, +} + +local function Title(props: Props) + local tokens = useTokens() + local nextOrder = createNextOrder() + + return e(View, { + tag = "size-full-0 auto-y row gap-medium padding-top-small align-y-center", + LayoutOrder = props.LayoutOrder, + }, { + Discord = e(Icon, { + name = IconName.Discord, + size = IconSize.Large, + style = tokens.Color.Content.Emphasis, + LayoutOrder = nextOrder(), + }), + + TextBlock = e(View, { + tag = "size-full-0 auto-y col", + LayoutOrder = nextOrder(), + }, { + Title = e(Text, { + tag = "size-full-0 auto-y text-title-medium content-emphasis text-wrap text-align-x-left", + Text = "Custom Profile", + LayoutOrder = nextOrder(), + }), + + Subtitle = e(Text, { + tag = "size-full-0 auto-y text-caption-medium content-default text-wrap text-align-x-left", + Text = if props.isNewProfile then "Create a new custom profile" else "Edit your custom profile", + LayoutOrder = nextOrder(), + }), + }), + }) +end + +return Title diff --git a/plugin/src/CustomProfile/createCustomProfileStore.luau b/plugin/src/CustomProfile/createCustomProfileStore.luau new file mode 100644 index 0000000..7f4682d --- /dev/null +++ b/plugin/src/CustomProfile/createCustomProfileStore.luau @@ -0,0 +1,236 @@ +local HttpService = game:GetService("HttpService") + +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) +local Sift = require(Packages.Sift) + +local Debounce = require(Plugin.Source.Common.Debounce) +local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore) +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local ProfileStore = require(Plugin.Source.PresenceManager.ProfileStore) + +export type CustomProfileData = LocalStorageStore.CustomProfileData +type PlaceContext = PlaceDetailsStore.PlaceContext + +local signal = Charm.signal +local computed = Charm.computed + +local LOOKUP_DEBOUNCE_SECONDS = 1 + +local DEFAULT_CUSTOM_PROFILE: CustomProfileData = { + name = "My Profile", + activity = { + details = "Building my game", + state = "{placeName}", + showPlaceIcon = true, + }, +} + +export type OverrideLookupStatus = "idle" | "loading" | "ready" | "error" + +export type OverrideLookupState = { + input: string, + placeId: number?, + status: OverrideLookupStatus, + context: PlaceContext?, + error: string?, +} + +export type SaveOptions = { + activate: boolean?, +} + +export type CustomProfileStore = { + getProfile: () -> CustomProfileData, + setProfile: (CustomProfileData | (CustomProfileData) -> CustomProfileData) -> CustomProfileData, + getOverrideLookupState: () -> OverrideLookupState, + setOverrideLookupInput: (input: string) -> (), + + isChanged: () -> boolean, + save: (options: SaveOptions?) -> (), + delete: () -> (), + destroy: () -> (), +} + +local function createCustomProfileStore(existingProfileId: string?): CustomProfileStore + local profileId = existingProfileId or HttpService:GenerateGUID() + + local function getExistingProfile(): CustomProfileData? + local localStorage = LocalStorageStore.get().getStorage() + return localStorage.customProfiles and localStorage.customProfiles[profileId] + end + + local getProfile, setProfile = signal<>(getExistingProfile() or DEFAULT_CUSTOM_PROFILE) + local placeDetailsStore = PlaceDetailsStore.get() + + local originalOverride = getProfile().activity.overridePlaceId + local initialInput = if originalOverride and originalOverride > 0 then tostring(originalOverride) else "" + + -- Kick off a fetch for the saved override so the UI doesn't sit in a + -- "loading" state when the editor reopens before the entry is cached. + if originalOverride and originalOverride > 0 then + placeDetailsStore.prefetchPlaceIds({ originalOverride }) + end + + local getLookupInput, setLookupInput = signal(initialInput) + local getLookupPlaceId, setLookupPlaceId = signal(originalOverride) + local getLookupError, setLookupError = signal(nil :: string?) + + local function persistPlaceOverride(placeId: number?) + setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + overridePlaceId = placeId or Sift.None, + }, + }) + end) + end + + local debouncedLookup = Debounce.trailing(LOOKUP_DEBOUNCE_SECONDS, function(placeId: number) + placeDetailsStore.prefetchPlaceIds({ placeId }) + setLookupPlaceId(placeId) + persistPlaceOverride(placeId) + end) + + local function setOverrideLookupInput(input: string) + local trimmed = input:match("^%s*(.-)%s*$") or "" + local normalizedInput = if trimmed == "0" then "" else trimmed + setLookupInput(normalizedInput) + + if normalizedInput == "" then + debouncedLookup.cancel() + setLookupPlaceId(nil) + setLookupError(nil) + persistPlaceOverride(nil) + return + end + + local placeId = tonumber(normalizedInput) + if not placeId or placeId <= 0 then + debouncedLookup.cancel() + setLookupPlaceId(nil) + setLookupError("invalid_place_id") + persistPlaceOverride(nil) + return + end + + setLookupError(nil) + + -- Intentionally do NOT clear lookupPlaceId here; the previously-resolved + -- place stays visible in the preview until the new lookup resolves, + -- avoiding a 1s "loading" flash on every keystroke. + debouncedLookup.call(placeId) + end + + local getOverrideLookupState = computed(function(): OverrideLookupState + local input = getLookupInput() + local error = getLookupError() + if error then + return { + input = input, + status = "error", + error = error, + } + end + + if input == "" then + return { + input = input, + status = "idle", + } + end + + local placeId = getLookupPlaceId() + if not placeId then + return { + input = input, + status = "loading", + } + end + + local entry = placeDetailsStore.getPlaceEntry(placeId) + if not entry or entry.status == "loading" then + return { + input = input, + placeId = placeId, + status = "loading", + } + end + + if entry.status == "error" then + return { + input = input, + placeId = placeId, + status = "error", + error = entry.error or "fetch_failed", + } + end + + local context = placeDetailsStore.getContextByPlaceId(placeId) + if not context then + return { + input = input, + placeId = placeId, + status = "loading", + } + end + + return { + input = input, + placeId = placeId, + status = "ready", + context = context, + } + end) + + local function isChanged(): boolean + local storage = LocalStorageStore.get().getStorage() + local customProfiles: LocalStorageStore.CustomProfiles = if storage.customProfiles + then table.clone(storage.customProfiles) + else {} + + local profile = getProfile() + if customProfiles == nil or customProfiles[profileId] == nil then + return not Sift.Dictionary.equalsDeep(profile, DEFAULT_CUSTOM_PROFILE) + end + + return not Sift.Dictionary.equalsDeep(profile, customProfiles[profileId]) + end + + local function save(options: SaveOptions?) + debouncedLookup.flush() + + local profileStore = ProfileStore.get() + profileStore.setCustomProfile(profileId, getProfile()) + + local shouldActivate = if options and options.activate ~= nil then options.activate else false + if shouldActivate then + profileStore.setActiveProfile(profileId) + end + end + + local function delete() + debouncedLookup.cancel() + ProfileStore.get().removeCustomProfile(profileId) + end + + local function destroy() + debouncedLookup.cancel() + end + + return { + getProfile = getProfile, + setProfile = setProfile, + getOverrideLookupState = getOverrideLookupState, + setOverrideLookupInput = setOverrideLookupInput, + + isChanged = isChanged, + save = save, + delete = delete, + destroy = destroy, + } +end + +return createCustomProfileStore diff --git a/plugin/src/CustomProfile/init.luau b/plugin/src/CustomProfile/init.luau new file mode 100644 index 0000000..b6e4fab --- /dev/null +++ b/plugin/src/CustomProfile/init.luau @@ -0,0 +1 @@ +return require(script.CustomProfile) diff --git a/plugin/src/MainScreen/AccountList/AccountList.luau b/plugin/src/MainScreen/AccountList/AccountList.luau index 79637b7..367a579 100644 --- a/plugin/src/MainScreen/AccountList/AccountList.luau +++ b/plugin/src/MainScreen/AccountList/AccountList.luau @@ -9,7 +9,7 @@ local AccountEntry = require(script.Parent.AccountEntry) local AccountStore = require(Plugin.Source.Authentication.AccountStore) local LinkAccountEntry = require(script.Parent.LinkAccountEntry) local NoneLinkedBlock = require(script.Parent.NoneLinkedBlock) -local Section = require(script.Parent.Parent.Section) +local Section = require(Plugin.Source.Common.Section) local createAccountStore = require(Plugin.Source.Authentication.AccountStore.createAccountStore) type LocalUser = createAccountStore.LocalUser diff --git a/plugin/src/MainScreen/MainScreen.luau b/plugin/src/MainScreen/MainScreen.luau index e532994..2fba1aa 100644 --- a/plugin/src/MainScreen/MainScreen.luau +++ b/plugin/src/MainScreen/MainScreen.luau @@ -45,7 +45,7 @@ local function MainScreen() sizeConstraint = { MaxSize = Vector2.new(620, math.huge), }, - padding = Vector2.new(0, tokens.Padding.Medium), + padding = Vector2.new(tokens.Padding.Small, tokens.Padding.Medium), }, { PreviewBlock = e(PreviewBlock, { LayoutOrder = nextOrder(), diff --git a/plugin/src/MainScreen/PreviewBlock.luau b/plugin/src/MainScreen/PreviewBlock.luau index 92ed8d4..35457ce 100644 --- a/plugin/src/MainScreen/PreviewBlock.luau +++ b/plugin/src/MainScreen/PreviewBlock.luau @@ -32,9 +32,9 @@ local function PreviewBlock(props: Props) }, { PreviewContainer = currentActivity and e(View, { tag = "auto-y", - Size = UDim2.fromScale(0.6, 0), + Size = UDim2.fromScale(0.8, 0), sizeConstraint = { - MaxSize = Vector2.new(280, math.huge), + MaxSize = Vector2.new(300, math.huge), }, LayoutOrder = nextOrder(), }, { diff --git a/plugin/src/MainScreen/ProfileSelection.luau b/plugin/src/MainScreen/ProfileSelection.luau index 64b62bc..835a2b1 100644 --- a/plugin/src/MainScreen/ProfileSelection.luau +++ b/plugin/src/MainScreen/ProfileSelection.luau @@ -7,8 +7,9 @@ local React = require(Packages.React) local ReactCharm = require(Packages.ReactCharm) local ReactUtils = require(Packages.ReactUtils) +local ModalStore = require(Plugin.Source.Modal.ModalStore) local ProfileStore = require(Plugin.Source.PresenceManager.ProfileStore) -local Section = require(script.Parent.Section) +local Section = require(Plugin.Source.Common.Section) local Telemetry = require(Plugin.Source.Telemetry.fireEventAsync) local Types = require(Plugin.Source.Telemetry.Types) @@ -28,6 +29,8 @@ local useSignalState = ReactCharm.useSignalState local e = React.createElement +local CUSTOM_PROFILE_TRIGGER_ID = "__add-custom-profile" + local profileDropdownItems = Charm.computed(function(): Foundation.DropdownItems type Record = { id: string, name: string, icon: IconName } local presetProfiles: { Record } = {} @@ -79,9 +82,26 @@ local profileDropdownItems = Charm.computed(function(): Foundation.DropdownItems table.insert(items, customItems) end + -- Custom profile trigger + table.insert(items, { + items = { + { + id = CUSTOM_PROFILE_TRIGGER_ID, + text = "New Profile", + icon = IconName.PlusLarge, + }, + }, + }) + return items end) +local function isPresetProfile(): boolean + local profileStore = ProfileStore.get() + local activeProfile = profileStore.getActiveProfile() + return if activeProfile then activeProfile.isPreset else true +end + type Props = { LayoutOrder: number?, } @@ -91,8 +111,24 @@ local function ProfileSelection(props: Props) local profileStore = ProfileStore.get() local activeProfileId = useSignalState(profileStore.getActiveProfileId) - local items = useSignalState(profileDropdownItems) + local isPreset = useSignalState(isPresetProfile) + + local editBtn = e(Button, { + text = "Edit", + icon = IconName.Pencil, + size = InputSize.Small, + isDisabled = isPreset, + onActivated = function() + ModalStore.get().open("customProfile", { + props = { + existingProfileId = activeProfileId, + }, + }) + end, + -- just have to hard code this because we create the element early + LayoutOrder = 100, + }) return e(Section, { title = "Profile", @@ -117,6 +153,11 @@ local function ProfileSelection(props: Props) size = InputSize.Small, width = UDim.new(1, 0), onItemChanged = function(id) + if id == CUSTOM_PROFILE_TRIGGER_ID then + ModalStore.get().open("customProfile") + return + end + if type(id) == "string" then profileStore.setActiveProfile(id) Telemetry.fireEvent({ @@ -129,21 +170,16 @@ local function ProfileSelection(props: Props) }), }), - Tooltip = e(Tooltip, { - title = "Custom profiles are coming soon!", - align = PopoverAlign.Center, - side = PopoverSide.Left, - LayoutOrder = nextOrder(), - }, { - EditBtn = e(Button, { - text = "Edit", - icon = IconName.Pencil, - size = InputSize.Small, - isDisabled = true, - onActivated = function() end, + EditBtn = if isPreset + then e(Tooltip, { + title = "You can't edit built-in profiles", + align = PopoverAlign.End, + side = PopoverSide.Top, LayoutOrder = nextOrder(), - }), - }), + }, { + EditBtn = editBtn, + }) + else editBtn, }), }) end diff --git a/plugin/src/Modal/ModalRenderer.luau b/plugin/src/Modal/ModalRenderer.luau index 314a6c9..32b191a 100644 --- a/plugin/src/Modal/ModalRenderer.luau +++ b/plugin/src/Modal/ModalRenderer.luau @@ -6,6 +6,7 @@ local ReactCharm = require(Packages.ReactCharm) local AddAccountFlow = require(Plugin.Source.AddAccountFlow) local ConfirmationDialog = require(script.Parent.ConfirmationDialog) +local CustomProfile = require(Plugin.Source.CustomProfile) local ModalStore = require(script.Parent.ModalStore) local SettingsModal = require(Plugin.Source.UserSettings.SettingsModal) local WelcomeDialog = require(Plugin.Source.Telemetry.TelemetryOptOutDialog) @@ -59,34 +60,55 @@ local MODAL_REGISTRY: { [ModalStore.ModalId]: ModalConfig } = { }) end, }, + + customProfile = { + type = "sheet", + render = function(isOpen, onClose, props) + return e(CustomProfile, { + isOpen = isOpen, + existingProfileId = props and props.existingProfileId, + onClose = onClose, + }) + end, + }, } local function ModalRenderer() local modalStore = ModalStore.get() - local activeModal = useSignalState(modalStore.getActiveModal) - local activeProps = useSignalState(modalStore.getActiveProps) + local modalStack = useSignalState(modalStore.getModalStack) + + local entriesById: { [ModalStore.ModalId]: ModalStore.ModalStackEntry } = {} + for _, entry in modalStack do + entriesById[entry.id] = entry + end - local children: { [string]: React.ReactNode } = {} + local children: { React.ReactNode } = {} + local function pushChild(key: string, node: React.ReactNode) + table.insert(children, e(React.Fragment, { key = key }, node)) + end + -- Keep closed sheets mounted so they preserve state. for id: ModalStore.ModalId, config in MODAL_REGISTRY do - local isOpen = (activeModal == id) - - if config.type == "sheet" then - -- Sheet modals are always mounted; isOpen toggles visibility + if config.type == "sheet" and entriesById[id] == nil then local function onClose(result: any?) modalStore.close(id, result) end - children[id] = config.render(isOpen, onClose, if isOpen then activeProps else nil) - elseif config.type == "dialog" then - -- Dialog modals are only mounted when active - if isOpen then - local function onClose(result: any?) - modalStore.close(id, result) - end + pushChild(id, config.render(false, onClose, nil)) + end + end + + -- Render open entries bottom-to-top so higher priority stacks above. + for index = #modalStack, 1, -1 do + local entry = modalStack[index] + local config = MODAL_REGISTRY[entry.id] - children[id] = config.render(isOpen, onClose, activeProps) + if config then + local function onClose(result: any?) + modalStore.close(entry.id, result) end + + pushChild(entry.id, config.render(true, onClose, entry.props)) end end diff --git a/plugin/src/Modal/ModalStore/createModalStore.luau b/plugin/src/Modal/ModalStore/createModalStore.luau index 5bfd4e9..5dadf4c 100644 --- a/plugin/src/Modal/ModalStore/createModalStore.luau +++ b/plugin/src/Modal/ModalStore/createModalStore.luau @@ -6,21 +6,29 @@ local Charm = require(Packages.Charm) local signal = Charm.signal local computed = Charm.computed -export type ModalId = "addAccount" | "welcome" | "confirmation" | "settings" +export type ModalId = "addAccount" | "welcome" | "confirmation" | "settings" | "customProfile" + +export type ModalStackEntry = { + id: ModalId, + priority: number, + props: { [string]: any }?, +} export type OpenOptions = { priority: number?, props: { [string]: any }?, } -type QueueEntry = { - id: ModalId, - priority: number, - props: { [string]: any }?, - waitingThread: thread?, +type QueueEntry = ModalStackEntry & { + -- Multiple callers can `openAsync` the same modal id (e.g. a discard prompt + -- racing with a delete prompt). We keep them all in a single queue entry -- + -- the first caller's props win, and every waiter resumes with the same + -- result when the modal closes. + waitingThreads: { thread }?, } export type ModalStore = { + getModalStack: () -> { ModalStackEntry }, getActiveModal: () -> ModalId?, getActiveProps: () -> { [string]: any }?, @@ -32,6 +40,19 @@ export type ModalStore = { local function createModalStore(): ModalStore local getQueue, setQueue = signal<<{ QueueEntry }>>({}) + local getModalStack = computed(function(): { ModalStackEntry } + local queue = getQueue() + local stack = table.create(#queue) + for index, entry in queue do + stack[index] = { + id = entry.id, + priority = entry.priority, + props = entry.props, + } + end + return stack + end) + local getActiveModal = computed(function(): ModalId? local queue = getQueue() local first = queue[1] @@ -83,10 +104,22 @@ local function createModalStore(): ModalStore local callingThread = coroutine.running() setQueue(function(prev) - -- No-op if already queued - for _, entry in prev do + -- If the modal is already queued, attach our thread to its waiter + -- list so we resume with the same result when it closes. The first + -- caller's props/priority continue to drive the rendered modal. + for index, entry in prev do if entry.id == id then - return prev + local waiters = if entry.waitingThreads then table.clone(entry.waitingThreads) else {} + table.insert(waiters, callingThread) + + local new = table.clone(prev) + new[index] = { + id = entry.id, + priority = entry.priority, + props = entry.props, + waitingThreads = waiters, + } + return new end end @@ -99,7 +132,7 @@ local function createModalStore(): ModalStore id = id, priority = priority, props = props, - waitingThread = callingThread, + waitingThreads = { callingThread }, }) return new end) @@ -109,7 +142,7 @@ local function createModalStore(): ModalStore end local function close(id: ModalId?, result: any?) - local threadToResume: thread? = nil + local threadsToResume: { thread } = {} setQueue(function(prev) local targetId = id @@ -124,7 +157,11 @@ local function createModalStore(): ModalStore local new = {} for _, entry in prev do if entry.id == targetId then - threadToResume = entry.waitingThread + if entry.waitingThreads then + for _, thread in entry.waitingThreads do + table.insert(threadsToResume, thread) + end + end else table.insert(new, entry) end @@ -133,12 +170,13 @@ local function createModalStore(): ModalStore return new end) - if threadToResume then - task.spawn(threadToResume, result) + for _, thread in threadsToResume do + task.spawn(thread, result) end end return { + getModalStack = getModalStack, getActiveModal = getActiveModal :: any, getActiveProps = getActiveProps, diff --git a/plugin/src/Modal/ModalStore/init.luau b/plugin/src/Modal/ModalStore/init.luau index 14af57f..6902e76 100644 --- a/plugin/src/Modal/ModalStore/init.luau +++ b/plugin/src/Modal/ModalStore/init.luau @@ -5,6 +5,7 @@ local Charm = require(Packages.Charm) local createModalStore = require(script.createModalStore) export type ModalId = createModalStore.ModalId +export type ModalStackEntry = createModalStore.ModalStackEntry return { get = Charm.computed(createModalStore), diff --git a/plugin/src/PlaceContextStore/createPlaceContextStore.luau b/plugin/src/PlaceContextStore/createPlaceContextStore.luau deleted file mode 100644 index d7af041..0000000 --- a/plugin/src/PlaceContextStore/createPlaceContextStore.luau +++ /dev/null @@ -1,282 +0,0 @@ -local HttpService = game:GetService("HttpService") -local MarketplaceService = game:GetService("MarketplaceService") - -local Plugin = script:FindFirstAncestor("StudioActivity") - -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - -local Logger = require(Plugin.Source.Logger) -local NotificationStore = require(Plugin.Source.Notification.NotificationStore) -local RetryBackoff = require(Plugin.Source.Api.RetryBackoff) - -local signal = Charm.signal -local computed = Charm.computed -local effect = Charm.effect - -local MAX_RETRIES = 3 - --- We use `roproxy` as a Roblox API proxy, because plugins can't make API --- requests directly to Roblox resources. Later on we probably want to move this --- fetch to our own backend to avoid the third-party dependency that we don't --- control. -local THUMBNAILS_API_FORMAT = - "https://thumbnails.roproxy.com/v1/games/icons?universeIds=%d&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false" - -export type PlaceType = "offline" | "online" -export type Status = "ready" | "error" | "loading" - -export type OnlinePlaceDetails = { - placeName: string, - placeLink: string, - - creatorName: string, - creatorType: "user" | "group", - creatorLink: string, - - iconThumbnail: string, - iconWebUrl: string?, -} - -export type OfflinePlaceDetails = { - placeName: string, -} - -export type PlaceContext = { - placeType: PlaceType, - placeId: number, - experienceId: number, - - onlinePlaceDetails: OnlinePlaceDetails?, - offlinePlaceDetails: OfflinePlaceDetails?, -} - -export type PlaceContextStore = { - getStatus: () -> Status, - getContext: () -> PlaceContext?, - refresh: () -> (), - destroy: () -> (), -} - ---[[ - Best-effort fetch of the game icon CDN URL from the Roblox thumbnails API. - Returns nil on any failure so it doesn't block the rest of the details. -]] -local function fetchIconWebUrlAsync(experienceId: number): string? - local url = string.format(THUMBNAILS_API_FORMAT, experienceId) - - local success, result = pcall(function() - return HttpService:RequestAsync({ - Url = url, - Method = "GET", - Compress = Enum.HttpCompression.None, - }) - end) - - if not success then - Logger:Warn(`PlaceContextStore: icon thumbnail request failed: {result}`) - return nil - end - - if not result.Success then - Logger:Warn(`PlaceContextStore: icon thumbnail API returned {result.StatusCode}`) - return nil - end - - local decodeSuccess, body = pcall(HttpService.JSONDecode, HttpService, result.Body :: string) - if not decodeSuccess then - Logger:Warn(`PlaceContextStore: failed to decode icon thumbnail response: {body}`) - return nil - end - - local data = body.data - if not data or #data == 0 then - Logger:Warn("PlaceContextStore: icon thumbnail response contained no data") - return nil - end - - local imageUrl = data[1].imageUrl - if not imageUrl or imageUrl == "" then - Logger:Warn("PlaceContextStore: icon thumbnail entry had no imageUrl") - return nil - end - - return imageUrl -end - ---[[ - Fetches online place details with exponential backoff retry. - Returns nil on persistent failure after MAX_RETRIES attempts. -]] -local function fetchOnlineDetailsWithRetryAsync(placeId: number, experienceId: number): OnlinePlaceDetails? - local backoffConfig = RetryBackoff.defaultConfig() - local backoffState = RetryBackoff.createState() - - for attempt = 1, MAX_RETRIES do - local success, result = pcall(function() - return MarketplaceService:GetProductInfoAsync(placeId) - end) - - if success then - local productInfo = result - - local creatorType: "user" | "group" = if productInfo.Creator.CreatorType == "Group" then "group" else "user" - - local creatorLink: string = if creatorType == "group" - then `https://www.roblox.com/groups/{productInfo.Creator.CreatorTargetId}` - else `https://www.roblox.com/users/{productInfo.Creator.CreatorTargetId}/profile` - - local iconWebUrl = fetchIconWebUrlAsync(experienceId) - - return { - placeName = productInfo.Name, - placeLink = `https://www.roblox.com/games/{placeId}`, - - creatorName = productInfo.Creator.Name, - creatorType = creatorType, - creatorLink = creatorLink, - - iconThumbnail = `rbxthumb://type=GameIcon&id={experienceId}&w=512&h=512`, - iconWebUrl = iconWebUrl, - } - end - - if attempt == MAX_RETRIES then - Logger:Warn(`PlaceContextStore: fetch failed after {MAX_RETRIES} attempts: {result}`) - return nil - end - - local delay = RetryBackoff.nextAttempt(backoffConfig, backoffState) - Logger:Warn( - `PlaceContextStore: fetch error, retrying in {string.format("%.1f", delay)}s (attempt {attempt}/{MAX_RETRIES}): {result}` - ) - task.wait(delay) - end - - return nil -end - ---[[ - This store manages context about the Place we're currently editing. It - handles data fetching for place data, tracking which scripts are open, etc. -]] -local function createPlaceContextStore(): PlaceContextStore - -- TODO: I don't think this can ever change without the whole plugin - -- reloading, but we're setup in case it actually can. Need to track if - -- it does change, though. - local getPlaceId, _setPlaceId = signal(game.PlaceId) - - -- Luau types are weird here - local getPlaceType: () -> PlaceType = computed(function(): any - return if getPlaceId() == 0 then "offline" else "online" - end) - - local getStatus, setStatus = signal<>("loading") - - -- Bumped to trigger a re-fetch from the UI (e.g. a "retry" button) - local getRefreshVersion, setRefreshVersion = signal(0) - - -- We have to dynamically fetch details for the online experience - local fetchOnlineDetailsThread: thread? - local getOnlinePlaceDetails, setOnlinePlaceDetails = signal<>(nil) - - local disposeOnlineFetchEffect = effect(function() - local placeType: PlaceType = getPlaceType() - local placeId = getPlaceId() - local _version = getRefreshVersion() - - if fetchOnlineDetailsThread ~= nil then - pcall(task.cancel, fetchOnlineDetailsThread) - end - - if placeType == "offline" then - setOnlinePlaceDetails(nil) - setStatus("ready") - return - end - - setStatus("loading") - Logger:Info(`PlaceContextStore: fetching details for place {placeId}`) - - fetchOnlineDetailsThread = task.spawn(function() - local experienceId = game.GameId - local details = fetchOnlineDetailsWithRetryAsync(placeId, experienceId) - - if details then - setOnlinePlaceDetails(details) - setStatus("ready") - Logger:Info(`PlaceContextStore: successfully fetched details for "{details.placeName}"`) - else - setOnlinePlaceDetails(nil) - setStatus("error") - NotificationStore.get().push("error", "Failed to load place details") - end - - fetchOnlineDetailsThread = nil - end) - end) - - -- Thankfully, offline place details is much simpler. Nothing is async. - local getOfflinePlaceDetails = computed(function(): OfflinePlaceDetails? - local placeType: PlaceType = getPlaceType() - if placeType ~= "offline" then - return nil - end - - return { - -- In a local file, this corresponds to the file name - placeName = game.Name, - } - end) - - -- Compose the final PlaceContext from sub-signals. - local getContext: () -> PlaceContext? = computed(function(): any - local placeType: PlaceType = getPlaceType() - local placeId = getPlaceId() - - if placeType == "offline" then - return { - placeType = placeType, - placeId = placeId, - experienceId = 0, - offlinePlaceDetails = getOfflinePlaceDetails(), - } - end - - local online = getOnlinePlaceDetails() - if not online then - return nil - end - - return { - placeType = placeType, - placeId = placeId, - experienceId = game.GameId, - onlinePlaceDetails = online, - } - end) - - local function refresh() - setRefreshVersion(function(prev: number) - return prev + 1 - end) - end - - local function destroy() - disposeOnlineFetchEffect() - - if fetchOnlineDetailsThread then - pcall(task.cancel, fetchOnlineDetailsThread) - fetchOnlineDetailsThread = nil - end - end - - return { - getStatus = getStatus, - getContext = getContext, - refresh = refresh, - destroy = destroy, - } -end - -return createPlaceContextStore diff --git a/plugin/src/PlaceContextStore/init.luau b/plugin/src/PlaceContextStore/init.luau deleted file mode 100644 index 0cd5618..0000000 --- a/plugin/src/PlaceContextStore/init.luau +++ /dev/null @@ -1,18 +0,0 @@ -local Plugin = script:FindFirstAncestor("StudioActivity") - -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - -local createPlaceContextStore = require(script.createPlaceContextStore) - -export type PlaceType = createPlaceContextStore.PlaceType -export type Status = createPlaceContextStore.Status -export type OnlinePlaceDetails = createPlaceContextStore.OnlinePlaceDetails -export type OfflinePlaceDetails = createPlaceContextStore.OfflinePlaceDetails -export type PlaceContext = createPlaceContextStore.PlaceContext - -return table.freeze({ - get = Charm.computed(function() - return createPlaceContextStore() - end), -}) diff --git a/plugin/src/PlaceDetailsStore/createPlaceDetailsStore.luau b/plugin/src/PlaceDetailsStore/createPlaceDetailsStore.luau new file mode 100644 index 0000000..ce05779 --- /dev/null +++ b/plugin/src/PlaceDetailsStore/createPlaceDetailsStore.luau @@ -0,0 +1,323 @@ +local HttpService = game:GetService("HttpService") +local MarketplaceService = game:GetService("MarketplaceService") + +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) + +local Logger = require(Plugin.Source.Logger) +local NotificationStore = require(Plugin.Source.Notification.NotificationStore) +local RetryBackoff = require(Plugin.Source.Api.RetryBackoff) + +local signal = Charm.signal +local computed = Charm.computed + +local MAX_RETRIES = 3 +local THUMBNAILS_API_URL = "https://thumbnails.roproxy.com/v1/batch" + +export type PlaceType = "offline" | "online" +export type Status = "ready" | "error" | "loading" + +export type OnlinePlaceDetails = { + placeName: string, + placeLink: string, + + creatorName: string, + creatorType: "user" | "group", + creatorLink: string, + + iconThumbnail: string, + iconWebUrl: string?, +} + +export type OfflinePlaceDetails = { + placeName: string, +} + +export type PlaceContext = { + placeType: PlaceType, + placeId: number, + experienceId: number, + + onlinePlaceDetails: OnlinePlaceDetails?, + offlinePlaceDetails: OfflinePlaceDetails?, +} + +export type PlaceEntry = { + status: Status, + details: OnlinePlaceDetails?, + experienceId: number?, + error: string?, +} + +export type PlaceDetailsStore = { + getContextByPlaceId: (placeId: number) -> PlaceContext?, + getCurrentContext: () -> PlaceContext?, + getPlaceEntry: (placeId: number) -> PlaceEntry?, + prefetchPlaceIds: (placeIds: { number }, options: { showNotification: boolean? }?) -> (), + destroy: () -> (), +} + +local function decodeThumbnailResponse(body: string): string? + local decodeSuccess, decoded = pcall(HttpService.JSONDecode, HttpService, body) + if not decodeSuccess then + Logger:Warn(`PlaceDetailsStore: failed to decode icon thumbnail response: {decoded}`) + return nil + end + + local data = decoded.data + if not data or #data == 0 then + Logger:Warn("PlaceDetailsStore: icon thumbnail response contained no data") + return nil + end + + local entry = data[1] + if entry.state and entry.state ~= "Completed" then + Logger:Warn(`PlaceDetailsStore: icon thumbnail entry state was {entry.state}`) + return nil + end + + local imageUrl = entry.imageUrl + if not imageUrl or imageUrl == "" then + Logger:Warn("PlaceDetailsStore: icon thumbnail entry had no imageUrl") + return nil + end + + return imageUrl +end + +--[[ + Best-effort fetch of the place icon CDN URL from the Roblox thumbnails API. + Returns nil on any failure so it doesn't block the rest of the details. +]] +local function fetchIconWebUrlAsync(placeId: number): string? + local payload = { + { + requestId = tostring(placeId), + type = "PlaceIcon", + targetId = placeId, + size = "512x512", + format = "Png", + isCircular = false, + }, + } + + local success, result = pcall(function() + return HttpService:RequestAsync({ + Url = THUMBNAILS_API_URL, + Method = "POST", + Compress = Enum.HttpCompression.None, + Headers = { + ["Content-Type"] = "application/json", + }, + Body = HttpService:JSONEncode(payload), + }) + end) + + if not success then + Logger:Warn(`PlaceDetailsStore: icon thumbnail request failed: {result}`) + return nil + end + + if not result.Success then + Logger:Warn(`PlaceDetailsStore: icon thumbnail API returned {result.StatusCode}`) + return nil + end + + return decodeThumbnailResponse(result.Body :: string) +end + +--[[ + Fetches online place details with exponential backoff retry. + Returns nil on persistent failure after MAX_RETRIES attempts. +]] +local function fetchOnlineDetailsWithRetryAsync(placeId: number): (OnlinePlaceDetails?, number?, string?) + local backoffConfig = RetryBackoff.defaultConfig() + local backoffState = RetryBackoff.createState() + + for attempt = 1, MAX_RETRIES do + local success, result = pcall(function() + return MarketplaceService:GetProductInfoAsync(placeId) + end) + + if success then + local productInfo = result + local creator = productInfo.Creator + local creatorType: "user" | "group" = if creator and creator.CreatorType == "Group" then "group" else "user" + local creatorId = if creator then creator.CreatorTargetId else 0 + + local creatorLink: string = if creatorType == "group" + then `https://www.roblox.com/groups/{creatorId}` + else `https://www.roblox.com/users/{creatorId}/profile` + + local iconWebUrl = fetchIconWebUrlAsync(placeId) + + local experienceId = if productInfo.UniverseId then productInfo.UniverseId else 0 + + return { + placeName = productInfo.Name, + placeLink = `https://www.roblox.com/games/{placeId}`, + + creatorName = if creator then creator.Name else "", + creatorType = creatorType, + creatorLink = creatorLink, + + iconThumbnail = `rbxthumb://type=PlaceIcon&id={placeId}&w=512&h=512`, + iconWebUrl = iconWebUrl, + }, + experienceId, + nil + end + + if attempt == MAX_RETRIES then + Logger:Warn(`PlaceDetailsStore: fetch failed after {MAX_RETRIES} attempts: {result}`) + return nil, nil, tostring(result) + end + + local delay = RetryBackoff.nextAttempt(backoffConfig, backoffState) + Logger:Warn( + `PlaceDetailsStore: fetch error, retrying in {string.format("%.1f", delay)}s (attempt {attempt}/{MAX_RETRIES}): {result}` + ) + task.wait(delay) + end + + return nil, nil, "unknown_error" +end + +local function createPlaceDetailsStore(): PlaceDetailsStore + local getEntries, setEntries = signal({} :: { [number]: PlaceEntry }) + local fetchThreads: { [number]: thread } = {} + + local function setEntry(placeId: number, entry: PlaceEntry) + setEntries(function(prev) + local new = table.clone(prev) + new[placeId] = entry + return new + end) + end + + local function getPlaceEntry(placeId: number): PlaceEntry? + local entries = getEntries() + return entries[placeId] + end + + local function ensurePlaceEntry(placeId: number, showNotification: boolean) + if placeId <= 0 then + return + end + + local entry = getPlaceEntry(placeId) + if entry and (entry.status == "loading" or entry.status == "ready") then + return + end + + setEntry(placeId, { + status = "loading", + }) + + if fetchThreads[placeId] then + pcall(task.cancel, fetchThreads[placeId]) + end + + Logger:Info(`PlaceDetailsStore: fetching details for place {placeId}`) + fetchThreads[placeId] = task.spawn(function() + local details, experienceId, errorMessage = fetchOnlineDetailsWithRetryAsync(placeId) + if details then + setEntry(placeId, { + status = "ready", + details = details, + experienceId = experienceId, + }) + Logger:Info(`PlaceDetailsStore: successfully fetched details for "{details.placeName}"`) + else + setEntry(placeId, { + status = "error", + error = errorMessage, + }) + if showNotification then + NotificationStore.get().push("error", "Failed to load place details") + end + end + + fetchThreads[placeId] = nil + end) + end + + local function prefetchPlaceIds(placeIds: { number }, options: { showNotification: boolean? }?) + local showNotification = if options and options.showNotification then true else false + + for _, placeId in placeIds do + if placeId and placeId > 0 then + ensurePlaceEntry(placeId, showNotification) + end + end + end + + local function getContextByPlaceId(placeId: number): PlaceContext? + if placeId <= 0 then + return nil + end + + local entry = getPlaceEntry(placeId) + if not entry or entry.status ~= "ready" or not entry.details then + return nil + end + + return { + placeType = "online", + placeId = placeId, + experienceId = entry.experienceId or 0, + onlinePlaceDetails = entry.details, + } + end + + local getCurrentPlaceId, setCurrentPlaceId = signal(game.PlaceId) + local placeIdConnection = game:GetPropertyChangedSignal("PlaceId"):Connect(function() + local placeId = game.PlaceId + setCurrentPlaceId(placeId) + if placeId > 0 then + ensurePlaceEntry(placeId, false) + end + end) + + local getCurrentContext = computed(function(): PlaceContext? + local currentPlaceId = getCurrentPlaceId() + if currentPlaceId == 0 then + return { + placeType = "offline", + placeId = 0, + experienceId = 0, + offlinePlaceDetails = { + placeName = game.Name, + }, + } + end + + return getContextByPlaceId(currentPlaceId) + end) + + if game.PlaceId > 0 then + prefetchPlaceIds({ game.PlaceId }, { + showNotification = true, + }) + end + + local function destroy() + placeIdConnection:Disconnect() + for placeId, thread in fetchThreads do + pcall(task.cancel, thread) + fetchThreads[placeId] = nil + end + end + + return { + getContextByPlaceId = getContextByPlaceId, + getCurrentContext = getCurrentContext :: any, + getPlaceEntry = getPlaceEntry, + prefetchPlaceIds = prefetchPlaceIds, + destroy = destroy, + } +end + +return createPlaceDetailsStore diff --git a/plugin/src/PlaceDetailsStore/init.luau b/plugin/src/PlaceDetailsStore/init.luau new file mode 100644 index 0000000..1913d95 --- /dev/null +++ b/plugin/src/PlaceDetailsStore/init.luau @@ -0,0 +1,20 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) + +local createPlaceDetailsStore = require(script.createPlaceDetailsStore) + +export type PlaceType = createPlaceDetailsStore.PlaceType +export type Status = createPlaceDetailsStore.Status +export type OnlinePlaceDetails = createPlaceDetailsStore.OnlinePlaceDetails +export type OfflinePlaceDetails = createPlaceDetailsStore.OfflinePlaceDetails +export type PlaceContext = createPlaceDetailsStore.PlaceContext +export type PlaceEntry = createPlaceDetailsStore.PlaceEntry +export type PlaceDetailsStore = createPlaceDetailsStore.PlaceDetailsStore + +return table.freeze({ + get = Charm.computed(function() + return createPlaceDetailsStore() + end), +}) diff --git a/plugin/src/Plugin/LocalStorageStore.luau b/plugin/src/Plugin/LocalStorageStore.luau index 51f3d60..e2f6533 100644 --- a/plugin/src/Plugin/LocalStorageStore.luau +++ b/plugin/src/Plugin/LocalStorageStore.luau @@ -4,26 +4,24 @@ local Packages = Plugin.Packages local Charm = require(Packages.Charm) local t = require(Packages.t) +local Logger = require(Plugin.Source.Logger) local createPluginSettingsStore = require(script.Parent.createPluginSettingsStore) local DiscordApi = require(Plugin.Source.Api.Discord) type ApiUser = DiscordApi.User -type CustomProfileActivityAssets = { - largeImage: string?, - largeText: string?, - smallImage: string?, - smallText: string?, -} - +--[[ + NOTE: All optional fields here are intentionally tri-state — `nil` means + "use the runtime default" (which can differ per field), and `true`/`false` + are explicit user choices. To clear a previously-set value back to the + default, callers must merge in `Sift.None` rather than `nil`, otherwise + the existing value will be kept by `Sift.Dictionary.mergeDeep`. +]] type CustomProfileActivity = { - name: string, details: string?, - detailsUrl: string?, state: string?, - stateUrl: string?, - startedAt: number?, - assets: CustomProfileActivityAssets?, + showPlaceIcon: boolean?, + overridePlaceId: number?, } export type CustomProfileData = { @@ -51,21 +49,11 @@ local defaultValue: LocalStorageStore = { disabledAccountIds = {}, } -local validateActivityAssets = t.interface({ - largeImage = t.optional(t.string), - largeText = t.optional(t.string), - smallImage = t.optional(t.string), - smallText = t.optional(t.string), -}) - local validateActivity = t.interface({ - name = t.string, details = t.optional(t.string), - detailsUrl = t.optional(t.string), state = t.optional(t.string), - stateUrl = t.optional(t.string), - startedAt = t.optional(t.number), - assets = t.optional(validateActivityAssets), + showPlaceIcon = t.optional(t.boolean), + overridePlaceId = t.optional(t.number), }) local validateCustomProfile = t.interface({ @@ -73,15 +61,39 @@ local validateCustomProfile = t.interface({ activity = validateActivity, }) -local validate = t.interface({ +-- Lenient at the top level: `customProfiles` is validated per-entry in +-- `validate` below so that one corrupt profile doesn't blow away the entire +-- LocalStorage payload. +local validateInterface = t.interface({ hasCompletedOnboarding = t.boolean, isPresenceActive = t.optional(t.boolean), selectedProfileId = t.optional(t.string), - customProfiles = t.optional(t.map(t.string, validateCustomProfile)), + customProfiles = t.optional(t.table), userDetailsCache = t.optional(t.table), disabledAccountIds = t.optional(t.table), }) +--[[ + Validates the full LocalStorage payload. Drops malformed `customProfiles` + entries in-place (logging a warning per drop) before applying the strict + top-level interface check, so a single corrupt profile doesn't cause us + to discard everything. +]] +local function validate(value: any): (boolean, string) + if typeof(value) == "table" and typeof(value.customProfiles) == "table" then + for id, profile in value.customProfiles do + local isValid, message = validateCustomProfile(profile) + if not isValid then + Logger:Warn(`LocalStorageStore: dropping malformed custom profile "{id}": {message}`) + value.customProfiles[id] = nil + end + end + end + + local isValid, message = validateInterface(value) + return isValid, message or "" +end + return { get = Charm.computed(function() return createPluginSettingsStore("PresenceLocalStorage", defaultValue, validate) diff --git a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau new file mode 100644 index 0000000..1fbc0ab --- /dev/null +++ b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau @@ -0,0 +1,49 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore) +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) +local interpolate = require(Plugin.Source.Common.interpolate) + +export type ActivityConfig = createPresenceManager.ActivityConfig +type CustomProfileData = LocalStorageStore.CustomProfileData +type PlaceContext = PlaceDetailsStore.PlaceContext + +local STUDIO_LOGO_ASSET = "studio-logo" + +local function buildCustomActivity(context: PlaceContext, profile: CustomProfileData): ActivityConfig + local onlineDetails = context.onlinePlaceDetails + local offlineDetails = context.offlinePlaceDetails + + local details = profile.activity.details + local state = profile.activity.state + + local templates = { + placeName = if onlineDetails + then onlineDetails.placeName + elseif offlineDetails then offlineDetails.placeName + else "", + creator = if onlineDetails then onlineDetails.creatorName else "", + } + + local activity: ActivityConfig = { + name = "Roblox Studio", + details = if details then interpolate(details, templates) else nil, + detailsUrl = "https://github.com/grilme99/studio-activity", + state = if state then interpolate(state, templates) else nil, + assets = if profile.activity.showPlaceIcon and onlineDetails + then { + largeImage = onlineDetails.iconWebUrl, + smallImage = STUDIO_LOGO_ASSET, + smallText = "Studio Activity", + } + else { + largeImage = STUDIO_LOGO_ASSET, + largeText = "Studio Activity", + }, + } + + return activity +end + +return buildCustomActivity diff --git a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau index ab284d3..4a86637 100644 --- a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau +++ b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau @@ -7,13 +7,14 @@ local Foundation = require(Packages.Foundation) local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) type ActivityConfig = createPresenceManager.ActivityConfig -local PlaceContextStore = require(Plugin.Source.PlaceContextStore) -type PlaceContext = PlaceContextStore.PlaceContext +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +type PlaceContext = PlaceDetailsStore.PlaceContext local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore) local Logger = require(Plugin.Source.Logger) local NotificationStore = require(Plugin.Source.Notification.NotificationStore) local RetryBackoff = require(Plugin.Source.Api.RetryBackoff) +local buildCustomActivity = require(script.Parent.buildCustomActivity) local presets = require(script.Parent.presets) local IconName = Foundation.Enums.IconName @@ -32,6 +33,7 @@ export type Profile = { name: string, icon: Foundation.IconName, isPreset: boolean, + overridePlaceId: number?, buildActivityAsync: (context: PlaceContext) -> ActivityConfig, } @@ -55,8 +57,9 @@ local function wrapCustomProfile(data: CustomProfileData): Profile name = data.name, icon = IconName.DiamondSimplified :: Foundation.IconName, isPreset = false, - buildActivityAsync = function(_context: PlaceContext): ActivityConfig - return data.activity + overridePlaceId = data.activity.overridePlaceId, + buildActivityAsync = function(context: PlaceContext): ActivityConfig + return buildCustomActivity(context, data) end, } end @@ -67,6 +70,7 @@ local function wrapPreset(preset: presets.PresetProfile): Profile name = preset.name, icon = preset.icon, isPreset = true, + overridePlaceId = nil, buildActivityAsync = preset.buildActivityAsync, } end @@ -105,6 +109,7 @@ end local function createProfileStore(): ProfileStore local localStorage = LocalStorageStore.get() + local placeDetailsStore = PlaceDetailsStore.get() local getActiveProfileId = computed(function(): string return localStorage.getStorage().selectedProfileId or "default" @@ -172,16 +177,35 @@ local function createProfileStore(): ProfileStore pcall(task.cancel, buildThread) end - local placeContextStore = PlaceContextStore.get() - local context = placeContextStore.getContext() + local activeProfileId = getActiveProfileId() + local storage = localStorage.getStorage() + local customProfiles: CustomProfiles = storage.customProfiles or {} + local activeCustomProfile = customProfiles[activeProfileId] + + local profile: Profile? + if presets[activeProfileId] then + profile = wrapPreset(presets[activeProfileId]) + elseif activeCustomProfile then + profile = wrapCustomProfile(activeCustomProfile) + else + profile = wrapPreset(presets.default) + end - if context == nil then + local overridePlaceId = if activeCustomProfile then activeCustomProfile.activity.overridePlaceId else nil + if profile == nil then setResolvedActivity(nil) return end - local profile = getActiveProfile() - if not profile then + local context: PlaceContext? + if overridePlaceId and overridePlaceId > 0 then + placeDetailsStore.prefetchPlaceIds({ overridePlaceId }) + context = placeDetailsStore.getContextByPlaceId(overridePlaceId) + else + context = placeDetailsStore.getCurrentContext() + end + + if context == nil then setResolvedActivity(nil) return end @@ -193,6 +217,22 @@ local function createProfileStore(): ProfileStore end) end) + local disposePrefetchEffect = effect(function() + local customProfiles: CustomProfiles = localStorage.getStorage().customProfiles or {} + local placeIds: { number } = {} + + for _, custom in customProfiles do + local overridePlaceId = custom.activity.overridePlaceId + if overridePlaceId and overridePlaceId > 0 then + table.insert(placeIds, overridePlaceId) + end + end + + if #placeIds > 0 then + placeDetailsStore.prefetchPlaceIds(placeIds) + end + end) + local function setCustomProfile(id: string, data: CustomProfileData) localStorage.setStorage(function(prev) local new = table.clone(prev) @@ -221,6 +261,7 @@ local function createProfileStore(): ProfileStore local function destroy() disposeProfileFallbackEffect() disposeBuildEffect() + disposePrefetchEffect() if buildThread then pcall(task.cancel, buildThread) diff --git a/plugin/src/PresenceManager/ProfileStore/presets.luau b/plugin/src/PresenceManager/ProfileStore/presets.luau index 7b9532a..3b728af 100644 --- a/plugin/src/PresenceManager/ProfileStore/presets.luau +++ b/plugin/src/PresenceManager/ProfileStore/presets.luau @@ -9,8 +9,8 @@ type IconName = Foundation.IconName local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) type ActivityConfig = createPresenceManager.ActivityConfig -local PlaceContextStore = require(Plugin.Source.PlaceContextStore) -type PlaceContext = PlaceContextStore.PlaceContext +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +type PlaceContext = PlaceDetailsStore.PlaceContext local STUDIO_LOGO_ASSET = "studio-logo" @@ -42,7 +42,10 @@ local presets: { [string]: PresetProfile } = { smallImage = STUDIO_LOGO_ASSET, smallText = "Studio Activity", } - else nil, + else { + largeImage = STUDIO_LOGO_ASSET, + largeText = "Studio Activity", + }, } end, }, diff --git a/rokit.toml b/rokit.toml index 68b4f32..ed192b2 100644 --- a/rokit.toml +++ b/rokit.toml @@ -4,7 +4,7 @@ wally = "UpliftGames/wally@0.3.2" wally-package-types = "JohnnyMorganz/wally-package-types@1.6.2" selene = "Kampfkarren/selene@0.30.1" stylua = "johnnymorganz/stylua@2.4.0" -luau-lsp = "JohnnyMorganz/luau-lsp@1.63.0" +luau-lsp = "JohnnyMorganz/luau-lsp@1.66.0" lune = "lune-org/lune@0.10.4" roblox-packages = "flipbook-labs/roblox-packages@0.5.0" lute = "luau-lang/lute@1.0.0"