diff --git a/assets/NotificationPop.mp3 b/assets/NotificationPop.mp3 new file mode 100644 index 00000000..5ecc1975 Binary files /dev/null and b/assets/NotificationPop.mp3 differ diff --git a/plugin/src/App/Notifications.lua b/plugin/src/App/Notifications.lua new file mode 100644 index 00000000..7da591cc --- /dev/null +++ b/plugin/src/App/Notifications.lua @@ -0,0 +1,198 @@ +local TextService = game:GetService("TextService") +local StudioService = game:GetService("StudioService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin + +local Roact = require(Rojo.Roact) +local Flipper = require(Rojo.Flipper) + +local bindingUtil = require(script.Parent.bindingUtil) + +local Theme = require(Plugin.App.Theme) +local Assets = require(Plugin.Assets) +local playSound = require(Plugin.playSound) + +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) + +local baseClock = DateTime.now().UnixTimestampMillis + +local e = Roact.createElement + +local Notification = Roact.Component:extend("Notification") + +function Notification:init() + self.motor = Flipper.SingleMotor.new(0) + self.binding = bindingUtil.fromMotor(self.motor) + + self.lifetime = self.props.timeout + + self.motor:onStep(function(value) + if value <= 0 then + if self.props.onClose then + self.props.onClose() + end + end + end) +end + +function Notification:dismiss() + self.motor:setGoal( + Flipper.Spring.new(0, { + frequency = 5, + dampingRatio = 1, + }) + ) +end + +function Notification:didMount() + self.motor:setGoal( + Flipper.Spring.new(1, { + frequency = 3, + dampingRatio = 1, + }) + ) + + playSound(Assets.Sounds.Notification) + + self.timeout = task.spawn(function() + local clock = os.clock() + local seen = false + while task.wait(1/10) do + local now = os.clock() + local dt = now - clock + clock = now + + if not seen then + seen = StudioService.ActiveScript == nil + end + + if not seen then + -- Don't run down timer before being viewed + continue + end + + self.lifetime -= dt + if self.lifetime <= 0 then + self:dismiss() + break + end + end + end) +end + +function Notification:willUnmount() + task.cancel(self.timeout) +end + +function Notification:render() + local time = DateTime.fromUnixTimestampMillis(self.props.timestamp) + + local textBounds = TextService:GetTextSize( + self.props.text, + 15, + Enum.Font.GothamSemibold, + Vector2.new(350, 700) + ) + + local transparency = self.binding:map(function(value) + return 1 - value + end) + + local size = self.binding:map(function(value) + return UDim2.fromOffset( + (35+40+textBounds.X)*value, + math.max(14+20+textBounds.Y, 32+20) + ) + end) + + return Theme.with(function(theme) + return e("TextButton", { + BackgroundTransparency = 1, + Size = size, + LayoutOrder = self.props.layoutOrder, + Text = "", + ClipsDescendants = true, + + [Roact.Event.Activated] = function() + self:dismiss() + end, + }, { + e(BorderedContainer, { + transparency = transparency, + size = UDim2.new(1, 0, 1, 0), + }, { + TextContainer = e("Frame", { + Size = UDim2.new(0, 35+textBounds.X, 1, -20), + Position = UDim2.new(0, 0, 0, 10), + BackgroundTransparency = 1 + }, { + Logo = e("ImageLabel", { + ImageTransparency = transparency, + Image = Assets.Images.PluginButton, + BackgroundTransparency = 1, + Size = UDim2.new(0, 32, 0, 32), + Position = UDim2.new(0, 0, 0.5, 0), + AnchorPoint = Vector2.new(0, 0.5), + }), + Info = e("TextLabel", { + Text = self.props.text, + Font = Enum.Font.GothamSemibold, + TextSize = 15, + TextColor3 = theme.Notification.InfoColor, + TextTransparency = transparency, + TextXAlignment = Enum.TextXAlignment.Left, + TextWrapped = true, + + Size = UDim2.new(0, textBounds.X, 0, textBounds.Y), + Position = UDim2.fromOffset(35, 0), + + LayoutOrder = 1, + BackgroundTransparency = 1, + }), + Time = e("TextLabel", { + Text = time:FormatLocalTime("LTS", "en-us"), + Font = Enum.Font.Code, + TextSize = 12, + TextColor3 = theme.Notification.InfoColor, + TextTransparency = transparency, + TextXAlignment = Enum.TextXAlignment.Left, + + Size = UDim2.new(1, -35, 0, 14), + Position = UDim2.new(0, 35, 1, -14), + + LayoutOrder = 1, + BackgroundTransparency = 1, + }), + }), + + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 17), + PaddingRight = UDim.new(0, 15), + }), + }) + }) + end) +end + +local Notifications = Roact.Component:extend("Notifications") + +function Notifications:render() + local notifs = {} + + for index, notif in ipairs(self.props.notifications) do + notifs[notif] = e(Notification, { + text = notif.text, + timestamp = notif.timestamp, + timeout = notif.timeout, + layoutOrder = (notif.timestamp - baseClock), + onClose = function() + self.props.onClose(index) + end, + }) + end + + return Roact.createFragment(notifs) +end + +return Notifications diff --git a/plugin/src/App/PluginSettings.lua b/plugin/src/App/PluginSettings.lua index 1c5b4e37..72a5e6c1 100644 --- a/plugin/src/App/PluginSettings.lua +++ b/plugin/src/App/PluginSettings.lua @@ -9,6 +9,7 @@ local Roact = require(Rojo.Roact) local defaultSettings = { openScriptsExternally = false, twoWaySync = false, + showNotifications = true, } local Settings = {} @@ -118,4 +119,4 @@ end return { StudioProvider = StudioProvider, with = with, -} \ No newline at end of file +} diff --git a/plugin/src/App/StatusPages/Settings.lua b/plugin/src/App/StatusPages/Settings.lua index f78efbff..54b1e252 100644 --- a/plugin/src/App/StatusPages/Settings.lua +++ b/plugin/src/App/StatusPages/Settings.lua @@ -202,12 +202,20 @@ function SettingsPage:render() layoutOrder = 1, }), + ShowNotifications = e(Setting, { + id = "showNotifications", + name = "Show Notifications", + description = "Popup notifications in viewport", + transparency = self.props.transparency, + layoutOrder = 2, + }), + TwoWaySync = e(Setting, { id = "twoWaySync", name = "Two-Way Sync", description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem", transparency = self.props.transparency, - layoutOrder = 2, + layoutOrder = 3, }), Layout = e("UIListLayout", { @@ -227,4 +235,4 @@ function SettingsPage:render() end) end -return SettingsPage \ No newline at end of file +return SettingsPage diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index 32578d3e..d27a2ecb 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -103,6 +103,10 @@ local lightTheme = strict("LightTheme", { LogoColor = BRAND_COLOR, VersionColor = hexColor(0x727272), }, + Notification = { + InfoColor = hexColor(0x00000), + CloseColor = BRAND_COLOR, + }, ErrorColor = hexColor(0x000000), ScrollBarColor = hexColor(0x000000), }) @@ -177,6 +181,10 @@ local darkTheme = strict("DarkTheme", { LogoColor = BRAND_COLOR, VersionColor = hexColor(0xD3D3D3) }, + Notification = { + InfoColor = hexColor(0xFFFFFF), + CloseColor = hexColor(0xFFFFFF), + }, ErrorColor = hexColor(0xFFFFFF), ScrollBarColor = hexColor(0xFFFFFF), }) diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index ed9b4443..1058954a 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -16,6 +16,7 @@ local Theme = require(script.Theme) local PluginSettings = require(script.PluginSettings) local Page = require(script.Page) +local Notifications = require(script.Notifications) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToggleButton = require(script.Components.Studio.StudioToggleButton) @@ -44,10 +45,37 @@ function App:init() self:setState({ appStatus = AppStatus.NotConnected, guiEnabled = false, + notifications = {}, toolbarIcon = Assets.Images.PluginButton, }) end +function App:addNotification(text: string, timeout: number?) + if not self.props.settings:get("showNotifications") then + return + end + + local notifications = table.clone(self.state.notifications) + table.insert(notifications, { + text = text, + timestamp = DateTime.now().UnixTimestampMillis, + timeout = timeout or 3, + }) + + self:setState({ + notifications = notifications, + }) +end + +function App:closeNotification(index: number) + local notifications = table.clone(self.state.notifications) + table.remove(notifications, index) + + self:setState({ + notifications = notifications, + }) +end + function App:getHostAndPort() local host = self.host:getValue() local port = self.port:getValue() @@ -81,6 +109,7 @@ function App:startSession() appStatus = AppStatus.Connecting, toolbarIcon = Assets.Images.PluginButton, }) + self:addNotification("Connecting to session...") elseif status == ServeSession.Status.Connected then local address = ("%s:%s"):format(host, port) self:setState({ @@ -89,8 +118,7 @@ function App:startSession() address = address, toolbarIcon = Assets.Images.PluginButtonConnected, }) - - Log.info("Connected to session '{}' at {}", details, address) + self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5) elseif status == ServeSession.Status.Disconnected then self.serveSession = nil @@ -104,13 +132,13 @@ function App:startSession() errorMessage = tostring(details), toolbarIcon = Assets.Images.PluginButtonWarning, }) + self:addNotification(tostring(details), 10) else self:setState({ appStatus = AppStatus.NotConnected, toolbarIcon = Assets.Images.PluginButton, }) - - Log.info("Disconnected session") + self:addNotification("Disconnected from session.") end end end) @@ -236,6 +264,21 @@ function App:render() end), }), + RojoNotifications = e("ScreenGui", {}, { + layout = e("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Bottom, + Padding = UDim.new(0, 5), + }), + notifs = e(Notifications, { + notifications = self.state.notifications, + onClose = function(index) + self:closeNotification(index) + end, + }), + }), + toggleAction = e(StudioPluginAction, { name = "RojoConnection", title = "Rojo: Connect/Disconnect", diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index 29e61d11..02fc2c7b 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -45,6 +45,9 @@ local Assets = { [500] = "rbxassetid://2609138523" }, }, + Sounds = { + Notification = "rbxassetid://9716079936", + }, StartSession = "", SessionActive = "", Configure = "", @@ -62,4 +65,4 @@ end guardForTypos("Assets", Assets) -return Assets \ No newline at end of file +return Assets diff --git a/plugin/src/init.server.lua b/plugin/src/init.server.lua index 79e2b51a..43c271c1 100644 --- a/plugin/src/init.server.lua +++ b/plugin/src/init.server.lua @@ -18,7 +18,7 @@ local App = require(script.App) local app = Roact.createElement(App, { plugin = plugin }) -local tree = Roact.mount(app, nil, "Rojo UI") +local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI") plugin.Unloading:Connect(function() Roact.unmount(tree) @@ -28,4 +28,4 @@ if Config.isDevBuild then local TestEZ = require(script.Parent.TestEZ) require(script.runTests)(TestEZ) -end \ No newline at end of file +end diff --git a/plugin/src/playSound.lua b/plugin/src/playSound.lua new file mode 100644 index 00000000..4e452313 --- /dev/null +++ b/plugin/src/playSound.lua @@ -0,0 +1,22 @@ +-- Roblox decided that sounds only play in Edit mode when parented to a plugin widget, for some reason +local plugin = plugin or script:FindFirstAncestorWhichIsA("Plugin") +local widget = plugin:CreateDockWidgetPluginGui("Rojo_soundPlayer", DockWidgetPluginGuiInfo.new( + Enum.InitialDockState.Float, + false, true, + 10, 10, + 10, 10 +)) +widget.Name = "Rojo_soundPlayer" +widget.Title = "Rojo Sound Player" + +return function(soundId) + local sound = Instance.new("Sound") + sound.SoundId = soundId + sound.Parent = widget + + sound.Ended:Connect(function() + sound:Destroy() + end) + + sound:Play() +end