This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,117 @@
local Modules = script.Parent.Parent
local Create = require(Modules.Create)
local Constants = require(Modules.Constants)
local Text = require(Modules.Text)
local ListEntry = require(Modules.Components.ListEntry)
local ICON_CELL_WIDTH = 60
local HEIGHT = 48
local LABEL_SPACING = 12
local ActionEntry = {}
function ActionEntry.new(appState, icon, localizationKey, size)
local self = {}
size = size or 24
setmetatable(self, {__index = ActionEntry})
local text = appState.localization:Format(localizationKey)
local listEntry = ListEntry.new(appState, HEIGHT)
self.rbx = listEntry.rbx
local iconFrame = Create.new"Frame" {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
Create.new"ImageLabel" {
BackgroundTransparency = 1,
Size = UDim2.new(0, size, 0, size),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Image = icon,
BorderSizePixel = 0,
},
}
iconFrame.Parent = self.rbx
local label = Create.new"TextLabel" {
Name = "Label",
BackgroundTransparency = 1,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = text,
TextXAlignment = Enum.TextXAlignment.Left,
}
label.Parent = self.rbx
local labelEffectiveWidth = Text.GetTextWidth(text, Enum.Font.SourceSans, Constants.Font.FONT_SIZE_18) + LABEL_SPACING
local value = Create.new"TextLabel" {
Name = "Value",
BackgroundTransparency = 1,
Size = UDim2.new(1, -ICON_CELL_WIDTH-12-labelEffectiveWidth, 1, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH+labelEffectiveWidth, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
Text = "",
TextXAlignment = Enum.TextXAlignment.Right,
ClipsDescendants = true,
}
value.Parent = self.rbx
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, -1),
}
divider.Parent = self.rbx
self.divider = divider
self.tapped = Instance.new("BindableEvent")
self.tapped.Parent = self.rbx
listEntry.tapped:connect(function()
self.tapped:fire()
end)
value:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:AdjustLayout()
end)
return self
end
function ActionEntry:SetDividerOffset(dividerOffset)
self.divider.Size = UDim2.new(1, -dividerOffset, 0, 1)
self.divider.Position = UDim2.new(0, dividerOffset, 1, -1)
end
function ActionEntry:AdjustLayout()
local text = self.rbx.Value.Text
local valueWidth = Text.GetTextWidth(text, Enum.Font.SourceSans, Constants.Font.FONT_SIZE_18)
if valueWidth > self.rbx.Value.AbsoluteSize.X then
self.rbx.Value.TextXAlignment = Enum.TextXAlignment.Right
else
self.rbx.Value.TextXAlignment = Enum.TextXAlignment.Left
end
end
function ActionEntry:Update(state)
self.rbx.Value.Text = state
self:AdjustLayout()
end
return ActionEntry
@@ -0,0 +1,589 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local GuiService = game:GetService("GuiService")
local TweenService = game:GetService("TweenService")
local HttpService = game:GetService("HttpService")
local UserInputService = game:GetService("UserInputService")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaChat.FlagSettings)
local GameParams = require(LuaChat.Models.GameParams)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local GetMultiplePlaceInfos = require(LuaChat.Actions.GetMultiplePlaceInfos)
local GetPlaceThumbnail = require(LuaChat.Actions.GetPlaceThumbnail)
local LoadingIndicator = require(LuaChat.Components.LoadingIndicator)
local NotificationType = require(LuaApp.Enum.NotificationType)
local PlayTogetherActions = require(LuaChat.Actions.PlayTogetherActions)
local Text = require(LuaChat.Text)
local FormFactor = require(LuaApp.Enum.FormFactor)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local UseCppTextTruncation = FlagSettings.UseCppTextTruncation()
local UrlSupportNewGamesAPI = settings():GetFFlag("UrlSupportNewGamesAPI")
local LuaChatAssetCardsSelfTerminateConnection = settings():GetFFlag("LuaChatAssetCardsSelfTerminateConnection")
local LuaChatFixAssetCardResizeTruncation = true or settings():GetFFlag("LuaChatFixAssetCardResizeTruncation")
local BUBBLE_PADDING = 10
local DEFAULT_THUMBNAIL = "rbxasset://textures/ui/LuaChat/icons/share-game-thumbnail.png"
local EXTERIOR_PADDING = 3
local GAME_CARD_BUTTON_CLICKED_EVENT = "clickBtnFromLinkCardInChat"
local GAME_MASK_IMAGE = "rbxasset://textures/ui/LuaChat/9-slice/gr-mask-game-icon.png"
local GAME_PLAY_INTENT = "gamePlayIntent"
local GAME_PLAY_EVENT_CONTEXT = "PlayGameFromLinkCard"
local ICON_SIZE = 64
local INTERIOR_PADDING = 12
local LINK_CARD_CLICKED_EVENT = "clickLinkCardInChat"
local PIN_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-pin.png"
local PIN_ICON_HORIZONTAL_PADDING = 10
local PIN_ICON_SIZE = 20
local PIN_ICON_VERTICAL_PADDING = 8
local PIN_PRESSED_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-pinpressed.png"
local PLACE_INFO_THUMBNAIL_SIZE = 50
local TOUCH_CONTEXT = "touch"
local ASSET_CARD_HORIZONTAL_MARGIN_PHONE = 108
local ASSET_CARD_HORIZONTAL_MARGIN_TABLET = 224
local function isOutgoingMessage(message)
local localUserId = tostring(Players.LocalPlayer.UserId)
return message.senderTargetId == localUserId
end
local FFlagLuaChatInfiniteRelayoutRecursionFix = settings():GetFFlag("LuaChatInfiniteRelayoutRecursionFix")
local AssetCard = {}
AssetCard.__index = AssetCard
function AssetCard.new(appState, message, assetId)
local self = {}
setmetatable(self, AssetCard)
local state = appState.store:getState()
local user = state.Users[message.senderTargetId]
local username = user and user.name or "unknown user"
self._analytics = appState.analytics
self.appState = appState
self.paddingObject = nil
self.message = message
self.bubbleType = "AssetCard"
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.cardBodyClick = nil
self.assetId = assetId
self.conversationId = message.conversationId
self.universeId = nil
self.pinButtonClick = nil
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
self.tail = Create.new "ImageLabel" {
Name = "Tail",
Size = UDim2.new(0, 6, 0, 6),
BackgroundTransparency = 1,
}
self.actionLabel = Create.new "TextLabel" {
Name = "ActionLabel",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0.5, 0.5),
Size = UDim2.new(0.8, 0, 0.8, 0),
Position = UDim2.new(0.5, 0, 0.5, 0),
TextSize = Constants.Font.FONT_SIZE_20,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = self.appState.localization:Format("Feature.Chat.Action.ViewAssetDetails"),
}
self.actionButton = Create.new "ImageButton" {
Name = "Action",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0.5, 1),
Position = UDim2.new(0.5, 0, 1, 0),
Size = UDim2.new(1, 0, 0, 32),
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3,3,4,4),
Image = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png",
self.actionLabel,
}
local textLabelWidth
if self.luaChatPlayTogetherEnabled then
textLabelWidth = -(PIN_ICON_SIZE + (2 * INTERIOR_PADDING) + BUBBLE_PADDING)
else
textLabelWidth = -(INTERIOR_PADDING + BUBBLE_PADDING)
end
self.Title = Create.new "TextLabel" {
Name = "Title",
TextTruncate = UseCppTextTruncation and Enum.TextTruncate.AtEnd or nil,
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0, 0),
TextSize = Constants.Font.FONT_SIZE_20,
Size = UDim2.new(1, textLabelWidth, 0, 20),
Position = UDim2.new(0, 0, 0, 0),
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Left
}
self.Icon = Create.new "ImageLabel" {
Name = "Icon",
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 0, self.Title.TextSize + INTERIOR_PADDING),
Size = UDim2.new(0, ICON_SIZE, 0, ICON_SIZE),
Create.new "ImageLabel" {
Name = "Mask",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = GAME_MASK_IMAGE,
ImageColor3 = Constants.Color.WHITE,
ScaleType = Enum.ScaleType.Slice,
Size = UDim2.new(1, 0, 1, 0),
SliceCenter = Rect.new(3,3,4,4),
}
}
self.Details = Create.new "TextLabel" {
Name = "Details",
BackgroundTransparency = 1,
Size = UDim2.new(1, - INTERIOR_PADDING -ICON_SIZE, 0, ICON_SIZE),
Position = self.Icon.Position + UDim2.new(0 , INTERIOR_PADDING + ICON_SIZE, 0, 0),
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_14,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextWrapped = true,
}
self.fadeScreen = Create.new "Frame" {
Name = "FadeScreen",
BackgroundTransparency = 0,
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Color3.new(1, 1, 1),
BorderSizePixel = 0,
}
self.Content = Create.new "ImageButton" {
Name = "Content",
BackgroundTransparency = 1,
Size = UDim2.new(1, -BUBBLE_PADDING * 2, 1, -BUBBLE_PADDING * 2),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Visible = false,
self.actionButton,
self.Title,
self.Icon,
self.Details,
self.fadeScreen,
}
if self.luaChatPlayTogetherEnabled then
self.PinIcon = Create.new "ImageLabel" {
Name = "PinIcon",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Size = UDim2.new(0, PIN_ICON_SIZE, 0, PIN_ICON_SIZE),
Image = PIN_ICON,
}
self.PinButton = Create.new "TextButton" {
Name = "PinButton",
Text = "",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.new(1, BUBBLE_PADDING, 0, -BUBBLE_PADDING),
Size = UDim2.new(0, PIN_ICON_SIZE + 2 * PIN_ICON_HORIZONTAL_PADDING,
0, PIN_ICON_SIZE + 2 * PIN_ICON_VERTICAL_PADDING),
self.PinIcon
}
self.PinButton.Parent = self.Content
end
self.bubble = Create.new "ImageLabel" {
Name = "Bubble",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.new(0, 0, 0, 0),
Size = UDim2.new(0, 267, 1, 0),
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(10, 10, 11, 11),
LayoutOrder = 2,
self.Content,
self.tail,
}
self.usernameLabel = Create.new "TextLabel" {
Name = "UsernameLabel",
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_12,
Visible = false,
BackgroundTransparency = 1,
Size = UDim2.new(1, -56, 0, 16),
Position = UDim2.new(0, 56, 0, 0),
TextColor3 = Constants.Color.GRAY2,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
Text = username,
}
self.bubbleContainer = Create.new "Frame" {
Name = "BubbleContainer",
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0),
self.bubble,
self.usernameLabel,
}
self.layout = Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}
self.rbx = Create.new "Frame" {
Name = "AssetCard",
BackgroundTransparency = 1,
self.layout,
self.bubbleContainer,
}
-- 'isOutgoing' means "is sent by the local user". This function separates the tail position & color
if isOutgoingMessage(message) then
self.tail.AnchorPoint = Vector2.new(0, 0)
self.tail.Position = UDim2.new(1, 0, 0, 0)
self.tail.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.AnchorPoint = Vector2.new(1, 0)
self.bubble.Position = UDim2.new(1, -10, 0, 0)
self.rbx.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
else
self.tail.AnchorPoint = Vector2.new(1, 0)
self.tail.Position = UDim2.new(0, 0, 0, 0)
self.tail.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.AnchorPoint = Vector2.new(0, 0)
self.bubble.Position = UDim2.new(0, 54, 0, 0)
self.rbx.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
end
self.appStateConnection = self.appState.store.changed:connect(function(state)
self:Update(state)
end)
table.insert(self.connections, self.appStateConnection)
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
local connection = self.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, connection)
else
table.insert(self.connections, connection)
end
end
self:Update(state)
if FFlagLuaChatInfiniteRelayoutRecursionFix then
self:Resize()
end
return self
end
function AssetCard:_truncateText()
if LuaChatFixAssetCardResizeTruncation then
if not UseCppTextTruncation then
if self.placeInfo ~= nil then
self.Title.Text = self.placeInfo.name
end
Text.TruncateTextLabel(self.Title, "...")
end
else
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.Title, "...")
end
end
end
function AssetCard:Resize()
local formFactor = self.appState.store:getState().FormFactor
local bubbleSizeOffsetY
if FFlagLuaChatInfiniteRelayoutRecursionFix then
bubbleSizeOffsetY = formFactor == FormFactor.PHONE and ASSET_CARD_HORIZONTAL_MARGIN_PHONE
or ASSET_CARD_HORIZONTAL_MARGIN_TABLET
else
bubbleSizeOffsetY = Constants:GetFormFactorSpecific(formFactor).ASSET_CARD_HORIZONTAL_MARGIN
end
local bubbleHeight = 92 + ICON_SIZE
self.bubble.Size = UDim2.new(1, -bubbleSizeOffsetY, 0, bubbleHeight)
local containerHeight = bubbleHeight
if self.usernameLabel.Visible then
containerHeight = containerHeight + self.usernameLabel.AbsoluteSize.Y
end
self.bubbleContainer.Size = UDim2.new(1, 0, 0, containerHeight)
local height = 0
for _, child in ipairs(self.rbx:GetChildren()) do
if child:IsA("GuiObject") then
height = height + child.AbsoluteSize.Y
end
end
height = height + EXTERIOR_PADDING * 2
if FFlagLuaChatInfiniteRelayoutRecursionFix then
-- FFlagLuaChatInfiniteRelayoutRecursionFix aims to seperate resize
-- and truncation logic so they may be called independantly
self.rbx.Size = UDim2.new(1, 0, 0, height)
self:_truncateText()
else
if LuaChatFixAssetCardResizeTruncation then
-- resize the frame before we truncate
self.rbx.Size = UDim2.new(1, 0, 0, height)
if not UseCppTextTruncation then
-- set the title text first in case the text is already truncated
-- from a Resize() call when the frame was a smaller width
if self.placeInfo ~= nil then
self.Title.Text = self.placeInfo.name
end
Text.TruncateTextLabel(self.Title, "...")
end
else
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.Title, "...")
end
self.rbx.Size = UDim2.new(1, 0, 0, height)
end
end
end
function AssetCard:onPinPressed()
self.PinIcon.Image = PIN_PRESSED_ICON
self.userInputServiceCon = UserInputService.InputEnded:Connect(function()
self:onPinRelease()
end)
end
function AssetCard:onPinRelease()
if self.userInputServiceCon then
self.userInputServiceCon:Disconnect()
self.userInputServiceCon = nil
end
self.PinIcon.Image = PIN_ICON
end
function AssetCard:Update(newState)
local placeInfo = newState.ChatAppReducer.PlaceInfos[self.assetId]
if placeInfo == nil then
self:ShowLoadingIndicator(true)
self.appState.store:dispatch(GetMultiplePlaceInfos({self.assetId}))
else
self.placeInfo = placeInfo
self.Title.Text = placeInfo.name
if FFlagLuaChatInfiniteRelayoutRecursionFix then
self:_truncateText()
end
local description = placeInfo.description:gsub("%s", " ")
if description:gsub("^%s+$", "") == "" then
description = self.appState.localization:Format("Feature.Chat.Label.NoDescriptionYet")
end
self.Details.Text = description
self.universeId = placeInfo.universeId
if self.luaChatPlayTogetherEnabled then
if self.pinButtonClick then self.pinButtonClick:Disconnect() end
if self.pinButtonInputBegin then self.pinButtonInputBegin:Disconnect() end
self.pinButtonClick = self.PinButton.Activated:Connect(function()
self.appState.store:dispatch(PlayTogetherActions.PinGame(self.conversationId, self.universeId))
end)
self.pinButtonInputBegin = self.PinButton.InputBegan:Connect(function()
self:onPinPressed()
end)
end
if UrlSupportNewGamesAPI then
local thumbnail = newState.ChatAppReducer.PlaceThumbnails[placeInfo.imageToken]
if thumbnail == nil then
self.appState.store:dispatch(GetPlaceThumbnail(
placeInfo.imageToken, PLACE_INFO_THUMBNAIL_SIZE, PLACE_INFO_THUMBNAIL_SIZE
))
else
if thumbnail.image == '' then
self.thumbnail = DEFAULT_THUMBNAIL
else
self.thumbnail = thumbnail.image
end
self:FillThumbnail()
self:Show()
end
else
self.thumbnail = DEFAULT_THUMBNAIL
self:Show()
end
end
if self.cardBodyClick then self.cardBodyClick:Disconnect() end
if self.detailsButtonClick then self.detailsButtonClick:Disconnect() end
self:StyleViewDetailsAsPlay(self.placeInfo ~= nil and self.placeInfo.isPlayable)
self.cardBodyClick = getInputEvent(self.Content):Connect(function()
self:ReportAnEvent(LINK_CARD_CLICKED_EVENT, TOUCH_CONTEXT)
if self.placeInfo then
GuiService:BroadcastNotification(self.assetId,
NotificationType.VIEW_GAME_DETAILS)
end
end)
self.detailsButtonClick = getInputEvent(self.actionButton):Connect(function()
self:ReportAnEvent(GAME_CARD_BUTTON_CLICKED_EVENT, TOUCH_CONTEXT)
if self.placeInfo then
if self.placeInfo.isPlayable then
self:ReportAnEvent(GAME_PLAY_INTENT, GAME_PLAY_EVENT_CONTEXT)
local gameParams = GameParams.fromPlaceId(self.assetId)
local payload = HttpService:JSONEncode(gameParams)
GuiService:BroadcastNotification(payload,
NotificationType.LAUNCH_GAME)
else
GuiService:BroadcastNotification(self.assetId,
NotificationType.VIEW_GAME_DETAILS)
end
end
end)
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
self:Resize()
end
end
function AssetCard:ReportAnEvent(eventName, eventContext)
local additionalArgs
if eventName == GAME_PLAY_INTENT then
additionalArgs = {
conversationId = self.conversationId,
rootPlaceId = self.assetId
}
else
additionalArgs = {
conversationId = self.conversationId,
placeId = self.assetId
}
end
self._analytics.EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
end
function AssetCard:StyleViewDetailsAsPlay(isShowingAsPlay)
if isShowingAsPlay then
self.actionButton.ImageColor3 = Constants.Color.GREEN_PRIMARY
self.actionLabel.Text = self.appState.localization:Format("Common.VisitGame.Label.Play")
self.actionLabel.TextColor3 = Constants.Color.WHITE
else
self.actionButton.ImageColor3 = Constants.Color.WHITE
self.actionLabel.Text = self.appState.localization:Format("Feature.Chat.Action.ViewAssetDetails")
self.actionLabel.TextColor3 = Constants.Color.GRAY1
end
end
function AssetCard:Show()
self.Content.Visible = true
spawn(function()
while (not self.Icon.IsLoaded) do wait() end
self:ShowLoadingIndicator(false)
local fadeInTween = TweenService:Create(
self.fadeScreen,
TweenInfo.new(0.4),
{BackgroundTransparency = 1}
)
fadeInTween:Play()
end)
if LuaChatAssetCardsSelfTerminateConnection then
if self.appStateConnection then
self.appStateConnection:disconnect()
end
end
end
function AssetCard:FillThumbnail()
self.Icon.Image = self.thumbnail or ""
end
function AssetCard:ShowLoadingIndicator(isVisible)
if isVisible then
if not self.loadingIndicator then
local loadingIndicator = LoadingIndicator.new(self.appState)
loadingIndicator.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
loadingIndicator.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
loadingIndicator.rbx.Size = UDim2.new(0.5, 0, 0.25, 0)
loadingIndicator.rbx.Parent = self.bubble
loadingIndicator:SetVisible(true)
self.loadingIndicator = loadingIndicator
end
else
if self.loadingIndicator then
self.loadingIndicator:Destroy()
end
end
end
if not LuaChatAssetCardsSelfTerminateConnection then
function AssetCard:DisconnectUpdate()
if self.Content.Visible then
if self.appStateConnection then
self.appStateConnection:disconnect()
self.appStateConnection = nil
end
end
end
end
function AssetCard:Destruct()
for _, connection in pairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in pairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
if self.pinButtonClick then self.pinButtonClick:Disconnect() end
if self.pinButtonInputBegin then self.pinButtonInputBegin:Disconnect() end
self.rbx:Destroy()
end
return AssetCard
@@ -0,0 +1,373 @@
local PlayerService = game:GetService("Players")
local CoreGui = game:GetService("CoreGui")
local TweenService = game:GetService("TweenService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Components = LuaChat.Components
local ChatGameDrawer = require(Components.ChatGameDrawer)
local Constants = require(LuaChat.Constants)
local DialogInfo = require(LuaChat.DialogInfo)
local PaddedImageButton = require(LuaChat.Components.PaddedImageButton)
local Roact = require(CoreGui.RobloxGui.Modules.Common.Roact)
local RoactAnalytics = require(LuaApp.Services.RoactAnalytics)
local RoactLocalization = require(LuaApp.Services.RoactLocalization)
local RoactRodux = require(CoreGui.RobloxGui.Modules.Common.RoactRodux)
local RoactServices = require(LuaApp.RoactServices)
local Text = require(Common.Text)
local FFlagLuaChatLoadGameLinkCardInChatAnalytics = settings():GetFFlag("LuaChatLoadGameLinkCardInChatAnalytics")
local FFlagGroupChatIconEnabled = settings():GetFFlag("LuaChatGroupChatIconEnabled")
local FFlagLuaChatFlexibleTitleWidth = settings():GetFFlag("LuaChatFlexibleTitleWidth")
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local PLATFORM_SPECIFIC_CONSTANTS = {
[Enum.Platform.Android] = {
BACK_BUTTON_ASSET_ID = "rbxasset://textures/ui/LuaChat/icons/ic-back-android.png",
},
Default = {
BACK_BUTTON_ASSET_ID = "rbxasset://textures/ui/LuaChat/icons/ic-back.png",
},
}
local TITLE_LABEL_MAX_WIDTH = 200
local TITLE_LABEL_HEIGHT = 25
local function getPlatformSpecific(platform)
return PLATFORM_SPECIFIC_CONSTANTS[platform] or PLATFORM_SPECIFIC_CONSTANTS.Default
end
local function createRoactInstanceGameDrawer(self)
local newGameDrawer = Roact.createElement(ChatGameDrawer, {
conversationId = self.conversationId,
isGameDrawerOpen = self.isGameDrawerOpen,
Localization = self.appState.localization,
onSize = function(newSize, forceOpen)
self:SetGameDrawerSize(newSize, forceOpen)
end,
})
return Roact.createElement(RoactRodux.StoreProvider, {
store = self.appState.store,
}, {
serviceProvider = Roact.createElement(RoactServices.ServiceProvider, {
services = {
[RoactAnalytics] = self.appState.analytics,
[RoactLocalization] = self.appState.localization,
}
}, {
newGameDrawer
})
})
end
local BaseHeader = {}
--[[
This type of pseudo-inheritance for components is usually bad, but this is a special exception.
It is not recommended to do this.
]]
function BaseHeader:Template()
local class = {}
for key, value in pairs(self) do
class[key] = value
end
return class
end
function BaseHeader:SetPlatform(platform)
self.platform = platform
end
function BaseHeader:SetTitle(text)
local label = self.titleLabel
if label then
local labelFont = label.Font
local labelTextSize = label.TextSize
local maxTitleLabelWidth = self.innerTitles.AbsoluteSize.X
if FFlagGroupChatIconEnabled and self.groupChatIcon then
maxTitleLabelWidth = maxTitleLabelWidth
- (self.groupChatIcon.Visible and self.groupChatIcon.AbsoluteSize.X or 0)
end
if FFlagLuaChatFlexibleTitleWidth then
label.Text = Text.Truncate(text, labelFont, labelTextSize, maxTitleLabelWidth, "...")
else
label.Text = Text.Truncate(text, labelFont, labelTextSize, TITLE_LABEL_MAX_WIDTH, "...")
end
local titleTextLength = Text.GetTextWidth(label.Text, labelFont, labelTextSize)
self.titleLabel.Size = UDim2.new(0, titleTextLength, 0, TITLE_LABEL_HEIGHT)
end
self.title = text
end
function BaseHeader:SetDefaultSubtitle()
if (self.dialogType ~= DialogInfo.DialogType.Centered) and (self.dialogType ~= DialogInfo.DialogType.Left) then
self:SetSubtitle("")
return
end
local displayText = ""
local player = PlayerService.LocalPlayer
if player then
local userId = tostring(player.UserId)
local localUser = self.appState.store:getState().Users[userId]
if localUser and not localUser.isFetching then
if player:GetUnder13() then
displayText = string.format("%s: <13", localUser.name)
else
displayText = string.format("%s: 13+", localUser.name)
end
end
end
self:SetSubtitle(displayText)
end
--[[
Sets the Header's subtitle
Pass an empty string to hide the subtitle completely.
Otherwise pass nil to default to the userAge label.
]]
function BaseHeader:SetSubtitle(displayText)
assert(type(displayText) == "nil" or type(displayText) == "string",
"Invalid argument number #1 to SetSubtitle, expected string or nil.")
self.subtitle = displayText
if displayText == "" then
self.innerSubtitle.Visible = false
else
self.innerSubtitle.Visible = true
self.innerSubtitle.Text = displayText
end
end
function BaseHeader:SetBackButtonEnabled(enabled)
self.backButton.rbx.Visible = enabled
end
function BaseHeader:AddButton(button)
table.insert(self.buttons, button)
button.rbx.Parent = self.innerButtons
button.rbx.LayoutOrder = #self.buttons
end
function BaseHeader:AddContent(content)
content.rbx.Parent = self.innerContent
end
function BaseHeader:SetConnectionState(connectionState)
if not self.subtitle then
self:SetDefaultSubtitle()
end
if self.dialogType == DialogInfo.DialogType.Right then
return
end
if connectionState ~= self.connectionState and self.rbx.Parent ~= nil and self.rbx.Parent.Parent ~= nil then
if connectionState == Enum.ConnectionState.Disconnected then
self.isDisconnectedOpen = true
self:DoSizeTweening()
else
self.isDisconnectedOpen = false
self:DoSizeTweening()
end
self.connectionState = connectionState
end
end
function BaseHeader:GetNewBackButton(dialogType)
local backButton
if dialogType == DialogInfo.DialogType.Modal then
backButton = PaddedImageButton.new(self.appState, "Close", "rbxasset://textures/ui/LuaChat/icons/ic-close-gray2.png")
elseif dialogType == DialogInfo.DialogType.Popup then
backButton = PaddedImageButton.new(self.appState, "Close", "rbxasset://textures/ui/LuaChat/icons/ic-close-white.png")
else
backButton = PaddedImageButton.new(self.appState, "Back", getPlatformSpecific(self.platform).BACK_BUTTON_ASSET_ID)
end
backButton.rbx.Position = UDim2.new(0, 0, 0.5, 0)
backButton.rbx.AnchorPoint = Vector2.new(0, 0.5)
return backButton
end
function BaseHeader:Destroy()
for _, conn in pairs(self.connections) do
conn:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, conn in pairs(self.rbx_connections) do
conn:Disconnect()
end
self.rbx_connections = {}
end
self.buttons = {}
-- Destroy an attached Roact Gamedrawer if we have one:
if self.roactInstanceGameDrawer ~= nil then
Roact.teardown(self.roactInstanceGameDrawer)
end
end
function BaseHeader:CreateGameDrawer(store, conversationId)
-- Create the game drawer. Note it's probably not shown yet:
self.conversationId = conversationId
self.heightOfGameDrawer = 0
self.hasFriendsInGame = false
self.isGameDrawerSized = false
self.isGameDrawerOpen = false
if FFlagLuaChatLoadGameLinkCardInChatAnalytics then
self.roactInstanceGameDrawer = Roact.mount(createRoactInstanceGameDrawer(self), self.rbx.GameDrawer, "ChatGameDrawer")
else
local newGameDrawer = Roact.createElement(ChatGameDrawer, {
AnchorPoint = Vector2.new(0, 0),
conversationId = conversationId,
Localization = self.appState.localization,
Position = UDim2.new(0, 0, 0, 0),
onSize = function(newSize, forceOpen)
self:SetGameDrawerSize(newSize, forceOpen)
end,
})
self.roactInstanceGameDrawer = Roact.mount(Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
serviceProvider = Roact.createElement(RoactServices.ServiceProvider, {
services = {
[RoactLocalization] = self.appState.localization,
}
}, {
newGameDrawer
})
}), self.rbx.GameDrawer, "ChatGameDrawer")
end
end
function BaseHeader:ToggleGameDrawer()
if self.isGameDrawerOpen == false then
self.isGameDrawerOpen = true
else
self.isGameDrawerOpen = false
end
if FFlagLuaChatLoadGameLinkCardInChatAnalytics then
self.roactInstanceGameDrawer = Roact.reconcile(self.roactInstanceGameDrawer, createRoactInstanceGameDrawer(self))
end
self:DoSizeTweening()
end
-- Note: Start is called whenever we return to a conversation:
function BaseHeader:Start()
self.lastHeight = -1
self.lastHeightDisconnected = -1
self.lastHeightDrawer = -1
if self.luaChatPlayTogetherEnabled then
if self.hasFriendsInGame then
self:OpenDrawer()
else
self:CloseDrawer()
end
end
end
function BaseHeader:OpenDrawer()
if self.isGameDrawerSized and (not self.isGameDrawerOpen) then
self.isGameDrawerOpen = true
self:DoSizeTweening()
end
end
function BaseHeader:CloseDrawer()
if self.isGameDrawerSized and self.isGameDrawerOpen then
self.isGameDrawerOpen = false
self:DoSizeTweening()
end
end
function BaseHeader:InputFocus()
if self.luaChatPlayTogetherEnabled then
self:CloseDrawer()
end
end
-- Set the open game drawer size - and tween it open:
function BaseHeader:SetGameDrawerSize(drawerSize, hasFriendsInGame)
if drawerSize > 0 and hasFriendsInGame and not self.isGameDrawerSized then
self.isGameDrawerOpen = true
end
self.heightOfGameDrawer = drawerSize
self.hasFriendsInGame = hasFriendsInGame
self.isGameDrawerSized = true
self:DoSizeTweening()
end
-- Do tweening for any drawers that might be open:
function BaseHeader:DoSizeTweening()
-- Base height of our header is the main contents:
local desiredHeight = self.heightOfHeader
-- Disconnected is the network error message:
if self.isDisconnectedOpen then
desiredHeight = desiredHeight + self.heightOfDisconnected
if not self.rbx.Disconnected.Visible or
(self.lastHeightDisconnected ~= self.heightOfDisconnected) then
local size = UDim2.new(1, 0, 0, self.heightOfDisconnected)
local resizeTween = TweenService:Create(
self.rbx.Disconnected,
TweenInfo.new(Constants.Tween.DEFAULT_TWEEN_TIME, Constants.Tween.DEFAULT_TWEEN_STYLE, Enum.EasingDirection.In),
{ Size = size }
)
resizeTween:Play()
self.rbx.Disconnected.Visible = true
self.lastHeightDisconnected = self.heightOfDisconnected
end
else
if self.rbx.Disconnected.Visible then
self.rbx.Disconnected.Size = UDim2.new(1, 0, 0, 0)
self.rbx.Disconnected.Visible = false
self.lastHeightDisconnected = 0
end
end
if self.luaChatPlayTogetherEnabled then
-- Game drawer is the list of games being played by participants of this conversation:
if self.isGameDrawerOpen and self.heightOfGameDrawer then
desiredHeight = desiredHeight + self.heightOfGameDrawer
if not self.rbx.GameDrawer.Visible or
(self.lastHeightDrawer ~= self.heightOfGameDrawer) then
self.rbx.GameDrawer.Visible = true
local size = UDim2.new(1, 0, 0, self.heightOfGameDrawer)
local resizeTween = TweenService:Create(
self.rbx.GameDrawer,
TweenInfo.new(Constants.Tween.DEFAULT_TWEEN_TIME, Constants.Tween.DEFAULT_TWEEN_STYLE, Enum.EasingDirection.In),
{ Size = size }
)
resizeTween:Play()
self.lastHeightDrawer = self.heightOfGameDrawer
end
else
if self.rbx.GameDrawer.Visible then
self.rbx.GameDrawer.Size = UDim2.new(1, 0, 0, 0)
self.rbx.GameDrawer.Visible = false
self.lastHeightDrawer = 0
end
end
end
-- Set the size of the main drawer:
if (self.lastHeight ~= desiredHeight) then
local size = UDim2.new(1, 0, 0, desiredHeight)
local resizeTween = TweenService:Create(
self.rbx,
TweenInfo.new(Constants.Tween.DEFAULT_TWEEN_TIME, Constants.Tween.DEFAULT_TWEEN_STYLE, Enum.EasingDirection.In),
{ Size = size }
)
resizeTween:Play()
self.lastHeight = desiredHeight
end
end
return BaseHeader
@@ -0,0 +1,187 @@
local CoreGui = game:GetService("CoreGui")
local NotificationService = game:GetService("NotificationService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local AppNotificationService = require(LuaApp.Services.AppNotificationService)
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local Intent = DialogInfo.Intent
local Signal = require(Common.Signal)
local Roact = require(Common.Roact)
local RoactAnalytics = require(LuaApp.Services.RoactAnalytics)
local RoactLocalization = require(LuaApp.Services.RoactLocalization)
local RoactNetworking = require(LuaApp.Services.RoactNetworking)
local RoactRodux = require(Common.RoactRodux)
local RoactServices = require(LuaApp.RoactServices)
local Components = LuaChat.Components
local HeaderLoader = require(Components.HeaderLoader)
local ResponseIndicator = require(Components.ResponseIndicator)
--[[
TODO: we would have a ticket "removing the fast flag LuaChatShareGameToChatFromChatV2".
When removing the flag LuaChatShareGameToChatFromChatV2, we need to delete the actions, reduces and store that
V1 share game to chat from chat used.
]]
-- V1 sharing game to chat from chat
local SharedGameList = require(Components.SharedGameList)
-- V2 sharing game to chat from chat
local SharedGamesList = require(Components.ShareGameToChatFromChat.SharedGamesList)
local TabBarView = require(LuaChat.TabBarView)
local TabPageParameters = require(LuaChat.Models.TabPageParameters)
-- Actions for V1 sharing game to chat from chat
local ClearAllGames = require(LuaChat.Actions.ShareGameToChatFromChat.ClearAllGamesInSortsShareGameToChatFromChat)
local ResetShareGameToChatAsync = require(LuaChat.Actions.ShareGameToChatFromChat.ResetShareGameToChatFromChatAsync)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local FFlagLuaChatShareGameToChatFromChatV2 = settings():GetFFlag("LuaChatShareGameToChatFromChatV2")
local BrowseGames = {}
BrowseGames.__index = BrowseGames
function BrowseGames.new(appState)
local self = {
appState = appState,
connections = {},
rbx_connections = {},
}
setmetatable(self, BrowseGames)
self._analytics = self.appState.analytics
self._localization = self.appState.localization
self._request = self.appState.request
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
self.header = HeaderLoader.GetHeader(appState, Intent.BrowseGames)
self.header:SetDefaultSubtitle()
self.header:SetTitle(self.appState.localization:Format("Feature.Chat.ShareGameToChat.BrowseGames"))
self.header:SetBackButtonEnabled(true)
self.header:SetConnectionState(Enum.ConnectionState.Disconnected)
local sharedGamesConfig = Constants.SharedGamesConfig
self.gamesPages = {}
local sharedGamesList = FFlagLuaChatShareGameToChatFromChatV2 and SharedGamesList
or SharedGameList
for _, sortName in ipairs(sharedGamesConfig.SortNames) do
table.insert(
self.gamesPages,
TabPageParameters(
self._localization:Format(sharedGamesConfig.SortsAttribute[sortName].TILE_LOCALIZATION_KEY),
sharedGamesList,
{
gameSort = sortName,
}
)
)
end
self.rbx = Create.new"Frame" {
Name = "BrowseGames",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
Create.new("UIListLayout") {
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
},
self.header.rbx,
Create.new"Frame" {
Name = "Content",
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
ClipsDescendants = true,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 1, -self.header.heightOfHeader),
self.responseIndicator.rbx,
},
}
self.mainContent = Roact.mount(Roact.createElement(RoactRodux.StoreProvider, {
store = appState.store,
}, {
Roact.createElement(RoactServices.ServiceProvider, {
services = {
[AppNotificationService] = NotificationService,
[RoactAnalytics] = self._analytics,
[RoactLocalization] = self._localization,
[RoactNetworking] = self._request,
}
}, {
TabBarView = Roact.createElement(TabBarView, {
tabs = self.gamesPages,
}),
}),
}), self.rbx.Content, "MainContent")
self.BackButtonPressed = Signal.new()
self.header.BackButtonPressed:connect(function()
if not FFlagLuaChatShareGameToChatFromChatV2 then
self:CleanGamesInSorts()
end
self.BackButtonPressed:fire()
end)
local headerSizeConnection = self.header.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, headerSizeConnection)
else
table.insert(self.connections, headerSizeConnection)
end
return self
end
function BrowseGames:CleanGamesInSorts()
self.appState.store:dispatch(ClearAllGames())
self.appState.store:dispatch(ResetShareGameToChatAsync())
end
function BrowseGames:Resize()
local sizeContent = UDim2.new(1, 0, 1, -self.header.rbx.AbsoluteSize.Y)
self.rbx.Content.Size = sizeContent
end
function BrowseGames:Update(current, previous)
self.header:SetConnectionState(current.ConnectionState)
local isSharing = self.appState.store:getState().ChatAppReducer.ShareGameToChatAsync.sharingGame or false
self.responseIndicator:SetVisible(isSharing)
end
function BrowseGames:Destruct()
for _, connection in pairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in pairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
self.header:Destroy()
self.responseIndicator:Destruct()
Roact.unmount(self.mainContent)
self.rbx:Destroy()
end
return BrowseGames
@@ -0,0 +1,35 @@
return function()
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaChat = Modules.LuaChat
local AppState = require(LuaChat.AppState)
local BrowseGames = require(LuaChat.Components.BrowseGames)
local FormFactor = require(Modules.LuaApp.Enum.FormFactor)
local SetFormFactor = require(Modules.LuaApp.Actions.SetFormFactor)
describe("new", function()
it("should create and destruct BrowseGames page on mobile phone with no errors", function()
local appState = AppState.mock()
appState.store:dispatch(SetFormFactor(FormFactor.PHONE))
local browseGames = BrowseGames.new(appState)
expect(browseGames).to.be.ok()
browseGames:Destruct()
expect(browseGames).to.be.ok()
end)
it("should create and destruct BrowseGames page on tablet with no errors", function()
local appState = AppState.mock()
appState.store:dispatch(SetFormFactor(FormFactor.TABLET))
local browseGames = BrowseGames.new(appState)
expect(browseGames).to.be.ok()
browseGames:Destruct()
expect(browseGames).to.be.ok()
end)
end)
end
@@ -0,0 +1,323 @@
local Players = game:GetService("Players")
local GuiService = game:GetService("GuiService")
local LuaChat = script.Parent.Parent
local UserThumbnail = require(script.Parent.UserThumbnail)
local TypingIndicator = require(script.Parent.TypingIndicator)
local UserChatBubble = require(script.Parent.UserChatBubble)
local AssetCard = require(script.Parent.AssetCard)
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local WebApi = require(LuaChat.WebApi)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local LuaApp = Modules.LuaApp
local NotificationType = require(LuaApp.Enum.NotificationType)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local RECEIVED_BUBBLE = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble2.png"
local RECEIVED_BUBBLE_WITH_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble.png"
local RECEIVED_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-tip.png"
local SENT_BUBBLE = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-self2.png"
local SENT_BUBBLE_WITH_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-self.png"
local SENT_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-self-tip.png"
local SENT_BUBBLE_OUTLINE = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble2.png"
local SENT_BUBBLE_OUTLINE_WITH_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-right.png"
local SENT_OUTLINE_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-tip-right.png"
local FFlagLuaChatInfiniteRelayoutRecursionFix = settings():GetFFlag("LuaChatInfiniteRelayoutRecursionFix")
local function isOutgoingMessage(message)
local localUserId = tostring(Players.LocalPlayer.UserId)
return message.senderTargetId == localUserId
end
local function isMessageSending(conversation, message)
if conversation and conversation.sendingMessages then
return conversation.sendingMessages:Get(message.id) ~= nil
end
return false
end
local PROTOCOL_IDENTIFIERS = {
"https?://", ""
}
local RESOURCE_NAMES = {
"www%.", "web%.", ""
}
local WHITELISTED_DOMAINS = {
"roblox", "sitetest%d%.robloxlabs", "gametest%d%.robloxlabs"
}
local MESSAGE_CONTENT_PATTERNS = {
GAME_LINK = "%.com/games[^%d]*(%d+)/?",
}
local ChatBubble = {}
ChatBubble.__index = ChatBubble
ChatBubble.BubbleType = {
AssetCard = "AssetCard",
ChatBubble = "UserChatBubble",
}
local function getBubbleImages(message, bubbleType)
if isOutgoingMessage(message) and bubbleType ~= ChatBubble.BubbleType.AssetCard then
return SENT_BUBBLE, SENT_BUBBLE_WITH_TAIL, SENT_TAIL
elseif isOutgoingMessage(message) then
return SENT_BUBBLE_OUTLINE, SENT_BUBBLE_OUTLINE_WITH_TAIL, SENT_OUTLINE_TAIL
else
return RECEIVED_BUBBLE, RECEIVED_BUBBLE_WITH_TAIL, RECEIVED_TAIL
end
end
function ChatBubble.new(appState, message, width)
width = width or 0
local self = {}
setmetatable(self, ChatBubble)
local conversationId = message.conversationId
local isSending = isMessageSending(appState.store:getState().ChatAppReducer.Conversations[conversationId], message)
if FFlagLuaChatInfiniteRelayoutRecursionFix then
self.width = width
end
self.appState = appState
self.message = message
self.bubbles = {}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.tailVisible = false
self.rbx = Create.new "Frame" {
Name = "ChatContainer",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 0),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
},
}
if message.moderated or isSending then
self:AddBubble(UserChatBubble.new(appState, message, nil, self.width), 1)
-- Specifically whitelist strings with .com/games in the url
elseif message.content:lower():match(MESSAGE_CONTENT_PATTERNS.GAME_LINK) then
local text = self:FilterForLinks()
-- Flush remaining text if it is not empty
if text:gsub("%s+","") ~= "" then
self:AddBubble(UserChatBubble.new(appState, message, text, self.width))
end
else
self:AddBubble(UserChatBubble.new(appState, message, nil, self.width), 1)
end
return self
end
function ChatBubble:FilterForLinks()
local text = self.message.content
for _, protocol in pairs(PROTOCOL_IDENTIFIERS) do
for _, resource in pairs(RESOURCE_NAMES) do
for _, domain in pairs(WHITELISTED_DOMAINS) do
local constructedUrlPattern = protocol .. resource .. domain .. MESSAGE_CONTENT_PATTERNS.GAME_LINK
for assetId in text:lower():gmatch(constructedUrlPattern) do
local linkStart, endLink = text:lower():find("[^%s*]*" .. constructedUrlPattern .. "[^%s*]*")
if linkStart then
local textBefore = text:sub(1, linkStart - 1)
if textBefore:gsub("%s+","") ~= "" then
self:AddBubble(UserChatBubble.new(self.appState, self.message, textBefore, self.width))
end
self:AddBubble(AssetCard.new(self.appState, self.message, assetId))
text = text:sub(endLink + 1)
else
return text
end
end
end
end
end
return text
end
function ChatBubble:AddBubble(bubble, placement)
table.insert(self.bubbles, placement or #self.bubbles+1 ,bubble)
bubble.rbx.Parent = self.rbx
bubble.LayoutOrder = placement or #self.bubbles
for i=1,#self.bubbles do
self.bubbles[i].LayoutOrder = i
end
local connection = bubble.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, connection)
else
table.insert(self.connections, connection)
end
self:Update()
end
function ChatBubble:SetUsernameVisible(value)
local bubblePos = self.bubbles[1].bubble.Position
if value then
self.bubbles[1].usernameLabel.Visible = true
self.bubbles[1].bubble.Position = UDim2.new(
bubblePos.X.Scale,
bubblePos.X.Offset,
0,
16
)
else
self.bubbles[1].usernameLabel.Visible = false
self.bubbles[1].bubble.Position = UDim2.new(
bubblePos.X.Scale,
bubblePos.X.Offset,
0,
0
)
end
self:Resize()
end
function ChatBubble:SetTypingIndicatorVisible(value)
if value and not self.indicator then
local indicator = TypingIndicator.new(self.appState, .4)
indicator.rbx.AnchorPoint = Vector2.new(0,0.5)
indicator.rbx.Position = UDim2.new(0, self.bubbles[1].usernameLabel.TextBounds.X + 3, 0.5, 0)
indicator.rbx.Parent = self.bubbles[1].usernameLabel
self.indicator = indicator
elseif self.indicator and not value then
self.indicator:Destroy()
self.indicator = nil
end
end
function ChatBubble:SetThumbnailVisible(value)
if value then
self.thumbnail = UserThumbnail.new(self.appState, self.message.senderTargetId, true)
self.thumbnail.rbx.Position = UDim2.new(0, 10, 0, 0)
self.thumbnail.rbx.Overlay.ImageColor3 = Constants.Color.GRAY6
self.thumbnail.rbx.Parent = self.bubbles[1].bubbleContainer
self.thumbnail.clicked:connect(function()
local user = self.appState.store:getState().Users[self.message.senderTargetId]
local userId = user and user.id
if userId then
GuiService:BroadcastNotification(WebApi.MakeUserProfileUrl(userId),
NotificationType.VIEW_PROFILE)
end
end)
else
if self.thumbnail then
self.thumbnail:Destruct()
end
end
end
function ChatBubble:SetTailVisible(value)
self.tailVisible = value
if not self.bubbles[1] then return end
for i, bubble in pairs(self.bubbles) do
local bubbleImage, bubbleWithTail, tailImage = getBubbleImages(self.message, bubble.bubbleType)
if value and i == 1 then
bubble.bubble.Image = bubbleWithTail
bubble.tail.Image = tailImage
bubble.tail.Visible = true
else
bubble.bubble.Image = bubbleImage
bubble.tail.Visible = false
end
end
end
function ChatBubble:SetPaddingObject(object)
if not self.bubbles[1] then return end
if self.bubbles[1].paddingObject then
self.bubbles[1].paddingObject:Destroy()
end
object.LayoutOrder = 1
object.Parent = self.bubbles[1].rbx
self.bubbles[1].paddingObject = object
self.bubbles[1]:Resize()
end
function ChatBubble:Resize()
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
for _,bubble in pairs(self.bubbles) do
bubble:Resize()
end
end
local height = 0
for _, child in ipairs(self.rbx:GetChildren()) do
if child:IsA("GuiObject") then
height = height + child.AbsoluteSize.Y
end
end
self.rbx.Size = UDim2.new(1, 0, 0, height)
end
function ChatBubble:Update()
self:SetTailVisible(self.tailVisible)
self:Resize()
end
function ChatBubble:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
for _, bubble in ipairs(self.bubbles) do
bubble:Destruct()
end
if self.thumbnail then
self.thumbnail:Destruct()
end
self.thumbnail = nil
self.rbx:Destroy()
end
return ChatBubble
@@ -0,0 +1,464 @@
return function()
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaChat = Modules.LuaChat
local MessageModel = require(LuaChat.Models.Message)
local ChatBubble = require(LuaChat.Components.ChatBubble)
local AppState = require(LuaChat.AppState)
local FFlagEnableChatMessageType = settings():GetFFlag("EnableChatMessageType")
describe("new", function()
it("should create with no errors", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "testing",
})
local chat = ChatBubble.new(appState, message)
expect(chat).to.be.ok()
end)
end)
describe("asset card creation", function()
describe("web protocol filtering", function()
it("should make a card with http:// prefix", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "http://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with https:// prefix", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with no protocol prefix", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
end)
describe("web resource names", function()
describe("www.", function()
it("should make a card with https:// protocol", function()
local appState = AppState.mock()
local message1 = MessageModel.mock({
content = "https://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat1 = ChatBubble.new(appState, message1)
expect(#chat1.bubbles).to.equal(1)
expect(chat1.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat1.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with http:// protocol", function()
local appState = AppState.mock()
local message2 = MessageModel.mock({
content = "http://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat2 = ChatBubble.new(appState, message2)
expect(#chat2.bubbles).to.equal(1)
expect(chat2.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat2.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with no protocol", function()
local appState = AppState.mock()
local message3 = MessageModel.mock({
content = "www.roblox.com/games/1818/Classic-Crossroads"
})
local chat3 = ChatBubble.new(appState, message3)
expect(#chat3.bubbles).to.equal(1)
expect(chat3.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat3.bubbles[1].assetId).to.equal("1818")
end)
end)
describe("web.", function()
it("should make a card with https:// protocol", function()
local appState = AppState.mock()
local message1 = MessageModel.mock({
content = "https://web.roblox.com/games/1818/Classic-Crossroads"
})
local chat1 = ChatBubble.new(appState, message1)
expect(#chat1.bubbles).to.equal(1)
expect(chat1.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat1.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with http:// protocol", function()
local appState = AppState.mock()
local message2 = MessageModel.mock({
content = "http://web.roblox.com/games/1818/Classic-Crossroads"
})
local chat2 = ChatBubble.new(appState, message2)
expect(#chat2.bubbles).to.equal(1)
expect(chat2.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat2.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with no protocol", function()
local appState = AppState.mock()
local message3 = MessageModel.mock({
content = "web.roblox.com/games/1818/Classic-Crossroads"
})
local chat3 = ChatBubble.new(appState, message3)
expect(#chat3.bubbles).to.equal(1)
expect(chat3.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat3.bubbles[1].assetId).to.equal("1818")
end)
end)
end)
describe("whitelisted domains", function()
describe("roblox.com", function()
it("should make a card with http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with only a resouce name", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card without a http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
end)
describe("sitetest1.robloxlabs.com", function()
it("should make a card with http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.sitetest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with only a resouce name", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "web.sitetest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card without a http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "sitetest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
end)
describe("gametest1.robloxlabs.com", function()
it("should make a card with http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.gametest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card with only a resouce name", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "web.gametest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should make a card without a http protocol prefixes", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "gametest1.robloxlabs.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
end)
describe("invalid domains", function()
it("should not create an asset card for non roblox domains", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.google.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.never.equal("AssetCard")
end)
end)
end)
describe("game link format", function()
it("should create an asset card with link without appended title text", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should ignore case when creating an asset card with link", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.ROBLOX.com/games/1818/"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should not create an asset card for non games", function()
local appState = AppState.mock()
local message1 = MessageModel.mock({
content = "https://www.roblox.com/users/1922632/profile"
})
local chat1 = ChatBubble.new(appState, message1)
expect(#chat1.bubbles).to.equal(1)
expect(chat1.bubbles[1].bubbleType).to.never.equal("AssetCard")
local message2 = MessageModel.mock({
content = "https://www.roblox.com/Groups/Group.aspx?gid=3475371"
})
local chat2 = ChatBubble.new(appState, message2)
expect(#chat2.bubbles).to.equal(1)
expect(chat2.bubbles[1].bubbleType).to.never.equal("AssetCard")
local message3 = MessageModel.mock({
content = "https://www.roblox.com/catalog/100929604/Green-Sparkle-Time-Fedora"
})
local chat3 = ChatBubble.new(appState, message3)
expect(#chat3.bubbles).to.equal(1)
expect(chat3.bubbles[1].bubbleType).to.never.equal("AssetCard")
end)
end)
describe("roblox links with text", function()
it("should create one UserChatBubble when presented with raw text", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "testing"
})
if FFlagEnableChatMessageType then
message.messageType = MessageModel.MessageTypes.PlainText
end
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[1].textContent.Text).to.equal("testing")
end)
if FFlagEnableChatMessageType then
it("should create one UserChatBubble with placeholder text if unknown message type is used", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "testing",
messageType = "SomeUnknownMessageTypeThatWillNeverExistInProduction"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[1].textContent.Text).to.equal("")
end)
end
it("should create one an AssetCard when presented with roblox link", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/Classic-Crossroads",
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(1)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
end)
it("should create two cards when presented a link with text if the link is first", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/Classic-Crossroads Play my game!"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(2)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
expect(chat.bubbles[2].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[2].textContent.Text).to.equal(" Play my game!")
end)
it("should create two cards when presented a link with text if the link is second", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "Play my game! https://www.roblox.com/games/1818/Classic-Crossroads"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(2)
expect(chat.bubbles[1].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[1].textContent.Text).to.equal("Play my game! ")
expect(chat.bubbles[2].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[2].assetId).to.equal("1818")
end)
it("should create three cards when presented a link with text", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "Play my game! https://www.roblox.com/games/1818/Classic-Crossroads Or dont."
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(3)
expect(chat.bubbles[1].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[1].textContent.Text).to.equal("Play my game! ")
expect(chat.bubbles[2].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[2].assetId).to.equal("1818")
expect(chat.bubbles[3].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[3].textContent.Text).to.equal(" Or dont.")
end)
it("should optionally accept the final / when parsing a roblox link", function()
local appState = AppState.mock()
local message1 = MessageModel.mock({
content = "https://www.roblox.com/games/1818/"
})
local chat1 = ChatBubble.new(appState, message1)
expect(#chat1.bubbles).to.equal(1)
expect(chat1.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat1.bubbles[1].assetId).to.equal("1818")
local message2 = MessageModel.mock({
content = "https://www.roblox.com/games/1337"
})
local chat2 = ChatBubble.new(appState, message2)
expect(#chat2.bubbles).to.equal(1)
expect(chat2.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat2.bubbles[1].assetId).to.equal("1337")
end)
it("should handle text in between two links", function()
local appState = AppState.mock()
local message = MessageModel.mock({
content = "https://www.roblox.com/games/1818/ or https://www.roblox.com/games/1337"
})
local chat = ChatBubble.new(appState, message)
expect(#chat.bubbles).to.equal(3)
expect(chat.bubbles[1].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[1].assetId).to.equal("1818")
expect(chat.bubbles[2].bubbleType).to.equal("UserChatBubble")
expect(chat.bubbles[2].textContent.Text).to.equal(" or ")
expect(chat.bubbles[3].bubbleType).to.equal("AssetCard")
expect(chat.bubbles[3].assetId).to.equal("1337")
end)
end)
end)
end
@@ -0,0 +1,114 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local PADDING_HEIGHT = 32
local TEXT_FONT = Enum.Font.SourceSans
local TEXT_FONT_SIZE = Constants.Font.FONT_SIZE_18
local ChatDisabledIndicator = {}
ChatDisabledIndicator.__index = ChatDisabledIndicator
function ChatDisabledIndicator.new(appState)
local self = {}
local imageButtonText = appState.localization:Format("Feature.Chat.Label.PrivacySettings")
local ibtWidth = Text.GetTextWidth(imageButtonText, Enum.Font.SourceSans, Constants.Font.FONT_SIZE_16)
local imageButton = Create.new "ImageButton" {
Name = "PrivacySettings",
AutoButtonColor = false,
Size = UDim2.new(0, ibtWidth+6, 0, 36),
BackgroundTransparency = 1,
LayoutOrder = 3,
BorderSizePixel = 0,
ScaleType = "Slice",
SliceCenter = Rect.new(3,3,4,4),
Image = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png",
ImageColor3 = Constants.Color.GREEN_PRIMARY,
Create.new "TextLabel" {
Name = "Title",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_16,
TextColor3 = Constants.Color.WHITE,
Text = imageButtonText,
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
}
}
local textLabelText = appState.localization:Format("Feature.Chat.Message.TurnOnChat")
local textLabel = Create.new "TextLabel" {
BackgroundTransparency = 1,
LayoutOrder = 2,
Font = TEXT_FONT,
TextColor3 = Constants.Color.GRAY2,
TextSize = TEXT_FONT_SIZE,
Text = textLabelText,
TextWrapped = true
}
self.rbx = Create.new "Frame" {
Name = "ChatDisabledIndicator",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 300),
Create.new "Frame" {
Name = "IndicatorInner",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 160),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
},
Create.new "ImageLabel" {
BackgroundTransparency = 1,
Size = UDim2.new(0, 72, 0, 72),
LayoutOrder = 1,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-friends.png",
},
textLabel,
imageButton,
},
}
local function rescaleFromParentSize()
local parentSize = self.rbx.AbsoluteSize
local tltHeight = Text.GetTextHeight(textLabelText, TEXT_FONT, TEXT_FONT_SIZE, parentSize.X)
textLabel.Size = UDim2.new(0,parentSize.X,0,tltHeight+PADDING_HEIGHT)
end
self.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(rescaleFromParentSize)
rescaleFromParentSize()
self.openPrivacySettings = Signal.new()
getInputEvent(self.rbx.IndicatorInner.PrivacySettings):Connect(function()
self.openPrivacySettings:fire()
end)
setmetatable(self, ChatDisabledIndicator)
return self
end
return ChatDisabledIndicator
@@ -0,0 +1,475 @@
--
-- ChatGameCard
--
-- This is a game that is shown (and possibly pinned) at the top of a chat
-- conversation.
--
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaApp.Constants)
local ContextualMenu = require(LuaApp.Components.ContextualMenu)
local FriendCarousel = require(LuaChat.Components.FriendCarousel)
local GetMultiplePlaceInfos = require(LuaChat.Actions.GetMultiplePlaceInfos)
local GetPlaceThumbnail = require(LuaChat.Actions.GetPlaceThumbnail)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local RoactAnalyticsGameCardLoaded = require(LuaChat.Services.RoactAnalyticsGameCardLoaded)
local RoactServices = require(LuaApp.RoactServices)
local ChatGameCard = Roact.PureComponent:extend("ChatGameCard")
local FFlagLuaChatLoadGameLinkCardInChatAnalytics = settings():GetFFlag("LuaChatLoadGameLinkCardInChatAnalytics")
local ICON_SIZE_SMALL = 36
local ICON_SIZE_LARGE = 60
local ICON_SIZE_SMALL_AMENDED = 48
local CARD_MARGINS = 12
local CARD_HEIGHT_SMALL = ICON_SIZE_SMALL + (CARD_MARGINS * 2)
local CARD_HEIGHT_LARGE = ICON_SIZE_LARGE + (CARD_MARGINS * 2)
local GAME_TEXT_COLOR = Constants.Color.GRAY1
local GAME_TEXT_FONT = Enum.Font.SourceSans
local GAME_TEXT_HEIGHT = 25
local GAME_TEXT_HEIGHT_WITH_SUBTITLE = 20
local GAME_TEXT_SIZE = 23
local GAME_TEXT_SIZE_WITH_SUBTITLE = 20
local SUBTITLE_TEXT_SIZE = 18
local SUBTITLE_TEXT_COLOR = Constants.Color.GRAY2
local CAROUSEL_SMALL_ICON_SIZE = 24
local CAROUSEL_LARGE_ICON_SIZE = 32
local CAROUSEL_SMALL_GAP = 3
local CAROUSEL_LARGE_GAP = 9
local CAROUSEL_SMALL_DOT_SIZE = 8
local CAROUSEL_LARGE_DOT_SIZE = 10
local ACTION_BUTTON_WIDTH = 60
local ACTION_BUTTON_HEIGHT = 32
local ACTION_COLOR_PLAY = Constants.Color.GREEN_PRIMARY
local ACTION_COLOR_TEXT = Constants.Color.WHITE
local DEBUG_OUTLINE = 0
local DEBUG_TRANSPARENCY = 1
local DEFAULT_GAME_ICON = "rbxasset://textures/ui/LuaApp/icons/ic-game.png"
local FADEOUT_MASK_IMAGE = "rbxasset://textures/ui/LuaChat/graphic/friendmask.png"
local FADEOUT_MASK_WIDTH = 10
local GAME_MASK_IMAGE = "rbxasset://textures/ui/LuaChat/9-slice/gr-mask-game-icon.png"
local ROUNDED_BUTTON = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png"
-- Set up some default state for this control:
function ChatGameCard:init()
self.state = {
isMenuOpen = false,
}
-- Localize strings. Needs to be done in context because of the way the localization object is being passed to us:
local localization = self.props.Localization
self.MenuInfoPlayGame = {
displayIcon = "rbxasset://textures/ui/LuaApp/icons/ic-games.png",
name = "PlayGameButton",
displayName = localization:Format("Feature.Chat.Drawer.PlayGame"),
}
self.MenuInfoPinGame = {
displayIcon = "rbxasset://textures/ui/LuaChat/icons/ic-pin.png",
name = "PinGameButton",
displayName = localization:Format("Feature.Chat.Drawer.PinGame")
}
self.MenuInfoUnpinGame = {
displayIcon = "rbxasset://textures/ui/LuaChat/icons/ic-unpin-20x20.png",
name = "UnpinGameButton",
displayName = localization:Format("Feature.Chat.Drawer.UnpinGame")
}
self.MenuItemInfoViewGameDetail = {
displayIcon = "rbxasset://textures/ui/LuaChat/icons/ic-viewdetails-20x20.png",
name = "ViewDetailsButton",
displayName = localization:Format("Feature.Chat.Drawer.ViewDetails"),
}
end
function ChatGameCard:render()
-- Information about the game is passed in as properties. Action to take
-- *on* the game should also be passed in, so we have containment and this
-- module only knows the miniumum necessary.
local parentLayoutOrder = self.props.LayoutOrder
-- Visual properties of this game card:
local isPinnedGame = self.props.isPinnedGame or false
local isRecommendedGame = self.props.isRecommended or false
local game = self.props.game
local getPlaceInfo = self.props.getPlaceInfo
local getPlaceThumbnail = self.props.getPlaceThumbnail
local localization = self.props.Localization
local onGamePin = self.props.onGamePin
local onGameStart = self.props.onGameStart
local onGameUnpin = self.props.onGameUnpin
local onViewDetails = self.props.onViewDetails
local placeInfos = self.props.placeInfos
local placeThumbnails = self.props.placeThumbnails or {}
local renderWidth = self.props.renderWidth or 0
-- Unpack from our properties:
local gameFriends = game.friends or {}
local placeId = game.placeId
-- Read or retrieve information about our place:
local placeInfo = placeInfos[placeId]
local gameName
local imageToken = nil
local universeId = nil
local isPlayable = false
if (placeInfo == nil) then
getPlaceInfo(placeId)
gameName = "(" .. localization:Format("Feature.Chat.Drawer.Loading") .. ")"
else
gameName = placeInfo.name
imageToken = placeInfo.imageToken
universeId = placeInfo.universeId
isPlayable = placeInfo.isPlayable
end
-- Configure some dimensions based on properties, the GameInfo section in
-- particular changes for a large (pinned) vs regular size card:
local cardHeight = CARD_HEIGHT_SMALL
local carouselItemDotSize = CAROUSEL_SMALL_DOT_SIZE
local carouselItemGap = CAROUSEL_SMALL_GAP
local carouselItemHeight = CAROUSEL_SMALL_ICON_SIZE
local friendAlignment = Enum.HorizontalAlignment.Right
local gameIconHeight = ICON_SIZE_SMALL
local gameIconWidth = gameIconHeight
local gameTextHeight = GAME_TEXT_HEIGHT
local gameTextSize = GAME_TEXT_SIZE
local infoFillDirection = Enum.FillDirection.Horizontal
local subtitle = ""
local subtitleVisibility = false
-- If we don't have an action button, zero the reserved width:
local buttonWidth = ACTION_BUTTON_WIDTH
if not isPlayable then
buttonWidth = 0
end
-- Scaling of elements:
local friendsWidthOffset = 0
local friendsWidthScale = 1
local textWidthOffset = 0
local textWidthScale = 1
if isPinnedGame then
cardHeight = CARD_HEIGHT_LARGE
carouselItemDotSize = CAROUSEL_LARGE_DOT_SIZE
carouselItemGap = CAROUSEL_LARGE_GAP
carouselItemHeight = CAROUSEL_LARGE_ICON_SIZE
friendAlignment = Enum.HorizontalAlignment.Left
gameIconHeight = ICON_SIZE_LARGE
gameIconWidth = gameIconHeight
infoFillDirection = Enum.FillDirection.Vertical
elseif isRecommendedGame then
carouselItemHeight = 0
gameTextHeight = GAME_TEXT_HEIGHT_WITH_SUBTITLE
gameTextSize = GAME_TEXT_SIZE_WITH_SUBTITLE
infoFillDirection = Enum.FillDirection.Vertical
subtitle = localization:Format("Feature.Chat.Drawer.Recommended")
subtitleVisibility = true
else
friendsWidthScale = 0.5
friendsWidthOffset = 0
textWidthScale = 0.5
textWidthOffset = 0
end
-- This is how much space we have in the center of the card:
local centerNegativeSpace = CARD_MARGINS + gameIconWidth + CARD_MARGINS + CARD_MARGINS
local countFriends = #gameFriends
-- Default is Play if nobody else is in the game, Join if we have friends:
local actionText
if isPlayable then
centerNegativeSpace = centerNegativeSpace + buttonWidth + CARD_MARGINS
local actionTextKey = "Feature.Chat.Drawer.Play"
if countFriends > 0 then
actionTextKey = "Feature.Chat.Drawer.Join"
end
actionText = localization:Format(actionTextKey)
end
local actionColor = ACTION_COLOR_PLAY
local actionTextColor = ACTION_COLOR_TEXT
-- ...and as a last metrics step, adjust the ratio of the game name and friend carousel:
if not (isPinnedGame or isRecommendedGame) then
local actualSpace = renderWidth - centerNegativeSpace
if actualSpace > 0 then
local friendSpace = carouselItemHeight * countFriends
local textAdjust = (actualSpace * 0.5) - (friendSpace + CARD_MARGINS)
if textAdjust > 0 then
textWidthOffset = textWidthOffset + textAdjust
friendsWidthOffset = friendsWidthOffset - textAdjust
end
end
end
-- Obtain the thumbnail for this game:
local gameIcon = DEFAULT_GAME_ICON
if placeInfo then
local thumbnail = placeThumbnails[imageToken]
if thumbnail == nil then
if imageToken and imageToken ~= "" then
if gameIconWidth == ICON_SIZE_SMALL then
getPlaceThumbnail(imageToken, ICON_SIZE_SMALL_AMENDED, ICON_SIZE_SMALL_AMENDED)
else
getPlaceThumbnail(imageToken, gameIconWidth, gameIconHeight)
end
end
elseif thumbnail.image ~= "" then
gameIcon = thumbnail.image
end
end
-- Build up a horizontal list of items for our card:
local cardItems = {}
cardItems["Layout"] = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, CARD_MARGINS)
})
-- Game icon:
local layoutOrder = 1
cardItems["GameIcon"] = Roact.createElement("ImageLabel", {
BackgroundTransparency = DEBUG_TRANSPARENCY,
BorderSizePixel = DEBUG_OUTLINE,
LayoutOrder = layoutOrder,
Size = UDim2.new(0, gameIconHeight, 0, gameIconHeight),
Image = gameIcon,
}, {
Mask = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = GAME_MASK_IMAGE,
ImageColor3 = Constants.Color.WHITE,
ScaleType = Enum.ScaleType.Slice,
Size = UDim2.new(1, 0, 1, 0),
SliceCenter = Rect.new(3,3,4,4),
}),
})
layoutOrder = layoutOrder + 1
cardItems["GameInfo"] = Roact.createElement("Frame", {
BackgroundTransparency = DEBUG_TRANSPARENCY,
BorderSizePixel = DEBUG_OUTLINE,
ClipsDescendants = true,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, -centerNegativeSpace, 1, 0),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = infoFillDirection,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
GameName = Roact.createElement("TextLabel", {
BackgroundTransparency = DEBUG_TRANSPARENCY,
BorderSizePixel = DEBUG_OUTLINE,
ClipsDescendants = true,
Font = GAME_TEXT_FONT,
LayoutOrder = 1,
Size = UDim2.new(textWidthScale, textWidthOffset, 0, gameTextHeight),
Text = gameName,
TextColor3 = GAME_TEXT_COLOR,
TextSize = gameTextSize,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top
},{
MaskRight = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = FADEOUT_MASK_IMAGE,
Position = UDim2.new(1, -FADEOUT_MASK_WIDTH, 0, 0),
Size = UDim2.new(0, FADEOUT_MASK_WIDTH, 1, 0),
ZIndex = 2,
}),
}),
Subtitle = Roact.createElement("TextLabel", {
BackgroundTransparency = DEBUG_TRANSPARENCY,
BorderSizePixel = DEBUG_OUTLINE,
ClipsDescendants = true,
Font = GAME_TEXT_FONT,
LayoutOrder = 1,
Size = UDim2.new(textWidthScale, textWidthOffset, 0, ICON_SIZE_SMALL - gameTextHeight),
Text = subtitle,
TextColor3 = SUBTITLE_TEXT_COLOR,
TextSize = SUBTITLE_TEXT_SIZE,
TextXAlignment = Enum.TextXAlignment.Left,
Visible = subtitleVisibility,
}),
GameFriends = Roact.createElement(FriendCarousel, {
dotSize = carouselItemDotSize,
friends = gameFriends,
HorizontalAlignment = friendAlignment,
itemGap = carouselItemGap,
itemSize = carouselItemHeight,
LayoutOrder = 2,
Size = UDim2.new(friendsWidthScale, friendsWidthOffset, 0, carouselItemHeight),
}),
})
layoutOrder = layoutOrder + 1
if isPlayable then
cardItems["ActionButton"] = Roact.createElement("ImageButton", {
AutoButtonColor = false,
BackgroundTransparency = 1,
BorderSizePixel = DEBUG_OUTLINE,
Image = ROUNDED_BUTTON,
ImageColor3 = actionColor,
LayoutOrder = layoutOrder,
ScaleType = Enum.ScaleType.Slice,
Size = UDim2.new(0, ACTION_BUTTON_WIDTH, 0, ACTION_BUTTON_HEIGHT),
SliceCenter = Rect.new(3,3,4,4),
[Roact.Event.Activated] = function(rbx)
onGameStart()
end
},{
ActionLabel = Roact.createElement("TextLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = GAME_TEXT_FONT,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 1, 0),
Text = actionText,
TextColor3 = actionTextColor,
TextSize = GAME_TEXT_SIZE,
}),
})
end
if self.state.isMenuOpen then
local menuItems
if isPinnedGame then
menuItems = { self.MenuInfoUnpinGame }
else
menuItems = { self.MenuInfoPinGame }
end
if isPlayable then
table.insert(menuItems, 1, self.MenuInfoPlayGame)
end
table.insert(menuItems, self.MenuItemInfoViewGameDetail)
local callbackCancel = function()
self:setState({ isMenuOpen = false })
end
local callbackSelect = function(item)
if item.name == self.MenuInfoPlayGame.name then
onGameStart()
elseif item.name == self.MenuInfoPinGame.name then
onGamePin(universeId)
elseif item.name == self.MenuInfoUnpinGame.name then
onGameUnpin()
elseif item.name == self.MenuItemInfoViewGameDetail.name then
onViewDetails()
end
callbackCancel()
end
cardItems["ContextMenu"] = Roact.createElement(ContextualMenu, {
callbackCancel = callbackCancel,
callbackSelect = callbackSelect,
menuItems = menuItems,
screenShape = self.state.screenShape,
})
end
-- Put a clickable wrapper around the entire card:
return Roact.createElement("TextButton", {
AutoButtonColor = false,
BackgroundTransparency = DEBUG_TRANSPARENCY,
BorderSizePixel = DEBUG_OUTLINE,
LayoutOrder = parentLayoutOrder,
Size = UDim2.new(1, 0, 0, cardHeight),
Text = "",
[Roact.Event.Activated] = function(rbx)
-- TODO: Move this screen size functionality into a helper component
-- so that it doesn't get repeated everywhere (see: MOBLUAPP-241).
-- We need to know the size of the screen, so we can position the
-- popout component appropriately. So we climb up the object
-- heirachy until we find the current ScreenGui:
local screenWidth = 0
local screenHeight = 0
local screenGui = rbx:FindFirstAncestorOfClass("ScreenGui")
if screenGui ~= nil then
screenWidth = screenGui.AbsoluteSize.X
screenHeight = screenGui.AbsoluteSize.Y
end
self:setState({
isMenuOpen = true,
screenShape = {
x = rbx.AbsolutePosition.X,
y = rbx.AbsolutePosition.Y,
width = rbx.AbsoluteSize.X,
height = rbx.AbsoluteSize.Y,
parentWidth = screenWidth,
parentHeight = screenHeight,
},
})
end,
}, cardItems)
end
function ChatGameCard:didUpdate(previousProps, previousState)
local conversationId = self.props.conversationId
local isGameDrawerOpen = self.props.isGameDrawerOpen
local placeId = self.props.game.placeId
if FFlagLuaChatLoadGameLinkCardInChatAnalytics then
local sendAnalytics = false
if isGameDrawerOpen ~= previousProps.isGameDrawerOpen and self.state == previousState then
sendAnalytics = true
end
if isGameDrawerOpen and sendAnalytics then
self.props.analytics.reportGameCardLoadedInLuaChat(tostring(conversationId), tostring(placeId))
end
end
end
ChatGameCard = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
placeInfos = state.ChatAppReducer.PlaceInfos,
placeThumbnails = state.ChatAppReducer.PlaceThumbnails,
}
end,
function(dispatch)
return {
getPlaceInfo = function(placeId)
dispatch(GetMultiplePlaceInfos({placeId}))
end,
getPlaceThumbnail = function(imageToken, iconWidth, iconHeight)
dispatch(GetPlaceThumbnail(imageToken, iconWidth, iconHeight))
end,
}
end
)(ChatGameCard)
ChatGameCard = RoactServices.connect({
analytics = RoactAnalyticsGameCardLoaded,
})(ChatGameCard)
return ChatGameCard
@@ -0,0 +1,41 @@
return function()
local LocalizationService = game:GetService("LocalizationService")
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local AppReducer = require(Modules.LuaApp.AppReducer)
local Localization = require(Modules.LuaApp.Localization)
local MockId = require(Modules.LuaApp.MockId)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local Rodux = require(Modules.Common.Rodux)
local ChatGameCard = require(Modules.LuaChat.Components.ChatGameCard)
local localization = Localization.new(LocalizationService.RobloxLocaleId)
it("should create and destroy without errors", function()
local store = Rodux.Store.new(AppReducer)
local renderWidth = 500
local game = {
placeId = MockId(),
friends = { { uid = MockId() }, { uid = MockId() },
{ uid = MockId() }, { uid = MockId() }, { uid = MockId() } },
}
local element = Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
GameCard = Roact.createElement(ChatGameCard, {
game = game,
isPinnedGame = true,
Localization = localization,
renderWidth = renderWidth,
})
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,673 @@
--
-- ChatGameDrawer
--
-- Contains ChatGameCard objects that represent pinned or in progress games.
-- This lives at the top of a conversation window.
--
local CoreGui = game:GetService("CoreGui")
local GuiService = game:GetService("GuiService")
local HttpService = game:GetService("HttpService")
local PlayerService = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Analytics = require(Common.Analytics)
local ChatGameCard = require(LuaChat.Components.ChatGameCard)
local Constants = require(LuaApp.Constants)
local GameParams = require(LuaChat.Models.GameParams)
local FlagSettings = require(LuaChat.FlagSettings)
local PlayTogetherActions = require(LuaChat.Actions.PlayTogetherActions)
local Roact = require(Common.Roact)
local RoactRodux = require(Common.RoactRodux)
local SortedActivelyPlayedGames = require(LuaChat.SortedActivelyPlayedGames)
local User = require(LuaApp.Models.User)
local ChatGameDrawer = Roact.PureComponent:extend("ChatGameDrawer")
local urlSupportNewGamesAPI = settings():GetFFlag("UrlSupportNewGamesAPI")
local luaChatPlayTogetherJoinGameInstance = FlagSettings.LuaChatPlayTogetherJoinGameInstance()
-- Drawer properties:
local DRAWER_BACKGROUND_COLOR = Constants.Color.WHITE
local BORDER_SIZE = 12
local DRAWER_SHADOW_IMAGE = "rbxasset://textures/ui/LuaChat/graphic/gr-overlay-shadow.png"
local DRAWER_SHADOW_HEIGHT = 5
-- Pointer up to the image:
local ICON_POINTER_HEIGHT = 6
local ICON_POINTER_WIDTH = 12
local ICON_POINTER_UP = "rbxasset://textures/ui/LuaApp/dropdown/gr-tip-up.png"
local ICON_POINTER_FROMEDGE = 20
-- "Pinned Game" text properties:
local PINNED_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-pin.png"
local PINNED_ICON_SIZE = 12
local PINNED_SPACER = 6
local PINNED_TEXT_COLOR = Constants.Color.GRAY2
local PINNED_TEXT_FONT = Enum.Font.SourceSans
local PINNED_TEXT_SIZE = 15
local PINNED_DIVIDER_COLOR = Constants.Color.GRAY4
local PINNED_BOTTOM_BORDER = Constants.Color.GRAY4
local PINNED_BOTTOM_BACKGROUND = Constants.Color.GRAY6
local SMALL_DIVIDER_OFFSET = 60
local CARD_HEIGHT_SMALL = 60
local CARD_HEIGHT_LARGE = 84
local MORE_TEXT_SIZE = 18
local MORE_TEXT_PADDING = 9
local MORE_TEXT_COLOR = Constants.Color.GRAY1
-- Set up some default state for this control:
function ChatGameDrawer:init()
self._analytics = Analytics.new()
self.state = {
isExpanded = false,
pointerPosition = UDim2.new(1, -ICON_POINTER_FROMEDGE, 0, 0),
pointerSet = false,
renderWidth = 0,
}
self.isGameDrawerSized = false
end
function ChatGameDrawer:UnpinGame()
local playTogetherUnpinGame = self.props.playTogetherUnpinGame
playTogetherUnpinGame(self.props.conversationId)
end
-- Games are pinned by universeId (but they're accessed using placeId elsewhere):
function ChatGameDrawer:PinGame(universeId)
local playTogetherPinGame = self.props.playTogetherPinGame
playTogetherPinGame(self.props.conversationId, universeId)
end
function ChatGameDrawer:ViewGameDetails(placeId)
GuiService:BroadcastNotification(placeId, GuiService:GetNotificationTypeList().VIEW_GAME_DETAILS_ANIMATED)
end
function ChatGameDrawer:GameStart(game)
local placeId = game.placeId
if luaChatPlayTogetherJoinGameInstance then
local friends = game.friends
if #friends == 0 then
-- No friends are here, join naively:
self:GamePlayPlace(placeId)
else
-- We have friends! Go join them:
local friend = friends[1]
if friend.placeId == friend.rootPlaceId then
-- If our friend is in the root instance, we can join the same instance:
self:GameJoinInstance(friend.placeId, friend.rootPlaceId, friend.gameInstanceId)
else
-- Otherwise, we must join to their playerID:
self:GameJoinUser(friend.uid, friend.placeId, friend.rootPlaceId)
end
end
else
self:GamePlayPlace(placeId)
end
end
function ChatGameDrawer:GamePlayPlace(placeId)
-- Report player start game via play together:
self:ReportGamePlayIntent(placeId)
self:ReportPlayerPlayGame(placeId)
-- Start a game:
local gameParams = GameParams.fromPlaceId(placeId)
local payload = HttpService:JSONEncode(gameParams)
GuiService:BroadcastNotification(payload, GuiService:GetNotificationTypeList().LAUNCH_GAME)
end
function ChatGameDrawer:GameJoinUser(userId, placeId, rootPlaceId)
-- Report player join game via play together:
self:ReportGamePlayIntent(rootPlaceId)
self:ReportPlayerJoinGame(placeId, rootPlaceId, nil)
-- Join a game:
local gameParams = GameParams.fromUserId(userId)
local payload = HttpService:JSONEncode(gameParams)
GuiService:BroadcastNotification(payload, GuiService:GetNotificationTypeList().LAUNCH_GAME)
end
function ChatGameDrawer:GameJoinInstance(placeId, rootPlaceId, gameInstanceId)
-- Report player join game via play together:
self:ReportGamePlayIntent(rootPlaceId)
self:ReportPlayerJoinGame(placeId, rootPlaceId, gameInstanceId)
-- Join a game:
local gameParams = GameParams.fromPlaceInstance(placeId, gameInstanceId)
local payload = HttpService:JSONEncode(gameParams)
GuiService:BroadcastNotification(payload, GuiService:GetNotificationTypeList().LAUNCH_GAME)
end
function ChatGameDrawer:GetGamesFromConversation(conversationId)
local conversations = self.props.conversations
local mostRecentlyPlayedGames = self.props.mostRecentlyPlayedGames
local users = self.props.users
-- Don't know if this is necessary:
if not urlSupportNewGamesAPI then
error("Server doesn't support new games API.")
return { countFriendsInGames = 0, games = {}, }
end
-- Early out if we have no conversation:
if conversationId == nil or conversationId == "nil" then
return { countFriendsInGames = 0, games = {}, }
end
-- Find the specific conversation we're interested in:
if not conversations then
return { countFriendsInGames = 0, games = {}, }
end
local conversation = conversations[conversationId]
if not conversation then
warn("ChatGameDrawer - Can't find conversation, id:" .. conversationId .. " t:" .. type(conversationId))
return { countFriendsInGames = 0, games = {}, }
end
local pinnedGameRootPlaceId = conversation.pinnedGame.rootPlaceId
local inGameParticipants = {}
local mostRecentPlayedPlayableGamePlaceId = mostRecentlyPlayedGames.playableGamePlaceId
local localPlayerId = tostring(PlayerService.LocalPlayer.UserId)
for _, userId in pairs(conversation.participants) do
if userId ~= localPlayerId then
local user = users[userId]
if user ~= nil then
if (user.presence == User.PresenceType.IN_GAME) and user.placeId then
table.insert(inGameParticipants, user)
end
end
end
end
local countFriendsInGames = #inGameParticipants
if countFriendsInGames > 0 then
return {
countFriendsInGames = countFriendsInGames,
games = SortedActivelyPlayedGames.getSortedGamesPlusEmptyPinned(pinnedGameRootPlaceId, inGameParticipants),
}
end
if pinnedGameRootPlaceId then
return {
countFriendsInGames = 0,
games = {
{
friends = {},
pinned = true,
placeId = pinnedGameRootPlaceId,
recommended = false,
}
}
}
end
if mostRecentPlayedPlayableGamePlaceId then
return {
countFriendsInGames = 0,
games = {
{
friends = {},
pinned = false,
placeId = mostRecentPlayedPlayableGamePlaceId,
recommended = true,
}
}
}
end
return {
countFriendsInGames = 0,
games = {}
}
end
function ChatGameDrawer:ReportGamePlayIntent(rootPlaceId)
local conversationId = self.props.conversationId
local eventContext = "PlayGameFromPlayTogether"
local eventName = "gamePlayIntent"
local player = PlayerService.LocalPlayer
local userId = "UNKNOWN"
if player then
userId = tostring(player.UserId)
end
local additionalArgs = {
uid = userId,
rootPlaceId = rootPlaceId,
}
self._analytics.EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
end
function ChatGameDrawer:ReportPlayerPlayGame(placeId)
local conversationId = self.props.conversationId
local eventContext = "touch"
local eventName = "clickPlayButtonInPlayTogether"
local player = PlayerService.LocalPlayer
local userId = "UNKNOWN"
if player then
userId = tostring(player.UserId)
end
local additionalArgs = {
uid = userId,
cid = conversationId,
placeId = placeId
}
self._analytics.EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
end
function ChatGameDrawer:ReportPlayerJoinGame(placeId, rootPlaceId, gameInstanceId)
local conversationId = self.props.conversationId
local eventContext = "touch"
local eventName = "clickJoinButtonInPlayTogether"
local player = PlayerService.LocalPlayer
local userId = "UNKNOWN"
if player then
userId = tostring(player.UserId)
end
local additionalArgs = {
uid = userId,
conversationId = conversationId,
placeId = placeId,
rootPlaceId = rootPlaceId,
gameInstanceId = gameInstanceId,
}
self._analytics.EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
end
function ChatGameDrawer:render()
local anchorPoint = self.props.AnchorPoint
local conversationId = self.props.conversationId
local isGameDrawerOpen = self.props.isGameDrawerOpen
local localization = self.props.Localization
local onSize = self.props.onSize
local parentLayoutOrder = self.props.layoutOrder
local position = self.props.Position
local isExpanded = self.state.isExpanded
local pointerPosition = self.state.pointerPosition or UDim2.new(1, -ICON_POINTER_FROMEDGE, 0, 0)
local pointerSet = self.state.pointerSet
local pointerTransparency = 1
if pointerSet then
pointerTransparency = 0
end
local gameInfo = self:GetGamesFromConversation(conversationId)
local countGames = #gameInfo.games
-- Early exit if we have nothing to display in the drawer:
if countGames == 0 then
spawn(function()
onSize(0, false)
end)
return nil
end
local hasFriendsActive = gameInfo.countFriendsInGames > 0
-- If we have active friends and this is the first time rendering, expand:
if hasFriendsActive and not self.isGameDrawerSized then
self.isGameDrawerSized = true
if not isExpanded then
spawn(function()
self:setState({ isExpanded = true })
end)
return nil
end
end
-- Build up our drop-down items here for display inside our main element:
local gameItems = {}
local drawerHeight = 0
gameItems["Layout"] = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Top,
})
-- Display our pinned game (if we have one):
local layoutOrder = 1
local hasPinnedGame = false
for _, game in ipairs(gameInfo.games) do
if game.pinned then
hasPinnedGame = true
local placeId = game.placeId
gameItems["PinnedTitle"] = Roact.createElement("TextButton", {
AutoButtonColor = false,
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, PINNED_ICON_SIZE + (BORDER_SIZE * 2)),
Text = "",
}, {
Icon = Roact.createElement("ImageLabel", {
AnchorPoint = Vector2.new(0, 0.5),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = PINNED_ICON,
Position = UDim2.new(0, BORDER_SIZE, 0.5, 0),
Size = UDim2.new(0, PINNED_ICON_SIZE, 0, PINNED_ICON_SIZE),
}),
Text = Roact.createElement("TextLabel", {
AnchorPoint = Vector2.new(0, 0.5),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = PINNED_TEXT_FONT,
Position = UDim2.new(0, BORDER_SIZE + PINNED_ICON_SIZE + PINNED_SPACER, 0.5, 0),
Size = UDim2.new(1, -(PINNED_ICON_SIZE + PINNED_SPACER + (BORDER_SIZE * 2)), 1, 0),
Text = localization:Format("Feature.Chat.Drawer.PinnedGame"),
TextColor3 = PINNED_TEXT_COLOR,
TextSize = PINNED_TEXT_SIZE,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
}),
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + PINNED_ICON_SIZE + (BORDER_SIZE * 2)
gameItems["Divider"] = Roact.createElement("Frame", {
BackgroundColor3 = PINNED_DIVIDER_COLOR,
BorderSizePixel = 0,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, -(BORDER_SIZE * 2), 0, 1),
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + 1
-- Index by pinned status and placeId:
gameItems["Pinned" .. placeId] = Roact.createElement(ChatGameCard, {
game = game,
conversationId = conversationId,
isGameDrawerOpen = isGameDrawerOpen,
isPinnedGame = true,
LayoutOrder = layoutOrder,
Localization = localization,
renderWidth = self.state.renderWidth,
onGameStart = function()
self:GameStart(game)
end,
onGameUnpin = function()
self:UnpinGame()
end,
onViewDetails = function()
self:ViewGameDetails(placeId)
end,
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + CARD_HEIGHT_LARGE
-- If we have more games to display, add a spacer between the pinned and regular games:
if (countGames > 1) then
gameItems["Spacer"] = Roact.createElement("Frame", {
BackgroundColor3 = PINNED_BOTTOM_BACKGROUND,
BackgroundTransparency = 0,
BorderColor3 = PINNED_BOTTOM_BORDER,
BorderSizePixel = 1,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, PINNED_SPACER),
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + PINNED_SPACER
end
-- Done, we found our pinned game in the list:
break
end
end
-- Display all the other games in progress - but only if we don't have a
-- pinned game or we're expanded:
if (not hasPinnedGame) or isExpanded then
local hasRegularGame = false
for _, game in ipairs(gameInfo.games) do
if not game.pinned then
-- If we've already added a regular game, insert a spacer before the next:
if hasRegularGame then
gameItems[layoutOrder] = Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, 1),
},{
divider = Roact.createElement("Frame", {
AnchorPoint = Vector2.new(1, 0),
BorderSizePixel = 0,
BackgroundColor3 = PINNED_DIVIDER_COLOR,
Position = UDim2.new(1, 0, 0, 0),
Size = UDim2.new(1, -SMALL_DIVIDER_OFFSET, 0, 1),
}),
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + 1
end
local placeId = game.placeId
-- Index as an unpinned game and placeId:
gameItems["Game" .. placeId] = Roact.createElement(ChatGameCard, {
game = game,
conversationId = conversationId,
isGameDrawerOpen = isGameDrawerOpen,
isPinnedGame = false,
isRecommended = game.recommended,
LayoutOrder = layoutOrder,
Localization = localization,
renderWidth = self.state.renderWidth,
onGameStart = function()
self:GameStart(game)
end,
onGamePin = function(universeId)
self:PinGame(universeId)
end,
onViewDetails = function()
self:ViewGameDetails(placeId)
end,
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + CARD_HEIGHT_SMALL
-- If we're not expanded, we've hit our limit:
if not isExpanded then
break
end
-- Next card will have a spacer before it.
hasRegularGame = true
end
end
end
-- The final item in the list should be the text to either show more or hide:
local endTextDivider = false
local endTextShow = false
local endLocalizeText = ""
if countGames == 0 then
endTextShow = true
endLocalizeText = localization:Format("Feature.Chat.Drawer.NoGames")
elseif countGames > 1 then
if isExpanded then
endLocalizeText = localization:Format("Feature.Chat.Drawer.ShowLess")
else
endLocalizeText = localization:Format("Feature.Chat.Drawer.ShowMore") .. " (+" .. (countGames - 1) .. ")"
end
endTextDivider = true
endTextShow = true
end
if endTextShow then
if endTextDivider then
gameItems["DividerBottom"] = Roact.createElement("Frame", {
BorderSizePixel = 0,
BackgroundColor3 = PINNED_DIVIDER_COLOR,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, 1),
})
layoutOrder = layoutOrder + 1
drawerHeight = drawerHeight + PINNED_SPACER
end
gameItems["ShowButton"] = Roact.createElement("TextButton", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = PINNED_TEXT_FONT,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, MORE_TEXT_SIZE + (MORE_TEXT_PADDING * 2)),
Text = endLocalizeText,
TextColor3 = MORE_TEXT_COLOR,
TextSize = MORE_TEXT_SIZE,
[Roact.Event.Activated] = function()
self:setState({ isExpanded = not self.state.isExpanded })
end
})
drawerHeight = drawerHeight + MORE_TEXT_SIZE + (MORE_TEXT_PADDING * 2)
end
-- Define the shadow component to hang off the bottom of the list:
-- Note: Do not count the height since this isn't inside the frame.
local shadow = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
Image = DRAWER_SHADOW_IMAGE,
Size = UDim2.new(1, 0, 0, DRAWER_SHADOW_HEIGHT),
Position = UDim2.new(0, 0, 1, 0),
})
spawn(function()
onSize(drawerHeight, hasFriendsActive)
end)
-- Create and return the main control itself:
return Roact.createElement("Frame", {
AnchorPoint = anchorPoint,
BackgroundColor3 = DRAWER_BACKGROUND_COLOR,
BackgroundTransparency = 0,
BorderSizePixel = 0,
ClipsDescendants = false,
LayoutOrder = parentLayoutOrder,
Position = position,
Size = UDim2.new(1, 0, 1, 0),
[Roact.Ref] = function(rbx)
if not rbx then
return
end
spawn(function()
self:resolveMetrics(rbx)
end)
end,
}, {
Pointer = Roact.createElement("ImageLabel", {
AnchorPoint = Vector2.new(0.5, 1),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = ICON_POINTER_UP,
ImageTransparency = pointerTransparency,
Position = pointerPosition,
Size = UDim2.new(0, ICON_POINTER_WIDTH, 0, ICON_POINTER_HEIGHT),
}),
Shadow = shadow,
Frame = Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
Size = UDim2.new(1, 0, 1, 0),
},
gameItems
)
})
end
function ChatGameDrawer:resolveMetrics(rbx)
-- This function finds the ActiveGameIcon and positions an arrow pointing
-- at it from our open drawer. The position of the icon can change depending
-- on a number of factors so it can't be hardcoded.
--
-- Also retrieves the actual width of our drawer to pass to child components
-- which need to be width-aware to render properly.
--
-- Note 1: I didn't want to attach this on the icon because it needs to line
-- up with the edge of the drawer.)
--
-- Note 2: we can't examine the GameDrawer position here because on the
-- initial pass through it is invisible with a position of 0,0 on screen.
-- Find the conversation header:
local header = rbx:FindFirstAncestor("HeaderFrame")
if header == nil then
warn("Couldn't find header.")
return
end
-- Find the "Play Together" icon:
local iconPlayTogether = header:FindFirstChild("TopGameIcon", true)
if iconPlayTogether == nil then
warn("Couldn't find Play Together icon.")
return
end
-- Figure out where on the screen iconPlayTogether is, so
-- we can position our pointer directly underneath it:
local iconFromEdge = (header.AbsolutePosition.X + header.AbsoluteSize.X) -
(iconPlayTogether.AbsolutePosition.X + (iconPlayTogether.AbsoluteSize.X * 0.5))
-- Track our drawer's width for content-aware scaling:
local renderWidth = rbx.AbsoluteSize.X
-- Prevent updating metrics if we already have the correct values:
if (not self.state.pointerSet) or
(self.state.renderWidth ~= renderWidth) or
(self.state.pointerPosition.X.Offset ~= -iconFromEdge) then
-- Update the pointer position state so it will render in the correct location:
-- Yes, we're using another spawn call here - but we need the 1-frame delay.
spawn(function()
self:setState({
pointerPosition = UDim2.new(1, -iconFromEdge, 0, 0),
pointerSet = true,
renderWidth = renderWidth,
})
end)
end
end
ChatGameDrawer = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
conversations = state.ChatAppReducer.Conversations,
mostRecentlyPlayedGames = state.ChatAppReducer.MostRecentlyPlayedGames,
users = state.Users,
}
end,
function(dispatch)
return {
playTogetherUnpinGame = function(conversationId)
dispatch(PlayTogetherActions.UnpinGame(conversationId))
end,
playTogetherPinGame = function(conversationId, universeId)
dispatch(PlayTogetherActions.PinGame(conversationId, universeId))
end,
}
end
)(ChatGameDrawer)
return ChatGameDrawer
@@ -0,0 +1,45 @@
if not settings():GetFFlag("UrlSupportNewGamesAPI") then
return function()
it("those tests are not meaningful when flag \"UrlSupportNewGamesAPI\" is off!", function()
end)
end
end
return function()
local LocalizationService = game:GetService("LocalizationService")
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local AppReducer = require(Modules.LuaApp.AppReducer)
local Localization = require(Modules.LuaApp.Localization)
local MockId = require(Modules.LuaApp.MockId)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local Rodux = require(Modules.Common.Rodux)
local ChatGameDrawer = require(Modules.LuaChat.Components.ChatGameDrawer)
local localization = Localization.new(LocalizationService.RobloxLocaleId)
it("should create and destroy without errors", function()
local store = Rodux.Store.new(AppReducer)
local element = Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
GameDrawer = Roact.createElement(ChatGameDrawer, {
AnchorPoint = Vector2.new(0, 0),
conversationId = MockId(),
Localization = localization,
Position = UDim2.new(0, 0, 0, 0),
onSize = function(newSize, forceOpen)
-- pass
end,
}),
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,397 @@
local CoreGui = game:GetService("CoreGui")
local PlayerService = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local FormFactor = require(LuaApp.Enum.FormFactor)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local Intent = DialogInfo.Intent
local SetRoute = require(LuaChat.Actions.SetRoute)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local FFlagLuaChatInputBarRefactor = settings():GetFFlag("LuaChatInputBarRefactor")
local FFlagLuaChatShareGameToChatFromChat = settings():GetFFlag("LuaChatShareGameToChatFromChat")
local GAME_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-game.png"
local INPUT_FRAME_IMAGE = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png"
local PRESSED_GAME_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-game-pressed-24x24.png"
local SEND_BUTTON_IMAGE = "rbxasset://textures/ui/LuaChat/graphic/send-white.png"
local SEND_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-send.png"
local CHAT_BAR_HEIGHT_TABLET = 64
local GAME_ICON_BOTTOM_PADDING = 8
local LINE_CUTOFF_PHONE = 4.5 / 5
local LINE_CUTOFF_TABLET = 0.95
local RIGHT_BUTTON_HEIGHT = 48
local RIGHT_BUTTON_WIDTH = 52
local SEND_BUTTON_BOTTOM_PADDING = 8
local MAX_CHARACTER_LENGTH = 160
local ChatInputBar = {}
ChatInputBar.__index = ChatInputBar
function ChatInputBar.new(appState)
local self = {}
setmetatable(self, ChatInputBar)
self.appState = appState
self.sendButtonEnabled = false
self.SendButtonPressed = Signal.new()
self.UserChangedText = Signal.new()
self.blockUserChangedText = true
self._analytics = appState.analytics
local isTablet = appState.store:getState().FormFactor == FormFactor.TABLET
local lineCutoff
if isTablet then
lineCutoff = LINE_CUTOFF_TABLET
else
lineCutoff = LINE_CUTOFF_PHONE
end
local function getTextButtonHeight(text, font, textSize, textBoxAbsoluteSizeX)
local textHeight = Text.GetTextHeight(text, font, textSize,
textBoxAbsoluteSizeX)
local maxTextHeight = Text.GetTextHeight("A\nB\nC\nD\nE", font, textSize,
textBoxAbsoluteSizeX) * lineCutoff
local textButtonHeight = 24 + math.min(textHeight, maxTextHeight)
return textHeight, maxTextHeight, textButtonHeight
end
local textButtonInputTextFont = Enum.Font.SourceSans
local textButtonInputTextSize = Constants.Font.FONT_SIZE_18
local _, heightOfFourLines
local textButtonHeight
if FFlagLuaChatInputBarRefactor then
_, _, heightOfFourLines = getTextButtonHeight("", textButtonInputTextFont, textButtonInputTextSize, 0)
if isTablet then
textButtonHeight = CHAT_BAR_HEIGHT_TABLET
else
textButtonHeight = heightOfFourLines
end
else
_, _, textButtonHeight = getTextButtonHeight("", textButtonInputTextFont, textButtonInputTextSize, 0)
end
local textBoxPosition
if isTablet then
textBoxPosition = UDim2.new(0, 0, 0, 0)
else
textBoxPosition = UDim2.new(0, 12, 0, 6)
end
local textBoxInstance = Create.new "TextBox" {
Name = "InputText",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
Position = textBoxPosition,
Text = "",
Font = textButtonInputTextFont,
TextSize = textButtonInputTextSize,
TextColor3 = Constants.Text.INPUT,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextWrapped = true,
OverlayNativeInput = true,
ClearTextOnFocus = false,
ManualFocusRelease = true,
MultiLine = true,
PlaceholderText = appState.localization:Format("Feature.Chat.Label.ChatInputPlaceholder"),
PlaceholderColor3 = Constants.Text.INPUT_PLACEHOLDER,
}
local inputBarInstance
if isTablet then
inputBarInstance = Create.new "ImageLabel" {
Name = "InputBarFrame",
Size = UDim2.new(1, -68, 1, -24),
AnchorPoint = Vector2.new(0, 0),
Position = UDim2.new(0, 12, 0, 12),
BackgroundTransparency = 1,
Image = "",
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3, 3, 4, 4),
Create.new "Frame" {
Name = "InnerFrame",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = false,
textBoxInstance
},
}
else
inputBarInstance = Create.new "ImageLabel" {
Name = "InputBarFrame",
Size = UDim2.new(1, -62, 1, -10),
AnchorPoint = Vector2.new(0, 0.5),
Position = UDim2.new(0, 10, 0.5, 0),
BackgroundTransparency = 1,
Image = INPUT_FRAME_IMAGE,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3, 3, 4, 4),
Create.new "Frame" {
Name = "InnerFrame",
Size = UDim2.new(1, -24, 1, -12),
BackgroundTransparency = 1,
ClipsDescendants = false,
textBoxInstance
},
}
end
local gameButtonAnchorPoint
local gameButtonPosition
if FFlagLuaChatInputBarRefactor then
gameButtonAnchorPoint = Vector2.new(1, 0.5)
gameButtonPosition = UDim2.new(1, 0, 0.5, 0)
else
gameButtonAnchorPoint = Vector2.new(0, 0)
gameButtonPosition = UDim2.new(
1,
-RIGHT_BUTTON_WIDTH,
1,
-RIGHT_BUTTON_HEIGHT - (isTablet and GAME_ICON_BOTTOM_PADDING or 0)
)
end
self.rbx = Create.new "TextButton" {
Name = "ChatInputBar",
Text = "",
AutoButtonColor = false,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, textButtonHeight),
BorderSizePixel = 0,
Create.new "Frame" {
Name = "TopBorder",
Size = UDim2.new(1, 0, 0, 1),
BackgroundColor3 = Constants.Color.GRAY3,
BorderSizePixel = 0,
Position = UDim2.new(0, 0, 0, 0),
},
inputBarInstance,
Create.new "ImageButton" {
Name = "GameButton",
BackgroundTransparency = 1,
Size = UDim2.new(0, RIGHT_BUTTON_WIDTH, 0, RIGHT_BUTTON_HEIGHT),
AnchorPoint = gameButtonAnchorPoint,
Position = gameButtonPosition,
Visible = FFlagLuaChatShareGameToChatFromChat,
Create.new "ImageLabel" {
Name = "GameIcon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 24, 0, 24),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Image = GAME_ICON,
}
},
Create.new "ImageButton" {
Name = "SendButton",
BackgroundTransparency = 1,
Size = UDim2.new(0, 32, 0, 32),
AnchorPoint = Vector2.new(0, 1),
Position = UDim2.new(1, -42, 1, -SEND_BUTTON_BOTTOM_PADDING),
Image = SEND_BUTTON_IMAGE,
ImageColor3 = Constants.Color.GRAY3,
Visible = not FFlagLuaChatShareGameToChatFromChat,
Create.new "ImageLabel" {
Name = "Icon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 1, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Image = SEND_ICON,
}
},
}
self.rbx.TouchTap:Connect(function()
--Sink this tap so the keyboard doesn't close
end)
self.textBox = textBoxInstance
local function updateTextBoxSize()
local textBoxText = self.textBox.Text
if textBoxText:len() == 0 then
textBoxText = self.textBox.PlaceholderText
end
local textHeight, maxTextHeight, textButtonHeightUpdate = getTextButtonHeight(
textBoxText,
self.textBox.Font,
self.textBox.TextSize,
self.textBox.AbsoluteSize.X
)
self.rbx.Size = UDim2.new(1, 0, 0, textButtonHeightUpdate)
if not self.textBox:IsFocused() and textHeight > maxTextHeight then
self.textBox.Size = UDim2.new(1, 0, 0, 24 + textHeight);
self.rbx.InputBarFrame.InnerFrame.ClipsDescendants = true
else
self.textBox.Size = UDim2.new(1, 0, 1, 0);
self.rbx.InputBarFrame.InnerFrame.ClipsDescendants = false
end
end
self.textBox.Focused:Connect(function()
updateTextBoxSize()
self.textBox.Size = UDim2.new(1, 0, 1, 0);
self.blockUserChangedText = false
end)
self.textBox.FocusLost:Connect(function()
updateTextBoxSize()
if #self.textBox.Text <= 0 then
self:Reset()
end
end)
self.textBox:GetPropertyChangedSignal("Text"):Connect(function()
local text = self.textBox.Text
updateTextBoxSize()
if FFlagLuaChatShareGameToChatFromChat then
self:RightButtonVisibility(string.len(text) == 0)
end
if self:_isMessageValid(text) then
self:SetSendButtonEnabled(true)
else
self:SetSendButtonEnabled(false)
end
if not self.blockUserChangedText then
self.UserChangedText:fire()
end
end)
getInputEvent(self.rbx.SendButton):Connect(function()
self:SendMessage()
end)
if FFlagLuaChatShareGameToChatFromChat then
getInputEvent(self.rbx.GameButton):Connect(function()
self:ReportBrowseGamesButtonTappedEvent()
if self.textBox:IsFocused() then
self.textBox:ReleaseFocus()
end
appState.store:dispatch(SetRoute(Intent.BrowseGames, {}))
end)
self.rbx.GameButton.InputBegan:Connect(function()
self:SetGameButtonIcon(true)
end)
self.rbx.GameButton.InputEnded:Connect(function()
self:SetGameButtonIcon(false)
end)
end
return self
end
function ChatInputBar:_isMessageValid(text)
if FFlagLuaChatInputBarRefactor then
if #text > MAX_CHARACTER_LENGTH then
return false
end
else
if #text >= MAX_CHARACTER_LENGTH then
return false
end
end
-- Only whitespace
if text:match("^%s*$") then
return false
end
return true
end
function ChatInputBar:RightButtonVisibility(isInputBoxEmpty)
self.rbx.SendButton.Visible = not isInputBoxEmpty
self.rbx.GameButton.Visible = isInputBoxEmpty
end
function ChatInputBar:Reset()
self.blockUserChangedText = true
self.textBox.Text = ""
self.textBox:ResetKeyboardMode()
self.blockUserChangedText = false
end
function ChatInputBar:SendMessage()
local text = self.textBox.Text
if not self:_isMessageValid(text) then
return
end
self:Reset()
self.SendButtonPressed:fire(text)
end
function ChatInputBar:SetSendButtonEnabled(value)
if self.sendButtonEnabled == value then
return
end
self.sendButtonEnabled = value
local color = value and Constants.Color.BLUE_PRIMARY or Constants.Color.GRAY3
self.rbx.SendButton.ImageColor3 = color
end
function ChatInputBar:SetGameButtonIcon(isPressed)
if isPressed then
self.rbx.GameButton.GameIcon.Image = PRESSED_GAME_ICON
else
self.rbx.GameButton.GameIcon.Image = GAME_ICON
end
end
function ChatInputBar:GetHeight()
return self.rbx.Size.Y.Offset
end
function ChatInputBar:ReportBrowseGamesButtonTappedEvent()
local eventContext = "touch"
local eventName = "chooseGameToShare"
local player = PlayerService.LocalPlayer
local userId = "UNKNOWN"
if player then
userId = tostring(player.UserId)
end
local additionalArgs = {
uid = userId,
cid = self.appState.store:getState().ChatAppReducer.ActiveConversationId
}
self._analytics.EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
end
function ChatInputBar:Destruct()
self.rbx:Destroy()
end
return ChatInputBar
@@ -0,0 +1,134 @@
return function()
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local AppState = require(LuaChat.AppState)
local ChatInputBar = require(LuaChat.Components.ChatInputBar)
local SetFormFactor = require(LuaApp.Actions.SetFormFactor)
local FormFactor = require(LuaApp.Enum.FormFactor)
local FFlagLuaChatInputBarRefactor = settings():GetFFlag("LuaChatInputBarRefactor")
describe("new", function()
it("should construct a new chat input bar with no errors", function()
local appState = AppState.mock()
appState.store:dispatch(SetFormFactor(FormFactor.TABLET))
local chatInputBar_Tablet = ChatInputBar.new(appState)
expect(chatInputBar_Tablet).to.be.ok()
chatInputBar_Tablet:Destruct()
appState.store:dispatch(SetFormFactor(FormFactor.PHONE))
local chatInputBar_Phone = ChatInputBar.new(appState)
expect(chatInputBar_Phone).to.be.ok()
chatInputBar_Phone:Destruct()
end)
end)
describe("isMessageValid", function()
if FFlagLuaChatInputBarRefactor then
it("should return false if string is longer than 160 characters", function()
local appState = AppState.mock()
local chatInputBar = ChatInputBar.new(appState)
local testString_Long = string.rep("b", 200)
local isMessageValid_Long = chatInputBar:_isMessageValid(testString_Long)
local testString_160 = string.rep("b", 160)
local isMessageValid_160 = chatInputBar:_isMessageValid(testString_160)
local testString_Short = "b"
local isMessageValid_Short = chatInputBar:_isMessageValid(testString_Short)
expect(isMessageValid_Long).to.equal(false)
expect(isMessageValid_160).to.equal(true)
expect(isMessageValid_Short).to.equal(true)
end)
end
it("should return false if string is only white space characters", function()
local appState = AppState.mock()
local chatInputBar = ChatInputBar.new(appState)
local testString_Empty = ""
local isMessageValid_Empty = chatInputBar:_isMessageValid(testString_Empty)
local testString_Space = " "
local isMessageValid_Space = chatInputBar:_isMessageValid(testString_Space)
local testString_NewLines = "\n\r\n"
local isMessageValid_NewLines = chatInputBar:_isMessageValid(testString_NewLines)
local testString_Hello = "hello"
local isMessageValid_Hello = chatInputBar:_isMessageValid(testString_Hello)
expect(isMessageValid_Empty).to.equal(false)
expect(isMessageValid_Space).to.equal(false)
expect(isMessageValid_NewLines).to.equal(false)
expect(isMessageValid_Hello).to.equal(true)
end)
end)
describe("SendMessage", function()
it("should fire SendButtonPressed when invoked", function()
local appState = AppState.mock()
local chatInputBar = ChatInputBar.new(appState)
local count = 0
chatInputBar.SendButtonPressed:Connect(function()
count = count + 1
end)
chatInputBar.textBox.Text = "hello"
chatInputBar:SendMessage()
expect(count).to.equal(1)
chatInputBar.textBox.Text = "goodbye"
chatInputBar:SendMessage()
expect(count).to.equal(2)
end)
it("should not send messages when text is invalid", function()
local appState = AppState.mock()
local chatInputBar = ChatInputBar.new(appState)
local count = 0
chatInputBar.SendButtonPressed:Connect(function()
count = count + 1
end)
chatInputBar.textBox.Text = ""
chatInputBar:SendMessage()
expect(count).to.equal(0)
chatInputBar.textBox.Text = "\n\n"
chatInputBar:SendMessage()
expect(count).to.equal(0)
end)
it("should reset textBox when invoked", function()
local appState = AppState.mock()
local chatInputBar = ChatInputBar.new(appState)
chatInputBar.textBox.Text = "hello"
chatInputBar:SendMessage()
expect(chatInputBar.textBox.Text).to.equal("")
end)
end)
end
@@ -0,0 +1,46 @@
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local LoadingIndicator = require(script.Parent.LoadingIndicator)
local ChatLoadingIndicator = {}
function ChatLoadingIndicator.new(appState)
local self = {}
local indicator = LoadingIndicator.new(appState, 2)
self.super = indicator
self.rbx = Create "Frame" {
Name = "ChatLoadingIndicator",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
Create "Frame" {
Name = "Inner",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 200),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Create "UIListLayout" {
VerticalAlignment = Enum.VerticalAlignment.Center,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
},
indicator.rbx,
},
}
setmetatable(self, ChatLoadingIndicator)
return self
end
function ChatLoadingIndicator:SetVisible(visible)
self.rbx.Visible = visible
self.super:SetVisible(visible)
end
ChatLoadingIndicator.__index = ChatLoadingIndicator
return ChatLoadingIndicator
@@ -0,0 +1,63 @@
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local Text = require(LuaChat.Text)
local Constants = require(LuaChat.Constants)
local ChatTimestamp = {}
ChatTimestamp.__index = ChatTimestamp
function ChatTimestamp.new(appState, text)
local self = {}
self.rbx = Create.new "Frame" {
Name = "ChatTimestamp",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 50),
Create.new "UIPadding" {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 10),
},
Create.new "ImageLabel" {
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Color3.fromRGB(224, 224, 224),
Size = UDim2.new(0, 150, 0, 30),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Image = "rbxasset://textures/ui/LuaChat/9-slice/system-message.png",
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3, 3, 4, 4),
Create.new "UIPadding" {
PaddingTop = UDim.new(0, 4),
PaddingBottom = UDim.new(0, 4),
PaddingLeft = UDim.new(0, 6),
PaddingRight = UDim.new(0, 6),
},
Create.new "TextLabel" {
BackgroundTransparency = 1,
TextColor3 = Color3.fromRGB(128, 128, 128),
Text = text,
TextSize = Constants.Font.FONT_SIZE_14,
Font = Enum.Font.SourceSans,
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5)
},
},
}
local textBounds = Text.GetTextBounds(text, Enum.Font.SourceSans, Constants.Font.FONT_SIZE_14, Vector2.new(1000, 1000))
self.rbx.ImageLabel.Size = UDim2.new(0, textBounds.X + 12, 0, textBounds.Y + 8)
setmetatable(self, ChatTimestamp)
return self
end
return ChatTimestamp
@@ -0,0 +1,593 @@
local CoreGui = game:GetService("CoreGui")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Conversation = require(LuaChat.Models.Conversation)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local FlagSettings = require(LuaChat.FlagSettings)
local FormFactor = require(LuaApp.Enum.FormFactor)
local Signal = require(Common.Signal)
local WebApi = require(LuaChat.WebApi)
local Components = LuaChat.Components
local PlayTogetherGameIcon = require(Components.PlayTogetherGameIcon)
local ChatInputBar = require(Components.ChatInputBar)
local HeaderLoader = require(Components.HeaderLoader)
local Icebreaker = require(Components.Icebreaker)
local LoadingIndicator = require(Components.LoadingIndicator)
local MessageList = require(Components.MessageList)
local PaddedImageButton = require(Components.PaddedImageButton)
local UserTypingIndicator = require(Components.UserTypingIndicator)
local getConversationDisplayTitle = require(LuaChat.Utils.getConversationDisplayTitle)
local SetActiveConversationId = require(LuaChat.Actions.SetActiveConversationId)
local Intent = DialogInfo.Intent
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local LuaChatAssetCardsSelfTerminateConnection = settings():GetFFlag("LuaChatAssetCardsSelfTerminateConnection")
local LuaChatGroupChatIconEnabled = settings():GetFFlag("LuaChatGroupChatIconEnabled")
local LuaChatActiveConversationId = settings():GetFFlag("LuaChatActiveConversationId")
local FFlagShareGameToChatStatusAnalytics = settings():GetFFlag("ShareGameToChatStatusAnalytics")
local FFlagLuaChatIcebreaker = settings():GetFFlag("LuaChatIcebreaker")
local ConversationView = {}
ConversationView.__index = ConversationView
local LayoutOrder = {
HEADER = 10,
INITIAL_LOADING_FRAME = 20,
MESSAGE_LIST = 30,
TYPING_INDICATOR = 1000,
INPUT_BAR = 1000000,
}
local function getNewestWithNilPreviousMessageId(messages)
for id, message, _ in messages:CreateReverseIterator() do
if message.previousMessageId == nil then
return id
end
end
return messages.keys[1]
end
local function sendPreprocess(inputText)
if inputText == "/shrug" then
return "¯\\_(ツ)_/¯"
end
-- Future chat commands will go here
return inputText
end
function ConversationView.new(appState)
local self = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
else
self.connections = {}
end
self.conversationId = nil
self.appState = appState
self.lastTypingTimestamp = 0
self.BackButtonPressed = Signal.new()
self.GroupDetailsButtonPressed = Signal.new()
self.wasTouchingBottom = false
self.oldConversation = nil
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
setmetatable(self, ConversationView)
self.rbx = Create.new "TextButton" {
Name = "Conversation",
Text = "",
AutoButtonColor = false,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 0,
BackgroundColor3 = Constants.Color.GRAY6,
BorderSizePixel = 0,
}
-- Component Setup
local header = HeaderLoader.GetHeader(appState, Intent.Conversation)
header:SetDefaultSubtitle()
if appState.store:getState().FormFactor == FormFactor.PHONE then
header:SetBackButtonEnabled(true)
else
header:SetBackButtonEnabled(false)
end
self.header = header
header.rbx.Parent = self.rbx
header.rbx.LayoutOrder = LayoutOrder.HEADER
header.rbx.ZIndex = 2 -- Render on top of the conversation (which is a peer)
local groupDetailsButton
groupDetailsButton = PaddedImageButton.new(appState, "GroupDetails",
"rbxasset://textures/ui/LuaChat/icons/ic-info.png")
header:AddButton(groupDetailsButton)
groupDetailsButton.Pressed:connect(function()
self.GroupDetailsButtonPressed:fire()
end)
-- Play Together feature gating:
if self.luaChatPlayTogetherEnabled then
local playTogetherGameIcon = PlayTogetherGameIcon.new(appState, nil, PlayTogetherGameIcon.Size.SMALL)
playTogetherGameIcon.Pressed:connect(function()
self.header:ToggleGameDrawer()
self.chatInputBar.textBox:ReleaseFocus()
end)
self.playTogetherGameIcon = playTogetherGameIcon
header:AddButton(playTogetherGameIcon)
end
-- Conversation contents are now in this frame so the drawer can render
-- on top of it as necessary. Note the "HeaderSpacer" element which
-- copies the size from the real header above it.
local contents = Create.new "Frame" {
Name = "Contents",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 0,
BackgroundColor3 = Constants.Color.GRAY6,
BorderSizePixel = 0,
Create.new "UIListLayout" {
SortOrder = "LayoutOrder",
},
Create.new "Frame" {
Name = "HeaderSpacer",
Size = header.rbx.Size,
BackgroundTransparency = 0,
BorderSizePixel = 0,
},
}
contents.Parent = self.rbx
local icebreaker
if FFlagLuaChatIcebreaker then
icebreaker = Icebreaker.new(self.appState)
icebreaker.rbx.Parent = self.rbx.Contents
icebreaker.rbx.Visible = false
icebreaker.rbx.LayoutOrder = LayoutOrder.INPUT_BAR - 1
self.icebreaker = icebreaker
icebreaker:PlayFlashAnimation()
icebreaker.SendButtonPressed:connect(function(text)
local messageSentLocalTime = tick()
text = sendPreprocess(text)
if FFlagShareGameToChatStatusAnalytics then
self.appState.store:dispatch(ConversationActions.SendMessage(self.conversationId,
text, messageSentLocalTime, Constants.Decorators.ICEBREAKER))
else
self.appState.store:dispatch(ConversationActions.SendMessage(self.conversationId,
text, nil, messageSentLocalTime, Constants.Decorators.ICEBREAKER))
end
end)
end
local chatInputBar = ChatInputBar.new(appState)
--These now get initialized in Update, based on conversationId of CurrentRoute in store
self.messageList = nil
self.messageListConnection = nil
self.typingIndicator = nil
self.initialLoadingFrame = nil
chatInputBar.rbx.Parent = contents
chatInputBar.rbx.Position = UDim2.new(0, 0, 1, -42)
chatInputBar.rbx.LayoutOrder = LayoutOrder.INPUT_BAR
self.chatInputBar = chatInputBar
--Close keyboard when tapping outside of both keyboard and input area
--Per spec at: https://confluence.roblox.com/display/SOCIAL/Misc+Notes
--This is a bit of a hack, but a tap that focuses self.chatInputBar.textBox
--Can also, it seems, be interpreted as a tap of self.rbx
--So if the self.chatInputBar.textBox was just focused, I won't release focus
--on tap.
local lastFocus = nil
self.chatInputBar.textBox.Focused:Connect(function()
lastFocus = tick()
self.header:InputFocus()
end)
self.rbx.TouchTap:Connect(function()
if (not lastFocus) or (tick() - lastFocus) > .3 then
self.chatInputBar.textBox:ReleaseFocus()
end
end)
-- Component Event Setup
header.BackButtonPressed:connect(function()
self.BackButtonPressed:fire()
end)
header.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Rescale()
end)
chatInputBar.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Rescale()
end)
chatInputBar.SendButtonPressed:connect(function(text)
local messageSentLocalTime = tick()
text = sendPreprocess(text)
if FFlagShareGameToChatStatusAnalytics then
appState.store:dispatch(ConversationActions.SendMessage(self.conversationId, text, messageSentLocalTime))
else
appState.store:dispatch(ConversationActions.SendMessage(self.conversationId, text, nil, messageSentLocalTime))
end
end)
chatInputBar.UserChangedText:connect(function()
if tick() - self.lastTypingTimestamp > Constants.Text.POST_TYPING_STATUS_INTERVAL then
self.lastTypingTimestamp = tick()
WebApi.PostTypingStatus(self.conversationId, true)
end
end)
return self
end
function ConversationView:Start()
self.header:Start()
self.header:SetConnectionState(self.appState.store:getState().ConnectionState)
if self.messageList and self.messageList.isTouchingBottom then
self.appState.store:dispatch(ConversationActions.MarkConversationAsRead(self.conversationId))
end
-- initial sizing
self:Rescale()
local propertyChangeSignal = UserInputService:GetPropertyChangedSignal("OnScreenKeyboardVisible")
local keyboardVisibleConnection = propertyChangeSignal:Connect(function()
self:TweenRescale()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, keyboardVisibleConnection)
else
table.insert(self.connections, keyboardVisibleConnection)
end
propertyChangeSignal = UserInputService:GetPropertyChangedSignal("OnScreenKeyboardPosition")
local keyboardSizeConnection = propertyChangeSignal:Connect(function()
self:TweenRescale()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, keyboardSizeConnection)
else
table.insert(self.connections, keyboardSizeConnection)
end
propertyChangeSignal = self.rbx:GetPropertyChangedSignal("AbsoluteSize")
local absoluteSizeConnection = propertyChangeSignal:Connect(function()
self:TweenRescale()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, absoluteSizeConnection)
else
table.insert(self.connections, absoluteSizeConnection)
end
local statusBarTappedConnection = UserInputService.StatusBarTapped:Connect(function()
if self.appState.store:getState().ChatAppReducer.Location.current.intent ~= Intent.Conversation then
return
end
if self.messageList then
self.messageList.rbx:ScrollToTop()
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, statusBarTappedConnection)
else
table.insert(self.connections, statusBarTappedConnection)
end
self:Update(self.appState.store:getState())
end
function ConversationView:Stop()
self.chatInputBar.textBox:ReleaseFocus()
if not LuaChatAssetCardsSelfTerminateConnection then
if self.messageList then
self.messageList:DisconnectChatBubbles()
end
end
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
else
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
end
self.rbx_connections = {}
end
function ConversationView:Pause()
self.chatInputBar.textBox:ReleaseFocus()
end
function ConversationView:Resume()
if self.messageList.isTouchingBottom then
self.appState.store:dispatch(ConversationActions.MarkConversationAsRead(self.conversationId))
end
end
function ConversationView:Update(state)
self.header:SetConnectionState(state.ConnectionState)
local currentConversationId = state.ChatAppReducer.Location.current.parameters.conversationId
local conversation = state.ChatAppReducer.Conversations[currentConversationId]
if LuaChatActiveConversationId then
if currentConversationId then
local activeConversationId = tostring(currentConversationId)
if state.ChatAppReducer.ActiveConversationId ~= activeConversationId then
self.appState.store:dispatch(SetActiveConversationId(activeConversationId))
end
end
end
if not conversation then
return
end
-- The game icon might not exist:
if self.playTogetherGameIcon then
self.playTogetherGameIcon:Update(conversation)
end
if currentConversationId and currentConversationId ~= self.conversationId then
self.conversationId = currentConversationId
self.isFetchingOlderMessages = conversation.fetchingOlderMessages
self.header:SetTitle(getConversationDisplayTitle(conversation))
if self.messageList then
self.messageList:Destruct()
end
local messageList = MessageList.new(self.appState, conversation)
messageList.rbx.LayoutOrder = LayoutOrder.MESSAGE_LIST
messageList.rbx.Parent = self.rbx.Contents
messageList:ResizeCanvas()
self.messageList = messageList
if self.messageListConnection ~= nil then
self.messageListConnection:Disconnect()
end
self.messageListConnection = self.messageList.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
if self.messageList.isTouchingBottom or self.wasTouchingBottom then
self:TweenScrollToBottom()
self.wasTouchingBottom = false
end
end)
local function onRequestOlderMessages()
local conversationModel = self.appState.store:getState().ChatAppReducer.Conversations[self.conversationId]
if conversationModel == nil then
return
end
local messages = conversationModel.messages
local exclusiveMessageStartId = getNewestWithNilPreviousMessageId(messages)
if conversationModel.fetchingOlderMessages or conversationModel.fetchedOldestMessage then
return
end
self.messageList:StartLoadingMessageHistoryAnimation()
self.appState.store:dispatch(ConversationActions.GetOlderMessages(self.conversationId, exclusiveMessageStartId))
end
if self.requestOlderMessagesConnection then
self.requestOlderMessagesConnection:disconnect()
end
self.requestOlderMessagesConnection = messageList.RequestOlderMessages:connect(onRequestOlderMessages)
--Make sure this gets called at least once
onRequestOlderMessages()
if self.readAllMessagesConnection then
self.readAllMessagesConnection:disconnect()
end
self.readAllMessagesConnection = messageList.ReadAllMessages:connect(function()
self.appState.store:dispatch(ConversationActions.MarkConversationAsRead(self.conversationId))
end)
if conversation.conversationType == Conversation.Type.ONE_TO_ONE_CONVERSATION then
if self.typingIndicator then
self.typingIndicator:Destruct()
end
local typingIndicator = UserTypingIndicator.new(self.appState, conversation)
typingIndicator.rbx.LayoutOrder = LayoutOrder.TYPING_INDICATOR
typingIndicator.rbx.Parent = self.rbx.Contents
self.typingIndicator = typingIndicator
typingIndicator.Resized:connect(function()
self:Rescale()
end)
end
if self.initialLoadingFrame then
self.initialLoadingFrame:Destroy()
end
local initialLoadingFrame = Create.new "Frame" {
Name = "InitialLoadingFrame",
Size = self.messageList.rbx.Size,
Position = self.messageList.rbx.Position,
BackgroundTransparency = 1,
LayoutOrder = LayoutOrder.INITIAL_LOADING_FRAME,
Visible = false
}
initialLoadingFrame.Parent = self.rbx.Contents
self.initialLoadingFrame = initialLoadingFrame
if self.messageList.isTouchingBottom then
self.appState.store:dispatch(ConversationActions.MarkConversationAsRead(self.conversationId))
end
if self.luaChatPlayTogetherEnabled then
self.header:CreateGameDrawer(self.appState.store, self.conversationId)
end
self:Rescale()
elseif conversation == self.oldConversation then
return
end
self.oldConversation = conversation
if not conversation.fetchingOlderMessages then
self.messageList:StopLoadingMessageHistoryAnimation()
end
if conversation.initialLoadingStatus == Constants.ConversationLoadingState.LOADING then
self:StartInitialLoadingAnimation()
else
self:StopInitialLoadingAnimation()
end
self.messageList:Update(conversation)
self.header:SetTitle(getConversationDisplayTitle(conversation))
if LuaChatGroupChatIconEnabled then
if conversation.conversationType == Conversation.Type.MULTI_USER_CONVERSATION then
self.header:SetGroupChatIconVisibility(true)
else
self.header:SetGroupChatIconVisibility(false)
end
end
if self.typingIndicator then
self.typingIndicator:Update(conversation)
end
-- Only show icebreaker if there are no sent messages
if FFlagLuaChatIcebreaker then
if self.icebreaker then
if conversation.messages then
local hasNotPostedMessage = conversation.messages:Length() < 1
local isNotSendingMessage = conversation.sendingMessages:Length() < 1
self.icebreaker.rbx.Visible = hasNotPostedMessage and isNotSendingMessage
self:Rescale()
end
end
end
end
function ConversationView:GetYOffset()
local keyboardSize = 0
if UserInputService.OnScreenKeyboardVisible and self.chatInputBar.textBox:IsFocused() then
keyboardSize = self.rbx.AbsoluteSize.Y - UserInputService.OnScreenKeyboardPosition.Y
end
local offset = keyboardSize
for _, child in ipairs(self.rbx.Contents:GetChildren()) do
if child:IsA("GuiObject") and (self.messageList == nil or child ~= self.messageList.rbx)
and child ~= self.initialLoadingFrame and child.Visible then
offset = offset + child.AbsoluteSize.Y
end
end
return offset
end
function ConversationView:Rescale()
if not self.messageList then
return
end
local offset = self:GetYOffset()
local newSize = UDim2.new(1, 0, 1, -offset)
local wasTouchingBottom = self.messageList.isTouchingBottom
self.messageList.rbx.Size = newSize
if wasTouchingBottom then
self.messageList:ScrollToBottom()
end
self.initialLoadingFrame.Size = newSize
end
function ConversationView:TweenRescale()
if self.messageList == nil then
return
end
local offset = self:GetYOffset()
local newSize = UDim2.new(1, 0, 1, -offset)
self.wasTouchingBottom = self.messageList.isTouchingBottom
self.initialLoadingFrame.Size = newSize
local duration = UserInputService.OnScreenKeyboardAnimationDuration
local tweenInfo = TweenInfo.new(duration)
local propertyGoals = {
Size = newSize,
}
local tween = TweenService:Create(self.messageList.rbx, tweenInfo, propertyGoals)
tween:Play()
end
function ConversationView:TweenScrollToBottom()
local offset = self:GetYOffset()
local height = self.messageList.rbx.CanvasSize.Y.Offset - self.messageList.rbx.AbsoluteWindowSize.Y + offset
local duration = UserInputService.OnScreenKeyboardAnimationDuration
local tweenInfo = TweenInfo.new(duration)
local propertyGoals =
{
CanvasPosition = Vector2.new(0, height)
}
local tween = TweenService:Create(self.messageList.rbx, tweenInfo, propertyGoals)
tween:Play()
end
function ConversationView:StartInitialLoadingAnimation()
if not self.loadingAnimationRunning then
self.loadingAnimationRunning = true
self.messageList.rbx.Visible = false
self.initialLoadingFrame.Visible = true
local loadingIndicator = LoadingIndicator.new(self.appState, 3)
loadingIndicator.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
loadingIndicator.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
loadingIndicator.rbx.Parent = self.initialLoadingFrame
end
end
function ConversationView:StopInitialLoadingAnimation()
if self.loadingAnimationRunning then
self.loadingAnimationRunning = false
self.messageList.rbx.Visible = true
self.initialLoadingFrame.Visible = false
self.initialLoadingFrame:ClearAllChildren()
end
end
return ConversationView
@@ -0,0 +1,300 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaChat.FlagSettings)
local FormFactor = require(LuaApp.Enum.FormFactor)
local getConversationDisplayTitle = require(LuaChat.Utils.getConversationDisplayTitle)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local OrderedMap = require(LuaChat.OrderedMap)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local Message = require(LuaChat.Models.Message)
local Components = LuaChat.Components
local ConversationThumbnail = require(Components.ConversationThumbnail)
local PlayTogetherGameIcon = require(Components.PlayTogetherGameIcon)
local UseCppTextTruncation = FlagSettings.UseCppTextTruncation()
local FFlagEnableChatMessageType = settings():GetFFlag("EnableChatMessageType")
local UNREAD_COUNTER_ENABLED = false
local ConversationEntry = {}
ConversationEntry.__index = ConversationEntry
function ConversationEntry.new(appState, conversation)
local self = {
appState = appState,
}
self.conversation = nil
self.Tapped = Signal.new()
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
local activeGameIconRbx = nil
if self.luaChatPlayTogetherEnabled then
self.activeGameIcon = PlayTogetherGameIcon.new(appState, conversation,
PlayTogetherGameIcon.Size.LARGE, PlayTogetherGameIcon.Type.ACTIVE)
self.activeGameIcon.rbx.AnchorPoint = Vector2.new(1, 0)
self.activeGameIcon.rbx.Position = UDim2.new(1, -12, 0, 12)
activeGameIconRbx = self.activeGameIcon.rbx
self.activeGameIcon.Pressed:connect(function()
self.Tapped:fire()
end)
end
self.thumb = ConversationThumbnail.new(appState, conversation)
self.thumb.rbx.Size = UDim2.new(0, 48, 0, 48)
self.thumb.rbx.Position = UDim2.new(0, 12, 0, 12)
self.content = Create.new "TextLabel" {
Name = "Content",
AnchorPoint = Vector2.new(0, 1),
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
TextTruncate = UseCppTextTruncation and Enum.TextTruncate.AtEnd or nil,
BackgroundTransparency = 1,
Size = UDim2.new(1, -58, 0, Constants.Font.FONT_SIZE_16),
Position = UDim2.new(0, 0, 1, -14),
TextSize = Constants.Font.FONT_SIZE_16,
Font = Enum.Font.SourceSans,
TextColor3 = Constants.Color.GRAY2,
ClipsDescendants = true,
}
self.lastMessageTime = Create.new "TextLabel" {
Name = "LastMessageTime",
AnchorPoint = Vector2.new(1,0),
TextXAlignment = Enum.TextXAlignment.Right,
TextYAlignment = Enum.TextYAlignment.Top,
BackgroundTransparency = 1,
Size = UDim2.new(0, 50, 0, Constants.Font.FONT_SIZE_14),
Position = UDim2.new(1, -12, 0, Constants.Font.FONT_SIZE_18_POS_OFFSET + 20),
TextSize = Constants.Font.FONT_SIZE_14,
Font = Enum.Font.SourceSans,
TextColor3 = Constants.Color.GRAY2,
}
self.title = Create.new "TextLabel" {
Name = "Title",
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextTruncate = UseCppTextTruncation and Enum.TextTruncate.AtEnd or nil,
BackgroundTransparency = 1,
Size = UDim2.new(1, -90, 0, Constants.Font.FONT_SIZE_18),
Position = UDim2.new(0, 0, 0, Constants.Font.FONT_SIZE_18_POS_OFFSET + 20),
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
ClipsDescendants = true,
}
self.unreadMessageIndicator = Create.new "ImageLabel" {
Name = "UnreadMessageCount",
AnchorPoint = Vector2.new(1,1),
BackgroundTransparency = 1,
BackgroundColor3 = Color3.fromRGB(226, 35, 26),
Size = UDim2.new(0, 24, 0, 16),
Position = UDim2.new(1, -12, 1, -14),
Image = "rbxasset://textures/ui/LuaChat/9-slice/new-message-indicator.png",
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(8, 8, 10, 10),
Visible = false,
Create.new "TextLabel" {
Name = "Label",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_12,
Font = Enum.Font.SourceSans,
Text = "1",
Position = UDim2.new(0, 0, 0, -1),
}
}
self.rbx = Create.new "TextButton" {
Name = "ConversationEntry",
Size = UDim2.new(1, 0, 0, 72),
BackgroundColor3 = Constants.Color.WHITE,
AutoButtonColor = false,
BorderSizePixel = 0,
Text = "",
Font = Enum.Font.SourceSans,
self.thumb.rbx,
Create.new "Frame" {
Name = "Body",
BackgroundTransparency = 1,
Size = UDim2.new(1, -72, 1, 0),
Position = UDim2.new(0, 72, 0, 0),
Create.new "Frame" {
Name = "Inner",
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 0, 0),
Size = UDim2.new(1, 0, 1, 0),
self.content,
self.lastMessageTime,
self.title,
self.unreadMessageIndicator,
activeGameIconRbx,
},
Create.new "Frame" {
Name = "BottomBorder",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, -1),
},
},
Create.new "Frame" {
Name = "ImageContainer",
BackgroundTransparency = 1,
Size = UDim2.new(0, 72, 0, 72),
Position = UDim2.new(0, 0, 0, 0),
},
}
getInputEvent(self.rbx):Connect(function()
self.Tapped:fire()
end)
if self.thumb.clicked then
self.thumb.clicked:connect(function()
self.Tapped:fire()
end)
end
setmetatable(self, ConversationEntry)
self:Update(conversation)
self.title:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self.title.Text = self.conversation.title
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.title, "...")
end
end)
if self.activeGameIcon then
self:AdjustForGameIcon(self.activeGameIcon.rbx.Visible)
self.activeGameIcon.rbx:GetPropertyChangedSignal("Visible"):Connect(function()
self:AdjustForGameIcon(self.activeGameIcon.rbx.Visible)
end)
end
return self
end
function ConversationEntry:AdjustForGameIcon(isIconVisible)
if isIconVisible then
self.lastMessageTime.Visible = false
self.content.Size = UDim2.new(1, -72, 0, 18)
else
self.lastMessageTime.Visible = true
self.content.Size = UDim2.new(1, -58, 0, 18)
end
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.content, "...")
end
end
function ConversationEntry:SetBackgroundColor(color3)
self.rbx.BackgroundColor3 = color3
if self.thumb.rbx:FindFirstChild("Mask") then
self.thumb.rbx.Mask.ImageColor3 = color3
elseif self.thumb.rbx:FindFirstChild("Overlay") then
self.thumb.rbx.Overlay.ImageColor3 = color3
end
end
function ConversationEntry:Update(conversation)
local state = self.appState.store:getState()
if state.FormFactor == FormFactor.TABLET then
local currentLocationParameters = state.ChatAppReducer.Location.current.parameters
local currentConversationId = currentLocationParameters and currentLocationParameters.conversationId or nil
if currentConversationId == conversation.id then
self:SetBackgroundColor(Constants.Color.GRAY5)
else
self:SetBackgroundColor(Constants.Color.WHITE)
end
end
if conversation == self.conversation then
return
end
local oldConversationState = self.conversation
self.conversation = conversation
local lastMessageId = conversation.messages.keys[#conversation.messages.keys]
local lastMessage = conversation.messages.values[lastMessageId]
local lastMessageText = ""
local lastMessageTime = ""
if lastMessage then
if FFlagEnableChatMessageType then
if lastMessage.messageType == Message.MessageTypes.PlainText then
lastMessageText = Text.RightTrim(lastMessage.content)
else
lastMessageText = ""
end
else
lastMessageText = Text.RightTrim(lastMessage.content)
end
lastMessageText = lastMessageText:gsub("%s", " ")
lastMessageTime = conversation.lastUpdated:GetShortRelativeTime()
elseif conversation.lastUpdated then
lastMessageTime = conversation.lastUpdated:GetShortRelativeTime()
end
local textColor = conversation.hasUnreadMessages and Constants.Color.GRAY1 or Constants.Color.GRAY2
self.content.Font = conversation.hasUnreadMessages and Constants.Font.TITLE or Enum.Font.SourceSans
self.content.Text = lastMessageText
self.content.TextColor3 = textColor
self.lastMessageTime.TextColor3 = textColor
self.lastMessageTime.Text = lastMessageTime
self.title.Text = getConversationDisplayTitle(conversation)
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.title, "...")
end
self.thumb:Update(conversation)
if self.activeGameIcon then
self.activeGameIcon:Update(conversation)
self:AdjustForGameIcon(self.activeGameIcon.rbx.Visible)
end
if UNREAD_COUNTER_ENABLED then
self.unreadMessageIndicator.Visible = conversation.hasUnreadMessages
if conversation.hasUnreadMessages and
(not oldConversationState or oldConversationState.messages ~= conversation.messages) then
local numUnread = 0
for _, message in OrderedMap.CreateIterator(conversation.messages) do
if not message.read then
numUnread = numUnread + 1
end
end
self.unreadMessageIndicator.Label.Text = tostring(numUnread)
end
end
end
return ConversationEntry
@@ -0,0 +1,58 @@
return function()
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaChat = Modules.LuaChat
local MessageModel = require(LuaChat.Models.Message)
local ConversationModel = require(LuaChat.Models.Conversation)
local ConversationEntry = require(LuaChat.Components.ConversationEntry)
local AppState = require(LuaChat.AppState)
local OrderedMap = require(LuaChat.OrderedMap)
local FFlagEnableChatMessageType = settings():GetFFlag("EnableChatMessageType")
describe("Conversation entry text", function()
local function createConvEntry (appState, messageType)
local message = MessageModel.mock({
content = "testing",
messageType = messageType
})
local conversationModel = ConversationModel.mock()
conversationModel.messages = OrderedMap.Insert(conversationModel.messages, unpack({ message }))
expect(conversationModel).to.be.ok()
return ConversationEntry.new(appState, conversationModel)
end
it("should create ConversationEntry when presented with raw text", function()
local appState = AppState.mock()
local convEntry
if FFlagEnableChatMessageType then
convEntry = createConvEntry(appState, MessageModel.MessageTypes.PlainText)
else
convEntry = createConvEntry(appState, "")
end
expect(convEntry).to.be.ok()
expect(convEntry.content).to.be.ok()
expect(convEntry.content.Text).to.be.ok()
expect(convEntry.content.Text).to.equal("testing")
end)
if FFlagEnableChatMessageType then
it("should create ConversationEntry with placeholder text if unknown message type is used", function()
local appState = AppState.mock()
local convEntry = createConvEntry(appState, "SomeUnknownMessageTypeThatWillNeverExistInProduction")
expect(convEntry).to.be.ok()
expect(convEntry.content).to.be.ok()
expect(convEntry.content.Text).to.be.ok()
expect(convEntry.content.Text).to.equal("")
end)
end
end)
end
@@ -0,0 +1,452 @@
local CoreGui = game:GetService("CoreGui")
local GuiService = game:GetService("GuiService")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local CorePackages = game:GetService("CorePackages")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local LuaAppConstants = require(LuaApp.Constants)
local Signal = require(Common.Signal)
local NotificationType = require(LuaApp.Enum.NotificationType)
local Components = LuaChat.Components
local ChatDisabledIndicator = require(Components.ChatDisabledIndicator)
local ChatLoadingIndicator = require(Components.ChatLoadingIndicator)
local ConversationList = require(Components.ConversationList)
local ConversationSearchBox = require(Components.ConversationSearchBox)
local ConversationEntry = require(Components.ConversationEntry)
local HeaderLoader = require(Components.HeaderLoader)
local NoFriendsIndicator = require(Components.NoFriendsIndicator)
local PaddedImageButton = require(Components.PaddedImageButton)
local TokenRefreshComponent = require(LuaApp.Components.TokenRefreshComponent)
local ApiFetchSortTokens = require(LuaApp.Thunks.ApiFetchSortTokens)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local GetFriendCount = require(LuaChat.Actions.GetFriendCount)
local SetActiveConversationId = require(LuaChat.Actions.SetActiveConversationId)
local SetAppLoaded = require(LuaChat.Actions.SetAppLoaded)
local Roact = require(Common.Roact)
local RoactRodux = require(Common.RoactRodux)
local appStageLoaded = require(LuaApp.Analytics.Events.appStageLoaded)
local FetchChatData = require(LuaApp.Thunks.FetchChatData)
local RetrievalStatus = require(CorePackages.AppTempCommon.LuaApp.Enum.RetrievalStatus)
local CREATE_CHAT_IMAGE = "rbxasset://textures/ui/LuaChat/icons/ic-createchat1-24x24.png"
local Intent = DialogInfo.Intent
local ConversationHub = {}
ConversationHub.__index = ConversationHub
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local LuaChatNotificationButtonEnabled = settings():GetFFlag("LuaChatNotificationButtonEnabled")
local LuaChatShareGameToChatFromChatV2 = settings():GetFFlag("LuaChatShareGameToChatFromChatV2")
local LuaChatActiveConversationId = settings():GetFFlag("LuaChatActiveConversationId")
local LuaChatCheckIsChatEnabled = settings():GetFFlag("LuaChatCheckIsChatEnabled")
local FFlagLuaChatCheckWasUsedRecently = settings():GetFFlag("LuaChatCheckWasUsedRecently")
local FetchChatSettings
local FetchChatEnabled
if FFlagLuaChatCheckWasUsedRecently then
FetchChatSettings = require(LuaChat.Actions.FetchChatSettings)
else
FetchChatEnabled = require(LuaChat.Actions.FetchChatEnabled)
end
local function requestOlderConversations(appState)
-- Don't fetch older conversations if the oldest conversation has already been fetched.
if appState.store:getState().ChatAppReducer.ConversationsAsync.oldestConversationIsFetched then
return
end
-- Don't fetch older conversations if the oldest conversation is fetched.
if appState.store:getState().ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
return
end
-- Ask for new conversations
local convoCount = 0
for _, _ in pairs(appState.store:getState().ChatAppReducer.Conversations) do
convoCount = convoCount + 1
end
local pageSize = Constants.PageSize.GET_CONVERSATIONS
local currentPage = math.floor(convoCount / pageSize)
spawn(function()
appState.store:dispatch(ConversationActions.GetLocalUserConversationsAsync(currentPage + 1, pageSize))
end)
end
local function refreshChatData(appState)
local function refreshChatDataImpl()
appState.store:dispatch(FetchChatData(function(chatEnabled)
if chatEnabled and LuaChatShareGameToChatFromChatV2 then
appState.store:dispatch(ApiFetchSortTokens(appState.request, LuaAppConstants.GameSortGroups.ChatGames))
end
end))
end
if FFlagLuaChatCheckWasUsedRecently then
local chatSettingsRetrievalStatus = appState.store:getState().ChatAppReducer.ChatSettings.retrievalStatus
if chatSettingsRetrievalStatus ~= RetrievalStatus.Fetching then
refreshChatDataImpl()
end
else
spawn(refreshChatDataImpl)
end
end
function ConversationHub.new(appState)
local self = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
else
self.connections = {}
end
setmetatable(self, ConversationHub)
if LuaChatCheckIsChatEnabled then
local state = appState.store:getState()
-- Only refresh here if we're not loaded:
if not state.ChatAppReducer.AppLoaded then
refreshChatData(appState)
else
local chatEnabled = FFlagLuaChatCheckWasUsedRecently and
state.ChatAppReducer.ChatSettings.chatEnabled or state.ChatAppReducer.ChatEnabled
if chatEnabled and LuaChatShareGameToChatFromChatV2 then
appState.store:dispatch(ApiFetchSortTokens(appState.request, LuaAppConstants.GameSortGroups.ChatGames))
end
end
else
spawn(function()
if FFlagLuaChatCheckWasUsedRecently then
appState.store:dispatch(FetchChatSettings())
else
appState.store:dispatch(FetchChatEnabled())
end
appState.store:dispatch(ConversationActions.GetUnreadConversationCountAsync())
appState.store:dispatch(GetFriendCount())
appState.store:dispatch(
ConversationActions.GetLocalUserConversationsAsync(1, Constants.PageSize.GET_CONVERSATIONS)
):andThen(function()
appState.store:dispatch(SetAppLoaded(true))
end)
if LuaChatShareGameToChatFromChatV2 then
appState.store:dispatch(ApiFetchSortTokens(appState.request, LuaAppConstants.GameSortGroups.ChatGames))
end
end)
end
self.appState = appState
self._analytics = appState.analytics
self.rbx = Create.new "Frame" {
Name = "ConversationHub",
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.WHITE,
BorderSizePixel = 0,
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
}
}
self.ConversationTapped = Signal.new()
self.CreateChatButtonPressed = Signal.new()
self.isSearchOpen = false
local header = HeaderLoader.GetHeader(appState, Intent.ConversationHub)
header:SetTitle(appState.localization:Format("CommonUI.Features.Label.Chat"))
header:SetDefaultSubtitle()
header.rbx.Parent = self.rbx
header.rbx.LayoutOrder = 0
self.header = header
local createChatButton = PaddedImageButton.new(self.appState, "CreateChat", CREATE_CHAT_IMAGE)
createChatButton:SetVisible(false)
createChatButton.Pressed:connect(function()
self.CreateChatButtonPressed:fire()
end)
self.createChatButton = createChatButton
local searchConversationsButton = PaddedImageButton.new(self.appState,
"SearchConversations", "rbxasset://textures/ui/LuaChat/icons/ic-search.png")
header:AddButton(searchConversationsButton)
header:AddButton(createChatButton)
if LuaChatNotificationButtonEnabled then
local notificationButton = PaddedImageButton.new(self.appState, "Notification",
"rbxasset://textures/Icon_Stream_Off.png")
notificationButton.Pressed:connect(function()
GuiService:BroadcastNotification("", NotificationType.VIEW_NOTIFICATIONS)
end)
header:AddButton(notificationButton)
end
local searchHeader = HeaderLoader.GetHeader(appState, Intent.ConversationHub)
searchHeader:SetTitle("")
searchHeader:SetSubtitle("")
searchHeader.rbx.LayoutOrder = 1
local conversationSearchBox = ConversationSearchBox.new(self.appState)
searchHeader:AddContent(conversationSearchBox)
local noFriendsIndicator = NoFriendsIndicator.new(appState)
self.noFriendsIndicator = noFriendsIndicator
noFriendsIndicator.rbx.Size = UDim2.new(1, 0, 1, -header.rbx.Size.Y.Offset)
noFriendsIndicator.rbx.Parent = self.rbx
noFriendsIndicator.rbx.LayoutOrder = 2
local chatDisabledIndicator = ChatDisabledIndicator.new(appState)
self.chatDisabledIndicator = chatDisabledIndicator
chatDisabledIndicator.rbx.Size = UDim2.new(1, 0, 1, -header.rbx.Size.Y.Offset)
chatDisabledIndicator.rbx.Parent = self.rbx
chatDisabledIndicator.rbx.LayoutOrder = 2
local chatLoadingIndicator = ChatLoadingIndicator.new(appState)
self.chatLoadingIndicator = chatLoadingIndicator
chatLoadingIndicator.rbx.Size = UDim2.new(1, 0, 1, -header.rbx.Size.Y.Offset)
chatLoadingIndicator.rbx.Parent = self.rbx
chatLoadingIndicator.rbx.LayoutOrder = 2
chatDisabledIndicator.openPrivacySettings:connect(function()
GuiService:BroadcastNotification("", NotificationType.PRIVACY_SETTINGS)
end)
local list = ConversationList.new(appState, appState.store:getState().ChatAppReducer.Conversations, ConversationEntry)
self.list = list
list.rbx.Size = UDim2.new(1, 0, 1, -header.rbx.Size.Y.Offset)
list.rbx.Parent = self.rbx
list.rbx.LayoutOrder = 2
list.ConversationTapped:connect(function(convoId)
conversationSearchBox:Cancel()
if not LuaChatActiveConversationId then
appState.store:dispatch(SetActiveConversationId(convoId))
end
self.ConversationTapped:fire(convoId)
end)
list.RequestOlderConversations:connect(function()
requestOlderConversations(appState)
end)
searchConversationsButton.Pressed:connect(function()
self.rbx.Position = UDim2.new(0, 0, 0, -header.rbx.AbsoluteSize.Y)
searchHeader.rbx.Parent = self.rbx
self.rbx.BackgroundColor3 = Constants.Color.GRAY5
list:SetFilterPredicate(conversationSearchBox.SearchFilterPredicate)
conversationSearchBox.rbx.SearchBoxContainer.SearchBoxBackground.Search:CaptureFocus()
self.isSearchOpen = true
end)
conversationSearchBox.SearchChanged:connect(function()
list:SetFilterPredicate(conversationSearchBox.SearchFilterPredicate)
self:getOlderConversationsForSearchIfNecessary()
end)
conversationSearchBox.Closed:connect(function()
searchHeader.rbx.Parent = nil
self.rbx.Position = UDim2.new(0, 0, 0, 0)
self.rbx.BackgroundColor3 = Constants.Color.WHITE
list:SetFilterPredicate(nil)
self.isSearchOpen = false
end)
if LuaChatShareGameToChatFromChatV2 then
self.tokenRefreshComponent = Roact.mount(Roact.createElement(RoactRodux.StoreProvider, {
store = appState.store,
}, {
TokenRefreshComponent = Roact.createElement(TokenRefreshComponent, {
sortToRefresh = LuaAppConstants.GameSortGroups.ChatGames,
}),
}), self.rbx, "TokenRefreshComponent")
end
appState.store.changed:connect(function(state, oldState)
self:Update(state, oldState)
if state.ChatAppReducer.Conversations ~= oldState.ChatAppReducer.Conversations
or state.ChatAppReducer.Location.current ~= oldState.ChatAppReducer.Location.current then
list:Update(state, oldState)
end
end)
local state = appState.store:getState()
self:Update(state, state)
local appRoutes = state.Navigation.history
local currentRoute = appRoutes[#appRoutes]
local currentSection = currentRoute[1]
appStageLoaded(self._analytics.EventStream, currentSection.name, "chatRender")
return self
end
function ConversationHub:Start()
local inputServiceConnection = UserInputService:GetPropertyChangedSignal('OnScreenKeyboardVisible'):Connect(function()
self:TweenRescale()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, inputServiceConnection)
else
table.insert(self.connections, inputServiceConnection)
end
local statusBarTappedConnection = UserInputService.StatusBarTapped:Connect(function()
if self.appState.store:getState().ChatAppReducer.Location.current.intent ~= Intent.ConversationHub then
return
end
self.list.rbx:ScrollToTop()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, statusBarTappedConnection)
else
table.insert(self.connections, statusBarTappedConnection)
end
end
function ConversationHub:Stop()
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
else
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
end
if LuaChatShareGameToChatFromChatV2 then
Roact.unmount(self.tokenRefreshComponent)
end
end
function ConversationHub:Update(state, oldState)
self.header:SetConnectionState(state.ConnectionState)
local conversations = state.ChatAppReducer.Conversations
local appLoaded = state.ChatAppReducer.AppLoaded
local haveConversations = next(conversations) ~= nil
local chatEnabled = FFlagLuaChatCheckWasUsedRecently and
state.ChatAppReducer.ChatSettings.chatEnabled or state.ChatAppReducer.ChatEnabled
if chatEnabled then
self.chatDisabledIndicator.rbx.Visible = false
local oldChatEnabled = FFlagLuaChatCheckWasUsedRecently and
oldState.ChatAppReducer.ChatSettings.chatEnabled or oldState.ChatAppReducer.ChatEnabled
if chatEnabled ~= oldChatEnabled then
if LuaChatCheckIsChatEnabled then
refreshChatData(self.appState)
else
spawn(function()
self.appState.store:dispatch(
ConversationActions.GetLocalUserConversationsAsync(1, Constants.PageSize.GET_CONVERSATIONS)
)
end)
end
end
else
self.chatDisabledIndicator.rbx.Visible = true
self.list.rbx.Visible = false
self.noFriendsIndicator.rbx.Visible = false
self.chatLoadingIndicator:SetVisible(false)
return
end
if appLoaded then
self.chatLoadingIndicator:SetVisible(false)
else
self.chatLoadingIndicator:SetVisible(true)
self.list.rbx.Visible = false
self.noFriendsIndicator.rbx.Visible = false
return
end
if haveConversations then
self.list.rbx.Visible = true
self.noFriendsIndicator.rbx.Visible = false
else
self.list.rbx.Visible = false
self.noFriendsIndicator.rbx.Visible = true
end
if state.FriendCount < Constants.MIN_PARTICIPANT_COUNT then
self.createChatButton:SetVisible(false)
else
self.createChatButton:SetVisible(true)
end
if state.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching
~= oldState.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
self.list:Update(state, oldState)
self:getOlderConversationsForSearchIfNecessary()
end
end
function ConversationHub:getOlderConversationsForSearchIfNecessary(appState)
-- To Check:
-- 1) Search is open
-- 2) Not have loaded all oldest conversations
-- 3) Not currently getting conversations
-- 4) Has enough items to show
-- Note that we already try to load more conversations if we scroll down to the bottom of the list
local state = self.appState.store:getState()
if not self.isSearchOpen
or state.ChatAppReducer.ConversationsAsync.oldestConversationIsFetched
or state.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
return
end
if self.list.rbx.CanvasSize.Y.Offset > self.list.rbx.AbsoluteSize.Y then
return
end
requestOlderConversations(self.appState)
end
function ConversationHub:TweenRescale()
local keyboardSize = 0
if UserInputService.OnScreenKeyboardVisible then
keyboardSize = self.rbx.AbsoluteSize.Y - UserInputService.OnScreenKeyboardPosition.Y
end
local newSize = UDim2.new(1, 0, 1, -(self.header.rbx.Size.Y.Offset + keyboardSize))
local duration = UserInputService.OnScreenKeyboardAnimationDuration
local tweenInfo = TweenInfo.new(duration)
local propertyGoals = {
Size = newSize
}
local tween = TweenService:Create(self.list.rbx, tweenInfo, propertyGoals)
tween:Play()
end
return ConversationHub
@@ -0,0 +1,418 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaChat.FlagSettings)
local PlayTogetherActions = require(LuaChat.Actions.PlayTogetherActions)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local LoadingIndicator = require(Components.LoadingIndicator)
local getConversationDisplayTitle = require(LuaChat.Utils.getConversationDisplayTitle)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local ConversationList = {}
local function conversationSortPredicate(a, b)
--For conversations that faked based on friend relations,
--for now there is no meaningful lastUpdated value to give them,
--and this property is set to nil, so the sort predicate needs to
--be able to handle that.
if a.lastUpdated ~= nil and b.lastUpdated ~= nil then
return a.lastUpdated:GetUnixTimestamp() > b.lastUpdated:GetUnixTimestamp()
elseif a.lastUpdated ~= nil then
return true
elseif b.lastUpdated ~= nil then
return false
else
return getConversationDisplayTitle(a) < getConversationDisplayTitle(b)
end
end
local function conversationEntrySortPredicate(a, b)
--For conversations that faked based on friend relations,
--for now there is no meaningful lastUpdated value to give them,
--and this property is set to nil, so the sort predicate needs to
--be able to handle that.
if a.conversation.lastUpdated ~= nil and b.conversation.lastUpdated ~= nil then
return a.conversation.lastUpdated:GetUnixTimestamp() > b.conversation.lastUpdated:GetUnixTimestamp()
elseif a.conversation.lastUpdated ~= nil then
return true
elseif b.conversation.lastUpdated ~= nil then
return false
else
return getConversationDisplayTitle(a.conversation) < getConversationDisplayTitle(b.conversation)
end
end
function ConversationList.new(appState, conversations, entryCard)
local self = {}
self.appState = appState
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.connections = {}
self.conversations = {}
self.conversationEntries = {}
self.conversationUsers = {}
self.isTouchingBottom = false
self.RequestOlderConversations = Signal.new()
self.noSearchResultsFound = false
self.sortWithConversationEntry = false
self.filterPredicate = nil
self.ConversationTapped = Signal.new()
self.lastTappedConversationEntry = nil
self._oldState = nil
self.entryCard = entryCard
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
self.rbx = Create.new "ScrollingFrame" {
Name = "ConversationList",
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 5,
ElasticBehavior = "Always",
ScrollingDirection = "Y",
BottomImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
MidImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
TopImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder
}
}
self.convoLoadingIndicatorFrame = Create.new "Frame" {
Name = "LoadingIndicatorFrame",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 72),
Visible = false
}
self.convoLoadingIndicatorFrame.Parent = self.rbx
local canvasSizeConnection = self.rbx:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
local canvasSizeYOffset = self.rbx.CanvasSize.Y.Offset
if self.convoLoadingIndicatorFrame.Visible then
canvasSizeYOffset = canvasSizeYOffset - self.convoLoadingIndicatorFrame.Size.Y.Offset
end
if not self.isTouchingBottom and
self.rbx.CanvasPosition.Y + self.rbx.AbsoluteSize.Y >= canvasSizeYOffset then
self.isTouchingBottom = true
self.RequestOlderConversations:fire()
elseif self.rbx.CanvasPosition.Y + self.rbx.AbsoluteSize.Y < canvasSizeYOffset then
self.isTouchingBottom = false
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, canvasSizeConnection)
else
table.insert(self.connections, canvasSizeConnection)
end
self.noResultsFrame = Create.new "Frame" {
Name = "NoResultsFrame",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 72),
Visible = false,
Create.new "TextLabel" {
Name = "Content",
AnchorPoint = Vector2.new(0, 1),
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
Font = Enum.Font.SourceSans,
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 18),
Position = UDim2.new(0, 0, 1, -18),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY3,
Text = appState.localization:Format("Feature.GameLeaderboard.Label.NoResults"),
}
}
self.noResultsFrame.Parent = self.rbx
local appStateConnection = appState.store.changed:connect(function(state, oldState)
if not oldState.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching
and state.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
self:StartFetchingConversationsAnimation()
elseif oldState.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching
and not state.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
self:StopFetchingConversationsAnimation()
end
self:CheckToShowNoSearchResults()
end)
table.insert(self.connections, appStateConnection)
setmetatable(self, ConversationList)
if conversations then
self:Update(self.appState.store:getState(), nil)
end
return self
end
function ConversationList:Update(current, previous)
local users = current.Users
local conversations = current.ChatAppReducer.Conversations
for _, conversation in pairs(conversations) do
local existing = self.conversations[conversation.id]
local existingEntry = self.conversationEntries[conversation.id]
local userCache = self.conversationUsers[conversation.id]
local doUpdate = false
if previous == nil then
doUpdate = true
elseif conversation ~= existing
or current.ChatAppReducer.Location.current ~= previous.ChatAppReducer.Location.current then
doUpdate = true
else
if userCache then
for _, id in ipairs(conversation.participants) do
if userCache[id] ~= users[id] then
doUpdate = true
break
end
end
else
doUpdate = true
end
end
if not userCache then
userCache = {}
self.conversationUsers[conversation.id] = userCache
end
if doUpdate then
if existingEntry then
existingEntry:Update(conversation)
else
local entry = self.entryCard.new(self.appState, conversation)
entry.rbx.Parent = self.rbx
local tappedConnection = entry.Tapped:connect(function()
self.ConversationTapped:fire(conversation.id)
end)
table.insert(self.connections, tappedConnection)
self.conversationEntries[conversation.id] = entry
end
self.conversations[conversation.id] = conversation
-- TODO: May not correctly handle users leaving?
for _, id in ipairs(conversation.participants) do
userCache[id] = users[id]
end
end
end
local toDelete = {}
for _, conversation in pairs(self.conversations) do
local hasBeenRemoved = conversations[conversation.id] == nil
if hasBeenRemoved then
table.insert(toDelete, conversation.id)
end
end
for _, id in ipairs(toDelete) do
local entry = self.conversationEntries[id]
entry.rbx:Destroy()
self.conversationEntries[id] = nil
self.conversations[id] = nil
end
self:Filter()
self:Sort()
self:ResizeCanvas()
if self.luaChatPlayTogetherEnabled then
self:FetchMostRecentlyPlayedGames()
end
self._oldState = current
end
function ConversationList:FetchMostRecentlyPlayedGames()
local state = self.appState.store:getState()
if state.ChatAppReducer.PlayTogetherAsync.fetchedMostRecentlyPlayedGames then
self:GetMostRecentlyPlayedPlayableGame()
return
end
if self.luaChatPlayTogetherEnabled then
self.appState.store:dispatch(PlayTogetherActions.GetMostRecentlyPlayedGames())
end
end
function ConversationList:GetMostRecentlyPlayedPlayableGame()
local state = self.appState.store:getState()
if state.ChatAppReducer.MostRecentlyPlayedGames.setPlayableGame then
return
end
self.appState.store:dispatch(PlayTogetherActions.GetMostRecentlyPlayedPlayableGame())
end
function ConversationList:SetFilterPredicate(filterPredicate)
self.filterPredicate = filterPredicate
local state = self.appState.store:getState()
self:Update(state, self._oldState or state)
end
function ConversationList:Filter()
local conversationCount = 0
local visibleCount = 0
for _, conversationEntry in pairs(self.conversationEntries) do
local visible
local conversation = conversationEntry.conversation
local filterPredicate = self.filterPredicate
if filterPredicate and conversation then
visible = filterPredicate(getConversationDisplayTitle(conversation))
else
visible = true
end
conversationEntry.rbx.Visible = visible
conversationCount = conversationCount + 1
if visible then
visibleCount = visibleCount + 1
end
end
self.noSearchResultsFound = ((conversationCount > 0) and (visibleCount == 0))
self:CheckToShowNoSearchResults()
end
function ConversationList:SetSortWithConversationEntry(value)
self.sortWithConversationEntry = value
end
function ConversationList:Sort()
if self.sortWithConversationEntry then
self:SortWithConversationEntry()
else
self:SortWithConversation()
end
end
function ConversationList:SortWithConversation()
local sorted = {}
for _, conversation in pairs(self.conversations) do
table.insert(sorted, conversation)
end
table.sort(sorted, conversationSortPredicate)
for key, conversation in ipairs(sorted) do
local entry = self.conversationEntries[conversation.id]
entry.rbx.LayoutOrder = key
end
self.convoLoadingIndicatorFrame.LayoutOrder = #sorted + 1
end
function ConversationList:SortWithConversationEntry()
local sorted = {}
for _, entry in pairs(self.conversationEntries) do
table.insert(sorted, entry)
end
table.sort(sorted, conversationEntrySortPredicate)
for key, conversationEntry in ipairs(sorted) do
local entry = self.conversationEntries[conversationEntry.conversation.id]
entry.rbx.LayoutOrder = key
end
self.convoLoadingIndicatorFrame.LayoutOrder = #sorted + 1
end
function ConversationList:ResizeCanvas()
local height = 0
for _, entry in pairs(self.conversationEntries) do
if entry.rbx.Visible then
height = height + entry.rbx.AbsoluteSize.Y
end
end
if self.convoLoadingIndicatorFrame.Visible then
height = height + self.convoLoadingIndicatorFrame.AbsoluteSize.Y
end
self.rbx.CanvasSize = UDim2.new(1, 0, 0, height)
end
function ConversationList:StartFetchingConversationsAnimation()
if not self.currentFetchConvoIndicator then
local loadingIndicator = LoadingIndicator.new(self.appState, 1)
loadingIndicator.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
loadingIndicator.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
loadingIndicator.rbx.Parent = self.convoLoadingIndicatorFrame
self.currentFetchConvoIndicator = loadingIndicator
self.convoLoadingIndicatorFrame.Visible = true
self:ResizeCanvas()
self:Sort()
end
end
function ConversationList:StopFetchingConversationsAnimation()
if self.currentFetchConvoIndicator then
self.currentFetchConvoIndicator:Destroy()
self.currentFetchConvoIndicator = nil
self.convoLoadingIndicatorFrame.Visible = false
self:ResizeCanvas()
end
end
function ConversationList:CheckToShowNoSearchResults()
local visible = false
if not self.convoLoadingIndicatorFrame.Visible then
visible = self.noSearchResultsFound
end
self.noResultsFrame.Visible = visible
end
function ConversationList:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
for _, entry in pairs(self.conversationEntries) do
entry:Destruct()
end
self.conversations = {}
self.conversationEntries = {}
self.conversationUsers = {}
self.rbx:Destroy()
end
ConversationList.__index = ConversationList
return ConversationList
@@ -0,0 +1,169 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local FFlagTextBoxOverrideManualFocusRelease = settings():GetFFlag("TextBoxOverrideManualFocusRelease")
local CANCEL_BUTTON_PADDING = 16
local ConversationSearchBox = {}
function ConversationSearchBox.new(appState)
local self = {}
setmetatable(self, {__index = ConversationSearchBox})
self.rbx = Create.new"Frame"
{
Name = "ConversationSearchBox",
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 1, 0),
Create.new "UIListLayout"
{
SortOrder = "LayoutOrder",
FillDirection = "Horizontal",
HorizontalAlignment = "Right",
},
}
local cancelButton = Create.new"TextButton"
{
Name = "Cancel",
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.WHITE,
Font = Enum.Font.SourceSans,
Text = appState.localization:Format("Feature.Chat.Action.Cancel"),
TextXAlignment = Enum.TextXAlignment.Center,
LayoutOrder = 1,
}
cancelButton.Size = UDim2.new(0,
Text.GetTextWidth(cancelButton.Text, cancelButton.Font, cancelButton.TextSize) + CANCEL_BUTTON_PADDING * 2, 1, 0)
cancelButton.Parent = self.rbx
local searchBoxContainer = Create.new"Frame"
{
Name = "SearchBoxContainer",
BackgroundTransparency = 1,
Size = UDim2.new(1, -cancelButton.Size.X.Offset, 1, 0),
Position = UDim2.new(0, 8, 0, 0),
LayoutOrder = 0,
}
searchBoxContainer.Parent = self.rbx
local searchBoxBackground = Create.new"ImageLabel"
{
Name = "SearchBoxBackground",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, -12),
Position = UDim2.new(0, 8, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
Image = "rbxasset://textures/ui/LuaChat/9-slice/search.png",
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3,3,4,4),
Create.new"ImageLabel"
{
Name = "SearchIcon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0, 8, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
ImageColor3 = Constants.Color.GRAY3,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-search.png",
},
}
searchBoxBackground.Parent = searchBoxContainer
local clearSearchButton = Create.new"ImageButton"
{
Name = "ClearButton",
BackgroundTransparency = 1,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(1, -8, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
Image = "rbxasset://textures/ui/LuaChat/icons/ic-clear-solid.png",
Visible = false,
}
clearSearchButton.Parent = searchBoxBackground
local search = Create.new"TextBox"
{
Name = "Search",
BackgroundTransparency = 1,
ClipsDescendants = true,
Size = UDim2.new(1, -36 - 8 - 32, 1, 0),
Position = UDim2.new(0, 36, 0, 0),
TextSize = Constants.Font.FONT_SIZE_14,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = "",
PlaceholderText = appState.localization:Format("Feature.Chat.Label.SearchWord"),
PlaceholderColor3 = Constants.Color.GRAY3,
TextXAlignment = Enum.TextXAlignment.Left,
OverlayNativeInput = true,
ClearTextOnFocus = false,
}
search.Parent = searchBoxBackground
if FFlagTextBoxOverrideManualFocusRelease then
search.ManualFocusRelease = true
end
self.search = search
self.SearchChanged = Signal.new()
self.Closed = Signal.new()
local searchText = ""
search:GetPropertyChangedSignal("Text"):Connect(function()
searchText = string.lower(search.Text)
clearSearchButton.Visible = searchText ~= ""
self.SearchChanged:fire(search.Text)
end)
search.FocusLost:Connect(function()
if search.Text:len() == 0 then
self:Cancel()
end
end)
getInputEvent(cancelButton):Connect(function()
self:Cancel()
end)
getInputEvent(clearSearchButton):Connect(function()
search.Text = ""
self:Cancel()
end)
self.SearchFilterPredicate = function(other)
if searchText == "" then
return true
end
return string.find(string.lower(other), searchText, 1, true) ~= nil
end
return self
end
function ConversationSearchBox:Cancel()
self.search:ReleaseFocus()
self.search.Text = ""
self.Closed:fire()
end
function ConversationSearchBox:Update(participants)
end
return ConversationSearchBox
@@ -0,0 +1,222 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Conversation = require(LuaChat.Models.Conversation)
local Create = require(LuaChat.Create)
local FormFactor = require(LuaApp.Enum.FormFactor)
local Functional = require(Common.Functional)
local HeadshotLoader = require(LuaChat.HeadshotLoader)
local UserThumbnail = require(LuaChat.Components.UserThumbnail)
local MAX_USERS_IN_THUMBNAIL = 4
local ConversationThumbnail = {}
ConversationThumbnail.__index = ConversationThumbnail
-- This lets us determine how to build the group thumbnail. Index represents how many people are in the thumbnail!
local IMAGE_LAYOUT = {
[1] = {
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(1, 0, 1, 0),
FramePosition = UDim2.new(0, 0, 0, 0),
},
},
[2] = {
{
Size = UDim2.new(2, 0, 1, 0),
Position = UDim2.new(-0.5, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 1, 0),
FramePosition = UDim2.new(0, 0, 0, 0),
Border = {
BorderPosition = UDim2.new(0.5, -1, 0, 0),
BorderSize = UDim2.new(0, 2, 1, 0),
},
},
{
Size = UDim2.new(2, 0, 1, 0),
Position = UDim2.new(-0.5, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 1, 0),
FramePosition = UDim2.new(0.5, 1, 0, 0),
},
},
[3] = {
{
Size = UDim2.new(2, 0, 1, 0),
Position = UDim2.new(-0.5, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 1, 0),
FramePosition = UDim2.new(0, 0, 0, 0),
Border = {
BorderPosition = UDim2.new(0.5, -1, 0, 0),
BorderSize = UDim2.new(0, 2, 1, 0),
},
},
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0.5, 1, 0, 0),
Border = {
BorderPosition = UDim2.new(0.5, 0, 0.5, -1),
BorderSize = UDim2.new(0.5, 0, 0, 2),
},
},
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0.5, 1, 0.5, 1),
},
},
[4] = {
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0, 0, 0, 0),
Border = {
BorderPosition = UDim2.new(0, 0, 0.5, -1),
BorderSize = UDim2.new(1, 0, 0, 2),
},
},
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0.5, 1, 0, 0),
},
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0, 0, 0.5, 1),
Border = {
BorderPosition = UDim2.new(0.5, -1, 0, 0),
BorderSize = UDim2.new(0, 2, 1, 0),
},
},
{
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
FrameSize = UDim2.new(0.5, -1, 0.5, -1),
FramePosition = UDim2.new(0.5, 1, 0.5, 1),
},
},
}
function ConversationThumbnail.new(appState, conversation)
local self = {
appState = appState,
}
local localId = tostring(Players.LocalPlayer.UserId)
local otherIds = Functional.Filter(conversation.participants, function(participantId)
return participantId ~= localId
end)
if conversation.conversationType == Conversation.Type.ONE_TO_ONE_CONVERSATION then
return UserThumbnail.new(appState, otherIds[1])
end
self.rbx = Create.new "Frame" {
Name = "ConversationThumbnail",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
}
setmetatable(self, ConversationThumbnail)
self:Update(conversation)
return self
end
function ConversationThumbnail:Update(conversation)
self.rbx:ClearAllChildren()
local localId = tostring(Players.LocalPlayer.UserId)
local otherIds = Functional.Filter(conversation.participants, function(participantId)
return participantId ~= localId
end)
local showUserIds
if conversation.conversationType == Conversation.Type.MULTI_USER_CONVERSATION then
local hasRoomForLocalUser = #conversation.participants <= MAX_USERS_IN_THUMBNAIL
if hasRoomForLocalUser then
showUserIds = conversation.participants
else
showUserIds = Functional.Take(otherIds, MAX_USERS_IN_THUMBNAIL)
end
else
showUserIds = otherIds
end
for userIndex, userId in ipairs(showUserIds) do
local layoutData = IMAGE_LAYOUT[#showUserIds][userIndex]
local frame = Create.new "Frame" {
Name = "AvatarHolder",
BackgroundTransparency = 1,
ClipsDescendants = true,
Size = layoutData.FrameSize,
Position = layoutData.FramePosition,
}
local headshot = Create.new "ImageLabel" {
Name = "Avatar",
Size = layoutData.Size,
Position = layoutData.Position,
BackgroundTransparency = 1,
}
headshot.Parent = frame
HeadshotLoader:Load(headshot, userId)
if layoutData.Border then
Create.new "Frame" {
Name = "Border",
Size = layoutData.Border.BorderSize,
Position = layoutData.Border.BorderPosition,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.GRAY4
}.Parent = self.rbx
end
frame.Parent = self.rbx
end
Create.new "ImageLabel" {
Name = "Mask",
Image = "rbxasset://textures/ui/LuaChat/graphic/gr-profile-border-48x48.png",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
}.Parent = self.rbx
local state = self.appState.store:getState()
if state.FormFactor == FormFactor.TABLET then
local currentLocationParameters = state.ChatAppReducer.Location.current.parameters
local currentConversationId = currentLocationParameters and currentLocationParameters.conversationId or nil
if currentConversationId == conversation.id then
self.rbx.Mask.ImageColor3 = Constants.Color.GRAY5
else
self.rbx.Mask.ImageColor3 = Constants.Color.WHITE
end
end
end
function ConversationThumbnail:Destruct()
self.rbx:Destroy()
end
return ConversationThumbnail
@@ -0,0 +1,192 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local ConversationModel = require(LuaChat.Models.Conversation)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local Immutable = require(Common.Immutable)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local FriendSearchBoxComponent = require(Components.FriendSearchBox)
local HeaderLoader = require(Components.HeaderLoader)
local ResponseIndicator = require(Components.ResponseIndicator)
local SectionComponent = require(Components.ListSection)
local RemoveRoute = require(LuaChat.Actions.RemoveRoute)
local Intent = DialogInfo.Intent
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local CreateChat = {}
CreateChat.__index = CreateChat
function CreateChat.new(appState)
local self = {
appState = appState,
}
setmetatable(self, CreateChat)
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.conversation = ConversationModel.empty()
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
-- Header:
self.header = HeaderLoader.GetHeader(appState, Intent.CreateChat)
self.header:SetDefaultSubtitle()
self.header:SetTitle(appState.localization:Format("Feature.Chat.Heading.ChatWithFriends"))
self.header:SetBackButtonEnabled(true)
self.header:SetConnectionState(Enum.ConnectionState.Disconnected)
-- Search for friends:
self.searchComponent = FriendSearchBoxComponent.new(
appState,
self.conversation.participants,
Constants.MAX_PARTICIPANT_COUNT,
function(user)
return user.isFriend and user.id ~= tostring(Players.LocalPlayer.UserId)
end
)
self.searchComponent.rbx.LayoutOrder = 3
local addParticipantConnection = self.searchComponent.addParticipant:connect(function(id)
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.Set(self.conversation.participants, #self.conversation.participants+1, id))
end)
table.insert(self.connections, addParticipantConnection)
local removeParticipantConnection = self.searchComponent.removeParticipant:connect(function(id)
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.RemoveValueFromList(self.conversation.participants, id))
end)
table.insert(self.connections, removeParticipantConnection)
-- Assemble the dialog from components we just made:
self.sectionComponent = SectionComponent.new(appState, nil, 2)
self.rbx = Create.new"Frame" {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Create.new("UIListLayout") {
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
},
self.header.rbx,
Create.new"Frame" {
Name = "Content",
Size = UDim2.new(1, 0, 1, -(self.header.heightOfHeader)),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
LayoutOrder = 1,
ClipsDescendants = true,
Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
},
self.sectionComponent.rbx,
self.searchComponent.rbx,
},
self.responseIndicator.rbx,
}
-- Wire up the save button to actually create our new chat group:
self.createChat = self.header:CreateHeaderButton("CreateChat", "Feature.Chat.Action.StartChatWithFriends")
self.header.innerButtons.Position = UDim2.new(1, 0, 0, 0)
self.createChat.rbx.Size = UDim2.new(0, 60, 1, 0)
self.createChat:SetEnabled(false)
local createChatConnection = self.createChat.Pressed:connect(function()
self.searchComponent.search:ReleaseFocus()
if #self.conversation.participants >= Constants.MIN_PARTICIPANT_COUNT then
self.responseIndicator:SetVisible(true)
self.appState.store:dispatch(
ConversationActions.CreateConversation(self.conversation, function(id)
self.responseIndicator:SetVisible(false)
if id ~= nil then
self.ConversationSaved:fire(id)
end
self.appState.store:dispatch(RemoveRoute(Intent.CreateChat))
end)
)
end
end)
table.insert(self.connections, createChatConnection)
self.BackButtonPressed = Signal.new()
self.header.BackButtonPressed:connect(function()
self.searchComponent.search:ReleaseFocus()
self.BackButtonPressed:fire()
end)
self.ConversationSaved = Signal.new()
spawn(function()
self.appState.store:dispatch(ConversationActions.GetAllFriendsAsync())
end)
self.tooManyFriendsAlertId = nil
local headerSizeConnection = self.header.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, headerSizeConnection)
else
table.insert(self.connections, headerSizeConnection)
end
return self
end
function CreateChat:Resize()
-- Content frame must resize if the header changes size (which happens when it shows the "Connecting" message):
local sizeContent = UDim2.new(1, 0, 1, -self.header.rbx.AbsoluteSize.Y)
self.rbx.Content.Size = sizeContent
-- Friends Search frame must resize to fit properly with their peers:
local sizeSearch = UDim2.new(1, 0, 1, -self.sectionComponent.rbx.AbsoluteSize.Y)
self.searchComponent.rbx.Size = sizeSearch
end
function CreateChat:ChangeParticipants(participants)
self.conversation = Immutable.Set(self.conversation, "participants", participants)
self.searchComponent:Update(participants)
self.createChat:SetEnabled(#participants >= Constants.MIN_PARTICIPANT_COUNT)
end
function CreateChat:Update(current)
self.header:SetConnectionState(current.ConnectionState)
self.searchComponent:Update(self.conversation.participants)
end
function CreateChat:Destruct()
for _, connection in pairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in pairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
self.header:Destroy()
self.responseIndicator:Destruct()
self.searchComponent:Destruct()
self.rbx:Destroy()
end
return CreateChat
@@ -0,0 +1,56 @@
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local DialogInfo = require(LuaChat.DialogInfo)
local Components = LuaChat.Components
local HeaderLoader = require(Components.HeaderLoader)
local Intent = DialogInfo.Intent
local DefaultScreen = {}
DefaultScreen.__index = DefaultScreen
function DefaultScreen.new(appState)
local self = {}
setmetatable(self, DefaultScreen)
local header = HeaderLoader.GetHeader(appState, Intent.DefaultScreen)
header:SetDefaultSubtitle()
self.rbx = Create.new "Frame" {
Name = "DefaultScreen",
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY6,
BorderSizePixel = 0,
Create.new "UIListLayout" {
SortOrder = "LayoutOrder",
},
header.rbx,
Create.new "Frame" {
Name = "Main",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1.0,
Create.new "ImageLabel" {
BackgroundTransparency = 1.0,
Size = UDim2.new(0, 150, 0, 150),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Image = "rbxasset://textures/ui/LuaChat/icons/ic-chat-large.png",
}
},
}
return self
end
function DefaultScreen:Update(current, previous) end
return DefaultScreen
@@ -0,0 +1,773 @@
-- TweenService and UserInputService required by TextInputDialog
local CoreGui = game:GetService("CoreGui")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FormFactor = require(LuaApp.Enum.FormFactor)
local Signal = require(Common.Signal)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local Components = LuaChat.Components
local TextInputEntryComponent = require(Components.TextInputEntry)
local PopRoute = require(LuaChat.Actions.PopRoute)
local FFlagLuaChatContextualMenuTransition = settings():GetFFlag("LuaChatContextualMenuTransition")
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local OptionDialog = {}
local TextInputDialog = {}
local ConfirmationDialog = {}
local AlertDialog = {}
local DialogComponents = {
OptionDialog = OptionDialog,
TextInputDialog = TextInputDialog,
ConfirmationDialog = ConfirmationDialog,
AlertDialog = AlertDialog,
}
local DEFAULT_DIALOG_WIDTH = 400
local OPTION_DIALOG_GAP_HEIGHT = 8
local TWEEN_TIME = Constants.Dialog.TWEEN_TIME
local function GetDialogWidth(appState)
if appState.store:getState().FormFactor == FormFactor.TABLET then
return UDim.new(0, DEFAULT_DIALOG_WIDTH)
else
return UDim.new(1, -Constants.ModalDialog.CLEARANCE_DIALOG_SIDE)
end
end
local function PushBackLayout(newObject, parentObject)
parentObject = parentObject or newObject.Parent
local listLayout = parentObject:FindFirstChildOfClass('UIListLayout')
local highestLayout = -1
if listLayout then
for _, child in pairs(parentObject:GetChildren()) do
if child:IsA('GuiBase') then
highestLayout = math.max(highestLayout, child.LayoutOrder)
end
end
end
newObject.LayoutOrder = highestLayout + 1
return newObject
end
local function makeDivider(height, color, transparency, layoutOrder)
transparency = transparency or 0
layoutOrder = layoutOrder or 0
return Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = color,
BackgroundTransparency = transparency,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, height),
Position = UDim2.new(0, 0, 1, -height),
LayoutOrder = layoutOrder,
}
end
local function tweenDialog(rbx, layout)
rbx.Size = UDim2.new(1, 0, 2*rbx.Size.Y.Scale, 0)
spawn(function()
local yTweenSize
if layout then
yTweenSize = layout.AbsoluteContentSize.Y
else
yTweenSize = rbx.AbsoluteSize.Y
end
rbx.Position = UDim2.new(rbx.Position.X.Scale, rbx.Position.X.Offset, -1, yTweenSize)
local tweenInfo = TweenInfo.new(TWEEN_TIME, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local propertyGoals = {
Position = UDim2.new(rbx.Position.X.Scale, rbx.Position.X.Offset, -1, 0),
}
local tween = TweenService:Create(rbx, tweenInfo, propertyGoals)
tween:Play()
end)
end
local function outerFrameAndHeader(appState, title, color, event)
color = color or Constants.Color.BLUE_PRIMARY
local dialogWidth = GetDialogWidth(appState)
local dialogLayout = Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
}
local rbx = Create.new"TextButton" {
Name = title,
AutoButtonColor = false,
Text = "",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1-Constants.Color.ALPHA_SHADOW_PRIMARY,
BackgroundColor3 = Constants.Color.GRAY1,
BorderSizePixel = 0,
Visible = false,
Active = true,
Create.new"ImageButton" {
Name = "Dialog",
AutoButtonColor = false,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(5,5,6,6),
Image = "rbxasset://textures/ui/LuaChat/9-slice/modal.png",
Size = UDim2.new(dialogWidth.Scale, dialogWidth.Offset, 0, 42),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
dialogLayout,
Create.new"TextLabel" {
Name = "Title",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Constants.Font.TITLE,
TextSize = Constants.Font.FONT_SIZE_18,
BackgroundColor3 = Constants.Color.WHITE,
Text = title,
Size = UDim2.new(1, 0, 0, 42),
TextColor3 = color,
LayoutOrder = 0,
},
makeDivider(3, color, nil, 1),
},
}
if not FFlagLuaChatContextualMenuTransition then
--Seems to be necessary to make sure clicks get sunk and don't fall through when dialog is open
getInputEvent(rbx):Connect(function()
rbx.Visible = false
appState.store:dispatch(PopRoute())
if event ~= nil then
event:fire()
end
end)
end
getInputEvent(rbx.Dialog):Connect(function() end)
return rbx
end
local function addCancelButtonCallbacks(dialogComponent, cancelButton, event)
local function onInputBegan(input)
cancelButton.BackgroundColor3 = Constants.Color.GRAY5
end
cancelButton.InputBegan:Connect(onInputBegan)
local function onInputEnded(input)
cancelButton.BackgroundColor3 = Constants.Color.WHITE
end
cancelButton.InputEnded:Connect(onInputEnded)
getInputEvent(cancelButton):Connect(function()
dialogComponent:Close()
if event ~= nil then
event:fire()
end
end)
end
local function makeConfirmCancelButtons(cancelTitle, confirmTitle)
return Create.new"Frame" {
Name = "ConfirmAndCancelButtons",
BackgroundColor3 = Constants.Color.WHITE,
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 48),
BorderSizePixel = 0,
Create.new"TextButton" {
Name = "Cancel",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
BackgroundColor3 = Constants.Color.WHITE,
Text = cancelTitle,
Size = UDim2.new(0.5, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
AutoButtonColor = false,
},
Create.new"Frame" {
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(0, 1, 1, 0),
Position = UDim2.new(0.5, -1, 0, 0),
},
Create.new"TextButton" {
Name = "Save",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.BLUE_PRIMARY,
BackgroundColor3 = Constants.Color.WHITE,
Text = confirmTitle,
Size = UDim2.new(0.5, 0, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AutoButtonColor = false,
},
}
end
-- Vertically resize the given frame so that it fits the contents:
local function ResizeFrame(frame)
local verticalSize = 0
local horizontalSize = frame.Size.X
for _, v in pairs(frame:GetChildren()) do
if v:IsA("GuiObject") and (v.Visible == true) then
verticalSize = verticalSize + v.AbsoluteSize.Y
end
end
local sizeFrame = UDim2.new(horizontalSize.Scale, horizontalSize.Offset, 0, verticalSize)
frame.Size = sizeFrame
end
function AlertDialog.new(appState, titleKey, messageKey)
local self = {
appState = appState,
}
setmetatable(self, {__index = AlertDialog})
local title = titleKey ~= nil and appState.localization:Format(titleKey) or ""
local message = messageKey ~= nil and appState.localization:Format(messageKey) or ""
self.rbx = outerFrameAndHeader(appState, title, Constants.Color.RED_PRIMARY)
if FFlagLuaChatContextualMenuTransition then
getInputEvent(self.rbx):Connect(function()
self.rbx.Visible = false
appState.store:dispatch(PopRoute())
end)
end
local messageFrame = Create.new"Frame" {
Name = "MessageFrame",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, 100),
Create.new"TextLabel" {
Name = "Message",
Text = message,
TextWrapped = true,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_16,
TextColor3 = Constants.Color.GRAY1,
BackgroundTransparency = 1,
Size = UDim2.new(1, -48, 1, -48),
Position = UDim2.new(0, 24, 0, 24),
}
}
messageFrame.Parent = self.rbx.Dialog
local divider = makeDivider(1, Constants.Color.GRAY4)
divider.Parent = self.rbx.Dialog
local cancelButtonFrame = Create.new"Frame" {
Name = "CancelFrame",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 42),
BorderSizePixel = 0,
Create.new"TextButton" {
Name = "Cancel",
BackgroundTransparency = 0,
BorderSizePixel = 0,
Font = Constants.Font.TITLE,
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
BackgroundColor3 = Constants.Color.WHITE,
Text = appState.localization:Format("Feature.Chat.Action.Confirm"),
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
AutoButtonColor = false,
}
}
cancelButtonFrame.Parent = self.rbx.Dialog
self.accepted = Instance.new("BindableEvent")
self.accepted.Parent = self.rbx
addCancelButtonCallbacks(self, cancelButtonFrame.Cancel, self.accepted)
ResizeFrame(self.rbx.Dialog)
return self
end
function AlertDialog:Update(alert)
local title = alert.titleKey ~= nil and self.appState.localization:Format(alert.titleKey) or ""
local message = alert.messageKey ~= nil and
self.appState.localization:Format(alert.messageKey, alert.messageArguments) or ""
self.rbx.Dialog.Title.Text = title
self.rbx.Dialog.MessageFrame.Message.Text = message
end
function AlertDialog:Prompt()
self.rbx.Visible = true
end
function AlertDialog:Close()
self.rbx.Visible = false
end
function ConfirmationDialog.new(appState, titleKey, messageKey, cancelTitleKey, confirmTitleKey)
local self = {
appState = appState,
}
setmetatable(self, {__index = ConfirmationDialog})
local title = titleKey ~= nil and appState.localization:Format(titleKey) or ""
local message = messageKey ~= nil and appState.localization:Format(messageKey) or ""
local cancelTitle = cancelTitleKey ~= nil and appState.localization:Format(cancelTitleKey) or ""
local confirmTitle = confirmTitleKey ~= nil and appState.localization:Format(confirmTitleKey) or ""
self.rbx = outerFrameAndHeader(appState, title, Constants.Color.RED_PRIMARY)
if FFlagLuaChatContextualMenuTransition then
getInputEvent(self.rbx):Connect(function()
self.rbx.Visible = false
appState.store:dispatch(PopRoute())
end)
end
local messageFrame = Create.new"Frame" {
Name = "MessageFrame",
BackgroundTransparency = 1,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, 100),
Create.new"TextButton" {
Name = "Message",
Text = message,
TextWrapped = true,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_16,
TextColor3 = Constants.Color.GRAY1,
BackgroundTransparency = 1,
Size = UDim2.new(1, -48, 1, -48),
Position = UDim2.new(0, 24, 0, 24),
AutoButtonColor = false,
}
}
messageFrame.Parent = self.rbx.Dialog
PushBackLayout(messageFrame)
local divider = makeDivider(1, Constants.Color.GRAY4)
divider.Parent = self.rbx.Dialog
PushBackLayout(divider)
local buttons = makeConfirmCancelButtons(cancelTitle, confirmTitle)
buttons.Parent = self.rbx.Dialog
PushBackLayout(buttons)
local cancelButton = buttons.Cancel
addCancelButtonCallbacks(self, cancelButton)
local saveButton = buttons.Save
buttons.Save.TextColor3 = Constants.Color.RED_PRIMARY
local function onInputBegan(input)
saveButton.BackgroundColor3 = Constants.Color.GRAY5
end
local function onInputEnded(input)
saveButton.BackgroundColor3 = Constants.Color.WHITE
end
saveButton.InputBegan:Connect(onInputBegan)
saveButton.InputEnded:Connect(onInputEnded)
getInputEvent(saveButton):Connect(function()
self.rbx.Visible = false
appState.store:dispatch(PopRoute())
self.saved:fire(self.data)
end)
self.saved = Signal.new()
ResizeFrame(self.rbx.Dialog)
self.data = nil
return self
end
function ConfirmationDialog:Close()
self.appState.store:dispatch(PopRoute())
end
function ConfirmationDialog:Update(messageKey, data, messageArguments)
self.data = data
local message = messageKey ~= nil and self.appState.localization:Format(messageKey, messageArguments) or ""
self.rbx.Dialog.MessageFrame.Message.Text = message
end
function ConfirmationDialog:Destruct()
self.rbx:Destroy()
end
function TextInputDialog.new(appState, titleLocalizationKey, maxChar)
local self = {
appState = appState,
}
setmetatable(self, {__index = TextInputDialog})
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
else
self.connections = {}
end
local title = appState.localization:Format(titleLocalizationKey)
self.cancel = Signal.new()
if FFlagLuaChatContextualMenuTransition then
self.rbx = outerFrameAndHeader(appState, title, Constants.Color.BLUE_PRIMARY)
getInputEvent(self.rbx):Connect(function()
self.rbx.Visible = false
appState.store:dispatch(PopRoute())
self.cancel:fire()
end)
else
self.rbx = outerFrameAndHeader(appState, title, Constants.Color.BLUE_PRIMARY, self.cancel)
end
local placeholderText = appState.localization:Format("Feature.Chat.Description.NameGroupChat")
local textInput = TextInputEntryComponent.new(appState, nil, placeholderText)
textInput.rbx.BackgroundTransparency = 1
textInput.rbx.Size = UDim2.new(1,-10,1,0)
textInput.rbx.Position = UDim2.new(0,10,0,0)
textInput:ShowDivider(false)
self.textInputComponent = textInput
self.value = textInput.value
local textCount = Create.new"TextLabel"{
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 18),
Position = UDim2.new(1, 0, 1, 0),
AnchorPoint = Vector2.new(1,0),
TextXAlignment = Enum.TextXAlignment.Right,
Text = "0/" .. maxChar,
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_12,
TextYAlignment = "Top",
}
local textInputContainer = Create.new"Frame"{
BackgroundTransparency = 1,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 98),
Create.new"ImageLabel"{
Name = "TextBackground",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, -40, 0, 36),
Position = UDim2.new(0.5, 0, 0.5, -5),
AnchorPoint = Vector2.new(0.5, 0.5),
ScaleType = "Slice",
SliceCenter = Rect.new(3,3,4,4),
Image = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png",
textInput.rbx, -- set parent
textCount, -- set parent
},
}
textInputContainer.Parent = self.rbx.Dialog
local divider = makeDivider(1, Constants.Color.GRAY4)
divider.LayoutOrder = 3
divider.Parent = self.rbx.Dialog
local cancelTitle = appState.localization:Format("Feature.Chat.Action.Cancel")
local confirmTitle = appState.localization:Format("Feature.Chat.Action.Save")
local buttons = makeConfirmCancelButtons(cancelTitle, confirmTitle)
buttons.LayoutOrder = 4
buttons.Parent = self.rbx.Dialog
local function isSubmitable()
return self.value:len() <= maxChar
end
local cancelButton = buttons.Cancel
addCancelButtonCallbacks(self, cancelButton, self.cancel)
local saveButton = buttons.Save
local function onInputBegan(input)
saveButton.BackgroundColor3 = Constants.Color.GRAY5
end
local function onInputEnded(input)
saveButton.BackgroundColor3 = Constants.Color.WHITE
end
saveButton.MouseButton1Down:Connect(onInputBegan)
saveButton.MouseButton1Up:Connect(onInputEnded)
getInputEvent(saveButton):Connect(function()
if isSubmitable() then
self:Close()
self.saved:fire(self.value)
end
end)
textInput.textBoxChanged:connect(function()
self.value = textInput.value
saveButton.TextColor3 = isSubmitable() and Constants.Color.BLUE_PRIMARY or Constants.Color.RED_PRIMARY
textCount.TextColor3 = isSubmitable() and Constants.Color.GRAY2 or Constants.Color.RED_PRIMARY
textCount.Text = string.format("%s/%s", tostring(self.value:len()), tostring(maxChar))
end)
self.saved = Signal.new()
ResizeFrame(self.rbx.Dialog)
self:SetupTweenForKeyboardEvents()
return self
end
function TextInputDialog:SetupTweenForKeyboardEvents()
self.tweenGroupNameDialogUp = nil
self.tweenGroupNameDialogDown = nil
local inputServiceConnection = UserInputService:GetPropertyChangedSignal('OnScreenKeyboardVisible'):Connect(function()
if UserInputService.OnScreenKeyboardVisible then
if self.tweenGroupNameDialogUp == nil then
local duration = UserInputService.OnScreenKeyboardAnimationDuration
local yPos = UserInputService.OnScreenKeyboardPosition.Y / 2
local tweenInfo = TweenInfo.new(duration)
local propertyGoals =
{
Position = UDim2.new(0.5, 0, 0, yPos)
}
self.tweenGroupNameDialogUp = TweenService:Create(self.rbx.Dialog, tweenInfo, propertyGoals)
end
self.tweenGroupNameDialogUp:Play()
else
if self.tweenGroupNameDialogDown == nil then
local duration = UserInputService.OnScreenKeyboardAnimationDuration
local tweenInfo = TweenInfo.new(duration)
local propertyGoals =
{
Position = UDim2.new(0.5, 0, 0.5, 0)
}
self.tweenGroupNameDialogDown = TweenService:Create(self.rbx.Dialog, tweenInfo, propertyGoals)
end
self.tweenGroupNameDialogDown:Play()
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, inputServiceConnection)
else
table.insert(self.connections, inputServiceConnection)
end
end
function TextInputDialog:Close()
self.textInputComponent.rbx.TextBox:ReleaseFocus()
self.appState.store:dispatch(PopRoute())
end
function TextInputDialog:Update(value)
self.textInputComponent:Update(value)
end
function TextInputDialog:Destruct()
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
else
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
end
self.textInputComponent:Destruct()
self.rbx:Destroy()
end
function OptionDialog.new(appState, titleKey, options, userId)
local self = {
appState = appState,
}
setmetatable(self, {__index = OptionDialog})
local title = titleKey ~= nil and appState.localization:Format(titleKey) or ""
self.rbx = outerFrameAndHeader(appState, title)
if FFlagLuaChatContextualMenuTransition then
local connection
connection = getInputEvent(self.rbx):Connect(function()
connection:Disconnect()
self:Close()
end)
else
self.rbx.Dialog.AnchorPoint = Vector2.new(0.5, 1)
self.rbx.Dialog.Position = UDim2.new(0.5, 0, 1, -10)
end
self.selected = Signal.new()
self.optionGuis = {}
local layout = Create.new"UIListLayout"{
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = "Center",
VerticalAlignment = "Bottom",
}
layout.Parent = self.rbx
local optionSizeY = 42 + 1
for optionId, optionTitleKey in pairs(options) do
local optionTitle = appState.localization:Format(optionTitleKey)
local optionGui = Create.new"TextButton"{
Name = optionTitle,
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_18,
BackgroundColor3 = Constants.Color.WHITE,
Text = optionTitle,
Size = UDim2.new(1, 0, 0, optionSizeY),
TextColor3 = Constants.Color.GRAY1,
AutoButtonColor = false,
}
local divider = makeDivider(1, Constants.Color.GRAY4)
divider.Position = UDim2.new(0, 0, 1, 0)
divider.Parent = optionGui
local function onInputBegan(input)
optionGui.BackgroundColor3 = Constants.Color.GRAY5
end
optionGui.InputBegan:Connect(onInputBegan)
local function onInputEnded(input)
optionGui.BackgroundColor3 = Constants.Color.WHITE
end
optionGui.InputEnded:Connect(onInputEnded)
getInputEvent(optionGui):Connect(function()
self.rbx.Visible = false
appState.store:dispatch(PopRoute())
self.selected:fire(optionId, self.data or {})
end)
optionGui.Parent = self.rbx.Dialog
PushBackLayout(optionGui)
self.optionGuis[optionId] = optionGui
end
local firstDivider = makeDivider(OPTION_DIALOG_GAP_HEIGHT, Constants.Color.WHITE, 1, 1)
firstDivider.BackgroundTransparency = 1
firstDivider.Parent = self.rbx
local cancelButtonFrame = Create.new"ImageLabel" {
Name = "CancelButton",
BackgroundTransparency = 1,
Size = UDim2.new(self.rbx.Dialog.Size.X.Scale, self.rbx.Dialog.Size.X.Offset, 0, Constants.ModalDialog.BUTTON_HEIGHT),
BorderSizePixel = 0,
ScaleType = "Slice",
SliceCenter = Rect.new(5,5,6,6),
Image = "rbxasset://textures/ui/LuaChat/9-slice/modal.png",
LayoutOrder = 2,
Create.new"TextButton" {
Name = "Cancel",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Constants.Font.TITLE,
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
BackgroundColor3 = Constants.Color.WHITE,
Text = appState.localization:Format("Feature.Chat.Action.Cancel"),
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
AutoButtonColor = false,
}
}
cancelButtonFrame.Parent = self.rbx
addCancelButtonCallbacks(self, cancelButtonFrame.Cancel)
local secondDivider = makeDivider(OPTION_DIALOG_GAP_HEIGHT, Constants.Color.WHITE, 1, 3)
secondDivider.BackgroundTransparency = 1
secondDivider.Parent = self.rbx
self.data = userId
self:Resize()
if FFlagLuaChatContextualMenuTransition then
tweenDialog(self.rbx, layout)
end
return self
end
-- Helper function so ResizeFrame doesn't have to be called externally:
function OptionDialog:Resize()
ResizeFrame(self.rbx.Dialog)
end
function OptionDialog:Close()
if FFlagLuaChatContextualMenuTransition then
self.rbx.BackgroundTransparency = 1
local totalHeight = self.rbx.ListLayout.AbsoluteContentSize.Y
local tweenInfo = TweenInfo.new(TWEEN_TIME, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
local propertyGoals = {
Position = UDim2.new(0, 0, -1, totalHeight),
}
local tween = TweenService:Create(self.rbx, tweenInfo, propertyGoals)
tween:Play()
wait(TWEEN_TIME)
end
self.appState.store:dispatch(PopRoute())
end
function OptionDialog:Destruct()
self.rbx:Destroy()
end
return DialogComponents
@@ -0,0 +1,189 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local ConversationModel = require(LuaChat.Models.Conversation)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local Immutable = require(Common.Immutable)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local FriendSearchBoxComponent = require(Components.FriendSearchBox)
local HeaderLoader = require(Components.HeaderLoader)
local ResponseIndicator = require(Components.ResponseIndicator)
local SetRoute = require(LuaChat.Actions.SetRoute)
local Intent = DialogInfo.Intent
local EditChatGroup = {}
EditChatGroup.__index = EditChatGroup
function EditChatGroup.new(appState, maxSize, convoId)
local self = {
appState = appState,
maxSize = maxSize,
convoId = convoId,
}
self.connections = {}
setmetatable(self, EditChatGroup)
self.conversation = ConversationModel.empty()
self.alreadyParticipants = {}
local oldConversation = self.appState.store:getState().ChatAppReducer.Conversations[self.convoId]
self.fromType = oldConversation.conversationType
for _, userId in pairs(oldConversation.participants) do
self.alreadyParticipants[userId] = true
end
self.searchComponent = FriendSearchBoxComponent.new(
appState,
self.conversation.participants,
self.maxSize,
function(user)
local isNotPlayer = user.id ~= tostring(Players.LocalPlayer.UserId)
local isNotInList = not self.alreadyParticipants[user.id]
return user.isFriend and isNotPlayer and isNotInList
end
)
local addParticipantConnection = self.searchComponent.addParticipant:connect(function(id)
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.Set(self.conversation.participants, #self.conversation.participants+1, id))
end)
table.insert(self.connections, addParticipantConnection)
local removeParticipantConnection = self.searchComponent.removeParticipant:connect(function(id)
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.RemoveValueFromList(self.conversation.participants, id))
end)
table.insert(self.connections, removeParticipantConnection)
-- Header:
self.header = HeaderLoader.GetHeader(appState, Intent.EditChatGroup)
self.header:SetDefaultSubtitle()
self.header:SetTitle(appState.localization:Format("Feature.Chat.Label.AddFriends"))
self.header:SetBackButtonEnabled(true)
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
self.rbx = Create.new"Frame" {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
BackgroundTransparency = 1,
self.header.rbx,
Create.new"Frame" {
Name = "Content",
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
self.searchComponent.rbx,
},
self.responseIndicator.rbx,
}
self.saveGroup = self.header:CreateHeaderButton("SaveGroup", "Feature.Chat.Action.Add")
self.saveGroup:SetEnabled(false)
self.header:AddButton(self.saveGroup)
local saveGroupConnection = self.saveGroup.Pressed:connect(function()
self.searchComponent.search:ReleaseFocus()
if #self.conversation.participants == 0 then
return
end
if self.fromType == ConversationModel.Type.MULTI_USER_CONVERSATION then
self.responseIndicator:SetVisible(true)
self.appState.store:dispatch(
ConversationActions.AddUsersToConversation(self.convoId, self.conversation.participants, function()
self.responseIndicator:SetVisible(false)
self.appState.store:dispatch(SetRoute(
Intent.Conversation,
{conversationId = self.convoId},
Intent.ConversationHub
))
end)
)
elseif self.fromType == ConversationModel.Type.ONE_TO_ONE_CONVERSATION then
self.responseIndicator:SetVisible(true)
local originalConvo = self.appState.store:getState().ChatAppReducer.Conversations[self.convoId]
local allParticipants = Immutable.Append(self.conversation.participants)
for _, userId in ipairs(originalConvo.participants) do
if userId ~= tostring(Players.LocalPlayer.UserId) then
table.insert(allParticipants, userId)
end
end
local newConvo = Immutable.JoinDictionaries(originalConvo, {
participants = allParticipants,
clientId = self.conversation.clientId,
title = "",
})
self.appState.store:dispatch(
ConversationActions.CreateConversation(newConvo, function(passedConversationId)
self.responseIndicator:SetVisible(false)
self.appState.store:dispatch(
SetRoute(Intent.Conversation, {conversationId = passedConversationId}, Intent.ConversationHub)
)
end)
)
end
end)
table.insert(self.connections, saveGroupConnection)
self.rbx.Content.Position = UDim2.new(0, 0, 0, self.header.rbx.Size.Y.Offset)
self.rbx.Content.Size = UDim2.new(1, 0, 1, -self.header.rbx.Size.Y.Offset)
self.BackButtonPressed = Signal.new()
self.header.BackButtonPressed:connect(function()
self.searchComponent.search:ReleaseFocus()
self.BackButtonPressed:fire()
end)
spawn(function()
self.appState.store:dispatch(ConversationActions.GetAllFriendsAsync())
end)
self.tooManyFriendsAlertId = nil
return self
end
function EditChatGroup:ChangeParticipants(participants)
self.conversation = Immutable.Set(self.conversation, "participants", participants)
self.searchComponent:Update(participants)
self.saveGroup:SetEnabled(#participants > 0)
end
function EditChatGroup:Update(current, previous)
self.header:SetConnectionState(current.ConnectionState)
self.searchComponent:Update(self.conversation.participants)
self.saveGroup:SetEnabled(#self.conversation.participants ~= #self.alreadyParticipants)
end
function EditChatGroup:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
self.header:Destroy()
self.responseIndicator:Destruct()
self.searchComponent:Destruct()
self.rbx:Destroy()
end
return EditChatGroup
@@ -0,0 +1,248 @@
--
-- FriendCarousel
--
-- This is a scrollable list of friend icons.
--
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local ApiFetchUsersThumbnail = require(LuaApp.Thunks.ApiFetchUsersThumbnail)
local ChatConstants = require(Modules.LuaChat.Constants)
local Constants = require(LuaApp.Constants)
local FriendIcon = require(Modules.LuaApp.Components.FriendIcon)
local Roact = require(Common.Roact)
local RoactRodux = require(Common.RoactRodux)
local UserModel = require(LuaApp.Models.User)
local FFlagLuaChatReplacePresenceIndicatorImages = settings():GetFFlag("LuaChatReplacePresenceIndicatorImages")
local FFlagLuaChatFriendIconRefactor = settings():GetFFlag("LuaChatFriendIconRefactor")
local FriendCarousel = Roact.Component:extend("FriendCarousel")
local IMAGE_DOT_INGAME = "rbxasset://textures/ui/LuaApp/icons/ic-green-dot.png"
local IMAGE_DOT_ONLINE = "rbxasset://textures/ui/LuaApp/icons/ic-blue-dot.png"
local IMAGE_DOT_STUDIO = "rbxasset://textures/ui/LuaApp/icons/ic-orange-dot.png"
local DEFAULT_DOT_SIZE = 8
local IMAGE_MASK = "rbxasset://textures/ui/LuaChat/graphic/friendmask.png"
local MASK_WIDTH = 10
-- Frame around the profile image:
local IMAGE_PROFILE_BORDER = "rbxasset://textures/ui/LuaChat/graphic/gr-profile-border-36x36.png"
local IMAGE_PROFILE_DEFAULT = "rbxasset://textures/ui/LuaChat/icons/ic-profile.png"
function FriendCarousel:init()
self.state = {
fadeScrollLeft = false,
fadeScrollRight = false,
}
end
function FriendCarousel:onPositionChanged(rbx)
-- Programatically show / hide the fade bars at either side of the carousel.
-- This hides items that are partly visible, but completely reveals the
-- first / last items when they're present.
-- Early return if we're not set up yet:
if (rbx.CanvasSize.X.Offset == 0) or (rbx.CanvasSize.Y.Offset == 0) or
(rbx.AbsoluteWindowSize.X == 0) or (rbx.AbsoluteWindowSize.Y == 0) then
return
end
local fadeLeft = (0 < rbx.CanvasPosition.X)
local fadeRight = (rbx.CanvasSize.X.Offset - rbx.CanvasPosition.X) > rbx.AbsoluteWindowSize.X
if (fadeLeft ~= self.state.fadeScrollLeft) or
(fadeRight ~= self.state.fadeScrollRight) then
spawn(function()
self:setState({
fadeScrollLeft = fadeLeft,
fadeScrollRight = fadeRight,
})
end)
end
end
function FriendCarousel:didMount()
if self.rbxScroller then
self:onPositionChanged(self.rbxScroller)
end
end
function FriendCarousel:render()
-- Visual properties of this game card:
local dotSize = self.props.dotSize or DEFAULT_DOT_SIZE
local friends = self.props.friends or {}
local getUserThumbnail = self.props.getUserThumbnail
local horizontalAlignment = self.props.HorizontalAlignment or Enum.HorizontalAlignment.Left
local itemGap = self.props.itemGap
local itemSize = self.props.itemSize
local layoutOrder = self.props.LayoutOrder
local size = self.props.Size or UDim2.new(1, 0, 0, itemSize)
local users = self.props.users
local presenceIndicatorSizeKey = ChatConstants:GetPresenceIndicatorSizeKey(dotSize)
-- Build up a horizontal list of items for our card:
local friendItems = {}
friendItems["Layout"] = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = horizontalAlignment,
Padding = UDim.new(0, itemGap),
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
})
local countFriends = #friends
if countFriends > 0 then
for index, friend in ipairs(friends) do
if FFlagLuaChatFriendIconRefactor then
local userFriend = users[friend.uid]
friendItems[index] = Roact.createElement(FriendIcon, {
user = userFriend,
dotSize = dotSize,
itemSize = itemSize,
layoutOrder = index,
})
else
-- Look up the presence information:
local imageFriend = nil
local iconDot = not FFlagLuaChatReplacePresenceIndicatorImages and IMAGE_DOT_ONLINE or nil
local userFriend = users[friend.uid]
if userFriend then
if FFlagLuaChatReplacePresenceIndicatorImages then
iconDot = ChatConstants.PresenceIndicatorImagesBySize[presenceIndicatorSizeKey][userFriend.presence]
else
if userFriend.presence == UserModel.PresenceType.IN_GAME then
iconDot = IMAGE_DOT_INGAME
elseif userFriend.presence == UserModel.PresenceType.IN_STUDIO then
iconDot = IMAGE_DOT_STUDIO
end
end
-- Find images for the friend portraits:
if userFriend.thumbnails and userFriend.thumbnails.HeadShot
and userFriend.thumbnails.HeadShot.Size48x48 then
imageFriend = userFriend.thumbnails.HeadShot.Size48x48
end
if imageFriend == nil then
imageFriend = IMAGE_PROFILE_DEFAULT
getUserThumbnail(friend.uid)
end
end
friendItems[index] = Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = index,
Size = UDim2.new(0, itemSize, 0, itemSize),
}, {
Profile = Roact.createElement("ImageButton", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = imageFriend,
Size = UDim2.new(0, itemSize, 0, itemSize),
ZIndex = 1,
}),
Border = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = IMAGE_PROFILE_BORDER,
Size = UDim2.new(0, itemSize, 0, itemSize),
ZIndex = 2,
}),
Dot = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = iconDot,
Position = UDim2.new(1, -dotSize, 1, -dotSize),
Size = UDim2.new(0, dotSize, 0, dotSize),
ZIndex = 3,
}),
})
end
end
end
local maskLeft = nil
if self.state.fadeScrollLeft then
maskLeft = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = IMAGE_MASK,
Position = UDim2.new(0, 0, 0, 0),
Rotation = 180,
Size = UDim2.new(0, MASK_WIDTH, 1, 0),
ZIndex = 2,
})
end
local maskRight = nil
if self.state.fadeScrollRight then
maskRight = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = IMAGE_MASK,
Position = UDim2.new(1, -MASK_WIDTH, 0, 0),
Size = UDim2.new(0, MASK_WIDTH, 1, 0),
ZIndex = 2,
})
end
-- This frame arrangement adds a semi-transparent overlay to
-- fade out items at the edge of the frame:
return Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = layoutOrder,
Position = UDim2.new(0, 0, 0, 0),
Size = size,
},{
MaskLeft = maskLeft,
MaskRight = maskRight,
ScrollyFrame = Roact.createElement("ScrollingFrame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
CanvasSize = UDim2.new(0, (itemSize + itemGap) * countFriends, 0, itemSize),
ClipsDescendants = true,
ScrollBarThickness = 0,
Size = UDim2.new(1, 0, 1, 0),
[Roact.Change.AbsolutePosition] = function(rbx, changed)
self:onPositionChanged(rbx)
end,
[Roact.Ref] = function(rbx)
self.rbxScroller = rbx
end,
}, friendItems)
})
end
FriendCarousel = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
users = state.Users,
}
end,
function(dispatch)
return {
getUserThumbnail = function(friendId)
spawn(function()
dispatch(ApiFetchUsersThumbnail(nil, { friendId },
Constants.AvatarThumbnailRequests.FRIEND_CAROUSEL
))
end)
end,
}
end
)(FriendCarousel)
return FriendCarousel
@@ -0,0 +1,38 @@
return function()
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local AppReducer = require(Modules.LuaApp.AppReducer)
local MockId = require(Modules.LuaApp.MockId)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local Rodux = require(Modules.Common.Rodux)
local FriendCarousel = require(Modules.LuaChat.Components.FriendCarousel)
it("should create and destroy without errors", function()
local store = Rodux.Store.new(AppReducer)
local gameFriends = { { uid = MockId() }, { uid = MockId() },
{ uid = MockId() }, { uid = MockId() }, { uid = MockId() } }
local carouselItemGap = 9
local carouselItemHeight = 32
local carouselItemDotSize = 10
local element = Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
GameFriends = Roact.createElement(FriendCarousel, {
dotSize = carouselItemDotSize,
friends = gameFriends,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
itemGap = carouselItemGap,
itemSize = carouselItemHeight,
Size = UDim2.new(1, 0, 1, carouselItemHeight),
}),
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,359 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Functional = require(Common.Functional)
local Signal = require(Common.Signal)
local ToastModel = require(LuaChat.Models.ToastModel)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local Components = LuaChat.Components
local ListSection = require(Components.ListSection)
local UserList = require(Components.UserList)
local UserThumbnailBar = require(Components.UserThumbnailBar)
local User = require(LuaApp.Models.User)
local ShowToast = require(LuaChat.Actions.ShowToast)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local FFlagTextBoxOverrideManualFocusRelease = settings():GetFFlag("TextBoxOverrideManualFocusRelease")
local FFlagLuaChatSortFriendsForConversation = settings():GetFFlag("LuaChatSortFriendsForConversation")
local CLEAR_TEXT_WIDTH = 44
local ICON_CELL_WIDTH = 60
local SEARCH_BOX_HEIGHT = 48
local FriendSearchBox = {}
FriendSearchBox.__index = FriendSearchBox
local PRESENCE_WEIGHTS = {
[User.PresenceType.IN_GAME] = 4,
[User.PresenceType.ONLINE] = 3,
[User.PresenceType.IN_STUDIO] = 2,
[User.PresenceType.OFFLINE] = 1,
}
local function friendSortPredicate(friend1, friend2)
local friend1Weight = PRESENCE_WEIGHTS[friend1.presence] or 0
local friend2Weight = PRESENCE_WEIGHTS[friend2.presence] or 0
if friend1Weight == friend2Weight then
return friend1.name:lower() < friend2.name:lower()
else
return friend1Weight > friend2Weight
end
end
function FriendSearchBox.new(appState, participants, maxParticipantCount, filter)
local self = {
appState = appState,
participants = participants,
users = appState.store:getState().Users,
maxParticipantCount = maxParticipantCount,
}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
setmetatable(self, FriendSearchBox)
self.friendThumbnails = UserThumbnailBar.new(appState, maxParticipantCount, 1)
local removedConnection = self.friendThumbnails.removed:connect(function(id)
self:RemoveParticipant(id)
end)
table.insert(self.connections, removedConnection)
self.userList = UserList.new(appState, nil, filter)
local userSelectedConnection = self.userList.userSelected:connect(function(user)
local selected = Functional.Find(self.participants, user.id)
if selected then
self:RemoveParticipant(user.id)
else
self:AddParticipant(user.id)
end
self:ClearText()
end)
table.insert(self.connections, userSelectedConnection)
self.rbx = Create.new"Frame" {
Name = "FriendSearchBox",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = "LayoutOrder",
},
self.friendThumbnails.rbx,
Create.new"Frame" {
Name = "Divider1",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
LayoutOrder = 2,
},
ListSection.new(appState, nil, 3).rbx,
Create.new"Frame" {
Name = "SearchContainer",
BackgroundTransparency = 0,
BackgroundColor3 = Constants.Color.WHITE,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, SEARCH_BOX_HEIGHT),
LayoutOrder = 4,
Create.new"ImageLabel" {
Name = "SearchIcon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 24, 0, 24),
Position = UDim2.new(0, ICON_CELL_WIDTH/2, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
ImageColor3 = Constants.Color.GRAY3,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-search.png",
},
Create.new"TextBox" {
Name = "Search",
BackgroundTransparency = 1,
Size = UDim2.new(1, -CLEAR_TEXT_WIDTH-ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = "",
PlaceholderText = appState.localization:Format("Feature.Friends.Label.SearchFriends"),
PlaceholderColor3 = Constants.Color.GRAY3,
TextXAlignment = Enum.TextXAlignment.Left,
OverlayNativeInput = true,
ClearTextOnFocus = false,
ClipsDescendants = true,
},
Create.new"ImageButton" {
Name = "Clear",
BackgroundTransparency = 1,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(1, -(CLEAR_TEXT_WIDTH/2), 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
AutoButtonColor = false,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-clear-solid.png",
ImageTransparency = 1,
},
},
Create.new"Frame" {
Name = "Divider2",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
LayoutOrder = 5,
},
Create.new"ScrollingFrame" {
Name = "ScrollingFrame",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 5,
BottomImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
MidImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
TopImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
LayoutOrder = 6,
self.userList.rbx,
},
}
self.searchContainer = self.rbx.SearchContainer
local clearButton = self.searchContainer.Clear
local search = self.searchContainer.Search
self.search = search
if FFlagTextBoxOverrideManualFocusRelease then
search.ManualFocusRelease = true
end
local clearButtonConnection = getInputEvent(clearButton):Connect(function()
self:ClearText()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, clearButtonConnection)
else
table.insert(self.connections, clearButtonConnection)
end
self:Resize()
local function updateClearButtonVisibility()
-- If we were to set the visible property of the clear button on the textbox focus lost event
-- it would disable the clear button, which in turn would stop the click event
-- from being able to notify the button
local visible = search:IsFocused() and (search.Text ~= "")
clearButton.ImageTransparency = visible and 0 or 1
end
self.searchChanged = Signal.new()
self.addParticipant = Signal.new()
self.removeParticipant = Signal.new()
local searchChangedConnection = search:GetPropertyChangedSignal("Text"):Connect(function()
updateClearButtonVisibility()
self.userList:ApplySearch(search.Text)
self:Resize()
self:ResizeCanvas()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, searchChangedConnection)
else
table.insert(self.connections, searchChangedConnection)
end
local focusedConnection = search.Focused:Connect(updateClearButtonVisibility)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, focusedConnection)
else
table.insert(self.connections, focusedConnection)
end
local focusLostConnection = search.FocusLost:Connect(updateClearButtonVisibility)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, focusLostConnection)
else
table.insert(self.connections, focusLostConnection)
end
self:UpdateFriends(appState.store:getState().Users, self.participants)
local userListAddConnection = self.userList.rbx.ChildAdded:Connect(function(child)
self:ResizeCanvas();
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, userListAddConnection)
else
table.insert(self.connections, userListAddConnection)
end
local userListRemoveConnection = self.userList.rbx.ChildRemoved:Connect(function(child)
self:ResizeCanvas();
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, userListRemoveConnection)
else
table.insert(self.connections, userListRemoveConnection)
end
local userListSizeConnection = self.userList.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:ResizeCanvas()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, userListSizeConnection)
else
table.insert(self.connections, userListSizeConnection)
end
return self
end
function FriendSearchBox:Resize()
local height = 0
for _, element in pairs(self.rbx:GetChildren()) do
if element:IsA("GuiObject") and element.Visible and element.Name ~= "ScrollingFrame" then
height = height + element.AbsoluteSize.Y
end
end
self.rbx.ScrollingFrame.Size = UDim2.new(1, 0, 1, -height)
end
function FriendSearchBox:ResizeCanvas()
local height = 0
for _, element in pairs(self.userList.rbx:GetChildren()) do
if element:IsA("GuiObject") and element.Visible then
height = height + element.AbsoluteSize.Y
end
end
self.rbx.ScrollingFrame.CanvasSize = UDim2.new(1, 0, 0, height)
end
function FriendSearchBox:AddParticipant(userId)
if #self.participants >= self.maxParticipantCount then
if self.tooManyFriendsToastModel == nil then
local messageKey = "Feature.Chat.Message.ToastText"
local messageArguments = {
friendNum = tostring(Constants.MAX_PARTICIPANT_COUNT),
}
local toastModel = ToastModel.new(Constants.ToastIDs.TOO_MANY_PEOPLE, messageKey, messageArguments)
self.tooManyFriendsToastModel = toastModel
end
self.appState.store:dispatch(ShowToast(self.tooManyFriendsToastModel))
else
self.addParticipant:fire(userId)
end
end
function FriendSearchBox:RemoveParticipant(userId)
self.removeParticipant:fire(userId)
end
function FriendSearchBox:UpdateFriends(users, selectedList)
local friends = {}
for _, user in pairs(users) do
table.insert(friends, user)
end
if FFlagLuaChatSortFriendsForConversation then
table.sort(friends, friendSortPredicate)
for _,friend in pairs (friends) do
if not PRESENCE_WEIGHTS[friend.presence] then
warn("Invalid presence value for "..friend.name)
end
end
end
self.userList:Update(friends, selectedList)
self:Resize()
end
function FriendSearchBox:ClearText()
self.searchContainer.Search.Text = ""
self:Resize()
end
function FriendSearchBox:Update(participants)
local state = self.appState.store:getState()
local users = state.Users
if participants ~= self.participants then
self.friendThumbnails:Update(participants)
end
if participants ~= self.participants or users ~= self.users then
self:UpdateFriends(users, participants)
end
self.participants = participants
self.users = users
self:Resize()
end
function FriendSearchBox:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
self.userList:Destruct()
self.friendThumbnails:Destruct()
self.rbx.Parent = nil
self.rbx:Destroy()
end
return FriendSearchBox
@@ -0,0 +1,220 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local ConversationThumbnail = require(LuaChat.Components.ConversationThumbnail)
local Create = require(LuaChat.Create)
local DateTime = require(LuaChat.DateTime)
local getConversationDisplayTitle = require(LuaChat.Utils.getConversationDisplayTitle)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local TextButton = require(LuaChat.Components.TextButton)
local UserPresenceTextLabel = require(LuaChat.Components.UserPresenceTextLabel)
local FlagSettings = require(LuaChat.FlagSettings)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local UseCppTextTruncation = FlagSettings.UseCppTextTruncation()
local GameShareCard = {}
GameShareCard.__index = GameShareCard
local ICON_CELL_WIDTH = 60
local HEIGHT = 54
local function getOneToOneConversationFriend(appState, conversation)
local friend = nil
if #conversation.participants == 2 then
local friendId = nil
for _, userId in ipairs(conversation.participants) do
if userId ~= tostring(Players.LocalPlayer.UserId) then
friendId = userId
break
end
end
if friendId then
friend = appState.store:getState().Users[friendId]
end
end
return friend
end
function GameShareCard.new(appState, conversation)
local self = {}
self.appState = appState
self.conversation = conversation
self.conversationId = conversation.id
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.Tapped = Signal.new()
self.startTime = DateTime.now()
setmetatable(self, GameShareCard)
local friend = getOneToOneConversationFriend(appState, conversation)
self.rbx = Create.new"Frame" {
Name = "GameShareCard",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, HEIGHT),
}
local conversationThumbnail = ConversationThumbnail.new(appState, conversation)
conversationThumbnail.rbx.Size = UDim2.new(0, 36, 0, 36)
conversationThumbnail.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
conversationThumbnail.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.conversationThumbnail = conversationThumbnail
if friend then
self.presenceSubLabel = UserPresenceTextLabel.new(appState, friend.id, {
Name = "subLabel",
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0.35, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0.55, 0),
})
self.presenceSubLabel.rbx.Parent = self.rbx
end
self.sendButton = TextButton.new(self.appState, "SendButton", "Feature.Chat.Action.Send")
local label = self.sendButton.rbx.Label
label.TextColor3 = Constants.Color.GRAY1
label.TextSize = Constants.Font.FONT_SIZE_16
label.Font = Enum.Font.SourceSans
self.sendButton:SetEnabled(true)
self.sendButton.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.sendButton.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
local sendTextWidth = Text.GetTextWidth(label.Text, label.Font, label.TextSize)
local conversationThumbnailFrame = Create.new"Frame" {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
conversationThumbnail.rbx,
}
conversationThumbnailFrame.Parent = self.rbx
self.sentLabel = Create.new "TextLabel" {
Name = "SentLabel",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0.5, 0.5),
TextSize = Constants.Font.FONT_SIZE_16,
Size = UDim2.new(1, 0, 0, 20),
Position = UDim2.new(0.5, 0, 0.5, 0),
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Right,
Text = self.appState.localization:Format("Feature.Chat.Label.Sent"),
Visible = false
}
local sendButtonFrame = Create.new"Frame" {
Name = "SendButtonFrame",
BackgroundTransparency = 1,
Size = UDim2.new(0, sendTextWidth + 14, 0, 32),
Position = UDim2.new(1, -((sendTextWidth + 14) /2) - 12, .5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Create.new"ImageLabel"{
Name = "SendImageLabel",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
ScaleType = "Slice",
SliceCenter = Rect.new(3,3,4,4),
Image = "rbxasset://textures/ui/LuaChat/9-slice/btn-control-sm.png",
self.sendButton.rbx,
},
self.sentLabel,
}
self.sendImageLabel = sendButtonFrame.SendImageLabel
sendButtonFrame.Parent = self.rbx
local sendButtonConnection = self.sendButton.Pressed:connect(function()
self.Tapped:fire()
self.sendImageLabel.Visible = false
self.sentLabel.Visible = true
end)
table.insert(self.connections, sendButtonConnection)
self.conversationTitle = Create.new"TextLabel" {
Name = "ConversationTitle",
BackgroundTransparency = 1,
Size = UDim2.new(1, -ICON_CELL_WIDTH - sendButtonFrame.Size.X.Offset - 20, 0.75, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = getConversationDisplayTitle(conversation),
TextTruncate = UseCppTextTruncation and Enum.TextTruncate.AtEnd or nil,
TextXAlignment = Enum.TextXAlignment.Left,
}
self.conversationTitle.Parent = self.rbx
local convoTitleChanged = self.conversationTitle:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self.conversationTitle.Text = getConversationDisplayTitle(conversation)
if not UseCppTextTruncation then
Text.TruncateTextLabel(self.conversationTitle, "...")
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, convoTitleChanged)
else
table.insert(self.connections, convoTitleChanged)
end
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0, 1),
Position = UDim2.new(0, ICON_CELL_WIDTH, 1, -1),
}
divider.Parent = self.rbx
return self
end
function GameShareCard:Update(conversation)
if not conversation.lastUpdated then
return
end
if conversation.lastUpdated:GetUnixTimestamp() < self.startTime:GetUnixTimestamp() then
self.conversation = conversation
end
end
function GameShareCard:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
if self.conversationThumbnail then
self.conversationThumbnail:Destruct()
end
if self.presenceSubLabel then
self.presenceSubLabel:Destruct()
end
self.rbx:Destroy()
end
return GameShareCard
@@ -0,0 +1,423 @@
local CoreGui = game:GetService("CoreGui")
local GuiService = game:GetService("GuiService")
local PlayerService = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local DialogInfo = require(LuaChat.DialogInfo)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local GetMultiplePlaceInfos = require(LuaChat.Actions.GetMultiplePlaceInfos)
local HeaderLoader = require(LuaChat.Components.HeaderLoader)
local PlaceInfoCard = require(LuaChat.Components.PlaceInfoCard)
local GameShareCard = require(LuaChat.Components.GameShareCard)
local ConversationList = require(LuaChat.Components.ConversationList)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local truncateAssetLink = require(LuaChat.Utils.truncateAssetLink)
local FlagSettings = require(LuaApp.FlagSettings)
local HttpStatus = require(LuaChat.WebApi).Status
local ToastModel = require(LuaChat.Models.ToastModel)
local ShowToast = require(LuaChat.Actions.ShowToast)
local NavigateBack = require(Modules.LuaApp.Thunks.NavigateBack)
local RemoveRoute = require(LuaChat.Actions.RemoveRoute)
local SetAppLoaded = require(LuaChat.Actions.SetAppLoaded)
local NotificationType = require(LuaApp.Enum.NotificationType)
local Intent = DialogInfo.Intent
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local FFlagShareGameToChatStatusAnalytics = settings():GetFFlag("ShareGameToChatStatusAnalytics")
local GameShareComponent = {}
GameShareComponent.__index = GameShareComponent
local ICON_CELL_WIDTH = 60
local SEARCH_BOX_HEIGHT = 48
local CLEAR_TEXT_WIDTH = 44
local PLACE_INFO_FRAME_HEIGHT = 84
local function requestOlderConversations(appState)
-- Don't fetch older conversations if the oldest conversation has already been fetched.
if appState.store:getState().ChatAppReducer.ConversationsAsync.oldestConversationIsFetched then
return
end
-- Ask for new conversations
local convoCount = 0
for _, _ in pairs(appState.store:getState().ChatAppReducer.Conversations) do
convoCount = convoCount + 1
end
local pageSize = Constants.PageSize.GET_CONVERSATIONS
local currentPage = math.floor(convoCount / pageSize)
spawn(function()
appState.store:dispatch(ConversationActions.GetLocalUserConversationsAsync(currentPage + 1, pageSize))
end)
end
function GameShareComponent.new(appState, placeId, innerFrame)
local self = {}
self._analytics = appState.analytics
self.appState = appState
self.placeId = placeId
self.placeInfo = appState.store:getState().ChatAppReducer.PlaceInfos[placeId]
setmetatable(self, GameShareComponent)
-- Header
self.header = HeaderLoader.GetHeader(appState, Intent.GameShare)
self.header:SetDefaultSubtitle()
self.header:SetTitle(appState.localization:Format("Feature.Chat.Heading.ShareGameToChat"))
self.header:SetBackButtonEnabled(true)
-- Place Info Card Frame
self.placeInfoCardFrame = Create.new"Frame" {
Name = "PlaceInfoCardFrame",
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, PLACE_INFO_FRAME_HEIGHT),
LayoutOrder = 2,
}
-- Search Container
self.searchContainer = Create.new"Frame" {
Name = "SearchContainer",
BackgroundTransparency = 0,
BackgroundColor3 = Constants.Color.WHITE,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, SEARCH_BOX_HEIGHT),
Visible = false,
LayoutOrder = 3,
Create.new"ImageLabel" {
Name = "SearchIcon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 24, 0, 24),
Position = UDim2.new(0, ICON_CELL_WIDTH/2, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
ImageColor3 = Constants.Color.GRAY3,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-search.png",
},
Create.new"TextBox" {
Name = "Search",
BackgroundTransparency = 1,
Size = UDim2.new(1, -CLEAR_TEXT_WIDTH-ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = "",
PlaceholderText = appState.localization:Format("Feature.Chat.Label.SearchForFriendsAndChat"),
PlaceholderColor3 = Constants.Color.GRAY3,
TextXAlignment = Enum.TextXAlignment.Left,
OverlayNativeInput = true,
ClearTextOnFocus = false,
ClipsDescendants = true,
},
Create.new"ImageButton" {
Name = "Clear",
BackgroundTransparency = 1,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(1, -(CLEAR_TEXT_WIDTH/2), 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
AutoButtonColor = false,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-clear-solid.png",
ImageTransparency = 1,
},
}
self.clearSearchButton = self.searchContainer.Clear
self.searchBox = self.searchContainer.Search
self.SearchFilterPredicate = function(other)
if self.searchBox.Text == "" then
return true
end
return string.find(string.lower(other), string.lower(self.searchBox.Text), 1, true) ~= nil
end
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
LayoutOrder = 4,
}
-- Conversation List Frame
self.conversationListFrame = Create.new "Frame" {
Name = "ConversationListFrame",
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = 5,
Size = UDim2.new(1, 0, 1, - self.header.rbx.Size.Y.Offset - SEARCH_BOX_HEIGHT - PLACE_INFO_FRAME_HEIGHT),
}
self.placeInfoCardFrame.Parent = innerFrame
self.searchContainer.Parent = innerFrame
divider.Parent = innerFrame
self.conversationListFrame.Parent = innerFrame
self.rbx = Create.new"ImageButton" {
Active = true,
AutoButtonColor = false,
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
Create.new("UIListLayout") {
Name = "ListLayout",
SortOrder = "LayoutOrder",
HorizontalAlignment = "Center",
},
self.header.rbx,
innerFrame,
}
if not appState.store:getState().ChatAppReducer.AppLoaded then
spawn(function()
appState.store:dispatch(
ConversationActions.GetLocalUserConversationsAsync(1, Constants.PageSize.GET_CONVERSATIONS)
):andThen(function()
appState.store:dispatch(SetAppLoaded(true))
end)
end)
end
return self
end
function GameShareComponent:Start()
local isLuaAppStarterScriptEnabled = FlagSettings:IsLuaAppStarterScriptEnabled()
self.connections = {}
-- back button
local backButtonConnection
if isLuaAppStarterScriptEnabled then
backButtonConnection = self.header.BackButtonPressed:connect(function()
self.appState.store:dispatch(NavigateBack())
GuiService:BroadcastNotification("", NotificationType.CLOSE_MODAL)
end)
else
backButtonConnection = self.header.BackButtonPressed:connect(function()
self.appState.store:dispatch(RemoveRoute(DialogInfo.Intent.GameShare))
GuiService:BroadcastNotification("", NotificationType.CLOSE_MODAL)
end)
end
table.insert(self.connections, backButtonConnection)
-- update
local appStateConnection = self.appState.store.changed:connect(function(state, oldState)
self:Update(state, oldState)
end)
table.insert(self.connections, appStateConnection)
-- search clear button
local clearButtonConnection = getInputEvent(self.clearSearchButton):Connect(function()
self.searchBox.Text = ""
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, clearButtonConnection)
else
table.insert(self.connections, clearButtonConnection)
end
local function updateClearButtonVisibility()
-- If we were to set the visible property of the clear button on the textbox focus lost event
-- it would disable the clear button, which in turn would stop the click event
-- from being able to notify the button
local visible = self.searchBox:IsFocused() and (self.searchBox.Text ~= "")
self.clearSearchButton.ImageTransparency = visible and 0 or 1
end
-- search box
local searchChangedConnection = self.searchBox:GetPropertyChangedSignal("Text"):Connect(function()
if self.list then
updateClearButtonVisibility()
self.list:SetFilterPredicate(self.SearchFilterPredicate)
self:getOlderConversationsForSearchIfNecessary()
end
end)
table.insert(self.connections, searchChangedConnection)
local focusedConnection = self.searchBox.Focused:Connect(updateClearButtonVisibility)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, focusedConnection)
else
table.insert(self.connections, focusedConnection)
end
local focusLostConnection = self.searchBox.FocusLost:Connect(updateClearButtonVisibility)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, focusLostConnection)
else
table.insert(self.connections, focusLostConnection)
end
if not self.placeInfo then
self.appState.store:dispatch(GetMultiplePlaceInfos({self.placeId}))
end
if self.appState.store:getState().ChatAppReducer.AppLoaded and self.placeInfo then
self:FillContent()
end
end
function GameShareComponent:Update(newState, oldState)
if (not oldState.ChatAppReducer.AppLoaded) and newState.ChatAppReducer.AppLoaded and self.placeInfo then
self:FillContent()
end
if (not self.placeInfo) and (newState.ChatAppReducer.PlaceInfos[self.placeId]) then
self.placeInfo = newState.ChatAppReducer.PlaceInfos[self.placeId]
if newState.ChatAppReducer.AppLoaded then
self:FillContent()
end
end
local newPageConversationsIsFetching = newState.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching
local oldPageConversationsIsFetching = oldState.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching
if newPageConversationsIsFetching ~= oldPageConversationsIsFetching and self.list then
self.list:Update(newState, oldState)
self:getOlderConversationsForSearchIfNecessary()
end
if newState.ChatAppReducer.Conversations ~= oldState.ChatAppReducer.Conversations and self.list then
self.list:Update(newState, oldState)
end
end
function GameShareComponent:FillContent()
self.searchContainer.Visible = true
self:FillPlaceInfo()
self:FillConversations()
end
function GameShareComponent:FillPlaceInfo()
if self.placeInfoCard then
return
end
self.placeInfoCard = PlaceInfoCard.new(self.appState, self.placeInfo)
self.placeInfoCard.rbx.Parent = self.placeInfoCardFrame
end
function GameShareComponent:FillConversations()
local conversations = self.appState.store:getState().ChatAppReducer.Conversations
local list = ConversationList.new(self.appState, conversations, GameShareCard)
list:SetSortWithConversationEntry(true)
self.list = list
list.rbx.Size = UDim2.new(1, 0, 1, 0)
list.rbx.Parent = self.conversationListFrame
local tappedConnection = list.ConversationTapped:connect(function(convoId)
local messageSentLocalTime = tick()
if FFlagShareGameToChatStatusAnalytics then
self.appState.store:dispatch(
ConversationActions.SendMessage(
convoId,
truncateAssetLink(self.placeInfo.url),
messageSentLocalTime
)):andThen(function(webStatus)
self:ReportSendButtonTappedEvent(convoId, webStatus)
if webStatus == HttpStatus.MODERATED then
local toastModel = ToastModel.new(Constants.ToastIDs.MESSAGE_WAS_MODERATED, "Feature.Chat.Message.GameLinkWasModerated")
self.appState.store:dispatch(ShowToast(toastModel))
end
end)
else
self:ReportSendButtonTappedEvent(convoId)
self.appState.store:dispatch(
ConversationActions.SendMessage(
convoId,
truncateAssetLink(self.placeInfo.url),
"Feature.Chat.Message.GameLinkWasModerated",
messageSentLocalTime
)
)
end
end)
table.insert(self.connections, tappedConnection)
local requestOlderConversationConnection = list.RequestOlderConversations:connect(function()
requestOlderConversations(self.appState)
end)
table.insert(self.connections, requestOlderConversationConnection)
end
function GameShareComponent:getOlderConversationsForSearchIfNecessary(appState)
-- To Check:
-- 1) Search is open
-- 2) Not have loaded all conversations.
-- 3) Not Ccrrently getting older conversations
-- 4) Having enouth search items to show
-- Note that we already try to load more conversations if we scroll down to the bottom of the list
local state = self.appState.store:getState()
local isSearchOpen = (self.searchBox.Text) ~= nil and (self.searchBox.Text ~= "")
if (not isSearchOpen) or state.ChatAppReducer.ConversationsAsync.oldestConversationIsFetched
or state.ChatAppReducer.ConversationsAsync.pageConversationsIsFetching then
return
end
if self.list.rbx.CanvasSize.Y.Offset > self.list.rbx.AbsoluteSize.Y then
return
end
requestOlderConversations(self.appState)
end
function GameShareComponent:ReportSendButtonTappedEvent(convoId, httpStatus)
local eventName = "clickSendBtnFromGameShareCard"
local eventContext = "touch"
local player = PlayerService.LocalPlayer
local userId = "UNKNOWN"
if player then
userId = tostring(player.UserId)
end
local additionalArgs = {
uid = userId,
placeid = self.placeId,
cid = convoId,
httpStatus = httpStatus,
}
self._analytics.EventStream:setRBXEvent(eventContext, eventName, additionalArgs)
end
function GameShareComponent:Stop()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
end
function GameShareComponent:Destruct()
if self.list then
self.list:Destruct()
end
if self.header then
self.header:Destroy()
end
if self.placeInfoCard then
self.placeInfoCard:Destruct()
end
self.rbx:Destroy()
end
return GameShareComponent
@@ -0,0 +1,344 @@
local Players = game:GetService("Players")
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Signal = require(Common.Signal)
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local Functional = require(Common.Functional)
local DialogInfo = require(LuaChat.DialogInfo)
local Components = LuaChat.Components
local HeaderLoader = require(Components.HeaderLoader)
local SectionComponent = require(Components.ListSection)
local ActionEntryComponent = require(Components.ActionEntry)
local UserListComponent = require(Components.UserList)
local ListEntryComponent = require(Components.ListEntry)
local ResponseIndicator = require(Components.ResponseIndicator)
local GenericDialogType = require(Components.GroupDetailDialogs.GenericDialogType)
local getConversationDisplayTitle = require(LuaChat.Utils.getConversationDisplayTitle)
local ConversationModel = require(LuaChat.Models.Conversation)
local SetRoute = require(LuaChat.Actions.SetRoute)
local Intent = DialogInfo.Intent
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local PARTICIPANT_VIEW = 1
local PARTICIPANT_REPORT = 2
local PARTICIPANT_REMOVE = 3
local function getAsset(name)
return "rbxasset://textures/ui/LuaChat/"..name..".png"
end
local SeeMoreButton = {}
SeeMoreButton.__index = SeeMoreButton
function SeeMoreButton.new(appState)
local self = {}
setmetatable(self, SeeMoreButton)
local listEntry = ListEntryComponent.new(appState, 40)
self.rbx = listEntry.rbx
self.tapped = listEntry.tapped
local label = Create.new"TextLabel" {
Name = "Label",
BackgroundTransparency = 1,
Size = UDim2.new(1, -12, 1, 0),
Position = UDim2.new(0, 12, 0, 0),
TextSize = Constants.Font.FONT_SIZE_16,
TextColor3 = Constants.Color.BLUE_PRIMARY,
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Left,
Text = appState.localization:Format("Feature.Chat.Action.SeeMoreFriends"),
}
label.Parent = self.rbx
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, -1),
}
divider.Parent = self.rbx
return self
end
function SeeMoreButton:Update(text)
self.rbx.Label.Text = text
end
local GroupDetail = {}
GroupDetail.__index = GroupDetail
function GroupDetail.new(appState, convoId)
local self = {}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
setmetatable(self, GroupDetail)
self.appState = appState
self.conversationId = convoId
self.AddFriendsPressed = Signal.new()
self.oldState = nil
self.header = HeaderLoader.GetHeader(appState, Intent.GroupDetail)
self.header:SetTitle(appState.localization:Format("Feature.Chat.Label.ChatDetails"))
self.header:SetDefaultSubtitle()
self.header:SetBackButtonEnabled(true)
self.rbx = Create.new"Frame" {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
self.header.rbx,
Create.new "ScrollingFrame" {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 0,
Create.new"Frame" {
Name = "Content",
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
},
},
},
}
local scrollingFrame = self.rbx.ScrollingFrame
local content = scrollingFrame.Content
self.BackButtonPressed = self.header.BackButtonPressed
scrollingFrame.Position = UDim2.new(0, 0, 0, self.header.rbx.Size.Y.Offset)
scrollingFrame.Size = UDim2.new(1, 0, 1, -self.header.rbx.Size.Y.Offset)
self.general = SectionComponent.new(appState, "Feature.Chat.Label.General")
self.general.rbx.LayoutOrder = 1
self.general.rbx.Parent = content
self.groupName = ActionEntryComponent.new(appState, getAsset("icons/ic-nametag"), "Feature.Chat.Label.ChatGroupName")
self.groupName.rbx.LayoutOrder = 2
self.groupName.rbx.Parent = content
local groupNameConnection = self.groupName.tapped.Event:Connect(function()
self.appState.store:dispatch(SetRoute(Intent.GenericDialog, {
dialog = GenericDialogType.EditChatGroupNameDialog,
dialogParameters = {
titleLocalizationKey = "Feature.Chat.Label.ChatGroupName",
maxChar = 150,
conversation = self.conversation,
}
}
))
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, groupNameConnection)
else
table.insert(self.connections, groupNameConnection)
end
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
self.responseIndicator.rbx.Parent = self.rbx
local members = SectionComponent.new(appState, "Feature.Chat.Label.Members")
members.rbx.LayoutOrder = 5
members.rbx.Parent = content
self.addFriends = ActionEntryComponent.new(appState, getAsset("icons/ic-add-friends"),
"Feature.Chat.Label.AddFriends", 36)
self.addFriends.rbx.LayoutOrder = 6
self.addFriends:SetDividerOffset(60)
self.addFriends.rbx.Parent = content
self.AddFriendsPressed = self.addFriends.tapped.Event
self.participantsList = UserListComponent.new(appState, getAsset("icons/ic-more"))
self.participantsList.rbx.LayoutOrder = 7
self.participantsList.rbx.Parent = content
local userSelectedConnection = self.participantsList.userSelected:connect(function(user)
if user.id ~= tostring(Players.LocalPlayer.UserId) then
self.appState.store:dispatch(SetRoute(Intent.GenericDialog, {
dialog = GenericDialogType.ParticipantDialog,
dialogParameters = {
titleKey = "Feature.Chat.Heading.Option",
options = {
[PARTICIPANT_VIEW] = "Feature.Chat.Label.ViewProfile",
[PARTICIPANT_REPORT] = "Feature.Chat.Action.ReportUser",
[PARTICIPANT_REMOVE] = "Feature.Chat.Action.RemoveFromGroup",
},
conversationId = self.conversationId,
conversation = self.conversation,
userId = user.id
}
}
))
end
end)
table.insert(self.connections, userSelectedConnection)
self.seeMore = SeeMoreButton.new(appState)
self.seeMore.rbx.LayoutOrder = 9
self.seeMore.rbx.Parent = content
self.showAllParticipants = false
local seeMoreConnection = self.seeMore.tapped:connect(function()
if self.showAllParticipants then
self.showAllParticipants = false
self:Update(appState.store:getState())
else
self.showAllParticipants = true
self:Update(appState.store:getState())
end
end)
table.insert(self.connections, seeMoreConnection)
self.blankSection = SectionComponent.new(appState)
self.blankSection.rbx.LayoutOrder = 10
self.blankSection.rbx.Parent = content
self.leaveGroup = ActionEntryComponent.new(appState, getAsset("icons/ic-leave"), "Feature.Chat.Heading.LeaveGroup")
self.leaveGroup.rbx.LayoutOrder = 11
self.leaveGroup.rbx.Parent = content
local leaveGroupConnection = self.leaveGroup.tapped.Event:Connect(function()
self.appState.store:dispatch(SetRoute(Intent.GenericDialog, {
dialog = GenericDialogType.LeaveGroupDialog,
dialogParameters = {
titleKey = "Feature.Chat.Heading.LeaveGroup",
messageKey = "Feature.Chat.Message.LeaveGroup",
cancelTitleKey = "Feature.Chat.Action.Stay",
confirmationTitleKey = "Feature.Chat.Action.Leave",
conversation = self.conversation
}
}
))
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, leaveGroupConnection)
else
table.insert(self.connections, leaveGroupConnection)
end
content.ListLayout:ApplyLayout()
self.conversation = ConversationModel.empty()
self:Update(appState.store:getState())
return self
end
function GroupDetail:Update(state)
local conversationId = state.ChatAppReducer.Location.current.parameters.conversationId
local conversation = state.ChatAppReducer.Conversations[conversationId]
self.header:SetConnectionState(state.ConnectionState)
if conversation ~= nil then --if conversation ~= self.conversation then
if conversation.id ~= self.conversation.id then
self.showAllParticipants = false
end
if conversation.isUserLeaving ~= self.conversation.isUserLeaving then
self.responseIndicator:SetVisible(conversation.isUserLeaving)
end
if conversation.isDefaultTitle then
local notSetLocalized = self.appState.localization:Format("Feature.Chat.Label.NotSet")
self.groupName:Update(notSetLocalized)
else
self.groupName:Update(getConversationDisplayTitle(conversation))
end
local count = 0
local users = Functional.Map(conversation.participants, function(userId)
count = count + 1
return (count <= 3 or self.showAllParticipants) and state.Users[userId] or nil
end)
if count > 3 and not self.showAllParticipants then
local messageArguments = {
NUMBER_OF_FRIENDS = tostring(count-3)
}
local message = self.appState.localization:Format("Feature.Chat.Action.SeeMoreFriends", messageArguments)
self.seeMore:Update(message)
self.seeMore.rbx.Visible = true
elseif count > 3 then
self.seeMore:Update(self.appState.localization:Format("Feature.Chat.Label.SeeLess"))
self.seeMore.rbx.Visible = true
else
self.seeMore.rbx.Visible = false
end
self.participantsList:Update(users)
if conversation.conversationType == ConversationModel.Type.MULTI_USER_CONVERSATION then
self.general.rbx.Visible = true
self.groupName.rbx.Visible = true
self.leaveGroup.rbx.Visible = true
self.blankSection.rbx.Visible = true
elseif conversation.conversationType == ConversationModel.Type.ONE_TO_ONE_CONVERSATION then
self.general.rbx.Visible = false
self.groupName.rbx.Visible = false
self.leaveGroup.rbx.Visible = false
self.blankSection.rbx.Visible = false
end
self.conversation = conversation
end
if self.oldState == nil or state.ChatAppReducer.Location.current ~= self.oldState.ChatAppReducer.Location.current then
if self.oldState ~= nil and state.ChatAppReducer.Location.current.intent == Intent.GroupDetail then
-- If any Dialog is mounted on ModalBase, close them.
if self.oldState.ChatAppReducer.Location.current.intent == Intent.GenericDialog then
self.oldState.ChatAppReducer.Location.current.parameters.dialog:Close()
end
end
end
local highestYValue = 0
for _, element in pairs(self.rbx.ScrollingFrame.Content:GetChildren()) do
if element:IsA("GuiObject") then
highestYValue = highestYValue + element.Size.Y.Offset
end
end
self.rbx.ScrollingFrame.CanvasSize = UDim2.new(1, 0, 0, highestYValue)
self.rbx.ScrollingFrame.Content.Size = UDim2.new(1, 0, 0, highestYValue)
self.oldState = state
end
function GroupDetail:Stop()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
end
function GroupDetail:Destruct()
self.responseIndicator:Destruct()
self.rbx:Destroy()
end
return GroupDetail
@@ -0,0 +1,56 @@
local Modules = script.Parent.Parent.Parent
local Components = Modules.Components
local DialogComponents = require(Components.DialogComponents)
local ConversationActions = require(Modules.Actions.ConversationActions)
local ResponseIndicator = require(Components.ResponseIndicator)
local EditChatGroupNameDialog = {}
function EditChatGroupNameDialog.new(appState, titleLocalizationKey, maxChar, conversation)
local self = {}
setmetatable(self, {__index = EditChatGroupNameDialog})
self.dialog = DialogComponents.TextInputDialog.new(appState, titleLocalizationKey, maxChar)
-- Setup ResponseIndicator
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
self.responseIndicator.rbx.Parent = self.dialog.rbx
self.conversation = conversation
self:UpdateGroupName()
-- Define saved action
self.savedConnection = self.dialog.saved:connect(function(newName)
self.responseIndicator:SetVisible(true)
local callback = function()
self.responseIndicator:SetVisible(false)
end
local action = ConversationActions.RenameGroupConversation(self.conversation.id, newName, callback)
appState.store:dispatch(action)
end)
return self
end
function EditChatGroupNameDialog:UpdateGroupName()
local groupName = ""
if not self.conversation.isDefaultTitle then
groupName = self.conversation.title
end
self.dialog:Update(groupName)
end
function EditChatGroupNameDialog:Destruct()
if self.savedConnection then
self.savedConnection:disconnect()
end
self.responseIndicator:Destruct()
self.dialog:Destruct()
end
return EditChatGroupNameDialog
@@ -0,0 +1,8 @@
local GenericDialogType = {
EditChatGroupNameDialog = "EditChatGroupNameDialog",
LeaveGroupDialog = "LeaveGroupDialog",
ParticipantDialog = "ParticipantDialog",
RemoveUserDialog = "RemoveUserDialog",
}
return GenericDialogType
@@ -0,0 +1,36 @@
local Players = game:GetService("Players")
local Modules = script.Parent.Parent.Parent
local Components = Modules.Components
local DialogComponents = require(Components.DialogComponents)
local ConversationActions = require(Modules.Actions.ConversationActions)
local LeaveGroupDialog = {}
function LeaveGroupDialog.new(appState, titleKey, messageKey, cancelTitleKey, confirmTitleKey, conversation)
local self = {}
setmetatable(self, {__index = LeaveGroupDialog})
self.dialog = DialogComponents.ConfirmationDialog.new(appState, titleKey, messageKey, cancelTitleKey, confirmTitleKey)
self.conversation = conversation
self.dialogConnection = self.dialog.saved:connect(function()
local userId = tostring(Players.LocalPlayer.UserId)
local convoId = self.conversation.id
local action = ConversationActions.RemoveUserFromConversation(userId, convoId)
appState.store:dispatch(action)
end)
return self
end
function LeaveGroupDialog:Destruct()
if self.dialogConnection then
self.dialogConnection:disconnect()
end
self.dialog:Destruct()
end
return LeaveGroupDialog
@@ -0,0 +1,96 @@
local Players = game:GetService("Players")
local GuiService = game:GetService("GuiService")
local CoreGui = game:GetService("CoreGui")
local LuaApp = CoreGui.RobloxGui.Modules.LuaApp
local LuaChat = CoreGui.RobloxGui.Modules.LuaChat
local GenericDialogType = require(LuaChat.Components.GroupDetailDialogs.GenericDialogType)
local DialogComponents = require(LuaChat.Components.DialogComponents)
local WebApi = require(LuaChat.WebApi)
local ConversationModel = require(LuaChat.Models.Conversation)
local SetRoute = require(LuaChat.Actions.SetRoute)
local DialogInfo = require(LuaChat.DialogInfo)
local NotificationType = require(LuaApp.Enum.NotificationType)
local Intent = DialogInfo.Intent
local PARTICIPANT_VIEW = 1
local PARTICIPANT_REPORT = 2
local PARTICIPANT_REMOVE = 3
local ParticipantDialog = {}
function ParticipantDialog.new(appState, titleKey, options, conversationId, conversation, userId)
local self = {}
setmetatable(self, {__index = ParticipantDialog})
self.appState = appState
self.dialog = DialogComponents.OptionDialog.new(appState, titleKey, options, userId)
self.conversationId = conversationId
self.conversation = conversation
if conversation ~= nil then
if conversation.initiator == tostring(Players.LocalPlayer.UserId)
and conversation.conversationType == ConversationModel.Type.MULTI_USER_CONVERSATION then
self.dialog.optionGuis[PARTICIPANT_REMOVE].Visible = true
else
self.dialog.optionGuis[PARTICIPANT_REMOVE].Visible = false
end
self.dialog:Resize()
end
self.dialogConnection = self.dialog.selected:connect(function(optionId, userIdSelected)
local user = self.appState.store:getState().Users[userIdSelected]
if user == nil then
return
end
if optionId == PARTICIPANT_VIEW then
if user and user.id and (type(user.id) == 'string' or type(user.id) == 'number') then
GuiService:BroadcastNotification(WebApi.MakeUserProfileUrl(user.id),
NotificationType.VIEW_PROFILE)
else
print("Bad input to RequestNativeView, show error prompt here")
end
elseif optionId == PARTICIPANT_REPORT then
if user and user.id and (type(user.id) == 'string' or type(user.id) == 'number') then
GuiService:BroadcastNotification(WebApi.MakeReportUserUrl(user.id, conversationId),
NotificationType.REPORT_ABUSE)
else
print("Bad input to RequestNativeView, show error prompt here")
end
elseif optionId == PARTICIPANT_REMOVE then
local messageArguments = {
USERNAME = user.name,
}
self.appState.store:dispatch(SetRoute(Intent.GenericDialog, {
dialog = GenericDialogType.RemoveUserDialog,
dialogParameters = {
titleKey = "Feature.Chat.Action.RemoveUser",
messageKey = "Feature.Chat.Message.RemoveUser",
cancelTitleKey = "Feature.Chat.Action.Cancel",
confirmationTitleKey = "Feature.Chat.Action.Remove",
conversation = self.conversation,
user = user,
messageArguments = messageArguments
}
}
))
end
end)
return self
end
function ParticipantDialog:Destruct()
if self.dialogConnection then
self.dialogConnection:disconnect()
end
self.dialog:Destruct()
end
return ParticipantDialog
@@ -0,0 +1,48 @@
local Modules = script.Parent.Parent.Parent
local Components = Modules.Components
local DialogComponents = require(Components.DialogComponents)
local ConversationActions = require(Modules.Actions.ConversationActions)
local ResponseIndicator = require(Components.ResponseIndicator)
local RemoveUserDialog = {}
function RemoveUserDialog.new(appState, titleKey, messageKey, cancelTitleKey, confirmTitleKey, conversation)
local self = {}
setmetatable(self, {__index = RemoveUserDialog})
self.appState = appState
self.dialog = DialogComponents.ConfirmationDialog.new(appState, titleKey, messageKey, cancelTitleKey, confirmTitleKey)
-- Setup ResponseIndicator
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
self.responseIndicator.rbx.Parent = self.dialog.rbx
self.conversation = conversation
self.dialogConnection = self.dialog.saved:connect(function(user)
local userId = user.id
local convoId = self.conversation.id
self.responseIndicator.rbx.Parent = self.rbx
self.responseIndicator:SetVisible(true)
local action = ConversationActions.RemoveUserFromConversation(userId, convoId, function()
self.responseIndicator:SetVisible(false)
end)
self.appState.store:dispatch(action)
end)
return self
end
function RemoveUserDialog:Destruct()
if self.dialogConnection then
self.dialogConnection:disconnect()
end
self.responseIndicator:Destruct()
self.dialog:Destruct()
end
return RemoveUserDialog
@@ -0,0 +1,384 @@
local CoreGui = game:GetService("CoreGui")
local UserInputService = game:GetService("UserInputService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaChat.FlagSettings)
local LuaAppFlagSettings = require(LuaApp.FlagSettings)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local BaseHeader = require(Components.BaseHeader)
local TextButton = require(Components.TextButton)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local UseCppTextTruncation = FlagSettings.UseCppTextTruncation()
local GroupChatIconEnabled = settings():GetFFlag("LuaChatGroupChatIconEnabled")
local FFlagLuaChatFlexibleTitleWidth = settings():GetFFlag("LuaChatFlexibleTitleWidth")
local HEIGHT_OF_DISCONNECTED = 32
local PLATFORM_SPECIFIC_CONSTANTS = {
[Enum.Platform.Android] = {
HEADER_CONTENT_FRAME_Y_OFFSET = 0,
HEADER_TITLE_FRAME_POSITION_NO_BACK_BUTTON = UDim2.new(0, 15, 0, 0),
HEADER_TITLE_FRAME_POSITION = UDim2.new(0, 72, 0, 0),
HEADER_TITLE_FRAME_ANCHOR_POINT = Vector2.new(0, 0),
HEADER_VERTICAL_ALIGNMENT = Enum.VerticalAlignment.Center,
HEADER_HORIZONTAL_ALIGNMENT = Enum.HorizontalAlignment.Left,
HEADER_TEXT_X_ALIGNMENT = 0,
},
Default = {
HEADER_CONTENT_FRAME_Y_OFFSET = 24,
HEADER_TITLE_FRAME_POSITION_NO_BACK_BUTTON = UDim2.new(0.5, 0, 0, 0),
HEADER_TITLE_FRAME_POSITION = UDim2.new(0.5, 0, 0, 0),
HEADER_TITLE_FRAME_ANCHOR_POINT = Vector2.new(0.5, 0),
HEADER_VERTICAL_ALIGNMENT = Enum.VerticalAlignment.Top,
HEADER_HORIZONTAL_ALIGNMENT = Enum.HorizontalAlignment.Center,
HEADER_TEXT_X_ALIGNMENT = 2,
},
}
local GROUP_CHAT_ICON_HEIGHT = 25
local GROUP_CHAT_ICON_WIDTH = 25
local GROUP_CHAT_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-group-16x16.png"
local TITLE_LABEL_HEIGHT = 25
local TITLE_LABEL_WIDTH = 200
local TITLE_ITEM_PADDING = 5
local SUBTITLE_LABEL_HEIGHT = 12
local SUBTITLE_LABEL_WIDTH = 200
local function getPlatformSpecific(platform)
return PLATFORM_SPECIFIC_CONSTANTS[platform] or PLATFORM_SPECIFIC_CONSTANTS.Default
end
local Header = BaseHeader:Template()
Header.__index = Header
function Header.new(appState, dialogType)
local self = {}
setmetatable(self, Header)
local platform = appState.store:getState().Platform
local isLuaAppStarterScriptEnabled = LuaAppFlagSettings:IsLuaAppStarterScriptEnabled()
self:SetPlatform(platform)
local platformConstants = getPlatformSpecific(platform)
self.heightOfHeader = UserInputService.NavBarSize.Y + UserInputService.StatusBarSize.Y
self.heightOfDisconnected = HEIGHT_OF_DISCONNECTED
self.buttons = {}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.appState = appState
self.dialogType = dialogType
self.backButton = BaseHeader:GetNewBackButton(dialogType)
self.backButton.rbx.Visible = false
self.title = ""
self.subtitle = nil
self.connectionState = Enum.ConnectionState.Connected
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
self.BackButtonPressed = Signal.new()
local backButtonConnection = self.backButton.Pressed:connect(function()
self.BackButtonPressed:fire()
end)
table.insert(self.connections, backButtonConnection)
local titleLabelSize
if FFlagLuaChatFlexibleTitleWidth then
titleLabelSize = UDim2.new(1, 0, 0, TITLE_LABEL_HEIGHT)
else
titleLabelSize = UDim2.new(0, TITLE_LABEL_WIDTH, 0, TITLE_LABEL_HEIGHT)
end
self.titleLabel = Create.new "TextLabel" {
Name = "Title",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
Font = Enum.Font.SourceSansBold,
LayoutOrder = 1,
Size = titleLabelSize,
Text = self.title,
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_20,
TextXAlignment = platformConstants.HEADER_TEXT_X_ALIGNMENT,
}
if GroupChatIconEnabled then
self.groupChatIcon = Create.new "ImageLabel" {
Name = "GroupChatIcon",
Visible = false,
BackgroundTransparency = 1,
LayoutOrder = 0,
Size = UDim2.new(0, GROUP_CHAT_ICON_WIDTH, 0, GROUP_CHAT_ICON_HEIGHT),
AnchorPoint = Vector2.new(1, 0),
Image = GROUP_CHAT_ICON,
}
local innerTitleFrameSize
if FFlagLuaChatFlexibleTitleWidth then
innerTitleFrameSize = UDim2.new(1, 0, 0, GROUP_CHAT_ICON_HEIGHT)
else
innerTitleFrameSize = UDim2.new(0, TITLE_LABEL_WIDTH + GROUP_CHAT_ICON_WIDTH, 0, GROUP_CHAT_ICON_HEIGHT)
end
self.innerTitleFrame = Create.new "Frame" {
Name = "InnerTitleFrame",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
LayoutOrder = 0,
Size = innerTitleFrameSize,
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, TITLE_ITEM_PADDING),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = platformConstants.HEADER_HORIZONTAL_ALIGNMENT,
},
self.groupChatIcon,
self.titleLabel,
}
if FFlagLuaChatFlexibleTitleWidth then
self.titleLabel.Size = UDim2.new(1, -(GROUP_CHAT_ICON_WIDTH + TITLE_ITEM_PADDING), 0, TITLE_LABEL_HEIGHT)
end
end
self.innerSubtitle = Create.new "TextLabel" {
Name = "Subtitle",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
Font = Enum.Font.SourceSans,
LayoutOrder = 2,
Size = UDim2.new(0, SUBTITLE_LABEL_WIDTH, 0, SUBTITLE_LABEL_HEIGHT),
Text = "",
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_12,
TextXAlignment = platformConstants.HEADER_TEXT_X_ALIGNMENT,
}
self.innerTitles = Create.new "Frame" {
Name = "Titles",
AnchorPoint = platformConstants.HEADER_TITLE_FRAME_ANCHOR_POINT,
BackgroundTransparency = 1,
Position = self:GetHeaderTitleFramePosition(),
Size = UDim2.new(0, TITLE_LABEL_WIDTH, 1, 0),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = isLuaAppStarterScriptEnabled and Enum.VerticalAlignment.Center
or platformConstants.HEADER_VERTICAL_ALIGNMENT,
HorizontalAlignment = platformConstants.HEADER_HORIZONTAL_ALIGNMENT,
},
}
if GroupChatIconEnabled then
self.innerTitleFrame.Parent = self.innerTitles
else
self.titleLabel.Parent = self.innerTitles
end
if not isLuaAppStarterScriptEnabled then
self.innerSubtitle.Parent = self.innerTitles
end
self.innerButtons = Create.new "Frame" {
Name = "Buttons",
AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
Position = UDim2.new(1, -5, 0, 0),
Size = UDim2.new(0, 100, 1, 0),
Create.new "UIListLayout" {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = platformConstants.HEADER_VERTICAL_ALIGNMENT,
},
}
self.innerContent = Create.new "Frame" {
Name = "Content",
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 0, UserInputService.StatusBarSize.Y),
Size = UDim2.new(1, 0, 0, UserInputService.NavBarSize.Y),
self.backButton.rbx,
self.innerTitles,
self.innerButtons,
}
self.innerHeader = Create.new "Frame" {
Name = "Header",
BackgroundColor3 = Constants.Color.BLUE_PRESSED,
BorderSizePixel = 0,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 0, self.heightOfHeader),
self.innerContent,
}
self.rbx = Create.new "Frame" {
Name = "HeaderFrame",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, self.heightOfHeader),
Create.new "UIListLayout" {
FillDirection = Enum.FillDirection.Vertical,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Top,
},
self.innerHeader,
Create.new "Frame" {
Name = "Disconnected",
AnchorPoint = Vector2.new(0, 1),
BackgroundColor3 = Constants.Color.GRAY3,
BorderSizePixel = 0,
ClipsDescendants = true,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0), -- Note: Deliberately has zero vertical height, will be scaled when shown.
Create.new "TextLabel" {
Name = "Title",
AnchorPoint = Vector2.new(0.5, 1),
BackgroundTransparency = 1,
Font = Enum.Font.SourceSans,
LayoutOrder = 0,
Position = UDim2.new(0.5, 0, 1, 0),
Size = UDim2.new(1, 0, 0, HEIGHT_OF_DISCONNECTED),
Text = appState.localization:Format("Feature.Chat.Message.NoConnectionMsg"),
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_14,
},
},
Create.new "Frame" {
Name = "GameDrawer",
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = false,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, 0), -- Note: Deliberately zero height, will be scaled open.
Visible = false,
},
}
local rbxConnectionsList = self.connections
if FFlagLuaChatToSplitRbxConnections then
rbxConnectionsList = self.rbx_connections
end
local parentChangedConnection = self.rbx:GetPropertyChangedSignal("Parent"):Connect(function()
if self.rbx and self.rbx.Parent then
if not UseCppTextTruncation then
game:GetService("RunService").Stepped:wait() -- TextBounds isn't recalculated when this fires so we wait
end
self:SetTitle(self.title) -- Again, this can be much cleaner once we have proper truncation support
end
end)
table.insert(rbxConnectionsList, parentChangedConnection)
local navBarSignal = UserInputService:GetPropertyChangedSignal("NavBarSize")
local navBarConnection = navBarSignal:Connect(function()
self:AdjustLayout()
end)
local statusBarSignal = UserInputService:GetPropertyChangedSignal("StatusBarSize")
local statusBarConnection = statusBarSignal:Connect(function()
self:AdjustLayout()
end)
self:AdjustLayout()
table.insert(rbxConnectionsList, navBarConnection)
table.insert(rbxConnectionsList, statusBarConnection)
if FFlagLuaChatFlexibleTitleWidth then
table.insert(rbxConnectionsList, self.innerTitles:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:SetTitle(self.title) -- Call SetTitle to truncate text according to the new size.
end))
table.insert(rbxConnectionsList, self.innerButtons:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:AdjustTitleHeader()
end))
table.insert(rbxConnectionsList, self.backButton.rbx:GetPropertyChangedSignal("Visible"):Connect(function()
self:AdjustTitleHeader()
end))
self:AdjustTitleHeader()
end
do
local connection = appState.store.changed:connect(function(state, oldState)
self:SetPlatform(state.Platform)
self:SetConnectionState(state.ConnectionState)
end)
table.insert(self.connections, connection)
end
return self
end
function Header:IsBackButtonVisible()
return self.backButton and self.backButton.rbx and self.backButton.rbx.Visible
end
function Header:AdjustLayout()
self.heightOfHeader = UserInputService.NavBarSize.Y + UserInputService.StatusBarSize.Y
self.rbx.Size = UDim2.new(1, 0, 0, self.heightOfHeader)
self.innerHeader.Size = UDim2.new(1, 0, 0, self.heightOfHeader)
self.innerContent.Position = UDim2.new(0, 0, 0, UserInputService.StatusBarSize.Y)
self.innerContent.Size = UDim2.new(1, 0, 0, UserInputService.NavBarSize.Y)
end
function Header:AdjustTitleHeader()
local backButtonWidth = self:IsBackButtonVisible() and self.backButton.rbx.AbsoluteSize.X or 0
local buttonsWidth = self.innerButtons and self.innerButtons.AbsoluteSize.X or 0
local titleWidthOffset = math.max(backButtonWidth, buttonsWidth) * 2
self.innerTitles.Size = UDim2.new(1, -titleWidthOffset, 1, 0)
self:SetTitle(self.title) -- Call SetTitle to truncate text according to the new size.
end
function Header:CreateHeaderButton(name, textKey)
local saveGroup = TextButton.new(self.appState, name, textKey)
self:AddButton(saveGroup)
return saveGroup
end
function Header:SetBackButtonEnabled(enabled)
self.backButton.rbx.Visible = enabled
self.innerTitles.Position = self:GetHeaderTitleFramePosition()
end
function Header:GetHeaderTitleFramePosition()
if self:IsBackButtonVisible() then
return getPlatformSpecific(self.platform).HEADER_TITLE_FRAME_POSITION
end
return getPlatformSpecific(self.platform).HEADER_TITLE_FRAME_POSITION_NO_BACK_BUTTON
end
function Header:SetGroupChatIconVisibility(enabled)
if enabled then
self.groupChatIcon.Visible = true
else
self.groupChatIcon.Visible = false
end
end
return Header
@@ -0,0 +1,26 @@
local LuaChat = script.Parent.Parent
local Components = LuaChat.Components
local DialogInfo = require(LuaChat.DialogInfo)
local Header = require(Components.Header)
local ModalHeader = require(Components.ModalHeader)
local HeaderLoader = {}
function HeaderLoader.GetHeader(appState, intent)
if intent == nil then
warn("intent passed to HeaderLoader.GetHeader is null.")
return Header.new(appState, DialogInfo.DialogType.Centered)
end
local dialogType = DialogInfo.GetTypeBasedOnIntent(appState.store:getState().FormFactor, intent)
if dialogType == DialogInfo.DialogType.Modal then
return ModalHeader.new(appState, dialogType)
else
return Header.new(appState, dialogType)
end
end
return HeaderLoader
@@ -0,0 +1,174 @@
local CoreGui = game:GetService("CoreGui")
local TweenService = game:GetService("TweenService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local sendIceBreakerAnalytics = require(LuaChat.Analytics.Events.sendIcebreaker)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local HELLO_BUTTON = "rbxasset://textures/ui/LuaChat/9-slice/hello-button.png"
local HELLO_MESSAGE_KEY = "Feature.Chat.Action.Hello"
local WAVE_EMOJI = "👋"
local TextMeasureTemporaryPatch = settings():GetFFlag("TextMeasureTemporaryPatch")
local FLASH_TWEEN_ANIMATION_DELAY_TIME = 0.6
local FLASH_TWEEN_ANIMATION_DOES_REVERSES = false
local FLASH_TWEEN_ANIMATION_REPEAT_COUNT = 2
local FLASH_TWEEN_ANIMATION_SCALE_GOAL = 1.2
local FLASH_TWEEN_ANIMATION_TIME = 0.6
local BUTTON_PADDING_HEIGHT = 8
local BUTTON_PADDING_WIDTH = 18
local BUTTON_MARGIN_BOTTOM = 12
local BUTTON_MARGIN_TOP = 10
local Icebreaker = {}
Icebreaker.__index = Icebreaker
function Icebreaker.new(appState)
local self = {}
setmetatable(self, Icebreaker)
self.appState = appState
self.SendButtonPressed = Signal.new()
self._analytics = appState.analytics
local helloTextWithEmoji = string.format(
"%s %s",
WAVE_EMOJI,
self.appState.localization:Format(HELLO_MESSAGE_KEY)
)
local textBounds = Text.GetTextBounds(
helloTextWithEmoji,
Enum.Font.SourceSans,
Constants.Font.FONT_SIZE_16,
Vector2.new(10000, 10000)
)
if TextMeasureTemporaryPatch then
textBounds = textBounds + Vector2.new(-2, -2)
end
self.animatorScale = Create.new "UIScale" {
Scale = 1,
}
self.icebreakerAnimator = Create.new "ImageLabel" {
Name = "HelloButtonAnimator",
BackgroundTransparency = 1,
LayoutOrder = 0,
Size = UDim2.new(0, textBounds.X + BUTTON_PADDING_WIDTH * 2, 1, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Image = HELLO_BUTTON,
ImageColor3 = Color3.new(0, 0, 0),
ImageTransparency = 0.5,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(16, 16, 16, 16),
ZIndex = 1,
self.animatorScale,
}
self.helloButton = Create.new "ImageButton" {
Name = "HelloButton",
BackgroundTransparency = 1,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 1, 0),
Image = HELLO_BUTTON,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(16, 16, 16, 16),
ZIndex = 2,
Create.new "Frame" {
Name = "Content",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Create.new "UIPadding" {
PaddingBottom = UDim.new(0, BUTTON_PADDING_HEIGHT),
PaddingLeft = UDim.new(0, BUTTON_PADDING_WIDTH),
PaddingRight = UDim.new(0, BUTTON_PADDING_WIDTH),
PaddingTop = UDim.new(0, BUTTON_PADDING_HEIGHT),
},
Create.new "TextLabel" {
Name = "HelloText",
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(0, textBounds.X, 1, 0),
Font = Enum.Font.SourceSans,
Text = helloTextWithEmoji,
TextSize = Constants.Font.FONT_SIZE_16,
},
},
}
self.rbx = Create.new "Frame" {
Name = "IcebreakerContainer",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(0, 1),
Size = UDim2.new(1, 0, 0, BUTTON_MARGIN_BOTTOM + BUTTON_PADDING_HEIGHT * 2 + textBounds.Y + BUTTON_MARGIN_TOP),
Create.new "UIPadding" {
PaddingBottom = UDim.new(0, BUTTON_MARGIN_BOTTOM),
PaddingTop = UDim.new(0, BUTTON_MARGIN_TOP),
},
Create.new "Frame" {
Name = "IceBreaker",
BackgroundTransparency = 1,
LayoutOrder = 1,
Size = UDim2.new(0, textBounds.X + BUTTON_PADDING_WIDTH * 2, 1, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
self.helloButton,
self.icebreakerAnimator,
}
}
getInputEvent(self.helloButton):Connect(function()
self:SendMessage(helloTextWithEmoji)
end)
return self
end
function Icebreaker:SendMessage(text)
local conversationId = tostring(self.appState.store:getState().ChatAppReducer.ActiveConversationId)
local eventContext = "luaChat"
local eventStreamImpl = self._analytics.EventStream
self.SendButtonPressed:fire(text)
sendIceBreakerAnalytics(eventStreamImpl, eventContext, conversationId)
end
function Icebreaker:PlayFlashAnimation()
local tweenInfo = TweenInfo.new(
FLASH_TWEEN_ANIMATION_TIME,
Enum.EasingStyle.Sine,
Enum.EasingDirection.Out,
FLASH_TWEEN_ANIMATION_REPEAT_COUNT,
FLASH_TWEEN_ANIMATION_DOES_REVERSES,
FLASH_TWEEN_ANIMATION_DELAY_TIME
)
TweenService:Create(self.icebreakerAnimator, tweenInfo, {
ImageTransparency = 1,
}):Play()
TweenService:Create(self.animatorScale, tweenInfo, {
Scale = FLASH_TWEEN_ANIMATION_SCALE_GOAL,
}):Play()
end
return Icebreaker
@@ -0,0 +1,79 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local ListEntry = {}
ListEntry.__index = ListEntry
function ListEntry.new(appState, height)
local self = {}
self.connections = {}
setmetatable(self, ListEntry)
self.rbx = Create.new"TextButton" {
Name = "Entry",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, height),
AutoButtonColor = false,
Text = "",
}
self.tapped = Signal.new()
self.beginHover = Signal.new()
self.endHover = Signal.new()
local beginningInput = nil
local function onInputBegan(input)
if input.UserInputType ~= Enum.UserInputType.Touch or
(input.UserInputState ~= Enum.UserInputState.Begin and input ~= beginningInput) then
return
end
beginningInput = input
self.rbx.BackgroundColor3 = Constants.Color.GRAY5
self.beginHover:fire()
return
end
local function onInputEnded(input, processed)
if input.UserInputType ~= Enum.UserInputType.Touch then
return
end
beginningInput = nil
self.rbx.BackgroundColor3 = Constants.Color.WHITE
self.endHover:fire()
return
end
local inputBeganConnection = self.rbx.InputBegan:Connect(onInputBegan)
table.insert(self.connections, inputBeganConnection)
local inputEndedConnection = self.rbx.InputEnded:Connect(onInputEnded)
table.insert(self.connections, inputEndedConnection)
local mouseClickedConnection = getInputEvent(self.rbx):Connect(function()
self.tapped:fire()
end)
table.insert(self.connections, mouseClickedConnection)
return self
end
function ListEntry:Destruct()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
self.rbx:Destroy()
end
return ListEntry
@@ -0,0 +1,61 @@
local function findAncestor(child, name)
local parent = child.Parent
while parent and parent.Name ~= name do
parent = parent.Parent
end
return parent
end
local DIVIDER_SIZE_LARGE = 26
local DIVIDER_SIZE_SMALL = 12
local Modules = findAncestor(script, "LuaChat")
local Create = require(Modules.Create)
local Constants = require(Modules.Constants)
local ListSection = {}
ListSection.__index = ListSection
function ListSection.new(appState, labelKey, layoutOrder)
local self = {}
setmetatable(self, ListSection)
local labelHeight = labelKey and DIVIDER_SIZE_LARGE or DIVIDER_SIZE_SMALL
self.rbx = Create.new"Frame" {
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, labelHeight),
LayoutOrder = layoutOrder,
}
if labelKey then
local labelText = appState.localization:Format(labelKey)
local label = Create.new"TextLabel" {
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 12, 1, 0),
BorderSizePixel = 0,
Text = labelText,
TextColor3 = Constants.Color.GRAY2,
TextSize = Constants.Font.FONT_SIZE_14,
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
AnchorPoint = Vector2.new(0,1),
}
label.Parent = self.rbx
end
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, -1),
}
divider.Parent = self.rbx
return self
end
return ListSection
@@ -0,0 +1,146 @@
local CoreGui = game:GetService("CoreGui")
local RunService = game:GetService("RunService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaApp.FlagSettings)
local LoadingBar = require(LuaApp.Components.LoadingBar)
local Roact = require(Common.Roact)
local LOADING_INDICATOR_WIDTH = 70
local LOADING_INDICATOR_HEIGHT = 16
local DOT_COUNT = 3
local DOT_ANIMATION_SPEED_MULTIPLIER = 1.75
local DOT_BASE_RELATIVE_HEIGHT = 0.7
local DOT_BASE_RELATIVE_WIDTH = 0.7
local DOT_HEIGHT_LERP_AMPLITUDE = 0.3
local LoadingIndicator = {}
local function makeDot()
return Create.new "ImageLabel" {
Name = "DotContainer",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
SizeConstraint = Enum.SizeConstraint.RelativeYY,
Create.new "Frame" {
Name = "Dot",
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
SizeConstraint = Enum.SizeConstraint.RelativeYY,
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5)
},
}
end
local function renderLoadingDots(self)
if not self.rbx.Visible then
return
end
local dotsTime = (time() * DOT_ANIMATION_SPEED_MULTIPLIER) % #self.dots
for i, dot in ipairs(self.dots) do
local dotHeight = DOT_BASE_RELATIVE_HEIGHT
if dotsTime >= i - 1 and dotsTime <= i then
dotHeight = DOT_BASE_RELATIVE_HEIGHT + DOT_HEIGHT_LERP_AMPLITUDE * math.sin(math.pi * (dotsTime % 1))
local colorAlpha = math.sin(math.pi * (dotsTime % 1))
dot.Dot.BackgroundColor3 = Constants.Color.GRAY3:lerp(Constants.Color.BLUE_PRIMARY, colorAlpha)
else
dot.Dot.BackgroundColor3 = Constants.Color.GRAY3
end
dot.Dot.Size = UDim2.new(DOT_BASE_RELATIVE_WIDTH, 0, dotHeight, 0)
end
end
function LoadingIndicator.new(appState, scale)
scale = scale or 1
local self = {}
self.connections = {}
self.rbx = Create.new "Frame" {
Name = "LoadingIndicator",
BackgroundTransparency = 1,
Size = UDim2.new(0, scale * LOADING_INDICATOR_WIDTH, 0, scale * LOADING_INDICATOR_HEIGHT)
}
local platform = appState.store:getState().Platform
local isLuaHomePageEnabled = FlagSettings.IsLuaHomePageEnabled(platform)
local isLuaGamesPageEnabled = FlagSettings.IsLuaGamesPageEnabled(platform)
self.isLoadingBarEnabled = isLuaHomePageEnabled and isLuaGamesPageEnabled
if self.isLoadingBarEnabled then
self.loadingBar = Roact.mount(Roact.createElement(LoadingBar), self.rbx)
else -- use dots loading indicator for non-lua pages
self.dots = {}
for i = 1, DOT_COUNT do
local value = (i - 1) / (DOT_COUNT - 1)
local dot = makeDot()
dot.Position = UDim2.new(value, 0, 0.5, 0)
dot.AnchorPoint = Vector2.new(value, 0.5)
dot.Parent = self.rbx
table.insert(self.dots, dot)
end
end
setmetatable(self, LoadingIndicator)
do
local connection = self.rbx.AncestryChanged:Connect(function(object, parent)
if object == self.rbx and parent == nil then
self:Destroy()
end
end)
table.insert(self.connections, connection)
end
if not self.isLoadingBarEnabled then
local connection = RunService.RenderStepped:Connect(function()
renderLoadingDots(self)
end)
table.insert(self.connections, connection)
end
return self
end
function LoadingIndicator:SetZIndex(index)
self.rbx.ZIndex = index
if self.isLoadingBarEnabled then
self.loadingBar.ZIndex = index
else
for _, dot in ipairs(self.dots) do
dot.ZIndex = index
dot.Dot.ZIndex = index
end
end
end
function LoadingIndicator:SetVisible(visible)
self.rbx.Visible = visible
end
function LoadingIndicator:Destroy()
if self.isLoadingBarEnabled then
Roact.unmount(self.loadingBar)
end
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.rbx:Destroy()
self.connections = {}
end
LoadingIndicator.__index = LoadingIndicator
return LoadingIndicator
@@ -0,0 +1,474 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local ChatBubble = require(LuaChat.Components.ChatBubble)
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local OrderedMap = require(LuaChat.OrderedMap)
local Signal = require(Common.Signal)
local ChatTimestamp = require(LuaChat.Components.ChatTimestamp)
local Conversation = require(LuaChat.Models.Conversation)
local LoadingIndicator = require(LuaChat.Components.LoadingIndicator)
local IS_BOTTOM_BUFFER = 5
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local LuaChatAssetCardsSelfTerminateConnection = settings():GetFFlag("LuaChatAssetCardsSelfTerminateConnection")
local FFlagLuaChatInfiniteRelayoutRecursionFix = settings():GetFFlag("LuaChatInfiniteRelayoutRecursionFix")
local MessageList = {}
MessageList.__index = MessageList
local function didLocalUserSend(message)
local localUserId = tostring(Players.LocalPlayer.UserId)
return message.senderTargetId == localUserId
end
local function getSpacer(height)
return Create.new "Frame" {
Name = "Spacer",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, height),
LayoutOrder = 1,
}
end
--[[
Updates the spacing and timestamp display of message identified by
messages and messageIndex.
]]
local function isFirstMessageInCluster(messages, messageIndex)
local message = messages:GetByIndex(messageIndex)
local previousMessage = messages:GetByIndex(messageIndex - 1)
if not previousMessage or message.sent:GetUnixTimestamp() - previousMessage.sent:GetUnixTimestamp() > 5 * 60 then
return true
elseif previousMessage and message.senderTargetId == previousMessage.senderTargetId then
return false
end
return true
end
local function updateBubblePadding(appState, conversationType, bubble, messages, messageIndex)
local message = messages:GetByIndex(messageIndex)
local previousMessage = messages:GetByIndex(messageIndex - 1)
local padding
local extraInfoVisible = true
if previousMessage then
if message.sent:GetUnixTimestamp() - previousMessage.sent:GetUnixTimestamp() > 5 * 60 then
local relativeTime = message.sent:GetLongRelativeTime()
padding = ChatTimestamp.new(appState, relativeTime).rbx
else
if message.senderTargetId == previousMessage.senderTargetId then
padding = getSpacer(2)
extraInfoVisible = false
else
padding = getSpacer(10)
end
end
else
local relativeTime = message.sent:GetLongRelativeTime()
padding = ChatTimestamp.new(appState, relativeTime).rbx
end
bubble:SetTailVisible(extraInfoVisible)
if not didLocalUserSend(message) then
bubble:SetThumbnailVisible(extraInfoVisible)
if conversationType == Conversation.Type.MULTI_USER_CONVERSATION then
bubble:SetUsernameVisible(extraInfoVisible)
end
end
bubble:SetPaddingObject(padding)
end
local function createChatHistoryMessagePadding()
local padding = Create.new "Frame" {
Name = "LoadingIndicatorPadding",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 50),
Create.new "UIPadding" {
PaddingBottom = UDim.new(0, 10),
PaddingTop = UDim.new(0, 20)
},
Create.new "Frame" {
Name = "Container",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
Position = UDim2.new(0.5, 0, 0.5, 0),
Size = UDim2.new(0, 84, 0, 22),
}
}
return padding
end
function MessageList.new(appState, conversation)
local self = {}
setmetatable(self, MessageList)
self.appState = appState
self.conversation = Conversation.mock()
self.messageIdToBubble = {}
self.RequestOlderMessages = Signal.new()
self.ReadAllMessages = Signal.new()
self.isTouchingBottom = true
self.lastThumbnailLocation = {}
self.loadingIndicatorPadding = createChatHistoryMessagePadding()
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
else
self.connections = {}
end
self.layout = Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
}
self.rbx = Create.new "ScrollingFrame" {
Name = "MessageList",
ElasticBehavior = "Always",
ScrollingDirection = "Y",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.GRAY6,
Size = UDim2.new(1, 0, 0, 0),
ScrollBarThickness = 5,
BottomImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
MidImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
TopImage = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png",
self.layout,
Create.new "UIPadding" {
PaddingBottom = UDim.new(0, 10),
},
}
self.loadingIndicatorPadding.Parent = self.rbx
self.loadingIndicatorPadding.Visible = false
self.rbx:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
if FFlagLuaChatInfiniteRelayoutRecursionFix then
local currentCanvasPositionY = self.rbx.CanvasPosition.Y
if currentCanvasPositionY + self.rbx.AbsoluteWindowSize.Y + IS_BOTTOM_BUFFER >= self.rbx.CanvasSize.Y.Offset then
self.isTouchingBottom = true
self.ReadAllMessages:fire()
else
self.isTouchingBottom = false
end
if self.rbx.CanvasSize.Y.Offset > self.rbx.AbsoluteSize.Y then
if self.lastCanvasPosition then
if self.lastCanvasPosition < 0 and currentCanvasPositionY >= 0 then
self.RequestOlderMessages:fire()
elseif currentCanvasPositionY == 0 then
self.RequestOlderMessages:fire()
end
end
self.lastCanvasPosition = currentCanvasPositionY
end
else
-- FFlagLuaChatInfiniteRelayoutRecursionFix caches CanvasPosition.Y
if self.rbx.CanvasPosition.Y + self.rbx.AbsoluteWindowSize.Y + IS_BOTTOM_BUFFER >= self.rbx.CanvasSize.Y.Offset then
self.isTouchingBottom = true
self.ReadAllMessages:fire()
else
self.isTouchingBottom = false
end
if self.rbx.CanvasSize.Y.Offset > self.rbx.AbsoluteSize.Y then
if self.lastCanvasPosition then
if self.lastCanvasPosition < 0 and self.rbx.CanvasPosition.Y >= 0 then
self.RequestOlderMessages:fire()
elseif self.rbx.CanvasPosition.Y == 0 then
self.RequestOlderMessages:fire()
end
end
self.lastCanvasPosition = self.rbx.CanvasPosition.Y
end
end
end)
self:Update(conversation)
if FFlagLuaChatInfiniteRelayoutRecursionFix then
table.insert(self.connections, self.layout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function()
self:_resize()
end))
end
return self
end
function MessageList:Update(conversation)
if conversation == nil then
return
end
-- NOTE The following loop is currently used to set the typing indiactor visiblity
-- to true. We currently pass an empty table for usersTyping when receiving a message.
for userId, typing in pairs(conversation.usersTyping) do
if self.lastThumbnailLocation[userId] then
local bubble = self.messageIdToBubble[self.lastThumbnailLocation[userId].id]
if bubble then
bubble:SetTypingIndicatorVisible(typing)
end
end
end
if conversation.messages == self.conversation.messages and
conversation.sendingMessages == self.conversation.sendingMessages then
return
end
-- Remove sending messages that have been moved to sent
for id, _ in self.conversation.sendingMessages:CreateIterator() do
if conversation.sendingMessages:Get(id) == nil then
if self.messageIdToBubble[id] then
self.messageIdToBubble[id].rbx:Destroy()
self.messageIdToBubble[id] = nil
end
end
end
local combinedMessages = OrderedMap.Merge(conversation.messages, conversation.sendingMessages)
-- Map containing whether a message has been adjusted, indexed by numeric keys
-- into the combinedMessages map.
local adjustedMessages = {}
local oldThumbnailLocation = self.lastThumbnailLocation
self.lastThumbnailLocation = {}
local havePassedNewestMessage = false
local havePassedDiscontiguousMessage = false
for id, message, index in combinedMessages:CreateReverseIterator() do
local existingMessage = self.conversation.messages:Get(id) or self.conversation.sendingMessages:Get(id)
local bubble = self.messageIdToBubble[id]
local senderId = message.senderTargetId
local isSending = conversation.sendingMessages:Get(id) ~= nil
if message ~= existingMessage then
if bubble then
bubble:Destruct()
end
local bubbleWidth
if FFlagLuaChatInfiniteRelayoutRecursionFix then
bubbleWidth = self.rbx.AbsoluteSize.X
end
bubble = ChatBubble.new(self.appState, message, bubbleWidth)
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
bubble.rbx.Parent = self.rbx
end
self.messageIdToBubble[id] = bubble
-- When a message updates, we need to recalculate layout on it and
-- the message immediately after it.
updateBubblePadding(self.appState, conversation.conversationType, bubble, combinedMessages, index)
if not adjustedMessages[index + 1] then
local nextMessage = combinedMessages:GetByIndex(index + 1)
if nextMessage then
local nextBubble = self.messageIdToBubble[nextMessage.id]
updateBubblePadding(self.appState, conversation.conversationType, nextBubble, combinedMessages, index + 1)
end
end
adjustedMessages[index] = true
adjustedMessages[index + 1] = true
end
if not self.lastThumbnailLocation[senderId] and isFirstMessageInCluster(combinedMessages, index) then
self.lastThumbnailLocation[senderId] = message
if ( oldThumbnailLocation and oldThumbnailLocation[senderId] and
oldThumbnailLocation[senderId] ~= message ) and self.messageIdToBubble[oldThumbnailLocation[senderId].id] then
self.messageIdToBubble[oldThumbnailLocation[senderId].id]:SetTypingIndicatorVisible(false)
end
end
-- When the MessageList updates, we assume the most recent message has
-- been received from a previously typing user for Group Conversations.
if havePassedNewestMessage == false and isSending == false and self.lastThumbnailLocation[senderId] then
havePassedNewestMessage = true
local thumbnailBubble = self.messageIdToBubble[self.lastThumbnailLocation[senderId].id]
if thumbnailBubble then
thumbnailBubble:SetTypingIndicatorVisible(false)
end
end
bubble.rbx.LayoutOrder = index
bubble.rbx.Visible = not havePassedDiscontiguousMessage
if FFlagLuaChatInfiniteRelayoutRecursionFix then
bubble.rbx.Parent = self.rbx
end
if (not isSending) and message.previousMessageId == nil then
havePassedDiscontiguousMessage = true
end
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
local connection = bubble.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
local wasTouchingBottom = self.isTouchingBottom
self:ResizeCanvas(wasTouchingBottom)
if wasTouchingBottom then
self:ScrollToBottom()
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, connection)
else
table.insert(self.connections, connection)
end
end
end
self.conversation = conversation
if FFlagLuaChatInfiniteRelayoutRecursionFix then
self:_resize()
else
local wasTouchingBottom = self.isTouchingBottom
self:ResizeCanvas(wasTouchingBottom)
if wasTouchingBottom then
self:ScrollToBottom()
end
end
end
function MessageList:_resize()
local wasTouchingBottom = self.isTouchingBottom
self:ResizeCanvas(wasTouchingBottom)
if wasTouchingBottom then
self:ScrollToBottom()
end
end
function MessageList:CalculateCanvasSize()
local height = 0
for _, child in pairs(self.messageIdToBubble) do
if child.rbx.Visible then
height = height + child.rbx.AbsoluteSize.Y
end
end
if self.loadingIndicatorPadding.Visible then
height = height + self.loadingIndicatorPadding.Size.Y.Offset
end
return UDim2.new(1, 0, 0, height)
end
function MessageList:ResizeCanvas(wasTouchingBottom)
local canvas = self.rbx
local oldHeight = canvas.CanvasSize.Y.Offset
canvas.CanvasSize = self:CalculateCanvasSize()
if not FFlagLuaChatInfiniteRelayoutRecursionFix or not wasTouchingBottom then
--Restore distance to bottom
canvas.CanvasPosition = canvas.CanvasPosition + Vector2.new(0, canvas.CanvasSize.Y.Offset - oldHeight)
end
end
function MessageList:ScrollToBottom()
local height = self.rbx.CanvasSize.Y.Offset - self.rbx.AbsoluteWindowSize.Y
self.rbx.CanvasPosition = Vector2.new(0, height)
end
function MessageList:TweenScrollToBottom(offsetY, tweenInfo)
local height = self.rbx.CanvasSize.Y.Offset - self.rbx.AbsoluteWindowSize.Y + offsetY
local propertyGoals =
{
CanvasPosition = Vector2.new(0, height)
}
local tween = TweenService:Create(self.rbx, tweenInfo, propertyGoals)
tween:Play()
end
function MessageList:StartLoadingMessageHistoryAnimation()
if self.loadingIndicator == nil then
self.loadingIndicator = LoadingIndicator.new(self.appState, 1)
end
local padding = self.loadingIndicatorPadding
self.loadingIndicator.rbx.Parent = padding.Container
padding.Visible = true
self.rbx.CanvasSize = self:CalculateCanvasSize()
end
function MessageList:StopLoadingMessageHistoryAnimation()
local loadingIndicator = self.loadingIndicator
if loadingIndicator then
local padding = self.loadingIndicatorPadding
padding.Visible = false
self.rbx.CanvasSize = self:CalculateCanvasSize()
self.loadingIndicator:Destroy()
self.loadingIndicator = nil
end
end
if not LuaChatAssetCardsSelfTerminateConnection then
function MessageList:DisconnectChatBubbles()
for _, chatEntry in pairs(self.messageIdToBubble) do
for _, childBubble in pairs(chatEntry.bubbles) do
if childBubble.bubbleType == "AssetCard" then
childBubble:DisconnectUpdate()
end
end
end
end
end
function MessageList:Destruct()
for _, bubble in pairs(self.messageIdToBubble) do
bubble:Destruct()
end
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
else
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
end
self.messageIdToBubble = {}
self.rbx:Destroy()
end
return MessageList
@@ -0,0 +1,233 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local BaseHeader = require(Components.BaseHeader)
local FlagSettings = require(LuaChat.FlagSettings)
local ModalTextButton = require(Components.ModalTextButton)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local UseCppTextTruncation = FlagSettings.UseCppTextTruncation()
local HEIGHT_OF_BORDER = 1
local HEIGHT_OF_DISCONNECTED = 32
local HEIGHT_OF_HEADER = 44
local ICON_CELL_WIDTH = 60
local TOTAL_HEIGHT_OF_HEADER = HEIGHT_OF_HEADER + HEIGHT_OF_BORDER
local ModalHeader = BaseHeader:Template()
ModalHeader.__index = ModalHeader
function ModalHeader.new(appState, dialogType)
local self = {}
self.heightOfHeader = TOTAL_HEIGHT_OF_HEADER
self.heightOfDisconnected = HEIGHT_OF_DISCONNECTED
self.buttons = {}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.appState = appState
self.dialogType = dialogType
self.backButton = BaseHeader:GetNewBackButton(dialogType)
self.backButton.rbx.Visible = false
self.backButton.rbx.Size = UDim2.new(0, 24, 0, 24)
self.backButton.rbx.Position = UDim2.new(0.5, -2, 0.5, 0)
self.backButton.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.title = ""
self.subtitle = nil
self.connectionState = Enum.ConnectionState.Connected
self.luaChatPlayTogetherEnabled = FlagSettings.IsLuaChatPlayTogetherEnabled(
self.appState.store:getState().FormFactor)
self.BackButtonPressed = Signal.new()
local backButtonConnection = self.backButton.Pressed:connect(function()
self.BackButtonPressed:fire()
end)
table.insert(self.connections, backButtonConnection)
self.titleLabel = Create.new "TextLabel" {
Name = "Title",
BackgroundTransparency = 1,
TextSize = Constants.Font.FONT_SIZE_20,
TextColor3 = Constants.Color.GRAY1,
Size = UDim2.new(0, 200, 0, 18),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = "Title",
Font = Enum.Font.SourceSans,
LayoutOrder = 0,
}
self.innerSubtitle = Create.new "TextLabel" {
Name = "Subtitle",
BackgroundTransparency = 1,
TextSize = Constants.Font.FONT_SIZE_12,
TextColor3 = Constants.Color.GRAY1,
Size = UDim2.new(0, 200, 0, 18),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = "",
Font = Enum.Font.SourceSans,
LayoutOrder = 1,
}
self.innerTitles = Create.new "Frame" {
Name = "Titles",
BackgroundTransparency = 1,
Size = UDim2.new(0, 200, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center
},
self.titleLabel,
self.innerSubtitle,
}
self.innerButtons = Create.new "Frame" {
Name = "Buttons",
BackgroundTransparency = 1,
Position = UDim2.new(1, -12, 0, 0),
Size = UDim2.new(0, 100, 1, 0),
AnchorPoint = Vector2.new(1, 0),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
},
}
self.innerContent = Create.new "Frame" {
Name = "Content",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, -4),
Position = UDim2.new(0, 0, 0, 4),
Create.new"Frame" {
Name = "Icon",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
self.backButton.rbx,
},
self.innerTitles,
self.innerButtons,
}
self.innerHeader = Create.new "Frame" {
Name = "Header",
BackgroundTransparency = 1, -- Set transparent so the rounded corners will show.
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, HEIGHT_OF_HEADER),
LayoutOrder = 0,
self.innerContent,
}
self.rbx = Create.new "Frame" {
Name = "HeaderFrame",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, TOTAL_HEIGHT_OF_HEADER),
Create.new("UIListLayout") {
Name = "ModalHeaderListLayout",
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Vertical,
},
self.innerHeader,
Create.new "Frame" {
Name = "BottomBorder",
BackgroundColor3 = Constants.Color.GRAY3,
Size = UDim2.new(1, 0, 0, HEIGHT_OF_BORDER),
BorderSizePixel = 0,
LayoutOrder = 1,
},
Create.new "Frame" {
Name = "Disconnected",
AnchorPoint = Vector2.new(0, 1),
BackgroundColor3 = Constants.Color.GRAY3,
BorderSizePixel = 0,
ClipsDescendants = true,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0), -- Note: Deliberately has zero vertical height, will be scaled.
Create.new "TextLabel" {
Name = "Title",
AnchorPoint = Vector2.new(0.5, 1),
BackgroundTransparency = 1,
Font = Constants.Font.TITLE,
LayoutOrder = 0,
Position = UDim2.new(0.5, 0, 1, 0),
Size = UDim2.new(1, 0, 0, HEIGHT_OF_DISCONNECTED),
Text = appState.localization:Format("Feature.Chat.Message.NoConnectionMsg"),
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_14,
},
},
Create.new "Frame" {
Name = "GameDrawer",
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = false,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, 0), -- Note: Deliberately zero height, will be scaled open.
Visible = false,
},
}
local parentChangedConnection = self.rbx:GetPropertyChangedSignal("Parent"):Connect(function()
if self.rbx and self.rbx.Parent then
if not UseCppTextTruncation then
game:GetService("RunService").Stepped:wait() -- TextBounds isn't recalculated when this fires so we wait
end
self:SetTitle(self.title) -- Again, this can be much cleaner once we have proper truncation support
end
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, parentChangedConnection)
else
table.insert(self.connections, parentChangedConnection)
end
do
local connection = appState.store.changed:connect(function(state, oldState)
self:SetConnectionState(state.ConnectionState)
end)
table.insert(self.connections, connection)
end
setmetatable(self, ModalHeader)
return self
end
function ModalHeader:CreateHeaderButton(name, textKey)
local saveGroup = ModalTextButton.new(self.appState, name, textKey)
self:AddButton(saveGroup)
return saveGroup
end
return ModalHeader
@@ -0,0 +1,76 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local FONT = Enum.Font.SourceSans
local TEXT_SIZE = Constants.Font.FONT_SIZE_18
local X_PADDING = 8
local TextButton = {}
TextButton.__index = TextButton
function TextButton.new(appState, name, textKey)
local self = {}
self.enabled = true
local text = appState.localization:Format(textKey)
local textWidth = Text.GetTextWidth(text, FONT, TEXT_SIZE)
self.rbx = Create.new "TextButton" {
Name = name,
BackgroundTransparency = 1,
Text = "",
Size = UDim2.new(0, textWidth + X_PADDING, 1, 0),
Create.new "TextLabel" {
Name = "Label",
Size = UDim2.new(0, textWidth, 0, TEXT_SIZE),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
BackgroundTransparency = 1,
Text = text,
Font = FONT,
TextSize = TEXT_SIZE,
TextColor3 = Constants.Color.BLUE_PRIMARY,
},
}
self.Pressed = Signal.new()
getInputEvent(self.rbx):Connect(function()
if not self.enabled then
return
end
self.Pressed:fire()
end)
setmetatable(self, TextButton)
return self
end
function TextButton:SetEnabled(value)
if value then
self.rbx.Label.TextColor3 = Constants.Color.BLUE_PRIMARY
self.rbx.Label.TextTransparency = 0
else
self.rbx.Label.TextColor3 = Constants.Color.GRAY3
end
self.enabled = value
end
return TextButton
@@ -0,0 +1,229 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local ConversationActions = require(LuaChat.Actions.ConversationActions)
local ConversationModel = require(LuaChat.Models.Conversation)
local Create = require(LuaChat.Create)
local DialogInfo = require(LuaChat.DialogInfo)
local Immutable = require(Common.Immutable)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local FriendSearchBoxComponent = require(Components.FriendSearchBox)
local HeaderLoader = require(Components.HeaderLoader)
local ResponseIndicator = require(Components.ResponseIndicator)
local SectionComponent = require(Components.ListSection)
local TextInputEntry = require(Components.TextInputEntry)
local RemoveRoute = require(LuaChat.Actions.RemoveRoute)
local Intent = DialogInfo.Intent
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local NewChatGroup = {}
NewChatGroup.__index = NewChatGroup
local function getAsset(name)
return "rbxasset://textures/ui/LuaChat/" .. name .. ".png"
end
function NewChatGroup.new(appState)
local self = {
appState = appState,
}
setmetatable(self, NewChatGroup)
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.conversation = ConversationModel.empty()
self.responseIndicator = ResponseIndicator.new(appState)
self.responseIndicator:SetVisible(false)
-- Header:
self.header = HeaderLoader.GetHeader(appState, Intent.NewChatGroup)
self.header:SetDefaultSubtitle()
self.header:SetTitle(appState.localization:Format("Feature.Chat.Heading.NewChatGroup"))
self.header:SetBackButtonEnabled(true)
self.header:SetConnectionState(Enum.ConnectionState.Disconnected)
-- Name the group:
local placeholderText = appState.localization:Format("Feature.Chat.Description.NameGroupChat")
self.groupName = TextInputEntry.new(appState, getAsset("icons/ic-nametag"), placeholderText)
self.groupName.rbx.LayoutOrder = 1
local sanitizeGroupName = function(input)
return input:gsub("\n", "")
end
local textboxChangedConnection = self.groupName.textBoxChanged:connect(function(newGroupName)
self.conversation.title = sanitizeGroupName(newGroupName)
end)
table.insert(self.connections, textboxChangedConnection)
local textboxFocusLostConnection = self.groupName.textBoxFocusLost:connect(function()
self.groupName:SanitizeInput(sanitizeGroupName)
end)
table.insert(self.connections, textboxFocusLostConnection)
-- Search for friends:
self.searchComponent = FriendSearchBoxComponent.new(
appState,
self.conversation.participants,
Constants.MAX_PARTICIPANT_COUNT,
function(user)
return user.isFriend and user.id ~= tostring(Players.LocalPlayer.UserId)
end
)
self.searchComponent.rbx.LayoutOrder = 3
local addParticipantConnection = self.searchComponent.addParticipant:connect(function(id)
self.groupName:ReleaseFocus()
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.Set(self.conversation.participants, #self.conversation.participants+1, id))
end)
table.insert(self.connections, addParticipantConnection)
local removeParticipantConnection = self.searchComponent.removeParticipant:connect(function(id)
self.groupName:ReleaseFocus()
self.searchComponent.search:ReleaseFocus()
self:ChangeParticipants(Immutable.RemoveValueFromList(self.conversation.participants, id))
end)
table.insert(self.connections, removeParticipantConnection)
-- Assemble the dialog from components we just made:
self.sectionComponent = SectionComponent.new(appState, nil, 2)
self.rbx = Create.new"Frame" {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Create.new("UIListLayout") {
Name = "ListLayout",
SortOrder = "LayoutOrder",
},
self.header.rbx,
Create.new"Frame" {
Name = "Content",
Size = UDim2.new(1, 0, 1, -(self.header.heightOfHeader)),
BackgroundColor3 = Constants.Color.GRAY5,
BorderSizePixel = 0,
LayoutOrder = 1,
ClipsDescendants = true,
Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = "LayoutOrder",
},
self.groupName.rbx,
self.sectionComponent.rbx,
self.searchComponent.rbx,
},
self.responseIndicator.rbx,
}
-- Wire up the save button to actually create our new chat group:
self.saveGroup = self.header:CreateHeaderButton("SaveGroup", "Feature.Chat.Action.Create")
self.saveGroup:SetEnabled(false)
local saveGroupConnection = self.saveGroup.Pressed:connect(function()
self.groupName:ReleaseFocus()
self.searchComponent.search:ReleaseFocus()
if #self.conversation.participants >= Constants.MIN_PARTICIPANT_COUNT then
self.responseIndicator:SetVisible(true)
self.appState.store:dispatch(
ConversationActions.CreateConversation(self.conversation, function(id)
self.responseIndicator:SetVisible(false)
if id ~= nil then
self.ConversationSaved:fire(id)
end
self.appState.store:dispatch(RemoveRoute(Intent.NewChatGroup))
end)
)
end
end)
table.insert(self.connections, saveGroupConnection)
self.BackButtonPressed = Signal.new()
self.header.BackButtonPressed:connect(function()
self.groupName:ReleaseFocus()
self.searchComponent.search:ReleaseFocus()
self.BackButtonPressed:fire()
end)
self.ConversationSaved = Signal.new()
spawn(function()
self.appState.store:dispatch(ConversationActions.GetAllFriendsAsync())
end)
self.tooManyFriendsAlertId = nil
-- Monitor for several size changes to properly scale dialog elements:
local groupNameConnection = self.groupName.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, groupNameConnection)
else
table.insert(self.connections, groupNameConnection)
end
local headerSizeConnection = self.header.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
if FFlagLuaChatToSplitRbxConnections then
table.insert(self.rbx_connections, headerSizeConnection)
else
table.insert(self.connections, headerSizeConnection)
end
return self
end
function NewChatGroup:Resize()
-- Content frame must resize if the header changes size (which happens when it shows the "Connecting" message):
local sizeContent = UDim2.new(1, 0, 1, -self.header.rbx.AbsoluteSize.Y)
self.rbx.Content.Size = sizeContent
-- Friends Search frame must resize to fit properly with their peers:
local sizeSearch = UDim2.new(1, 0, 1, -(self.groupName.rbx.AbsoluteSize.Y + self.sectionComponent.rbx.AbsoluteSize.Y))
self.searchComponent.rbx.Size = sizeSearch
end
function NewChatGroup:ChangeParticipants(participants)
self.conversation = Immutable.Set(self.conversation, "participants", participants)
self.searchComponent:Update(participants)
self.saveGroup:SetEnabled(#participants >= Constants.MIN_PARTICIPANT_COUNT)
end
function NewChatGroup:Update(current, previous)
self.header:SetConnectionState(current.ConnectionState)
self.searchComponent:Update(self.conversation.participants)
end
function NewChatGroup:Destruct()
for _, connection in ipairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in ipairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
self.header:Destroy()
self.groupName:Destruct()
self.responseIndicator:Destruct()
self.searchComponent:Destruct()
self.rbx:Destroy()
end
return NewChatGroup
@@ -0,0 +1,56 @@
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local NoFriendsIndicator = {}
NoFriendsIndicator.__index = NoFriendsIndicator
function NoFriendsIndicator.new(appState)
local self = {}
self.rbx = Create.new "Frame" {
Name = "NoFriendsIndicator",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 300),
Create.new "Frame" {
Name = "IndicatorInner",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 160),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
},
Create.new "ImageLabel" {
BackgroundTransparency = 1,
Size = UDim2.new(0, 72, 0, 72),
LayoutOrder = 1,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-friends.png",
},
Create.new "TextLabel" {
Size = UDim2.new(1, -32, 0, 66),
BackgroundTransparency = 1,
LayoutOrder = 2,
Font = Enum.Font.SourceSans,
TextColor3 = Constants.Color.GRAY2,
TextSize = Constants.Font.FONT_SIZE_18,
TextWrapped = true,
Text = appState.localization:Format("Feature.Chat.Message.MakeFriendsToChat"),
},
},
}
setmetatable(self, NoFriendsIndicator)
return self
end
return NoFriendsIndicator
@@ -0,0 +1,49 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local PaddedImageButton = {}
function PaddedImageButton.new(appState, name, imageUrl)
local self = {}
self.rbx = Create.new "ImageButton" {
Name = name,
Size = UDim2.new(0, 40, 0, 40),
BackgroundTransparency = 1,
Create.new "ImageLabel" {
Name = "ImageLabel",
Size = UDim2.new(0, 24, 0, 24),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
BackgroundTransparency = 1,
Image = imageUrl
},
}
self.Pressed = Signal.new()
getInputEvent(self.rbx):Connect(function()
self.Pressed:fire()
end)
setmetatable(self, PaddedImageButton)
return self
end
function PaddedImageButton:SetVisible(value)
self.rbx.Visible = value
end
PaddedImageButton.__index = PaddedImageButton
return PaddedImageButton
@@ -0,0 +1,114 @@
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local GetPlaceThumbnail = require(LuaChat.Actions.GetPlaceThumbnail)
local PlaceInfoCard = {}
PlaceInfoCard.__index = PlaceInfoCard
local PLACE_INFO_CARD_HEIGHT = 72
local PLACE_INFO_THUMBNAIL_SIZE = 50
local UrlSupportNewGamesAPI = settings():GetFFlag("UrlSupportNewGamesAPI")
function PlaceInfoCard.new(appState, placeInfo)
local self = {}
self.appState = appState
self.placeInfo = placeInfo
self.thumbnail = appState.store:getState().ChatAppReducer.PlaceThumbnails[placeInfo.imageToken]
self.connections = {}
setmetatable(self, PlaceInfoCard)
self.rbx = Create.new "Frame" {
Name = "PlaceInfoCardFrame",
BackgroundColor3 = Constants.Color.WHITE,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, PLACE_INFO_CARD_HEIGHT),
Create.new "Frame" {
Name = "PlaceThumbnailFrame",
BackgroundColor3 = Constants.Color.WHITE,
BorderSizePixel = 0,
Size = UDim2.new(0, PLACE_INFO_CARD_HEIGHT, 0, PLACE_INFO_CARD_HEIGHT),
Position = UDim2.new(0, 0, 0, 0)
},
Create.new"TextLabel" {
Name = "PlaceTitle",
BackgroundTransparency = 1,
Size = UDim2.new(1, -PLACE_INFO_CARD_HEIGHT, 0.75, 0),
Position = UDim2.new(0, PLACE_INFO_CARD_HEIGHT, 0, 0),
TextSize = Constants.Font.FONT_SIZE_16,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = placeInfo.name,
TextXAlignment = Enum.TextXAlignment.Left,
},
Create.new"TextLabel" {
Name = "BuilderLabel",
BackgroundTransparency = 1,
Size = UDim2.new(1, -PLACE_INFO_CARD_HEIGHT, 0.35, 0),
Position = UDim2.new(0, PLACE_INFO_CARD_HEIGHT, 0.5, 0),
TextSize = Constants.Font.FONT_SIZE_14,
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
Text = appState.localization:Format("Feature.Chat.Label.ByBuilder", { USERNAME = placeInfo.builder }),
TextXAlignment = Enum.TextXAlignment.Left,
},
}
if not UrlSupportNewGamesAPI then
self.thumbnail = "rbxasset://textures/ui/LuaChat/icons/share-game-thumbnail.png"
end
if not self.thumbnail then
self.appState.store:dispatch(GetPlaceThumbnail(self.placeInfo.imageToken,
PLACE_INFO_THUMBNAIL_SIZE, PLACE_INFO_THUMBNAIL_SIZE))
local appStateConnection = self.appState.store.changed:connect(function(state, oldState)
self:Update(state, oldState)
end)
table.insert(self.connections, appStateConnection)
else
self:FillThumbnail()
end
return self
end
function PlaceInfoCard:Update(newState, oldState)
local thumbnail = newState.ChatAppReducer.PlaceThumbnails[self.placeInfo.imageToken]
if (not self.thumbnail) and thumbnail then
if thumbnail == '' then
self.thumbnail = "rbxasset://textures/ui/LuaChat/icons/share-game-thumbnail.png"
else
self.thumbnail = thumbnail
end
self:FillThumbnail()
end
end
function PlaceInfoCard:FillThumbnail()
self.placeThumbnail = Create.new "ImageLabel" {
Name = "PlaceThumbnail",
Image = self.thumbnail.image,
Size = UDim2.new(0, 48, 0, 48),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
}
self.placeThumbnail.Parent = self.rbx.PlaceThumbnailFrame
end
function PlaceInfoCard:Destruct()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
self.rbx:Destroy()
end
return PlaceInfoCard
@@ -0,0 +1,265 @@
local CoreGui = game:GetService("CoreGui")
local PlayerService = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local FlagSettings = require(LuaChat.FlagSettings)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local GetMultiplePlaceInfos = require(LuaChat.Actions.GetMultiplePlaceInfos)
local GetPlaceThumbnail = require(LuaChat.Actions.GetPlaceThumbnail)
local Signal = require(Modules.Common.Signal)
local SortedActivelyPlayedGames = require(LuaChat.SortedActivelyPlayedGames)
local User = require(LuaApp.Models.User)
local LuaChatPlayTogetherUseRootPresence = FlagSettings.LuaChatPlayTogetherUseRootPresence()
local ICON_MASK = "rbxasset://textures/ui/LuaChat/graphic/gr-mask-game-icon-48x48.png"
local STACKED_ICON_MASK = "rbxasset://textures/ui/LuaChat/graphic/gr-gamealbum-icon-52x52.png"
local BORDER_ICON_MASK = "rbxasset://textures/ui/LuaChat/graphic/gr-game-border-24x24.png"
local DEFAULT_THUMBNAIL = "rbxasset://textures/ui/LuaChat/icons/share-game-thumbnail.png"
local LARGE_GAME_ICON_OUTER_SIZE = UDim2.new(0, 48, 0, 48)
local LARGE_GAME_STACK_ICON_SIZE = UDim2.new(0, 52, 0, 52)
local SMALL_GAME_ICON_OUTER_SIZE = UDim2.new(0, 40, 0, 40)
local SMALL_GAME_ICON_CHILD_SIZE = UDim2.new(0, 24, 0, 24)
local PLACE_INFO_THUMBNAIL_SIZE = 48
local UrlSupportNewGamesAPI = settings():GetFFlag("UrlSupportNewGamesAPI")
local PlayTogetherGameIcon = {}
PlayTogetherGameIcon.__index = PlayTogetherGameIcon
PlayTogetherGameIcon.Size = {
SMALL = "SMALL",
LARGE = "LARGE",
}
PlayTogetherGameIcon.Type = {
DEFAULT = "DEFAULT",
ACTIVE = "ACTIVE"
}
function PlayTogetherGameIcon.new(appState, conversation, iconSize, iconType)
local self = {
appState = appState,
}
self.cachedConversation = nil
self.cachedThumbnailPlaceInfo = nil
self.conversationId = nil
self.conversationUsers = {}
self.fetchedMostRecentlyPlayedGames = false
self.setIconPending = false
self.type = iconType or PlayTogetherGameIcon.Type.DEFAULT
self.updateConnection = nil
local childImage = BORDER_ICON_MASK
local childSize = SMALL_GAME_ICON_CHILD_SIZE
local size = SMALL_GAME_ICON_CHILD_SIZE
local outerSize = SMALL_GAME_ICON_OUTER_SIZE
if iconSize == PlayTogetherGameIcon.Size.LARGE then
childImage = ICON_MASK
childSize = LARGE_GAME_STACK_ICON_SIZE
size = LARGE_GAME_ICON_OUTER_SIZE
outerSize = LARGE_GAME_ICON_OUTER_SIZE
end
self.rbx = Create.new "Frame" {
Name = "Frame",
BackgroundTransparency = 1,
Size = outerSize,
Create.new "ImageButton" {
Name = "TopGameIcon",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
Image = DEFAULT_THUMBNAIL,
Position = UDim2.new(0.5, 0, 0.5, 0),
Size = size,
Create.new "ImageLabel" {
Name = "Mask",
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
Image = childImage,
Position = UDim2.new(0.5, 0, 0.5, 0),
Size = childSize,
}
}
}
self.iconSize = iconSize
self.mask = self.rbx.TopGameIcon.Mask
setmetatable(self, PlayTogetherGameIcon)
self:SetVisible(false)
if conversation ~= nil then
self.conversationId = conversation.id
self.cachedConversation = conversation
self:Update(conversation)
self.updateConnection = appState.store.changed:connect(function(state, oldState)
-- Update if there's a change in which conversation we're viewing:
local newConversation = state.ChatAppReducer.Conversations[self.conversationId]
-- Note conversations can be nil if we're a dummy 1:1 conversation
-- which was removed and replaced by a server conversation:
if newConversation == nil then
return
end
if self.cachedConversation ~= newConversation then
self.cachedConversation = newConversation
self:Update(self.cachedConversation)
return
end
-- Update if there's any change in our conversation participants:
local users = state.Users
local localPlayerId = tostring(PlayerService.LocalPlayer.UserId)
local participantsCache = self.cachedConversation.participants
for _, id in ipairs(conversation.participants) do
if id ~= localPlayerId then
if participantsCache[id] ~= users[id] then
self:Update(self.cachedConversation)
return
end
end
end
-- Update if we were waiting for our icon to load:
if self.setIconPending then
self:Update(self.cachedConversation)
return
end
end)
end
self.Pressed = Signal.new()
getInputEvent(self.rbx.TopGameIcon):Connect(function()
self.Pressed:fire()
end)
return self
end
function PlayTogetherGameIcon:Update(conversation)
if not UrlSupportNewGamesAPI then
self.rbx.TopGameIcon.Image = DEFAULT_THUMBNAIL
return
end
local state = self.appState.store:getState()
local pinnedGameRootPlaceId = nil
if conversation.pinnedGame and conversation.pinnedGame.rootPlaceId then
pinnedGameRootPlaceId = conversation.pinnedGame.rootPlaceId
end
local inGameParticipants = {}
local mostRecentPlayedPlayableGamePlaceId =
self.appState.store:getState().ChatAppReducer.MostRecentlyPlayedGames.playableGamePlaceId
local localPlayerId = tostring(PlayerService.LocalPlayer.UserId)
for _, userId in pairs(conversation.participants) do
if userId ~= localPlayerId then
local user = state.Users[userId]
if user ~= nil then
if LuaChatPlayTogetherUseRootPresence then
if (user.presence == User.PresenceType.IN_GAME) and user.rootPlaceId then
table.insert(inGameParticipants, user)
end
else
if (user.presence == User.PresenceType.IN_GAME) and user.placeId then
table.insert(inGameParticipants, user)
end
end
end
end
end
if self.type == PlayTogetherGameIcon.Type.ACTIVE then
if #inGameParticipants > 0 then
local activelyPlayedGames = SortedActivelyPlayedGames.getSortedGames(pinnedGameRootPlaceId,
inGameParticipants)
local isMultiple = #activelyPlayedGames > 1
self:SetThumbnail(state, activelyPlayedGames[1].placeId, isMultiple)
else
self:SetVisible(false)
end
else
if #inGameParticipants > 0 then
local activelyPlayedGames = SortedActivelyPlayedGames.getSortedGames(pinnedGameRootPlaceId,
inGameParticipants)
self:SetThumbnail(state, activelyPlayedGames[1].placeId, false)
elseif pinnedGameRootPlaceId then
self:SetThumbnail(state, pinnedGameRootPlaceId, false)
elseif mostRecentPlayedPlayableGamePlaceId then
self:SetThumbnail(state, mostRecentPlayedPlayableGamePlaceId, false)
else
self:SetVisible(false)
end
end
end
function PlayTogetherGameIcon:SetThumbnail(state, chosenPlaceId, isMultiple)
-- Always set the default icon while loading, so we don't see the old one:
self.rbx.TopGameIcon.Image = DEFAULT_THUMBNAIL
local placeInfo = state.ChatAppReducer.PlaceInfos[chosenPlaceId]
if placeInfo == nil then
self.appState.store:dispatch(GetMultiplePlaceInfos({chosenPlaceId}))
self.setIconPending = true
else
self.cachedThumbnailPlaceInfo = placeInfo
local thumbnail = state.ChatAppReducer.PlaceThumbnails[placeInfo.imageToken]
if thumbnail == nil then
self.appState.store:dispatch(GetPlaceThumbnail(
placeInfo.imageToken, PLACE_INFO_THUMBNAIL_SIZE, PLACE_INFO_THUMBNAIL_SIZE
))
self.setIconPending = true
else
self:SetIsMultipleGames(isMultiple)
self.setIconPending = false
if thumbnail.image ~= '' then
self.rbx.TopGameIcon.Image = thumbnail.image
end
end
end
self:SetVisible(true)
end
function PlayTogetherGameIcon:SetVisible(value)
self.rbx.Visible = value
end
function PlayTogetherGameIcon:SetIsMultipleGames(isMultiple)
if self.iconSize == PlayTogetherGameIcon.Size.SMALL then
return
end
if isMultiple then
self.mask.Image = STACKED_ICON_MASK
self.mask.Size = LARGE_GAME_STACK_ICON_SIZE
else
self.mask.Image = ICON_MASK
self.mask.Size = LARGE_GAME_ICON_OUTER_SIZE
end
end
function PlayTogetherGameIcon:Destruct()
if self.updateConnection then
self.updateConnection:Disconnect()
end
self.rbx:Destroy()
end
return PlayTogetherGameIcon
@@ -0,0 +1,65 @@
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local LoadingIndicatorComponent = require(LuaChat.Components.LoadingIndicator)
local PADDING = 48
local ResponseIndicator = {}
function ResponseIndicator.new(appState)
local self = {}
setmetatable(self, {__index = ResponseIndicator})
self.connections = {}
self.indicator = LoadingIndicatorComponent.new(appState)
self.indicator:SetZIndex(3)
self.indicator.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.indicator.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
self.rbx = Create.new"ImageButton" { -- So you can't select buttons underneath
Name = "ResponseIndicator",
BackgroundTransparency = Constants.Color.ALPHA_SHADOW_HOVER,
BackgroundColor3 = Constants.Color.GRAY1,
AutoButtonColor = false,
BorderSizePixel = 0,
Active = false,
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
ZIndex = 3,
Create.new"ImageLabel" {
BackgroundTransparency = 1,
Size = self.indicator.rbx.Size + UDim2.new(0, PADDING, 0, PADDING),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(3,3,4,4),
Image = "rbxasset://textures/ui/LuaChat/9-slice/input-default.png",
BorderSizePixel = 0,
ZIndex = 3,
self.indicator.rbx,
},
}
return self
end
function ResponseIndicator:SetVisible(value)
self.rbx.Visible = value
self.indicator:SetVisible(value)
end
function ResponseIndicator:Destruct()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
self.indicator:Destroy()
self.rbx:Destroy()
end
return ResponseIndicator
@@ -0,0 +1,136 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local Constants = require(Modules.LuaChat.Constants)
local formatInteger = require(Modules.LuaChat.Utils.formatInteger)
local LocalizedTextLabel = require(Modules.LuaApp.Components.LocalizedTextLabel)
local TEXT_FONT = Enum.Font.SourceSans
local GAME_NAME_LABEL_HEIGHT = 20
local GAME_NAME_LABEL_TEXT_SIZE = 23
local GAME_NAME_LABEL_TEXT_COLOR = Constants.Color.GRAY1
local CREATOR_NAME_LABEL_HEIGHT = 14
local CREATOR_NAME_LABEL_TEXT_SIZE = 15
local CREATOR_NAME_LABEL_TOP_PADDING = 6
local CREATOR_NAME_LABEL_COLOR = Constants.Color.GRAY2
local SUBTITLE_LABEL_HEIGHT = 14
local SUBTITLE_LABEL_TEXT_SIZE = 14
local SUBTITLE_LABEL_TOP_PADDING = 3
local SUBTITLE_FRAME_HEIGHT = SUBTITLE_LABEL_HEIGHT + SUBTITLE_LABEL_TOP_PADDING
local ROBUX_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-robux.png"
local ROBUX_ICON_SIZE = 12
local ROBUX_TO_PRICE_PADDING = 3
local PRICE_COLOR = Constants.Color.GREEN_PRIMARY
local FFlagLuaChatShareGameToChatFromChatV2 = settings():GetFFlag("LuaChatShareGameToChatFromChatV2")
local GameInformation = Roact.PureComponent:extend("GameInformation")
function GameInformation:render()
local gameModel = self.props.gameModel
if not gameModel then
return nil
end
local gameTitle = gameModel.name
local creatorName = gameModel.creatorName
-- TODO wait lua app team store the playability of the game, default value is true
-- Ticket: MOBLUAPP-683, Review http://swarm.roblox.local/reviews/226723/
-- TODO: SOC-3779 This ticket has been committed since this comment.
-- * New ticket: https://jira.roblox.com/browse/SOC-3779
local playable = FFlagLuaChatShareGameToChatFromChatV2 or self.props.gameModel.isPlayable
local gamePrice = gameModel.price
local showPrice = gamePrice and playable
return Roact.createElement("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
Name = Roact.createElement("TextLabel", {
BackgroundTransparency = 1,
Font = TEXT_FONT,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 0, GAME_NAME_LABEL_HEIGHT),
Text = gameTitle,
TextColor3 = GAME_NAME_LABEL_TEXT_COLOR,
TextSize = GAME_NAME_LABEL_TEXT_SIZE,
TextTruncate = Enum.TextTruncate.AtEnd,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
}),
Creator = Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
Font = TEXT_FONT,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, CREATOR_NAME_LABEL_HEIGHT + CREATOR_NAME_LABEL_TOP_PADDING),
Text = {"Feature.Chat.ShareGameToChat.By", creatorName = creatorName},
TextColor3 = CREATOR_NAME_LABEL_COLOR,
TextSize = CREATOR_NAME_LABEL_TEXT_SIZE,
TextTruncate = Enum.TextTruncate.AtEnd,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
}),
NotAvailableTip = not playable and Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
Font = TEXT_FONT,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, SUBTITLE_FRAME_HEIGHT),
Text = "Feature.Chat.ShareGameToChat.GameNotAvailable",
TextColor3 = CREATOR_NAME_LABEL_COLOR,
TextSize = SUBTITLE_LABEL_TEXT_SIZE,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
}),
GamePrice = showPrice and Roact.createElement("Frame", {
BackgroundTransparency = 1,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, SUBTITLE_FRAME_HEIGHT),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, ROBUX_TO_PRICE_PADDING),
}),
RobuxIcon = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
Image = ROBUX_ICON,
LayoutOrder = 1,
ScaleType = Enum.ScaleType.Fit,
Size = UDim2.new(0, ROBUX_ICON_SIZE, 0, ROBUX_ICON_SIZE),
}),
Price = Roact.createElement("TextLabel",{
BackgroundTransparency = 1,
Size = UDim2.new(1, -(ROBUX_ICON_SIZE + ROBUX_TO_PRICE_PADDING), 1, 0),
Font = TEXT_FONT,
LayoutOrder = 2,
Text = formatInteger(gamePrice),
TextColor3 = PRICE_COLOR,
TextSize = SUBTITLE_LABEL_TEXT_SIZE,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
}),
}),
})
end
return GameInformation
@@ -0,0 +1,165 @@
return function()
local GameInformation = require(script.Parent.GameInformation)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local mockServices = require(Modules.LuaApp.TestHelpers.mockServices)
local FFlagLuaChatShareGameToChatFromChatV2 = settings():GetFFlag("LuaChatShareGameToChatFromChatV2")
-- TODO: SOC-3805 `gameModel` should be using an actual model.
local function createValidGameModel(isPlayable, price)
return {
imageToken = "mock-token",
name = "mock-name",
universeId = "mock-id",
isPlayable = isPlayable,
price = price or nil,
creatorName = "mock-creator",
}
end
describe("SHOULD create and destroy without errors", function()
it("WHEN passed no props", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation)
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("WHEN passed a valid gameModel", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(),
})
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end)
if not FFlagLuaChatShareGameToChatFromChatV2 then
describe("SHOULD determine if the game is available", function()
describe("SHOULD display playability status visible", function()
it("WHEN the game is not playable and free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(false, nil),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local notAvailableTip = folder:FindFirstChild("NotAvailableTip", true)
expect(notAvailableTip).to.be.ok()
Roact.unmount(instance)
end)
it("WHEN the game is not playable and not free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(false, 1000),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local notAvailableTip = folder:FindFirstChild("NotAvailableTip", true)
expect(notAvailableTip).to.be.ok()
Roact.unmount(instance)
end)
end)
describe("SHOULD display playability status as not visible", function()
it("WHEN the game is playable and free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(true, nil),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local notAvailableTip = folder:FindFirstChild("NotAvailableTip", true)
expect(notAvailableTip).to.never.be.ok()
Roact.unmount(instance)
end)
end)
end)
describe("SHOULD determine to show price", function()
describe("SHOULD display price as visible", function()
it("WHEN the game is playable and not free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(true, 1000),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local gamePrice = folder:FindFirstChild("GamePrice", true)
expect(gamePrice).to.be.ok()
Roact.unmount(instance)
end)
end)
describe("SHOULD display price as not visible", function()
it("WHEN the game is playable and free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(true, nil),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local gamePrice = folder:FindFirstChild("GamePrice", true)
expect(gamePrice).to.never.be.ok()
Roact.unmount(instance)
end)
it("WHEN the game is not playable and free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(false, nil),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local gamePrice = folder:FindFirstChild("GamePrice", true)
expect(gamePrice).to.never.be.ok()
Roact.unmount(instance)
end)
it("WHEN the game is not playable and not free", function()
local element = mockServices({
GameInformation = Roact.createElement(GameInformation, {
gameModel = createValidGameModel(false, 1000),
})
})
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local gamePrice = folder:FindFirstChild("GamePrice", true)
expect(gamePrice).to.never.be.ok()
Roact.unmount(instance)
end)
end)
end)
end
end
@@ -0,0 +1,29 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local GAME_BORDER_ICON = "rbxasset://textures/ui/LuaChat/graphic/gr-game-border-60x60.png"
local RoundedIcon = Roact.PureComponent:extend("RoundedIcon")
function RoundedIcon:render()
local image = self.props.Image
local size = self.props.Size
local layoutOrder = self.props.LayoutOrder
return Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
Image = image,
Size = size,
LayoutOrder = layoutOrder,
}, {
RoundCornerOverlay = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
Image = GAME_BORDER_ICON,
Size = UDim2.new(1, 0, 1, 0),
}),
})
end
return RoundedIcon
@@ -0,0 +1,32 @@
return function()
local RoundedIcon = require(script.Parent.RoundedIcon)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
describe("SHOULD create and destroy without errors", function()
it("WHEN passed no props", function()
local element = Roact.createElement(RoundedIcon)
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("WHEN passed an Image", function()
local element = Roact.createElement(RoundedIcon, {
Image = "mock-id",
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("WHEN passed a valid Size", function()
local element = Roact.createElement(RoundedIcon, {
Size = UDim2.new(1, 0, 1, 0),
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end)
end
@@ -0,0 +1,74 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local isInputTypeTouchOrMouseDown = require(Modules.LuaChat.Utils.isInputTypeTouchOrMouseDown)
local SEND_BUTTON_ICON = "rbxasset://textures/ui/LuaChat/icons/icon-share-game-24x24.png"
local SEND_BUTTON_ICON_PRESSED = "rbxasset://textures/ui/LuaChat/icons/icon-share-game-pressed-24x24.png"
local SEND_BUTTON_ICON_SIZE = 24
local SEND_BUTTON_LEFT_PADDING = 12
local SEND_BUTTON_RIGHT_PADDING = 25
local SEND_BUTTON_FULL_WIDTH = SEND_BUTTON_ICON_SIZE + SEND_BUTTON_LEFT_PADDING + SEND_BUTTON_RIGHT_PADDING
local SendToChatButton = Roact.PureComponent:extend("SendToChatButton")
function SendToChatButton.getWidth()
return SEND_BUTTON_FULL_WIDTH
end
function SendToChatButton:init()
self.state = {
isButtonPressed = false,
}
self.onSendButtonInputBegan = function(_, inputObject)
if isInputTypeTouchOrMouseDown(inputObject) then
self:setState({
isButtonPressed = true,
})
end
end
self.onSendButtonInputEnded = function(_, inputObject)
if isInputTypeTouchOrMouseDown(inputObject) then
self:setState({
isButtonPressed = false,
})
end
end
end
function SendToChatButton:render()
local onActivated = self.props.onActivated
local layoutOrder = self.props.LayoutOrder
local isButtonPressed = self.state.isButtonPressed
local sendButtonImage = SEND_BUTTON_ICON
if isButtonPressed then
sendButtonImage = SEND_BUTTON_ICON_PRESSED
end
return Roact.createElement("ImageButton", {
BackgroundTransparency = 1,
LayoutOrder = layoutOrder,
Size = UDim2.new(0, SEND_BUTTON_FULL_WIDTH, 1, 0),
[Roact.Event.InputBegan] = self.onSendButtonInputBegan,
[Roact.Event.InputEnded] = self.onSendButtonInputEnded,
[Roact.Event.Activated] = onActivated,
},{
SendButton = Roact.createElement("ImageLabel", {
AnchorPoint = Vector2.new(0, 0.5),
BackgroundTransparency = 1,
Image = sendButtonImage,
Position = UDim2.new(0, SEND_BUTTON_LEFT_PADDING, 0.5, 0),
Size = UDim2.new(0, SEND_BUTTON_ICON_SIZE, 0, SEND_BUTTON_ICON_SIZE),
}),
})
end
return SendToChatButton
@@ -0,0 +1,21 @@
return function()
local SendToChatButton = require(script.Parent.SendToChatButton)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
describe("SHOULD create and destroy without errors", function()
it("WHEN passed no props", function()
local element = Roact.createElement(SendToChatButton)
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end)
it("SHOULD return a number from getWidth", function()
local width = SendToChatButton.getWidth()
expect(type(width)).to.equal("number")
end)
end
@@ -0,0 +1,233 @@
local CoreGui = game:GetService("CoreGui")
local GuiService = game:GetService("GuiService")
local Modules = CoreGui.RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local RoactAnalyticsSharedGameItem = require(Modules.LuaChat.Services.RoactAnalyticsSharedGameItem)
local RoactRodux = require(Modules.Common.RoactRodux)
local RoactServices = require(Modules.LuaApp.RoactServices)
local GameInformation = require(Modules.LuaChat.Components.ShareGameToChatFromChat.GameInformation)
local RoundedIcon = require(Modules.LuaChat.Components.ShareGameToChatFromChat.RoundedIcon)
local SendToChatButton = require(Modules.LuaChat.Components.ShareGameToChatFromChat.SendToChatButton)
local ConversationActions = require(Modules.LuaChat.Actions.ConversationActions)
local Constants = require(Modules.LuaChat.Constants)
local isInputTypeTouchOrMouseDown = require(Modules.LuaChat.Utils.isInputTypeTouchOrMouseDown)
local UNPRESSED_BACKGROUND_COLOR = Constants.Color.WHITE
local PRESSED_BACKGROUND_COLOR = Constants.Color.GRAY5
local DEFAULT_SEPARATOR_LINE_COLOR = Constants.Color.GRAY4
local TOTAL_HEIGHT = 84
local GAME_ICON_SIZE = Constants.SharedGamesConfig.Thumbnail.SHOWN_SIZE
local GAME_ICON_LEFT_PADDING = 15
local GAME_ICON_RIGHT_PADDING = 12
local GAME_ICON_TOP_PADDING = 12
local GAME_ICON_FULL_WIDTH = GAME_ICON_SIZE + GAME_ICON_LEFT_PADDING + GAME_ICON_RIGHT_PADDING
local GAME_LOADING_ICON = "rbxasset://textures/ui/LuaApp/icons/ic-game.png"
local SEND_BUTTON_WIDTH = SendToChatButton.getWidth()
local function verticalSpacer(props)
local height = props.height
local layoutOrder = props.LayoutOrder
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 0, height),
BackgroundTransparency = 1,
LayoutOrder = layoutOrder,
})
end
local function horizontalSpacer(props)
local width = props.width
local layoutOrder = props.LayoutOrder
return Roact.createElement("Frame", {
Size = UDim2.new(0, width, 1, 0),
BackgroundTransparency = 1,
LayoutOrder = layoutOrder,
})
end
local SharedGameItem = Roact.PureComponent:extend("SharedGameItem")
function SharedGameItem:init()
self.state = {
isGameInputDown = false,
}
self.onGameButtonActivated = function()
-- TODO: SOC-3805 Can remove string coercion when using proper model
local universeId = self.props.game and tostring(self.props.game.universeId)
if universeId then
local notificationType = GuiService:GetNotificationTypeList().VIEW_GAME_DETAILS_ANIMATED
GuiService:BroadcastNotification(string.format("%s", universeId), notificationType)
end
end
self.onGameItemInputBegan = function(_, inputObject)
if isInputTypeTouchOrMouseDown(inputObject) then
self:setState({
isGameInputDown = true,
})
end
end
self.onGameItemInputEnded = function(_, inputObject)
if isInputTypeTouchOrMouseDown(inputObject) then
self:setState({
isGameInputDown = false,
})
end
end
self.onSendButtonActivated = function()
local gameModel = self.props.game
local activeConversationId = self.props.activeConversationId
local analytics = self.props.analytics
local gameUrl = self.props.gameUrl
local isSharing = self.props.isSharing
local universeId = gameModel and tostring(gameModel.universeId)
if universeId then
if not isSharing then
self.props.shareGameToChat(activeConversationId, analytics, universeId, gameUrl)
end
end
end
end
function SharedGameItem:render()
local gameModel = self.props.game
local layoutOrder = self.props.layoutOrder
local gameThumbnails = self.props.gameThumbnails
-- TODO: SOC-3805 `gameModel` should be using an actual model.
-- Currently this is the raw data derived from the WebApi endpoint
-- (called from ShareGameToChatFromChatThunks:FetchGames)
-- This means we have to coerce all ids into strings manually here
local gameId = gameModel and tostring(gameModel.universeId)
local gameIcon = gameId and gameThumbnails[gameId] or GAME_LOADING_ICON
local isGameInputDown = self.state.isGameInputDown
local backgroundColor = UNPRESSED_BACKGROUND_COLOR
if isGameInputDown then
backgroundColor = PRESSED_BACKGROUND_COLOR
end
return Roact.createElement("Frame", {
BackgroundColor3 = backgroundColor,
BorderSizePixel = 0,
LayoutOrder = layoutOrder,
Size = UDim2.new(1, 0, 0, TOTAL_HEIGHT),
},{
Content = Roact.createElement("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
GameButtonContainer = Roact.createElement("ImageButton", {
BackgroundTransparency = 1,
LayoutOrder = 1,
Size = UDim2.new(1, -SEND_BUTTON_WIDTH, 1, 0),
[Roact.Event.Activated] = self.onGameButtonActivated,
[Roact.Event.InputBegan] = self.onGameItemInputBegan,
[Roact.Event.InputEnded] = self.onGameItemInputEnded,
},{
LayoutHorizontal = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
LeftMargin = Roact.createElement(horizontalSpacer, {
width = GAME_ICON_LEFT_PADDING,
LayoutOrder = 1,
}),
GameIconSection = Roact.createElement("Frame", {
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(0, GAME_ICON_SIZE, 1, 0),
}, {
LayoutVertical = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
TopMargin = Roact.createElement(verticalSpacer, {
height = GAME_ICON_TOP_PADDING,
LayoutOrder = 1,
}),
GameIcon = Roact.createElement(RoundedIcon, {
Image = gameIcon,
Size = UDim2.new(0, GAME_ICON_SIZE, 0, GAME_ICON_SIZE),
LayoutOrder = 2,
}),
}),
IconToInfoPadding = Roact.createElement(horizontalSpacer, {
width = GAME_ICON_RIGHT_PADDING,
LayoutOrder = 3,
}),
GameInfoAntiPadding = Roact.createElement("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -GAME_ICON_FULL_WIDTH, 1, 0),
LayoutOrder = 3,
}, {
GameInfo = gameModel and Roact.createElement(GameInformation, {
gameModel = gameModel,
}),
})
}),
SendButtonContainer = Roact.createElement(SendToChatButton, {
onActivated = self.onSendButtonActivated,
LayoutOrder = 2,
}),
}),
Separator = Roact.createElement("Frame", {
AnchorPoint = Vector2.new(0, 1),
BackgroundColor3 = DEFAULT_SEPARATOR_LINE_COLOR,
BorderSizePixel = 0,
Position = UDim2.new(0, GAME_ICON_FULL_WIDTH, 1, 0),
Size = UDim2.new(1, -GAME_ICON_FULL_WIDTH, 0, 1),
}),
})
end
SharedGameItem = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
-- TODO: SOC-3392 Kill activeConversationId and pass conversationId as routing prop
activeConversationId = state.ChatAppReducer.ActiveConversationId,
gameThumbnails = state.GameThumbnails,
-- TODO: SOC-3896 Allow this to come from parent component and not in store
isSharing = state.ChatAppReducer.ShareGameToChatAsync.sharingGame,
}
end,
function(dispatch)
return {
shareGameToChat = function(activeConversationId, analytics, universeId)
analytics.reportShareGameToChatFromChat(activeConversationId, tostring(universeId))
return dispatch(ConversationActions.ShareGame(activeConversationId, universeId))
end,
}
end
)(SharedGameItem)
SharedGameItem = RoactServices.connect({
analytics = RoactAnalyticsSharedGameItem,
})(SharedGameItem)
return SharedGameItem
@@ -0,0 +1,69 @@
return function()
local SharedGameItem = require(script.Parent.SharedGameItem)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local AppReducer = require(Modules.LuaApp.AppReducer)
local Roact = require(Modules.Common.Roact)
local Rodux = require(Modules.Common.Rodux)
local mockServices = require(Modules.LuaApp.TestHelpers.mockServices)
local MOCK_UNIVERSE_ID = "MOCK_UNIVERSE_ID"
local MOCK_THUMBNAIL = "MOCK_THUMBNAIL"
-- TODO: SOC-3805 `gameModel` should be using an actual model.
local function createValidGameModel(universeId)
return {
imageToken = "mock-token",
name = "mock-name",
universeId = universeId,
isPlayable = true,
price = 1000,
creatorName = "mock-creator",
}
end
local storeWithGameThumbnails = Rodux.Store.new(AppReducer, {
GameThumbnails = {
[MOCK_UNIVERSE_ID] = MOCK_THUMBNAIL,
},
})
local function createSharedGameItemTest(universeId)
local element = mockServices({
SharedGameItem = Roact.createElement(SharedGameItem, {
game = createValidGameModel(universeId),
}),
}, {
includeStoreProvider = true,
store = storeWithGameThumbnails,
})
return element
end
describe("SHOULD create and destroy without errors", function()
it("WHEN passed no props", function()
local element = mockServices({
SharedGameItem = Roact.createElement(SharedGameItem),
}, {
includeStoreProvider = true,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("WHEN passed a valid gameModel prop", function()
local element = createSharedGameItemTest(MOCK_UNIVERSE_ID)
local folder = Instance.new("Folder")
local instance = Roact.mount(element, folder)
local gameInfoInstance = folder:FindFirstChild("GameInfo", true)
expect(gameInfoInstance).to.be.ok()
Roact.unmount(instance)
end)
end)
end
@@ -0,0 +1,190 @@
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Immutable = require(Modules.Common.Immutable)
local memoize = require(Modules.Common.memoize)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local RoactServices = require(Modules.LuaApp.RoactServices)
local RoactNetworking = require(Modules.LuaApp.Services.RoactNetworking)
local LuaAppConstants = require(LuaApp.Constants)
local LuaChatConstants = require(LuaChat.Constants)
local LocalizedTextLabel = require(LuaApp.Components.LocalizedTextLabel)
local LoadingBar = require(LuaApp.Components.LoadingBar)
local RefreshScrollingFrame = require(LuaApp.Components.RefreshScrollingFrame)
local SharedGameItem = require(LuaChat.Components.SharedGameItem)
local ApiFetchGamesData = require(LuaApp.Thunks.ApiFetchGamesData)
local ApiFetchGamesInSort = require(LuaApp.Thunks.ApiFetchGamesInSort)
local RetrievalStatus = require(LuaApp.Enum.RetrievalStatus)
local GAME_ITEM_HEIGHT = 84
local NO_GAMES_TIPS_TOP_PADDING = 30
local NO_GAMES_TIPS_LABEL_HEIGHT = 25
local SharedGamesList = Roact.PureComponent:extend("SharedGamesList")
SharedGamesList.defaultProps = {
visible = false
}
function SharedGamesList:init()
self.loadMoreGames = function(count)
self.isLoadingMore = true
local loadCount = count or LuaChatConstants.DEFAULT_GAME_FETCH_COUNT
local sorts = self.props.sorts
local selectedSortName = self.props.gameSort
local dispatchLoadMoreGames = self.props.dispatchLoadMoreGames
local networking = self.props.networking
local selectedSort = sorts[selectedSortName]
return dispatchLoadMoreGames(networking, selectedSort, selectedSort.rowsRequested, loadCount,
selectedSort.nextPageExclusiveStartId)
end
self.refresh = function()
return self.props.dispatchRefresh(self.props.networking, self.props.gameSort)
end
if self.props.visible then
self.refresh()
end
end
function SharedGamesList:willUpdate()
if not self.props.fetchingSortTokenStatus or
self.props.fetchingSortTokenStatus == RetrievalStatus.Fetching then
return
end
if not self.props.fetchingGamesStatus then
self.refresh()
end
end
function SharedGamesList:render()
if not self.props.visible then
return nil
end
if not self.props.fetchingSortTokenStatus or
self.props.fetchingSortTokenStatus == RetrievalStatus.Fetching then
return Roact.createElement(LoadingBar)
end
local games = self.props.games
local sortName = self.props.gameSort
local sorts = self.props.sorts
local selectedSort = sorts[sortName]
local gameCount = #selectedSort.entries
local hasMoreRows = selectedSort.hasMoreRows
local gamesItems = {}
gamesItems["Layout"] = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
})
for index = 1, gameCount do
local uid = selectedSort.entries[index].universeId
gamesItems[index] = Roact.createElement(SharedGameItem, {
game = games[uid],
itemHeight = GAME_ITEM_HEIGHT,
layoutOrder = index,
})
end
if (not self.props.fetchingGamesStatus or self.props.fetchingGamesStatus == RetrievalStatus.Fetching)
and gameCount == 0 then
return Roact.createElement(LoadingBar)
end
return Roact.createElement(RefreshScrollingFrame, {
BackgroundColor3 = LuaChatConstants.Color.GRAY6,
CanvasSize = UDim2.new(1, 0, 0, 0),
Position = UDim2.new(0, 0, 0, 0),
Size = UDim2.new(1, 0, 1, 0),
onLoadMore = hasMoreRows and self.loadMoreGames,
refresh = self.refresh,
}, {
SharedGamesList = gameCount > 0 and Roact.createElement("Frame", {
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, gameCount * GAME_ITEM_HEIGHT),
LayoutOrder = 3,
}, gamesItems),
NoGamesTip = (gameCount == 0) and Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
Position = UDim2.new(0, 0, 0, NO_GAMES_TIPS_TOP_PADDING),
Size = UDim2.new(1, 0, 0, NO_GAMES_TIPS_LABEL_HEIGHT),
Text = LuaChatConstants.SharedGamesConfig.SortsAttribute[sortName].ERROR_TIP_LOCALIZATION_KEY,
TextColor3 = LuaChatConstants.Color.GRAY2,
TextSize = 20,
}),
})
end
local function sortSorts(a, b)
return a.displayName:lower() < b.displayName:lower()
end
local selectSorts = memoize(function(gameSorts, gameSortsContents)
local sorts = {}
for _, sortInfo in pairs(gameSorts) do
local gameSortContents = gameSortsContents[sortInfo.name]
local sort = Immutable.JoinDictionaries(sortInfo, {
entries = gameSortContents.entries,
rowsRequested = gameSortContents.rowsRequested,
hasMoreRows = gameSortContents.hasMoreRows,
nextPageExclusiveStartId = gameSortContents.nextPageExclusiveStartId,
})
table.insert(sorts, sort)
sorts[sort.name] = sort
end
table.sort(sorts, sortSorts)
return sorts
end)
SharedGamesList = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
fetchingSortTokenStatus = state.RequestsStatus.GameSortTokenFetchingStatus[LuaAppConstants.GameSortGroups.ChatGames],
fetchingGamesStatus = state.RequestsStatus.GameSortsStatus[props.gameSort],
games = state.Games,
sorts = selectSorts(state.GameSorts, state.GameSortsContents),
}
end,
function(dispatch)
return {
dispatchRefresh = function(networking, sortName)
return dispatch(ApiFetchGamesData(networking, nil, sortName))
end,
dispatchLoadMoreGames = function(networking, sort, startRows, maxRows, nextPageExclusiveStartId)
return dispatch(ApiFetchGamesInSort(networking, sort, true, {
startRows = startRows,
maxRows = maxRows,
exclusiveStartId = nextPageExclusiveStartId
}))
end
}
end
)(SharedGamesList)
return RoactServices.connect({
networking = RoactNetworking,
})(SharedGamesList)
@@ -0,0 +1,21 @@
return function()
local SharedGamesList = require(script.Parent.SharedGamesList)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local mockServices = require(Modules.LuaApp.TestHelpers.mockServices)
it("should create and destroy without errors", function()
local element = mockServices({
SharedGamesList = Roact.createElement(SharedGamesList, {
gameSort = "Popular",
}),
}, {
includeStoreProvider = true,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,473 @@
local GuiService = game:GetService("GuiService")
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local UserInputService = game:GetService("UserInputService")
local Constants = require(Modules.LuaChat.Constants)
local ConversationActions = require(Modules.LuaChat.Actions.ConversationActions)
local formatInteger = require(Modules.LuaChat.Utils.formatInteger)
local getGameUrlByPlaceId = require(Modules.LuaChat.Utils.getGameUrlByPlaceId)
local LocalizedTextLabel = require(Modules.LuaApp.Components.LocalizedTextLabel)
local Roact = require(Modules.Common.Roact)
local RoactAnalyticsSharedGameItem = require(Modules.LuaChat.Services.RoactAnalyticsSharedGameItem)
local RoactRodux = require(Modules.Common.RoactRodux)
local RoactServices = require(Modules.LuaApp.RoactServices)
local Text = require(Modules.Common.Text)
local DEFAULT_BACKGROUND_COLOR = Constants.Color.WHITE
local DEFAULT_GAME_ADDITIONAL_INFO_LABEL_HEIGHT = 14
local DEFAULT_GAME_ADDITIONAL_INFO_LABEL_TEXT_SIZE = 14
local DEFAULT_GAME_CREATOR_LABEL_BOTTOM_PADDING = 3
local DEFAULT_GAME_CREATOR_LABEL_COLOR = Constants.Color.GRAY2
local DEFAULT_GAME_CREATOR_LABEL_HEIGHT = 14
local DEFAULT_GAME_CREATOR_LABEL_TEXT_SIZE = 15
local DEFAULT_GAME_CREATOR_LABEL_TOP_PADDING = 6
local DEFAULT_GAME_NAME_LABEL_COLOR = Constants.Color.GRAY1
local DEFAULT_GAME_NAME_LABEL_HEIGHT = 20
local DEFAULT_GAME_NAME_LABEL_TEXT_SIZE = 23
local DEFAULT_GAME_ICON_LEFT_PADDING = 15
local DEFAULT_GAME_ICON_RIGHT_PADDING = 12
local DEFAULT_GAME_ICON_SIZE = Constants.SharedGamesConfig.Thumbnail.SHOWN_SIZE
local DEFAULT_GAME_ICON_TOP_PADDING = 12
local DEFAULT_ITEM_HEIGHT = 84
local DEFAULT_ITEM_PRESSED_BACKGROUND_COLOR = Constants.Color.GRAY5
local DEFAULT_PRICE_COLOR = Constants.Color.GREEN_PRIMARY
local DEFAULT_ROBUX_ICON_SIZE = 12
local DEFAULT_SEND_BUTTON_ICON_SIZE = 24
local DEFAULT_SEND_BUTTON_LEFT_PADDING = 12
local DEFAULT_SEND_BUTTON_RIGHT_PADDING = 25
local DEFAULT_SEPARATOR_LINE_COLOR = Constants.Color.GRAY4
local DEFAULT_TEXT_FONT = Enum.Font.SourceSans
local GAME_LOADING_ICON = "rbxasset://textures/ui/LuaApp/icons/ic-game.png"
local GAME_BORDER_ICON = "rbxasset://textures/ui/LuaChat/graphic/gr-game-border-60x60.png"
local ROBUX_ICON = "rbxasset://textures/ui/LuaChat/icons/ic-robux.png"
local SEND_BUTTON_ICON = "rbxasset://textures/ui/LuaChat/icons/icon-share-game-24x24.png"
local SENT_BUTTON_ICON = "rbxasset://textures/ui/LuaChat/icons/icon-share-game-pressed-24x24.png"
local FFlagLuaChatShareGameToChatFromChatV2 = settings():GetFFlag("LuaChatShareGameToChatFromChatV2")
local SharedGameItem = Roact.PureComponent:extend("SharedGameItem")
SharedGameItem.defaultProps = {
backgroundColor = DEFAULT_BACKGROUND_COLOR,
gameAdditionalLabelHeight = DEFAULT_GAME_ADDITIONAL_INFO_LABEL_HEIGHT,
gameAdditionalLabelTextSize = DEFAULT_GAME_ADDITIONAL_INFO_LABEL_TEXT_SIZE,
gameCreatorLabelBottomPadding = DEFAULT_GAME_CREATOR_LABEL_BOTTOM_PADDING,
gameCreatorLabelColor = DEFAULT_GAME_CREATOR_LABEL_COLOR,
gameCreatorLabelHeight = DEFAULT_GAME_CREATOR_LABEL_HEIGHT,
gameCreatorLabelTopPadding = DEFAULT_GAME_CREATOR_LABEL_TOP_PADDING,
gameCreatorLabelTextSize = DEFAULT_GAME_CREATOR_LABEL_TEXT_SIZE,
gameIconSize = DEFAULT_GAME_ICON_SIZE,
gameIconLeftPadding = DEFAULT_GAME_ICON_LEFT_PADDING,
gameIconRightPadding = DEFAULT_GAME_ICON_RIGHT_PADDING,
gameIconTopPadding = DEFAULT_GAME_ICON_TOP_PADDING,
gameNameLabelColor = DEFAULT_GAME_NAME_LABEL_COLOR,
gameNameLabelHeight = DEFAULT_GAME_NAME_LABEL_HEIGHT,
gameNameLabelTextSize = DEFAULT_GAME_NAME_LABEL_TEXT_SIZE,
itemPressedBackgroundColor = DEFAULT_ITEM_PRESSED_BACKGROUND_COLOR,
itemHeight = DEFAULT_ITEM_HEIGHT,
priceColor = DEFAULT_PRICE_COLOR,
robuxIconSize = DEFAULT_ROBUX_ICON_SIZE,
sendButtonIconSize = DEFAULT_SEND_BUTTON_ICON_SIZE,
sendButtonLeftPadding = DEFAULT_SEND_BUTTON_LEFT_PADDING,
sendButtonRightPadding = DEFAULT_SEND_BUTTON_RIGHT_PADDING,
separatorLineColor = DEFAULT_SEPARATOR_LINE_COLOR,
textFont = DEFAULT_TEXT_FONT
}
function SharedGameItem:init()
self.state = {
gameItemDown = false,
gameItemActivated = false,
sendButtonDown = false,
sendButtonActivated = false,
}
self.creatorName = nil
self.gameNameTextLabel = nil
self.gameCreatorTextLabel = nil
self.onGameButtonActivated = function()
self:openGameDetails()
end
self.onSendButtonInputBegan = function(_, inputObject)
if (inputObject.UserInputType == Enum.UserInputType.Touch or
inputObject.UserInputType == Enum.UserInputType.MouseButton1) and
inputObject.UserInputState == Enum.UserInputState.Begin then
self:onSendButtonDown()
end
end
self.onSendButtonInputEnded = function(_, inputObject)
self:onSendButtonUp()
end
self.onGameItemInputBegan = function(_, inputObject)
if (inputObject.UserInputType == Enum.UserInputType.Touch or
inputObject.UserInputType == Enum.UserInputType.MouseButton1) and
inputObject.UserInputState == Enum.UserInputState.Begin then
self:onGameItemDown()
end
end
self.onGameItemInputEnded = function(_, inputObject)
self:onGameItemUp()
end
end
function SharedGameItem:onSendButtonDown()
if not self.state.sendButtonDown then
self:eventDisconnect()
self.userInputServiceCon = UserInputService.InputEnded:Connect(function()
self:onSendButtonUp()
end)
self:setState({
sendButtonDown = true,
sendButtonActivated = false,
})
end
end
function SharedGameItem:onSendButtonUp(buttonActivated)
if self.state.sendButtonDown or self.state.sendButtonActivated ~= buttonActivated then
self:setState({
sendButtonDown = false,
sendButtonActivated = buttonActivated,
})
end
self:eventDisconnect()
end
function SharedGameItem:onGameItemDown()
if not self.state.gameItemDown then
self:eventDisconnect()
self.userInputServiceCon = UserInputService.InputEnded:Connect(function()
self:onGameItemUp()
end)
self:setState({
gameItemDown = true,
gameItemActivated = false,
})
end
end
function SharedGameItem:onGameItemUp(buttonActivated)
if self.state.gameItemDown or self.state.gameItemActivated ~= buttonActivated then
self:setState({
gameItemDown = false,
gameItemActivated = buttonActivated,
})
end
self:eventDisconnect()
end
function SharedGameItem:openGameDetails()
local notificationType = GuiService:GetNotificationTypeList().VIEW_GAME_DETAILS_ANIMATED
GuiService:BroadcastNotification(string.format("%d", self.props.game.placeId), notificationType)
end
function SharedGameItem:render()
local activeConversationId = self.props.activeConversationId
local analytics = self.props.analytics
local backgroundColor = self.props.backgroundColor
local gameAdditionalLabelHeight = self.props.gameAdditionalLabelHeight
local gameAdditionalLabelTextSize = self.props.gameAdditionalLabelTextSize
local gameCreatorLabelBottomPadding = self.props.gameCreatorLabelBottomPadding
local gameCreatorLabelColor = self.props.gameCreatorLabelColor
local gameCreatorLabelHeight = self.props.gameCreatorLabelHeight
local gameCreatorLabelTextSize = self.props.gameCreatorLabelTextSize
local gameCreatorLabelTopPadding = self.props.gameCreatorLabelTopPadding
local gameIconLeftPadding = self.props.gameIconLeftPadding
local gameIconRightPadding = self.props.gameIconRightPadding
local gameIconSize = self.props.gameIconSize
local gameIconTopPadding = self.props.gameIconTopPadding
local gameIconWidth = gameIconSize + gameIconLeftPadding + gameIconRightPadding
local gameNameLabelHeight = self.props.gameNameLabelHeight
local gameNameLabelColor = self.props.gameNameLabelColor
local gameNameLabelTextSize = self.props.gameNameLabelTextSize
local gameThumbnails = self.props.gameThumbnails
local itemHeight = self.props.itemHeight
local itemPressedBackgroundColor = self.props.itemPressedBackgroundColor
local isSharing = self.props.isSharing
local layoutOrder = self.props.layoutOrder
local placeId = tostring(self.props.game.placeId)
local universeId = self.props.game.universeId
-- TODO wait lua app team store the playability of the game, default value is true
-- Ticket: MOBLUAPP-683, Review http://swarm.roblox.local/reviews/226723/
local playable = FFlagLuaChatShareGameToChatFromChatV2 or self.props.game.isPlayable
local priceColor = self.props.priceColor
local robuxIconSize = self.props.robuxIconSize
local sendButtonIconSize = self.props.sendButtonIconSize
local sendButtonLeftPadding = self.props.sendButtonLeftPadding
local sendButtonRightPadding = self.props.sendButtonRightPadding
local sendButtonWidth = sendButtonIconSize + sendButtonLeftPadding + sendButtonRightPadding
local separatorLineColor = self.props.separatorLineColor
local textFont = self.props.textFont
local gameId = FFlagLuaChatShareGameToChatFromChatV2 and universeId or placeId
local additionalInfoFrameHeight = gameAdditionalLabelHeight + gameCreatorLabelBottomPadding
local showPrice = self.props.game.price ~= nil and playable
local showAdditionalInfo = showPrice or (not playable)
local gameIcon = gameThumbnails[gameId] or GAME_LOADING_ICON
local gameInfoHeight = showAdditionalInfo and
(gameNameLabelHeight + gameCreatorLabelHeight + 2 * gameCreatorLabelBottomPadding + gameAdditionalLabelHeight) or
(gameNameLabelHeight + gameCreatorLabelHeight + gameCreatorLabelBottomPadding)
local gameUrl = FFlagLuaChatShareGameToChatFromChatV2 and getGameUrlByPlaceId(placeId)
or self.props.game.url
return Roact.createElement("Frame", {
BackgroundColor3 = self.state.gameItemDown and itemPressedBackgroundColor or backgroundColor,
BorderSizePixel = 0,
LayoutOrder = FFlagLuaChatShareGameToChatFromChatV2 and layoutOrder or 0,
Size = UDim2.new(1, 0, 0, itemHeight),
},{
Separator = Roact.createElement("Frame", {
AnchorPoint = Vector2.new(0, 1),
BackgroundColor3 = separatorLineColor,
BorderSizePixel = 0,
Position = UDim2.new(0, gameIconWidth, 1, 0),
Size = UDim2.new(1, -gameIconWidth, 0, 1),
}),
Game = Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, itemHeight),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
GameButtonContainer = Roact.createElement("TextButton", {
BackgroundTransparency = 1,
LayoutOrder = 1,
Size = UDim2.new(1, -sendButtonWidth, 1, 0),
Text = "",
[Roact.Event.Activated] = self.onGameButtonActivated,
[Roact.Event.InputBegan] = self.onGameItemInputBegan,
[Roact.Event.InputEnded] = self.onGameItemInputEnded,
},{
Icon = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = gameIcon,
Position = UDim2.new(0, gameIconLeftPadding, 0, gameIconTopPadding),
Size = UDim2.new(0, gameIconSize, 0, gameIconSize),
}, {
RoundCornerOverlay = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = GAME_BORDER_ICON,
Size = UDim2.new(0, gameIconSize, 0, gameIconSize),
}),
}),
GameInfo = Roact.createElement("Frame", {
AnchorPoint = Vector2.new(0, 0.5),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Position = UDim2.new(0, gameIconWidth, 0.5, 0),
Size = UDim2.new(1, -gameIconWidth, 0, gameInfoHeight),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
Name = Roact.createElement("TextLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = textFont,
LayoutOrder = 1,
Size = UDim2.new(1, 0, 0, gameNameLabelHeight),
Text = self.props.game.name,
TextColor3 = gameNameLabelColor,
TextSize = gameNameLabelTextSize,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
[Roact.Ref] = function(rbx)
if rbx then
self.gameNameTextLabel = rbx
end
end,
}),
Creator = Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = textFont,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, gameCreatorLabelHeight + gameCreatorLabelTopPadding),
Text = {"Feature.Chat.ShareGameToChat.By", creatorName = self.props.game.creatorName},
TextColor3 = gameCreatorLabelColor,
TextSize = gameCreatorLabelTextSize,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
[Roact.Ref] = function(rbx)
if rbx then
self.gameCreatorTextLabel = rbx
self.creatorName = rbx.Text
end
end
}),
NotAvailableTip = not playable and Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = textFont,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, additionalInfoFrameHeight),
Text = "Feature.Chat.ShareGameToChat.GameNotAvailable",
TextColor3 = gameCreatorLabelColor,
TextSize = gameAdditionalLabelTextSize,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
}),
GamePrice = showPrice and Roact.createElement("Frame", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = 3,
Size = UDim2.new(1, 0, 0, additionalInfoFrameHeight),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 3),
}),
RobuxIcon = Roact.createElement("ImageLabel", {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = ROBUX_ICON,
LayoutOrder = 1,
ScaleType = Enum.ScaleType.Fit,
Size = UDim2.new(0, robuxIconSize, 0, robuxIconSize),
}),
Price = Roact.createElement("TextLabel",{
BackgroundTransparency = 1,
Size = UDim2.new(1, -15, 1, 0),
Font = DEFAULT_TEXT_FONT,
LayoutOrder = 2,
Text = formatInteger(self.props.game.price),
TextColor3 = priceColor,
TextSize = gameAdditionalLabelTextSize,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Bottom,
}, {
padding = Roact.createElement("UIPadding", {
PaddingLeft = UDim.new(3, 0),
}),
}),
}),
})
}),
SendButtonContainer = Roact.createElement("TextButton", {
Active = not isSharing,
BackgroundTransparency = 1,
ClipsDescendants = true,
LayoutOrder = 2,
Position = UDim2.new(1, -sendButtonWidth, 0, 0),
Size = UDim2.new(0, sendButtonWidth, 1, 0),
Text = "",
[Roact.Event.InputBegan] = self.onSendButtonInputBegan,
[Roact.Event.InputEnded] = self.onSendButtonInputEnded,
[Roact.Event.Activated] = function()
if not isSharing then
self.props.shareGameToChat(activeConversationId, analytics, placeId, gameUrl)
end
end,
},{
SendButton = Roact.createElement("ImageLabel", {
AnchorPoint = Vector2.new(0, 0.5),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = false,
Image = self.state.sendButtonDown and SENT_BUTTON_ICON or SEND_BUTTON_ICON,
Position = UDim2.new(0, sendButtonLeftPadding, 0.5, 0),
Size = UDim2.new(0, sendButtonIconSize, 0, sendButtonIconSize),
}),
}),
})
})
end
function SharedGameItem:didMount()
local function resizeGameName()
self.gameNameTextLabel.Text = Text.Truncate(self.props.game.name, self.props.textFont,
self.gameNameTextLabel.TextSize, self.gameNameTextLabel.AbsoluteSize.X, "...")
end
local function resizeGameCreator()
self.gameCreatorTextLabel.Text = Text.Truncate(self.creatorName, self.props.textFont,
self.gameCreatorTextLabel.TextSize, self.gameCreatorTextLabel.AbsoluteSize.X, "...")
end
resizeGameName()
resizeGameCreator()
self.connections = {}
table.insert(self.connections, self.gameNameTextLabel:GetPropertyChangedSignal("Text"):Connect(resizeGameName))
table.insert(self.connections, self.gameNameTextLabel:GetPropertyChangedSignal("AbsoluteSize"):Connect(resizeGameName))
table.insert(self.connections, self.gameCreatorTextLabel:GetPropertyChangedSignal("Text"):Connect(resizeGameCreator))
table.insert(
self.connections,
self.gameCreatorTextLabel:GetPropertyChangedSignal("AbsoluteSize"):Connect(resizeGameCreator)
)
end
function SharedGameItem:willUnmount()
for _, connection in pairs(self.connections) do
connection:Disconnect()
end
self:eventDisconnect()
end
function SharedGameItem:eventDisconnect()
if self.userInputServiceCon then
self.userInputServiceCon:Disconnect()
self.userInputServiceCon = nil
end
end
SharedGameItem = RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
activeConversationId = state.ChatAppReducer.ActiveConversationId,
gameThumbnails = state.GameThumbnails,
isSharing = state.ChatAppReducer.ShareGameToChatAsync.sharingGame,
}
end,
function(dispatch)
return {
shareGameToChat = function(activeConversationId, analytics, placeId, url)
analytics.reportShareGameToChatFromChat(activeConversationId, tostring(placeId))
return dispatch(ConversationActions.ShareGame(activeConversationId, url))
end,
}
end
)(SharedGameItem)
SharedGameItem = RoactServices.connect({
analytics = RoactAnalyticsSharedGameItem,
})(SharedGameItem)
return SharedGameItem
@@ -0,0 +1,29 @@
return function()
local SharedGameItem = require(script.Parent.SharedGameItem)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Game = require(Modules.LuaApp.Models.Game)
local Roact = require(Modules.Common.Roact)
local mockServices = require(Modules.LuaApp.TestHelpers.mockServices)
it("should create and destroy without errors", function()
local game = Game.mock()
game.url = "http://www.roblox.com/game/10395446"
game.isPlayable = true
game.price = 0
local element = mockServices({
SharedGameItem = Roact.createElement(SharedGameItem, {
itemHeight = 84,
game = game,
layoutOrder = 1,
}),
}, {
includeStoreProvider = true,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,161 @@
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local LocalizedTextLabel = require(LuaApp.Components.LocalizedTextLabel)
local LoadingIndicator = require(LuaApp.Components.LoadingIndicator)
local Roact = require(Modules.Common.Roact)
local RoactRodux = require(Modules.Common.RoactRodux)
local SharedGameItem = require(LuaChat.Components.SharedGameItem)
local ShareGameToChatThunks = require(LuaChat.Actions.ShareGameToChatFromChat.ShareGameToChatFromChatThunks)
local DEFAULT_BACKGROUND_COLOR = Constants.Color.GRAY6
local DEFAULT_ITEM_HEIGHT = 84
local DEFAULT_TIPS_LABEL_COLOR = Constants.Color.GRAY2
local DEFAULT_TIPS_LABEL_FONT = Enum.Font.SourceSans
local DEFAULT_TIPS_LABEL_HEIGHT = 25
local DEFAULT_TIPS_LABEL_TEXT_SIZE = 20
local DEFAULT_TIPS_LABEL_TOP_MARGIN = 30
local SCROLLING_FRAME_IMAGE = "rbxasset://textures/ui/LuaChat/9-slice/scroll-bar.png"
local SharedGameList = Roact.PureComponent:extend("SharedGameList")
SharedGameList.defaultProps = {
backgroundColor = DEFAULT_BACKGROUND_COLOR,
itemHeight = DEFAULT_ITEM_HEIGHT,
tipsLabelColor = DEFAULT_TIPS_LABEL_COLOR,
tipsLabelFont = DEFAULT_TIPS_LABEL_FONT,
tipsLabelHeight = DEFAULT_TIPS_LABEL_HEIGHT,
tipsLabelTextSize = DEFAULT_TIPS_LABEL_TEXT_SIZE,
tipsLabelTopMargin = DEFAULT_TIPS_LABEL_TOP_MARGIN,
}
function SharedGameList:init()
self.gamesList = nil
self.isLoading = false
self.onScrollingFrameRef = function(rbx)
if rbx then
self.gamesList = rbx
else
warn("can not capture scrolling frame")
end
end
self:ShouldFetchGames(self.props)
end
function SharedGameList:render()
local backgroundColor = self.props.backgroundColor
local frameHeight = self.props.frameHeight
local gamesDetailInfos = self.props.gamesInfo
local gameSorts = self.props.gameSorts
local itemHeight = self.props.itemHeight
local sortName = self.props.gameSort
local tipsLabelColor = self.props.tipsLabelColor
local tipsLabelHeight = self.props.tipsLabelHeight
local tipsLabelFont = self.props.tipsLabelFont
local tipsLabelTextSize = self.props.tipsLabelTextSize
local tipsLabelTopMargin = self.props.tipsLabelTopMargin
local itemsCount = 0
local gamesListItems = {}
if not self.isLoading then
gamesListItems["Layout"] = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
VerticalAlignment = Enum.VerticalAlignment.Center,
})
local games = gameSorts[sortName]
itemsCount = #games.placeIds
for index, placeId in ipairs(games.placeIds) do
gamesListItems[index] = Roact.createElement(SharedGameItem, {
itemHeight = itemHeight,
game = gamesDetailInfos[placeId],
layoutOrder = index,
})
end
end
local contentHeight = itemsCount * itemHeight
return Roact.createElement("Frame", {
BackgroundColor3 = backgroundColor,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
}, {
GamesList = itemsCount ~= 0 and Roact.createElement("ScrollingFrame", {
BackgroundTransparency = 1,
BottomImage = SCROLLING_FRAME_IMAGE,
BorderSizePixel = 0,
CanvasSize = UDim2.new(1, 0, 0, contentHeight),
MidImage = SCROLLING_FRAME_IMAGE,
Size = contentHeight > frameHeight and UDim2.new(1, 0, 1, 0) or UDim2.new(1, 0, 0, contentHeight),
ScrollBarThickness = 5,
ScrollingDirection = Enum.ScrollingDirection.Y,
TopImage = SCROLLING_FRAME_IMAGE,
[Roact.Ref] = self.onScrollingFrameRef,
}, gamesListItems),
Indicator = self.isLoading and Roact.createElement(LoadingIndicator, {
AnchorPoint = Vector2.new(0.5, 0),
Position = UDim2.new(0.5, 0, 0, tipsLabelTopMargin),
}),
NoGamesTip = ((not self.isLoading) and itemsCount == 0) and Roact.createElement(LocalizedTextLabel, {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = tipsLabelFont,
Position = UDim2.new(0, 0, 0, tipsLabelTopMargin),
Size = UDim2.new(1, 0, 0, tipsLabelHeight),
Text = Constants.SharedGamesConfig.SortsAttribute[sortName].ERROR_TIP_LOCALIZATION_KEY,
TextColor3 = tipsLabelColor,
TextSize = tipsLabelTextSize,
}),
})
end
function SharedGameList:didUpdate()
if self.gamesList then
self.gamesList.CanvasPosition = Vector2.new(0, 0)
end
end
function SharedGameList:willUpdate(newProps)
self:ShouldFetchGames(newProps)
end
function SharedGameList:ShouldFetchGames(props)
local gameSorts = props.gameSorts
local sortName = props.gameSort
local games = gameSorts[sortName]
self.isLoading = false
if games == nil or games.placeIds == nil then
if not ShareGameToChatThunks.HasGameFetchRequestCompleted(sortName, props.shareGameToChatAsync) then
self.isLoading = true
self.props.fetchGames(sortName, Constants.SharedGamesConfig.Thumbnail.FETCHED_SIZE)
end
end
end
return RoactRodux.UNSTABLE_connect2(
function(state, props)
return {
gamesInfo = state.ChatAppReducer.SharedGamesInfo,
gameSorts = state.ChatAppReducer.SharedGameSorts,
shareGameToChatAsync = state.ChatAppReducer.ShareGameToChatAsync,
}
end,
function(dispatch)
return {
fetchGames = function(gameSortName, fetchedThumbnailSize)
return dispatch(ShareGameToChatThunks.FetchGames(gameSortName, fetchedThumbnailSize))
end,
}
end
)(SharedGameList)
@@ -0,0 +1,22 @@
return function()
local SharedGameList = require(script.Parent.SharedGameList)
local Modules = game:GetService("CoreGui").RobloxGui.Modules
local Roact = require(Modules.Common.Roact)
local mockServices = require(Modules.LuaApp.TestHelpers.mockServices)
it("should create and destroy without errors", function()
local element = mockServices({
SharedGameList = Roact.createElement(SharedGameList, {
frameHeight = 100,
gameSort = "Popular",
}),
}, {
includeStoreProvider = true,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,74 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local FONT = Constants.Font.TITLE
local TEXT_SIZE = Constants.Font.FONT_SIZE_18
local X_PADDING = 8
local TextButton = {}
TextButton.__index = TextButton
function TextButton.new(appState, name, textKey)
local self = {}
self.enabled = true
local text = appState.localization:Format(textKey)
local textWidth = Text.GetTextWidth(text, FONT, TEXT_SIZE)
self.rbx = Create.new "TextButton" {
Name = name,
BackgroundTransparency = 1,
Text = "",
Size = UDim2.new(0, textWidth + X_PADDING, 1, 0),
Create.new "TextLabel" {
Name = "Label",
Size = UDim2.new(0, textWidth, 0, TEXT_SIZE),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
BackgroundTransparency = 1,
Text = text,
Font = FONT,
TextSize = TEXT_SIZE,
TextColor3 = Constants.Color.WHITE,
},
}
self.Pressed = Signal.new()
getInputEvent(self.rbx):Connect(function()
if not self.enabled then
return
end
self.Pressed:fire()
end)
setmetatable(self, TextButton)
return self
end
function TextButton:SetEnabled(value)
if value then
self.rbx.Label.TextTransparency = 0
else
self.rbx.Label.TextTransparency = 0.7
end
self.enabled = value
end
return TextButton
@@ -0,0 +1,178 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local ListEntry = require(LuaChat.Components.ListEntry)
local FFlagTextBoxOverrideManualFocusRelease = settings():GetFFlag("TextBoxOverrideManualFocusRelease")
local ICON_CELL_WIDTH = 60
local CLEAR_TEXT_WIDTH = 48
local HEIGHT = 48
local TextInputEntry = {}
function TextInputEntry.new(appState, icon, placeholder)
local self = {}
self.connections = {}
setmetatable(self, {__index = TextInputEntry})
local size = 24
local iconWidth = 0
local listEntry = ListEntry.new(appState, HEIGHT)
self.listEntry = listEntry
self.rbx = listEntry.rbx
self.placeholder = placeholder
if icon then
iconWidth = ICON_CELL_WIDTH
local iconImageLabel = Create.new"Frame" {
Name = "Icon",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, iconWidth, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
Create.new"ImageLabel" {
Name = "IconImage",
BackgroundTransparency = 1,
Size = UDim2.new(0, size, 0, size),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Image = icon,
BorderSizePixel = 0,
},
}
iconImageLabel.Parent = self.rbx
end
local textBox = Create.new"TextBox" {
Name = "TextBox",
BackgroundTransparency = 1,
Size = UDim2.new(1, -iconWidth - CLEAR_TEXT_WIDTH, 1, 0),
Position = UDim2.new(0, iconWidth, 0, 0),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = "",
PlaceholderText = placeholder or "",
PlaceholderColor3 = Constants.Color.GRAY3,
TextXAlignment = Enum.TextXAlignment.Left,
OverlayNativeInput = true,
ClearTextOnFocus = false,
ClipsDescendants = true,
}
if FFlagTextBoxOverrideManualFocusRelease then
textBox.ManualFocusRelease = true
end
textBox.Parent = self.rbx
local clearButton = Create.new"ImageButton" {
Name = "Clear",
BackgroundTransparency = 1,
ImageTransparency = 1,
Size = UDim2.new(0, CLEAR_TEXT_WIDTH, 0, CLEAR_TEXT_WIDTH),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(1, -CLEAR_TEXT_WIDTH/2, 0.5, 0),
AutoButtonColor = false,
Create.new"ImageLabel" {
Name = "ClearImage",
BackgroundTransparency = 1,
ImageTransparency = 0,
Size = UDim2.new(0, 16, 0, 16),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
Image = "rbxasset://textures/ui/LuaChat/icons/ic-clear-solid.png",
},
}
clearButton.Parent = self.rbx
local clearButtonConnection = getInputEvent(clearButton):Connect(function()
self.textBoxComponent.Text = ""
end)
table.insert(self.connections, clearButtonConnection)
local divider = Create.new"Frame"{
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, -1),
}
divider.Parent = self.rbx
self.textBoxComponent = textBox
self.value = textBox.Text
self.textBoxChanged = Signal.new()
self.textBoxFocusLost = Signal.new()
local function updateClearButtonVisibility()
local visible = (self.textBoxComponent.Text ~= "")
clearButton.Visible = visible
end
updateClearButtonVisibility()
local textChangedConnection = textBox:GetPropertyChangedSignal("Text"):Connect(function()
self.value = self.textBoxComponent.Text
self.textBoxChanged:fire(self.value)
updateClearButtonVisibility()
end)
table.insert(self.connections, textChangedConnection)
local focusedConnection = textBox.Focused:Connect(updateClearButtonVisibility)
table.insert(self.connections, focusedConnection)
local focusLostConnection = textBox.FocusLost:Connect(function()
self.textBoxFocusLost:fire()
updateClearButtonVisibility()
end)
table.insert(self.connections, focusLostConnection)
return self
end
function TextInputEntry:SanitizeInput(sanitizeFunc)
self.value = sanitizeFunc(self.value)
self.textBoxComponent.Text = self.value
return self.value
end
function TextInputEntry:ReleaseFocus()
self.textBoxComponent:ReleaseFocus()
end
function TextInputEntry:ShowDivider(show)
self.rbx.Divider.Visible = show
end
function TextInputEntry:Update(value)
if value ~= self.value then
self.rbx.TextBox.Text = value
if self.placeholder == nil and value ~= "" then
self.placeholder = value
self.rbx.TextBox.PlaceholderText = value
end
end
end
function TextInputEntry:Destruct()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
self.listEntry:Destruct()
self.rbx:Destroy()
end
return TextInputEntry
@@ -0,0 +1,202 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local FormFactor = require(LuaApp.Enum.FormFactor)
local Text = require(Modules.Common.Text)
local ToastComplete = require(LuaChat.Actions.ToastComplete)
local INITIAL_SIZE = UDim2.new(1, -96, 0, 56)
local POSITION_HIDE = UDim2.new(0.5, 0, 1, 72)
local POSITION_SHOW = UDim2.new(0.5, 0, 1, -56-48)
local TEXT_SIZE = Constants.Font.FONT_SIZE_16
local TEXT_FONT = Enum.Font.SourceSans
local ANIMATION_DURATION = 2
local NORMAL_PHONE_MINIMUM_WIDTH = 360
local PADDING = 12
local PHONE_MARGIN = 48
local SMALL_PHONE_MARGIN = 24
local SINGLE_LINE_HEIGHT = 22
local TABLET_MAXIMUM_WIDTH = 400
local TOAST_BACKGROUND = "rbxasset://textures/ui/LuaChat/9-slice/error-toast.png"
local FFlagLuaChatToastRefactor = settings():GetFFlag("LuaChatToastRefactor")
local ToastComponent = {}
ToastComponent.__index = ToastComponent
function ToastComponent.new(appState, route)
local self = {}
self.appState = appState
self.route = route
self.positionHide = POSITION_HIDE
setmetatable(self, ToastComponent)
if FFlagLuaChatToastRefactor then
self.rbx = Create.new"ImageLabel" {
Name = "ToastComponent",
AnchorPoint = Vector2.new(0.5, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Image = TOAST_BACKGROUND,
Position = self.positionHide,
Size = INITIAL_SIZE,
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(5, 5, 6, 6),
Visible = false,
Create.new"TextLabel" {
Name = "Message",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = TEXT_FONT,
Position = UDim2.new(0, PADDING, 0, PADDING),
Size = UDim2.new(1, -2 * PADDING, 1, -2 * PADDING),
Text = "",
TextColor3 = Constants.Color.WHITE,
TextSize = TEXT_SIZE,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
}
}
else
self.rbx = Create.new"Frame" {
Name = "ToastComponent",
Size = INITIAL_SIZE,
Position = POSITION_HIDE,
AnchorPoint = Vector2.new(0.5, 0),
BackgroundTransparency = 0.1,
BackgroundColor3 = Constants.Color.GRAY1,
BorderSizePixel = 0,
Visible = true,
Create.new"TextLabel" {
Name = "Message",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Font = Enum.Font.SourceSans,
TextSize = TEXT_SIZE,
TextColor3 = Constants.Color.WHITE,
Text = "",
Size = UDim2.new(1, 0, 1, 0),
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
}
}
end
self.appState.store.changed:connect(function(current, previous)
if current ~= previous then
self:Update(current.ChatAppReducer.Toast)
end
end)
return self
end
function ToastComponent:Update(toast)
if toast == nil then
return
end
-- We don't want to show the toast if another one with the same id is being shown.
if self.toast and (self.toast.id == toast.id) then
return
end
self.toast = toast
self:Show(toast)
end
function ToastComponent:Hide()
self.rbx:TweenPosition(
self.positionHide,
Enum.EasingDirection.In,
Enum.EasingStyle.Quad,
Constants.Tween.DEFAULT_TWEEN_TIME,
false,
function(status)
self.appState.store:dispatch(ToastComplete(self.toast))
self.toast = nil
end
)
end
function ToastComponent:Show(toast)
local message = toast.messageKey ~= nil and
self.appState.localization:Format(toast.messageKey, toast.messageArguments) or ""
self.rbx.Message.Text = message
local positionShown
if FFlagLuaChatToastRefactor then
local isTablet = self.appState.store:getState().FormFactor == FormFactor.TABLET
local screenGui = self.rbx:FindFirstAncestorOfClass("ScreenGui")
local screenWidth
if isTablet then
screenWidth = screenGui.AbsoluteSize.Y
else
screenWidth = screenGui.AbsoluteSize.X
end
local margin, maxWidth, width, height
if screenWidth < NORMAL_PHONE_MINIMUM_WIDTH then
margin = SMALL_PHONE_MARGIN
else
margin = PHONE_MARGIN
end
if isTablet then
maxWidth = TABLET_MAXIMUM_WIDTH
else
maxWidth = screenWidth - 2 * margin - PADDING * 2
end
local textHeight = Text.GetTextHeight(message, TEXT_FONT, TEXT_SIZE, maxWidth)
if textHeight > SINGLE_LINE_HEIGHT then
width = isTablet and TABLET_MAXIMUM_WIDTH or (screenWidth - margin * 2)
else
width = self.rbx.Message.TextBounds.X + PADDING * 2
end
height = textHeight + 2 * PADDING
positionShown = UDim2.new(0.5, 0, 1, -height - margin)
self.positionHide = UDim2.new(0.5, 0, 1, height + margin)
self.rbx.Size = UDim2.new(0, width, 0, height)
self.rbx.Position = self.positionHide
self.rbx.Visible = true
else
local textWidth = self.rbx.Message.TextBounds.X
self.rbx.Size = UDim2.new(0, textWidth + PADDING * 2, 0, 56)
self.rbx.Position = POSITION_HIDE
positionShown = POSITION_SHOW
end
self.rbx:TweenPosition(
positionShown,
Enum.EasingDirection.Out,
Enum.EasingStyle.Quad,
Constants.Tween.DEFAULT_TWEEN_TIME,
false,
function(status)
wait(ANIMATION_DURATION)
if self.toast.id == toast.id then
self:Hide()
end
end
)
end
return ToastComponent
@@ -0,0 +1,105 @@
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local INDICATOR_WIDTH = 60
local INDICATOR_HEIGHT = 16
local DOT_COUNT = 3
local ANIMATION_SPEED_MULTIPLIER = 1.75
local TypingIndicator = {}
local function makeDot()
return Create.new "ImageLabel" {
Name = "DotContainer",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
SizeConstraint = Enum.SizeConstraint.RelativeYY,
Create.new "ImageLabel" {
Name = "Dot",
BackgroundTransparency = 1,
Image = "rbxasset://textures/ui/LuaChat/graphic/send-white.png",
ImageColor3 = Color3.new(0.5, 0.5, 0.5),
Size = UDim2.new(1, 0, 1, 0),
SizeConstraint = Enum.SizeConstraint.RelativeYY,
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5)
},
}
end
function TypingIndicator.new(appState, scale)
scale = scale or 1
local self = {}
self.connections = {}
self.rbx = Create.new "Frame" {
Name = "TypingIndicator",
BackgroundTransparency = 1,
Size = UDim2.new(0, scale * INDICATOR_WIDTH, 0, scale * INDICATOR_HEIGHT)
}
self.dots = {}
for i = 1, DOT_COUNT do
local value = (i - 1) / (DOT_COUNT - 1)
local dot = makeDot()
dot.Position = UDim2.new(value, 0, 0, 0)
dot.AnchorPoint = Vector2.new(value, 0)
dot.Parent = self.rbx
table.insert(self.dots, dot)
end
setmetatable(self, TypingIndicator)
do
local connection = self.rbx.AncestryChanged:Connect(function(object, parent)
if object == self.rbx and parent == nil then
self:Destroy()
end
end)
table.insert(self.connections, connection)
end
do
local connection = RunService.RenderStepped:Connect(function()
if not self.rbx.Visible then
return
end
local time = (Workspace.DistributedGameTime * ANIMATION_SPEED_MULTIPLIER) % #self.dots
for i, dot in ipairs(self.dots) do
local size = 0.5
if time >= i - 1 and time <= i then
size = 0.5 + 0.5 * math.sin(math.pi * (time % 1))
end
dot.Dot.Size = UDim2.new(size, 0, size, 0)
end
end)
table.insert(self.connections, connection)
end
return self
end
function TypingIndicator:Destroy()
self.rbx:Destroy()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
end
TypingIndicator.__index = TypingIndicator
return TypingIndicator
@@ -0,0 +1,303 @@
local Players = game:GetService("Players")
local CoreGui = game:GetService("CoreGui")
local LuaApp = CoreGui.RobloxGui.Modules.LuaApp
local LuaChat = script.Parent.Parent
local Create = require(LuaChat.Create)
local Constants = require(LuaChat.Constants)
local Text = require(LuaChat.Text)
local FormFactor = require(LuaApp.Enum.FormFactor)
local FFlagEnableChatMessageType = settings():GetFFlag("EnableChatMessageType")
local Message = FFlagEnableChatMessageType and require(LuaChat.Models.Message)
local BUBBLE_PADDING = 10
local TEXT_BUBBLE_X_PADDING = 54
local TEXT_BUBBLE_X_TABLET_ADDITIONAL_PADDING = 112
local FFlagLuaChatInfiniteRelayoutRecursionFix = settings():GetFFlag("LuaChatInfiniteRelayoutRecursionFix")
local function isOutgoingMessage(message)
local localUserId = tostring(Players.LocalPlayer.UserId)
return message.senderTargetId == localUserId
end
local function isMessageSending(conversation, message)
if conversation and conversation.sendingMessages then
return conversation.sendingMessages:Get(message.id) ~= nil
end
return false
end
local UserChatBubble = {}
UserChatBubble.__index = UserChatBubble
function UserChatBubble.new(appState, message, newContent, width)
local self = {}
setmetatable(self, UserChatBubble)
local conversationId = message.conversationId
local isSending = isMessageSending(appState.store:getState().ChatAppReducer.Conversations[conversationId], message)
local state = appState.store:getState()
local user = state.Users[message.senderTargetId]
local username = user and user.name or "unknown user"
self.appState = appState
self.paddingObject = nil
self.message = message
self.bubbleType = "UserChatBubble"
self.connections = {}
if FFlagLuaChatInfiniteRelayoutRecursionFix then
self.width = width
end
if FFlagEnableChatMessageType then
if newContent then
self.displayMessage = newContent
elseif self.message.messageType == Message.MessageTypes.PlainText then
self.displayMessage = self.message.content
else
self.displayMessage = ""
end
else
self.displayMessage = newContent and newContent or self.message.content
end
self.textContent = Create.new "TextLabel" {
Name = "TextContent",
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
TextColor3 = Constants.Color.WHITE,
TextSize = Constants.Font.FONT_SIZE_18,
Text = "",
Font = Enum.Font.SourceSans,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left,
}
self.tail = Create.new "ImageLabel" {
Name = "Tail",
Size = UDim2.new(0, 6, 0, 6),
BackgroundTransparency = 1,
}
self.bubble = Create.new "ImageLabel" {
Name = "Bubble",
BackgroundTransparency = 1,
AnchorPoint = Vector2.new(1, 0),
Position = UDim2.new(0, 0, 0, 0),
Size = UDim2.new(1, 0, 1, 0),
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(10, 10, 11, 11),
LayoutOrder = 2,
Create.new "Frame" {
Name = "Content",
BackgroundTransparency = 1,
Size = UDim2.new(1, -BUBBLE_PADDING * 2, 1, -BUBBLE_PADDING * 2),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
self.textContent,
},
self.tail,
}
self.usernameLabel = Create.new "TextLabel" {
Name = "UsernameLabel",
Font = Enum.Font.SourceSans,
TextSize = Constants.Font.FONT_SIZE_12,
Visible = false,
BackgroundTransparency = 1,
Size = UDim2.new(1, -56, 0, 16),
Position = UDim2.new(0, 56, 0, 0),
TextColor3 = Constants.Color.GRAY2,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
Text = username,
}
self.bubbleContainer = Create.new "Frame" {
Name = "BubbleContainer",
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 1),
self.bubble,
self.usernameLabel,
}
self.rbx = Create.new "Frame" {
Name = "Message",
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 0, 0),
Create.new "UIListLayout" {
SortOrder = Enum.SortOrder.LayoutOrder,
},
self.bubbleContainer,
}
-- 'isOutgoing' means "is sent by the local user". This function separates the tail position & color
if isOutgoingMessage(message) then
local bubbleColor = isSending and Constants.Color.BLUE_DISABLED or Constants.Color.BLUE_PRIMARY
local textTransparency = isSending and Constants.Color.ALPHA_SHADOW_PRIMARY or 0
self.tail.AnchorPoint = Vector2.new(0, 0)
self.tail.Position = UDim2.new(1, 0, 0, 0)
self.tail.ImageColor3 = bubbleColor
self.bubble.ImageColor3 = bubbleColor
self.bubble.AnchorPoint = Vector2.new(1, 0)
self.bubble.Position = UDim2.new(1, -10, 0, 0)
self.textContent.TextColor3 = Constants.Color.WHITE
self.textContent.TextTransparency = textTransparency
self.rbx.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
else
self.tail.AnchorPoint = Vector2.new(1, 0)
self.tail.Position = UDim2.new(0, 0, 0, 0)
self.tail.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.ImageColor3 = Color3.new(1, 1, 1)
self.bubble.AnchorPoint = Vector2.new(0, 0)
self.bubble.Position = UDim2.new(0, TEXT_BUBBLE_X_PADDING, 0, 0)
self.textContent.TextColor3 = Constants.Color.GRAY1
self.rbx.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
end
self:Update(message)
if not FFlagLuaChatInfiniteRelayoutRecursionFix then
local connection = self.rbx:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
self:Resize()
end)
table.insert(self.connections, connection)
end
return self
end
function UserChatBubble:Resize()
local padding = (TEXT_BUBBLE_X_PADDING + BUBBLE_PADDING) * 2 -- * 2 for both sides
if self.appState.store:getState().FormFactor == FormFactor.TABLET then
padding = padding + TEXT_BUBBLE_X_TABLET_ADDITIONAL_PADDING
end
local viewportWidth
if FFlagLuaChatInfiniteRelayoutRecursionFix then
viewportWidth = self.width
else
viewportWidth = self.rbx.AbsoluteSize.X
end
local textWidth = viewportWidth - padding
local textBounds = Text.GetTextBounds(
self.textContent.Text,
self.textContent.Font,
self.textContent.TextSize,
Vector2.new(textWidth, 10000)
)
local doublePadding = BUBBLE_PADDING * 2
self.bubble.Size = UDim2.new(0, textBounds.X + doublePadding, 0, textBounds.Y + doublePadding)
local containerHeight = self.bubble.AbsoluteSize.Y
if self.usernameLabel.Visible then
containerHeight = containerHeight + self.usernameLabel.AbsoluteSize.Y
end
self.bubbleContainer.Size = UDim2.new(1, 0, 0, containerHeight)
local height = 0
for _, child in ipairs(self.rbx:GetChildren()) do
if child:IsA("GuiObject") then
height = height + child.AbsoluteSize.Y
end
end
self.rbx.Size = UDim2.new(1, 0, 0, height)
end
local function CreateFilteringText(appState, filteringTextKey, textColor)
return Create.new "Frame" {
Name = "ModeratedNotice",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, 18),
LayoutOrder = 3,
Create.new "TextLabel" {
Name = "ModeratedNoticeText",
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(1, -10, 0, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
TextColor3 = textColor,
TextSize = Constants.Font.FONT_SIZE_14,
Text = appState.localization:Format(filteringTextKey),
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Right,
TextYAlignment = Enum.TextYAlignment.Top,
}
}
end
function UserChatBubble:Update(message)
self.message = message
if message.moderated then
self.displayMessage = string.gsub(self.displayMessage, "[^%s]", "#")
end
self.textContent.Text = self.displayMessage
local textColor
local filteringTextKey = nil
if message.moderated then
filteringTextKey = "Feature.Chat.Message.MessageContentModerated"
textColor = Constants.Color.RED_NEGATIVE
elseif message.filteredForReceivers then
filteringTextKey = "Feature.Chat.Message.Default"
textColor = Constants.Color.GRAY3
end
-- Either the message was moderated or it was filtered for some users (minors).
if filteringTextKey and (not self.moderatedText) then
self.moderatedText = CreateFilteringText(self.appState, filteringTextKey, textColor)
self.moderatedText.Parent = self.rbx
end
-- There was an error and the message was not sent.
if (message.failed or message.moderated) and (not self.alertIcon) then
self.alertIcon = Create.new "ImageLabel" {
Name = "FailedIcon",
Size = UDim2.new(0, 18, 0, 18),
AnchorPoint = Vector2.new(1, 0.5),
Position = UDim2.new(0, -10, 0.5, 0),
ImageColor3 = Constants.Color.RED_PRIMARY,
BackgroundTransparency = 1,
Image = "rbxasset://textures/ui/LuaChat/icons/ic-alert.png"
}
self.alertIcon.Parent = self.bubble
end
self:Resize()
end
function UserChatBubble:Destruct()
for _, connection in ipairs(self.connections) do
connection:Disconnect()
end
self.connections = {}
self.rbx:Destroy()
end
return UserChatBubble
@@ -0,0 +1,168 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaApp = Modules.LuaApp
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local User = require(LuaApp.Models.User)
local Components = LuaChat.Components
local ListEntry = require(Components.ListEntry)
local UserThumbnail = require(Components.UserThumbnail)
local ICON_CELL_WIDTH = 60
local HEIGHT = 56
local function userPresenceToText(localization, presence, lastLocation)
if presence == User.PresenceType.OFFLINE then
return localization:Format("Common.Presence.Label.Offline")
elseif presence == User.PresenceType.ONLINE then
return localization:Format("Common.Presence.Label.Online")
elseif presence == User.PresenceType.IN_GAME then
if lastLocation ~= nil then
return lastLocation
else
return localization:Format("Common.Presence.Label.Online")
end
elseif presence == User.PresenceType.IN_STUDIO then
if lastLocation ~= nil then
return lastLocation
else
return localization:Format("Common.Presence.Label.Online")
end
end
end
local UserEntry = {}
UserEntry.__index = UserEntry
function UserEntry.new(appState, user, icon, selected)
local self = {
user = user,
appState = appState,
}
setmetatable(self, UserEntry)
self.icon = icon or "rbxasset://textures/ui/LuaChat/graphic/ic-checkbox.png"
local listEntry = ListEntry.new(appState, HEIGHT)
self.rbx = listEntry.rbx
local userThumbnail = UserThumbnail.new(appState, user.id, true)
userThumbnail.rbx.Size = UDim2.new(0, 36, 0, 36)
userThumbnail.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
userThumbnail.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.userThumbnail = userThumbnail
local userThumbnailFrame = Create.new"Frame" {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, ICON_CELL_WIDTH, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
userThumbnail.rbx,
}
userThumbnailFrame.Parent = self.rbx
local label = Create.new"TextLabel" {
Name = "Label",
BackgroundTransparency = 1,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0.75, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0.5, 1 - Constants.Font.FONT_SIZE_18),
TextSize = Constants.Font.FONT_SIZE_18,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = user.name,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
}
label.Parent = self.rbx
local sublabel = Create.new"TextLabel" {
Name = "SubLabel",
BackgroundTransparency = 1,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0.35, 0),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0.5, 1),
TextSize = Constants.Font.FONT_SIZE_14,
TextColor3 = Constants.Color.GRAY2,
Font = Enum.Font.SourceSans,
Text = userPresenceToText(appState.localization, user.presence, user.lastLocation),
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
}
sublabel.Parent = self.rbx
local value = Create.new"ImageLabel" {
Name = "Icon",
BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(1, -32, .5, 0),
Image = self.icon,
AnchorPoint = Vector2.new(0.5, 0.5),
Visible = user.id ~= tostring(Players.LocalPlayer.UserId),
}
value.Parent = self.rbx
local divider = Create.new"Frame" {
Name = "Divider",
BackgroundColor3 = Constants.Color.GRAY4,
BorderSizePixel = 0,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0, 1),
Position = UDim2.new(0, ICON_CELL_WIDTH, 1, -1),
}
divider.Parent = self.rbx
self:SetIsSelected(selected)
self.tapped = Signal.new()
listEntry.tapped:connect(function()
self.tapped:fire()
end)
listEntry.beginHover:connect(function()
userThumbnail.rbx.Overlay.ImageColor3 = Constants.Color.GRAY5
end)
listEntry.endHover:connect(function()
userThumbnail.rbx.Overlay.ImageColor3 = Constants.Color.WHITE
end)
return self
end
function UserEntry:SetIsSelected(selected)
if selected then
self.rbx.Icon.Image = "rbxasset://textures/ui/LuaChat/graphic/ic-checkbox-on.png"
else
self.rbx.Icon.Image = self.icon
end
end
function UserEntry:Update(user, selected)
self.user = user
self.rbx.Label.Text = user.name
self.rbx.SubLabel.Text = userPresenceToText(self.appState.localization, user.presence, user.lastLocation)
self:SetIsSelected(selected)
if user.id == tostring(Players.LocalPlayer.UserId) then
self.rbx.Icon.Visible = false
else
self.rbx.Icon.Visible = true
end
self.userThumbnail:Update(user)
end
function UserEntry:Destruct()
self.userThumbnail:Destruct()
self.rbx:Destroy()
end
return UserEntry
@@ -0,0 +1,150 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Create = require(LuaChat.Create)
local Functional = require(Common.Functional)
local Signal = require(Common.Signal)
local Components = LuaChat.Components
local UserEntry = require(Components.UserEntry)
local ICON_CELL_WIDTH = 60
local UserList = {}
UserList.__index = UserList
function UserList.new(appState, icon, filter)
local self = {
appState = appState,
icon = icon,
filter = filter,
}
setmetatable(self, UserList)
self.users = nil
self.userEntries = {}
self.rbx = Create.new"Frame" {
Size = UDim2.new(1, 0, 0, 0),
BackgroundTransparency = 1,
Create.new"UIListLayout" {
Name = "ListLayout",
SortOrder = "LayoutOrder",
Padding = UDim.new(0, 0),
},
}
self.userSelected = Signal.new()
return self
end
function UserList:ResetFinalDivider()
local children = self.rbx:GetChildren()
local lastChild = nil
for i = 1, #children do
local child = children[#children-i+1]
if child:IsA("GuiObject") and child.Visible then
if lastChild == nil or lastChild.LayoutOrder < child.LayoutOrder then
lastChild = child
end
end
end
if lastChild then
local divider = lastChild.Divider
divider.Size = UDim2.new(1, -ICON_CELL_WIDTH, 0, 1)
divider.Position = UDim2.new(0, ICON_CELL_WIDTH, 1, -1)
end
end
function UserList:FormatFinalDivider()
local children = self.rbx:GetChildren()
local lastChild = nil
for i = 1, #children do
local child = children[#children-i+1]
if child:IsA("GuiObject") and child.Visible then
if lastChild == nil or lastChild.LayoutOrder < child.LayoutOrder then
lastChild = child
end
end
end
if lastChild then
local divider = lastChild.Divider
divider.Size = UDim2.new(1, -ICON_CELL_WIDTH, 0, 1)
divider.Position = UDim2.new(0, ICON_CELL_WIDTH, 1, -1)
end
end
function UserList:ApplySearch(searchTerm)
searchTerm = searchTerm:upper()
self:ResetFinalDivider()
for _, userEntry in pairs(self.userEntries) do
local name = userEntry.rbx.Label.Text:upper()
local first = name:find(searchTerm)
if first ~= nil then
userEntry.rbx.LayoutOrder = first
userEntry.rbx.Visible = true
else
userEntry.rbx.Visible = false
end
end
self.rbx.ListLayout:ApplyLayout()
self:FormatFinalDivider()
end
function UserList:Update(users, selectedList)
if self.users == users then
return
end
self.users = users
for _, userEntry in pairs(self.userEntries) do
userEntry.rbx.Visible = false
end
local height = 0
for _, user in pairs(users) do
if self.filter == nil or self.filter(user) then
local userEntry = self.userEntries[user.id]
local selected = selectedList and Functional.Find(selectedList, user.id) or false
if userEntry == nil then
self:ResetFinalDivider()
userEntry = UserEntry.new(self.appState, user, self.icon, selected)
self.tappedConnection = userEntry.tapped:connect(function()
userEntry.selected = not userEntry.selected
self.userSelected:fire(user)
end)
userEntry.rbx.Parent = self.rbx
self.userEntries[user.id] = userEntry
self:FormatFinalDivider()
else
userEntry.rbx.Visible = true
userEntry:Update(user, selected)
end
height = height + userEntry.rbx.AbsoluteSize.Y
end
end
self.rbx.Size = UDim2.new(1, 0, 0, height)
end
function UserList:Destruct()
for _, userEntry in pairs(self.userEntries) do
userEntry:Destruct()
end
if self.tappedConnection then
self.tappedConnection:disconnect()
end
self.users = {}
self.userEntries = {}
self.rbx:Destroy()
end
return UserList
@@ -0,0 +1,69 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local LuaChat = Modules.LuaChat
local LuaApp = Modules.LuaApp
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local User = require(LuaApp.Models.User)
local UserPresenceTextLabel = {}
UserPresenceTextLabel.__index = UserPresenceTextLabel
function UserPresenceTextLabel.new(appState, userId, additionalProps)
local self = {
appState = appState,
connections = {},
lastUserModel = nil,
userId = userId,
}
setmetatable(self, UserPresenceTextLabel)
self.rbx = Create.new("TextLabel")(
{
BackgroundTransparency = 1,
TextSize = Constants.Font.FONT_SIZE_14,
TextColor3 = Constants.Color.GRAY3,
Font = Enum.Font.SourceSans,
TextXAlignment = Enum.TextXAlignment.Left,
},
additionalProps
)
table.insert(self.connections, appState.store.changed:connect(function(newState)
self:Update(newState)
end))
self:Update(appState.store:getState())
return self
end
function UserPresenceTextLabel:RenderPresenceText(user)
self.rbx.Text = User.userPresenceToText(self.appState.localization, user)
end
function UserPresenceTextLabel:Update(state)
local user = state.Users[self.userId]
if not user then
return
end
if user == self.lastUserModel then
return
end
self.lastUserModel = user
self:RenderPresenceText(user)
end
function UserPresenceTextLabel:Destruct()
for _, connection in pairs(self.connections) do
connection:Disconnect()
end
self.rbx:Destroy()
end
return UserPresenceTextLabel
@@ -0,0 +1,172 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local HeadshotLoader = require(LuaChat.HeadshotLoader)
local Signal = require(Common.Signal)
local getInputEvent = require(LuaChat.Utils.getInputEvent)
local FFlagLuaChatToSplitRbxConnections = settings():GetFFlag("LuaChatToSplitRbxConnections")
local FFlagLuaChatReplacePresenceIndicatorImages = settings():GetFFlag("LuaChatReplacePresenceIndicatorImages")
local OVERLAY_IMAGE_BIG = "rbxasset://textures/ui/LuaChat/graphic/gr-profile-border-48x48.png"
local OVERLAY_IMAGE_SMALL = "rbxasset://textures/ui/LuaChat/graphic/gr-profile-border-36x36.png"
local PRESENCE_DEFAULT_IMAGE = "rbxasset://textures/ui/LuaChat/graphic/indicator-background.png"
local UserThumbnail = {}
UserThumbnail.__index = UserThumbnail
function UserThumbnail.new(appState, userId, small)
local self = {}
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
self.rbx_connections = {}
end
self.appState = appState
self.userId = userId
self.clicked = Signal.new()
local size = small and 36 or 48
local overlayImage = small and OVERLAY_IMAGE_SMALL or OVERLAY_IMAGE_BIG
if FFlagLuaChatReplacePresenceIndicatorImages then
self.presenceIndicatorSize = small and 12 or 14
self.presenceIndicatorSizeKey = Constants:GetPresenceIndicatorSizeKey(self.presenceIndicatorSize)
end
self.headshot = Create.new "ImageLabel" {
Name = "Avatar",
Image = "",
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
BackgroundTransparency = 1,
}
local mask = Create.new "ImageLabel" {
Name = "Overlay",
Image = overlayImage,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}
if FFlagLuaChatReplacePresenceIndicatorImages then
self.pIndicatorBg = Create.new "ImageLabel" {
Name = "Presence",
Size = UDim2.new(0, self.presenceIndicatorSize, 0, self.presenceIndicatorSize),
BackgroundTransparency = 1,
Image = PRESENCE_DEFAULT_IMAGE,
Position = UDim2.new(1, -self.presenceIndicatorSize, 1, -self.presenceIndicatorSize),
Visible = false,
}
else
self.pIndicatorBg = Create.new "ImageLabel" {
Name = "Presence",
Size = UDim2.new(0, 14, 0, 14),
BackgroundTransparency = 1,
Image = PRESENCE_DEFAULT_IMAGE,
Position = UDim2.new(1, -14, 1, -14),
Visible = false,
}
end
self.rbx = Create.new "ImageButton" {
Name = "UserThumbnail",
BackgroundTransparency = 1,
ImageTransparency = 1,
Image = "",
Size = UDim2.new(0, size, 0, size),
AutoButtonColor = false,
self.headshot,
mask,
self.pIndicatorBg,
}
setmetatable(self, UserThumbnail)
self:Update()
self.rbx.AncestryChanged:Connect(function(rbx, parent)
if rbx == self.rbx and parent == nil then
self:Destruct()
end
end)
do
local connection = appState.store.changed:connect(function(state, oldState)
if state.Users == oldState.Users then
return
end
if state.Users[userId] == oldState.Users[userId] then
return
end
self:Update()
end)
table.insert(self.connections, connection)
end
local rbxConnectionList = self.connections
if FFlagLuaChatToSplitRbxConnections then
rbxConnectionList = self.rbx_connections
end
table.insert(rbxConnectionList,
getInputEvent(self.rbx):Connect(function()
self.clicked:fire(self.user)
end)
)
return self
end
function UserThumbnail:Destruct()
for _, connection in pairs(self.connections) do
connection:disconnect()
end
self.connections = {}
if FFlagLuaChatToSplitRbxConnections then
for _, connection in pairs(self.rbx_connections) do
connection:Disconnect()
end
self.rbx_connections = {}
end
self.rbx:Destroy()
end
function UserThumbnail:Update()
local user = self.appState.store:getState().Users[self.userId]
if not user then
return
end
if user == self.user then
return
end
self.user = user
HeadshotLoader:Load(self.headshot, self.userId)
local presenceImage
if FFlagLuaChatReplacePresenceIndicatorImages then
presenceImage = Constants.PresenceIndicatorImagesBySize[self.presenceIndicatorSizeKey][user.presence]
else
presenceImage = Constants.PresenceIndicatorImages[user.presence]
end
if presenceImage then
self.pIndicatorBg.Visible = true
self.pIndicatorBg.Image = presenceImage
else
self.pIndicatorBg.Visible = false
end
end
return UserThumbnail
@@ -0,0 +1,256 @@
local CoreGui = game:GetService("CoreGui")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local UserThumbnail = require(LuaChat.Components.UserThumbnail)
local LuaChatCreateChatUserScrolling = settings():GetFFlag("LuaChatCreateChatUserScrolling2")
local GROUP_ICON_SIZE = 24
local ICON_CELL_WIDTH = 60
local REMOVE_BUTTON_SIZE = 16
local THUMBNAIL_LABEL_HEIGHT = 15
local THUMBNAIL_PADDING_HEIGHT = 10
local THUMBNAIL_PADDING_WIDTH = 16
local THUMBNAIL_SIZE = 48
local EXTRA_END_PADDING = .5 * (ICON_CELL_WIDTH - GROUP_ICON_SIZE)
local THUMBNAIL_PLUS_HEIGHT = THUMBNAIL_SIZE + THUMBNAIL_LABEL_HEIGHT + THUMBNAIL_PADDING_HEIGHT * 2
local THUMBNAIL_PLUS_WIDTH = THUMBNAIL_SIZE + THUMBNAIL_PADDING_WIDTH
local UserThumbnailPlus = {}
UserThumbnailPlus.__index = UserThumbnailPlus
function UserThumbnailPlus.new(appState)
local self = {
appState = appState,
}
setmetatable(self, UserThumbnailPlus)
self.rbx = Create.new"Frame" {
Name = "ThumbnailFrame",
BackgroundTransparency = 1,
Size = UDim2.new(0, THUMBNAIL_PLUS_WIDTH, 0, THUMBNAIL_PLUS_HEIGHT),
}
self:SetEmptyThumbnail()
self.removed = Signal.new()
return self
end
function UserThumbnailPlus:Update(user)
if user == self.user then
return
end
self.user = user
if user == nil then
self:SetEmptyThumbnail(user)
else
self:SetThumbnail(user)
end
end
function UserThumbnailPlus:SetEmptyThumbnail()
if self.userThumbnailFrame ~= nil then
self.userThumbnailFrame.Parent = nil
end
self.userThumbnailFrame = Create.new"ImageLabel" {
Name = "UserThumbnail",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, THUMBNAIL_SIZE, 0, THUMBNAIL_SIZE),
Position = UDim2.new(0, 0, 0, THUMBNAIL_PADDING_HEIGHT),
Image = "rbxasset://textures/ui/LuaChat/graphic/gr-profile-border-48x48-dotted.png",
}
self.userThumbnailFrame.Parent = self.rbx
end
function UserThumbnailPlus:SetThumbnail(user)
if self.userThumbnailFrame ~= nil then
self.userThumbnailFrame.Parent = nil
end
self.userThumbnail = UserThumbnail.new(self.appState, user.id, false)
self.userThumbnail.rbx.Size = UDim2.new(0, THUMBNAIL_SIZE, 0, THUMBNAIL_SIZE)
self.userThumbnailFrame = Create.new"Frame" {
Name = "UserThumbnail",
BackgroundTransparency = 1,
Size = UDim2.new(0, THUMBNAIL_SIZE, 1, -THUMBNAIL_PADDING_HEIGHT*2),
Position = UDim2.new(0, 0, 0, THUMBNAIL_PADDING_HEIGHT),
self.userThumbnail.rbx,
Create.new"TextLabel" {
Name = "UserName",
BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 0, THUMBNAIL_LABEL_HEIGHT),
Position = UDim2.new(0, 0, 1, -THUMBNAIL_LABEL_HEIGHT),
TextSize = Constants.Font.FONT_SIZE_12,
TextColor3 = Constants.Color.GRAY1,
Font = Enum.Font.SourceSans,
Text = user.name,
TextXAlignment = Enum.TextXAlignment.Left,
ClipsDescendants = true,
},
Create.new"ImageLabel" {
Name = "RemoveImage",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, REMOVE_BUTTON_SIZE, 0, REMOVE_BUTTON_SIZE),
Position = UDim2.new(1, 0, 0, 0),
AnchorPoint = Vector2.new(1, 0),
Image = "rbxasset://textures/ui/LuaChat/icons/ic-remove.png",
},
}
self.userThumbnailFrame.Parent = self.rbx
self.tapped = self.userThumbnail.clicked
self.tapped:connect(function()
self.removed:fire(self.user.id)
end)
end
function UserThumbnailPlus:Destruct()
if self.userThumbnail then
self.userThumbnail:Destruct()
end
self.rbx:Destroy()
end
local UserThumbnailBar = {}
UserThumbnailBar.__index = UserThumbnailBar
function UserThumbnailBar.new(appState, maxSize, layoutOrder)
local self = {
appState = appState,
maxSize = maxSize,
connections = {},
}
setmetatable(self, UserThumbnailBar)
local friendsIcon = Create.new"Frame" {
Name = "GroupIcon",
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, ICON_CELL_WIDTH, 0, THUMBNAIL_SIZE),
Position = UDim2.new(0, 0, 0, THUMBNAIL_PADDING_HEIGHT),
Create.new"ImageLabel" {
Name = "IconImage",
BackgroundTransparency = 1,
Size = UDim2.new(0, GROUP_ICON_SIZE, 0, GROUP_ICON_SIZE),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
Image = "rbxasset://textures/ui/LuaChat/icons/ic-group.png",
},
Create.new"TextLabel" {
Name = "FriendCount",
BackgroundTransparency = 1,
TextSize = Constants.Font.FONT_SIZE_14,
TextColor3 = Constants.Color.GRAY2,
Size = UDim2.new(1, 0, 0, THUMBNAIL_LABEL_HEIGHT),
Position = UDim2.new(0, 0, 1, -THUMBNAIL_LABEL_HEIGHT),
AnchorPoint = Vector2.new(0, 0),
Text = "0/" .. maxSize,
Font = "SourceSans",
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Bottom,
},
}
local canvasSizeX = EXTRA_END_PADDING + THUMBNAIL_PLUS_WIDTH * self.maxSize - THUMBNAIL_PADDING_WIDTH
if LuaChatCreateChatUserScrolling then
self.rbx = Create.new"Frame" {
Name = "UserThumbnailBar",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, THUMBNAIL_PLUS_HEIGHT),
LayoutOrder = layoutOrder,
friendsIcon,
Create.new"ScrollingFrame" {
Name = "UserScrollingBar",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, -ICON_CELL_WIDTH, 0, THUMBNAIL_PLUS_HEIGHT),
Position = UDim2.new(0, ICON_CELL_WIDTH, 0, 0),
LayoutOrder = layoutOrder,
ScrollBarThickness = 0,
ScrollingDirection = Enum.ScrollingDirection.X,
ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,
CanvasSize = UDim2.new(0, canvasSizeX, 1, 0),
},
}
self.friendsIcon = friendsIcon
else
self.rbx = Create.new"Frame" {
Name = "UserThumbnailBar",
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.WHITE,
Size = UDim2.new(1, 0, 0, THUMBNAIL_PLUS_HEIGHT),
LayoutOrder = layoutOrder,
friendsIcon,
}
end
self.removed = Signal.new()
self.thumbnails = {}
for i = 1, self.maxSize do
local thumb = UserThumbnailPlus.new(appState)
table.insert(self.thumbnails, thumb)
if LuaChatCreateChatUserScrolling then
thumb.rbx.Position = UDim2.new(0, THUMBNAIL_PLUS_WIDTH * (i-1), 0, 0)
thumb.rbx.Parent = self.rbx.UserScrollingBar
else
thumb.rbx.Position = UDim2.new(0, ICON_CELL_WIDTH + THUMBNAIL_PLUS_WIDTH * (i-1), 0, 0)
thumb.rbx.Parent = self.rbx
end
table.insert(self.connections, thumb.removed:connect(function(id)
self.removed:fire(id)
end))
end
return self
end
function UserThumbnailBar:Update(participants)
local users = self.appState.store:getState().Users
local participantCount = #participants
for index,thumbnail in ipairs(self.thumbnails) do
if index <= participantCount then
thumbnail:Update(users[participants[index]])
else
thumbnail:Update(nil)
end
end
if LuaChatCreateChatUserScrolling then
self.friendsIcon.FriendCount.Text = participantCount .. "/" .. self.maxSize
else
self.rbx.GroupIcon.FriendCount.Text = participantCount .. "/" .. self.maxSize
end
end
function UserThumbnailBar:Destruct()
for _, connection in pairs(self.connections) do
connection:disconnect()
end
for _,thumbnail in ipairs(self.thumbnails) do
thumbnail:Destruct()
end
self.thumbnails = {}
self.rbx:Destroy()
end
return UserThumbnailBar
@@ -0,0 +1,148 @@
local CoreGui = game:GetService("CoreGui")
local Players = game:GetService("Players")
local Modules = CoreGui.RobloxGui.Modules
local Common = Modules.Common
local LuaChat = Modules.LuaChat
local Constants = require(LuaChat.Constants)
local Conversation = require(LuaChat.Models.Conversation)
local Create = require(LuaChat.Create)
local Signal = require(Common.Signal)
local Text = require(LuaChat.Text)
local Components = LuaChat.Components
local TypingIndicator = require(Components.TypingIndicator)
local UserThumbnail = require(Components.UserThumbnail)
local BUBBLE_PADDING = 10
local DEFAULT_SPACER_BETWEEN_BUBBLES = 2
local RECEIVED_BUBBLE_WITH_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble.png"
local RECEIVED_TAIL = "rbxasset://textures/ui/LuaChat/9-slice/chat-bubble-tip.png"
local UserTypingIndicator = {}
UserTypingIndicator.__index = UserTypingIndicator
function UserTypingIndicator.new(appState, conversation)
local self = {
lastTyping = 0
}
self.Resized = Signal.new()
self.conversation = conversation
self.indicator = TypingIndicator.new(appState)
self.indicator.rbx.AnchorPoint = Vector2.new(0.5, 0.5)
self.indicator.rbx.Position = UDim2.new(0.5, 0, 0.5, 0)
self.indicator.rbx.Size = UDim2.new(0, 50, 0, 12)
local localUserId = tostring(Players.LocalPlayer.UserId)
local otherUserId
for _, participant in ipairs(conversation.participants) do
if participant ~= localUserId then
otherUserId = participant
break
end
end
self.otherUserId = otherUserId
self.thumbnail = UserThumbnail.new(appState, otherUserId, true)
self.thumbnail.rbx.Position = UDim2.new(0, 10, 0, 0)
self.thumbnail.rbx.Overlay.ImageColor3 = Constants.Color.GRAY6
self.tail = Create.new "ImageLabel" {
Name = "Tail",
Size = UDim2.new(0, 6, 0, 6),
AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
Image = RECEIVED_TAIL,
}
self.bubble = Create.new "ImageLabel" {
Name = "Bubble",
BackgroundTransparency = 1,
Position = UDim2.new(0, 56, 0, 0),
Size = UDim2.new(0, 70, 0, 38),
ScaleType = Enum.ScaleType.Slice,
SliceCenter = Rect.new(10, 10, 11, 11),
Image = RECEIVED_BUBBLE_WITH_TAIL,
LayoutOrder = 2,
Create.new "Frame" {
Name = "Content",
BackgroundTransparency = 1,
Size = UDim2.new(1, -BUBBLE_PADDING * 2, 1, -BUBBLE_PADDING * 2),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 0),
self.indicator.rbx
},
self.tail,
}
self.rbx = Create.new "Frame" {
Name = "UserTypingIndicator",
Size = UDim2.new(1, 0, 0, 0),
BorderSizePixel = 0,
BackgroundColor3 = Constants.Color.GRAY6,
self.thumbnail.rbx,
self.bubble,
}
setmetatable(self, UserTypingIndicator)
self:Hide()
return self
end
function UserTypingIndicator:Update(conversation)
if conversation.conversationType == Conversation.Type.MULTI_USER_CONVERSATION then
return
end
if conversation.usersTyping == self.conversation.usersTyping then
return
end
self.conversation = conversation
local timeNow = tick()
self.lastTyping = timeNow
if conversation.usersTyping[self.otherUserId] then
self:Show()
else
self:Hide()
end
end
function UserTypingIndicator:Show()
self.rbx.Visible = true
self.indicator.rbx.Visible = true
-- In order to treat indicator as a single line message, generate text
-- bubble height from default font size 18 to ensure typing indiactor
-- height will scale with font scale.
local temporaryTextHeight = Text.GetTextHeight("", Enum.Font.SourceSans, Constants.Font.FONT_SIZE_18,
self.rbx.AbsoluteSize.X)
self.rbx.Size = UDim2.new(1, 0, 0, temporaryTextHeight + BUBBLE_PADDING * 2 + DEFAULT_SPACER_BETWEEN_BUBBLES)
self.Resized:fire()
end
function UserTypingIndicator:Hide()
self.rbx.Visible = false
self.indicator.rbx.Visible = false
self.rbx.Size = UDim2.new(1, 0, 0, 0)
self.Resized:fire()
end
function UserTypingIndicator:Destruct()
self.rbx:Destroy()
end
return UserTypingIndicator