From f5484df6ccb29355b46c6e0bcbf3aab07d40c8ef Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Wed, 20 May 2026 00:24:59 -0400 Subject: [PATCH 1/6] more activities --- plugin/bin/setup.luau | 18 +- plugin/bin/setupGuest.luau | 108 ++++++++++ plugin/src/CustomProfile/PreviewBlock.luau | 13 +- plugin/src/PluginContextStore/Types.luau | 39 ++++ .../createConnectionTracker.luau | 193 ++++++++++++++++++ .../createPluginContextStore.luau | 102 +++++++++ plugin/src/PluginContextStore/init.luau | 18 ++ .../ProfileStore/BuildContext.luau | 11 + .../ProfileStore/buildCustomActivity.luau | 11 +- .../ProfileStore/createProfileStore.luau | 28 ++- .../PresenceManager/ProfileStore/presets.luau | 32 ++- 11 files changed, 544 insertions(+), 29 deletions(-) create mode 100644 plugin/bin/setupGuest.luau create mode 100644 plugin/src/PluginContextStore/Types.luau create mode 100644 plugin/src/PluginContextStore/createConnectionTracker.luau create mode 100644 plugin/src/PluginContextStore/createPluginContextStore.luau create mode 100644 plugin/src/PluginContextStore/init.luau create mode 100644 plugin/src/PresenceManager/ProfileStore/BuildContext.luau diff --git a/plugin/bin/setup.luau b/plugin/bin/setup.luau index 9561fa0..3d7f73e 100644 --- a/plugin/bin/setup.luau +++ b/plugin/bin/setup.luau @@ -8,17 +8,20 @@ local Charm = require(Packages.Charm) local Telemetry = require(Plugin.Source.Telemetry.fireEventAsync) local Types = require(Plugin.Source.Telemetry.Types) local createPluginLoader = require(script.Parent.createPluginLoader) +local setupGuest = require(script.Parent.setupGuest) export type PluginMain = (plugin: Plugin, pluginLoaderContext: createPluginLoader.PluginLoaderContext) -> () -> () local function setup(plugin: Plugin, main: PluginMain) plugin.Name = "StudioActivity" - -- Currently, the plugin breaks in weird ways when running in Play Solo. - -- While I come up with a better long-term solution, we're just going to - -- block the plugin from running if we're in an active DataModel. - -- See: https://github.com/grilme99/studio-activity/issues/6 + -- Roblox loads this plugin into both the edit data model and any active + -- play-test data models (Play Solo spawns a server + client pair). The + -- edit instance owns the UI, presence pipeline, and Discord sessions. + -- Play-test instances run a stripped-down guest that just reports their + -- liveness back to the host via PluginConnectionService. if RunService:IsRunning() then + setupGuest(plugin) return end @@ -38,6 +41,12 @@ local function setup(plugin: Plugin, main: PluginMain) local PresenceManager = require(Plugin.Source.PresenceManager) local presenceManager = PresenceManager.get() + -- Initialize PluginContextStore so it begins tracking the active script + -- and any play-test data models before the user opens the UI. This must + -- happen before ProfileStore so the build effect sees a populated context. + local PluginContextStore = require(Plugin.Source.PluginContextStore) + local pluginContextStore = PluginContextStore.get() + -- Initialize ProfileStore early so it loads the active profile from disk -- and begins resolving it against PlaceContext. local ProfileStore = require(Plugin.Source.PresenceManager.ProfileStore) @@ -128,6 +137,7 @@ local function setup(plugin: Plugin, main: PluginMain) disposeProfileEffect() profileStore.destroy() presenceManager.destroy() + pluginContextStore.destroy() pluginLoaderContext.destroy() end) diff --git a/plugin/bin/setupGuest.luau b/plugin/bin/setupGuest.luau new file mode 100644 index 0000000..87d76a2 --- /dev/null +++ b/plugin/bin/setupGuest.luau @@ -0,0 +1,108 @@ +--[[ + Lightweight entry point used when the plugin loads inside a play-test + data model rather than the edit data model. + + Play Solo creates two test data models (one server, one client) that + each receive their own copy of the plugin. We don't run the full UI, + auth, or presence pipeline in those data models; we just open a + `PluginConnection` to the edit data model and tell the host we're + running, then sit quietly until the data model is torn down. + + The host treats the existence of any connected `PluginConnectionTargetType.Test` + connection as "play testing", so messages here are best-effort + enrichment (role: server/client) rather than load-bearing signals. +]] + +local PluginConnectionService = game:GetService("PluginConnectionService") +local RunService = game:GetService("RunService") + +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Logger = require(Plugin.Source.Logger) + +local EDIT_TYPE = Enum.PluginConnectionTargetType.Edit + +type Role = "server" | "client" | "unknown" + +local function detectRole(): Role + if RunService:IsServer() then + return "server" + elseif RunService:IsClient() then + return "client" + end + return "unknown" +end + +local function sendStarted(connection: PluginConnection, role: Role) + if not connection.Connected then + return + end + local ok, err = pcall(function() + connection:SendMessage({ + kind = "playtestStarted", + role = role, + }) + end) + if not ok then + Logger:Warn(`setupGuest: failed to send playtestStarted: {tostring(err)}`) + end +end + +local function sendStopped(connection: PluginConnection) + if not connection.Connected then + return + end + pcall(function() + connection:SendMessage({ kind = "playtestStopped" }) + end) +end + +local function setupGuest(plugin: Plugin) + plugin.Name = "StudioActivity" + + if not PluginConnectionService:CanHaveConnectionType(EDIT_TYPE) then + Logger:Info("setupGuest: data model cannot have an Edit connection; skipping") + return + end + + local role: Role = detectRole() + Logger:Info(`setupGuest: starting guest mode with role={role}`) + + -- Track all connections we've already announced ourselves to, so we don't + -- double-send if the Connected event fires for the same connection. + local announced: { [string]: PluginConnection } = {} + + local function announce(connection: PluginConnection) + local targetId = connection.TargetId + if announced[targetId] then + return + end + announced[targetId] = connection + sendStarted(connection, role) + end + + -- Subscribe first so we don't miss any Edit connection that arrives + -- between subscribe and enumerate. + local connectedConn = PluginConnectionService.Connected:Connect(function(connection: PluginConnection) + if connection.Type == EDIT_TYPE then + announce(connection) + end + end) + + for _, existing in PluginConnectionService:GetPluginConnectionsOfType(EDIT_TYPE) do + announce(existing) + end + + plugin.Unloading:Connect(function() + -- Best-effort stopped notification. The connection dropping is the + -- canonical signal; this is just a hint that may arrive faster. + for _, connection in announced do + sendStopped(connection) + end + + connectedConn:Disconnect() + table.clear(announced) + end) +end + +return setupGuest diff --git a/plugin/src/CustomProfile/PreviewBlock.luau b/plugin/src/CustomProfile/PreviewBlock.luau index 78735fd..030d04f 100644 --- a/plugin/src/CustomProfile/PreviewBlock.luau +++ b/plugin/src/CustomProfile/PreviewBlock.luau @@ -8,11 +8,14 @@ local ReactCharm = require(Packages.ReactCharm) local ReactUtils = require(Packages.ReactUtils) local ActivityPreview = require(Plugin.Source.ActivityPreview) +local BuildContext = require(Plugin.Source.PresenceManager.ProfileStore.BuildContext) local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local PluginContextStore = require(Plugin.Source.PluginContextStore) local ProfileContext = require(script.Parent.ProfileContext) local buildCustomActivity = require(Plugin.Source.PresenceManager.ProfileStore.buildCustomActivity) type ActivityConfig = buildCustomActivity.ActivityConfig +type BuildContext = BuildContext.BuildContext local View = Foundation.View @@ -30,6 +33,7 @@ local function PreviewBlock(props: Props) local nextOrder = createNextOrder() local profile = ProfileContext.useProfile() local placeDetailsStore = PlaceDetailsStore.get() + local pluginContextStore = PluginContextStore.get() local currentActivityComputed = useMemo(function() return Charm.computed(function(): ActivityConfig? @@ -43,9 +47,14 @@ local function PreviewBlock(props: Props) return nil end - return buildCustomActivity(placeContext, customProfile) + local buildContext: BuildContext = { + place = placeContext, + studio = pluginContextStore.getStudioContext(), + } + + return buildCustomActivity(buildContext, customProfile) end) - end, { profile.profileStore :: any, placeDetailsStore }) + end, { profile.profileStore :: any, placeDetailsStore, pluginContextStore }) local currentActivity = useSignalState(currentActivityComputed) diff --git a/plugin/src/PluginContextStore/Types.luau b/plugin/src/PluginContextStore/Types.luau new file mode 100644 index 0000000..f6ee5bb --- /dev/null +++ b/plugin/src/PluginContextStore/Types.luau @@ -0,0 +1,39 @@ +export type TestPeerRole = "server" | "client" | "unknown" + +export type TestPeer = { + targetId: string, + role: TestPeerRole, +} + +export type ActiveScriptInfo = { + name: string, + fullName: string, + className: string, +} + +export type StudioContext = { + isPlayTesting: boolean, + testPeers: { [string]: TestPeer }, + activeScript: ActiveScriptInfo?, +} + +--[[ + Wire protocol between guest (test DM) and host (edit DM). + + The host treats the existence of a connected `PluginConnection` of type + `Test` as the source of truth for "play testing is active". Messages + enrich state (currently just the role) and provide forward-compatibility + headroom. + + Unknown `kind` values are ignored on the receiver side. +]] +export type GuestMessage = + { + kind: "playtestStarted", + role: TestPeerRole, + } + | { + kind: "playtestStopped", + } + +return {} diff --git a/plugin/src/PluginContextStore/createConnectionTracker.luau b/plugin/src/PluginContextStore/createConnectionTracker.luau new file mode 100644 index 0000000..e67945a --- /dev/null +++ b/plugin/src/PluginContextStore/createConnectionTracker.luau @@ -0,0 +1,193 @@ +local PluginConnectionService = game:GetService("PluginConnectionService") + +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) + +local Logger = require(Plugin.Source.Logger) +local Types = require(script.Parent.Types) + +type TestPeer = Types.TestPeer +type TestPeerRole = Types.TestPeerRole + +export type ConnectionTracker = { + getPeers: () -> { [string]: TestPeer }, + destroy: () -> (), +} + +local function parseRole(value: any): TestPeerRole + if value == "server" or value == "client" then + return value :: TestPeerRole + end + return "unknown" +end + +local function createConnectionTracker(): ConnectionTracker + local getPeers, setPeers = Charm.signal<<{ [string]: TestPeer }>>({}) + + local connections: { RBXScriptConnection } = {} + + -- Keyed by TargetId; tracks per-connection disposers so we can clean up if a + -- single connection drops without nuking the whole tracker + local perConnectionDisposers: { [string]: { RBXScriptConnection } } = {} + + local destroyed = false + + local function disconnectAll(list: { RBXScriptConnection }) + for _, rbxConn in list do + rbxConn:Disconnect() + end + table.clear(list) + end + + local function removePeer(targetId: string) + setPeers(function(prev) + if prev[targetId] == nil then + return prev + end + local new = table.clone(prev) + new[targetId] = nil + return new + end) + + local disposers = perConnectionDisposers[targetId] + if disposers then + perConnectionDisposers[targetId] = nil + disconnectAll(disposers) + end + end + + local function upsertPeer(targetId: string, role: TestPeerRole) + setPeers(function(prev) + local existing = prev[targetId] + -- Don't downgrade a known role back to "unknown" + local nextRole: TestPeerRole = if role == "unknown" + and existing + and existing.role ~= "unknown" + then existing.role + else role + + if existing and existing.role == nextRole then + return prev + end + + local new = table.clone(prev) + new[targetId] = { + targetId = targetId, + role = nextRole, + } + return new + end) + end + + local function registerConnection(connection: PluginConnection) + if destroyed then + return + end + + local targetId = connection.TargetId + + -- A connection may already be disconnected by the time we observe it + -- (rare race between enumeration and registration). Skip it. + if not connection.Connected then + return + end + + -- Avoid double-registering on the same TargetId + if perConnectionDisposers[targetId] then + return + end + + local disposers: { RBXScriptConnection } = {} + perConnectionDisposers[targetId] = disposers + + upsertPeer(targetId, "unknown") + + local messageOk, messageBinding = pcall(function() + return connection:BindToMessage(function(message: any) + if typeof(message) ~= "table" then + return + end + + local kind = message.kind + if kind == "playtestStarted" then + upsertPeer(targetId, parseRole(message.role)) + elseif kind == "playtestStopped" then + -- Connection drop is canonical; treat explicit stop as a + -- hint and still wait for `Connected` to flip. Some guests + -- may send this best-effort right before unload. + removePeer(targetId) + else + Logger:Info(`PluginContextStore: ignored unknown guest message kind={tostring(kind)}`) + end + end) + end) + + if messageOk and messageBinding then + table.insert(disposers, messageBinding) + else + Logger:Warn(`PluginContextStore: failed to bind message handler for {targetId}: {tostring(messageBinding)}`) + end + + local propOk, propSignal = pcall(function() + return connection:GetPropertyChangedSignal("Connected") + end) + + if propOk and propSignal then + local rbxConn = propSignal:Connect(function() + if not connection.Connected then + removePeer(targetId) + end + end) + table.insert(disposers, rbxConn) + else + Logger:Warn(`PluginContextStore: failed to subscribe Connected for {targetId}: {tostring(propSignal)}`) + end + + -- Re-check Connected after we've registered. If it became false between + -- the initial check and now, we still want to drop the peer. + if not connection.Connected then + removePeer(targetId) + end + end + + --[[ + Subscribe to new connections first so nothing that arrives between + "subscribe" and "enumerate" is missed. Pre-existing connections are + then enumerated and registered (registration is idempotent). + ]] + local addedConn = PluginConnectionService.Connected:Connect(function(newConnection) + registerConnection(newConnection) + end) + table.insert(connections, addedConn) + + for _, existingConnection in + PluginConnectionService:GetPluginConnectionsOfType(Enum.PluginConnectionTargetType.Test) + do + registerConnection(existingConnection) + end + + local function destroy() + if destroyed then + return + end + destroyed = true + + disconnectAll(connections) + + for targetId, disposers in perConnectionDisposers do + disconnectAll(disposers) + perConnectionDisposers[targetId] = nil + end + + setPeers({}) + end + + return { + getPeers = getPeers, + destroy = destroy, + } +end + +return createConnectionTracker diff --git a/plugin/src/PluginContextStore/createPluginContextStore.luau b/plugin/src/PluginContextStore/createPluginContextStore.luau new file mode 100644 index 0000000..363cb14 --- /dev/null +++ b/plugin/src/PluginContextStore/createPluginContextStore.luau @@ -0,0 +1,102 @@ +--[[ + `PluginContextStore` aggregates contextual state about the user's + current Studio session that's relevant to building an activity but + is independent of the place being edited: + + - The currently-edited script + - Whether the user is currently play testing + + The store is intended to run in the **edit** data model only. + Guest plugin instances running in test data models use a separate + lightweight entry path (`plugin/bin/setupGuest.luau`). +]] + +local StudioService = game:GetService("StudioService") + +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Packages = Plugin.Packages +local Charm = require(Packages.Charm) + +local Types = require(script.Parent.Types) +local createConnectionTracker = require(script.Parent.createConnectionTracker) + +type ActiveScriptInfo = Types.ActiveScriptInfo +type StudioContext = Types.StudioContext +type TestPeer = Types.TestPeer + +export type PluginContextStore = { + getActiveScript: () -> ActiveScriptInfo?, + getIsPlayTesting: () -> boolean, + getTestPeers: () -> { [string]: TestPeer }, + getStudioContext: () -> StudioContext, + destroy: () -> (), +} + +local function snapshotActiveScript(script: Instance?): ActiveScriptInfo? + if script == nil then + return nil + end + + local fullName: string + local fullNameOk, fullNameResult = pcall(function() + return script:GetFullName() + end) + if fullNameOk and typeof(fullNameResult) == "string" then + fullName = fullNameResult + else + fullName = script.Name + end + + return { + name = script.Name, + fullName = fullName, + className = script.ClassName, + } +end + +local function createPluginContextStore(): PluginContextStore + -- Active-script tracking + + local getActiveScript, setActiveScript = Charm.signal(snapshotActiveScript(StudioService.ActiveScript)) + + local function refreshActiveScript() + setActiveScript(snapshotActiveScript(StudioService.ActiveScript)) + end + + local activeScriptConnection = StudioService:GetPropertyChangedSignal("ActiveScript"):Connect(refreshActiveScript) + + -- Test-DM peer tracking + + local tracker = createConnectionTracker() + + local getIsPlayTesting = Charm.computed(function(): boolean + return next(tracker.getPeers()) ~= nil + end) + + local getStudioContext = Charm.computed(function(): StudioContext + return { + isPlayTesting = next(tracker.getPeers()) ~= nil, + testPeers = tracker.getPeers(), + activeScript = getActiveScript(), + } + end) + + local function destroy() + if activeScriptConnection then + activeScriptConnection:Disconnect() + end + + tracker.destroy() + end + + return { + getActiveScript = getActiveScript, + getIsPlayTesting = getIsPlayTesting, + getTestPeers = tracker.getPeers, + getStudioContext = getStudioContext, + destroy = destroy, + } +end + +return createPluginContextStore diff --git a/plugin/src/PluginContextStore/init.luau b/plugin/src/PluginContextStore/init.luau new file mode 100644 index 0000000..fd3d326 --- /dev/null +++ b/plugin/src/PluginContextStore/init.luau @@ -0,0 +1,18 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local Types = require(script.Types) +local createPluginContextStore = require(script.createPluginContextStore) +local createSingleton = require(Plugin.Source.Common.createSingleton) + +export type ActiveScriptInfo = Types.ActiveScriptInfo +export type TestPeer = Types.TestPeer +export type TestPeerRole = Types.TestPeerRole +export type StudioContext = Types.StudioContext +export type GuestMessage = Types.GuestMessage +export type PluginContextStore = createPluginContextStore.PluginContextStore + +return table.freeze({ + get = createSingleton(function(): createPluginContextStore.PluginContextStore + return createPluginContextStore() + end), +}) diff --git a/plugin/src/PresenceManager/ProfileStore/BuildContext.luau b/plugin/src/PresenceManager/ProfileStore/BuildContext.luau new file mode 100644 index 0000000..0e014d3 --- /dev/null +++ b/plugin/src/PresenceManager/ProfileStore/BuildContext.luau @@ -0,0 +1,11 @@ +local Plugin = script:FindFirstAncestor("StudioActivity") + +local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) +local PluginContextStore = require(Plugin.Source.PluginContextStore) + +export type BuildContext = { + place: PlaceDetailsStore.PlaceContext, + studio: PluginContextStore.StudioContext, +} + +return {} diff --git a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau index b535aaf..b3b88bf 100644 --- a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau +++ b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau @@ -1,21 +1,22 @@ local Plugin = script:FindFirstAncestor("StudioActivity") +local BuildContext = require(script.Parent.BuildContext) 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) local interpolate = require(Plugin.Source.Common.interpolate) export type ActivityConfig = createPresenceManager.ActivityConfig type CustomProfileData = LocalStorageStore.CustomProfileData -type PlaceContext = PlaceDetailsStore.PlaceContext +type BuildContext = BuildContext.BuildContext local STUDIO_LOGO_ASSET = "studio-logo" local MAX_CUSTOM_BUTTONS = 2 -local function buildCustomActivity(context: PlaceContext, profile: CustomProfileData): ActivityConfig - local onlineDetails = context.onlinePlaceDetails - local offlineDetails = context.offlinePlaceDetails +local function buildCustomActivity(context: BuildContext, profile: CustomProfileData): ActivityConfig + local place = context.place + local onlineDetails = place.onlinePlaceDetails + local offlineDetails = place.offlinePlaceDetails local details = profile.activity.details local state = profile.activity.state diff --git a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau index 4a86637..14de2bf 100644 --- a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau +++ b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau @@ -8,7 +8,11 @@ local createPresenceManager = require(Plugin.Source.PresenceManager.createPresen type ActivityConfig = createPresenceManager.ActivityConfig local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) -type PlaceContext = PlaceDetailsStore.PlaceContext + +local PluginContextStore = require(Plugin.Source.PluginContextStore) + +local BuildContextModule = require(script.Parent.BuildContext) +type BuildContext = BuildContextModule.BuildContext local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore) local Logger = require(Plugin.Source.Logger) @@ -34,7 +38,7 @@ export type Profile = { icon: Foundation.IconName, isPreset: boolean, overridePlaceId: number?, - buildActivityAsync: (context: PlaceContext) -> ActivityConfig, + buildActivityAsync: (context: BuildContext) -> ActivityConfig, } export type ProfileStore = { @@ -58,7 +62,7 @@ local function wrapCustomProfile(data: CustomProfileData): Profile icon = IconName.DiamondSimplified :: Foundation.IconName, isPreset = false, overridePlaceId = data.activity.overridePlaceId, - buildActivityAsync = function(context: PlaceContext): ActivityConfig + buildActivityAsync = function(context: BuildContext): ActivityConfig return buildCustomActivity(context, data) end, } @@ -79,7 +83,7 @@ end Retry a profile build with exponential backoff. The build function can yield. Returns nil on persistent failure. ]] -local function buildWithRetry(profile: Profile, context: PlaceContext): ActivityConfig? +local function buildWithRetry(profile: Profile, context: BuildContext): ActivityConfig? local backoffConfig = RetryBackoff.defaultConfig() local backoffState = RetryBackoff.createState() @@ -110,6 +114,7 @@ end local function createProfileStore(): ProfileStore local localStorage = LocalStorageStore.get() local placeDetailsStore = PlaceDetailsStore.get() + local pluginContextStore = PluginContextStore.get() local getActiveProfileId = computed(function(): string return localStorage.getStorage().selectedProfileId or "default" @@ -197,21 +202,26 @@ local function createProfileStore(): ProfileStore return end - local context: PlaceContext? + local placeContext: PlaceDetailsStore.PlaceContext? if overridePlaceId and overridePlaceId > 0 then placeDetailsStore.prefetchPlaceIds({ overridePlaceId }) - context = placeDetailsStore.getContextByPlaceId(overridePlaceId) + placeContext = placeDetailsStore.getContextByPlaceId(overridePlaceId) else - context = placeDetailsStore.getCurrentContext() + placeContext = placeDetailsStore.getCurrentContext() end - if context == nil then + if placeContext == nil then setResolvedActivity(nil) return end + local buildContext: BuildContext = { + place = placeContext, + studio = pluginContextStore.getStudioContext(), + } + buildThread = task.spawn(function() - local activity = buildWithRetry(profile, context) + local activity = buildWithRetry(profile, buildContext) setResolvedActivity(activity) buildThread = nil end) diff --git a/plugin/src/PresenceManager/ProfileStore/presets.luau b/plugin/src/PresenceManager/ProfileStore/presets.luau index 4252f42..e8143c2 100644 --- a/plugin/src/PresenceManager/ProfileStore/presets.luau +++ b/plugin/src/PresenceManager/ProfileStore/presets.luau @@ -3,39 +3,53 @@ local Plugin = script:FindFirstAncestor("StudioActivity") local Packages = Plugin.Packages local Foundation = require(Packages.Foundation) +local BuildContext = require(script.Parent.BuildContext) 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 type ActivityConfig = createPresenceManager.ActivityConfig -type PlaceContext = PlaceDetailsStore.PlaceContext +type BuildContext = BuildContext.BuildContext local STUDIO_LOGO_ASSET = "studio-logo" export type PresetProfile = { name: string, icon: IconName, - buildActivityAsync: (context: PlaceContext) -> ActivityConfig, + buildActivityAsync: (context: BuildContext) -> ActivityConfig, } +local function defaultState(context: BuildContext): string + if context.studio.isPlayTesting then + return "Testing" + end + + local activeScript = context.studio.activeScript + if activeScript then + return `Editing {activeScript.name}` + end + + return "Editing in Studio" +end + local presets: { [string]: PresetProfile } = { default = { name = "Default", icon = IconName.PhotoCameraFace :: IconName, - buildActivityAsync = function(context: PlaceContext): ActivityConfig - local onlineDetails = if context.placeType == "online" then context.onlinePlaceDetails else nil - local offlineDetails = if context.placeType == "offline" then context.offlinePlaceDetails else nil + buildActivityAsync = function(context: BuildContext): ActivityConfig + local place = context.place + local onlineDetails = if place.placeType == "online" then place.onlinePlaceDetails else nil + local offlineDetails = if place.placeType == "offline" then place.offlinePlaceDetails else nil return { name = "Roblox Studio", - details = "Editing in Studio", - state = if onlineDetails + details = if onlineDetails then onlineDetails.placeName elseif offlineDetails then offlineDetails.placeName else nil, + state = defaultState(context), detailsUrl = Constants.PLUGIN_LINK_PROFILE_LINK, buttons = if onlineDetails then { @@ -59,7 +73,7 @@ local presets: { [string]: PresetProfile } = { confidential = { name = "Confidential", icon = IconName.Eyelashes :: IconName, - buildActivityAsync = function(_context: PlaceContext): ActivityConfig + buildActivityAsync = function(_context: BuildContext): ActivityConfig return { name = "Roblox Studio", details = "Editing in Studio", From f00703afb26aee26c75d56c9a8a975efb0de6644 Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Wed, 20 May 2026 17:11:29 -0400 Subject: [PATCH 2/6] hook up custom profiles and add a loading state for activity preview --- .../src/ActivityPreview/ActivityDetails.luau | 63 +++++++++++++------ plugin/src/ActivityPreview/ActivityImage.luau | 25 +++++--- .../src/ActivityPreview/ActivityPreview.luau | 13 ++-- plugin/src/ActivityPreview/ActivityTimer.luau | 22 ++++--- plugin/src/ActivityPreview/useTimerText.luau | 3 +- plugin/src/CustomProfile/PreviewBlock.luau | 3 +- plugin/src/CustomProfile/SettingsList.luau | 44 +++++++++++++ .../createCustomProfileStore.luau | 2 + plugin/src/MainScreen/PreviewBlock.luau | 2 +- plugin/src/Plugin/LocalStorageStore.luau | 4 ++ .../ProfileStore/buildCustomActivity.luau | 17 ++++- 11 files changed, 154 insertions(+), 44 deletions(-) diff --git a/plugin/src/ActivityPreview/ActivityDetails.luau b/plugin/src/ActivityPreview/ActivityDetails.luau index 166c779..1dc8364 100644 --- a/plugin/src/ActivityPreview/ActivityDetails.luau +++ b/plugin/src/ActivityPreview/ActivityDetails.luau @@ -9,40 +9,65 @@ local Types = require(script.Parent.Types) local View = Foundation.View local Text = Foundation.Text +local Skeleton = Foundation.Skeleton +local Radius = Foundation.Enums.Radius local createNextOrder = ReactUtils.createNextOrder local e = React.createElement type Props = { - activity: Types.Activity, + activity: Types.Activity?, LayoutOrder: number?, } local function ActivityDetails(props: Props) local nextOrder = createNextOrder() + local activity = props.activity return e(View, { - tag = "size-full-0 auto-y col gap-xxsmall", + tag = { + ["size-full-0 auto-y col"] = true, + ["gap-xxsmall"] = props.activity ~= nil, + ["gap-xsmall"] = props.activity == nil, + }, LayoutOrder = props.LayoutOrder, }, { - Name = e(Text, { - 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 = "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 = "size-full-0 auto-y text-align-x-left text-wrap text-body-small content-default", - Text = props.activity.state, - LayoutOrder = nextOrder(), - }), + Name = if activity + then e(Text, { + tag = "size-full-0 auto-y text-align-x-left text-wrap text-title-medium content-emphasis", + Text = activity.name, + LayoutOrder = nextOrder(), + }) + else e(Skeleton, { + radius = Radius.XSmall, + Size = UDim2.new(1, 0, 0, 14), + LayoutOrder = nextOrder(), + }), + + Details = if activity + then activity.details and e(Text, { + tag = "size-full-0 auto-y text-align-x-left text-wrap text-body-small content-default", + Text = activity.details, + LayoutOrder = nextOrder(), + }) + else e(Skeleton, { + radius = Radius.XSmall, + Size = UDim2.new(0.45, 0, 0, 12), + LayoutOrder = nextOrder(), + }), + + State = if activity + then activity.state and e(Text, { + tag = "size-full-0 auto-y text-align-x-left text-wrap text-body-small content-default", + Text = activity.state, + LayoutOrder = nextOrder(), + }) + else e(Skeleton, { + radius = Radius.XSmall, + Size = UDim2.new(0.6, 0, 0, 12), + LayoutOrder = nextOrder(), + }), }) end diff --git a/plugin/src/ActivityPreview/ActivityImage.luau b/plugin/src/ActivityPreview/ActivityImage.luau index 41ebb40..83f1090 100644 --- a/plugin/src/ActivityPreview/ActivityImage.luau +++ b/plugin/src/ActivityPreview/ActivityImage.luau @@ -11,8 +11,10 @@ local WebImage = require(Plugin.Source.Common.WebImage) local View = Foundation.View local Tooltip = Foundation.Tooltip +local Skeleton = Foundation.Skeleton local PopoverSide = Foundation.Enums.PopoverSide local PopoverAlign = Foundation.Enums.PopoverAlign +local Radius = Foundation.Enums.Radius local useTokens = Foundation.Hooks.useTokens local useSignalState = ReactCharm.useSignalState @@ -40,8 +42,8 @@ local function ActivityImage(props: Props) local largeImageUrl = if rawLargeImage then appAssets.resolveUrl(rawLargeImage) else nil local smallImageUrl = if rawSmallImage then appAssets.resolveUrl(rawSmallImage) else nil - local largeBlock = largeImageUrl - and e(View, { + local largeBlock = if largeImageUrl + then e(View, { tag = "size-full", }, { Image = e(WebImage, { @@ -49,11 +51,20 @@ local function ActivityImage(props: Props) size = "big", }), }) + else e(Skeleton, { + radius = Radius.Small, + Size = UDim2.fromScale(1, 1), + }) - local smallImageBlock = smallImageUrl and e(WebImage, { - url = smallImageUrl, - size = "small", - }) + local smallImageBlock = if smallImageUrl + then e(WebImage, { + url = smallImageUrl, + size = "small", + }) + else e(Skeleton, { + radius = Radius.Circle, + Size = UDim2.fromScale(1, 1), + }) return e(View, { tag = "size-1600-1600", @@ -67,7 +78,7 @@ local function ActivityImage(props: Props) }, largeBlock) else largeBlock, - SmallImageBlock = smallImageUrl and e(View, { + SmallImageBlock = e(View, { tag = "size-700-700 bg-surface-300 radius-circle", stroke = { Color = tokens.Color.Surface.Surface_300.Color3, diff --git a/plugin/src/ActivityPreview/ActivityPreview.luau b/plugin/src/ActivityPreview/ActivityPreview.luau index 1a22b68..4b0ce7b 100644 --- a/plugin/src/ActivityPreview/ActivityPreview.luau +++ b/plugin/src/ActivityPreview/ActivityPreview.luau @@ -20,12 +20,13 @@ local createNextOrder = ReactUtils.createNextOrder local e = React.createElement type Props = { - activity: Types.Activity, + activity: Types.Activity?, LayoutOrder: number?, } local function ActivityPreview(props: Props) local nextOrder = createNextOrder() + local activity = props.activity return e(View, { tag = "size-full-0 auto-y bg-surface-300 clip col padding-small gap-medium radius-small stroke-emphasis stroke-thick", @@ -50,7 +51,7 @@ local function ActivityPreview(props: Props) LayoutOrder = nextOrder(), }, { ActivityImage = e(ActivityImage, { - assets = props.activity.assets, + assets = if activity then activity.assets else nil, LayoutOrder = nextOrder(), }), @@ -59,19 +60,19 @@ local function ActivityPreview(props: Props) LayoutOrder = nextOrder(), }, { ActivityDetails = e(ActivityDetails, { - activity = props.activity, + activity = activity, LayoutOrder = nextOrder(), }), ActivityTimer = e(ActivityTimer, { - startedAt = props.activity.startedAt, + startedAt = if activity then activity.startedAt else nil, LayoutOrder = nextOrder(), }), }), }), - ActivityButtons = (props.activity.buttons and #props.activity.buttons > 0) and e(ActivityButtons, { - buttons = props.activity.buttons, + ActivityButtons = (activity and activity.buttons and #activity.buttons > 0) and e(ActivityButtons, { + buttons = activity.buttons, LayoutOrder = nextOrder(), }), }) diff --git a/plugin/src/ActivityPreview/ActivityTimer.luau b/plugin/src/ActivityPreview/ActivityTimer.luau index 28848dd..13b7ae9 100644 --- a/plugin/src/ActivityPreview/ActivityTimer.luau +++ b/plugin/src/ActivityPreview/ActivityTimer.luau @@ -10,9 +10,11 @@ local useTimerText = require(script.Parent.useTimerText) local View = Foundation.View local Text = Foundation.Text local Icon = Foundation.Icon +local Skeleton = Foundation.Skeleton local IconName = Foundation.Enums.IconName local IconVariant = Foundation.Enums.IconVariant local IconSize = Foundation.Enums.IconSize +local Radius = Foundation.Enums.Radius local useTokens = Foundation.Hooks.useTokens local createNextOrder = ReactUtils.createNextOrder @@ -29,7 +31,7 @@ local function ActivityTimer(props: Props) local nextOrder = createNextOrder() local tokens = useTokens() - local contentColor = tokens.Color.Extended.Green.Green_500 + local contentColor = if props.startedAt then tokens.Color.Extended.Green.Green_500 else tokens.Color.System.Neutral local startTime = useMemo(function() return props.startedAt or os.time() @@ -49,12 +51,18 @@ local function ActivityTimer(props: Props) LayoutOrder = nextOrder(), }), - TimerText = e(Text, { - tag = "auto-xy text-body-small", - textStyle = contentColor, - Text = timerText, - LayoutOrder = nextOrder(), - }), + TimerText = if props.startedAt + then e(Text, { + tag = "auto-xy text-body-small", + textStyle = contentColor, + Text = timerText, + LayoutOrder = nextOrder(), + }) + else e(Skeleton, { + radius = Radius.XSmall, + Size = UDim2.fromOffset(42, 12), + LayoutOrder = nextOrder(), + }), }) end diff --git a/plugin/src/ActivityPreview/useTimerText.luau b/plugin/src/ActivityPreview/useTimerText.luau index c178359..fb1e0b6 100644 --- a/plugin/src/ActivityPreview/useTimerText.luau +++ b/plugin/src/ActivityPreview/useTimerText.luau @@ -17,7 +17,7 @@ local function useTimerText(startTime: number): React.Binding useEffect(function() local isRunning = true - task.spawn(function() + local thread = task.spawn(function() while isRunning do local currentTime = os.time() local elapsedTime = currentTime - startTime @@ -28,6 +28,7 @@ local function useTimerText(startTime: number): React.Binding end) return function() + task.cancel(thread) isRunning = false end end, { startTime }) diff --git a/plugin/src/CustomProfile/PreviewBlock.luau b/plugin/src/CustomProfile/PreviewBlock.luau index 030d04f..ecdf0e7 100644 --- a/plugin/src/CustomProfile/PreviewBlock.luau +++ b/plugin/src/CustomProfile/PreviewBlock.luau @@ -38,6 +38,7 @@ local function PreviewBlock(props: Props) 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) @@ -62,7 +63,7 @@ local function PreviewBlock(props: Props) tag = "size-full-0 auto-y col align-x-center padding-bottom-small", LayoutOrder = props.LayoutOrder, }, { - PreviewContainer = currentActivity and e(View, { + PreviewContainer = e(View, { tag = "auto-y", Size = UDim2.fromScale(0.8, 0), sizeConstraint = { diff --git a/plugin/src/CustomProfile/SettingsList.luau b/plugin/src/CustomProfile/SettingsList.luau index 64fb654..e2f06be 100644 --- a/plugin/src/CustomProfile/SettingsList.luau +++ b/plugin/src/CustomProfile/SettingsList.luau @@ -42,6 +42,8 @@ local function SettingsList(props: Props) local showPlaceIcon = withDefault(currentProfile.activity.showPlaceIcon, true) local showJoinButton = withDefault(currentProfile.activity.showJoinButton, true) local showPluginButton = withDefault(currentProfile.activity.showPluginButton, false) + local showCurrentScript = withDefault(currentProfile.activity.showCurrentScript, true) + local showTestingState = withDefault(currentProfile.activity.showTestingState, true) return e(View, { tag = "size-full-0 auto-y", @@ -123,6 +125,48 @@ local function SettingsList(props: Props) LayoutOrder = nextOrder(), }), + ShowScriptToggle = e(List.Item, { + leading = IconName.ChainLink, + title = "Show current script", + description = "Show the script you're currently editing on your activity.", + onActivated = { + onActivated = function() + profileStore.setProfile(function(prev) + local current = withDefault(prev.activity.showCurrentScript, true) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showCurrentScript = not current, + }, + }) + end) + end, + inputType = ListItemInputType.Toggle, + isChecked = showCurrentScript, + }, + LayoutOrder = nextOrder(), + }), + + ShowTestingToggle = e(List.Item, { + leading = IconName.ChainLink, + title = "Show testing mode", + description = "Show if you're currently play testing your game.", + onActivated = { + onActivated = function() + profileStore.setProfile(function(prev) + local current = withDefault(prev.activity.showTestingState, true) + return Sift.Dictionary.mergeDeep(prev, { + activity = { + showTestingState = not current, + }, + }) + end) + end, + inputType = ListItemInputType.Toggle, + isChecked = showTestingState, + }, + LayoutOrder = nextOrder(), + }), + PluginButtonToggle = e(List.Item, { leading = IconName.Heart, title = "Show plugin button", diff --git a/plugin/src/CustomProfile/createCustomProfileStore.luau b/plugin/src/CustomProfile/createCustomProfileStore.luau index fe5b509..da0df5a 100644 --- a/plugin/src/CustomProfile/createCustomProfileStore.luau +++ b/plugin/src/CustomProfile/createCustomProfileStore.luau @@ -27,6 +27,8 @@ local DEFAULT_CUSTOM_PROFILE: CustomProfileData = { showPlaceIcon = true, showJoinButton = true, showPluginButton = false, + showCurrentScript = true, + showTestingState = true, }, } diff --git a/plugin/src/MainScreen/PreviewBlock.luau b/plugin/src/MainScreen/PreviewBlock.luau index 35457ce..5cde955 100644 --- a/plugin/src/MainScreen/PreviewBlock.luau +++ b/plugin/src/MainScreen/PreviewBlock.luau @@ -30,7 +30,7 @@ local function PreviewBlock(props: Props) tag = "size-full-0 auto-y col align-x-center", LayoutOrder = props.LayoutOrder, }, { - PreviewContainer = currentActivity and e(View, { + PreviewContainer = e(View, { tag = "auto-y", Size = UDim2.fromScale(0.8, 0), sizeConstraint = { diff --git a/plugin/src/Plugin/LocalStorageStore.luau b/plugin/src/Plugin/LocalStorageStore.luau index 105f118..cd1d133 100644 --- a/plugin/src/Plugin/LocalStorageStore.luau +++ b/plugin/src/Plugin/LocalStorageStore.luau @@ -25,9 +25,13 @@ export type CustomProfileButton = { type CustomProfileActivity = { details: string?, state: string?, + showPlaceIcon: boolean?, showJoinButton: boolean?, showPluginButton: boolean?, + showCurrentScript: boolean?, + showTestingState: boolean?, + customButtons: { CustomProfileButton }?, overridePlaceId: number?, } diff --git a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau index b3b88bf..06c2d10 100644 --- a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau +++ b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau @@ -13,13 +13,26 @@ type BuildContext = BuildContext.BuildContext local STUDIO_LOGO_ASSET = "studio-logo" local MAX_CUSTOM_BUTTONS = 2 +local function resolveState(context: BuildContext, profile: CustomProfileData, templates: { [string]: string }): string? + if context.studio.isPlayTesting and profile.activity.showTestingState then + return "Testing" + end + + local activeScript = context.studio.activeScript + if activeScript and profile.activity.showCurrentScript then + return `Editing {activeScript.name}` + end + + local state = profile.activity.state + return if state then interpolate(state, templates) else nil +end + local function buildCustomActivity(context: BuildContext, profile: CustomProfileData): ActivityConfig local place = context.place local onlineDetails = place.onlinePlaceDetails local offlineDetails = place.offlinePlaceDetails local details = profile.activity.details - local state = profile.activity.state local templates = { placeName = if onlineDetails @@ -55,7 +68,7 @@ local function buildCustomActivity(context: BuildContext, profile: CustomProfile local activity: ActivityConfig = { name = "Roblox Studio", details = if details then interpolate(details, templates) else nil, - state = if state then interpolate(state, templates) else nil, + state = resolveState(context, profile, templates), detailsUrl = Constants.PLUGIN_LINK_PROFILE_LINK, buttons = if #buttons > 0 then buttons else nil, assets = if profile.activity.showPlaceIcon and onlineDetails From b6dec8e85fc707cafe56a0a722bdcfc36ad6215a Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Wed, 20 May 2026 17:20:10 -0400 Subject: [PATCH 3/6] improve copy across custom profile ui --- .../CustomProfileGameOverride.luau | 2 +- .../CustomProfileGameOverride/OverridePreview.luau | 4 ++-- .../CustomProfileText/TemplateHint.luau | 5 ++--- plugin/src/CustomProfile/ProfileActions.luau | 2 +- plugin/src/CustomProfile/SettingsList.luau | 14 +++++++------- plugin/src/MainScreen/ProfileSelection.luau | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau b/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau index 70fa64d..68e9f85 100644 --- a/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau +++ b/plugin/src/CustomProfile/CustomProfileGameOverride/CustomProfileGameOverride.luau @@ -55,7 +55,7 @@ local function CustomProfileGameOverride(props: Props) }, { 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.", + hint = "Show a specific game in your activity. Leave blank to use the place you're editing.", value = override.placeId or 0, onChanged = function(placeId: number, reason: OnChangeCallbackReason) -- NumberInput re-fires onChanged with its `value` prop on focus loss. diff --git a/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau b/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau index cc83374..aa94358 100644 --- a/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau +++ b/plugin/src/CustomProfile/CustomProfileGameOverride/OverridePreview.luau @@ -88,9 +88,9 @@ local function OverridePreview(props: Props) 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" + then "Override active" elseif override.status == "idle" then "No override set" - else "We ran into an issue", + else "Couldn't load place", LayoutOrder = nextOrder(), }), diff --git a/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau b/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau index b1aa8b7..cce4436 100644 --- a/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau +++ b/plugin/src/CustomProfile/CustomProfileText/TemplateHint.luau @@ -19,8 +19,7 @@ type Props = { } local function TemplateHint(props: Props) - local description = - "Use the templating syntax to add dynamic data to your Discord activity. Available templates:\n\n" + local description = "Insert live data into your activity text using these variables:\n\n" for i, template in TEMPLATES do description ..= `• \{{template}\}` @@ -33,7 +32,7 @@ local function TemplateHint(props: Props) return e(FeedbackAlert, { severity = AlertSeverity.Info, - title = "Templating Syntax", + title = "Use variables", description = description, LayoutOrder = props.LayoutOrder, }, {}) diff --git a/plugin/src/CustomProfile/ProfileActions.luau b/plugin/src/CustomProfile/ProfileActions.luau index b4dcfc3..74e7e13 100644 --- a/plugin/src/CustomProfile/ProfileActions.luau +++ b/plugin/src/CustomProfile/ProfileActions.luau @@ -50,7 +50,7 @@ local function ProfileActions(props: Props) local accepted = ModalStore.get().openAsync("confirmation", { priority = 2, props = { - title = "Are you sure?", + title = "Delete this profile?", body = "This action cannot be undone.", }, }) diff --git a/plugin/src/CustomProfile/SettingsList.luau b/plugin/src/CustomProfile/SettingsList.luau index e2f06be..ebb7320 100644 --- a/plugin/src/CustomProfile/SettingsList.luau +++ b/plugin/src/CustomProfile/SettingsList.luau @@ -57,7 +57,7 @@ local function SettingsList(props: Props) ActivityText = e(List.Item, { leading = IconName.TextUppercaseALowercaseA, title = "Custom text", - description = "Edit the text shown on your activity.", + description = "Set the lines of text shown in your activity.", onActivated = function() ModalStore.get().open("customProfileText", { props = { @@ -72,7 +72,7 @@ local function SettingsList(props: Props) GameOverride = e(List.Item, { leading = IconName.ControllerWithCog, title = "Game override", - description = "Override the game displayed on your activity.", + description = "Show a specific game in your activity instead of the one you're editing.", onActivated = function() ModalStore.get().open("customProfileGameOverride", { props = { @@ -86,7 +86,7 @@ local function SettingsList(props: Props) GameIconToggle = e(List.Item, { leading = IconName.FrameCamera, title = "Show game icon", - description = "Show the platform icon of the place you're currently editing.", + description = "Display the icon of the game you're editing.", onActivated = { onActivated = function() profileStore.setProfile(function(prev) @@ -107,7 +107,7 @@ local function SettingsList(props: Props) 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.", + description = "Add a 'Play on Roblox' button that links to your game.", onActivated = { onActivated = function() profileStore.setProfile(function(prev) @@ -128,7 +128,7 @@ local function SettingsList(props: Props) ShowScriptToggle = e(List.Item, { leading = IconName.ChainLink, title = "Show current script", - description = "Show the script you're currently editing on your activity.", + description = "Display the name of the script you're editing.", onActivated = { onActivated = function() profileStore.setProfile(function(prev) @@ -149,7 +149,7 @@ local function SettingsList(props: Props) ShowTestingToggle = e(List.Item, { leading = IconName.ChainLink, title = "Show testing mode", - description = "Show if you're currently play testing your game.", + description = "Indicate when you're playtesting your game.", onActivated = { onActivated = function() profileStore.setProfile(function(prev) @@ -170,7 +170,7 @@ local function SettingsList(props: Props) PluginButtonToggle = e(List.Item, { leading = IconName.Heart, title = "Show plugin button", - description = "Support Studio Activity by linking it on your profile.", + description = "Add a button that links others to the Studio Activity plugin.", onActivated = { onActivated = function() profileStore.setProfile(function(prev) diff --git a/plugin/src/MainScreen/ProfileSelection.luau b/plugin/src/MainScreen/ProfileSelection.luau index 835a2b1..8b6bb8c 100644 --- a/plugin/src/MainScreen/ProfileSelection.luau +++ b/plugin/src/MainScreen/ProfileSelection.luau @@ -172,7 +172,7 @@ local function ProfileSelection(props: Props) EditBtn = if isPreset then e(Tooltip, { - title = "You can't edit built-in profiles", + title = "Built-in profiles can't be edited", align = PopoverAlign.End, side = PopoverSide.Top, LayoutOrder = nextOrder(), From f86832835fe82aa2c5c6340773de63c88e7b15e1 Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Thu, 21 May 2026 11:16:41 +0100 Subject: [PATCH 4/6] fix timer --- plugin/src/CustomProfile/PreviewBlock.luau | 8 ++++++-- .../ProfileStore/createProfileStore.luau | 7 +++++++ .../src/PresenceManager/createPresenceManager.luau | 12 +++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugin/src/CustomProfile/PreviewBlock.luau b/plugin/src/CustomProfile/PreviewBlock.luau index ecdf0e7..2ee6beb 100644 --- a/plugin/src/CustomProfile/PreviewBlock.luau +++ b/plugin/src/CustomProfile/PreviewBlock.luau @@ -11,6 +11,7 @@ local ActivityPreview = require(Plugin.Source.ActivityPreview) local BuildContext = require(Plugin.Source.PresenceManager.ProfileStore.BuildContext) local PlaceDetailsStore = require(Plugin.Source.PlaceDetailsStore) local PluginContextStore = require(Plugin.Source.PluginContextStore) +local PresenceManager = require(Plugin.Source.PresenceManager) local ProfileContext = require(script.Parent.ProfileContext) local buildCustomActivity = require(Plugin.Source.PresenceManager.ProfileStore.buildCustomActivity) @@ -34,6 +35,7 @@ local function PreviewBlock(props: Props) local profile = ProfileContext.useProfile() local placeDetailsStore = PlaceDetailsStore.get() local pluginContextStore = PluginContextStore.get() + local presenceManager = PresenceManager.get() local currentActivityComputed = useMemo(function() return Charm.computed(function(): ActivityConfig? @@ -53,9 +55,11 @@ local function PreviewBlock(props: Props) studio = pluginContextStore.getStudioContext(), } - return buildCustomActivity(buildContext, customProfile) + local activity = buildCustomActivity(buildContext, customProfile) + activity.startedAt = presenceManager.getSessionStartedAt() + return activity end) - end, { profile.profileStore :: any, placeDetailsStore, pluginContextStore }) + end, { profile.profileStore :: any, placeDetailsStore, pluginContextStore, presenceManager }) local currentActivity = useSignalState(currentActivityComputed) diff --git a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau index 14de2bf..334115d 100644 --- a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau +++ b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau @@ -4,6 +4,7 @@ local Packages = Plugin.Packages local Charm = require(Packages.Charm) local Foundation = require(Packages.Foundation) +local PresenceManager = require(Plugin.Source.PresenceManager) local createPresenceManager = require(Plugin.Source.PresenceManager.createPresenceManager) type ActivityConfig = createPresenceManager.ActivityConfig @@ -115,6 +116,7 @@ local function createProfileStore(): ProfileStore local localStorage = LocalStorageStore.get() local placeDetailsStore = PlaceDetailsStore.get() local pluginContextStore = PluginContextStore.get() + local presenceManager = PresenceManager.get() local getActiveProfileId = computed(function(): string return localStorage.getStorage().selectedProfileId or "default" @@ -220,8 +222,13 @@ local function createProfileStore(): ProfileStore studio = pluginContextStore.getStudioContext(), } + local startedAt = presenceManager.getSessionStartedAt() + buildThread = task.spawn(function() local activity = buildWithRetry(profile, buildContext) + if activity then + activity.startedAt = startedAt + end setResolvedActivity(activity) buildThread = nil end) diff --git a/plugin/src/PresenceManager/createPresenceManager.luau b/plugin/src/PresenceManager/createPresenceManager.luau index b5d25f9..fd9be95 100644 --- a/plugin/src/PresenceManager/createPresenceManager.luau +++ b/plugin/src/PresenceManager/createPresenceManager.luau @@ -50,6 +50,7 @@ export type ActivityConfig = { export type PresenceManager = { getActivity: () -> ActivityConfig?, + getSessionStartedAt: () -> number?, getIsActive: () -> boolean, getDisabledAccountIds: () -> { [string]: true }, @@ -148,7 +149,7 @@ local function createPresenceManager(discord: DiscordApi.DiscordApi): PresenceMa local sessionTokens: { [string]: string } = {} -- Tracks when sessions first became active, used as the Discord timestamp. - local sessionStartedAt: number? = os.time() + local getSessionStartedAt, setSessionStartedAt = signal(os.time() :: number?) -- Incremented on every sync to detect stale heartbeat writes. -- The heartbeat checks this before and after API calls to avoid writing @@ -346,11 +347,11 @@ local function createPresenceManager(discord: DiscordApi.DiscordApi): PresenceMa return end - if sessionStartedAt == nil then - sessionStartedAt = os.time() + if peek(getSessionStartedAt) == nil then + setSessionStartedAt(os.time()) end - local serialized = serializeActivity(activity, sessionStartedAt) + local serialized = serializeActivity(activity, peek(getSessionStartedAt)) local activeAccountIds: { [string]: true } = {} for id, account in accounts do @@ -498,7 +499,7 @@ local function createPresenceManager(discord: DiscordApi.DiscordApi): PresenceMa end local generationBefore = syncGeneration - local serialized = serializeActivity(activity, sessionStartedAt) + local serialized = serializeActivity(activity, peek(getSessionStartedAt)) local authAccounts = peek(auth.getStorage).accounts local disabledIds = peek(getDisabledAccountIds) @@ -569,6 +570,7 @@ local function createPresenceManager(discord: DiscordApi.DiscordApi): PresenceMa return { getActivity = getActivity, + getSessionStartedAt = getSessionStartedAt, getIsActive = getIsActive, getDisabledAccountIds = getDisabledAccountIds, From f945a61293048a165658cf5de8e8d8e8897e6e28 Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Thu, 21 May 2026 11:27:14 +0100 Subject: [PATCH 5/6] more copy updates --- plugin/src/CustomProfile/SettingsList.luau | 4 ++-- plugin/src/CustomProfile/createCustomProfileStore.luau | 4 ++-- .../src/PresenceManager/ProfileStore/buildCustomActivity.luau | 3 ++- plugin/src/PresenceManager/ProfileStore/presets.luau | 3 ++- plugin/src/PresenceManager/createPresenceManager.luau | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/plugin/src/CustomProfile/SettingsList.luau b/plugin/src/CustomProfile/SettingsList.luau index ebb7320..1c47353 100644 --- a/plugin/src/CustomProfile/SettingsList.luau +++ b/plugin/src/CustomProfile/SettingsList.luau @@ -126,7 +126,7 @@ local function SettingsList(props: Props) }), ShowScriptToggle = e(List.Item, { - leading = IconName.ChainLink, + leading = IconName.SquareCode, title = "Show current script", description = "Display the name of the script you're editing.", onActivated = { @@ -147,7 +147,7 @@ local function SettingsList(props: Props) }), ShowTestingToggle = e(List.Item, { - leading = IconName.ChainLink, + leading = IconName.PersonPlay, title = "Show testing mode", description = "Indicate when you're playtesting your game.", onActivated = { diff --git a/plugin/src/CustomProfile/createCustomProfileStore.luau b/plugin/src/CustomProfile/createCustomProfileStore.luau index da0df5a..1607a17 100644 --- a/plugin/src/CustomProfile/createCustomProfileStore.luau +++ b/plugin/src/CustomProfile/createCustomProfileStore.luau @@ -22,8 +22,8 @@ local LOOKUP_DEBOUNCE_SECONDS = 1 local DEFAULT_CUSTOM_PROFILE: CustomProfileData = { name = "My Profile", activity = { - details = "Building my game", - state = "{placeName}", + details = "{placeName}", + state = "Building my game", showPlaceIcon = true, showJoinButton = true, showPluginButton = false, diff --git a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau index 06c2d10..1b2507e 100644 --- a/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau +++ b/plugin/src/PresenceManager/ProfileStore/buildCustomActivity.luau @@ -15,7 +15,7 @@ local MAX_CUSTOM_BUTTONS = 2 local function resolveState(context: BuildContext, profile: CustomProfileData, templates: { [string]: string }): string? if context.studio.isPlayTesting and profile.activity.showTestingState then - return "Testing" + return "Playtesting" end local activeScript = context.studio.activeScript @@ -74,6 +74,7 @@ local function buildCustomActivity(context: BuildContext, profile: CustomProfile assets = if profile.activity.showPlaceIcon and onlineDetails then { largeImage = onlineDetails.iconWebUrl, + largeText = onlineDetails.placeName, smallImage = STUDIO_LOGO_ASSET, smallText = "Studio Activity", } diff --git a/plugin/src/PresenceManager/ProfileStore/presets.luau b/plugin/src/PresenceManager/ProfileStore/presets.luau index e8143c2..b868b5d 100644 --- a/plugin/src/PresenceManager/ProfileStore/presets.luau +++ b/plugin/src/PresenceManager/ProfileStore/presets.luau @@ -23,7 +23,7 @@ export type PresetProfile = { local function defaultState(context: BuildContext): string if context.studio.isPlayTesting then - return "Testing" + return "Playtesting" end local activeScript = context.studio.activeScript @@ -59,6 +59,7 @@ local presets: { [string]: PresetProfile } = { assets = if onlineDetails then { largeImage = onlineDetails.iconWebUrl, + largeText = onlineDetails.placeName, smallImage = STUDIO_LOGO_ASSET, smallText = "Studio Activity", } diff --git a/plugin/src/PresenceManager/createPresenceManager.luau b/plugin/src/PresenceManager/createPresenceManager.luau index fd9be95..86c584e 100644 --- a/plugin/src/PresenceManager/createPresenceManager.luau +++ b/plugin/src/PresenceManager/createPresenceManager.luau @@ -212,7 +212,7 @@ local function createPresenceManager(discord: DiscordApi.DiscordApi): PresenceMa -- On last attempt, don't wait — just return the error if attempt == MAX_RETRIES then Logger:Warn(`{context}: failed after {MAX_RETRIES} attempts, last error: {errorType}`) - NotificationStore.get().push("error", "Failed to update Discord presence") + NotificationStore.get().push("error", "Failed to update Discord activity") Telemetry.fireEvent({ type = "sessionError", value = Types.SessionError.new({ error = "max_retries" }), From a410da3f21a095b958b7452d843703c172d2c679 Mon Sep 17 00:00:00 2001 From: Brooke Rhodes Date: Thu, 21 May 2026 11:37:55 +0100 Subject: [PATCH 6/6] small tweaks --- plugin/src/ActivityPreview/ActivityImage.luau | 5 +++-- plugin/src/MainScreen/ProfileSelection.luau | 9 +++++++-- .../ProfileStore/createProfileStore.luau | 5 +++++ plugin/src/PresenceManager/ProfileStore/presets.luau | 11 +++++++---- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/plugin/src/ActivityPreview/ActivityImage.luau b/plugin/src/ActivityPreview/ActivityImage.luau index 83f1090..e524571 100644 --- a/plugin/src/ActivityPreview/ActivityImage.luau +++ b/plugin/src/ActivityPreview/ActivityImage.luau @@ -61,10 +61,11 @@ local function ActivityImage(props: Props) url = smallImageUrl, size = "small", }) - else e(Skeleton, { + elseif rawSmallImage then e(Skeleton, { radius = Radius.Circle, Size = UDim2.fromScale(1, 1), }) + else nil return e(View, { tag = "size-1600-1600", @@ -78,7 +79,7 @@ local function ActivityImage(props: Props) }, largeBlock) else largeBlock, - SmallImageBlock = e(View, { + SmallImageBlock = rawSmallImage and e(View, { tag = "size-700-700 bg-surface-300 radius-circle", stroke = { Color = tokens.Color.Surface.Surface_300.Color3, diff --git a/plugin/src/MainScreen/ProfileSelection.luau b/plugin/src/MainScreen/ProfileSelection.luau index 8b6bb8c..7181d0f 100644 --- a/plugin/src/MainScreen/ProfileSelection.luau +++ b/plugin/src/MainScreen/ProfileSelection.luau @@ -32,7 +32,7 @@ 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 } + type Record = { id: string, name: string, icon: IconName, sortOrder: number } local presetProfiles: { Record } = {} local customProfiles: { Record } = {} @@ -43,11 +43,16 @@ local profileDropdownItems = Charm.computed(function(): Foundation.DropdownItems id = id, icon = profile.icon, name = profile.name, + sortOrder = profile.sortOrder, }) end - -- Sort the list of profiles + -- Presets honor the order authored in presets.luau (Default before + -- Confidential, etc.), falling back to alphabetical for ties. table.sort(presetProfiles, function(a, b) + if a.sortOrder ~= b.sortOrder then + return a.sortOrder < b.sortOrder + end return a.name < b.name end) table.sort(customProfiles, function(a, b) diff --git a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau index 334115d..6979808 100644 --- a/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau +++ b/plugin/src/PresenceManager/ProfileStore/createProfileStore.luau @@ -38,6 +38,7 @@ export type Profile = { name: string, icon: Foundation.IconName, isPreset: boolean, + sortOrder: number, overridePlaceId: number?, buildActivityAsync: (context: BuildContext) -> ActivityConfig, } @@ -62,6 +63,9 @@ local function wrapCustomProfile(data: CustomProfileData): Profile name = data.name, icon = IconName.DiamondSimplified :: Foundation.IconName, isPreset = false, + -- Custom profiles are sorted alphabetically in a separate bucket, + -- so this value is unused; included only to satisfy the Profile type. + sortOrder = 0, overridePlaceId = data.activity.overridePlaceId, buildActivityAsync = function(context: BuildContext): ActivityConfig return buildCustomActivity(context, data) @@ -75,6 +79,7 @@ local function wrapPreset(preset: presets.PresetProfile): Profile name = preset.name, icon = preset.icon, isPreset = true, + sortOrder = preset.sortOrder, overridePlaceId = nil, buildActivityAsync = preset.buildActivityAsync, } diff --git a/plugin/src/PresenceManager/ProfileStore/presets.luau b/plugin/src/PresenceManager/ProfileStore/presets.luau index b868b5d..fcad3bb 100644 --- a/plugin/src/PresenceManager/ProfileStore/presets.luau +++ b/plugin/src/PresenceManager/ProfileStore/presets.luau @@ -18,6 +18,7 @@ local STUDIO_LOGO_ASSET = "studio-logo" export type PresetProfile = { name: string, icon: IconName, + sortOrder: number, buildActivityAsync: (context: BuildContext) -> ActivityConfig, } @@ -37,7 +38,8 @@ end local presets: { [string]: PresetProfile } = { default = { name = "Default", - icon = IconName.PhotoCameraFace :: IconName, + icon = IconName.Studio :: IconName, + sortOrder = 0, buildActivityAsync = function(context: BuildContext): ActivityConfig local place = context.place local onlineDetails = if place.placeType == "online" then place.onlinePlaceDetails else nil @@ -71,9 +73,10 @@ local presets: { [string]: PresetProfile } = { end, }, - confidential = { - name = "Confidential", - icon = IconName.Eyelashes :: IconName, + incognito = { + name = "Incognito", + icon = IconName.DisguiseNoseGlasses :: IconName, + sortOrder = 1, buildActivityAsync = function(_context: BuildContext): ActivityConfig return { name = "Roblox Studio",