add gs
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
-- Written by Kip Turner, Copyright Roblox 2015
|
||||
local RunService = game:GetService('RunService')
|
||||
local GuiService = game:GetService('GuiService')
|
||||
local TextService = game:GetService('TextService')
|
||||
local Players = game:GetService('Players')
|
||||
|
||||
local PlatformService = nil
|
||||
pcall(function() PlatformService = game:GetService('PlatformService') end)
|
||||
|
||||
local Util = {}
|
||||
do
|
||||
|
||||
function Util.IsFinite(num)
|
||||
return num == num and num ~= 1/0 and num ~= -1/0
|
||||
end
|
||||
|
||||
function Util.FindAncestorOfType(guiObject, class)
|
||||
local parent = guiObject and guiObject.Parent
|
||||
if parent then
|
||||
if parent:IsA(class) then
|
||||
return parent
|
||||
else
|
||||
return Util.FindAncestorOfType(parent, class)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Util.CalculateRelativeDimensions(guiObject, guiDims, mockup_dims, rootGuiSize)
|
||||
local guiResolution = GuiService:GetScreenResolution()
|
||||
local parentSurfaceGui = Util.FindAncestorOfType(guiObject, 'SurfaceGui')
|
||||
if parentSurfaceGui then
|
||||
guiResolution = parentSurfaceGui.CanvasSize
|
||||
end
|
||||
local absolutePercentSize = (guiDims / mockup_dims)
|
||||
if mockup_dims.y > 0 and guiResolution.y > 0 then
|
||||
local mockupAspectRatio = mockup_dims.x / mockup_dims.y
|
||||
local globalAspectRatio = guiResolution.x / guiResolution.y
|
||||
absolutePercentSize = absolutePercentSize * (mockupAspectRatio / globalAspectRatio)
|
||||
local parentObject = guiObject.Parent
|
||||
if parentObject then
|
||||
local parentPercentScreen = parentObject.AbsoluteSize / guiResolution
|
||||
local parentSizeInverse = 1 / parentPercentScreen
|
||||
if Util.IsFinite(parentSizeInverse.x) and Util.IsFinite(parentSizeInverse.y) then
|
||||
return UDim2.new(parentSizeInverse.x * absolutePercentSize.x, 0, parentSizeInverse.y * absolutePercentSize.y, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
return UDim2.new(absolutePercentSize.x, 0, absolutePercentSize.y, 0)
|
||||
end
|
||||
|
||||
|
||||
-- Anchor Graph
|
||||
-- 1 2 3
|
||||
-- 4 5 6
|
||||
-- 7 8 9
|
||||
|
||||
Util.Enum =
|
||||
{
|
||||
Anchor =
|
||||
{
|
||||
TopLeft = 1;
|
||||
TopMiddle = 2;
|
||||
TopRight = 3;
|
||||
CenterLeft = 4;
|
||||
Center = 5;
|
||||
CenterRight = 6;
|
||||
BottomLeft = 7;
|
||||
BottomMiddle = 8;
|
||||
BottomRight = 9;
|
||||
};
|
||||
};
|
||||
|
||||
function Util.CalculateFit(containerObject, rawImageSize)
|
||||
local absSize = containerObject.AbsoluteSize
|
||||
local scalar = absSize / rawImageSize
|
||||
local fixedSize = rawImageSize * math.min(scalar.X, scalar.Y)
|
||||
|
||||
return UDim2.new(0, fixedSize.X , 0, fixedSize.Y)
|
||||
end
|
||||
|
||||
function Util.CalculateFill(containerObject, rawImageSize)
|
||||
local absSize = containerObject.AbsoluteSize
|
||||
local scalar = absSize / rawImageSize
|
||||
local fixedSize = rawImageSize * math.max(scalar.X, scalar.Y)
|
||||
|
||||
return UDim2.new(0, fixedSize.X , 0, fixedSize.Y)
|
||||
end
|
||||
|
||||
function Util.Create(instanceType)
|
||||
return function(data)
|
||||
local obj = Instance.new(instanceType)
|
||||
for k, v in pairs(data) do
|
||||
if type(k) == 'number' then
|
||||
v.Parent = obj
|
||||
else
|
||||
obj[k] = v
|
||||
end
|
||||
end
|
||||
return obj
|
||||
end
|
||||
end
|
||||
|
||||
---- TWEENING ----
|
||||
local ActiveTweens = {}
|
||||
|
||||
local function getActiveTween(prop, instance)
|
||||
return ActiveTweens[prop] and ActiveTweens[prop][instance]
|
||||
end
|
||||
|
||||
local function setActiveTween(prop, instance, newTween)
|
||||
if not ActiveTweens[prop] then
|
||||
ActiveTweens[prop] = {}
|
||||
end
|
||||
ActiveTweens[prop][instance] = newTween
|
||||
end
|
||||
|
||||
function Util.Linear(t, b, c, d)
|
||||
if t >= d then return b + c end
|
||||
|
||||
return c*t/d + b
|
||||
end
|
||||
|
||||
function Util.EaseOutQuad(t, b, c, d)
|
||||
if t >= d then return b + c end
|
||||
|
||||
t = t/d;
|
||||
return -c * t*(t-2) + b
|
||||
end
|
||||
|
||||
function Util.EaseInOutQuad(t, b, c, d)
|
||||
if t >= d then return b + c end
|
||||
|
||||
t = t / (d/2);
|
||||
if (t < 1) then return c/2*t*t + b end;
|
||||
t = t - 1;
|
||||
return -c/2 * (t*(t-2) - 1) + b;
|
||||
end
|
||||
|
||||
function Util.SCurveUDim2(t, b, c, d)
|
||||
if t >= d then return b + c end
|
||||
t = t / d;
|
||||
if t < 0 then t = 0 end
|
||||
if t > 1 then t = 1 end
|
||||
local T = 3 * t * t - 2 * t * t * t
|
||||
return UDim2.new(
|
||||
T * c.X.Scale,
|
||||
T * c.X.Offset,
|
||||
T * c.Y.Scale,
|
||||
T * c.Y.Offset) + b
|
||||
end
|
||||
|
||||
function Util.SCurveVector2(t, b, c, d)
|
||||
if t >= d then return b + c end
|
||||
t = t / d;
|
||||
if t < 0 then t = 0 end
|
||||
if t > 1 then t = 1 end
|
||||
local T = 3 * t * t - 2 * t * t * t
|
||||
return Vector2.new(
|
||||
T * c.X,
|
||||
T * c.Y) + b
|
||||
end
|
||||
|
||||
function Util.PropertyTweener(instance, prop, start, final, duration, easingFunc, override, callbackFunction)
|
||||
easingFunc = easingFunc or Util.Linear
|
||||
override = override or false
|
||||
|
||||
local this = {}
|
||||
this.StartTime = tick()
|
||||
this.EndTime = this.StartTime + duration
|
||||
this.Cancelled = false
|
||||
|
||||
local finished = false
|
||||
local percentComplete = 0
|
||||
|
||||
|
||||
local function setValue(newValue)
|
||||
if instance then
|
||||
instance[prop] = newValue
|
||||
end
|
||||
end
|
||||
|
||||
local function finalize()
|
||||
setValue(easingFunc(1, start, final - start, 1))
|
||||
finished = true
|
||||
percentComplete = 1
|
||||
|
||||
if getActiveTween(prop, instance) == this then
|
||||
setActiveTween(prop, instance, nil)
|
||||
end
|
||||
|
||||
if callbackFunction then
|
||||
callbackFunction()
|
||||
end
|
||||
end
|
||||
|
||||
if override or not getActiveTween(prop, instance) then
|
||||
if getActiveTween(prop, instance) then
|
||||
getActiveTween(prop, instance):Cancel()
|
||||
end
|
||||
setActiveTween(prop, instance, this)
|
||||
|
||||
-- Initial set
|
||||
setValue(easingFunc(0, start, final - start, duration))
|
||||
spawn(function()
|
||||
local now = tick()
|
||||
while now < this.EndTime and instance and not this.Cancelled do
|
||||
setValue(easingFunc(now - this.StartTime, start, final - start, duration))
|
||||
percentComplete = Util.Clamp(0, 1, (now - this.StartTime) / duration)
|
||||
RunService.RenderStepped:wait()
|
||||
now = tick()
|
||||
end
|
||||
if this.Cancelled == false and instance then
|
||||
finalize()
|
||||
end
|
||||
|
||||
if getActiveTween(prop, instance) == this then
|
||||
setActiveTween(prop, instance, nil)
|
||||
end
|
||||
end)
|
||||
else
|
||||
finished = true
|
||||
end
|
||||
|
||||
function this:GetFinal()
|
||||
return final
|
||||
end
|
||||
|
||||
function this:GetPercentComplete()
|
||||
return percentComplete
|
||||
end
|
||||
|
||||
function this:IsFinished()
|
||||
return finished
|
||||
end
|
||||
|
||||
function this:Finish()
|
||||
if not finished then
|
||||
self:Cancel()
|
||||
finalize()
|
||||
end
|
||||
end
|
||||
|
||||
function this:Cancel()
|
||||
this.Cancelled = true
|
||||
finished = true
|
||||
if getActiveTween(prop, instance) == this then
|
||||
setActiveTween(prop, instance, nil)
|
||||
end
|
||||
end
|
||||
|
||||
return this
|
||||
end
|
||||
|
||||
function Util.CancelTweens(tweens)
|
||||
for i, tween in pairs(tweens) do
|
||||
tween:Finish()
|
||||
tweens[i] = nil
|
||||
end
|
||||
end
|
||||
---------------
|
||||
|
||||
--- EVENTS ----
|
||||
function Util.Signal()
|
||||
local sig = {}
|
||||
|
||||
local mSignaler = Instance.new('BindableEvent')
|
||||
|
||||
local mArgData = nil
|
||||
local mArgDataCount = nil
|
||||
|
||||
function sig:fire(...)
|
||||
mArgData = {...}
|
||||
mArgDataCount = select('#', ...)
|
||||
mSignaler:Fire()
|
||||
end
|
||||
|
||||
function sig:connect(f)
|
||||
if not f then error("connect(nil)", 2) end
|
||||
return mSignaler.Event:connect(function()
|
||||
f(unpack(mArgData, 1, mArgDataCount))
|
||||
end)
|
||||
end
|
||||
|
||||
function sig:wait()
|
||||
mSignaler.Event:wait()
|
||||
assert(mArgData, "Missing arg data, likely due to :TweenSize/Position corrupting threadrefs.")
|
||||
return unpack(mArgData, 1, mArgDataCount)
|
||||
end
|
||||
|
||||
return sig
|
||||
end
|
||||
|
||||
function Util.DisconnectEvent(conn)
|
||||
if conn then
|
||||
conn:disconnect()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Util.DisconnectEvents(conns)
|
||||
if conns and type(conns) == 'table' then
|
||||
for _, conn in pairs(conns) do
|
||||
conn:disconnect()
|
||||
end
|
||||
end
|
||||
end
|
||||
--------------
|
||||
|
||||
-- MATH --
|
||||
function Util.Clamp(low, high, input)
|
||||
return math.max(low, math.min(high, input))
|
||||
end
|
||||
|
||||
function Util.ClampVector2(low, high, input)
|
||||
return Vector2.new(Util.Clamp(low.x, high.x, input.x), Util.Clamp(low.y, high.y, input.y))
|
||||
end
|
||||
|
||||
function Util.TweenPositionOrSet(guiObject, ...)
|
||||
if guiObject:IsDescendantOf(game) then
|
||||
guiObject:TweenPosition(...)
|
||||
else
|
||||
guiObject.Position = select(1, ...)
|
||||
end
|
||||
end
|
||||
----
|
||||
|
||||
function Util.ClampCanvasPosition(scrollingContainer, position)
|
||||
local container = scrollingContainer
|
||||
local parentSize = container.Parent and container.Parent.AbsoluteSize or Vector2.new(0,0)
|
||||
local absoluteCanvasSize = Vector2.new(container.CanvasSize.X.Scale * parentSize.X + container.CanvasSize.X.Offset,
|
||||
container.CanvasSize.Y.Scale * parentSize.Y + container.CanvasSize.Y.Offset)
|
||||
local nextX = Util.Clamp(0, absoluteCanvasSize.X - container.AbsoluteWindowSize.X, position.X)
|
||||
local nextY = Util.Clamp(0, absoluteCanvasSize.Y - container.AbsoluteWindowSize.Y, position.Y)
|
||||
|
||||
return Vector2.new(nextX, nextY)
|
||||
end
|
||||
|
||||
function Util.Round(num, roundToNearest)
|
||||
roundToNearest = roundToNearest or 1
|
||||
return math.floor((num + roundToNearest/2) / roundToNearest) * roundToNearest
|
||||
end
|
||||
--------------
|
||||
|
||||
-- FORMATING --
|
||||
-- Removed whitespace from the beginning and end of the string
|
||||
function Util.ChompString(str)
|
||||
return tostring(str):gsub("^%s+" , ""):gsub("%s+$" , "")
|
||||
end
|
||||
|
||||
-- replace multiple whitespace with one; remove leading and trailing whitespace
|
||||
function Util.SpaceNormalizeString(str)
|
||||
return tostring(str):gsub("%s+", " "):gsub("^%s+" , ""):gsub("%s+$" , "")
|
||||
end
|
||||
|
||||
function Util.FormatNumberString(value)
|
||||
-- Make sure beginning and end of the string is clipped
|
||||
local stringValue = Util.ChompString(value)
|
||||
return stringValue:reverse():gsub("%d%d%d", "%1,"):reverse():gsub("^,", "")
|
||||
end
|
||||
|
||||
-- PrettyPrint function for formatting data structures into flat strings, useful for debugging
|
||||
|
||||
function Util.PrettyPrint(tb)
|
||||
if type(tb) == 'table' then
|
||||
local str = "{"
|
||||
for k, v in pairs(tb) do
|
||||
str = ((str == "{") and str or str..", ")
|
||||
if type(k) == 'string' then
|
||||
str = str..k.." = "
|
||||
elseif type(k) == 'number' then
|
||||
-- nothing
|
||||
else
|
||||
str = str.."["..k.."] = "
|
||||
end
|
||||
str = str..Util.PrettyPrint(v)
|
||||
end
|
||||
return str.."}"
|
||||
elseif type(tb) == 'string' then
|
||||
return "'"..tb.."'"
|
||||
else
|
||||
return tostring(tb)
|
||||
end
|
||||
end
|
||||
|
||||
local isSandboxed = nil
|
||||
function Util.DebugLog(...)
|
||||
if isSandboxed == nil then
|
||||
isSandboxed = PlatformService and PlatformService:IsSandboxed()
|
||||
end
|
||||
if isSandboxed or PlatformService == nil then
|
||||
print(...)
|
||||
end
|
||||
end
|
||||
|
||||
-- K is a tunable parameter that changes the shape of the S-curve
|
||||
-- the larger K is the more straight/linear the curve gets
|
||||
local function SCurveTranform(t, k, lowerK)
|
||||
k = k or 0.35
|
||||
lowerK = lowerK or 0.8
|
||||
t = Util.Clamp(-1,1,t)
|
||||
if t >= 0 then
|
||||
return (k*t) / (k - t + 1)
|
||||
end
|
||||
return -((lowerK*-t) / (lowerK + t + 1))
|
||||
end
|
||||
|
||||
local function toSCurveSpace(t, deadzone)
|
||||
deadzone = deadzone or 0.1
|
||||
return (1 + deadzone) * (2*math.abs(t) - 1) - deadzone
|
||||
end
|
||||
|
||||
local function fromSCurveSpace(t)
|
||||
return t/2 + 0.5
|
||||
end
|
||||
|
||||
function Util.GamepadLinearToCurve(thumbstickPosition, deadzone, k, lowerK)
|
||||
local function onAxis(axisValue)
|
||||
local sign = axisValue < 0 and -1 or 1
|
||||
|
||||
local point = fromSCurveSpace(SCurveTranform(toSCurveSpace(math.abs(axisValue), deadzone)), k, lowerK)
|
||||
return Util.Clamp(-1,1, point * sign)
|
||||
end
|
||||
return Vector2.new(onAxis(thumbstickPosition.x), onAxis(thumbstickPosition.y))
|
||||
end
|
||||
|
||||
|
||||
function Util.IsFastFlagEnabled(flagName)
|
||||
local success, isFlagEnabled = pcall(function()
|
||||
return settings():GetFFlag(flagName)
|
||||
end)
|
||||
|
||||
if success and not isFlagEnabled then
|
||||
Util.DebugLog("Fast Flag:", flagName, "is currently not enabled.")
|
||||
elseif not success then
|
||||
Util.DebugLog("GetFFlag failed for flag:", flagName)
|
||||
end
|
||||
|
||||
return success and isFlagEnabled
|
||||
end
|
||||
|
||||
|
||||
function Util.GetFastVariable(variableName)
|
||||
local success, value = pcall(function()
|
||||
return settings():GetFVariable(variableName)
|
||||
end)
|
||||
|
||||
return success and value
|
||||
end
|
||||
|
||||
local function getLocalPlayer()
|
||||
while Players.LocalPlayer == nil do
|
||||
wait()
|
||||
end
|
||||
return Players.LocalPlayer
|
||||
end
|
||||
|
||||
function Util.IsFeatureNonZero(fastIntName)
|
||||
return (tonumber(Util.GetFastVariable(fastIntName)) or 0) ~= 0
|
||||
end
|
||||
|
||||
function Util.IsFeatureRolledOut(fastIntName)
|
||||
return getLocalPlayer().UserId % 100 < (tonumber(Util.GetFastVariable(fastIntName)) or 0)
|
||||
end
|
||||
|
||||
|
||||
function Util.ExponentialRepeat(loopPredicate, loopBody, repeatCount)
|
||||
repeatCount = repeatCount or 6
|
||||
local retryCount = 1
|
||||
local numRetries = repeatCount
|
||||
|
||||
while retryCount <= numRetries and loopPredicate() do
|
||||
local done = loopBody()
|
||||
if done then return end
|
||||
wait(retryCount ^ 2)
|
||||
retryCount = retryCount + 1
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
function Util.SplitString(str, sep)
|
||||
local result = {}
|
||||
if str and sep then
|
||||
for word in string.gmatch(str, '([^' .. sep .. ']+)') do
|
||||
table.insert(result, word)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
|
||||
local function findAssetsHelper(object, result, baseUrl)
|
||||
if not object then return end
|
||||
|
||||
if object:IsA('CharacterMesh') then
|
||||
if object.MeshId > 0 then
|
||||
table.insert(result, baseUrl .. tostring(object.MeshId))
|
||||
end
|
||||
if object.BaseTextureId > 0 then
|
||||
table.insert(result, baseUrl .. tostring(object.BaseTextureId))
|
||||
end
|
||||
if object.OverlayTextureId > 0 then
|
||||
table.insert(result, baseUrl .. tostring(object.OverlayTextureId))
|
||||
end
|
||||
elseif object:IsA('FileMesh') then
|
||||
table.insert(result, object.MeshId)
|
||||
table.insert(result, object.TextureId)
|
||||
elseif object:IsA('Decal') then
|
||||
table.insert(result, object.Texture)
|
||||
elseif object:IsA('Pants') then
|
||||
table.insert(result, object.PantsTemplate)
|
||||
elseif object:IsA('Shirt') then
|
||||
table.insert(result, object.ShirtTemplate)
|
||||
end
|
||||
|
||||
for _, child in pairs(object:GetChildren()) do
|
||||
findAssetsHelper(child, result, baseUrl)
|
||||
end
|
||||
end
|
||||
function Util.FindAssetsInModel(object, baseUrl)
|
||||
baseUrl = baseUrl or 'https://assetgame.roblox.com/asset/?id='
|
||||
local result = {}
|
||||
findAssetsHelper(object, result, baseUrl)
|
||||
return result
|
||||
end
|
||||
|
||||
function Util.ConvertFontSizeEnumToInt(fontSizeEnum)
|
||||
local name = fontSizeEnum.Name
|
||||
-- TODO: this is sort of gross?
|
||||
local result = string.match(name, '%d+')
|
||||
return result or 10
|
||||
end
|
||||
|
||||
function Util.Upper(text)
|
||||
if PlatformService then
|
||||
return PlatformService:Upper(text)
|
||||
else
|
||||
return string.upper(text)
|
||||
end
|
||||
end
|
||||
|
||||
function Util.SetSelectedCoreObject(obj)
|
||||
GuiService.SelectedCoreObject = obj
|
||||
end
|
||||
|
||||
function Util.AddSelectionParent(selectionName, selectionParent)
|
||||
GuiService:AddSelectionParent(selectionName, selectionParent)
|
||||
end
|
||||
|
||||
function Util.RemoveSelectionGroup(selectionName)
|
||||
GuiService:RemoveSelectionGroup(selectionName)
|
||||
end
|
||||
|
||||
function Util.GetTextBounds(textGui)
|
||||
local getFinalBounds = false
|
||||
local textBound = nil
|
||||
if textGui then
|
||||
if textGui.TextFits then
|
||||
textBound = textGui.TextBounds
|
||||
getFinalBounds = true
|
||||
else
|
||||
--If TextWrapped is true, we can't really figure out what are the bounds should be, we need at least get some constraints on X/Y
|
||||
if not textGui.TextWrapped then
|
||||
textBound = TextService:GetTextSize(textGui.Text, textGui.TextSize, textGui.Font, Vector2.new(0, 0))
|
||||
getFinalBounds = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return getFinalBounds, textBound
|
||||
end
|
||||
|
||||
--A utility func to resize button based on the textGui
|
||||
--(textGui doesn't necessarily a child of the button, this func just makes sure the textGui can fit in the buttonGui),
|
||||
--offsetX and offsetY are used to specify the distance from text border to button border
|
||||
--We assume the alignment style is 'Center', so always add offsetX * 2 and offsetY * 2, need to adjust the input offset if the alignment is not 'Center'
|
||||
--if it's nil, it means we don't overwrite the button's size on that axis
|
||||
--Return the result which indicateds whether we resized the button
|
||||
--Note: We resize the button by changing it's Size.Offset, so if it's original size is based on Size.Scale, the button won't be resized
|
||||
function Util.ResizeButtonWithText(buttonGui, textGui, offsetX, offsetY)
|
||||
local resized = false
|
||||
local success, textBound = Util.GetTextBounds(textGui)
|
||||
if success then
|
||||
if buttonGui.Size.X.Scale == 0 and buttonGui.Size.Y.Scale == 0 then
|
||||
--Can't depend on the AbsoluteSize, which will be 0,0 if the buttonGui is not a descendant of worksapce
|
||||
local buttonAbsSize = Vector2.new(buttonGui.Size.X.Offset, buttonGui.Size.Y.Offset)
|
||||
local newAbsSizeX, newAbsSizeY = buttonAbsSize.X, buttonAbsSize.Y
|
||||
if offsetX and buttonAbsSize.X < textBound.X + offsetX * 2 then
|
||||
newAbsSizeX = textBound.X + offsetX * 2
|
||||
resized = true
|
||||
end
|
||||
if offsetY and buttonAbsSize.Y < textBound.Y + offsetY * 2 then
|
||||
newAbsSizeY = textBound.Y + offsetY * 2
|
||||
resized = true
|
||||
end
|
||||
|
||||
if resized then
|
||||
buttonGui.Size = UDim2.new(0, newAbsSizeX, 0, newAbsSizeY)
|
||||
end
|
||||
end
|
||||
end
|
||||
return resized
|
||||
end
|
||||
|
||||
--This is used to get the suitable size for the buttonGui to fit different texts
|
||||
--alternativeTexts is an array of all possible texts in the buttonGui
|
||||
function Util.ResizeButtonWithDynamicText(buttonGui, textGui, alternativeTexts, offsetX, offsetY)
|
||||
local resized = false
|
||||
resized = Util.ResizeButtonWithText(buttonGui, textGui, offsetX, offsetY) or resized
|
||||
local originalText = textGui.Text
|
||||
if alternativeTexts and #alternativeTexts > 0 then
|
||||
for i = 1, #alternativeTexts do
|
||||
textGui.Text = alternativeTexts[i]
|
||||
resized = Util.ResizeButtonWithText(buttonGui, textGui, offsetX, offsetY) or resized
|
||||
end
|
||||
end
|
||||
|
||||
textGui.Text = originalText
|
||||
return resized
|
||||
end
|
||||
end
|
||||
|
||||
return Util
|
||||
Reference in New Issue
Block a user