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,41 @@
stds.roblox = {
globals = {
"game"
},
read_globals = {
-- Roblox globals
"script",
-- Extra functions
"tick", "warn", "spawn",
"wait", "settings", "typeof", "delay",
-- Types
"Vector2", "Vector3",
"Color3",
"UDim", "UDim2",
"Rect",
"CFrame",
"Enum",
"Instance",
}
}
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}
}
ignore = {
"212", -- unused arguments
}
std = "lua51+roblox"
files["**/*.spec.lua"] = {
std = "+testez",
}
@@ -0,0 +1,68 @@
--[[
A helper function to define a Rodux action creator with an associated name.
Normally when creating a Rodux action, you can just create a function:
return function(value)
return {
type = "MyAction",
value = value,
}
end
And then when you check for it in your reducer, you either use a constant,
or type out the string name:
if action.type == "MyAction" then
-- change some state
end
Typos here are a remarkably common bug. We also have the issue that there's
no link between reducers and the actions that they respond to!
`Action` (this helper) provides a utility that makes this a bit cleaner.
Instead, define your Rodux action like this:
return Action("MyAction", function(value)
return {
value = value,
}
end)
We no longer need to add the `type` field manually.
Additionally, the returned action creator now has a 'name' property that can
be checked by your reducer:
local MyAction = require(Reducers.MyAction)
...
if action.type == MyAction.name then
-- change some state!
end
Now we have a clear link between our reducers and the actions they use, and
if we ever typo a name, we'll get a warning in LuaCheck as well as an error
at runtime!
]]
return function(name, fn)
assert(type(name) == "string", "A name must be provided to create an Action")
assert(type(fn) == "function", "A function must be provided to create an Action")
return setmetatable({
name = name,
}, {
__call = function(self, ...)
local result = fn(...)
assert(type(result) == "table", "An action must return a table")
result.type = name
return result
end
})
end
@@ -0,0 +1,85 @@
return function()
local Action = require(script.Parent.Action)
it("should return a table", function()
local action = Action("foo", function()
return {}
end)
expect(action).to.be.a("table")
end)
it("should set the name of the action", function()
local action = Action("foo", function()
return {}
end)
expect(action.name).to.equal("foo")
end)
it("should be able to be called as a function", function()
local action = Action("foo", function()
return {}
end)
expect(action).never.to.throw()
end)
it("should return a table when called as a function", function()
local action = Action("foo", function()
return {}
end)
expect(action()).to.be.a("table")
end)
it("should set the type of the action", function()
local action = Action("foo", function()
return {}
end)
expect(action().type).to.equal("foo")
end)
it("should set values", function()
local action = Action("foo", function(value)
return {
value = value
}
end)
expect(action(100).value).to.equal(100)
end)
it("should throw when passed a function", function()
local action = Action("foo", function()
return function() end
end)
expect(action).to.throw()
end)
it("should throw with a invalid name", function()
expect(function()
Action(nil, function()
return {}
end)
end).to.throw()
expect(function()
Action(100, function()
return {}
end)
end).to.throw()
end)
it("should throw when passed a invalid function", function()
expect(function()
Action("foo", nil)
end).to.throw()
expect(function()
Action("foo", {})
end).to.throw()
end)
end
@@ -0,0 +1,19 @@
local Color = {}
function Color.RgbFromHex(hexColor)
assert(hexColor >= 0 and hexColor <= 0xffffff, "RgbFromHex: Out of range")
local b = hexColor % 256
hexColor = (hexColor - b) / 256
local g = hexColor % 256
hexColor = (hexColor - g) / 256
local r = hexColor
return r, g, b
end
function Color.Color3FromHex(hexColor)
return Color3.fromRGB(Color.RgbFromHex(hexColor))
end
return Color
@@ -0,0 +1,33 @@
return function()
local Color = require(script.Parent.Color)
describe("RgbFromHex", function()
it("should convert a hex color to rgb correctly", function()
local r, g, b = Color.RgbFromHex(0x232527)
expect(r).to.equal(35)
expect(g).to.equal(37)
expect(b).to.equal(39)
r, g, b = Color.RgbFromHex(0x0)
expect(r).to.equal(0)
expect(g).to.equal(0)
expect(b).to.equal(0)
r, g, b = Color.RgbFromHex(0xffffff)
expect(r).to.equal(255)
expect(g).to.equal(255)
expect(b).to.equal(255)
end)
it("should assert if given a hex color out of range", function()
expect(function()
Color.RgbFromHex(-1)
end).to.throw()
expect(function()
Color.RgbFromHex(0x1000000)
end).to.throw()
end)
end)
end
@@ -0,0 +1,131 @@
--[[
Provides an implementation of functional programming primitives.
]]
local Functional = {}
--[[
Create a copy of a list with only values for which `callback` returns true
]]
function Functional.Filter(list, callback)
local new = {}
for key = 1, #list do
local value = list[key]
if callback(value, key) then
table.insert(new, value)
end
end
return new
end
--[[
Create a copy of a list where each value is transformed by `callback`
]]
function Functional.Map(list, callback)
local new = {}
for key = 1, #list do
new[key] = callback(list[key], key)
end
return new
end
--[[
Identical to Map, except that the result will be reversed.
]]
function Functional.MapReverse(list, callback)
local new = {}
for key = #list, 1, -1 do
new[key] = callback(list[key], key)
end
return new
end
--[[
Create a copy of a list doing a combination filter and map.
If callback returns nil for any item, it is considered filtered from the
list. Any other value is considered the result of the 'map' operation.
]]
function Functional.FilterMap(list, callback)
local new = {}
for key = 1, #list do
local value = list[key]
local result = callback(value, key)
if result ~= nil then
table.insert(new, result)
end
end
return new
end
--[[
Performs a left-fold of the list with the given initial value and callback.
]]
function Functional.Fold(list, initial, callback)
local accum = initial
for key = 1, #list do
accum = callback(accum, list[key], key)
end
return accum
end
--[[
Performs a fold over the entries in the given dictionary.
]]
function Functional.FoldDictionary(dictionary, initial, callback)
local accum = initial
for key, value in pairs(dictionary) do
accum = callback(accum, key, value)
end
return accum
end
--[[
Returns a list that contains at most `count` values from the given list.
]]
function Functional.Take(list, count, startingIndex)
startingIndex = startingIndex or 1
local maxIndex = count + (startingIndex - 1)
if maxIndex > #list then
maxIndex = #list
end
local new = {}
for i = startingIndex, maxIndex do
local value = list[i]
local newIndex = i - (startingIndex - 1)
new[newIndex] = value
end
return new
end
--[[
If the list contains the sought-after element, return its index, or nil otherwise.
]]
function Functional.Find(list, value)
for index, element in ipairs(list) do
if element == value then
return index
end
end
return nil
end
return Functional
@@ -0,0 +1,218 @@
return function()
local Functional = require(script.Parent.Functional)
local function identity(...)
return ...
end
local function add(a, b)
return a + b
end
describe("Filter", function()
it("should copy lists correctly", function()
local listA = {1, 2, 3}
local listB = Functional.Filter(listA, function()
return true
end)
expect(listB).never.to.equal(listA)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i])
end
end)
it("should correctly use the filter predicate", function()
local listA = {1, 2, 3, 4, 5}
local listB = Functional.Filter(listA, function(value, key)
expect(value).to.equal(key)
return value % 2 == 0
end)
expect(listB[1]).to.equal(2)
expect(listB[2]).to.equal(4)
end)
end)
describe("Map", function()
it("should copy lists correctly using the identity function", function()
local listA = {1, 2, 3}
local listB = Functional.Map(listA, identity)
expect(listB).never.to.equal(listA)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i])
end
end)
it("should correctly use the map predicate", function()
local listA = {1, 2, 3}
local listB = Functional.Map(listA, function(value, key)
expect(value).to.equal(key)
return value * 2
end)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i] * 2)
end
end)
end)
describe("MapReverse", function()
it("should copy lists correctly using the identity function", function()
local listA = {1, 2, 3}
local listB = Functional.MapReverse(listA, identity)
expect(listB).never.to.equal(listA)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i])
end
end)
it("should correctly use the map predicate", function()
local listA = {1, 2, 3}
local listB = Functional.MapReverse(listA, function(value, key)
expect(value).to.equal(key)
return value * 2
end)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i] * 2)
end
end)
it("should iterate backwards", function()
local list = {1, 2, 3}
local nextKey = 3
Functional.MapReverse(list, function(value, key)
expect(value).to.equal(nextKey)
expect(key).to.equal(nextKey)
nextKey = nextKey - 1
end)
expect(nextKey).to.equal(0)
end)
end)
describe("FilterMap", function()
it("should copy truthy lists using the identity function", function()
local listA = {1, 2, 3}
local listB = Functional.FilterMap(listA, identity)
expect(listB).never.to.equal(listA)
for i = 1, #listB do
expect(listB[i]).to.equal(listA[i])
end
end)
it("should correctly use the filter-map predicate", function()
local listA = {1, 2, 3, 4, 5}
-- Create a list containing only the odd numbers, and double those numbers
local listB = Functional.FilterMap(listA, function(value, key)
expect(value).to.equal(key)
if value % 2 == 0 then
return nil
end
return value * 2
end)
expect(listB[1]).to.equal(2)
expect(listB[2]).to.equal(6)
expect(listB[3]).to.equal(10)
end)
end)
describe("Fold", function()
it("should left-fold lists", function()
local list = {1, 2, 3, 4, 5}
local sum = Functional.Fold(list, 0, add)
expect(sum).to.equal(15)
end)
end)
describe("Take", function()
it("should take values from a list", function()
local a = {1, 2, 3}
local b = Functional.Take(a, 2)
expect(#b).to.equal(2)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(2)
end)
it("should not take past the end of a list", function()
local a = {1, 2, 3}
local b = Functional.Take(a, 4)
expect(#b).to.equal(3)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(2)
expect(b[3]).to.equal(3)
end)
it("should copy all values when taking past the end of a list", function()
local a = {1, 2, 3}
local b = Functional.Take(a, 4)
expect(#b).to.equal(#a)
expect(a[1]).to.equal(b[1])
expect(a[2]).to.equal(b[2])
expect(a[3]).to.equal(b[3])
end)
it("should take values from a starting index when provided", function()
local a = {1, 2, 3, 4}
local b = Functional.Take(a, 2, 2)
expect(#b).to.equal(2)
expect(b[1]).to.equal(2)
expect(b[2]).to.equal(3)
end)
it("should not take past the end of a list when the starting index is provided", function()
local a = {1, 2, 3, 4}
local b = Functional.Take(a, 3, 3)
expect(#b).to.equal(2)
expect(b[1]).to.equal(3)
expect(b[2]).to.equal(4)
end)
end)
describe("Find", function()
it("should return index of matched item", function()
local a = {"foo", "bar", "garply"}
local b = Functional.Find(a, "bar")
expect(b).to.equal(2)
end)
it("should find the first example in the case of duplicates", function()
local a = {"foo", "bar", "garply", "bar"}
local b = Functional.Find(a, "bar")
expect(b).to.equal(2)
end)
it("should return nil if item is not found", function()
local a = {"foo", "bar", "garply"}
local b = Functional.Find(a, "fleebledegoop")
expect(b).to.equal(nil)
end)
end)
end
@@ -0,0 +1,141 @@
--[[
Provides functions for manipulating immutable data structures.
]]
local Immutable = {}
--[[
Merges dictionary-like tables together.
]]
function Immutable.JoinDictionaries(...)
local result = {}
for i = 1, select("#", ...) do
local dictionary = select(i, ...)
for key, value in pairs(dictionary) do
result[key] = value
end
end
return result
end
--[[
Joins any number of lists together into a new list
]]
function Immutable.JoinLists(...)
local new = {}
for listKey = 1, select("#", ...) do
local list = select(listKey, ...)
local len = #new
for itemKey = 1, #list do
new[len + itemKey] = list[itemKey]
end
end
return new
end
--[[
Creates a new copy of the dictionary and sets a value inside it.
]]
function Immutable.Set(dictionary, key, value)
local new = {}
for key, value in pairs(dictionary) do
new[key] = value
end
new[key] = value
return new
end
--[[
Creates a new copy of the list with the given elements appended to it.
]]
function Immutable.Append(list, ...)
local new = {}
local len = #list
for key = 1, len do
new[key] = list[key]
end
for i = 1, select("#", ...) do
new[len + i] = select(i, ...)
end
return new
end
--[[
Remove elements from a dictionary
]]
function Immutable.RemoveFromDictionary(dictionary, ...)
local result = {}
for key, value in pairs(dictionary) do
local found = false
for listKey = 1, select("#", ...) do
if key == select(listKey, ...) then
found = true
break
end
end
if not found then
result[key] = value
end
end
return result
end
--[[
Remove the given key from the list.
]]
function Immutable.RemoveFromList(list, removeIndex)
local new = {}
for i = 1, #list do
if i ~= removeIndex then
table.insert(new, list[i])
end
end
return new
end
--[[
Remove the range from the list starting from the index.
]]
function Immutable.RemoveRangeFromList(list, index, count)
local new = {}
for i = 1, #list do
if i < index or i >= index + count then
table.insert(new, list[i])
end
end
return new
end
--[[
Creates a new list that has no occurrences of the given value.
]]
function Immutable.RemoveValueFromList(list, removeValue)
local new = {}
for i = 1, #list do
if list[i] ~= removeValue then
table.insert(new, list[i])
end
end
return new
end
return Immutable
@@ -0,0 +1,284 @@
return function()
local Immutable = require(script.Parent.Immutable)
describe("JoinDictionaries", function()
it("should preserve immutability", function()
local a = {}
local b = {}
local c = Immutable.JoinDictionaries(a, b)
expect(c).never.to.equal(a)
expect(c).never.to.equal(b)
end)
it("should treat list-like values like dictionary values", function()
local a = {
[1] = 1,
[2] = 2,
[3] = 3
}
local b = {
[1] = 11,
[2] = 22
}
local c = Immutable.JoinDictionaries(a, b)
expect(c[1]).to.equal(b[1])
expect(c[2]).to.equal(b[2])
expect(c[3]).to.equal(a[3])
end)
it("should merge dictionary values correctly", function()
local a = {
hello = "world",
foo = "bar"
}
local b = {
foo = "baz",
tux = "penguin"
}
local c = Immutable.JoinDictionaries(a, b)
expect(c.hello).to.equal(a.hello)
expect(c.foo).to.equal(b.foo)
expect(c.tux).to.equal(b.tux)
end)
it("should merge multiple dictionaries", function()
local a = {
foo = "yes"
}
local b = {
bar = "yup"
}
local c = {
baz = "sure"
}
local d = Immutable.JoinDictionaries(a, b, c)
expect(d.foo).to.equal(a.foo)
expect(d.bar).to.equal(b.bar)
expect(d.baz).to.equal(c.baz)
end)
end)
describe("JoinLists", function()
it("should preserve immutability", function()
local a = {}
local b = {}
local c = Immutable.JoinLists(a, b)
expect(c).never.to.equal(a)
expect(c).never.to.equal(b)
end)
it("should treat list-like values correctly", function()
local a = {1, 2, 3}
local b = {4, 5, 6}
local c = Immutable.JoinLists(a, b)
expect(#c).to.equal(6)
for i = 1, #c do
expect(c[i]).to.equal(i)
end
end)
it("should merge multiple lists", function()
local a = {1, 2}
local b = {3, 4}
local c = {5, 6}
local d = Immutable.JoinLists(a, b, c)
expect(#d).to.equal(6)
for i = 1, #d do
expect(d[i]).to.equal(i)
end
end)
end)
describe("Set", function()
it("should preserve immutability", function()
local a = {}
local b = Immutable.Set(a, "foo", "bar")
expect(b).never.to.equal(a)
end)
it("should treat numeric keys normally", function()
local a = {1, 2, 3}
local b = Immutable.Set(a, 2, 4)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(4)
expect(b[3]).to.equal(3)
end)
it("should overwrite dictionary-like keys", function()
local a = {
foo = "bar",
baz = "qux"
}
local b = Immutable.Set(a, "foo", "hello there")
expect(b.foo).to.equal("hello there")
expect(b.baz).to.equal(a.baz)
end)
end)
describe("Append", function()
it("should preserve immutability", function()
local a = {}
local b = Immutable.Append(a, "another happy landing")
expect(b).never.to.equal(a)
end)
it("should append values", function()
local a = {1, 2, 3}
local b = Immutable.Append(a, 4, 5)
expect(#b).to.equal(5)
for i = 1, #b do
expect(b[i]).to.equal(i)
end
end)
end)
describe("RemoveFromDictionary", function()
it("should preserve immutability", function()
local a = { foo = "bar" }
local b = Immutable.RemoveFromDictionary(a, "foo")
expect(b).to.never.equal(a)
end)
it("should remove fields from the dictionary", function()
local a = {
foo = "bar",
baz = "qux",
boof = "garply",
}
local b = Immutable.RemoveFromDictionary(a, "foo", "boof")
expect(b.foo).to.never.be.ok()
expect(b.baz).to.equal("qux")
expect(b.boof).to.never.be.ok()
end)
end)
describe("RemoveFromList", function()
it("should preserve immutability", function()
local a = {1, 2, 3}
local b = Immutable.RemoveFromList(a, 2)
expect(b).never.to.equal(a)
end)
it("should remove elements from the list", function()
local a = {1, 2, 3}
local b = Immutable.RemoveFromList(a, 2)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(3)
expect(b[3]).never.to.be.ok()
end)
end)
describe("RemoveRangeFromList", function()
it("should preserve immutability", function()
local a = {1, 2, 3}
local b = Immutable.RemoveRangeFromList(a, 2, 1)
expect(b).never.to.equal(a)
end)
it("should remove elements properly from the list", function()
local a = {1, 2, 3}
local b = Immutable.RemoveRangeFromList(a, 2, 1)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(3)
expect(b[3]).never.to.be.ok()
end)
it("should remove elements properly from the list", function()
local a = {1, 2, 3, 4, 5, 6}
local b = Immutable.RemoveRangeFromList(a, 1, 4)
expect(b[1]).to.equal(5)
expect(b[2]).to.equal(6)
expect(b[3]).never.to.be.ok()
end)
it("should remove elements properly from the list", function()
local a = {1, 2, 3, 4, 5, 6}
local b = Immutable.RemoveRangeFromList(a, 2, 4)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(6)
expect(b[3]).never.to.be.ok()
end)
it("should remove elements properly from the list", function()
local a = {1, 2, 3, 4, 5, 6, 7}
local b = Immutable.RemoveRangeFromList(a, 4, 4)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(2)
expect(b[3]).to.equal(3)
expect(b[4]).never.to.be.ok()
end)
it("should not remove any elements when count is 0 or less", function()
local a = {1, 2, 3}
local b = Immutable.RemoveRangeFromList(a, 2, 0)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(2)
expect(b[3]).to.equal(3)
local c = Immutable.RemoveRangeFromList(a, 2, -1)
expect(c[1]).to.equal(1)
expect(c[2]).to.equal(2)
expect(c[3]).to.equal(3)
end)
end)
describe("RemoveValueFromList", function()
it("should preserve immutability", function()
local a = {1, 1, 1}
local b = Immutable.RemoveValueFromList(a, 1)
expect(b).never.to.equal(a)
end)
it("should remove all elements from the list", function()
local a = {1, 2, 2, 3}
local b = Immutable.RemoveValueFromList(a, 2)
expect(b[1]).to.equal(1)
expect(b[2]).to.equal(3)
expect(b[3]).never.to.be.ok()
end)
end)
end
@@ -0,0 +1,123 @@
local LuaUseUtf8TextTruncation = settings():GetFFlag("LuaUseUtf8TextTruncation")
local TextMeasureTemporaryPatch = settings():GetFFlag("TextMeasureTemporaryPatch")
local TextService = game:GetService("TextService")
local Text = {}
-- FYI: Any number greater than 2^30 will make TextService:GetTextSize give invalid results
local MAX_BOUND = 10000
-- TODO(CLIPLAYEREX-1633): We can remove this padding patch after fixing TextService:GetTextSize sizing bug
Text._TEMP_PATCHED_PADDING = Vector2.new(0, 0)
if TextMeasureTemporaryPatch then
Text._TEMP_PATCHED_PADDING = Vector2.new(2, 2)
end
-- Wrapper function for GetTextSize
function Text.GetTextBounds(text, font, fontSize, bounds)
return TextService:GetTextSize(text, fontSize, font, bounds) + Text._TEMP_PATCHED_PADDING
end
function Text.GetTextWidth(text, font, fontSize)
return Text.GetTextBounds(text, font, fontSize, Vector2.new(MAX_BOUND, MAX_BOUND)).X
end
function Text.GetTextHeight(text, font, fontSize, widthCap)
return Text.GetTextBounds(text, font, fontSize, Vector2.new(widthCap, MAX_BOUND)).Y
end
-- TODO(CLIPLAYEREX-391): Kill these truncate functions once we have official support for text truncation
function Text.Truncate(text, font, fontSize, widthInPixels, overflowMarker)
overflowMarker = overflowMarker or ""
if Text.GetTextWidth(text, font, fontSize) > widthInPixels then
if LuaUseUtf8TextTruncation then
-- A binary search may be more efficient
local lastText = ""
for _, stopIndex in utf8.graphemes(text) do
local newText = string.sub(text, 1, stopIndex) .. overflowMarker
if Text.GetTextWidth(newText, font, fontSize) > widthInPixels then
return lastText
end
lastText = newText
end
else
for len = #text, 1, -1 do
local newText = string.sub(text, 1, len) .. overflowMarker
if Text.GetTextWidth(newText, font, fontSize) <= widthInPixels then
return newText
end
end
end
else -- No truncation needed
return text
end
return ""
end
function Text.TruncateTextLabel(textLabel, overflowMarker)
textLabel.Text = Text.Truncate(textLabel.Text, textLabel.Font,
textLabel.TextSize, textLabel.AbsoluteSize.X, overflowMarker)
end
-- Remove whitespace from the beginning and end of the string
function Text.Trim(str)
if type(str) ~= "string" then
error(string.format("Text.Trim called on non-string type %s.", type(str)), 2)
end
return (str:gsub("^%s*(.-)%s*$", "%1"))
end
-- Remove whitespace from the end of the string
function Text.RightTrim(str)
if type(str) ~= "string" then
error(string.format("Text.RightTrim called on non-string type %s.", type(str)), 2)
end
return (str:gsub("%s+$", ""))
end
-- Remove whitespace from the beginning of the string
function Text.LeftTrim(str)
if type(str) ~= "string" then
error(string.format("Text.LeftTrim called on non-string type %s.", type(str)), 2)
end
return (str:gsub("^%s+", ""))
end
-- Replace multiple whitespace with one; remove leading and trailing whitespace
function Text.SpaceNormalize(str)
if type(str) ~= "string" then
error(string.format("Text.SpaceNormalize called on non-string type %s.", type(str)), 2)
end
return (str:gsub("%s+", " "):gsub("^%s+" , ""):gsub("%s+$" , ""))
end
-- Splits a string by the provided pattern into a table. The pattern is interpreted as plain text.
function Text.Split(str, pattern)
if type(str) ~= "string" then
error(string.format("Text.Split called on non-string type %s.", type(str)), 2)
elseif type(pattern) ~= "string" then
error(string.format("Text.Split called with a pattern that is non-string type %s.", type(pattern)), 2)
elseif pattern == "" then
error("Text.Split called with an empty pattern.", 2)
end
local result = {}
local currentPosition = 1
while true do
local patternStart, patternEnd = string.find(str, pattern, currentPosition, true)
if not patternStart or not patternEnd then break end
table.insert(result, string.sub(str, currentPosition, patternStart - 1))
currentPosition = patternEnd + 1
end
table.insert(result, string.sub(str, currentPosition, string.len(str)))
return result
end
return Text
@@ -0,0 +1,410 @@
return function()
local Text = require(script.Parent.Text)
describe("GetTextBounds", function()
it("should return a bounds of padding width and font-size height when the string is empty", function()
local bounds = Text.GetTextBounds("", Enum.Font.SourceSans, 18, Vector2.new(1000, 1000))
expect(bounds.X).to.equal(Text._TEMP_PATCHED_PADDING.x)
expect(bounds.Y).to.equal(18 + Text._TEMP_PATCHED_PADDING.y)
end)
it("should return the height and width of a string as one line with large bounds", function()
local bounds = Text.GetTextBounds("One Two Three", Enum.Font.SourceSans, 18, Vector2.new(1000, 1000))
expect(bounds.Y).to.equal(18 + Text._TEMP_PATCHED_PADDING.y)
end)
it("should return the height of the string as multiple lines with short bounds", function()
local bounds = Text.GetTextBounds("One Two Three Four", Enum.Font.SourceSans, 18, Vector2.new(32, 1000))
expect(bounds.Y > 18).to.equal(true)
end)
end)
describe("GetTextHeight", function()
it("should return height equal to font size when string is empty", function()
local height = Text.GetTextHeight("", Enum.Font.SourceSans, 18, 0)
expect(height).to.equal(18 + Text._TEMP_PATCHED_PADDING.y)
end)
end)
describe("GetTextWidth", function()
it("should return width equal to 1 when string is empty", function()
local width = Text.GetTextWidth("", Enum.Font.SourceSans, 18, 18)
expect(width).to.equal(Text._TEMP_PATCHED_PADDING.x)
end)
end)
describe("Truncate", function()
it("should return empty string", function()
local emptyQuery = Text.Truncate("", Enum.Font.SourceSans, 18, 0, "...")
expect(emptyQuery).to.be.a("string")
expect(emptyQuery).to.equal("")
end)
it("should return empty string for not empty box", function()
local emptyQuery = Text.Truncate("", Enum.Font.SourceSans, 18, 50, "...")
expect(emptyQuery).to.be.a("string")
expect(emptyQuery).to.equal("")
end)
it("should truncate with ...", function()
local reallyLongQuery = Text.Truncate(
"One Two Three Four Five Six Seven Eight Nine Ten Eleven Twelve", Enum.Font.SourceSans, 18, 100, "...")
expect(reallyLongQuery).to.equal("One Two Thre...")
end)
it("should truncate without a ...", function()
local reallyLongQueryNoOverflowMarker = Text.Truncate(
"One Two Three Four Five Six Seven Eight Nine Ten Eleven Twelve", Enum.Font.SourceSans, 18, 100)
expect(reallyLongQueryNoOverflowMarker).to.equal("One Two Three ")
end)
it("should not truncate", function()
local shouldFitQuery = Text.Truncate("One Two", Enum.Font.SourceSans, 18, 100)
expect(shouldFitQuery).to.equal("One Two")
end)
it("should not truncate, off by one check", function()
local oneCharQuery = Text.Truncate("O", Enum.Font.SourceSans, 18, 100)
expect(oneCharQuery).to.equal("O")
end)
it("should truncate, off by one check", function()
local oneCharNoRoomQuery = Text.Truncate("O", Enum.Font.SourceSans, 18, 0)
expect(oneCharNoRoomQuery).to.equal("")
end)
it("should perform a negative width check", function()
local shouldFitQuery = Text.Truncate("One Two", Enum.Font.SourceSans, 18, -100, "...")
expect(shouldFitQuery).to.equal("")
end)
itFIXME("should truncate long graphemes properly", function()
-- 11-byte rainbow flag grapheme
-- Flag, zero-space-joiner, rainbow
local rainbowFlag = utf8.char(127987) .. utf8.char(8205) .. utf8.char(127752)
local oneFlagWithinLimit = Text.Truncate(
rainbowFlag, Enum.Font.SourceSans, 18, 100, "...")
expect(oneFlagWithinLimit).to.equal(rainbowFlag)
local twoRainbowFlags = rainbowFlag .. rainbowFlag
local twoFlagsAreFine = Text.Truncate(
twoRainbowFlags, Enum.Font.SourceSans, 18, 100, "...")
expect(twoFlagsAreFine).to.equal(twoRainbowFlags)
local fourRainbowFlags = twoRainbowFlags .. twoRainbowFlags
local fourFlagsIsTooLong = Text.Truncate(
fourRainbowFlags, Enum.Font.SourceSans, 18, 100, "...")
expect(fourFlagsIsTooLong).to.equal(twoRainbowFlags .. "...") -- With --fflags==true fails because of truncation
end)
end)
describe("TruncateTextLabel", function()
it("should use text label attributes to truncate text", function()
local screenGui = Instance.new("ScreenGui")
local textLabel = Instance.new("TextLabel")
textLabel.Size = UDim2.new(0, 100, 0, 32)
textLabel.Text = "One Two Three Four Five Six Seven Eight Nine Ten Eleven Twelve"
textLabel.Font = Enum.Font.SourceSans
textLabel.TextSize = 18
textLabel.Parent = screenGui
Text.TruncateTextLabel(textLabel)
expect(textLabel.Text).to.equal("One Two Three ")
end)
end)
describe("TrimString", function()
it("Should trim the string properly", function()
local trimmedInput = Text.Trim("")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim(" ")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim("ab")
local expected = "ab"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim(" ab ")
local expected = "ab"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim(" a b ")
local expected = "a b"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim("\r\n\t\f a\r\n\t\f ")
local expected = "a"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string with unicode characters properly", function()
local trimmedInput = Text.Trim("😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓")
local expected = "😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim(" 😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓 ")
local expected = "😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
it("Should trim the string properly", function()
local trimmedInput = Text.Trim("\n 😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓 \n")
local expected = "😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
end)
describe("RightTrimString", function()
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim("")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim(" ")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim("ab")
local expected = "ab"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim(" ab ")
local expected = " ab"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim(" a b ")
local expected = " a b"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim("\r\n\t\f a\r\n\t\f ")
local expected = "\r\n\t\f a"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string with unicode characters properly", function()
local trimmedInput = Text.RightTrim("😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓")
local expected = "😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim(" 😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓 ")
local expected = " 😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
it("Should right trim the string properly", function()
local trimmedInput = Text.RightTrim("\n 😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓 \n")
local expected = "\n 😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
end)
describe("LeftTrimString", function()
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim("")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim(" ")
local expected = ""
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim("ab")
local expected = "ab"
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim(" ab ")
local expected = "ab "
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim(" a b ")
local expected = " a b "
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim("\r\n\t\f a\r\n\t\f ")
local expected = "a\r\n\t\f "
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string with unicode characters properly", function()
local trimmedInput = Text.LeftTrim("😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓")
local expected = "😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓"
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim(" 😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓 ")
local expected = "😤👩🏼‍🏫😭ぼ😀で😹🤕あ👩🏻‍🎓 "
expect(trimmedInput).to.equal(expected)
end)
it("Should left trim the string properly", function()
local trimmedInput = Text.LeftTrim("\n 😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓 \n")
local expected = "😤👩🏼‍🏫😭ぼ😀 \nで😹🤕あ👩🏻‍🎓 \n"
expect(trimmedInput).to.equal(expected)
end)
end)
describe("SpaceNormalize", function()
it("should remove multiple spaces between words", function()
local a = "This is not a normal sentence."
expect(Text.SpaceNormalize(a)).to.equal("This is not a normal sentence.")
end)
it("should remove leading and trailing whitespace", function()
local a = " SpaceTabSpaceTab "
expect(Text.SpaceNormalize(a)).to.equal("SpaceTabSpaceTab")
end)
it("should not change a string with no whitespace", function()
local a = "There'sNo%Whit.e\\space--InThis."
expect(Text.SpaceNormalize(a)).to.equal(a)
end)
it("should remove all whitespace in a string that is nothing but whitespace", function()
local a = " "
expect(Text.SpaceNormalize(a)).to.equal("")
end)
it("should handle the case where the string is empty", function()
local a = ""
expect(Text.SpaceNormalize(a)).to.equal(a)
end)
it("should throw an error if called an a non-string type", function()
local a = { first = 1, second = 2 }
expect(function()
Text.SpaceNormalize(a)
end).to.throw()
end)
end)
describe("Split", function()
local function tableEquals(tb1, tb2)
local tables = { tb1, tb2 }
for _,tb in ipairs(tables) do
for key in pairs(tb) do
if tb1[key] ~= tb2[key] then
return false
end
end
end
return true
end
it("should return the correct table for your standard use case", function()
local a = "this,is,comma,separated"
local pattern = ","
local expectedResult = {
[1] = "this",
[2] = "is",
[3] = "comma",
[4] = "separated",
}
expect(tableEquals(Text.Split(a, pattern), expectedResult)).to.equal(true)
end)
it("should not remove whitespace", function()
local a = " SpaceTab , , Space"
local pattern = ","
local expectedResult = {
[1] = " SpaceTab ",
[2] = " ",
[3] = " Space",
}
expect(tableEquals(Text.Split(a, pattern), expectedResult)).to.equal(true)
end)
it("should treat regular expressions as plain text", function()
local a = "Notyour^%s+normalstring.Thisisasecondsentence."
local b = "."
local c = "^%s+"
local d = "%A"
local expectedB = {
[1] = "Notyour^%s+normalstring",
[2] = "Thisisasecondsentence",
[3] = "",
}
local expectedC = {
[1] = "Notyour",
[2] = "normalstring.Thisisasecondsentence."
}
local expectedD = {
[1] = "Notyour^%s+normalstring.Thisisasecondsentence."
}
expect(tableEquals(Text.Split(a, b), expectedB)).to.equal(true)
expect(tableEquals(Text.Split(a, c), expectedC)).to.equal(true)
expect(tableEquals(Text.Split(a, d), expectedD)).to.equal(true)
end)
it("should work when pattern is not in string", function()
local a = "The pattern you are looking for does not exist."
local pattern = ","
local expectedResult = {
[1] = "The pattern you are looking for does not exist.",
}
expect(tableEquals(Text.Split(a, pattern), expectedResult)).to.equal(true)
end)
it("should work when called on an empty string", function()
local a = ""
local pattern = ","
local expectedResult = {
[1] = "",
}
expect(tableEquals(Text.Split(a, pattern), expectedResult)).to.equal(true)
end)
it("should throw an error if called on an empty pattern", function()
local a = "The pattern definitely doesn't exist here."
local pattern = ""
expect(function()
Text.Split(a, pattern)
end).to.throw()
end)
it("should throw an error if called an a non-string type", function()
local a = { first = 1, second = 2 }
local b = "an actual string"
expect(function()
Text.Split(a, b)
end).to.throw()
expect(function()
Text.Split(b, a)
end).to.throw()
end)
end)
end
@@ -0,0 +1,68 @@
--[[
memoize creates a function as a wrapper that caches the last outputs of a function.
This is useful if you know that the function should return the same output every
time it is run with the same inputs. The function should only return an output, and
not have any side effects. These side effects are not cached.
Without memoize's caching, even though the function ouputs the same values, the
memory locations of the values are different; tables made in the function, even if
they have the same values, won't be the same tables.
memoize only caches the last set of inputs and ouputs. This means that it is only
helpful when the function is likely to be called with the same inputs multiple
times in a row. This is the case with most Roact use cases.
Note that memoize only does a ** shallow check on table inputs ** . This means
that if the same table is input but the elements of the table are different then
it will be assumed that the table has not changed.
In addition to all the previous warnings, memoize strips trailing nils. This means
that if foo is a memoized function and we call foo(), then foo(nil) will return a
cached value. This is opposed to how print handles input. print() only outputs a
new line, but print(nil) outputs "nil". This is because varargs can detect the
number of arguments passed in. So, be careful when using memoize with varargs.
Trailing nils will be stripped.
The wrapper can take any number of inputs and give any number of outputs.
Leading and interspersed nils are handled gracefully. Trailing nils on the input
are stripped.
]]
local function captureSize(...)
return {...}, select("#", ...)
end
local function memoize(func)
assert(type(func) == "function", "memoize requires a function to memoize")
local lastArgs
local lastNumArgs
local lastOutput
local lastNumOutput
return function(...)
local numArgs = select("#", ...)
while numArgs > 0 and select(numArgs, ...) == nil do
numArgs = numArgs - 1
end
if numArgs ~= lastNumArgs then
lastArgs = {...}
lastNumArgs = numArgs
lastOutput, lastNumOutput = captureSize(func(...))
return unpack(lastOutput, 1, lastNumOutput)
end
for i = 1, lastNumArgs do
if select(i, ...) ~= lastArgs[i] then
lastArgs = {...}
lastOutput, lastNumOutput = captureSize(func(...))
break
end
end
return unpack(lastOutput, 1, lastNumOutput)
end
end
return memoize
@@ -0,0 +1,213 @@
return function()
local memoize = require(script.Parent.memoize)
describe("memoize", function()
it("should handle arity 0", function()
local callCount = 0
local identity = memoize(function(a, b)
callCount = callCount + 1
return a, b
end)
expect(identity()).to.equal(nil)
expect(identity(nil)).to.equal(nil)
expect(identity(nil, nil)).to.equal(nil)
expect(callCount).to.equal(1)
end)
it("should handle arity 1", function()
local callCount = 0
local identity = memoize(function(a)
callCount = callCount + 1
return a
end)
expect(identity(5)).to.equal(5)
expect(identity(5)).to.equal(5)
expect(callCount).to.equal(1)
expect(identity(6)).to.equal(6)
expect(callCount).to.equal(2)
expect(identity(5)).to.equal(5)
expect(callCount).to.equal(3)
end)
it("should handle arity 2", function()
local callCount = 0
local identity = memoize(function(a, b)
callCount = callCount + 1
return a, b
end)
local a, b
a, b = identity(5, 6)
expect(a).to.equal(5)
expect(b).to.equal(6)
a, b = identity(5, 6)
expect(a).to.equal(5)
expect(b).to.equal(6)
expect(callCount).to.equal(1)
a, b = identity(6, 5)
expect(a).to.equal(6)
expect(b).to.equal(5)
expect(callCount).to.equal(2)
a, b = identity(5, 6)
expect(a).to.equal(5)
expect(b).to.equal(6)
expect(callCount).to.equal(3)
end)
it("should handle mixed arity", function()
local callCount = 0
local identity = memoize(function(a, b)
callCount = callCount + 1
return a, b
end)
local a, b
a, b = identity(5, 6)
expect(a).to.equal(5)
expect(b).to.equal(6)
a, b = identity(5, 6)
expect(a).to.equal(5)
expect(b).to.equal(6)
expect(callCount).to.equal(1)
a, b = identity(5)
expect(a).to.equal(5)
expect(b).to.equal(nil)
a, b = identity(5)
expect(a).to.equal(5)
expect(b).to.equal(nil)
expect(callCount).to.equal(2)
a, b = identity()
expect(a).to.equal(nil)
expect(b).to.equal(nil)
a, b = identity()
expect(a).to.equal(nil)
expect(b).to.equal(nil)
expect(callCount).to.equal(3)
end)
it("should handle trailing nils", function()
local callCount = 0
local identity = memoize(function(a, b)
callCount = callCount + 1
return a, b
end)
local a, b
a, b = identity(5, nil)
expect(a).to.equal(5)
expect(b).to.equal(nil)
a, b = identity(5)
expect(a).to.equal(5)
expect(b).to.equal(nil)
expect(callCount).to.equal(1)
a, b = identity(7)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(callCount).to.equal(2)
a, b = identity(5)
expect(a).to.equal(5)
expect(b).to.equal(nil)
expect(callCount).to.equal(3)
end)
it("should handle leading nils", function()
local callCount = 0
local identity = memoize(function(a, b)
callCount = callCount + 1
return a, b
end)
local a, b
a, b = identity(nil, 7)
expect(a).to.equal(nil)
expect(b).to.equal(7)
a, b = identity(nil, 7)
expect(a).to.equal(nil)
expect(b).to.equal(7)
expect(callCount).to.equal(1)
a, b = identity(7)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(callCount).to.equal(2)
a, b = identity(nil, 7)
expect(a).to.equal(nil)
expect(b).to.equal(7)
expect(callCount).to.equal(3)
end)
it("should handle interspersed nils", function()
local callCount = 0
local identity = memoize(function(a, b, c, d)
callCount = callCount + 1
return a, b, c, d
end)
local a, b, c, d
a, b, c, d = identity(7, nil, 7, nil)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(c).to.equal(7)
expect(d).to.equal(nil)
-- Trailing nils can affect how interspersed nils are handled
a, b, c, d = identity(7, nil, 7)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(c).to.equal(7)
expect(d).to.equal(nil)
expect(callCount).to.equal(1)
a, b, c, d = identity(7, nil, nil, nil)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(c).to.equal(nil)
expect(d).to.equal(nil)
expect(callCount).to.equal(2)
a, b, c, d = identity(7, nil, 7, nil)
expect(a).to.equal(7)
expect(b).to.equal(nil)
expect(c).to.equal(7)
expect(d).to.equal(nil)
expect(callCount).to.equal(3)
end)
end)
end
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(user)
return {
user = user
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(users)
return {
users = users
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId)
return {
userId = userId
}
end)
@@ -0,0 +1,9 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId, response)
return {
userId = userId,
response = response
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId)
return {
userId = userId
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(placesInfos)
return {
placesInfos = placesInfos,
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId)
return {
userId = userId,
}
end)
@@ -0,0 +1,8 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(deviceOrientation)
return {
deviceOrientation = deviceOrientation,
}
end)
@@ -0,0 +1,10 @@
local CorePackages = game:GetService("CorePackages")
local Common = CorePackages.AppTempCommon.Common
local Action = require(Common.Action)
return Action(script.Name, function(count)
return {
count = count,
}
end)
@@ -0,0 +1,26 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
--[[
Passes a table that looks like this : { "universeId" : {json}, ... }
{
"26034470" : {
universeId : "26034470",
placeId : "70542190",
url : https://t5.rbxcdn.com/ed422c6fbb22280971cfb289f40ac814,
final : true
}, {...}, ...
}
]]
--TODO MOBLUAPP-778 Refactor improper Setter Actions.
return Action(script.Name, function(thumbnailsTable)
assert(type(thumbnailsTable) == "table",
string.format("SetGameThumbnails action expects thumbnailsTable to be a table, was %s", type(thumbnailsTable)))
return {
thumbnails = thumbnailsTable
}
end)
@@ -0,0 +1,11 @@
local CorePackages = game:GetService("CorePackages")
local Common = CorePackages.AppTempCommon.Common
local Action = require(Common.Action)
return Action(script.Name, function(userId, universeId)
return {
userId = userId,
universeId = universeId,
}
end)
@@ -0,0 +1,9 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId, isFriend)
return {
userId = userId,
isFriend = isFriend,
}
end)
@@ -0,0 +1,11 @@
local CorePackages = game:GetService("CorePackages")
local Common = CorePackages.AppTempCommon.Common
local Action = require(Common.Action)
return Action(script.Name, function(userId, membershipType)
return {
userId = userId,
membershipType = membershipType,
}
end)
@@ -0,0 +1,10 @@
local CorePackages = game:GetService("CorePackages")
local Action = require(CorePackages.AppTempCommon.Common.Action)
return Action(script.Name, function(userId, presence, lastLocation)
return {
userId = tostring(userId),
presence = presence,
lastLocation = lastLocation,
}
end)
@@ -0,0 +1,13 @@
local CorePackages = game:GetService("CorePackages")
local Common = CorePackages.AppTempCommon.Common
local Action = require(Common.Action)
return Action(script.Name, function(userId, image, thumbnailType, thumbnailSize)
return {
userId = userId,
image = image,
thumbnailType = thumbnailType,
thumbnailSize = thumbnailSize,
}
end)
@@ -0,0 +1,51 @@
local Workspace = game:GetService("Workspace")
local RunService = game:GetService('RunService')
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local BAR_SLICE_CENTER = Rect.new(1, 0, 2, 3)
local BAR_MAX_SIZE = 15
local BAR_MAX_AMPLITUDE = 40
local BAR_DIAMETER = 4
local BAR_PERIOD = 1.25
local LoadingBar = Roact.Component:extend("LoadingBar")
function LoadingBar:init()
self.barRef = Roact.createRef()
end
function LoadingBar:render()
local zIndex = self.props.ZIndex
return Roact.createElement("ImageLabel", {
Image = "rbxasset://textures/ui/LuaApp/9-slice/gr-loading-indicator.png",
ScaleType = "Slice",
SliceCenter = BAR_SLICE_CENTER,
BackgroundTransparency = 1,
BorderSizePixel = 0,
ZIndex = zIndex,
[Roact.Ref] = self.barRef,
})
end
function LoadingBar:didMount()
self.connection = RunService.RenderStepped:Connect(function()
local t = Workspace.DistributedGameTime
local instance = self.barRef.current
local period = 2.0 * math.pi / BAR_PERIOD
local width = (BAR_MAX_SIZE/2) * (1 - math.cos(2*t*period))
instance.Size = UDim2.new(0, BAR_DIAMETER + width, 0, BAR_DIAMETER)
local x = BAR_MAX_AMPLITUDE * math.cos(t*period)
instance.Position = UDim2.new(0.5, x - width/2 - BAR_DIAMETER/2, 0.5, 0)
end)
end
function LoadingBar:willUnmount()
self.connection:Disconnect()
end
return LoadingBar
@@ -0,0 +1,17 @@
return function()
local LoadingBar = require(script.Parent.LoadingBar)
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
it("should create and destroy without errors", function()
local element = Roact.createElement(LoadingBar, {
Position = UDim2.new(0.5, 0, 0.5, 5),
AnchorPoint = Vector2.new(0.5, 0.5),
})
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,21 @@
local RetrievalStatus = {}
local EnumValues =
{
NotStarted = "NotStarted",
Fetching = "Fetching",
Done = "Done",
Failed = "Failed",
}
setmetatable(RetrievalStatus,
{
__newindex = function(t, key, index)
end,
__index = function(t, index)
assert(EnumValues[index] ~= nil, ("RetrievalStatus Enum has no value: " .. tostring(index)))
return EnumValues[index]
end
})
return RetrievalStatus
@@ -0,0 +1,10 @@
local CorePackages = game:GetService("CorePackages")
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
return {
[0] = User.PresenceType.OFFLINE,
[1] = User.PresenceType.ONLINE,
[2] = User.PresenceType.IN_GAME,
[3] = User.PresenceType.IN_STUDIO,
}
@@ -0,0 +1,15 @@
local CorePackages = game:GetService("CorePackages")
local Players = game:GetService("Players")
local ThrottleUserId = require(CorePackages.AppTempCommon.LuaApp.Utils.ThrottleUserId)
local FIntLuaHomePageEnablePlacesListV1 = settings():GetFVariable("LuaHomePageEnablePlacesListV1V361")
-- Don't call this function globally because we cannot get the userId
-- Reason: The LocalPlayer wouldn't be ready if we called it globally.
return function()
local throttleNumber = tonumber(FIntLuaHomePageEnablePlacesListV1)
local userId = Players.LocalPlayer.UserId
return ThrottleUserId(throttleNumber, userId)
end
@@ -0,0 +1,11 @@
local CorePackages = game:GetService("CorePackages")
local FFlagPlacesListV1 = require(CorePackages.AppTempCommon.LuaApp.Flags.IsPlacesListV1Enabled)
local FFlagLuaHomePeopleListV1V361 = settings():GetFFlag("LuaHomePeopleListV1V361")
return function()
-- When we should fetch games data could change in the future
-- Right now, we should fetch games data when people list on or place list on
return FFlagLuaHomePeopleListV1V361 or FFlagPlacesListV1()
end
@@ -0,0 +1,15 @@
local CorePackages = game:GetService("CorePackages")
local HttpService = game:GetService("HttpService")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
return function(requestImpl, conversationId, messageText)
local payload = HttpService:JSONEncode({
conversationId = conversationId,
message = messageText,
})
local url = string.format("%s/send-message", Url.CHAT_URL)
return requestImpl(url, "POST", { postBody = payload })
end
@@ -0,0 +1,14 @@
local CorePackages = game:GetService("CorePackages")
local HttpService = game:GetService("HttpService")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
return function(requestImpl, userId, clientId)
local payload = HttpService:JSONEncode({
participantuserId = userId
})
local url = string.format("%s/start-one-to-one-conversation", Url.CHAT_URL)
return requestImpl(url, "POST", { postBody = payload })
end
@@ -0,0 +1,48 @@
local CorePackages = game:GetService("CorePackages")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
--[[
This endpoint returns a promise that resolves to:
[
{
"final": true,
"url": "string",
"retryToken": "string",
"universeId": 0,
"placeId": 0
}, {...}, ...
]
]]
-- requestImpl - (function<promise<HttpResponse>>(url, requestMethod, options))
-- imageTokens - (array<long>) the placeIds of the places you want to get thumbnails for
-- height - (int) the height of the asset to render
-- width - (int) the width of the asset to render
return function(requestImpl, imageTokens, height, width)
local args = {}
if height then
table.insert(args, string.format("height=%d", height))
end
if width then
table.insert(args, string.format("width=%d", width))
end
-- append all of the thumbnail tokens
local totalTokens = 0
for _, value in pairs(imageTokens) do
totalTokens = totalTokens + 1
table.insert(args, string.format("imageTokens=%s", value))
end
if totalTokens == 0 then
error("cannot fetch thumbnails without tokens")
end
-- construct the url
local url = string.format("%sv1/games/game-thumbnails?%s", Url.GAME_URL, table.concat(args, "&"))
-- return a promise of the result listed above
return requestImpl(url, "GET")
end
@@ -0,0 +1,33 @@
local CorePackages = game:GetService("CorePackages")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
--[[
This endpoint returns games' information with a batches of place ids
Doc: https://games.roblox.com/docs#!/Games/get_v1_games_multiget_place_details
{
"placeId": 0,
"name": "string",
"description": "string",
"url": "string",
"builder": "string",
"builderId": 0,
"isPlayable": true,
"reasonProhibited": "string",
"universeId": 0,
"universeRootPlaceId": 0,
"price": 0,
"imageToken": "string"
}
]]--
return function(requestImpl, placeIds)
local argTable = {
placeIds = placeIds,
}
local args = Url:makeQueryString(argTable)
local url = string.format("%s/v1/games/multiget-place-details?%s", Url.GAME_URL, args)
return requestImpl(url, "GET")
end
@@ -0,0 +1,17 @@
local CorePackages = game:GetService("CorePackages")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
return function(requestImpl, placeIds)
local argTable = {
placeIds = placeIds,
}
-- construct the url
local args = Url:makeQueryString(argTable)
local url = string.format("%s/v1/games/multiget-place-details?%s",
Url.GAME_URL, args
)
return requestImpl(url, "GET")
end
@@ -0,0 +1,30 @@
local CorePackages = game:GetService("CorePackages")
local Players = game:GetService("Players")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
--[[
This endpoint returns a promise that resolves to:
[
{
"success:" true,
"count": "0"
},
]
]]--
-- requestImpl - (function<promise<HttpResponse>>(url, requestMethod, options))
return function(requestImpl)
local argTable = {
userId = Players.LocalPlayer.UserId,
}
local args = Url:makeQueryString(argTable)
local url = string.format("%s/user/get-friendship-count?%s",
Url.API_URL, tostring(Players.LocalPlayer.UserId), args
)
return requestImpl(url, "GET")
end
@@ -0,0 +1,10 @@
local CorePackages = game:GetService("CorePackages")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
return function(requestImpl, userId)
local url = string.format("%s/users/%s/friends",
Url.FRIEND_URL, userId
)
return requestImpl(url, "GET")
end
@@ -0,0 +1,25 @@
local CorePackages = game:GetService("CorePackages")
local HttpService = game:GetService("HttpService")
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
-- Endpoint documented here:
-- https://presence.roblox.com/docs
return function(requestImpl, userIds)
local userIdsToNumber = {}
for _, id in pairs(userIds) do
local idToNumber = tonumber(id)
if idToNumber then
table.insert(userIdsToNumber, idToNumber)
end
end
local payload = HttpService:JSONEncode({
userIds = userIdsToNumber,
})
local url = string.format("%s/presence/users", Url.PRESENCE_URL)
return requestImpl(url, "POST", { postBody = payload })
end
@@ -0,0 +1,48 @@
local CorePackages = game:GetService("CorePackages")
local Players = game:GetService("Players")
local Promise = require(CorePackages.AppTempCommon.LuaApp.Promise)
local THUMBNAIL_TYPE_BY_NAME = {
AvatarThumbnail = Enum.ThumbnailType.AvatarThumbnail,
HeadShot = Enum.ThumbnailType.HeadShot,
}
local THUMBNAIL_SIZE_BY_NAME = {
Size48x48 = Enum.ThumbnailSize.Size48x48,
Size60x60 = Enum.ThumbnailSize.Size60x60,
Size100x100 = Enum.ThumbnailSize.Size100x100,
Size150x150 = Enum.ThumbnailSize.Size150x150,
Size352x352 = Enum.ThumbnailSize.Size352x352
}
return function(userId, thumbnailType, thumbnailSize)
return Promise.new(function(resolve, reject)
--Async methods will yield the thread
spawn(function()
local result = {success = false}
local success, message = pcall(function()
local image, isFinal = Players:GetUserThumbnailAsync(
userId, THUMBNAIL_TYPE_BY_NAME[thumbnailType], THUMBNAIL_SIZE_BY_NAME[thumbnailSize]
)
result = {
success = true,
id = userId,
thumbnailType = thumbnailType,
thumbnailSize = thumbnailSize,
image = isFinal and image or nil,
isFinal = isFinal,
}
end)
if success then
resolve(result)
else
result.message = message
reject(result)
end
end)
end)
end
@@ -0,0 +1,114 @@
--[[
Url Constructor
Provides a single location for base urls.
]]--
local ContentProvider = game:GetService("ContentProvider")
-- helper functions
local function parseBaseUrlInformation()
-- get the current base url from the current configuration
local baseUrl = ContentProvider.BaseUrl
-- keep a copy of the base url (https://www.roblox.com/)
-- append a trailing slash if there isn't one
if baseUrl:sub(#baseUrl) ~= "/" then
baseUrl = baseUrl .. "/"
end
-- parse out scheme (http, https)
local _, schemeEnd = baseUrl:find("://")
-- parse out the prefix (www, kyle, ying, etc.)
local prefixIndex, prefixEnd = baseUrl:find("%.", schemeEnd + 1)
local basePrefix = baseUrl:sub(schemeEnd + 1, prefixIndex - 1)
-- parse out the domain (roblox.com/, sitetest1.robloxlabs.com/, etc.)
local baseDomain = baseUrl:sub(prefixEnd + 1)
return baseUrl, basePrefix, baseDomain
end
local function preventTableModification(aTable, key, value)
error("Attempt to modify read-only table")
end
local function createReadOnlyTable(aTable)
return setmetatable({}, {
__index = aTable,
__newindex = preventTableModification,
__metatable = false
});
end
-- url construction building blocks
local _baseUrl, _basePrefix, _baseDomain = parseBaseUrlInformation()
-- construct urls once
local _baseApiUrl = string.format("https://api.%s", _baseDomain)
local _baseAuthUrl = string.format("https://auth.%s", _baseDomain)
local _baseChatUrl = string.format("https://chat.%sv2", _baseDomain)
local _baseFriendUrl = string.format("https://friends.%sv1", _baseDomain)
local _baseGameAssetUrl = string.format("https://assetgame.%s", _baseDomain)
local _baseGamesUrl = string.format("https://games.%s", _baseDomain)
local _baseNotificationUrl = string.format("https://notifications.%s", _baseDomain)
local _basePresenceUrl = string.format("https://presence.%sv1", _baseDomain)
local _baseRealtimeUrl = string.format("https://realtime.%s", _baseDomain)
local _baseWebUrl = string.format("https://web.%s", _baseDomain)
local _baseWwwUrl = string.format("https://www.%s", _baseDomain)
local _baseAdsUrl = string.format("https://ads.%s", _baseDomain)
-- public api
local Url = {
DOMAIN = _baseDomain,
PREFIX = _basePrefix,
BASE_URL = _baseUrl,
API_URL = _baseApiUrl,
AUTH_URL = _baseAuthUrl,
GAME_URL = _baseGamesUrl,
GAME_ASSET_URL = _baseGameAssetUrl,
CHAT_URL = _baseChatUrl,
FRIEND_URL = _baseFriendUrl,
PRESENCE_URL = _basePresenceUrl,
NOTIFICATION_URL = _baseNotificationUrl,
REALTIME_URL = _baseRealtimeUrl,
WEB_URL = _baseWebUrl,
WWW_URL = _baseWwwUrl,
ADS_URL = _baseAdsUrl,
}
function Url:getUserProfileUrl(userId)
return string.format("%susers/%s/profile", self.BASE_URL, userId)
end
function Url:isVanitySite()
return self.PREFIX ~= "www"
end
-- data - (table<string, string>) a table of key/value pairs to format
function Url:makeQueryString(data)
--NOTE - This function can be used to create a query string of parameters
-- at the end of url query, or create a application/form-url-encoded post body string
local params = {}
-- NOTE - Arrays are handled, but generally data is expected to be flat.
for key, value in pairs(data) do
if value ~= nil then --for optional params
if type(value) == "table" then
for i = 1, #value do
table.insert(params, key .. "=" .. value[i])
end
else
table.insert(params, key .. "=" .. tostring(value))
end
end
end
return table.concat(params, "&")
end
-- prevent anyone from modifying this table:
Url = createReadOnlyTable(Url)
return Url
@@ -0,0 +1,12 @@
return function()
local ContentProvider = game:GetService("ContentProvider")
local Url = require(script.Parent.Url)
it("The base url has not been changed for debugging", function()
local baseUrl = ContentProvider.BaseUrl
expect(baseUrl).to.equal(Url.BASE_URL)
end)
end
@@ -0,0 +1,10 @@
--[[
A function to return a fake ID, used for testing
]]
local lastId = 0
return function()
lastId = lastId + 1
return ("MOCK-%d"):format(lastId)
end
@@ -0,0 +1,18 @@
local ThumbnailRequest = {}
function ThumbnailRequest.new()
local self = {}
return self
end
function ThumbnailRequest.fromData(thumbnailType, thumbnailSize)
local self = ThumbnailRequest.new()
self.thumbnailType = thumbnailType
self.thumbnailSize = thumbnailSize
return self
end
return ThumbnailRequest
@@ -0,0 +1,117 @@
local CorePackages = game:GetService("CorePackages")
local Players = game:GetService("Players")
local MockId = require(CorePackages.AppTempCommon.LuaApp.MockId)
local FFlagFixUsersReducerDataLoss = settings():GetFFlag("FixUsersReducerDataLoss")
local User = {}
User.PresenceType = {
OFFLINE = "OFFLINE",
ONLINE = "ONLINE",
IN_GAME = "IN_GAME",
IN_STUDIO = "IN_STUDIO",
}
function User.new()
local self = {}
return self
end
function User.mock()
local self = User.new()
self.id = MockId()
self.isFetching = false
self.isFriend = false
self.lastLocation = nil
self.name = "USER NAME"
self.universeId = nil
self.placeId = nil
self.rootPlaceId = nil
self.gameInstanceId = nil
self.lastOnline = 0
self.presence = User.PresenceType.OFFLINE
self.membership = nil
if not FFlagFixUsersReducerDataLoss then
self.thumbnails = {}
end
return self
end
function User.fromData(id, name, isFriend)
local self = User.new()
self.id = tostring(id)
self.isFetching = false
self.isFriend = isFriend
self.lastLocation = nil
self.name = name
self.universeId = nil
self.placeId = nil
self.rootPlaceId = nil
self.gameInstanceId = nil
self.lastOnline = 0
if FFlagFixUsersReducerDataLoss then
self.presence = (self.id == tostring(Players.LocalPlayer.UserId)) and User.PresenceType.ONLINE or nil
else
self.presence = (self.id == tostring(Players.LocalPlayer.UserId)) and User.PresenceType.ONLINE or
User.PresenceType.OFFLINE
self.thumbnails = {}
end
return self
end
function User.compare(user1, user2)
assert(not(user1 == nil and user2 == nil))
assert(user1 == nil or typeof(user1) == "table")
assert(user2 == nil or typeof(user2) == "table")
-- Return false if any of the provided input is nil(empty).
if not user1 or not user2 then
return false
end
for field, valueInUser2 in pairs(user2) do
if user1[field] ~= valueInUser2 then
return false
end
end
for field, valueInUser1 in pairs(user1) do
if user2[field] ~= valueInUser1 then
return false
end
end
return true
end
function User.userPresenceToText(localization, user)
local presence = user.presence
local lastLocation = user.lastLocation
if not presence then
return ''
end
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) or (presence == User.PresenceType.IN_STUDIO) then
if lastLocation ~= nil then
return lastLocation
else
return localization:Format("Common.Presence.Label.Online")
end
end
end
return User
@@ -0,0 +1,68 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Immutable = require(CorePackages.AppTempCommon.Common.Immutable)
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
it("should detect if provided users are identical", function()
local clone1 = User.fromData(1, "Andy", true)
local clone2 = Immutable.Set(clone1, "isFriend", true)
local result = User.compare(clone1, clone2)
expect(result).to.equal(true)
result = User.compare(clone2, clone1)
expect(result).to.equal(true)
end)
it("should detect when there is one or more fields with different values", function()
local andy = User.fromData(1, "Andy", true)
local ollie = Immutable.Set(andy, "name", "Ollie")
local result = User.compare(andy, ollie)
expect(result).to.equal(false)
result = User.compare(ollie, andy)
expect(result).to.equal(false)
end)
it("should detect descrepancy when one user model contains more fields than the other", function()
local andy = User.fromData(1, "Andy", true)
local secretlyNotAndy = Immutable.Set(andy, "someDifferentField", "I'm Ollie!")
local result = User.compare(andy, secretlyNotAndy)
expect(result).to.equal(false)
result = User.compare(secretlyNotAndy, andy)
expect(result).to.equal(false)
end)
it("should throw if invalid input is provided", function()
local aString = "I'm not a table."
local teddy = User.fromData(1, "Teddy", true)
expect(function() User.compare(nil, nil) end).to.throw()
expect(function() User.compare(aString, nil) end).to.throw()
expect(function() User.compare(nil, aString) end).to.throw()
expect(function() User.compare(aString, aString) end).to.throw()
expect(function() User.compare(teddy, aString) end).to.throw()
expect(function() User.compare(aString, teddy) end).to.throw()
end)
it("should return false if any one of the input is empty or nil)", function()
local emptyTable = {}
local teddy = User.fromData(1, "Teddy", true)
local result = User.compare(teddy, nil)
expect(result).to.equal(false)
result = User.compare(nil, teddy)
expect(result).to.equal(false)
result = User.compare(teddy, emptyTable)
expect(result).to.equal(false)
result = User.compare(emptyTable, teddy)
expect(result).to.equal(false)
end)
end
@@ -0,0 +1,51 @@
local percentReporting = tonumber(settings():GetFVariable("PercentReportingNetworkProfileAfterStartup"))
local FEATURE_NAME = "NetworkProfileDuringStartup"
local QUEUED_MEASURE_NAME = "Queued"
local NAME_LOOKUP_MEASURE_NAME = "NameLookup"
local CONNECT_MEASURE_NAME = "Connect"
local SSL_HANDSHAKE_MEASURE_NAME = "SSLHandshake"
local MAKE_REQUEST_MEASURE_NAME = "MakeRequest"
local RECEIVE_RESPONSE_MEASURE_NAME = "ReceiveResponse"
local NetworkProfiler = {}
NetworkProfiler.__index = NetworkProfiler
NetworkProfiler.aggregate = {
queued = 0.0,
nameLookup = 0.0,
connect = 0.0,
sslHandshake = 0.0,
makeRequest = 0.0,
receiveResponse = 0.0,
}
function NetworkProfiler:track(timeProfile)
self.aggregate.queued = self.aggregate.queued + timeProfile.queued
if timeProfile.nameLookup >= 0 then
self.aggregate.nameLookup = self.aggregate.nameLookup + timeProfile.nameLookup
end
if timeProfile.connect >= 0 then
self.aggregate.connect = self.aggregate.connect + timeProfile.connect
end
if timeProfile.sslHandshake >= 0 then
self.aggregate.sslHandshake = self.aggregate.sslHandshake + timeProfile.sslHandshake
end
if timeProfile.makeRequest >= 0 then
self.aggregate.makeRequest = self.aggregate.makeRequest + timeProfile.makeRequest
end
if timeProfile.receiveResponse >= 0 then
self.aggregate.receiveResponse = self.aggregate.receiveResponse + timeProfile.receiveResponse
end
end
function NetworkProfiler:report(reportToDiag)
reportToDiag(FEATURE_NAME, QUEUED_MEASURE_NAME, self.aggregate.queued, percentReporting)
reportToDiag(FEATURE_NAME, NAME_LOOKUP_MEASURE_NAME, self.aggregate.nameLookup, percentReporting)
reportToDiag(FEATURE_NAME, CONNECT_MEASURE_NAME, self.aggregate.connect, percentReporting)
reportToDiag(FEATURE_NAME, SSL_HANDSHAKE_MEASURE_NAME, self.aggregate.sslHandshake, percentReporting)
reportToDiag(FEATURE_NAME, MAKE_REQUEST_MEASURE_NAME, self.aggregate.makeRequest, percentReporting)
reportToDiag(FEATURE_NAME, RECEIVE_RESPONSE_MEASURE_NAME, self.aggregate.receiveResponse, percentReporting)
end
return NetworkProfiler
@@ -0,0 +1,343 @@
--[[
An implementation of Promises similar to Promise/A+.
]]
local TU = require(script.Parent.TableUtilities)
local PROMISE_DEBUG = true
-- If promise debugging is on, use a version of pcall that warns on failure.
-- This is useful for finding errors that happen within Promise itself.
local wpcall
if PROMISE_DEBUG then
wpcall = function(f, ...)
local result = { pcall(f, ...) }
if not result[1] then
warn(result[2])
end
return unpack(result)
end
else
wpcall = pcall
end
--[[
Creates a function that invokes a callback with correct error handling and
resolution mechanisms.
]]
local function createAdvancer(callback, resolve, reject)
return function(...)
local result = { wpcall(callback, ...) }
local ok = table.remove(result, 1)
if ok then
resolve(unpack(result))
else
reject(unpack(result))
end
end
end
local function isEmpty(t)
return next(t) == nil
end
local Promise = {}
Promise.__index = Promise
Promise.Status = {
Started = "Started",
Resolved = "Resolved",
Rejected = "Rejected",
}
--[[
Constructs a new Promise with the given initializing callback.
This is generally only called when directly wrapping a non-promise API into
a promise-based version.
The callback will receive 'resolve' and 'reject' methods, used to start
invoking the promise chain.
For example:
local function get(url)
return Promise.new(function(resolve, reject)
spawn(function()
resolve(HttpService:GetAsync(url))
end)
end)
end
get("https://google.com")
:andThen(function(stuff)
print("Got some stuff!", stuff)
end)
]]
function Promise.new(callback)
local promise = {
-- Used to locate where a promise was created
_source = debug.traceback(),
-- A tag to identify us as a promise
_type = "Promise",
_status = Promise.Status.Started,
-- A table containing a list of all results, whether success or failure.
-- Only valid if _status is set to something besides Started
_value = nil,
-- Queues representing functions we should invoke when we update!
_queuedResolve = {},
_queuedReject = {},
}
setmetatable(promise, Promise)
local function resolve(...)
promise:_resolve(...)
end
local function reject(...)
promise:_reject(...)
end
local ok, err = wpcall(callback, resolve, reject)
if not ok and promise._status == Promise.Status.Started then
reject(err)
end
return promise
end
--[[
Create a promise that represents the immediately resolved value.
]]
function Promise.resolve(value)
return Promise.new(function(resolve)
resolve(value)
end)
end
--[[
Create a promise that represents the immediately rejected value.
]]
function Promise.reject(value)
return Promise.new(function(_, reject)
reject(value)
end)
end
--[[
Returns a new promise that:
* is resolved when all input promises resolve
* is rejected if ANY input promises reject
]]
function Promise.all(...)
local promises = {...}
-- check if we've been given a list of promises, not just a variable number of promises
if type(promises[1]) == "table" and promises[1]._type ~= "Promise" then
-- we've been given a table of promises already
promises = promises[1]
end
return Promise.new(function(resolve, reject)
local isResolved = false
local results = {}
local totalCompleted = 0
local function promiseCompleted(index, result)
if isResolved then
return
end
results[index] = result
totalCompleted = totalCompleted + 1
if totalCompleted == #promises then
resolve(results)
isResolved = true
end
end
if #promises == 0 then
resolve(results)
isResolved = true
return
end
for index, promise in ipairs(promises) do
-- if a promise isn't resolved yet, add listeners for when it does
if promise._status == Promise.Status.Started then
promise:andThen(function(result)
promiseCompleted(index, result)
end):catch(function(reason)
isResolved = true
reject(reason)
end)
-- if a promise is already resolved, move on
elseif promise._status == Promise.Status.Resolved then
promiseCompleted(index, unpack(promise._value))
-- if a promise is rejected, reject the whole chain
else --if promise._status == Promise.Status.Rejected then
isResolved = true
reject(unpack(promise._value))
end
end
end)
end
--[[
Is the given object a Promise instance?
]]
function Promise.is(object)
if type(object) ~= "table" then
return false
end
return object._type == "Promise"
end
--[[
Creates a new promise that receives the result of this promise.
The given callbacks are invoked depending on that result.
]]
function Promise:andThen(successHandler, failureHandler)
-- Create a new promise to follow this part of the chain
return Promise.new(function(resolve, reject)
-- Our default callbacks just pass values onto the next promise.
-- This lets success and failure cascade correctly!
local successCallback = resolve
if successHandler then
successCallback = createAdvancer(successHandler, resolve, reject)
end
local failureCallback = reject
if failureHandler then
failureCallback = createAdvancer(failureHandler, resolve, reject)
end
if self._status == Promise.Status.Started then
-- If we haven't resolved yet, put ourselves into the queue
table.insert(self._queuedResolve, successCallback)
table.insert(self._queuedReject, failureCallback)
elseif self._status == Promise.Status.Resolved then
-- This promise has already resolved! Trigger success immediately.
successCallback(unpack(self._value))
elseif self._status == Promise.Status.Rejected then
-- This promise died a terrible death! Trigger failure immediately.
failureCallback(unpack(self._value))
end
end)
end
--[[
Used to catch any errors that may have occurred in the promise.
]]
function Promise:catch(failureCallback)
return self:andThen(nil, failureCallback)
end
--[[
Yield until the promise is completed.
This matches the execution model of normal Roblox functions.
]]
function Promise:await()
if self._status == Promise.Status.Started then
local result
local bindable = Instance.new("BindableEvent")
self:andThen(function(...)
result = {...}
bindable:Fire(true)
end, function(...)
result = {...}
bindable:Fire(false)
end)
local ok = bindable.Event:Wait()
bindable:Destroy()
if not ok then
error(tostring(result[1]), 2)
end
return unpack(result)
elseif self._status == Promise.Status.Resolved then
return unpack(self._value)
elseif self._status == Promise.Status.Rejected then
error(tostring(self._value[1]), 2)
end
end
function Promise:_resolve(...)
if self._status ~= Promise.Status.Started then
return
end
-- If the resolved value was a Promise, we chain onto it!
if Promise.is((...)) then
-- Without this warning, arguments sometimes mysteriously disappear
if select("#", ...) > 1 then
local message = ("When returning a Promise from andThen, extra arguments are discarded! See:\n\n%s"):format(
self._source
)
warn(message)
end
(...):andThen(function(...)
self:_resolve(...)
end, function(...)
self:_reject(...)
end)
return
end
self._status = Promise.Status.Resolved
self._value = {...}
-- We assume that these callbacks will not throw errors.
for _, callback in ipairs(self._queuedResolve) do
callback(...)
end
end
function Promise:_reject(...)
if self._status ~= Promise.Status.Started then
return
end
self._status = Promise.Status.Rejected
self._value = {...}
-- If there are any rejection handlers, call those!
if not isEmpty(self._queuedReject) then
-- We assume that these callbacks will not throw errors.
for _, callback in ipairs(self._queuedReject) do
callback(...)
end
else
-- At this point, no one was able to observe the error.
-- An error handler might still be attached if the error occurred
-- synchronously. We'll wait one tick, and if there are still no
-- observers, then we should put a message in the console.
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
TU.RecursiveToString((...)),
self._source
)
warn(message)
end
end
return Promise
@@ -0,0 +1,331 @@
return function()
local Promise = require(script.Parent.Promise)
describe("Promise.new", function()
it("should instantiate with a callback", function()
local promise = Promise.new(function() end)
expect(promise).to.be.ok()
end)
it("should invoke the given callback with resolve and reject", function()
local callCount = 0
local resolveArg
local rejectArg
local promise = Promise.new(function(resolve, reject)
callCount = callCount + 1
resolveArg = resolve
rejectArg = reject
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(resolveArg).to.be.a("function")
expect(rejectArg).to.be.a("function")
expect(promise._status).to.equal(Promise.Status.Started)
end)
it("should resolve promises on resolve()", function()
local callCount = 0
local promise = Promise.new(function(resolve)
callCount = callCount + 1
resolve()
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Resolved)
end)
it("should reject promises on reject()", function()
local callCount = 0
local promise = Promise.new(function(resolve, reject)
callCount = callCount + 1
reject()
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Rejected)
end)
it("should reject on error in callback", function()
local callCount = 0
local promise = Promise.new(function()
callCount = callCount + 1
error("hahah")
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]:find("hahah")).to.be.ok()
end)
end)
describe("Promise.resolve", function()
it("should immediately resolve with a value", function()
local promise = Promise.resolve(5)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
expect(promise._value[1]).to.equal(5)
end)
it("should chain onto passed promises", function()
local promise = Promise.resolve(Promise.new(function(_, reject)
reject(7)
end))
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]).to.equal(7)
end)
end)
describe("Promise.reject", function()
it("should immediately reject with a value", function()
local promise = Promise.reject(6)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]).to.equal(6)
end)
it("should pass a promise as-is as an error", function()
local innerPromise = Promise.new(function(resolve)
resolve(6)
end)
local promise = Promise.reject(innerPromise)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]).to.equal(innerPromise)
end)
end)
describe("Promise:andThen", function()
it("should chain onto resolved promises", function()
local args
local argsLength
local callCount = 0
local badCallCount = 0
local promise = Promise.resolve(5)
local chained = promise
:andThen(function(...)
args = {...}
argsLength = select("#", ...)
callCount = callCount + 1
end, function()
badCallCount = badCallCount + 1
end)
expect(badCallCount).to.equal(0)
expect(callCount).to.equal(1)
expect(argsLength).to.equal(1)
expect(args[1]).to.equal(5)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
expect(promise._value[1]).to.equal(5)
expect(chained).to.be.ok()
expect(chained).never.to.equal(promise)
expect(chained._status).to.equal(Promise.Status.Resolved)
expect(#chained._value).to.equal(0)
end)
it("should chain onto rejected promises", function()
local args
local argsLength
local callCount = 0
local badCallCount = 0
local promise = Promise.reject(5)
local chained = promise
:andThen(function(...)
badCallCount = badCallCount + 1
end, function(...)
args = {...}
argsLength = select("#", ...)
callCount = callCount + 1
end)
expect(badCallCount).to.equal(0)
expect(callCount).to.equal(1)
expect(argsLength).to.equal(1)
expect(args[1]).to.equal(5)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]).to.equal(5)
expect(chained).to.be.ok()
expect(chained).never.to.equal(promise)
expect(chained._status).to.equal(Promise.Status.Resolved)
expect(#chained._value).to.equal(0)
end)
it("should chain onto asynchronously resolved promises", function()
local args
local argsLength
local callCount = 0
local badCallCount = 0
local startResolution
local promise = Promise.new(function(resolve)
startResolution = resolve
end)
local chained = promise
:andThen(function(...)
args = {...}
argsLength = select("#", ...)
callCount = callCount + 1
end, function()
badCallCount = badCallCount + 1
end)
expect(callCount).to.equal(0)
expect(badCallCount).to.equal(0)
startResolution(6)
expect(badCallCount).to.equal(0)
expect(callCount).to.equal(1)
expect(argsLength).to.equal(1)
expect(args[1]).to.equal(6)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
expect(promise._value[1]).to.equal(6)
expect(chained).to.be.ok()
expect(chained).never.to.equal(promise)
expect(chained._status).to.equal(Promise.Status.Resolved)
expect(#chained._value).to.equal(0)
end)
it("should chain onto asynchronously rejected promises", function()
local args
local argsLength
local callCount = 0
local badCallCount = 0
local startResolution
local promise = Promise.new(function(_, reject)
startResolution = reject
end)
local chained = promise
:andThen(function()
badCallCount = badCallCount + 1
end, function(...)
args = {...}
argsLength = select("#", ...)
callCount = callCount + 1
end)
expect(callCount).to.equal(0)
expect(badCallCount).to.equal(0)
startResolution(6)
expect(badCallCount).to.equal(0)
expect(callCount).to.equal(1)
expect(argsLength).to.equal(1)
expect(args[1]).to.equal(6)
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]).to.equal(6)
expect(chained).to.be.ok()
expect(chained).never.to.equal(promise)
expect(chained._status).to.equal(Promise.Status.Resolved)
expect(#chained._value).to.equal(0)
end)
end)
describe("Promise.all", function()
it("should resolve immediately with an empty table argument", function()
local promise = Promise.all({})
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
local result = unpack(promise._value)
expect(type(result)).to.equal("table")
expect(#result).to.equal(0)
end)
it("should resolve immediately with no arguments", function()
local promise = Promise.all()
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
local result = unpack(promise._value)
expect(type(result)).to.equal("table")
expect(#result).to.equal(0)
end)
it("should resolve with one result with one resolved argument", function()
local promise = Promise.all(Promise.resolve(7))
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
local result = unpack(promise._value)
expect(type(result)).to.equal("table")
expect(#result).to.equal(1)
expect(result[1]).to.equal(7)
end)
it("should resolve with multiple resolved arguments", function()
local promise = Promise.all(Promise.resolve(7), Promise.resolve(4))
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Resolved)
local result = unpack(promise._value)
expect(type(result)).to.equal("table")
expect(#result).to.equal(2)
expect(result[1]).to.equal(7)
expect(result[2]).to.equal(4)
end)
it("should reject with one rejected argument", function()
local promise = Promise.all(Promise.reject(5))
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
local result = unpack(promise._value)
expect(result).to.equal(5)
end)
it("should reject with one success followed by one rejected argument", function()
local promise = Promise.all(Promise.resolve(7), Promise.reject(4))
expect(promise).to.be.ok()
expect(promise._status).to.equal(Promise.Status.Rejected)
local result = unpack(promise._value)
expect(result).to.equal(4)
end)
end)
end
@@ -0,0 +1,43 @@
local CorePackages = game:GetService("CorePackages")
local Immutable = require(CorePackages.AppTempCommon.Common.Immutable)
local RetrievalStatus = require(CorePackages.AppTempCommon.LuaApp.Enum.RetrievalStatus)
local FetchUserFriendsStarted = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsStarted)
local FetchUserFriendsFailed = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsFailed)
local FetchUserFriendsCompleted = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsCompleted)
local function setFieldPerUser(state, fieldName, userId, value)
local field = state[fieldName] or {}
return Immutable.JoinDictionaries(state, {
[fieldName] = Immutable.JoinDictionaries(field, {
[userId] = value
})
})
end
local function setRetrievalStatus(state, userId, status)
return setFieldPerUser(state, "retrievalStatus", userId, status)
end
local function setRetrievalFailureResponse(state, userId, response)
return setFieldPerUser(state, "retrievalFailureResponse", userId, response)
end
return function(state, action)
state = state or {
retrievalStatus = {},
retrievalFailureResponse = {},
}
if action.type == FetchUserFriendsStarted.name then
state = setRetrievalStatus(state, action.userId, RetrievalStatus.Fetching)
elseif action.type == FetchUserFriendsFailed.name then
state = setRetrievalStatus(state, action.userId, RetrievalStatus.Failed)
state = setRetrievalFailureResponse(state, action.userId, action.response)
elseif action.type == FetchUserFriendsCompleted.name then
state = setRetrievalStatus(state, action.userId, RetrievalStatus.Done)
end
return state
end
@@ -0,0 +1,16 @@
local CorePackages = game:GetService("CorePackages")
local Immutable = require(CorePackages.AppTempCommon.Common.Immutable)
local ReceivedPlacesInfos = require(CorePackages.AppTempCommon.LuaApp.Actions.ReceivedPlacesInfos)
return function(state, action)
state = state or {}
if action.type == ReceivedPlacesInfos.name then
for _, placeInfo in pairs(action.placesInfos) do
state = Immutable.Set(state, tostring(placeInfo.universeId), placeInfo)
end
end
return state
end
@@ -0,0 +1,117 @@
local CorePackages = game:GetService("CorePackages")
local Immutable = require(CorePackages.AppTempCommon.Common.Immutable)
local AddUser = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUser)
local AddUsers = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUsers)
local ReceivedUserPresence = require(CorePackages.AppTempCommon.LuaChat.Actions.ReceivedUserPresence)
local RemoveUser = require(CorePackages.AppTempCommon.LuaApp.Actions.RemoveUser)
local SetUserIsFriend = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserIsFriend)
local SetUserMembershipType = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserMembershipType)
local SetUserPresence = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserPresence)
local SetUserThumbnail = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserThumbnail)
local FFlagFixUsersReducerDataLoss = settings():GetFFlag("FixUsersReducerDataLoss")
return function(state, action)
state = state or {}
if action.type == AddUser.name then
local user = action.user
state = Immutable.Set(state, user.id, user)
elseif action.type == AddUsers.name then
if FFlagFixUsersReducerDataLoss then
local addedUsers = action.users
local usersUpdate = {}
for userId, addedUser in pairs(addedUsers) do
local existingUser = state[userId]
if existingUser then
usersUpdate[userId] = Immutable.JoinDictionaries(existingUser, addedUser)
else
usersUpdate[userId] = addedUser
end
end
state = Immutable.JoinDictionaries(state, usersUpdate)
else
local users = action.users
state = Immutable.JoinDictionaries(state, users)
end
elseif action.type == SetUserIsFriend.name then
local user = state[action.userId]
if user then
local newUser = Immutable.Set(user, "isFriend", action.isFriend)
state = Immutable.Set(state, user.id, newUser)
else
warn("Setting isFriend on user", action.userId, "who doesn't exist yet")
end
elseif action.type == SetUserPresence.name then
local user = state[action.userId]
if user then
local newUser = Immutable.JoinDictionaries(user, {
presence = action.presence,
lastLocation = action.lastLocation,
})
state = Immutable.Set(state, user.id, newUser)
else
warn("Setting presence on user", action.userId, "who doesn't exist yet")
end
elseif action.type == ReceivedUserPresence.name then
local user = state[action.userId]
if user then
state = Immutable.JoinDictionaries(state, {
[action.userId] = Immutable.JoinDictionaries(user, {
presence = action.presence,
lastLocation = action.lastLocation,
placeId = action.placeId,
rootPlaceId = action.rootPlaceId,
gameInstanceId = action.gameInstanceId,
lastOnline = action.lastOnline,
universeId = action.universeId,
}),
})
end
elseif action.type == SetUserThumbnail.name then
local user = state[action.userId]
if user then
if FFlagFixUsersReducerDataLoss then
local thumbnails = user.thumbnails or {}
state = Immutable.JoinDictionaries(state, {
[action.userId] = Immutable.JoinDictionaries(user, {
thumbnails = Immutable.JoinDictionaries(thumbnails, {
[action.thumbnailType] = Immutable.JoinDictionaries(thumbnails[action.thumbnailType] or {}, {
[action.thumbnailSize] = action.image,
}),
}),
}),
})
else
state = Immutable.JoinDictionaries(state, {
[action.userId] = Immutable.JoinDictionaries(user, {
thumbnails = Immutable.JoinDictionaries(user.thumbnails, {
[action.thumbnailType] = Immutable.JoinDictionaries(user.thumbnails[action.thumbnailType] or {}, {
[action.thumbnailSize] = action.image,
}),
}),
}),
})
end
end
elseif action.type == SetUserMembershipType.name then
local user = state[action.userId]
if user then
state = Immutable.JoinDictionaries(state, {
[action.userId] = Immutable.JoinDictionaries(user, {
membership = action.membershipType,
}),
})
end
elseif action.type == RemoveUser.name then
if state[action.userId] then
state = Immutable.RemoveFromDictionary(state, action.userId)
end
end
return state
end
@@ -0,0 +1,106 @@
return function()
local CorePackages = game:GetService("CorePackages")
local MockId = require(CorePackages.AppTempCommon.LuaApp.MockId)
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
local Users = require(CorePackages.AppTempCommon.LuaApp.Reducers.Users)
local AddUser = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUser)
local ReceivedUserPresence = require(CorePackages.AppTempCommon.LuaChat.Actions.ReceivedUserPresence)
local SetUserIsFriend = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserIsFriend)
local SetUserMembershipType = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserMembershipType)
local SetUserPresence = require(CorePackages.AppTempCommon.LuaApp.Actions.SetUserPresence)
describe("initial state", function()
it("should return an initial table when passed nil", function()
local state = Users(nil, {})
expect(state).to.be.a("table")
end)
end)
describe("AddUser", function()
it("should add a user to the store", function()
local user = User.mock()
local state = {}
state = Users(state, AddUser(user))
expect(state[user.id]).to.equal(user)
end)
end)
describe("SetUserIsFriend", function()
it("should set isFriend on an existing user", function()
local user = User.mock()
local state = {
[user.id] = user
}
expect(state[user.id].isFriend).to.equal(false)
state = Users(state, SetUserIsFriend(user.id, true))
expect(state[user.id].isFriend).to.equal(true)
state = Users(state, SetUserIsFriend(user.id, false))
expect(state[user.id].isFriend).to.equal(false)
end)
end)
describe("SetUserPresence", function()
it("should set presence on an existing user", function()
local user = User.mock()
local state = {
[user.id] = user
}
expect(state[user.id].presence).to.equal(User.PresenceType.OFFLINE)
state = Users(state, SetUserPresence(user.id, User.PresenceType.ONLINE))
expect(state[user.id].presence).to.equal(User.PresenceType.ONLINE)
state = Users(state, SetUserPresence(user.id, User.PresenceType.IN_GAME))
expect(state[user.id].presence).to.equal(User.PresenceType.IN_GAME)
state = Users(state, SetUserPresence(user.id, User.PresenceType.IN_STUDIO))
expect(state[user.id].presence).to.equal(User.PresenceType.IN_STUDIO)
end)
end)
describe("ReceivedUserPresence", function()
it("should set presence on an existing user", function()
local user = User.mock()
local state = {
[user.id] = user
}
local existingPresence = user.presence
local newPresence = 'ONLINE'
local lastLocation = MockId()
local newPlaceId = MockId()
state = Users(state, ReceivedUserPresence(user.id, newPresence, lastLocation, newPlaceId))
expect(user.presence).to.equal(existingPresence)
expect(state[user.id].presence).to.equal(newPresence)
expect(state[user.id].lastLocation).to.equal(lastLocation)
expect(state[user.id].placeId).to.equal(newPlaceId)
end)
end)
describe("SetUserMembershipType", function()
it("should set membership on an existing user", function()
local user = User.mock()
local state = {
[user.id] = user
}
local existingMembership = user.membership
local newMembership = Enum.MembershipType.BuildersClub
state = Users(state, SetUserMembershipType(user.id, newMembership))
expect(user.membership).to.equal(existingMembership)
expect(state[user.id].membership).to.equal(newMembership)
end)
end)
end
@@ -0,0 +1,100 @@
local Result = {}
Result.__index = Result
local ResultTypeSymbol = newproxy(true)
function Result.new(status, value)
assert(typeof(status) == "boolean")
local result = {
-- Used to locate where a result was created
_source = debug.traceback(),
-- A tag to identify us as a result
[ResultTypeSymbol] = true,
_status = status,
-- The value success value or error message.
_value = value,
}
setmetatable(result, Result)
return result
end
function Result.success(value)
return Result.new(true, value)
end
function Result.error(value)
return Result.new(false, value)
end
--[[
Is the given object a Result instance?
]]
function Result.is(object)
if typeof(object) ~= "table" then
return false
end
return object[ResultTypeSymbol]
end
--[[
The given callbacks are invoked depending on that result.
Creates a new result that receives the output of the callback.
]]
function Result:match(successHandler, errorHandler)
assert(typeof(successHandler) == "function" or typeof(successHandler) == "nil",
string.format("Result:match expects successHandler to be a function or nil, got %s", typeof(successHandler)))
assert(typeof(errorHandler) == "function" or typeof(errorHandler) == "nil",
string.format("Result:match expects errorHandler to be a function or nil, got %s", typeof(errorHandler)))
local newResult
if self._status then
if successHandler ~= nil then
newResult = successHandler(self._value)
else
return self
end
else
if errorHandler ~= nil then
newResult = errorHandler(self._value)
else
return self
end
end
if Result.is(newResult) then
return newResult
else
return Result.success(newResult)
end
end
--[[
The given callback is invoked if the result is success.
Creates a new result that receives the output of the callback.
]]
function Result:matchSuccess(successHandler)
return self:match(successHandler, nil)
end
--[[
The given callback is invoked if the result is error.
Creates a new result that receives the output of the callback.
]]
function Result:matchError(errorHandler)
return self:match(nil, errorHandler)
end
function Result:unwrap()
return self._status, self._value
end
return Result
@@ -0,0 +1,217 @@
return function()
local Result = require(script.Parent.Result)
describe("Constructors", function()
it("should return a success result from Result.new with a success value", function()
local success, value = Result.new(true, "foo"):unwrap()
expect(success).to.equal(true)
expect(value).to.equal("foo")
end)
it("should return an error result from Result.new with a failure value", function()
local success, value = Result.new(false, "foo"):unwrap()
expect(success).to.equal(false)
expect(value).to.equal("foo")
end)
it("should return a success result from Result.success", function()
local success, value = Result.success("foo"):unwrap()
expect(success).to.equal(true)
expect(value).to.equal("foo")
end)
it("should return an error result from Result.error", function()
local success, value = Result.error("foo"):unwrap()
expect(success).to.equal(false)
expect(value).to.equal("foo")
end)
end)
describe("Result:match", function()
it("should call the first callback with the value if it's a success result", function()
local called = false
Result.success("foo"):match(
function(value)
called = true
expect(value).to.equal("foo")
end,
function(value)
assert(false)
end
)
expect(called).to.equal(true)
end)
it("should call the second callback with the error if it's an error result", function()
local called = false
Result.error("foo"):match(
function(value)
assert(false)
end,
function(value)
expect(value).to.equal("foo")
called = true
end
)
expect(called).to.equal(true)
end)
it("should return the result of the first callback if it's a result", function()
Result.success("foo"):match(
function()
return Result.success("bar")
end,
nil
):match(
function(value)
expect(value).to.equal("bar")
end,
nil
)
end)
it("should return the result of the second callback if it's a result", function()
Result.error("foo"):match(
nil,
function()
return Result.success("bar")
end
):match(
function(value)
expect(value).to.equal("bar")
end,
nil
)
end)
it("should return self if it's success and the first callback isn't provided", function()
local result1 = Result.success("foo")
local result2 = result1:match(nil, nil)
expect(result1).to.equal(result2)
end)
it("should return self if it's error and the second callback isn't provided", function()
local result1 = Result.error("foo")
local result2 = result1:match(nil, nil)
expect(result1).to.equal(result2)
end)
it("should return success result wrapping value returned by first callback if not a result", function()
Result.success("foo"):match(
function()
return "bar"
end,
nil
):match(
function(value)
expect(value).to.equal("bar")
end,
nil
)
end)
it("should return success result wrapping value returned by second callback if not a result", function()
Result.error("foo"):match(
nil,
function()
return "bar"
end
):match(
function(value)
expect(value).to.equal("bar")
end,
nil
)
end)
end)
describe("Result:matchSuccess", function()
it("should call the callback with the value if it's a success result", function()
local called = false
Result.success("foo"):matchSuccess(
function(value)
expect(value).to.equal("foo")
called = true
end
)
expect(called).to.equal(true)
end)
it("should not call the callback if it's an error result", function()
Result.error("foo"):matchSuccess(
function(value)
assert(false)
end
)
end)
it("should return the result of the callback if it's a result", function()
Result.success("foo"):matchSuccess(
function()
return Result.success("bar")
end
):matchSuccess(
function(value)
expect(value).to.equal("bar")
end
)
end)
it("should return success result wrapping value returned by callback if not a result", function()
Result.success("foo"):matchSuccess(
function()
return "bar"
end
):matchSuccess(
function(value)
expect(value).to.equal("bar")
end
)
end)
end)
describe("Result:matchError", function()
it("should call the callback with the value if it's an error result", function()
local called = false
Result.error("foo"):matchError(
function(value)
expect(value).to.equal("foo")
called = true
end
)
expect(called).to.equal(true)
end)
it("should not call the callback if it's a success result", function()
Result.success("foo"):matchError(
function(value)
assert(false)
end
)
end)
it("should return the result of the callback if it's a result", function()
Result.error("foo"):matchError(
function()
return Result.success("bar")
end
):matchSuccess(
function(value)
expect(value).to.equal("bar")
end
)
end)
it("should return success result wrapping value returned by callback if not a result", function()
Result.error("foo"):matchError(
function()
return "bar"
end
):matchSuccess(
function(value)
expect(value).to.equal("bar")
end
)
end)
end)
end
@@ -0,0 +1,184 @@
--[[
Provides functions for comparing and printing lua tables.
]]
local TableUtilities = {}
local defaultIgnore = {}
--[[
Takes two tables A and B, returns if they have the same key-value pairs
Except ignored keys
]]
function TableUtilities.ShallowEqual(A, B, ignore)
if not A or not B then
return false
elseif A == B then
return true
end
if not ignore then
ignore = defaultIgnore
end
for key, value in pairs(A) do
if B[key] ~= value and not ignore[key] then
return false
end
end
for key, value in pairs(B) do
if A[key] ~= value and not ignore[key] then
return false
end
end
return true
end
--[[
Takes two tables A, B and a key, returns if two tables have the same value at key
]]
function TableUtilities.EqualKey(A, B, key)
if A and B and key and key ~= "" and A[key] and B[key] and A[key] == B[key] then
return true
end
return false
end
--[[
Takes two tables A and B, returns a new table with elements of A
which are either not keys in B or have a different value in B
]]
function TableUtilities.TableDifference(A, B)
local new = {}
for key, value in pairs(A) do
if B[key] ~= A[key] then
new[key] = value
end
end
return new
end
--[[
Takes a list and returns a table whose
keys are elements of the list and whose
values are all true
]]
local function membershipTable(list)
local result = {}
for i = 1, #list do
result[list[i]] = true
end
return result
end
--[[
Takes a table and returns a list of keys in that table
]]
local function listOfKeys(t)
local result = {}
for key,_ in pairs(t) do
table.insert(result, key)
end
return result
end
--[[
Takes two lists A and B, returns a new list of elements of A
which are not in B
]]
function TableUtilities.ListDifference(A, B)
return listOfKeys(TableUtilities.TableDifference(membershipTable(A), membershipTable(B)))
end
--[[
For debugging. Returns false if the given table has any of the following:
- a key that is neither a number or a string
- a mix of number and string keys
- number keys which are not exactly 1..#t
]]
function TableUtilities.CheckListConsistency(t)
local containsNumberKey = false
local containsStringKey = false
local numberConsistency = true
local index = 1
for x, _ in pairs(t) do
if type(x) == 'string' then
containsStringKey = true
elseif type(x) == 'number' then
if index ~= x then
numberConsistency = false
end
containsNumberKey = true
else
return false
end
if containsStringKey and containsNumberKey then
return false
end
index = index + 1
end
if containsNumberKey then
return numberConsistency
end
return true
end
--[[
For debugging, serializes the given table to a reasonable string that might even interpret as lua.
]]
function TableUtilities.RecursiveToString(t, indent)
indent = indent or ''
if type(t) == 'table' then
local result = ""
if not TableUtilities.CheckListConsistency(t) then
result = result .. "-- WARNING: this table fails the list consistency test\n"
end
result = result .. "{\n"
for k,v in pairs(t) do
if type(k) == 'string' then
result = result
.. " "
.. indent
.. tostring(k)
.. " = "
.. TableUtilities.RecursiveToString(v, " "..indent)
..";\n"
end
if type(k) == 'number' then
result = result .. " " .. indent .. TableUtilities.RecursiveToString(v, " "..indent)..",\n"
end
end
result = result .. indent .. "}"
return result
else
return tostring(t)
end
end
--[[
Takes a table and returns the field count
]]
function TableUtilities.FieldCount(t)
local fieldCount = 0
for _ in pairs(t) do
fieldCount = fieldCount + 1
end
return fieldCount
end
return TableUtilities
@@ -0,0 +1,156 @@
return function()
local TableUtilities = require(script.Parent.TableUtilities)
it("should return whether tables are equal to each other", function()
local tableA = nil
local tableB = nil
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = nil
tableB = {}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = {}
tableB = nil
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = {}
tableB = {}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(true)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value1",
}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(true)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value2",
}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key2 = "value1",
}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key2 = "value2",
}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value1",
key2 = "value2",
}
expect(TableUtilities.ShallowEqual(tableA, tableB)).to.equal(false)
end)
it("should return whether tables are equal to each other at key", function()
local tableA = nil
local tableB = nil
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = nil
tableB = {}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {}
tableB = nil
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {}
tableB = {}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value1",
}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(true)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value2",
}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key2 = "value1",
}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key2 = "value2",
}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(false)
tableA = {
key1 = "value1",
}
tableB = {
key1 = "value1",
key2 = "value2",
}
expect(TableUtilities.EqualKey(tableA, tableB)).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "")).to.equal(false)
expect(TableUtilities.EqualKey(tableA, tableB, "key1")).to.equal(true)
expect(TableUtilities.EqualKey(tableA, tableB, "key2")).to.equal(false)
end)
it("should return table's field count", function()
local table = {}
expect(TableUtilities.FieldCount(table)).to.equal(0)
table = {
key1 = "value1",
}
expect(TableUtilities.FieldCount(table)).to.equal(1)
table = {
key1 = "value1",
key2 = "value2",
}
expect(TableUtilities.FieldCount(table)).to.equal(2)
end)
end
@@ -0,0 +1,91 @@
local CorePackages = game:GetService("CorePackages")
local LuaApp = CorePackages.AppTempCommon.LuaApp
local GamesGetThumbnails = require(LuaApp.Http.Requests.GamesGetThumbnails)
local SetGameThumbnails = require(LuaApp.Actions.SetGameThumbnails)
local Functional = require(CorePackages.AppTempCommon.Common.Functional)
local Promise = require(LuaApp.Promise)
local Result = require(LuaApp.Result)
local TableUtilities = require(LuaApp.TableUtilities)
local THUMBNAIL_PAGE_COUNT = 20
local THUMBNAIL_SIZE = 150
local RETRY_MAX_COUNT = math.max(0, settings():GetFVariable("LuaAppNonFinalThumbnailMaxRetries"))
local RETRY_TIME_MULTIPLIER = 2 -- seconds
local function convertToId(value)
if type(value) ~= "number" and type(value) ~= "string" then
return Result.error("convertToId expects value passed in to be a number or a string")
end
return Result.success(tostring(value))
end
local function subdivideThumbnailTokenArray(thumbnailTokens, tokenLimit)
local someTokens = {}
for i = 1, #thumbnailTokens, tokenLimit do
local subArray = Functional.Take(thumbnailTokens, tokenLimit, i)
table.insert(someTokens, subArray)
end
return someTokens
end
local function fetchThumbnailBatch(networkImpl, store, thumbnailTokens)
return GamesGetThumbnails(networkImpl, thumbnailTokens, THUMBNAIL_SIZE, THUMBNAIL_SIZE):andThen(function(result)
local thumbnails = {}
local unfinalizedThumbnails = {}
for _,image in pairs(result.responseBody) do
local convertToIdResult = convertToId(image.universeId)
convertToIdResult:match(function(universeId)
if image.final == false then
unfinalizedThumbnails[universeId] = image.retryToken
else
-- index all of the thumbnails by universeId
thumbnails[universeId] = image
end
end, function(convertToIdError)
warn(convertToIdError)
end)
end
store:dispatch(SetGameThumbnails(thumbnails))
return Promise.resolve(unfinalizedThumbnails)
end)
end
local function fetchSubdividedThumbnailsArray(networkImpl, store, thumbnailTokens)
return fetchThumbnailBatch(networkImpl, store, thumbnailTokens):andThen(function(unfinalizedThumbnails)
local remainingUnfinalizedThumbnails = unfinalizedThumbnails
for retryCount = 1, RETRY_MAX_COUNT do
if TableUtilities.FieldCount(remainingUnfinalizedThumbnails) == 0 then
return -- Bail out, we're done!
end
wait(RETRY_TIME_MULTIPLIER * math.pow(2, retryCount - 1))
remainingUnfinalizedThumbnails = fetchThumbnailBatch(networkImpl, store, remainingUnfinalizedThumbnails):await()
end
end)
end
local function fetchThumbnails(networkImpl, thumbnailTokens)
return function(store)
-- NOTE : because the size of each thumbnail token, me must limit the number we can fetch at a time.
-- So break apart the array of tokens we get into smaller, more manageable pieces.
local fetchPromises = {}
local someTokens = subdivideThumbnailTokenArray(thumbnailTokens, THUMBNAIL_PAGE_COUNT)
for _, thumbsArr in ipairs(someTokens) do
local promise = fetchSubdividedThumbnailsArray(networkImpl, store, thumbsArr)
table.insert(fetchPromises, promise)
end
return Promise.all(fetchPromises)
end
end
return fetchThumbnails
@@ -0,0 +1,40 @@
local CorePackages = game:GetService("CorePackages")
local ApiFetchGameThumbnails = require(CorePackages.AppTempCommon.LuaApp.Thunks.ApiFetchGameThumbnails)
local Functional = require(CorePackages.AppTempCommon.Common.Functional)
local GamesMultigetPlaceDetails = require(CorePackages.AppTempCommon.LuaApp.Http.Requests.GamesMultigetPlaceDetails)
local PlaceInfoModel = require(CorePackages.AppTempCommon.LuaChat.Models.PlaceInfoModel)
local ReceivedPlacesInfos = require(CorePackages.AppTempCommon.LuaApp.Actions.ReceivedPlacesInfos)
return function(networkImpl, placeIds)
return function(store)
if not placeIds or #placeIds == 0 then
return
end
return GamesMultigetPlaceDetails(networkImpl, placeIds):andThen(function(result)
local data = result.responseBody
local imageTokens = {}
local placeInfos = Functional.Map(data, function(placeInfoData)
local placeInfo = PlaceInfoModel.fromWeb(placeInfoData)
local universePlaceInfos = store:getState().UniversePlaceInfos
local universeId = placeInfo.universeRootPlaceId
if not universePlaceInfos[universeId] or placeInfo.imageToken ~= universePlaceInfos[universeId].imageToken then
table.insert(imageTokens, placeInfo.imageToken)
end
return placeInfo
end)
store:dispatch(ReceivedPlacesInfos(placeInfos))
if #imageTokens > 0 then
store:dispatch(ApiFetchGameThumbnails(networkImpl, imageTokens))
end
return placeInfos
end)
end
end
@@ -0,0 +1,24 @@
local CorePackages = game:GetService("CorePackages")
local Functional = require(CorePackages.AppTempCommon.Common.Functional)
local GetPlaceInfos = require(CorePackages.AppTempCommon.LuaApp.Http.Requests.GetPlaceInfos)
-- LuaChat
local PlaceInfoModel = require(CorePackages.AppTempCommon.LuaChat.Models.PlaceInfoModel)
local ReceivedMultiplePlaceInfos = require(CorePackages.AppTempCommon.LuaChat.Actions.ReceivedMultiplePlaceInfos)
return function(networkImpl, placeIds)
return function(store)
return GetPlaceInfos(networkImpl, placeIds):andThen(function(result)
local data = result.responseBody
local placeInfos = Functional.Map(data, function(placeInfoData)
return PlaceInfoModel.fromWeb(placeInfoData)
end)
store:dispatch(ReceivedMultiplePlaceInfos(placeInfos))
return placeInfos
end)
end
end
@@ -0,0 +1,21 @@
local CorePackages = game:GetService("CorePackages")
local Actions = CorePackages.AppTempCommon.LuaApp.Actions
local Requests = CorePackages.AppTempCommon.LuaApp.Http.Requests
local UsersGetFriendCount = require(Requests.UsersGetFriendCount)
local SetFriendCount = require(Actions.SetFriendCount)
return function(networkImpl)
return function(store)
return UsersGetFriendCount(networkImpl):andThen(function(result)
local data = result.responseBody
if data.success and data.count then
store:dispatch(SetFriendCount(data.count))
end
return data.count
end)
end
end
@@ -0,0 +1,63 @@
local CorePackages = game:GetService("CorePackages")
local Requests = CorePackages.AppTempCommon.LuaApp.Http.Requests
local Promise = require(CorePackages.AppTempCommon.LuaApp.Promise)
local ApiFetchUsersPresences = require(CorePackages.AppTempCommon.LuaApp.Thunks.ApiFetchUsersPresences)
local ApiFetchUsersThumbnail = require(CorePackages.AppTempCommon.LuaApp.Thunks.ApiFetchUsersThumbnail)
local ApiFetchUsersFriendCount = require(CorePackages.AppTempCommon.LuaApp.Thunks.ApiFetchUsersFriendCount)
local UsersGetFriends = require(Requests.UsersGetFriends)
local AddUsers = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUsers)
local FetchUserFriendsStarted = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsStarted)
local FetchUserFriendsFailed = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsFailed)
local FetchUserFriendsCompleted = require(CorePackages.AppTempCommon.LuaApp.Actions.FetchUserFriendsCompleted)
local UserModel = require(CorePackages.AppTempCommon.LuaApp.Models.User)
local UpdateUsers = require(CorePackages.AppTempCommon.LuaApp.Thunks.UpdateUsers)
local LuaAppRemoveGetFriendshipCountApiCalls = settings():GetFFlag("LuaAppRemoveGetFriendshipCountApiCalls")
local homePageDataFetchRefactor = settings():GetFFlag('LuaHomePageDataFetchRefactor')
return function(requestImpl, userId, thumbnailRequest)
return function(store)
store:dispatch(FetchUserFriendsStarted(userId))
if not LuaAppRemoveGetFriendshipCountApiCalls then
store:dispatch(ApiFetchUsersFriendCount(requestImpl))
end
return UsersGetFriends(requestImpl, userId):andThen(function(response)
local responseBody = response.responseBody
local userIds = {}
local newUsers = {}
for _, userData in pairs(responseBody.data) do
local id = tostring(userData.id)
local newUser = UserModel.fromData(id, userData.name, true)
table.insert(userIds, id)
newUsers[newUser.id] = newUser
end
if LuaAppRemoveGetFriendshipCountApiCalls then
store:dispatch(UpdateUsers(newUsers))
else
store:dispatch(AddUsers(newUsers))
end
return userIds
end):andThen(function(userIds)
-- Asynchronously fetch friend thumbnails so we don't block display of UI
store:dispatch(ApiFetchUsersThumbnail(requestImpl, userIds, thumbnailRequest))
return store:dispatch(ApiFetchUsersPresences(requestImpl, userIds))
end):andThen(
function(result)
store:dispatch(FetchUserFriendsCompleted(userId))
if homePageDataFetchRefactor then
return Promise.resolve(result)
end
end,
function(response)
store:dispatch(FetchUserFriendsFailed(userId, response))
if homePageDataFetchRefactor then
return Promise.reject(response)
end
end
)
end
end
@@ -0,0 +1,26 @@
local CorePackages = game:GetService("CorePackages")
local Utils = CorePackages.AppTempCommon.LuaChat.Utils
local getPlaceIds = require(Utils.getFriendsActiveGamesPlaceIdsFromUsersPresence)
local receiveUsersPresence = require(Utils.receiveUsersPresence)
local ApiFetchGamesDataByPlaceIds = require(CorePackages.AppTempCommon.LuaApp.Thunks.ApiFetchGamesDataByPlaceIds)
local UsersGetPresence = require(CorePackages.AppTempCommon.LuaApp.Http.Requests.UsersGetPresence)
local FFlagLuaHomeGetFriendsPlayingGamesInfo = settings():GetFFlag("LuaHomeGetFriendsPlayingGamesInfo")
return function(networkImpl, userIds)
return function(store)
return UsersGetPresence(networkImpl, userIds):andThen(function(result)
local userPresences = result.responseBody.userPresences
receiveUsersPresence(userPresences, store)
if FFlagLuaHomeGetFriendsPlayingGamesInfo then
local placeIds = getPlaceIds(userPresences, store)
store:dispatch(ApiFetchGamesDataByPlaceIds(networkImpl, placeIds))
end
end)
end
end
@@ -0,0 +1,40 @@
local CorePackages = game:GetService("CorePackages")
local Actions = CorePackages.AppTempCommon.LuaApp.Actions
local Requests = CorePackages.AppTempCommon.LuaApp.Http.Requests
local UsersGetThumbnail = require(Requests.UsersGetThumbnail)
local SetUserThumbnail = require(Actions.SetUserThumbnail)
local Promise = require(CorePackages.AppTempCommon.LuaApp.Promise)
local function fetchThumbnailsBatch(networkImpl, userIds, thumbnailRequest)
local fetchedPromises = {}
for _, userId in pairs(userIds) do
table.insert(fetchedPromises,
UsersGetThumbnail(userId, thumbnailRequest.thumbnailType, thumbnailRequest.thumbnailSize)
)
end
return Promise.all(fetchedPromises)
end
return function(networkImpl, userIds, thumbnailRequests)
return function(store)
-- We currently cannot batch request user avatar thumbnails,
-- so each thumbnailRequest has to be processed individually.
local fetchedPromises = {}
for _, thumbnailRequest in pairs(thumbnailRequests) do
table.insert(fetchedPromises,
fetchThumbnailsBatch(networkImpl, userIds, thumbnailRequest):andThen(function(result)
for _, data in pairs(result) do
store:dispatch(SetUserThumbnail(data.id, data.image, data.thumbnailType, data.thumbnailSize))
end
end)
)
end
return Promise.all(fetchedPromises)
end
end
@@ -0,0 +1,42 @@
local CorePackages = game:GetService("CorePackages")
local Players = game:GetService("Players")
local AppTempCommon = CorePackages.AppTempCommon
local Requests = CorePackages.AppTempCommon.LuaApp.Http.Requests
local ChatSendMessage = require(Requests.ChatSendMessage)
local ChatStartOneToOneConversation = require(Requests.ChatStartOneToOneConversation)
local Url = require(CorePackages.AppTempCommon.LuaApp.Http.Url)
local trimCharacterFromEndString = require(AppTempCommon.Temp.trimCharacterFromEndString)
local INVITE_TEXT_MESSAGE = "Come join me in %s"
local INVITE_LINK_MESSAGE = "%s/games/%s"
return function(networkImpl, userId, placeInfo)
local clientId = Players.LocalPlayer.UserId
-- Construct the invite messages based on place info
local inviteTextMessage = string.format(INVITE_TEXT_MESSAGE, placeInfo.name)
local trimmedUrl = trimCharacterFromEndString(Url.WWW_URL, "/")
local inviteLinkMessage = string.format(INVITE_LINK_MESSAGE, trimmedUrl, placeInfo.universeRootPlaceId)
return function(store)
return ChatStartOneToOneConversation(networkImpl, userId, clientId):andThen(function(conversationResult)
local conversation = conversationResult.responseBody.conversation
return ChatSendMessage(networkImpl, conversation.id, inviteTextMessage):andThen(function()
return ChatSendMessage(networkImpl, conversation.id, inviteLinkMessage):andThen(function(inviteResult)
local data = inviteResult.responseBody
return {
resultType = data.resultType,
conversationId = conversation.id,
placeId = placeInfo.universeRootPlaceId,
}
end)
end)
end)
end
end
@@ -0,0 +1,50 @@
local CorePackages = game:GetService("CorePackages")
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
local AddUsers = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUsers)
local SetFriendCount = require(CorePackages.AppTempCommon.LuaApp.Actions.SetFriendCount)
return function(users)
return function(store)
local friendCountOffset = 0
local updatedUsers = {}
for _, user in pairs(users) do
local needsUpdate = false
local userId = user.id
local isFriend = user.isFriend
local offset = 0
assert(typeof(isFriend) == "boolean")
local userInStore = store:getState().Users[userId]
if userInStore then
-- Mark user with needsUpdate if any of the field is different
-- from the existing user information in Store.
if not User.compare(userInStore, user) then
needsUpdate = true
if userInStore.isFriend ~= isFriend then
offset = isFriend and 1 or -1
end
end
else
needsUpdate = true
offset = isFriend and 1 or 0
end
if needsUpdate then
friendCountOffset = friendCountOffset + offset
updatedUsers[userId] = user
end
end
if next(updatedUsers) then
store:dispatch(AddUsers(updatedUsers))
end
if friendCountOffset ~= 0 then
local currentFriendCount = store:getState().FriendCount
store:dispatch(SetFriendCount(currentFriendCount + friendCountOffset))
end
end
end
@@ -0,0 +1,123 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Rodux = require(CorePackages.Rodux)
local Immutable = require(CorePackages.AppTempCommon.Common.Immutable)
local UpdateUsers = require(CorePackages.AppTempCommon.LuaApp.Thunks.UpdateUsers)
local AddUsers = require(CorePackages.AppTempCommon.LuaApp.Actions.AddUsers)
local SetFriendCount = require(CorePackages.AppTempCommon.LuaApp.Actions.SetFriendCount)
local FriendCount = require(CorePackages.AppTempCommon.LuaChat.Reducers.FriendCount)
local Users = require(CorePackages.AppTempCommon.LuaApp.Reducers.Users)
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
local function UsersReducerMonitor (state, action)
state = state or {
numberOfAddUsersCalled = 0,
numberOfUsersPassedIn = 0,
}
if action.type == AddUsers.name then
state.numberOfAddUsersCalled = state.numberOfAddUsersCalled + 1
state.numberOfUsersPassedIn = 0
for _, _ in pairs(action.users) do
state.numberOfUsersPassedIn = state.numberOfUsersPassedIn + 1
end
end
return state
end
local function FriendCountReducerMonitor (state, action)
state = state or {
numberOfSetFriendCountCalled = 0,
}
if action.type == SetFriendCount.name then
state.numberOfSetFriendCountCalled = state.numberOfSetFriendCountCalled + 1
end
return state
end
local function CustomReducer(state, action)
state = state or {}
return {
Users = Users(state.Users, action),
UsersReducerMonitor = UsersReducerMonitor(state.UsersReducerMonitor, action),
FriendCount = FriendCount(state.FriendCount, action),
FriendCountReducerMonitor = FriendCountReducerMonitor(state.FriendCountReducerMonitor, action),
}
end
local listOfUsers = {
["1"] = User.fromData(1, "Hedonism Bot", true),
["2"] = User.fromData(2, "Hypno Toad", true),
["3"] = User.fromData(3, "John Zoidberg", false),
["4"] = User.fromData(4, "Pazuzu", true),
["5"] = User.fromData(5, "Ogden Wernstrom", true),
["6"] = User.fromData(6, "Lrrr", true),
}
it("should do nothing if empty list of users is provided", function()
local store = Rodux.Store.new(CustomReducer, {}, {
Rodux.thunkMiddleware,
})
store:dispatch(UpdateUsers({ }))
local state = store:getState()
expect(state.UsersReducerMonitor.numberOfAddUsersCalled).to.equal(0)
expect(state.FriendCountReducerMonitor.numberOfSetFriendCountCalled).to.equal(0)
end)
it("should update only the number of users with modified data", function()
local store = Rodux.Store.new(CustomReducer, {
Users = listOfUsers,
}, {
Rodux.thunkMiddleware,
})
local currentUsers = store:getState().Users
local listOfUsersWithPotentialUpdates = {
Immutable.Set(currentUsers["2"], "presence", User.PresenceType.IN_GAME), -- changed
Immutable.Set(currentUsers["5"], "isFriend", false), -- changed
Immutable.Set(currentUsers["6"], "isFriend", true), -- did not change
}
store:dispatch(UpdateUsers(listOfUsersWithPotentialUpdates))
local state = store:getState()
expect(state.UsersReducerMonitor.numberOfAddUsersCalled).to.equal(1)
expect(state.UsersReducerMonitor.numberOfUsersPassedIn).to.equal(2)
end)
it("should correctly update the number of friends", function()
local store = Rodux.Store.new(CustomReducer, {}, {
Rodux.thunkMiddleware,
})
store:dispatch(UpdateUsers(listOfUsers))
local state = store:getState()
expect(state.FriendCountReducerMonitor.numberOfSetFriendCountCalled).to.equal(1)
expect(state.FriendCount).to.equal(5)
local currentUsers = store:getState().Users
local listOfUsersWithPotentialUpdates = {
Immutable.Set(currentUsers["2"], "presence", User.PresenceType.IN_GAME), -- friendship didn't change
Immutable.Set(currentUsers["5"], "isFriend", false), -- friendship changed
Immutable.Set(currentUsers["6"], "isFriend", false), -- friendship changed
User.fromData(7, "Nibbler", true), -- new friend
}
store:dispatch(UpdateUsers(listOfUsersWithPotentialUpdates))
state = store:getState()
expect(state.FriendCount).to.equal(4)
end)
end
@@ -0,0 +1,10 @@
-- Helper function to throttle based on player Id:
return function(throttle, userId)
assert(type(throttle) == "number")
assert(type(userId) == "number")
-- Determine userRollout using last two digits of user ID:
-- (+1 to change range from 0-99 to 1-100 as 0 is off, 100 is full on):
local userRollout = (userId % 100) + 1
return userRollout <= throttle
end
@@ -0,0 +1,67 @@
return function()
local ThrottleUserId = require(script.Parent.ThrottleUserId)
describe("ThrottleUserId", function()
it("should always reject zero%", function()
local gating = ThrottleUserId(0, 10000)
expect(gating).to.equal(false)
gating = ThrottleUserId(0, 10001)
expect(gating).to.equal(false)
gating = ThrottleUserId(0, 10025)
expect(gating).to.equal(false)
gating = ThrottleUserId(0, 10075)
expect(gating).to.equal(false)
gating = ThrottleUserId(0, 10099)
expect(gating).to.equal(false)
gating = ThrottleUserId(0, 10100)
expect(gating).to.equal(false)
end)
it("should always accept 100%", function()
local gating = ThrottleUserId(100, 10000)
expect(gating).to.equal(true)
gating = ThrottleUserId(100, 10001)
expect(gating).to.equal(true)
gating = ThrottleUserId(100, 10025)
expect(gating).to.equal(true)
gating = ThrottleUserId(100, 10075)
expect(gating).to.equal(true)
gating = ThrottleUserId(100, 10099)
expect(gating).to.equal(true)
gating = ThrottleUserId(100, 10100)
expect(gating).to.equal(true)
end)
it("should reject IDs over throttle percent", function()
local gating = ThrottleUserId(25, 10050)
expect(gating).to.equal(false)
gating = ThrottleUserId(50, 10075)
expect(gating).to.equal(false)
gating = ThrottleUserId(75, 10099)
expect(gating).to.equal(false)
end)
it("should accept IDs under throttle percent", function()
local gating = ThrottleUserId(1, 10100)
expect(gating).to.equal(true)
gating = ThrottleUserId(10, 10109)
expect(gating).to.equal(true)
gating = ThrottleUserId(25, 10023)
expect(gating).to.equal(true)
end)
end)
end
@@ -0,0 +1,9 @@
local Modules = game:GetService("CorePackages").AppTempCommon
local Common = Modules.Common
local Action = require(Common.Action)
return Action(script.Name, function(convo)
return {
conversation = convo,
}
end)
@@ -0,0 +1,10 @@
local Modules = game:GetService("CorePackages").AppTempCommon
local Common = Modules.Common
local Action = require(Common.Action)
return Action(script.Name, function(placeInfos)
return {
placeInfos = placeInfos,
}
end)
@@ -0,0 +1,52 @@
local Modules = game:GetService("CorePackages").AppTempCommon
local Action = require(Modules.Common.Action)
local DateTime = require(Modules.LuaChat.DateTime)
local luaChatUseNewFriendsAndPresenceEndpoint = settings():GetFFlag("LuaChatUseNewFriendsAndPresenceEndpointV356")
local luaChatPlayTogetherUseRootPresence = settings():GetFFlag("LuaChatPlayTogetherUseRootPresence")
local luaChatRootPresenceEnabled = luaChatUseNewFriendsAndPresenceEndpoint and luaChatPlayTogetherUseRootPresence
if luaChatRootPresenceEnabled then
return Action(script.Name, function(userId,
presence, lastLocation,
placeId, rootPlaceId,
gameInstanceId, lastOnlineISO, universeId, previousUniverseId)
local lastOnline = 0
if lastOnlineISO ~= nil then
local lastDateTime = DateTime.fromIsoDate(lastOnlineISO)
if lastDateTime ~= nil then
lastOnline = lastDateTime:GetUnixTimestamp()
end
end
return {
userId = userId,
presence = presence,
lastLocation = lastLocation,
placeId = placeId,
rootPlaceId = rootPlaceId,
gameInstanceId = gameInstanceId,
lastOnline = lastOnline,
universeId = universeId,
previousUniverseId = previousUniverseId,
}
end)
else
return Action(script.Name, function(userId,
presence, lastLocation,
placeId, gameInstanceId,
universeId, previousUniverseId)
return {
userId = userId,
presence = presence,
lastLocation = lastLocation,
placeId = placeId,
gameInstanceId = gameInstanceId,
universeId = universeId,
previousUniverseId = previousUniverseId,
}
end)
end
@@ -0,0 +1,487 @@
--[[
This is a Lua implementation of the DateTime API proposal. It'll eventually
be implemented in C++ and merged into the rest of the codebase if this model
of working with dates ends up being useful.
]]
local TimeZone = require(script.Parent.TimeZone)
local TimeUnit = require(script.Parent.TimeUnit)
local DateTime = {}
local monthShortNames = {
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec"
}
local monthLongNames = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
}
local dayShortNames = {
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
}
local dayLongNames = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
}
--[[
We structure tokens like this to preserve order, since Lua associative
arrays have no inherent order.
]]
local tokens = {
{"YYYY", function(values)
return tostring(values.Year)
end},
{"MMMM", function(values)
return monthLongNames[values.Month]
end},
{"MMM", function(values)
return monthShortNames[values.Month]
end},
{"MM", function(values)
return ("%02d"):format(values.Month)
end},
{"M", function(values)
return tostring(values.Month)
end},
{"DDDD", function(values)
return dayLongNames[values.WeekDay]
end},
{"DDD", function(values)
return dayShortNames[values.WeekDay]
end},
{"DD", function(values)
return ("%02d"):format(values.Day)
end},
{"D", function(values)
return tostring(values.Day)
end},
{"HH", function(values)
local hour = values.Hour
return ("%02d"):format(hour)
end},
{"H", function(values)
local hour = values.Hour
return tostring(hour)
end},
{"hh", function(values)
local hour = values.Hour % 12
if hour == 0 then
hour = 12
end
return ("%02d"):format(hour)
end},
{"h", function(values)
local hour = values.Hour % 12
if hour == 0 then
hour = 12
end
return tostring(hour)
end},
{"mm", function(values)
return ("%02d"):format(values.Minute)
end},
{"m", function(values)
return tostring(values.Minute)
end},
{"ss", function(values)
return ("%02d"):format(values.Seconds)
end},
{"s", function(values)
return tostring(values.Seconds)
end},
{"A", function(values)
return values.Hour >= 12 and "PM" or "AM"
end},
{"a", function(values)
return values.Hour >= 12 and "pm" or "am"
end}
}
local tokenKeys = {}
for _, pair in ipairs(tokens) do
table.insert(tokenKeys, pair[1])
end
local tokenMap = {}
for _, pair in ipairs(tokens) do
tokenMap[pair[1]] = pair[2]
end
--[[
What's the next token in this source?
]]
local function getToken(source, i)
local char = source:sub(i, i)
for _, token in ipairs(tokenKeys) do
-- Only keep checking if the first character matches the token
if token:sub(1, 1) == char then
local match = source:sub(i, i + token:len() - 1)
if match == token then
return token
end
end
end
end
--[[
An estimate of the current time zone's offset from UTC in seconds.
This might fail for weird timezones (UTC +/- 14), but we can fix that by
picking a reference time that's further away from the Unix epoch.
]]
local function getTimeZoneOffset()
local actualEpoch = 86400 + 43200
local epoch = os.time({year = 1970, month = 1, day = 2, isdst = -1})
if epoch then
return actualEpoch - epoch
else
return 0
end
end
--[[
Create a DateTime with the given values in UTC.
All values are optional!
]]
function DateTime.new(year, month, day, hour, minute, seconds)
local tzOffset = getTimeZoneOffset()
local timestamp = os.time({
year = year or 1970,
month = month or 1,
day = day or 1,
hour = hour or 0,
min = minute or 0,
sec = seconds or 0,
isdst = -1
})
if timestamp == nil then
timestamp = 0
end
if seconds then
local subseconds = seconds - math.floor(seconds)
timestamp = timestamp + subseconds
end
return DateTime.fromUnixTimestamp(timestamp + tzOffset)
end
--[[
Create a DateTime representing now.
]]
function DateTime.now()
return DateTime.fromUnixTimestamp(os.time())
end
--[[
Create a Datetime from the given Unix timestamp.
Limited to the range [0, 2^32), which lets us represent dates out to about
2038.
]]
function DateTime.fromUnixTimestamp(timestamp)
assert(type(timestamp) == "number", "Invalid argument #1 to fromUnixTimestamp, expected number.")
local self = {}
self.value = timestamp
setmetatable(self, DateTime)
return self
end
--[[
Attempt to create a DateTime from an ISO 8601 date-time string.
Will return nil on failure and output a warning to a console denoting what
went wrong. This can probably turned into a second return value if we need
to handle that data programmatically.
]]
function DateTime.fromIsoDate(isoDate)
assert(type(isoDate) == "string", "Invalid argument #1 to DateTime.fromIsoDate, expected string.")
local datePattern = "^(%d+)%-(%d+)%-(%d+)" -- 0000-00-00
local timePattern = "T(%d+):(%d+):(%d+%.?%d*)" -- T00:00:00
local utcPattern = "Z$"
local timeZonePattern = "([+-]%d+):(%d+)$" -- either Z or +/- followed by "00:00"
local timezone = 0
local values = {1970, 1, 1, 0, 0, 0}
local year, month, day = isoDate:match(datePattern)
if not year then
warn(("Invalid ISO 8601 date: %q"):format(isoDate))
return nil
end
values[1] = tonumber(year)
values[2] = tonumber(month)
values[3] = tonumber(day)
local hour, minute, seconds = isoDate:match(timePattern)
if hour then
values[4] = tonumber(hour)
values[5] = tonumber(minute)
values[6] = tonumber(seconds)
local isUtc = isoDate:match(utcPattern)
if not isUtc then
local offsetHours, offsetMinutes = isoDate:match(timeZonePattern)
if not offsetHours then
local offsetTotal = getTimeZoneOffset()
offsetHours = offsetTotal / 3600
offsetMinutes = 0
warn(("Invalid time zone in ISO 8601 date: %q -- falling back to local time"):format(isoDate))
end
timezone = 3600 * tonumber(offsetHours) + 60 * tonumber(offsetMinutes)
end
end
local date = DateTime.new(unpack(values))
date.value = date.value - timezone
return date
end
--[[
Format our current date using a formatting string. Look at the DateTime
proposal to see information about the different formatting tokens.
Generally, they try to resemble LDML and/or Moment.js-style formatting.
The time zone parameter is optional and defaults to the current time zone,
TimeZone.Current.
]]
function DateTime:Format(formatString, tz)
assert(type(formatString) == "string", "Invalid argument #1 to Format, expected string.")
tz = tz or TimeZone.Current
local values = self:GetValues(tz)
local buffer = {}
local i = 1
while i <= formatString:len() do
local char = formatString:sub(i, i)
local token = getToken(formatString, i)
if token then
table.insert(buffer, tokenMap[token](values))
i = i + token:len()
elseif char == "[" then
-- Crawl forward until the next ] and interpret that text literally
local j = i
while j <= formatString:len() do
j = j + 1
if formatString:sub(j, j) == "]" then
break
end
end
table.insert(buffer, formatString:sub(i + 1, j - 1))
i = j + 1
else
table.insert(buffer, char)
i = i + 1
end
end
local result = table.concat(buffer)
return result
end
--[[
Get a table of values representing the date-time in the given timezone.
The time zone parameter is optional and defaults to the current zime zone,
TimeZone.Current.
]]
function DateTime:GetValues(tz)
tz = tz or TimeZone.Current
local reference
if tz == TimeZone.Current then
reference = os.date("*t", self.value)
elseif tz == TimeZone.UTC then
reference = os.date("!*t", self.value)
end
if not reference then
error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
end
return {
Year = reference.year,
Month = reference.month,
Day = reference.day,
Hour = reference.hour,
Minute = reference.min,
Seconds = reference.sec,
WeekDay = reference.wday
}
end
--[[
Recover a Unix timestamp representing the DateTime's value.
]]
function DateTime:GetUnixTimestamp()
return self.value
end
--[[
Format the DateTime as an ISO 8601 date string with time attached.
Always formats the time as UTC. There generally aren't many reasons to
generate an ISO 8601 date in another time zone.
]]
function DateTime:GetIsoDate()
return self:Format("YYYY-MM-DD[T]HH:mm:ss[Z]", TimeZone.UTC)
end
-- Used by IsSame
local descendingGranularityUnits = {
{
unit = TimeUnit.Years,
key = "Year"
},
{
unit = TimeUnit.Months,
key = "Month"
},
{
unit = TimeUnit.Days,
key = "Day"
},
{
unit = TimeUnit.Hours,
key = "Hour"
},
{
unit = TimeUnit.Minutes,
key = "Minute"
},
{
unit = TimeUnit.Seconds,
key = "Seconds"
}
}
--[[
Checks whether two DateTime values are the same, given a granularity and
timezone value.
Granularity defaults to seconds and time zone defaults to the current local
time zone.
]]
function DateTime:IsSame(other, granularity, timezone)
granularity = granularity or TimeUnit.Seconds
timezone = timezone or TimeZone.Current
local selfUnix = self:GetUnixTimestamp()
local otherUnix = other:GetUnixTimestamp()
if selfUnix == otherUnix then
return true
end
local selfValues = self:GetValues(timezone)
local otherValues = other:GetValues(timezone)
-- Week logic is special
if granularity == TimeUnit.Weeks then
local diff = math.abs(selfUnix - otherUnix)
local diffDays = diff / (60 * 60 * 24)
-- Two dates separated by 7 or more whole days are never in the same week
if diffDays >= 7 then
return false
end
-- Two dates separated by less than 7 days will be sorted monotonically
-- if they're in the same week
-- TODO: Use start-of-week value to shift WeekDay for locale
if selfUnix > otherUnix then
return selfValues.WeekDay >= otherValues.WeekDay
else
return selfValues.WeekDay <= otherValues.WeekDay
end
end
for _, unit in ipairs(descendingGranularityUnits) do
local selfValue = selfValues[unit.key]
local otherValue = otherValues[unit.key]
if selfValue ~= otherValue then
return false
end
if unit.unit == granularity then
break
end
end
return true
end
--[[
Get a human-readable timestamp relative to the given epoch, which defaults
to now. The format of the time is contextual to how far away the times are.
]]
function DateTime:GetLongRelativeTime(epoch)
epoch = epoch or DateTime.now()
if self:IsSame(epoch, TimeUnit.Days) then
return self:Format("h:mm A")
elseif self:IsSame(epoch, TimeUnit.Weeks) then
return self:Format("DDD | h:mm A")
elseif self:IsSame(epoch, TimeUnit.Years) then
return self:Format("MMM D | h:mm A")
else
return self:Format("MMM D, YYYY | h:mm A")
end
end
--[[
Get a human-readable timestamp relative to the given epoch, which defaults
to now. The format of the time is contextual to how far away the times are.
]]
function DateTime:GetShortRelativeTime(epoch)
epoch = epoch or DateTime.now()
if self:IsSame(epoch, TimeUnit.Days) then
return self:Format("h:mm A")
elseif self:IsSame(epoch, TimeUnit.Weeks) then
return self:Format("DDD")
elseif self:IsSame(epoch, TimeUnit.Years) then
return self:Format("MMM D")
else
return self:Format("MMM D, YYYY")
end
end
DateTime.__index = DateTime
return DateTime
@@ -0,0 +1,355 @@
return function()
local DateTime = require(script.Parent.DateTime)
local TimeZone = require(script.Parent.TimeZone)
local TimeUnit = require(script.Parent.TimeUnit)
describe("Constructors", function()
it("should construct with 'new'", function()
expect(DateTime.new()).to.be.ok()
expect(DateTime.new(2017)).to.be.ok()
expect(DateTime.new(2017, 5)).to.be.ok()
expect(DateTime.new(2017, 5, 3)).to.be.ok()
expect(DateTime.new(2017, 5, 3, 12)).to.be.ok()
expect(DateTime.new(2017, 5, 3, 12, 34)).to.be.ok()
expect(DateTime.new(2017, 5, 3, 12, 34, 51)).to.be.ok()
end)
it("should construct with 'now'", function()
expect(DateTime.now()).to.be.ok()
end)
it("should construct from a Unix timestamp", function()
expect(DateTime.fromUnixTimestamp(0)).to.be.ok()
expect(DateTime.fromUnixTimestamp(os.time())).to.be.ok()
end)
it("should construct from an ISO 8601 date", function()
-- Basic date
do
local date = DateTime.fromIsoDate("1988-03-17")
expect(date).to.be.ok()
end
-- Date and time
do
local date = DateTime.fromIsoDate("2017-04-10T20:40:16Z")
expect(date).to.be.ok()
expect(date:GetUnixTimestamp()).to.equal(1491856816)
end
-- Date and time with no time zone
do
local date = DateTime.fromIsoDate("2017-04-10T20:40:16")
expect(date).to.be.ok()
end
-- Date, time, and time zone offset
do
local date = DateTime.fromIsoDate("2017-04-10T20:40:16+01:00")
expect(date).to.be.ok()
expect(date:GetUnixTimestamp()).to.equal(1491856816 - 3600)
end
-- Date, time, and negative time zone offset
do
local date = DateTime.fromIsoDate("2017-04-10T20:40:16-01:00")
expect(date).to.be.ok()
expect(date:GetUnixTimestamp()).to.equal(1491856816 + 3600)
end
end)
end)
describe("Measurements", function()
it("should get values in UTC", function()
local date = DateTime.new()
local values = date:GetValues(TimeZone.UTC)
expect(values).to.be.ok()
expect(values.Year).to.be.a("number")
expect(values.Month).to.be.a("number")
expect(values.Day).to.be.a("number")
expect(values.Hour).to.be.a("number")
expect(values.Minute).to.be.a("number")
expect(values.Seconds).to.be.a("number")
-- Locale specific!
expect(values.WeekDay).to.be.a("number")
end)
it("should get values in local time", function()
local date = DateTime.new()
local values = date:GetValues(TimeZone.Current)
expect(values).to.be.ok()
expect(values.Year).to.be.a("number")
expect(values.Month).to.be.a("number")
expect(values.Day).to.be.a("number")
expect(values.Hour).to.be.a("number")
expect(values.Minute).to.be.a("number")
expect(values.Seconds).to.be.a("number")
-- Locale specific!
expect(values.WeekDay).to.be.a("number")
end)
it("should preserve values from 'new' constructor", function()
local date = DateTime.new(2017, 11, 3, 12, 34, 51)
local values = date:GetValues(TimeZone.UTC)
expect(values.Year).to.equal(2017)
expect(values.Month).to.equal(11)
expect(values.Day).to.equal(3)
expect(values.Hour).to.equal(12)
expect(values.Minute).to.equal(34)
expect(values.Seconds).to.equal(51)
end)
it("should preserve Unix timestamp values", function()
do
local date = DateTime.fromUnixTimestamp(0)
expect(date:GetUnixTimestamp()).to.equal(0)
end
do
local date = DateTime.fromUnixTimestamp(123456789)
expect(date:GetUnixTimestamp()).to.equal(123456789)
end
end)
end)
describe("Formatting", function()
it("should have correct formatting tokens", function()
local date = DateTime.new(2016, 1, 2, 15, 8, 9)
-- Shortcut time zone specification
local function format(str)
return date:Format(str, TimeZone.UTC)
end
expect(format("YYYY")).to.equal("2016")
expect(format("M")).to.equal("1")
expect(format("MM")).to.equal("01")
expect(format("D")).to.equal("2")
expect(format("DD")).to.equal("02")
expect(format("H")).to.equal("15")
expect(format("HH")).to.equal("15")
expect(format("h")).to.equal("3")
expect(format("hh")).to.equal("03")
expect(format("m")).to.equal("8")
expect(format("mm")).to.equal("08")
expect(format("s")).to.equal("9")
expect(format("ss")).to.equal("09")
-- Locale-specific tests!
expect(format("MMM")).to.equal("Jan")
expect(format("MMMM")).to.equal("January")
expect(format("A")).to.equal("PM")
expect(format("a")).to.equal("pm")
end)
it("should preserve text within brackets", function()
local date = DateTime.new(2017, 1, 2, 15, 8, 9)
local function format(str)
return date:Format(str, TimeZone.UTC)
end
expect(format("[Hello, world!]")).to.equal("Hello, world!")
expect(format("[YYYY-MM-DD]")).to.equal("YYYY-MM-DD")
end)
it("should create identical ISO 8601 dates for UTC inputs", function()
local date = DateTime.fromIsoDate("2017-04-10T20:40:16Z")
expect(date:GetIsoDate()).to.equal("2017-04-10T20:40:16Z")
end)
it("should handle dates around midnight", function()
local date = DateTime.new(2015, 4, 20, 0, 0, 0)
expect(date:Format("H", TimeZone.UTC)).to.equal("0")
expect(date:Format("HH", TimeZone.UTC)).to.equal("00")
expect(date:Format("h", TimeZone.UTC)).to.equal("12")
expect(date:Format("hh", TimeZone.UTC)).to.equal("12")
expect(date:Format("a", TimeZone.UTC)).to.equal("am")
end)
it("should handle dates around noon", function()
local date = DateTime.new(2017, 5, 23, 12, 0, 0)
expect(date:Format("H", TimeZone.UTC)).to.equal("12")
expect(date:Format("HH", TimeZone.UTC)).to.equal("12")
expect(date:Format("h", TimeZone.UTC)).to.equal("12")
expect(date:Format("hh", TimeZone.UTC)).to.equal("12")
expect(date:Format("a", TimeZone.UTC)).to.equal("pm")
end)
it("should return correct 24-hour clock sequences", function()
local date = DateTime.new(2017, 9, 13, 0, 0, 0)
local formatString = "YYYY-MM-DD HH:mm:ss"
local expected = {
"2017-09-13 00:00:00",
"2017-09-13 01:00:00",
"2017-09-13 02:00:00",
"2017-09-13 03:00:00",
"2017-09-13 04:00:00",
"2017-09-13 05:00:00",
"2017-09-13 06:00:00",
"2017-09-13 07:00:00",
"2017-09-13 08:00:00",
"2017-09-13 09:00:00",
"2017-09-13 10:00:00",
"2017-09-13 11:00:00",
"2017-09-13 12:00:00",
"2017-09-13 13:00:00",
"2017-09-13 14:00:00",
"2017-09-13 15:00:00",
"2017-09-13 16:00:00",
"2017-09-13 17:00:00",
"2017-09-13 18:00:00",
"2017-09-13 19:00:00",
"2017-09-13 20:00:00",
"2017-09-13 21:00:00",
"2017-09-13 22:00:00",
"2017-09-13 23:00:00",
"2017-09-14 00:00:00",
"2017-09-14 01:00:00",
}
for i = 1, #expected do
local result = date:Format(formatString, TimeZone.UTC)
expect(result).to.equal(expected[i])
-- Advance once hour
date = date.fromUnixTimestamp(date:GetUnixTimestamp() + 3600)
end
end)
it("should return correct 12-hour clock sequences", function()
local date = DateTime.new(2017, 9, 13, 0, 0, 0)
local formatString = "YYYY-MM-DD hh:mm:ss a"
local expected = {
"2017-09-13 12:00:00 am",
"2017-09-13 01:00:00 am",
"2017-09-13 02:00:00 am",
"2017-09-13 03:00:00 am",
"2017-09-13 04:00:00 am",
"2017-09-13 05:00:00 am",
"2017-09-13 06:00:00 am",
"2017-09-13 07:00:00 am",
"2017-09-13 08:00:00 am",
"2017-09-13 09:00:00 am",
"2017-09-13 10:00:00 am",
"2017-09-13 11:00:00 am",
"2017-09-13 12:00:00 pm",
"2017-09-13 01:00:00 pm",
"2017-09-13 02:00:00 pm",
"2017-09-13 03:00:00 pm",
"2017-09-13 04:00:00 pm",
"2017-09-13 05:00:00 pm",
"2017-09-13 06:00:00 pm",
"2017-09-13 07:00:00 pm",
"2017-09-13 08:00:00 pm",
"2017-09-13 09:00:00 pm",
"2017-09-13 10:00:00 pm",
"2017-09-13 11:00:00 pm",
"2017-09-14 12:00:00 am",
"2017-09-14 01:00:00 am",
}
for i = 1, #expected do
local result = date:Format(formatString, TimeZone.UTC)
expect(result).to.equal(expected[i])
-- Advance once hour
date = date.fromUnixTimestamp(date:GetUnixTimestamp() + 3600)
end
end)
it("should have LongRelativeTime", function()
local date = DateTime.new(2015, 4, 20, 0, 0, 0)
expect(date:GetLongRelativeTime()).to.be.a("string")
end)
it("should have ShortRelativeTime", function()
local date = DateTime.new(2015, 4, 20, 0, 0, 0)
expect(date:GetShortRelativeTime()).to.be.a("string")
end)
end)
describe("Comparisons", function()
describe("IsSame", function()
it("should equate dates with different granularity", function()
local value = DateTime.new(2003, 6, 11, 15, 8, 9)
local same = DateTime.new(2003, 6, 11, 15, 8, 9)
expect(value:IsSame(value)).to.equal(true)
expect(value:IsSame(same)).to.equal(true)
local units = {TimeUnit.Years, TimeUnit.Months, TimeUnit.Days, TimeUnit.Hours, TimeUnit.Minutes}
for _, unit in ipairs(units) do
expect(value:IsSame(same, unit)).to.equal(true)
end
local sameMinute = DateTime.new(2003, 6, 11, 15, 8, 10)
expect(value:IsSame(sameMinute)).to.equal(false)
expect(value:IsSame(sameMinute, TimeUnit.Minutes)).to.equal(true)
expect(value:IsSame(sameMinute, TimeUnit.Years)).to.equal(true)
local sameHour = DateTime.new(2003, 6, 11, 15, 9, 0)
expect(value:IsSame(sameHour)).to.equal(false)
expect(value:IsSame(sameHour, TimeUnit.Hours)).to.equal(true)
expect(value:IsSame(sameHour, TimeUnit.Years)).to.equal(true)
local sameDay = DateTime.new(2003, 6, 11, 14, 8, 9)
expect(value:IsSame(sameDay)).to.equal(false)
expect(value:IsSame(sameDay, TimeUnit.Days)).to.equal(true)
expect(value:IsSame(sameDay, TimeUnit.Years)).to.equal(true)
local sameMonth = DateTime.new(2003, 6, 12, 15, 8, 9)
expect(value:IsSame(sameMonth)).to.equal(false)
expect(value:IsSame(sameMonth, TimeUnit.Months)).to.equal(true)
expect(value:IsSame(sameMonth, TimeUnit.Years)).to.equal(true)
local sameYear = DateTime.new(2003, 7, 12, 15, 8, 9)
expect(value:IsSame(sameYear)).to.equal(false)
expect(value:IsSame(sameYear, TimeUnit.Years)).to.equal(true)
local diffYear = DateTime.new(2004, 6, 11, 15, 8, 9)
expect(value:IsSame(diffYear)).to.equal(false)
expect(value:IsSame(diffYear, TimeUnit.Years)).to.equal(false)
end)
it("should equate values using week boundaries", function()
local sunday = DateTime.new(2017, 5, 7)
local saturday = DateTime.new(2017, 5, 13)
local monday = DateTime.new(2017, 5, 8)
local tuesday = DateTime.new(2017, 5, 9)
-- TODO: Specify locale when that lands; default may break tests
local function sameWeek(a, b)
return a:IsSame(b, TimeUnit.Weeks, TimeZone.UTC)
end
expect(sameWeek(monday, monday)).to.equal(true)
expect(sameWeek(sunday, monday)).to.equal(true)
expect(sameWeek(tuesday, monday)).to.equal(true)
expect(sameWeek(saturday, monday)).to.equal(true)
local nextSunday = DateTime.new(2017, 5, 14)
expect(sameWeek(nextSunday, monday)).to.equal(false)
end)
end)
end)
end
@@ -0,0 +1,39 @@
local CorePackages = game:GetService("CorePackages")
local MockId = require(CorePackages.AppTempCommon.LuaApp.MockId)
local PlaceInfoModel = {}
function PlaceInfoModel.new()
local self = {}
return self
end
function PlaceInfoModel.mock()
local self = PlaceInfoModel.new()
self.builder = "builder"
self.builderId = MockId()
self.description = "description"
self.imageToken = MockId()
self.isPlayable = true
self.name = "name"
self.placeId = MockId()
self.price = 0
self.reasonProhibited = nil
self.universeId = MockId()
self.universeRootPlaceId = MockId()
self.url = "url"
return self
end
function PlaceInfoModel.fromWeb(data)
local self = data or {}
self.placeId = tostring(self.placeId)
return self
end
return PlaceInfoModel
@@ -0,0 +1,12 @@
local CorePackages = game:GetService("CorePackages")
local SetFriendCount = require(CorePackages.AppTempCommon.LuaApp.Actions.SetFriendCount)
return function(state, action)
state = state or 0
if action.type == SetFriendCount.name then
state = action.count
end
return state
end
@@ -0,0 +1,18 @@
return function()
local CorePackages = game:GetService("CorePackages")
local FriendCount = require(CorePackages.AppTempCommon.LuaChat.Reducers.FriendCount)
local SetFriendCount = require(CorePackages.AppTempCommon.LuaApp.Actions.SetFriendCount)
it("should be zero by default", function()
local state = FriendCount(nil, {})
expect(state).to.equal(0)
end)
it("should respond to SetFriendCount", function()
local state = FriendCount(nil, {})
state = FriendCount(state, SetFriendCount(520))
expect(state).to.equal(520)
end)
end
@@ -0,0 +1,22 @@
local CorePackages = game:GetService("CorePackages")
local Common = CorePackages.AppTempCommon.Common
local LuaChat = CorePackages.AppTempCommon.LuaChat
local ReceivedMultiplePlaceInfos = require(LuaChat.Actions.ReceivedMultiplePlaceInfos)
local Immutable = require(Common.Immutable)
return function(state, action)
state = state or {}
if action.type == ReceivedMultiplePlaceInfos.name then
local newInfos = {}
for _, placeInfo in ipairs(action.placeInfos) do
newInfos[placeInfo.placeId] = placeInfo
end
state = Immutable.JoinDictionaries(state, newInfos)
end
return state
end
@@ -0,0 +1,36 @@
return function()
local CorePackages = game:GetService("CorePackages")
local LuaApp = CorePackages.AppTempCommon.LuaApp
local LuaChat = CorePackages.AppTempCommon.LuaChat
local MockId = require(LuaApp.MockId)
local ReceivedMultiplePlaceInfos = require(LuaChat.Actions.ReceivedMultiplePlaceInfos)
local PlaceInfosReducer = require(script.Parent.PlaceInfos)
describe("initial state", function()
it("should return an initial table when passed nil", function()
local state = PlaceInfosReducer(nil, {})
expect(state).to.be.a("table")
end)
end)
describe("ReceivedMultiplePlaceInfos", function()
it("should add place info to the store", function()
local state = PlaceInfosReducer(nil, {})
local placeId = MockId()
local returnedPlaceInfo = ReceivedMultiplePlaceInfos({
{
placeId = placeId,
imageToken = "image-token",
},
})
state = PlaceInfosReducer(state, returnedPlaceInfo)
expect(state[placeId]).to.equal(returnedPlaceInfo.placeInfos[1])
end)
end)
end
@@ -0,0 +1,17 @@
local TimeUnit = setmetatable({}, {
__index = function(self, key)
error(("Invalid TimeUnit \"%s\""):format(tostring(key)), 2)
end
})
TimeUnit.Seconds = "Seconds"
TimeUnit.Minutes = "Minutes"
TimeUnit.Hours = "Hours"
TimeUnit.Days = "Days"
TimeUnit.Months = "Months"
TimeUnit.Years = "Years"
-- Locale-specific
TimeUnit.Weeks = "Weeks"
return TimeUnit
@@ -0,0 +1,10 @@
local TimeZone = setmetatable({}, {
__index = function(self, key)
error(("Invalid TimeZone \"%s\""):format(tostring(key)), 2)
end
})
TimeZone.UTC = -2
TimeZone.Current = -1
return TimeZone
@@ -0,0 +1,17 @@
local CorePackages = game:GetService("CorePackages")
local User = require(CorePackages.AppTempCommon.LuaApp.Models.User)
local WebPresenceMap = require(CorePackages.AppTempCommon.LuaApp.Enum.WebPresenceMap)
return function(friendsPresence, store)
local placeIds = {}
for _, presenceModel in pairs(friendsPresence) do
if WebPresenceMap[presenceModel.userPresenceType] == User.PresenceType.IN_GAME
and (not store:getState().UniversePlaceInfos[presenceModel.universeId]) then
table.insert(placeIds, presenceModel.placeId)
end
end
return placeIds
end
@@ -0,0 +1,39 @@
local CorePackages = game:GetService("CorePackages")
local ReceivedUserPresence = require(CorePackages.AppTempCommon.LuaChat.Actions.ReceivedUserPresence)
local WebPresenceMap = require(CorePackages.AppTempCommon.LuaApp.Enum.WebPresenceMap)
local luaChatUseNewFriendsAndPresenceEndpoint = settings():GetFFlag("LuaChatUseNewFriendsAndPresenceEndpointV356")
local luaChatPlayTogetherUseRootPresence = settings():GetFFlag("LuaChatPlayTogetherUseRootPresence")
local luaChatRootPresenceEnabled = luaChatUseNewFriendsAndPresenceEndpoint and luaChatPlayTogetherUseRootPresence
return function(friendsPresence, store)
for _, presenceModel in pairs(friendsPresence) do
local userInStore = store:getState().Users[tostring(presenceModel.userId)]
local previousUniverseId = userInStore and userInStore.universeId or nil
if luaChatRootPresenceEnabled then
store:dispatch(ReceivedUserPresence(
tostring(presenceModel.userId),
WebPresenceMap[presenceModel.userPresenceType],
presenceModel.lastLocation,
presenceModel.placeId and tostring(presenceModel.placeId) or nil,
presenceModel.rootPlaceId and tostring(presenceModel.rootPlaceId) or nil,
presenceModel.gameId and tostring(presenceModel.gameId) or nil,
presenceModel.lastOnline and tostring(presenceModel.lastOnline) or nil,
presenceModel.universeId,
previousUniverseId
))
else
store:dispatch(ReceivedUserPresence(
tostring(presenceModel.userId),
WebPresenceMap[presenceModel.userPresenceType],
presenceModel.lastLocation,
presenceModel.placeId and tostring(presenceModel.placeId) or nil,
presenceModel.gameId and tostring(presenceModel.gameId) or nil,
presenceModel.universeId,
previousUniverseId
))
end
end
end
@@ -0,0 +1,79 @@
local AnalyticsService = game:GetService("AnalyticsService")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local SETTINGS_HUB_INVITE_RELEASE_STREAM_TIME = tonumber(settings():GetFVariable("SettingsHubInviteReleaseStreamTime"))
or math.huge
local function getPlatformTarget()
local platformTarget = "unknownLua"
local platformEnum = Enum.Platform.None
-- the call to GetPlatform is wrapped in a pcall() because the Testing Service
-- executes the scripts in the wrong authorization level
pcall(function()
platformEnum = UserInputService:GetPlatform()
end)
-- bucket the platform based on consumer platform
local isDesktopClient = (platformEnum == Enum.Platform.Windows) or (platformEnum == Enum.Platform.OSX)
local isMobileClient = (platformEnum == Enum.Platform.IOS) or (platformEnum == Enum.Platform.Android)
isMobileClient = isMobileClient or (platformEnum == Enum.Platform.UWP)
local isConsole = (platformEnum == Enum.Platform.XBox360) or (platformEnum == Enum.Platform.XBoxOne)
isConsole = isConsole or (platformEnum == Enum.Platform.PS3) or (platformEnum == Enum.Platform.PS4)
isConsole = isConsole or (platformEnum == Enum.Platform.WiiU)
-- assign a target based on the form factor
if isDesktopClient then
platformTarget = "client"
elseif isMobileClient then
platformTarget = "mobile"
elseif isConsole then
platformTarget = "console"
else
-- if we don't have a name for the form factor, report it here so that we can eventually track it down
platformTarget = platformTarget .. tostring(platformEnum)
end
return platformTarget
end
local EventStream = {}
EventStream.__index = EventStream
function EventStream.new(overridePlatformTarget, overrideAnalyticsImpl)
local self = {}
setmetatable(self, EventStream)
self._analyticsImpl = overrideAnalyticsImpl or AnalyticsService
self._platformTarget = overridePlatformTarget or getPlatformTarget()
return self
end
function EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
additionalArgs = additionalArgs or {}
-- this function sends reports to the server in batches, not real-time
self._analyticsImpl:SetRBXEventStream(self._platformTarget, eventContext, eventName, additionalArgs)
if not self.timerSteppedConnection then
local lastGameTime = time()
self.timerSteppedConnection = RunService.Stepped:Connect(function(gameTime)
if gameTime - lastGameTime > SETTINGS_HUB_INVITE_RELEASE_STREAM_TIME then
self:releaseRBXEventStream()
end
end)
end
end
function EventStream:releaseRBXEventStream()
self._analyticsImpl:ReleaseRBXEventStream(self._platformTarget)
if self.timerSteppedConnection then
self.timerSteppedConnection:Disconnect()
self.timerSteppedConnection = nil
end
end
return EventStream
@@ -0,0 +1,90 @@
local CorePackages = game:GetService("CorePackages")
local HttpService = game:GetService("HttpService")
local LuaApp = CorePackages.AppTempCommon.LuaApp
local Promise = require(LuaApp.Promise)
local DEFAULT_THROTTLING_PRIORITY = Enum.ThrottlingPriority.Extreme
local DEFAULT_POST_ASYNC_CONTENT_TYPE = Enum.HttpContentType.ApplicationJson
-- httpRequest : (table, optional) an object that implements the same http functions as the data model
return function(httpImpl)
local function doHttpPost(url, options)
assert(options.postBody, "Expected a postBody to be specified with this request")
assert(type(options.postBody) == "string", "Expected postBody to be a string")
if not options.contentType then
options.contentType = DEFAULT_POST_ASYNC_CONTENT_TYPE
end
if not options.throttlingPriority then
options.throttlingPriority = DEFAULT_THROTTLING_PRIORITY
end
return function()
return httpImpl:PostAsyncFullUrl(
url,
options.postBody,
options.throttlingPriority,
options.contentType
)
end
end
local function doHttpGet(url)
return function()
return httpImpl:GetAsyncFullUrl(url, DEFAULT_THROTTLING_PRIORITY)
end
end
-- return the request function
-- url : (string)
-- requestMethod : (string) "GET", "POST"
-- args : (table, optional)
-- options.throttlingPriority : (Enum.ThrottlingPriority, optional)
-- options.contentType : (Enum.HttpContentType, optional)
-- options.postBody : (string, optional ("POST" only))
-- RETURNS : (promise<HttpResponse or string>)
return function(url, requestMethod, options)
assert(type(url) == "string", "Expected url to be a string")
assert(type(requestMethod) == "string", "Expected requestMethod to be a string")
assert(not options or type(options) == "table", "Expected options to be a table")
requestMethod = string.upper(requestMethod)
local httpFunction
if requestMethod == "POST" then
httpFunction = doHttpPost(url, options)
elseif requestMethod == "GET" then
httpFunction = doHttpGet(url)
else
error(string.format("Unsupported requestMethod : %s", requestMethod or "nil"))
end
return Promise.new(function(resolve, reject)
if httpFunction then
spawn(function()
local success, response = pcall(httpFunction)
if success then
local jsonSuccess, decodedJson = pcall(function()
return HttpService:JSONDecode(response)
end)
if jsonSuccess then
resolve({
responseBody = decodedJson,
})
else
reject(decodedJson)
end
else
reject(response)
end
end)
else
reject()
end
end)
end
end
@@ -0,0 +1,61 @@
return function()
local httpRequest = require(script.Parent.httpRequest)
local function createTestRequestFunc(testResponse)
local requestService = {}
function requestService:GetAsyncFullUrl()
return testResponse
end
function requestService:PostAsyncFullUrl()
return testResponse
end
return httpRequest(requestService)
end
it("should return a function", function()
expect(httpRequest()).to.be.ok()
expect(type(httpRequest())).to.equal("function")
end)
it("should validate its inputs", function()
local testRequest = createTestRequestFunc()
local function testParams(url, requestMethod, args)
return function()
testRequest(url, requestMethod, args)
end
end
local validUrl = "friends.roblox.com"
local validMethod = "GET"
local validArgs = {}
-- url checks
expect(testParams(nil, validMethod, validArgs)).to.throw()
expect(testParams(123, validMethod, validArgs)).to.throw()
expect(testParams({}, validMethod, validArgs)).to.throw()
expect(testParams(true, validMethod, validArgs)).to.throw()
expect(testParams(function() end, validMethod, validArgs)).to.throw()
-- request method checks
expect(testParams(validUrl, nil, validArgs)).to.throw()
expect(testParams(validUrl, 123, validArgs)).to.throw()
expect(testParams(validUrl, {}, validArgs)).to.throw()
expect(testParams(validUrl, true, validArgs)).to.throw()
expect(testParams(validUrl, function() end, validArgs)).to.throw()
-- args checks
expect(testParams(validUrl, validMethod, 123)).to.throw()
expect(testParams(validUrl, validMethod, "Test")).to.throw()
expect(testParams(validUrl, validMethod, true)).to.throw()
expect(testParams(validUrl, validMethod, function() end)).to.throw()
end)
it("should throw an error if the requestMethod isn't supported", function()
local testRequest = createTestRequestFunc("foo")
expect(function()
testRequest("testUrl", "GIVEANDTAKE")
end).to.throw()
end)
end
@@ -0,0 +1,16 @@
return function(targetString, blacklistedCharacter)
local charactersArray = {}
local indexArray = {}
for index, byte in utf8.codes(targetString) do
local graphemeCharacter = utf8.char(byte)
table.insert(charactersArray, 1, graphemeCharacter)
table.insert(indexArray, 1, index)
end
for index, graphemeCharacter in ipairs(charactersArray) do
if graphemeCharacter ~= blacklistedCharacter then
return targetString:sub(1, indexArray[index])
end
end
return ""
end
@@ -0,0 +1,71 @@
return function()
local trimCharacterFromEndString = require(script.Parent.trimCharacterFromEndString)
describe("single byte characters", function()
it("should not trim a string if it does not end with passed character", function()
local passedString = "testing"
local passedCharacter = "/"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(passedString)
end)
it("should trim a string if it ends with a single instance of the passed character", function()
local passedString = "testing/"
local passedCharacter = "/"
local expectedString = "testing"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
it("should trim a string if it ends with multiple instances of the passed character", function()
local passedString = "testing///"
local passedCharacter = "/"
local expectedString = "testing"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
it("should do nothing if the passed character is empty", function()
local passedString = "hunter2"
local passedCharacter = ""
local expectedString = "hunter2"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
end)
describe("multiple byte characters", function()
it("should not trim a string if it does not end with passed character", function()
local passedString = "testing"
local passedCharacter = "🐶"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(passedString)
end)
it("should trim a string if it ends with a single instance of the passed character", function()
local passedString = "testing🐶"
local passedCharacter = "🐶"
local expectedString = "testing"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
it("should trim a string if it ends with multiple instances of the passed character", function()
local passedString = "testing🐶🐶🐶"
local passedCharacter = "🐶"
local expectedString = "testing"
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
end)
describe("a string with all blacklisted characters", function()
it("should return a empty string", function()
local passedString = "pppppppppppp"
local passedCharacter = "p"
local expectedString = ""
expect(trimCharacterFromEndString(passedString, passedCharacter)).to.equal(expectedString)
end)
end)
end
@@ -0,0 +1,193 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local Modalifier = require(script.Parent.Modalifier)
local Dropdown = Roact.Component:extend("Dropdown")
function Dropdown:init()
self.state = {
Open = false,
Hovered = false,
}
end
function Dropdown:render()
local listChildren = {
Layout = Roact.createElement("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
}),
}
for index, listItem in ipairs(self.props.ListItems) do
listChildren["ListObject"..index] = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 0, self.props.ListItemHeight),
LayoutOrder = index,
BorderSizePixel = 0,
BackgroundTransparency = 1,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingBottom = UDim.new(0, 4),
}),
Button = Roact.createElement("TextButton", {
BorderSizePixel = 0,
Size = UDim2.new(1, 0, 1, 0),
TextColor3 = self.props.TextColor3,
Text = "",
BackgroundColor3 = self.props.BackgroundColor3,
[Roact.Event.Activated] = function()
listItem.OnActivated()
self:setState({
Open = false,
})
end,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingLeft = UDim.new(0, 4),
}),
Text = Roact.createElement("TextLabel", {
BackgroundTransparency = 1,
TextColor3 = self.props.TextColor3,
Size = UDim2.new(1, 0, 1, 0),
Text = listItem.Text,
TextXAlignment = Enum.TextXAlignment.Left,
}),
}),
})
end
local visibleChildren
if self.state.Open then
visibleChildren = {
Button = Roact.createElement("TextButton", {
Size = UDim2.new(1, 0, 1, 0),
BorderColor3 = self.props.BorderColor3,
AutoButtonColor = false,
BackgroundTransparency = 0,
Text = "",
BackgroundColor3 = self.props.ButtonDownColor3,
TextXAlignment = Enum.TextXAlignment.Left,
[Roact.Event.Activated] = function()
self:setState({
Open = false,
})
end,
-- Empty functions here work around bug-147 in Roact
[Roact.Event.InputBegan] = function()
end,
[Roact.Event.InputEnded] = function()
end,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingLeft = UDim.new(0, 4),
}),
TextLabel = Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
TextColor3 = self.props.TextColor3,
BackgroundTransparency = 1,
BorderSizePixel = 0,
TextXAlignment = Enum.TextXAlignment.Left,
Text = self.props.CurrentText,
}),
Arrow = Roact.createElement("ImageLabel", {
Position = UDim2.new(1, -15, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Size = UDim2.new(0, 5, 0, 3),
BorderSizePixel = 0,
BackgroundTransparency = 1,
Image = "rbxasset://textures/menuDownArrow.png",
}),
}),
Modalifier = Roact.createElement(Modalifier, {
Window = self.props.Window,
OnClosed = function()
self:setState({
Open = false,
})
end,
Render = function(position)
return Roact.createElement("Frame", {
Position = UDim2.new(0, position.X, 0, position.Y + self.props.ListItemHeight),
Size = UDim2.new(0, 160, 0, #(self.props.ListItems) * self.props.ListItemHeight),
BackgroundTransparency = 0,
BackgroundColor3 = self.props.BackgroundColor3,
BorderColor3 = self.props.BorderColor3,
}, listChildren)
end,
}),
}
else
visibleChildren = {
Button = Roact.createElement("TextButton", {
Size = UDim2.new(1, 0, 1, 0),
BorderColor3 = self.props.BorderColor3,
AutoButtonColor = false,
BackgroundTransparency = 0,
BackgroundColor3 = self.state.Hovered and self.props.ButtonHoverColor3 or self.props.BackgroundColor3,
Text = "",
TextXAlignment = Enum.TextXAlignment.Left,
[Roact.Event.Activated] = function()
self:setState({
Open = true,
Hovered = false,
})
end,
[Roact.Event.InputBegan] = function(gui, input)
if input.UserInputType == Enum.UserInputType.MouseMovement then
self:setState({
Hovered = true,
})
end
end,
[Roact.Event.InputEnded] = function(gui, input)
if input.UserInputType == Enum.UserInputType.MouseMovement then
self:setState({
Hovered = false,
})
end
end,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingLeft = UDim.new(0, 4),
}),
TextLabel = Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
TextColor3 = self.props.TextColor3,
BackgroundTransparency = 1,
BorderSizePixel = 0,
TextXAlignment = Enum.TextXAlignment.Left,
Text = self.props.CurrentText,
}),
Arrow = Roact.createElement("ImageLabel", {
Position = UDim2.new(1, -15, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
Size = UDim2.new(0, 5, 0, 3),
BorderSizePixel = 0,
BackgroundTransparency = 1,
Image = "rbxasset://textures/menuDownArrow.png",
}),
}),
}
end
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
}, visibleChildren)
end
return Dropdown
@@ -0,0 +1,47 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local LocaleSelector = require(script.Parent.LocaleSelector)
local LabeledLocaleSelector = Roact.Component:extend("LabeledLocaleSelector")
function LabeledLocaleSelector:render()
return Roact.createElement("Frame", {
Size = UDim2.new(0, 300, 0, 25),
BackgroundTransparency = 1,
BorderSizePixel = 0,
LayoutOrder = self.props.LayoutOrder
}, {
Layout = Roact.createElement("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 5),
}),
LocaleIdLabel = Roact.createElement("TextLabel", {
Text = self.props.LabelText,
TextXAlignment = "Right",
TextYAlignment = "Center",
TextColor3 = self.props.TextColor3,
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(0, 50, 0, 25),
LayoutOrder = 0,
}),
LocaleSelectorGroup = Roact.createElement(LocaleSelector, {
Window = self.props.Window,
Size = UDim2.new(0, 200, 0, 25),
BackgroundColor3 = self.props.BackgroundColor3,
TextColor3 = self.props.TextColor3,
BorderColor3 = self.props.BorderColor3,
ButtonHoverColor3 = self.props.ButtonHoverColor3,
ButtonDownColor3 = self.props.ButtonDownColor3,
InitialLocaleId = self.props.InitialLocaleId,
SetLocaleId = self.props.SetLocaleId,
LayoutOrder = 1,
}),
})
end
return LabeledLocaleSelector
@@ -0,0 +1,127 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local Dropdown = require(script.Parent.Dropdown)
local customMenuItemText = "(Custom)"
local localeInfos = {
{ localeId = "en-us", name = "English" },
{ localeId = "fr-fr", name = "French" },
{ localeId = "de-de", name = "German" },
{ localeId = "pt-br", name = "Portuguese (Brazil)" },
{ localeId = "es-es", name = "Spanish" },
}
local localeNameMap = {}
for _, info in ipairs(localeInfos) do
localeNameMap[info.localeId] = info.name
end
local LocaleSelector = Roact.Component:extend("LocaleSelector")
function LocaleSelector:init()
self.state = {
LocaleId = self.props.InitialLocaleId
}
self.textBoxRef = Roact.createRef()
end
local function getMenuTextForLocale(localeId)
if localeId == "" then
return customMenuItemText
end
return localeNameMap[localeId] or customMenuItemText
end
function LocaleSelector:render()
local ListItems = {}
for index, item in ipairs(localeInfos) do
ListItems[index] = {
Text = item.name,
OnActivated = function()
self:setState({
LocaleId = item.localeId,
})
end
}
end
ListItems[#localeInfos + 1] = {
Text = customMenuItemText,
OnActivated = function()
self:setState({
LocaleId = "",
})
self.textBoxRef.current:CaptureFocus()
end
}
return Roact.createElement("Frame", {
Size = self.props.Size,
BackgroundTransparency = 1.0,
}, {
Layout = Roact.createElement("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 5),
}),
LocaleNameDropdown = Roact.createElement("Frame", {
Size = UDim2.new(0, 160, 0, 25),
LayoutOrder = 1,
}, {
Dropdown = Roact.createElement(Dropdown, {
Window = self.props.Window,
BackgroundColor3 = self.props.BackgroundColor3,
TextColor3 = self.props.TextColor3,
BorderColor3 = self.props.BorderColor3,
ButtonHoverColor3 = self.props.ButtonHoverColor3,
ButtonDownColor3 = self.props.ButtonDownColor3,
CurrentText = getMenuTextForLocale(self.state.LocaleId),
ListItemHeight = 25,
ListItems = ListItems,
}),
}),
LocaleIdTextBox = Roact.createElement("Frame", {
Size = UDim2.new(0, 50, 0, 25),
BorderSizePixel = 1,
BorderColor3 = self.props.BorderColor3,
BackgroundColor3 = self.props.BackgroundColor3,
LayoutOrder = 2,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingLeft = UDim.new(0, 4),
}),
TextboxInternal = Roact.createElement("TextBox", {
Size = UDim2.new(1, 0, 1, 0),
TextColor3 = self.props.TextColor3,
BorderColor3 = self.props.BorderColor3,
Text = self.state.LocaleId,
TextXAlignment = "Left",
ClearTextOnFocus = false,
BackgroundTransparency = 1,
[Roact.Ref] = self.textBoxRef,
[Roact.Event.FocusLost] = function()
self:setState({
LocaleId = self.textBoxRef.current.Text,
})
end,
})
}),
})
end
function LocaleSelector:didUpdate()
self.props.SetLocaleId(self.state.LocaleId)
end
return LocaleSelector
@@ -0,0 +1,53 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local Modalifier = Roact.Component:extend("Modalifier")
function Modalifier:init()
self.state = {
ContentAbsolutePosition = Vector2.new(0, 0),
Showing = false,
}
end
function Modalifier:render()
local visibleChildren = {}
if self.state.Showing then
visibleChildren = {
content = self.props.Render(self.state.ContentAbsolutePosition),
}
end
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
[Roact.Change.AbsolutePosition] = function(instance)
spawn(function()
self:setState({
ContentAbsolutePosition = instance.AbsolutePosition,
Showing = true,
})
end)
end,
}, {
Portal = Roact.createElement(Roact.Portal, {
-- Future redesign strongly suggested here: https://jira.roblox.com/browse/CLILUACORE-287
target = self.props.Window,
}, {
Curtain = Roact.createElement("TextButton", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
Text = "",
[Roact.Event.Activated] = function()
self.props.OnClosed()
end,
}, visibleChildren),
}),
})
end
return Modalifier
@@ -0,0 +1,76 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local LabeledLocaleSelector = require(script.Parent.LabeledLocaleSelector)
local PlayerLocaleView = Roact.Component:extend("PlayerLocaleView")
local StudioEnableLuaAPIsForThemes = settings():GetFFlag("StudioEnableLuaAPIsForThemes")
local robloxLocaleLabelText = "Locale"
function PlayerLocaleView:init()
if StudioEnableLuaAPIsForThemes then
self.state = {
Theme = settings().Studio.Theme,
}
end
end
function PlayerLocaleView:render()
local TextColor3
local BorderColor3
local BackgroundColor3
local ButtonHoverColor3
local ButtonDownColor3
if StudioEnableLuaAPIsForThemes then
TextColor3 = self.state.Theme:GetColor(Enum.StudioStyleGuideColor.BrightText)
BorderColor3 = self.state.Theme:GetColor(Enum.StudioStyleGuideColor.Border)
BackgroundColor3 = self.state.Theme:GetColor(Enum.StudioStyleGuideColor.MainBackground)
ButtonHoverColor3 = self.state.Theme:GetColor(Enum.StudioStyleGuideColor.Button, Enum.StudioStyleGuideModifier.Hover)
ButtonDownColor3 = self.state.Theme:GetColor(Enum.StudioStyleGuideColor.Button, Enum.StudioStyleGuideModifier.Pressed)
else
TextColor3 = Color3.new( 0, 0, 0 )
BorderColor3 = Color3.new( 0.713726, 0.713726, 0.713726 )
BackgroundColor3 = Color3.new( 1, 1, 1 )
ButtonHoverColor3 = Color3.new( 0.894118, 0.933333, 0.996078 )
ButtonDownColor3 = Color3.new( 0.858824, 0.858824, 0.858824 )
end
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 0,
BackgroundColor3 = BackgroundColor3,
BorderSizePixel = 0,
}, {
Padding = Roact.createElement("UIPadding", {
PaddingTop = UDim.new(0, 5),
}),
Roblox = Roact.createElement(LabeledLocaleSelector, {
Window = self.props.Window,
LabelText = robloxLocaleLabelText,
TextColor3 = TextColor3,
BorderColor3 = BorderColor3,
BackgroundColor3 = BackgroundColor3,
ButtonHoverColor3 = ButtonHoverColor3,
ButtonDownColor3 = ButtonDownColor3,
InitialLocaleId = self.props.InitialRobloxLocaleId,
SetLocaleId = self.props.SetRobloxLocaleId,
}),
})
end
function PlayerLocaleView:didMount()
if StudioEnableLuaAPIsForThemes then
settings().Studio.ThemeChanged:Connect(
function()
self:setState({
Theme = settings().Studio.Theme,
})
end
)
end
end
return PlayerLocaleView
@@ -0,0 +1,100 @@
local runService = game:GetService("RunService")
local LocalizationService = game:GetService("LocalizationService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CorePackages = game:GetService("CorePackages")
local enabled = runService:IsEdit()
local function createTextScraperControls(toolbar, plugin)
function getTextScraperAsset()
return LocalizationService.IsTextScraperRunning
and "rbxasset://textures/localizationUIScrapingOn.png"
or "rbxasset://textures/localizationUIScrapingOff.png"
end
local captureButton = toolbar:CreateButton(
"Text Capture",
"Enable untranslated text capture",
getTextScraperAsset()
)
local exportButton = toolbar:CreateButton(
"Export",
"Export LocalizationTables under LocalizationService to CSV files",
"rbxasset://textures/localizationExport.png"
)
local importButton = toolbar:CreateButton(
"Import",
"Import CSV files to LocalizationTables under LocalizationService",
"rbxasset://textures/localizationImport.png"
)
captureButton.Enabled = enabled
captureButton.Click:Connect(function()
if not LocalizationService.IsTextScraperRunning then
LocalizationService:StartTextScraper()
else
LocalizationService:StopTextScraper()
end
captureButton.Icon = getTextScraperAsset()
end)
exportButton.Enabled = enabled
exportButton.Click:Connect(function()
LocalizationService:PromptExportToCSVs()
end)
importButton.Enabled = enabled
importButton.Click:Connect(function()
LocalizationService:PromptImportFromCSVs()
end)
end
local function getInitialRobloxLocaleId()
local forcedLocale = LocalizationService.RobloxForcePlayModeRobloxLocaleId
if forcedLocale ~= "" then
return forcedLocale
end
return LocalizationService.RobloxLocaleId
end
local function createPlayerLocaleViewButton(toolbar, plugin)
local PlayerLocaleView = require(script.Parent.Components.PlayerLocaleView)
local Roact = require(CorePackages.Roact)
local Window = plugin:CreateDockWidgetPluginGui("LocalizationTesting",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Left))
Window.Title = "Test Language"
local params = {
Window = Window,
SetRobloxLocaleId = function(localeId)
LocalizationService.RobloxForcePlayModeRobloxLocaleId = localeId
end,
InitialRobloxLocaleId = getInitialRobloxLocaleId(),
}
Roact.mount(Roact.createElement(PlayerLocaleView, params), Window)
Window.Enabled = false
local button = toolbar:CreateButton(
"Test Language",
"Hide/show the Localization Testing view",
"rbxasset://textures/localizationTestingIcon.png")
button.Enabled = enabled
button.Click:Connect(
function()
Window.Enabled = not Window.Enabled
end
)
end
return function(plugin)
local toolbar = plugin:CreateToolbar("Localization Tools")
createTextScraperControls(toolbar, plugin)
createPlayerLocaleViewButton(toolbar, plugin)
end
@@ -0,0 +1,30 @@
local CorePackages = game:GetService("CorePackages")
return function()
local Roact = require(CorePackages.Roact)
local Dropdown = require(script.Parent.Parent.Components.Dropdown)
it("mounts and unmounts", function()
local handle = Roact.mount(Roact.createElement(Dropdown, {
ListItems = {},
}))
Roact.unmount(handle)
end)
itFIXME("inits with an item list and includes the default value in the resulting UI", function()
local container = Instance.new("Frame")
local element = Roact.createElement(Dropdown, {
CurrentText = "Option 1 (default)",
ListItems = {
"Option 1",
"Option 2"
},
})
local handle = Roact.mount(element, container)
expect(container.Frame.button.textLabel.Text).to.equal("Option 1 (default)")
Roact.unmount(handle)
end)
end
@@ -0,0 +1,24 @@
local CorePackages = game:GetService("CorePackages")
return function()
local Roact = require(CorePackages.Roact)
local LabeledLocaleSelector = require(script.Parent.Parent.Components.LabeledLocaleSelector)
it("mounts and unmounts", function()
local element = Roact.createElement(LabeledLocaleSelector)
local handle = Roact.mount(element)
Roact.unmount(handle)
end)
it("inits with label text and displays that label text", function()
local container = Instance.new("Frame")
local element = Roact.createElement(LabeledLocaleSelector, {
LabelText = "Choose your locale"
})
local handle = Roact.mount(element, container)
expect(container.Frame.LocaleIdLabel.Text).to.equal("Choose your locale")
Roact.unmount(handle)
end)
end
@@ -0,0 +1,32 @@
local CorePackages = game:GetService("CorePackages")
local function recursivePrint(node, indent)
indent = indent or ""
for _, child in pairs(node:GetChildren()) do
print("|"..indent..tostring( child.Name ))
recursivePrint(child, indent.." ")
end
end
return function()
local Roact = require(CorePackages.Roact)
local LocaleSelector = require(script.Parent.Parent.Components.LocaleSelector)
it("mounts and unmounts", function()
local element = Roact.createElement(LocaleSelector)
local handle = Roact.mount(element)
Roact.unmount(handle)
end)
it("inits with a selected locale and displays that locale", function()
local container = Instance.new("Frame")
local element = Roact.createElement(LocaleSelector, {
InitialLocaleId = "kw-gb"
})
local handle = Roact.mount(element, container)
expect(container.Frame.LocaleIdTextBox.TextboxInternal.Text).to.equal("kw-gb")
Roact.unmount(handle)
end)
end

Some files were not shown because too many files have changed in this diff Show More