diff --git a/.lute/build.luau b/.lute/build.luau index 819a9f2..d03d7e0 100644 --- a/.lute/build.luau +++ b/.lute/build.luau @@ -100,7 +100,7 @@ local context: BuildContext = { api = { secure = channel == "prod", - host = process.env.API_HOST, + host = if channel == "prod" then process.env.API_HOST else "localhost:8787", }, discord = { diff --git a/backend/src/routes/gh_redirect.rs b/backend/src/routes/gh_redirect.rs index e295a99..796baa0 100644 --- a/backend/src/routes/gh_redirect.rs +++ b/backend/src/routes/gh_redirect.rs @@ -7,7 +7,7 @@ use crate::extractors::{Edge, WorkerContext}; use crate::posthog; const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com"; -const GITHUB_REPO_URL: &str = "https://github.com/grilme99/studio-activity"; +const GITHUB_REPO_URL: &str = "https://github.com/BrookenRecord/studio-activity"; #[allow(clippy::must_use_candidate)] #[tracing::instrument( diff --git a/plugin/src/ActivityPreview/ActivityButtons.luau b/plugin/src/ActivityPreview/ActivityButtons.luau new file mode 100644 index 0000000..ac6f952 --- /dev/null +++ b/plugin/src/ActivityPreview/ActivityButtons.luau @@ -0,0 +1,65 @@ +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 Types = require(script.Parent.Types) + +local View = Foundation.View +local Button = Foundation.Button +local Tooltip = Foundation.Tooltip +local InputSize = Foundation.Enums.InputSize +local ButtonVariant = Foundation.Enums.ButtonVariant +local FillBehavior = Foundation.Enums.FillBehavior +local PopoverAlign = Foundation.Enums.PopoverAlign +local PopoverSide = Foundation.Enums.PopoverSide + +local createNextOrder = ReactUtils.createNextOrder + +local e = React.createElement + +local function NOOP() end + +type Props = { + buttons: { Types.ActivityButton }, + LayoutOrder: number?, +} + +local function ActivityButtons(props: Props) + local nextOrder = createNextOrder() + + local buttonChildren: { React.ReactNode } = {} + for index, button in props.buttons do + table.insert( + buttonChildren, + e(Button, { + text = button.label, + key = tostring(index), + size = InputSize.Small, + variant = if index == 1 then ButtonVariant.SoftEmphasis else ButtonVariant.Standard, + fillBehavior = FillBehavior.Fill, + width = UDim.new(1, 0), + LayoutOrder = index, + onActivated = NOOP, + }) + ) + end + + return e(Tooltip, { + title = if #props.buttons == 1 + then "You won't see this button on your own profile" + else "You won't see these buttons on your own profile", + align = PopoverAlign.Center, + side = PopoverSide.Top, + LayoutOrder = props.LayoutOrder, + }, { + ButtonsContainer = e(View, { + tag = "size-full-0 auto-y col gap-small", + LayoutOrder = nextOrder(), + }, buttonChildren), + }) +end + +return ActivityButtons diff --git a/plugin/src/ActivityPreview/ActivityPreview.luau b/plugin/src/ActivityPreview/ActivityPreview.luau index fa01699..1a22b68 100644 --- a/plugin/src/ActivityPreview/ActivityPreview.luau +++ b/plugin/src/ActivityPreview/ActivityPreview.luau @@ -5,6 +5,7 @@ local Foundation = require(Packages.Foundation) local React = require(Packages.React) local ReactUtils = require(Packages.ReactUtils) +local ActivityButtons = require(script.Parent.ActivityButtons) local ActivityDetails = require(script.Parent.ActivityDetails) local ActivityImage = require(script.Parent.ActivityImage) local ActivityTimer = require(script.Parent.ActivityTimer) @@ -68,6 +69,11 @@ local function ActivityPreview(props: Props) }), }), }), + + ActivityButtons = (props.activity.buttons and #props.activity.buttons > 0) and e(ActivityButtons, { + buttons = props.activity.buttons, + LayoutOrder = nextOrder(), + }), }) end diff --git a/plugin/src/ActivityPreview/Types.luau b/plugin/src/ActivityPreview/Types.luau index a36b898..56280e6 100644 --- a/plugin/src/ActivityPreview/Types.luau +++ b/plugin/src/ActivityPreview/Types.luau @@ -1,3 +1,8 @@ +export type ActivityButton = { + label: string, + url: string, +} + export type ActivityAssets = { largeImage: string?, largeText: string?, @@ -11,6 +16,7 @@ export type Activity = { detailsUrl: string?, state: string?, stateUrl: string?, + buttons: { ActivityButton }?, startedAt: number?, assets: ActivityAssets?, } diff --git a/plugin/src/Api/AppAssetsStore.luau b/plugin/src/Api/AppAssetsStore.luau index a80cc31..9d1b8eb 100644 --- a/plugin/src/Api/AppAssetsStore.luau +++ b/plugin/src/Api/AppAssetsStore.luau @@ -6,6 +6,7 @@ local Charm = require(Packages.Charm) local BuildVars = require(Plugin.Source.BuildVars) local DiscordApi = require(Plugin.Source.Api.Discord) local Logger = require(Plugin.Source.Logger) +local createSingleton = require(Plugin.Source.Common.createSingleton) type ApplicationAsset = DiscordApi.ApplicationAsset @@ -60,7 +61,7 @@ local function createAppAssetsStore(discord: DiscordApi.DiscordApi) end return { - get = Charm.computed(function() + get = createSingleton(function() local discord = DiscordApi.new({ clientId = BuildVars.discord.clientId, }) diff --git a/plugin/src/Authentication/AccountStore/init.luau b/plugin/src/Authentication/AccountStore/init.luau index 8acd4da..3a9442f 100644 --- a/plugin/src/Authentication/AccountStore/init.luau +++ b/plugin/src/Authentication/AccountStore/init.luau @@ -1,14 +1,12 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local BuildVars = require(Plugin.Source.BuildVars) local DiscordApi = require(Plugin.Source.Api.Discord) local createAccountStore = require(script.createAccountStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) return table.freeze({ - get = Charm.computed(function() + get = createSingleton(function() local discord = DiscordApi.new({ clientId = BuildVars.discord.clientId, }) diff --git a/plugin/src/Authentication/AuthStore.luau b/plugin/src/Authentication/AuthStore.luau index 9a933a0..c7bb503 100644 --- a/plugin/src/Authentication/AuthStore.luau +++ b/plugin/src/Authentication/AuthStore.luau @@ -1,10 +1,10 @@ local Plugin = script:FindFirstAncestor("StudioActivity") local Packages = Plugin.Packages -local Charm = require(Packages.Charm) local t = require(Packages.t) local createPluginSettingsStore = require(Plugin.Source.Plugin.createPluginSettingsStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type Account = { accessToken: string, @@ -34,7 +34,7 @@ local validate = t.interface({ }) return { - get = Charm.computed(function() + get = createSingleton(function() return createPluginSettingsStore("PresenceAuthStore", defaultValue, validate) end), } diff --git a/plugin/src/Common/Constants.luau b/plugin/src/Common/Constants.luau new file mode 100644 index 0000000..bfad082 --- /dev/null +++ b/plugin/src/Common/Constants.luau @@ -0,0 +1,8 @@ +local Constants = {} + +local REDIRECT_BASE = + "https://activity.brooke.sh/?utm_source=discord&utm_medium=rich_presence&utm_campaign=studio_activity" +Constants.PLUGIN_LINK_PROFILE_LINK = `{REDIRECT_BASE}&utm_content=profile_link` +Constants.PLUGIN_LINK_PROFILE_BUTTON = `{REDIRECT_BASE}&utm_content=profile_button` + +return table.freeze(Constants) diff --git a/plugin/src/Common/createSingleton.luau b/plugin/src/Common/createSingleton.luau new file mode 100644 index 0000000..12433df --- /dev/null +++ b/plugin/src/Common/createSingleton.luau @@ -0,0 +1,44 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) + +--[[ + Returns a lazy getter for a singleton store. The factory is invoked on + first call and the result is cached forever. The factory runs inside + `Charm.untracked`, so any signal reads or writes performed during the + store's own initialization do NOT register as dependencies of whichever + reactive context (effect / computed / React render) happened to make the + first call. The returned store is still fully reactive at the method + level — its internal signals and computeds keep working as normal. + + Without this, a factory that synchronously reads and writes one of its + own internal signals during init (e.g. `createPlaceDetailsStore` calling + `prefetchPlaceIds(game.PlaceId)`) would link that signal as a dependency + of the calling reactive context, and every subsequent mutation of the + signal would cause the caller to re-evaluate — constructing a brand new + store and feeding back into the same cycle. +]] +local function createSingleton(factory: () -> T): () -> T + local instance: T? = nil + local initializing = false + + return function(): T + if instance ~= nil then + return instance :: T + end + if initializing then + error("createSingleton: recursive factory invocation detected", 2) + end + initializing = true + local ok, result = pcall(Charm.untracked, factory) + initializing = false + if not ok then + error(result, 2) + end + instance = result + return instance :: T + end +end + +return createSingleton diff --git a/plugin/src/CustomProfile/ActivityFields.luau b/plugin/src/CustomProfile/ActivityFields.luau deleted file mode 100644 index 55f3288..0000000 --- a/plugin/src/CustomProfile/ActivityFields.luau +++ /dev/null @@ -1,110 +0,0 @@ -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 index 69f5a5e..36c27f3 100644 --- a/plugin/src/CustomProfile/CustomProfile.luau +++ b/plugin/src/CustomProfile/CustomProfile.luau @@ -5,13 +5,12 @@ 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 SettingsList = require(script.Parent.SettingsList) local Title = require(script.Parent.Title) local View = Foundation.View @@ -91,7 +90,7 @@ local function CustomProfileSheet(props: SheetProps) Content = e(Sheet.Content, nil, { Container = e(View, { - tag = "size-full-0 auto-y col padding-y-small", + tag = "size-full-0 auto-y col padding-top-small", LayoutOrder = nextOrder(), }, { PreviewBlock = e(PreviewBlock, { @@ -102,11 +101,7 @@ local function CustomProfileSheet(props: SheetProps) LayoutOrder = nextOrder(), }), - ActivityFields = e(ActivityFields, { - LayoutOrder = nextOrder(), - }), - - GameOverride = e(GameOverride, { + SettingsList = e(SettingsList, { LayoutOrder = nextOrder(), }), }), diff --git a/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau b/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau new file mode 100644 index 0000000..70fa64d --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau @@ -0,0 +1,104 @@ +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 View = Foundation.View +local Dialog = Foundation.Dialog +local NumberInput = Foundation.NumberInput +local DialogSize = Foundation.Enums.DialogSize +local InputSize = Foundation.Enums.InputSize +local ButtonVariant = Foundation.Enums.ButtonVariant +local OnChangeCallbackReason = Foundation.Enums.OnChangeCallbackReason +local NumberInputControlsVariant = Foundation.Enums.NumberInputControlsVariant + +type OnChangeCallbackReason = Foundation.OnChangeCallbackReason +type CustomProfileStore = ProfileContext.CustomProfileStore + +local createNextOrder = ReactUtils.createNextOrder +local useSignalState = ReactCharm.useSignalState + +local e = React.createElement + +type Props = { + store: CustomProfileStore, + onClose: () -> (), +} + +local function CustomProfileGameOverride(props: Props) + local nextOrder = createNextOrder() + + local override = useSignalState(props.store.getOverrideLookupState) + + return e(Dialog.Root, { + size = DialogSize.Medium, + hasBackdrop = true, + disablePortal = false, + onClose = function() + props.onClose() + end, + }, { + DialogTitle = e(Dialog.Title, { + text = "Game Override", + }), + + DialogContent = e(Dialog.Content, nil, { + LayoutBlock = e(View, { + tag = "size-full-0 auto-y col gap-medium padding-xsmall", + 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 + props.store.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, { + store = props.store, + LayoutOrder = nextOrder(), + }), + }), + }), + + DialogActions = e(Dialog.Actions, { + actions = { + { + text = "Save", + variant = ButtonVariant.Emphasis, + onActivated = function() + props.onClose() + end, + }, + }, + }), + }) +end + +return React.memo(CustomProfileGameOverride) diff --git a/plugin/src/CustomProfile/GameOverride/OverridePreview.luau b/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau similarity index 95% rename from plugin/src/CustomProfile/GameOverride/OverridePreview.luau rename to plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau index 7f2e262..cc83374 100644 --- a/plugin/src/CustomProfile/GameOverride/OverridePreview.luau +++ b/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau @@ -20,17 +20,19 @@ local Radius = Foundation.Enums.Radius local createNextOrder = ReactUtils.createNextOrder local useSignalState = ReactCharm.useSignalState +type CustomProfileStore = ProfileContext.CustomProfileStore + local e = React.createElement type Props = { + store: CustomProfileStore, LayoutOrder: number?, } local function OverridePreview(props: Props) local nextOrder = createNextOrder() - local profile = ProfileContext.useProfile() - local override = useSignalState(profile.profileStore.getOverrideLookupState) + local override = useSignalState(props.store.getOverrideLookupState) local placeDetails = override.context and override.context.onlinePlaceDetails local iconWebUrl = placeDetails and placeDetails.iconWebUrl diff --git a/plugin/src/CustomProfile/CustomProfileGameOverride/init.luau b/plugin/src/CustomProfile/CustomProfileGameOverride/init.luau new file mode 100644 index 0000000..0eb9500 --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfileGameOverride/init.luau @@ -0,0 +1 @@ +return require(script.CustomProfileGameOverride) diff --git a/plugin/src/CustomProfile/CustomProfileText/CustomProfileText.luau b/plugin/src/CustomProfile/CustomProfileText/CustomProfileText.luau new file mode 100644 index 0000000..e8ca170 --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfileText/CustomProfileText.luau @@ -0,0 +1,115 @@ +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 Sift = require(Packages.Sift) + +local ProfileContext = require(script.Parent.Parent.ProfileContext) +local TemplateHint = require(script.Parent.TemplateHint) + +local View = Foundation.View +local Dialog = Foundation.Dialog +local TextInput = Foundation.TextInput +local DialogSize = Foundation.Enums.DialogSize +local ButtonVariant = Foundation.Enums.ButtonVariant +local InputSize = Foundation.Enums.InputSize + +local createNextOrder = ReactUtils.createNextOrder + +local e = React.createElement + +type CustomProfileData = ProfileContext.CustomProfileData +type CustomProfileStore = ProfileContext.CustomProfileStore + +type Props = { + profile: React.Binding, + store: CustomProfileStore, + onClose: () -> (), +} + +local function CustomProfileText(props: Props) + local nextOrder = createNextOrder() + + return e(Dialog.Root, { + size = DialogSize.Medium, + hasBackdrop = true, + disablePortal = false, + onClose = function() + props.onClose() + end, + }, { + DialogTitle = e(Dialog.Title, { + text = "Custom Text", + }), + + DialogContent = e(Dialog.Content, nil, { + Container = e(View, { + tag = "size-full-0 auto-y col gap-large padding-xsmall padding-top-small", + LayoutOrder = nextOrder(), + }, { + InputContainer = e(View, { + tag = "size-full-0 auto-y col gap-medium", + LayoutOrder = nextOrder(), + }, { + Details = e(TextInput, { + text = props.profile: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) + props.store.setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + details = if text == "" then Sift.None else text, + }, + }) + end) + end, + }), + + State = e(TextInput, { + text = props.profile: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) + props.store.setProfile(function(prev) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + state = if text == "" then Sift.None else text, + }, + }) + end) + end, + }), + }), + + TemplateHint = e(TemplateHint, { + LayoutOrder = nextOrder(), + }), + }), + }), + + DialogActions = e(Dialog.Actions, { + actions = { + { + text = "Save", + variant = ButtonVariant.Emphasis, + onActivated = function() + props.onClose() + end, + }, + }, + }), + }) +end + +return React.memo(CustomProfileText) diff --git a/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau b/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau new file mode 100644 index 0000000..b1aa8b7 --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau @@ -0,0 +1,42 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Foundation = require(Packages.Foundation) +local React = require(Packages.React) + +local FeedbackAlert = Foundation.FeedbackAlert +local AlertSeverity = Foundation.Enums.AlertSeverity + +local e = React.createElement + +local TEMPLATES = { + "placeName", + "creator", +} + +type Props = { + LayoutOrder: number?, +} + +local function TemplateHint(props: Props) + local description = + "Use the templating syntax to add dynamic data to your Discord activity. Available templates:\n\n" + + for i, template in TEMPLATES do + description ..= `• \{{template}\}` + if i ~= #TEMPLATES then + description ..= "\n" + end + end + + description ..= "\n\nExample: I'm working on {placeName}" + + return e(FeedbackAlert, { + severity = AlertSeverity.Info, + title = "Templating Syntax", + description = description, + LayoutOrder = props.LayoutOrder, + }, {}) +end + +return TemplateHint diff --git a/plugin/src/CustomProfile/CustomProfileText/init.luau b/plugin/src/CustomProfile/CustomProfileText/init.luau new file mode 100644 index 0000000..3518297 --- /dev/null +++ b/plugin/src/CustomProfile/CustomProfileText/init.luau @@ -0,0 +1 @@ +return require(script.CustomProfileText) diff --git a/plugin/src/CustomProfile/GameOverride/GameOverride.luau b/plugin/src/CustomProfile/GameOverride/GameOverride.luau deleted file mode 100644 index 8b27ba8..0000000 --- a/plugin/src/CustomProfile/GameOverride/GameOverride.luau +++ /dev/null @@ -1,79 +0,0 @@ -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/init.luau b/plugin/src/CustomProfile/GameOverride/init.luau deleted file mode 100644 index 9d5396e..0000000 --- a/plugin/src/CustomProfile/GameOverride/init.luau +++ /dev/null @@ -1 +0,0 @@ -return require(script.GameOverride) diff --git a/plugin/src/CustomProfile/SettingsList.luau b/plugin/src/CustomProfile/SettingsList.luau new file mode 100644 index 0000000..64fb654 --- /dev/null +++ b/plugin/src/CustomProfile/SettingsList.luau @@ -0,0 +1,150 @@ +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 ModalStore = require(Plugin.Source.Modal.ModalStore) +local ProfileContext = require(script.Parent.ProfileContext) + +local View = Foundation.View +local List = Foundation.List +local InputSize = Foundation.Enums.InputSize +local IconName = Foundation.Enums.IconName +local ListItemInputType = Foundation.Enums.ListItemInputType + +local createNextOrder = ReactUtils.createNextOrder +local useSignalBinding = ReactCharm.useSignalBinding +local useSignalState = ReactCharm.useSignalState + +local e = React.createElement + +type CustomProfileData = ProfileContext.CustomProfileData + +local function withDefault(value: boolean?, default: boolean): boolean + return if value == nil then default else value +end + +type Props = { + LayoutOrder: number?, +} + +local function SettingsList(props: Props) + local nextOrder = createNextOrder() + local profileStore = ProfileContext.useProfile().profileStore + + local currentProfile = useSignalState(profileStore.getProfile) + local profileBinding = useSignalBinding(profileStore.getProfile) + + local showPlaceIcon = withDefault(currentProfile.activity.showPlaceIcon, true) + local showJoinButton = withDefault(currentProfile.activity.showJoinButton, true) + local showPluginButton = withDefault(currentProfile.activity.showPluginButton, false) + + return e(View, { + tag = "size-full-0 auto-y", + LayoutOrder = props.LayoutOrder, + }, { + List = e(List.Root, { + hasDivider = true, + isContained = { isContained = false :: false, hasMargin = false }, + size = InputSize.Small, + }, { + ActivityText = e(List.Item, { + leading = IconName.TextUppercaseALowercaseA, + title = "Custom text", + description = "Edit the text shown on your activity.", + onActivated = function() + ModalStore.get().open("customProfileText", { + props = { + profile = profileBinding, + store = profileStore, + }, + }) + end, + LayoutOrder = nextOrder(), + }), + + GameOverride = e(List.Item, { + leading = IconName.ControllerWithCog, + title = "Game override", + description = "Override the game displayed on your activity.", + onActivated = function() + ModalStore.get().open("customProfileGameOverride", { + props = { + store = profileStore, + }, + }) + end, + LayoutOrder = nextOrder(), + }), + + GameIconToggle = e(List.Item, { + leading = IconName.FrameCamera, + title = "Show game icon", + description = "Show the platform icon of the place you're currently editing.", + onActivated = { + onActivated = function() + profileStore.setProfile(function(prev) + local current = withDefault(prev.activity.showPlaceIcon, true) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showPlaceIcon = not current, + }, + }) + end) + end, + inputType = ListItemInputType.Toggle, + isChecked = showPlaceIcon, + }, + LayoutOrder = nextOrder(), + }), + + JoinButtonToggle = e(List.Item, { + leading = IconName.ChainLink, + title = "Show play button", + description = "Link the place you're currently editing with a 'Play on Roblox' button.", + onActivated = { + onActivated = function() + profileStore.setProfile(function(prev) + local current = withDefault(prev.activity.showJoinButton, true) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showJoinButton = not current, + }, + }) + end) + end, + inputType = ListItemInputType.Toggle, + isChecked = showJoinButton, + }, + LayoutOrder = nextOrder(), + }), + + PluginButtonToggle = e(List.Item, { + leading = IconName.Heart, + title = "Show plugin button", + description = "Support Studio Activity by linking it on your profile.", + onActivated = { + onActivated = function() + profileStore.setProfile(function(prev) + local current = withDefault(prev.activity.showPluginButton, false) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showPluginButton = not current, + }, + }) + end) + end, + inputType = ListItemInputType.Toggle, + isChecked = showPluginButton, + }, + LayoutOrder = nextOrder(), + }), + }), + }) +end + +return React.memo(SettingsList) diff --git a/plugin/src/CustomProfile/createCustomProfileStore.luau b/plugin/src/CustomProfile/createCustomProfileStore.luau index 7f4682d..fe5b509 100644 --- a/plugin/src/CustomProfile/createCustomProfileStore.luau +++ b/plugin/src/CustomProfile/createCustomProfileStore.luau @@ -25,6 +25,8 @@ local DEFAULT_CUSTOM_PROFILE: CustomProfileData = { details = "Building my game", state = "{placeName}", showPlaceIcon = true, + showJoinButton = true, + showPluginButton = false, }, } diff --git a/plugin/src/Modal/ConfirmationDialog.luau b/plugin/src/Modal/ConfirmationDialog.luau index a4c6563..2ea30c8 100644 --- a/plugin/src/Modal/ConfirmationDialog.luau +++ b/plugin/src/Modal/ConfirmationDialog.luau @@ -14,7 +14,6 @@ type Props = { title: string?, body: string?, onClose: (result: boolean) -> (), - LayoutOrder: number?, } local function ConfirmationDialog(props: Props) diff --git a/plugin/src/Modal/ModalRenderer.luau b/plugin/src/Modal/ModalRenderer.luau index 32b191a..5e69c11 100644 --- a/plugin/src/Modal/ModalRenderer.luau +++ b/plugin/src/Modal/ModalRenderer.luau @@ -7,6 +7,8 @@ local ReactCharm = require(Packages.ReactCharm) local AddAccountFlow = require(Plugin.Source.AddAccountFlow) local ConfirmationDialog = require(script.Parent.ConfirmationDialog) local CustomProfile = require(Plugin.Source.CustomProfile) +local CustomProfileGameOverride = require(Plugin.Source.CustomProfile.CustomProfileGameOverride) +local CustomProfileText = require(Plugin.Source.CustomProfile.CustomProfileText) local ModalStore = require(script.Parent.ModalStore) local SettingsModal = require(Plugin.Source.UserSettings.SettingsModal) local WelcomeDialog = require(Plugin.Source.Telemetry.TelemetryOptOutDialog) @@ -71,6 +73,27 @@ local MODAL_REGISTRY: { [ModalStore.ModalId]: ModalConfig } = { }) end, }, + + customProfileText = { + type = "dialog", + render = function(_isOpen, onClose, props) + return e(CustomProfileText, { + onClose = onClose, + profile = props.profile, + store = props.store, + }) + end, + }, + + customProfileGameOverride = { + type = "dialog", + render = function(_isOpen, onClose, props) + return e(CustomProfileGameOverride, { + onClose = onClose, + store = props.store, + }) + end, + }, } local function ModalRenderer() diff --git a/plugin/src/Modal/ModalStore/createModalStore.luau b/plugin/src/Modal/ModalStore/createModalStore.luau index 5dadf4c..48d7907 100644 --- a/plugin/src/Modal/ModalStore/createModalStore.luau +++ b/plugin/src/Modal/ModalStore/createModalStore.luau @@ -6,7 +6,14 @@ local Charm = require(Packages.Charm) local signal = Charm.signal local computed = Charm.computed -export type ModalId = "addAccount" | "welcome" | "confirmation" | "settings" | "customProfile" +export type ModalId = + "addAccount" + | "welcome" + | "confirmation" + | "settings" + | "customProfile" + | "customProfileText" + | "customProfileGameOverride" export type ModalStackEntry = { id: ModalId, diff --git a/plugin/src/Modal/ModalStore/init.luau b/plugin/src/Modal/ModalStore/init.luau index 6902e76..571dcec 100644 --- a/plugin/src/Modal/ModalStore/init.luau +++ b/plugin/src/Modal/ModalStore/init.luau @@ -1,12 +1,10 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local createModalStore = require(script.createModalStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type ModalId = createModalStore.ModalId export type ModalStackEntry = createModalStore.ModalStackEntry return { - get = Charm.computed(createModalStore), + get = createSingleton(createModalStore), } diff --git a/plugin/src/Notification/NotificationStore/init.luau b/plugin/src/Notification/NotificationStore/init.luau index f431231..5a95da6 100644 --- a/plugin/src/Notification/NotificationStore/init.luau +++ b/plugin/src/Notification/NotificationStore/init.luau @@ -1,11 +1,9 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local createNotificationStore = require(script.createNotificationStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type NotificationType = createNotificationStore.NotificationType return { - get = Charm.computed(createNotificationStore), + get = createSingleton(createNotificationStore), } diff --git a/plugin/src/PlaceDetailsStore/init.luau b/plugin/src/PlaceDetailsStore/init.luau index 1913d95..ce53b0e 100644 --- a/plugin/src/PlaceDetailsStore/init.luau +++ b/plugin/src/PlaceDetailsStore/init.luau @@ -1,9 +1,7 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local createPlaceDetailsStore = require(script.createPlaceDetailsStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type PlaceType = createPlaceDetailsStore.PlaceType export type Status = createPlaceDetailsStore.Status @@ -14,7 +12,5 @@ export type PlaceEntry = createPlaceDetailsStore.PlaceEntry export type PlaceDetailsStore = createPlaceDetailsStore.PlaceDetailsStore return table.freeze({ - get = Charm.computed(function() - return createPlaceDetailsStore() - end), + get = createSingleton(createPlaceDetailsStore), }) diff --git a/plugin/src/Plugin/LocalStorageStore.luau b/plugin/src/Plugin/LocalStorageStore.luau index e2f6533..105f118 100644 --- a/plugin/src/Plugin/LocalStorageStore.luau +++ b/plugin/src/Plugin/LocalStorageStore.luau @@ -1,15 +1,20 @@ local Plugin = script:FindFirstAncestor("StudioActivity") 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 createSingleton = require(Plugin.Source.Common.createSingleton) local DiscordApi = require(Plugin.Source.Api.Discord) type ApiUser = DiscordApi.User +export type CustomProfileButton = { + label: string, + url: string, +} + --[[ NOTE: All optional fields here are intentionally tri-state — `nil` means "use the runtime default" (which can differ per field), and `true`/`false` @@ -21,6 +26,9 @@ type CustomProfileActivity = { details: string?, state: string?, showPlaceIcon: boolean?, + showJoinButton: boolean?, + showPluginButton: boolean?, + customButtons: { CustomProfileButton }?, overridePlaceId: number?, } @@ -95,7 +103,7 @@ local function validate(value: any): (boolean, string) end return { - get = Charm.computed(function() + get = createSingleton(function() return createPluginSettingsStore("PresenceLocalStorage", defaultValue, validate) end), } diff --git a/plugin/src/Plugin/PluginStore/init.luau b/plugin/src/Plugin/PluginStore/init.luau index 558ef4a..53ee219 100644 --- a/plugin/src/Plugin/PluginStore/init.luau +++ b/plugin/src/Plugin/PluginStore/init.luau @@ -1,10 +1,8 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local createPluginStore = require(script.createPluginStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) return { - get = Charm.computed(createPluginStore), + get = createSingleton(createPluginStore), } diff --git a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau index 1fbc0ab..b535aaf 100644 --- a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau +++ b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau @@ -1,5 +1,6 @@ local Plugin = script:FindFirstAncestor("StudioActivity") +local Constants = require(Plugin.Source.Common.Constants) local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore) local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) @@ -10,6 +11,7 @@ type CustomProfileData = LocalStorageStore.CustomProfileData type PlaceContext = PlaceDetailsStore.PlaceContext local STUDIO_LOGO_ASSET = "studio-logo" +local MAX_CUSTOM_BUTTONS = 2 local function buildCustomActivity(context: PlaceContext, profile: CustomProfileData): ActivityConfig local onlineDetails = context.onlinePlaceDetails @@ -26,11 +28,35 @@ local function buildCustomActivity(context: PlaceContext, profile: CustomProfile creator = if onlineDetails then onlineDetails.creatorName else "", } + local buttons: { createPresenceManager.ActivityConfigButton } = {} + if onlineDetails and profile.activity.showJoinButton then + table.insert(buttons, { + label = "Play on Roblox", + url = onlineDetails.placeLink, + }) + end + + if profile.activity.showPluginButton then + table.insert(buttons, { + label = "Get Studio Activity", + url = Constants.PLUGIN_LINK_PROFILE_BUTTON, + }) + end + + if profile.activity.customButtons then + local remaining = MAX_CUSTOM_BUTTONS - #buttons + local count = math.min(remaining, #profile.activity.customButtons) + for i = 1, count do + table.insert(buttons, profile.activity.customButtons[i]) + end + end + 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, + detailsUrl = Constants.PLUGIN_LINK_PROFILE_LINK, + buttons = if #buttons > 0 then buttons else nil, assets = if profile.activity.showPlaceIcon and onlineDetails then { largeImage = onlineDetails.iconWebUrl, diff --git a/plugin/src/PresenceManager/ProfileStore/init.luau b/plugin/src/PresenceManager/ProfileStore/init.luau index b9e835a..90d2aaa 100644 --- a/plugin/src/PresenceManager/ProfileStore/init.luau +++ b/plugin/src/PresenceManager/ProfileStore/init.luau @@ -1,15 +1,11 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local createProfileStore = require(script.createProfileStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type ProfileStore = createProfileStore.ProfileStore export type Profile = createProfileStore.Profile return table.freeze({ - get = Charm.computed(function() - return createProfileStore() - end), + get = createSingleton(createProfileStore), }) diff --git a/plugin/src/PresenceManager/ProfileStore/presets.luau b/plugin/src/PresenceManager/ProfileStore/presets.luau index 3b728af..4252f42 100644 --- a/plugin/src/PresenceManager/ProfileStore/presets.luau +++ b/plugin/src/PresenceManager/ProfileStore/presets.luau @@ -3,13 +3,14 @@ local Plugin = script:FindFirstAncestor("StudioActivity") local Packages = Plugin.Packages local Foundation = require(Packages.Foundation) +local Constants = require(Plugin.Source.Common.Constants) +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) + local IconName = Foundation.Enums.IconName type IconName = Foundation.IconName -local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) type ActivityConfig = createPresenceManager.ActivityConfig - -local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) type PlaceContext = PlaceDetailsStore.PlaceContext local STUDIO_LOGO_ASSET = "studio-logo" @@ -35,7 +36,12 @@ local presets: { [string]: PresetProfile } = { then onlineDetails.placeName elseif offlineDetails then offlineDetails.placeName else nil, - stateUrl = if onlineDetails then onlineDetails.placeLink else nil, + detailsUrl = Constants.PLUGIN_LINK_PROFILE_LINK, + buttons = if onlineDetails + then { + { label = "Play on Roblox", url = onlineDetails.placeLink }, + } + else nil, assets = if onlineDetails then { largeImage = onlineDetails.iconWebUrl, @@ -57,6 +63,7 @@ local presets: { [string]: PresetProfile } = { return { name = "Roblox Studio", details = "Editing in Studio", + detailsUrl = Constants.PLUGIN_LINK_PROFILE_LINK, assets = { largeImage = STUDIO_LOGO_ASSET, largeText = "Studio Activity", diff --git a/plugin/src/PresenceManager/createPresenceManager.luau b/plugin/src/PresenceManager/createPresenceManager.luau index bd4e601..b5d25f9 100644 --- a/plugin/src/PresenceManager/createPresenceManager.luau +++ b/plugin/src/PresenceManager/createPresenceManager.luau @@ -32,12 +32,18 @@ export type ActivityConfigAssets = { smallText: string?, } +export type ActivityConfigButton = { + label: string, + url: string, +} + export type ActivityConfig = { name: string, details: string?, detailsUrl: string?, state: string?, stateUrl: string?, + buttons: { ActivityConfigButton }?, startedAt: number?, assets: ActivityConfigAssets?, } @@ -56,6 +62,10 @@ export type PresenceManager = { --- Convert ActivityConfig into the API format (mostly changing to snake_case.) local function serializeActivity(activity: ActivityConfig, startedAt: number?): ApiActivity + if activity.buttons and #activity.buttons > 2 then + Logger:Warn("Activities only support up to 2 buttons") + end + local apiActivity: ApiActivity = { type = 0, -- PLAYING name = activity.name, @@ -66,6 +76,8 @@ local function serializeActivity(activity: ActivityConfig, startedAt: number?): state = activity.state, state_url = activity.stateUrl, + buttons = activity.buttons, + application_id = BuildVars.discord.clientId, platform = "desktop", } diff --git a/plugin/src/PresenceManager/init.luau b/plugin/src/PresenceManager/init.luau index 1221291..a9e23bb 100644 --- a/plugin/src/PresenceManager/init.luau +++ b/plugin/src/PresenceManager/init.luau @@ -1,18 +1,16 @@ local Plugin = script:FindFirstAncestor("StudioActivity") -local Packages = Plugin.Packages -local Charm = require(Packages.Charm) - local BuildVars = require(Plugin.Source.BuildVars) local DiscordApi = require(Plugin.Source.Api.Discord) local createPresenceManager = require(script.createPresenceManager) +local createSingleton = require(Plugin.Source.Common.createSingleton) export type PresenceManager = createPresenceManager.PresenceManager export type ActivityConfig = createPresenceManager.ActivityConfig export type ActivityConfigAssets = createPresenceManager.ActivityConfigAssets return table.freeze({ - get = Charm.computed(function() + get = createSingleton(function() local discord = DiscordApi.new({ clientId = BuildVars.discord.clientId, }) diff --git a/plugin/src/UserSettings/UserSettingsStore.luau b/plugin/src/UserSettings/UserSettingsStore.luau index a8a27c6..b7165bc 100644 --- a/plugin/src/UserSettings/UserSettingsStore.luau +++ b/plugin/src/UserSettings/UserSettingsStore.luau @@ -1,10 +1,10 @@ local Plugin = script:FindFirstAncestor("StudioActivity") local Packages = Plugin.Packages -local Charm = require(Packages.Charm) local t = require(Packages.t) local createPluginSettingsStore = require(Plugin.Source.Plugin.createPluginSettingsStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) local defaultSettings = require(script.Parent.DefaultSettings) export type UserSettings = { @@ -28,7 +28,7 @@ for key in defaultSettings do end return { - get = Charm.computed(function() + get = createSingleton(function() return createPluginSettingsStore("PresenceUserSettings", defaultValue, validate) end), }