import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/quickConnect.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("UserSelect")
m.log.verbose("Initializing UserSelect component")
m.top.isOptionsAvailable = false
' Overhang config — declarative field projected onto the overhang by JRScene (registerOverhangData).
m.top.isLogoVisible = true
m.top.findNode("selectUserLabel").text = translate(translationKeys.LabelSelectAUser)
m.top.findNode("manualLoginButton").text = translate(translationKeys.ButtonManualLogin)
m.buttons = m.top.findNode("buttons")
m.userRow = m.top.findNode("userRow")
' A picked user / the manual-login + quick-connect buttons emit intents the loginRouter
' coordinator handles.
m.userRow.observeField("userSelected", "onUserSelected")
m.buttons.observeField("buttonSelected", "onButtonSelected")
' Quick Connect is supported on every Jellyfin version JellyRock targets
' (>= 10.7.0). Endpoint and request-shape differences are handled inside
' sdk.quickConnect.* via versionChecker / apiVersion dispatch. The button is
' removed only when the server explicitly reports the feature disabled.
m.quickConnectButton = m.top.findNode("quickConnect")
m.quickConnectButton.text = translate(translationKeys.ButtonQuickConnect)
' Apply prior probe result immediately if already known disabled this session.
' Default of true (fail-open) means the button stays unless we have evidence.
' Otherwise the probe runs in onScreenShown (render-thread fetchAsync). On 10.7
' the /QuickConnect/Enabled endpoint is missing, so the probe fails open (button
' stays visible) - the dialog fallback handles the rare admin-disabled case there.
if m.global.server.isQuickConnectEnabled = false
removeQuickConnectButton("server reports Quick Connect disabled (cached)")
end if
' Quick Connect poll cadence. Re-armed by hand after each poll settles rather
' than repeating, so a slow response can never overlap the next request.
m.quickConnectTimer = m.top.findNode("quickConnectTimer")
m.quickConnectTimer.observeField("fire", "pollQuickConnect")
m.buttons.callFunc("center")
end sub
' Probe the server's /QuickConnect/Enabled endpoint and remove the QC button if
' the server reports it disabled. Render-thread promise (#551 Batch 1, 3b pattern)
' — replaces the former QuickConnectEnabledTask (createObject + observeFieldScoped
' on responseCode). Fail-open: only a definitive `false` removes the button; any
' non-ok HTTP response, transport failure, or timeout leaves it visible.
sub probeQuickConnectAvailability()
' Already removed this session (cached-disabled in init) → nothing to probe.
if not isValid(m.quickConnectButton) then return
req = GetApi().BuildGetQuickConnectEnabledRequest()
if not isValid(req)
m.log.warn("Quick Connect probe skipped - server URL not configured")
return
end if
m.log.info("Probing /QuickConnect/Enabled to gate the Quick Connect button", "url:", req.url)
promises.chain(fetchAsync(req, "quickConnectEnabled")).then(sub(res as object)
if not res.ok
' Server reachable but returned a non-2xx status (e.g. 10.7 has no endpoint).
' Fail-open: leave the button and the cached global field as-is.
m.log.warn("Quick Connect probe got non-OK response - failing open", "statusCode:", res.statusCode)
return
end if
' Endpoint returns a plain boolean body. Coerce defensively in case a future
' server returns an object envelope or wraps the value as a string.
enabled = res.json
enabledType = type(enabled)
if enabledType <> "Boolean" and enabledType <> "roBoolean"
m.log.warn("Quick Connect probe got unexpected body type", "type:", enabledType, "raw:", res.text)
enabled = (LCase(res.text.Trim()) = "true")
end if
m.global.server.isQuickConnectEnabled = enabled
m.log.info("Quick Connect availability probed", "enabled:", enabled, "statusCode:", res.statusCode)
if enabled = false
removeQuickConnectButton("server reports Quick Connect disabled")
end if
end sub).catch(sub(err as object)
' Transport failure / timeout — fail-open, leave the button visible.
m.log.warn("Quick Connect probe failed - failing open", err.reason)
end sub)
end sub
' Remove the Quick Connect button and re-center the remaining buttons.
' Safe to call repeatedly - removeChild is a no-op once the node is detached.
sub removeQuickConnectButton(reason as string)
if not isValid(m.quickConnectButton) then return
if isValid(m.quickConnectButton.getParent())
m.buttons.removeChild(m.quickConnectButton)
m.buttons.callFunc("center")
end if
m.quickConnectButton = invalid
m.log.info("Quick Connect button removed", reason)
end sub
sub onItemContentChanged()
stopLoadingSpinner()
m.top.findNode("UserRow").ItemContent = m.top.itemContent
redraw()
end sub
' A user tile was picked. Resolve its id from the rendered list (UserRow fires the
' username), publish both, and emit the userSelected intent (was CreateUserSelectGroup
' returning the username to LoginFlow's public-user branch).
sub onUserSelected(event as object)
name = event.getData()
if not isValidAndNotEmpty(name) then return
userId = ""
for each u in m.top.itemContent
if u.name = name
userId = u.id
exit for
end if
end for
m.top.selectedUserName = name
m.top.selectedUserId = userId
m.top.getScene().preLoginIntent = "userSelected"
end sub
' Manual-login / Quick Connect buttons → intents the loginRouter coordinator handles.
sub onButtonSelected(event as object)
buttonGroup = event.getRoSGNode()
btn = buttonGroup.getChild(event.getData())
if not isValid(btn) then return
if btn.id = "manualLoginButton"
m.top.getScene().preLoginIntent = "manualLogin"
else if btn.id = "quickConnect"
' NOT an intent: Quick Connect's first three steps are network calls with no
' navigation between them, so they belong on the render thread where
' fetchAsync works. The coordinator is handed the finished session at the
' end (quickConnectAuthenticated), which is the only step that needs it.
startQuickConnect()
end if
end sub
' ===========================================================================
' Quick Connect
'
' The whole render-thread half of the flow lives here, because all three of its
' requests are `fetchAsync` promises and the promise registry lives on the `m` of
' the component that called fetchAsync (see source/api/apiPromise.bs). Only the
' last step — turning an AuthenticationResult into a signed-in session — needs
' the main thread, and that is handed to the loginRouter coordinator as the
' `quickConnectAuthenticated` intent.
'
' press -> startQuickConnect POST /QuickConnect/Initiate
' wait -> pollQuickConnect GET /QuickConnect/Connect (every 3s)
' approve-> exchangeQuickConnect POST /Users/AuthenticateWithQuickConnect
' finish -> askToSaveCredentials -> intent -> loginRouter.user.Login()
'
' This replaces a QuickConnect Task NODE that was created fresh on every poll —
' roughly one task thread every three seconds for as long as the user took to
' walk to their phone, which is the fan-out shape source/api/CLAUDE.md forbids
' and the &h29 "too many task threads" class from #728. It also ran user.Login()
' on that task thread, which no other login path does.
' ===========================================================================
' Quick Connect pressed. Start a request and open the dialog once we have a code.
sub startQuickConnect()
req = GetApi().BuildInitiateQuickConnectRequest()
if not isValid(req)
' No server URL: nothing was asked, so nothing can be said about whether the
' feature is on. Not-reachable is the honest report.
m.log.warn("Quick Connect could not start - server URL not configured")
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
' A blocking spinner, briefly: the user pressed a button and nothing can happen
' until the server issues a code. This used to be a SYNCHRONOUS call on the
' main thread (loginRouter.onQuickConnectRequested), which froze the whole
' event loop — remote input included — for up to timeouts.HTTP_MS (10s) on an
' unreachable server.
startLoadingSpinner()
m.log.info("Starting Quick Connect", "method:", req.method)
promises.chain(fetchAsync(req, "quickConnectInitiate")).then(sub(res as object)
stopLoadingSpinner()
if not res.ok
' Only a 401 means the feature is off; a 10.11 server that is still
' starting answers 503, and telling that user Quick Connect is disabled
' would make them stop trying. See quickConnectInitiateFailure.
m.log.warn("Quick Connect initiate refused", "statusCode:", res.statusCode)
reportQuickConnectFailure(quickConnectInitiateFailure(res))
return
end if
session = quickConnectSession(res.json)
if not isValid(session)
' The server answered 200 with nothing usable in it. Not "disabled" — it
' said the feature is on and then failed to issue a code.
m.log.warn("Quick Connect initiate returned no usable secret/code")
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
openQuickConnectDialog(session)
end sub).catch(sub(err as object)
stopLoadingSpinner()
m.log.warn("Quick Connect initiate failed", err.reason)
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
end sub)
end sub
' Show the code and start polling for approval.
sub openQuickConnectDialog(session as object)
m.quickConnectSecret = session.secret
m.quickConnectFailures = 0
m.quickConnectPolls = 0
m.quickConnectDialog = showQuickConnectDialog(translate(translationKeys.ButtonQuickConnect), translate(translationKeys.MessageQuickConnectEnterCode), session.code, "onQuickConnectDialogResult")
m.quickConnectTimer.control = "start"
end sub
' Ask the server whether the code has been approved yet. Fires on the timer, so
' the request is never in flight twice.
sub pollQuickConnect()
if not isValidAndNotEmpty(m.quickConnectSecret) then return
req = GetApi().BuildConnectQuickConnectRequest(m.quickConnectSecret)
if not isValid(req)
endQuickConnect()
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
' A fresh requestId per poll. They are never concurrent — the timer is re-armed
' only after a poll settles — but reusing one across requests is the pool's
' documented foot-gun, and a numbered id says which poll a log line is about.
m.quickConnectPolls++
promises.chain(fetchAsync(req, "quickConnectPoll-" + stri(m.quickConnectPolls).trim())).then(sub(res as object)
handleQuickConnectPoll(quickConnectPollOutcome(res))
end sub).catch(sub(err as object)
m.log.warn("Quick Connect poll failed", err.reason)
handleQuickConnectPoll(QC_POLL_FAILED)
end sub)
end sub
' Act on one poll outcome. The classification itself is pure and unit-tested —
' see quickConnectPollOutcome in source/utils/quickConnect.bs for the measured
' server behaviour behind it.
sub handleQuickConnectPoll(outcome as string)
' Cancelled while this poll was in flight: the dialog is already gone.
if not isValidAndNotEmpty(m.quickConnectSecret) then return
if outcome = QC_POLL_APPROVED
m.log.info("Quick Connect approved on server - exchanging secret for a token")
exchangeQuickConnect()
return
end if
if outcome = QC_POLL_EXPIRED
' The one outcome the old implementation could not see. It arrives as a 404,
' which the sync JSON path turned into "not approved yet" - so an expired
' code polled forever behind a dialog the user had no reason to distrust.
m.log.info("Quick Connect code expired")
endQuickConnect()
reportQuickConnectFailure(QC_FAIL_SPENT)
return
end if
if outcome = QC_POLL_FAILED
m.quickConnectFailures++
if quickConnectShouldGiveUp(m.quickConnectFailures)
m.log.warn("Quick Connect gave up after consecutive failures", m.quickConnectFailures)
endQuickConnect()
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
else
' A clean "not yet" clears the tolerance — the cap is for a run of failures,
' not for a total across a session that is otherwise healthy.
m.quickConnectFailures = 0
end if
m.quickConnectTimer.control = "start"
end sub
' Approved: trade the secret for an access token.
sub exchangeQuickConnect()
secret = m.quickConnectSecret
' Stop polling and take the dialog down BEFORE the exchange: the code on it is
' spent, and abandonDialog delivers no result, which is right here — the user
' did not dismiss it, so onQuickConnectDialogResult must not run.
m.quickConnectTimer.control = "stop"
abandonQuickConnectDialog()
req = GetApi().BuildAuthenticateWithQuickConnectRequest(secret)
m.quickConnectSecret = ""
if not isValid(req)
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
startLoadingSpinner()
promises.chain(fetchAsync(req, "quickConnectAuthenticate")).then(sub(res as object)
stopLoadingSpinner()
if not res.ok or not isValid(res.json)
' Almost always the secret being spent — it is consumed on first use and
' it expires. The exception is a 503, which is the server starting rather
' than anything wrong with the code. See quickConnectExchangeFailure.
m.log.warn("Quick Connect token exchange refused", "statusCode:", res.statusCode)
reportQuickConnectFailure(quickConnectExchangeFailure(res))
return
end if
' A 200 that carries no usable session. Reported HERE because this is the last
' step still on a screen that can say anything: once the intent drains, the
' coordinator has no dialog and no view field to report through, and the user
' would answer the save-credentials question and then get nothing at all.
'
' UNAVAILABLE rather than SPENT — the same reading startQuickConnect gives an
' initiate that answers 200 with nothing usable in it. The server did not
' reject the code, it agreed and then failed to produce a session, so the
' honest advice is to try again rather than to go and fetch a new code.
if not quickConnectHasSession(res.json)
m.log.warn("Quick Connect token exchange returned no usable session")
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
return
end if
m.quickConnectAuthResult = res.json
askToSaveQuickConnectCredentials()
end sub).catch(sub(err as object)
stopLoadingSpinner()
m.log.warn("Quick Connect token exchange failed", err.reason)
reportQuickConnectFailure(QC_FAIL_UNAVAILABLE)
end sub)
end sub
' Signed in, but nothing is persisted yet. Ask the same question LoginScene asks
' with its "save credentials" checkbox, in the standard confirm dialog — the app
' is holding a live session either way, so a cancel is a legitimate "no", not a
' failure.
sub askToSaveQuickConnectCredentials()
m.saveCredentialsDialog = showConfirmDialog(translate(translationKeys.ButtonQuickConnect), translate(translationKeys.MessageQuickConnectAuthenticatedSaveCredentials), "onSaveQuickConnectCredentialsResult")
end sub
sub onSaveQuickConnectCredentialsResult()
dialog = m.saveCredentialsDialog
m.saveCredentialsDialog = invalid
auth = m.quickConnectAuthResult
m.quickConnectAuthResult = invalid
if not isValid(auth) then return
' Back (cancelled) means the same thing as No: sign in, persist nothing. The
' session is already live either way — this question is only about the registry.
m.top.saveCredentials = isValid(dialog) and isValid(dialog.result) and dialog.result.confirmed = true
m.top.quickConnectAuth = auth
m.log.info("Quick Connect complete", "saveCredentials:", m.top.saveCredentials)
m.top.getScene().preLoginIntent = "quickConnectAuthenticated"
end sub
' The user dismissed the code dialog. The only outcome it can produce on its own.
sub onQuickConnectDialogResult()
m.log.info("Quick Connect cancelled by the user")
m.quickConnectDialog = invalid
endQuickConnect()
end sub
' Stop polling and drop the dialog + session state. Safe to call repeatedly, and
' safe to call with no flow in progress.
'
' `returnFocusToPicker` is false for exactly one caller — onDestroy. See
' abandonQuickConnectDialog for why the two cases differ.
sub endQuickConnect(returnFocusToPicker = true as boolean)
if isValid(m.quickConnectTimer) then m.quickConnectTimer.control = "stop"
m.quickConnectSecret = ""
m.quickConnectFailures = 0
abandonQuickConnectDialog(returnFocusToPicker)
end sub
' Take the code dialog down WITHOUT delivering a result, and put focus back where
' it came from.
'
' abandonDialog, not cancelOpenDialog: we ARE the owner, and a result delivered
' back into this scope would re-enter the teardown we are in the middle of.
'
' The re-focus is the part that is easy to miss. abandonDialog() removes the node
' and restores nothing — correct for its other callers, which are all onDestroy,
' where the view is going away and there is no focus to hand back. This screen
' STAYS ON THE AIR, and what comes next (the failure alert, or the
' save-credentials confirm) DERIVES its returnFocusTo from whatever holds focus at
' that instant. Measured on device: after an abandon that derivation collapses to
' the scene root, so dismissing the next dialog would hand focus to nothing. See
' the abandonDialog characterization in tests/source/unit/utils/dialogs.spec.bs.
'
' Safe when no dialog is open: focus is already on the button row, so this is a
' no-op on the paths that fail before a dialog ever opens.
'
' `returnFocusToPicker` is FALSE from onDestroy, and that is not a nicety: this
' view is being replaced, so re-focusing our own button row there could take focus
' off the view that replaces us. A destroyed view has nothing to hand focus back
' to, which is the case abandonDialog's no-restore behaviour is built for.
sub abandonQuickConnectDialog(returnFocusToPicker = true as boolean)
abandonDialog(m.quickConnectDialog)
m.quickConnectDialog = invalid
if returnFocusToPicker and isValid(m.buttons) then m.buttons.setFocus(true)
end sub
' Report a Quick Connect failure in the standard alert dialog.
'
' The three reasons get three different messages because only one of them tells
' the user to stop: DISABLED means the server has the feature switched off, while
' UNAVAILABLE means try again shortly and SPENT means get a fresh code. The
' classification is pure and version-matrixed (source/utils/quickConnect.bs);
' this function only maps it to a string.
'
' Presented AFTER endQuickConnect has taken the code dialog down, never over it:
' presentOverlayDialog supersedes an incumbent overlay, so showing the alert first
' would cancel the code dialog and re-enter onQuickConnectDialogResult.
sub reportQuickConnectFailure(reason as string)
if reason = QC_FAIL_DISABLED
messageKey = translationKeys.LabelQuickConnectNotAvailable
else if reason = QC_FAIL_SPENT
messageKey = translationKeys.ErrorQuickConnectCodeExpired
else
messageKey = translationKeys.ErrorQuickConnectUnreachable
end if
showAlertDialog(translate(translationKeys.ButtonQuickConnect), translate(messageKey))
end sub
sub redraw()
userCount = m.top.itemContent.Count()
topBorder = 360
leftBorder = 130
itemWidth = 300
itemSpacing = 40
if userCount < 5
leftBorder = (1920 - ((userCount * itemWidth) + ((userCount - 1) * itemSpacing))) / 2
end if
' break()
m.top.findNode("UserRow").translation = [leftBorder, topBorder]
end sub
' JRScreen hook called when the screen is displayed by the screen manager
sub onScreenShown()
m.log.info("UserSelect screen shown")
' The loginRouter coordinator builds the public+saved user list (sync GetPublicUsers on the
' main thread) and passes it as route context. Render it once on mount.
if not isValidAndNotEmpty(m.top.itemContent)
route = m.top.route
if isValid(route) and isValid(route.context) and isValid(route.context.users)
m.top.itemContent = route.context.users
end if
end if
' Load splashscreen (handles race condition)
loadSplashscreen()
' Probe Quick Connect availability so the button can hide on disabled servers.
probeQuickConnectAvailability()
end sub
' JRScreen hook called when the screen is hidden by the screen manager
sub onScreenHidden()
m.log.info("UserSelect screen hidden - clearing backdrop")
' Clear backdrop using forceBackdrop to ensure it clears even before login
m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
end sub
' Load splashscreen if enabled on server
' Always fetches fresh branding config from server
sub loadSplashscreen()
m.log.info("Loading splashscreen - fetching branding config from server")
' Clear backdrop while waiting (forceBackdrop ensures it works before login)
m.global.sceneManager.callFunc("setBackgroundImage", "", false, true)
' Check if value is already cached (from previous session)
serverNode = m.global.server
if isValid(serverNode.isSplashscreenEnabled)
' Already cached, apply immediately for instant display
m.log.debug("Splashscreen setting already cached, applying immediately")
applySplashscreen(serverNode.isSplashscreenEnabled)
else
m.log.debug("Splashscreen setting not cached")
end if
' Always refresh in case the cached value is stale. Render-thread promise (#551
' Batch 1, 3b pattern) — replaces the former BrandingConfigTask (createObject +
' observeFieldScoped on responseCode) plus its companion observer on the global
' isSplashscreenEnabled field. The task also wrote that global session-cache
' field; we keep that write here and apply the result directly in .then().
req = GetApi().BuildGetBrandingConfigurationRequest()
promises.chain(fetchAsync(req, "brandingConfig")).then(sub(res as object)
serverNode = m.global.server
if res.ok and isValid(res.json) and isValid(res.json.SplashscreenEnabled)
serverNode.isSplashscreenEnabled = res.json.SplashscreenEnabled
m.log.info("Branding config cached successfully", "splashscreenEnabled:", res.json.SplashscreenEnabled)
else
' Any non-ok HTTP response (the error contract resolves these) or a missing
' SplashscreenEnabled field → default to disabled, matching the old task's
' failure path.
serverNode.isSplashscreenEnabled = false
m.log.warn("Branding config fetch returned no usable data - defaulting splashscreen to disabled")
end if
applySplashscreen(serverNode.isSplashscreenEnabled)
end sub).catch(sub(err as object)
' Transport failure / timeout — default to disabled, same as the old failure path.
m.log.warn("Branding config fetch failed", err.reason)
m.global.server.isSplashscreenEnabled = false
applySplashscreen(false)
end sub)
end sub
' Apply splash background based on enabled flag
' Uses forceBackdrop parameter to show splashscreen before user login
sub applySplashscreen(serverSplashEnabled as boolean)
' Get global splash screen setting (reads from registry or settings.json default)
' This works before user login since global settings are device-wide
globalSetting = getGlobalSplashScreenSetting()
' Resolve splash screen setting (JellyRock global override or server setting)
isSplashEnabled = resolveSplashScreen(globalSetting, serverSplashEnabled)
m.log.debug("Applying splashscreen", "globalSetting:", globalSetting, "serverSetting:", serverSplashEnabled, "resolvedValue:", isSplashEnabled)
if isSplashEnabled = true
' Build splash URL with correct parameters
splashUrl = buildURL("/Branding/Splashscreen", { "format": "jpg", "tag": "splash" })
' buildURL returns invalid when server URL is not configured
if not isValid(splashUrl)
m.log.warn("Cannot load splashscreen - server URL not configured")
m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
return
end if
m.log.info("Splashscreen enabled - loading", splashUrl)
' Set backdrop with animation, forceBackdrop=true bypasses user setting check
m.global.sceneManager.callFunc("setBackgroundImage", splashUrl, true, true)
else
m.log.info("Splashscreen disabled - using default background")
m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "back"
' Back → change server (coordinator deletes the server and navigates to /server).
m.top.getScene().preLoginIntent = "userBack"
return true
else if key = "up"
if m.top.focusedChild.isSubType("JRButtonGroup")
m.top.findNode("UserRow").setFocus(true)
return true
end if
else if key = "down"
if m.top.focusedChild.isSubType("UserRow")
m.buttons.setFocus(true)
return true
end if
end if
return false
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
' Note: onScreenHidden already cleared the backdrop; this handles task/ref cleanup.
sub onDestroy()
m.log.verbose("onDestroy")
' The branding-config + Quick Connect probes are now render-thread promises
' (fetchAsync); any in-flight request is cancelled by the abandonApiPromises()
' call the auto-abandon BSC plugin injects as the first statement of onDestroy.
' Any Quick Connect flow in progress: stop the poll timer and take the code
' dialog down. Overlay dialogs are appended to the SCENE, not to this view, so
' one left open here would strand a modal over whatever screen replaces us —
' with a scoped observer pointing into a torn-down scope.
'
' `false` = do NOT hand focus back to our button row. Every other caller wants
' that; this one must not have it, because the view replacing us is the thing
' that should end up focused.
endQuickConnect(false)
abandonDialog(m.saveCredentialsDialog)
m.saveCredentialsDialog = invalid
m.quickConnectAuthResult = invalid
' Release the selection + button observers.
if isValid(m.userRow) then m.userRow.unobserveField("userSelected")
if isValid(m.buttons) then m.buttons.unobserveField("buttonSelected")
if isValid(m.quickConnectTimer) then m.quickConnectTimer.unobserveField("fire")
' Clear node references
m.userRow = invalid
m.quickConnectButton = invalid
m.quickConnectTimer = invalid
m.buttons = invalid
end sub