Implement scene and app
This commit is contained in:
parent
20b2fee279
commit
0749f01e86
@ -31,7 +31,7 @@ _Meta_ follows the following principles:
|
|||||||
It knows the following layers of UI:
|
It knows the following layers of UI:
|
||||||
|
|
||||||
- An app is the entry point of the executable, containing the windows.
|
- An app is the entry point of the executable, containing the windows.
|
||||||
- A window is a container holding one or multiple views.
|
- A scene element is a template for a container holding one or multiple views (e.g. a window).
|
||||||
- A view is a part of the actual UI inside a window, another view or a menu.
|
- A view is a part of the actual UI inside a window, another view or a menu.
|
||||||
- A menu is a list of buttons, other menus, and views. Certain views (such as menu buttons) allow menus to be used.
|
- A menu is a list of buttons, other menus, and views. Certain views (such as menu buttons) allow menus to be used.
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,6 @@ _Meta_ follows the following principles:
|
|||||||
It knows the following layers of UI:
|
It knows the following layers of UI:
|
||||||
|
|
||||||
- An app is the entry point of the executable, containing the windows.
|
- An app is the entry point of the executable, containing the windows.
|
||||||
- A window is a container holding one or multiple views.
|
- A scene element is a template for a container holding one or multiple views (e.g. a window).
|
||||||
- A view is a part of the actual UI inside a window, another view or a menu.
|
- A view is a part of the actual UI inside a window, another view or a menu.
|
||||||
- A menu is a list of buttons, other menus, and views. Certain views (such as menu buttons) allow menus to be used.
|
- A menu is a list of buttons, other menus, and views. Certain views (such as menu buttons) allow menus to be used.
|
||||||
|
|||||||
@ -14,6 +14,8 @@ public enum StateManager {
|
|||||||
public static var blockUpdates = false
|
public static var blockUpdates = false
|
||||||
/// Whether to save state.
|
/// Whether to save state.
|
||||||
public static var saveState = true
|
public static var saveState = true
|
||||||
|
/// The application identifier.
|
||||||
|
static var appID: String?
|
||||||
/// The functions handling view updates.
|
/// The functions handling view updates.
|
||||||
static var updateHandlers: [(Bool) -> Void] = []
|
static var updateHandlers: [(Bool) -> Void] = []
|
||||||
/// The state.
|
/// The state.
|
||||||
@ -35,7 +37,7 @@ public enum StateManager {
|
|||||||
func contains(id: UUID) -> Bool {
|
func contains(id: UUID) -> Bool {
|
||||||
ids.first == id || ids.second == id
|
ids.first == id || ids.second == id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the identifier to a new one.
|
/// Change the identifier to a new one.
|
||||||
/// - Parameter newID: The new identifier.
|
/// - Parameter newID: The new identifier.
|
||||||
mutating func changeID(new newID: UUID) {
|
mutating func changeID(new newID: UUID) {
|
||||||
|
|||||||
79
Sources/Model/User Interface/App/App.swift
Normal file
79
Sources/Model/User Interface/App/App.swift
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// App.swift
|
||||||
|
// Meta
|
||||||
|
//
|
||||||
|
// Created by david-swift on 01.07.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
/// A structure conforming to `App` is the entry point of your app.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// @main
|
||||||
|
/// struct Test: App {
|
||||||
|
///
|
||||||
|
/// let id = "io.github.AparokshaUI.TestApp"
|
||||||
|
///
|
||||||
|
/// var scene: Scene {
|
||||||
|
/// WindowScene()
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
public protocol App {
|
||||||
|
|
||||||
|
/// The app storage typ.
|
||||||
|
associatedtype Storage: AppStorage
|
||||||
|
|
||||||
|
/// The app's application ID.
|
||||||
|
var id: String { get }
|
||||||
|
/// The app's scene.
|
||||||
|
@SceneBuilder var scene: Scene { get }
|
||||||
|
// swiftlint:disable implicitly_unwrapped_optional
|
||||||
|
/// The app storage.
|
||||||
|
var app: Storage! { get set }
|
||||||
|
// swiftlint:enable implicitly_unwrapped_optional
|
||||||
|
|
||||||
|
/// An app has to have an `init()` initializer.
|
||||||
|
init()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension App {
|
||||||
|
|
||||||
|
/// The application's entry point.
|
||||||
|
public static func main() {
|
||||||
|
let app = setupApp()
|
||||||
|
app.app.run {
|
||||||
|
for element in app.scene {
|
||||||
|
element.setupInitialContainers(app: app.app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize and get the app with the app storage.
|
||||||
|
///
|
||||||
|
/// To run the app, call the ``AppStorage/run(automaticSetup:manualSetup:)`` function.
|
||||||
|
public static func setupApp() -> Self {
|
||||||
|
var appInstance = self.init()
|
||||||
|
appInstance.app = Storage(id: appInstance.id) { appInstance }
|
||||||
|
StateManager.addUpdateHandler { force in
|
||||||
|
var removeIndices: [Int] = []
|
||||||
|
for (index, element) in appInstance.app.sceneStorage.enumerated() {
|
||||||
|
if element.destroy {
|
||||||
|
removeIndices.insert(index, at: 0)
|
||||||
|
} else if let scene = appInstance.scene.first(
|
||||||
|
where: { $0.id == element.id }
|
||||||
|
) as? Storage.SceneElementType as? SceneElement {
|
||||||
|
scene.update(element, app: appInstance.app, updateProperties: force)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index in removeIndices {
|
||||||
|
appInstance.app.sceneStorage.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StateManager.appID = appInstance.id
|
||||||
|
return appInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
67
Sources/Model/User Interface/App/AppStorage.swift
Normal file
67
Sources/Model/User Interface/App/AppStorage.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// AppStorage.swift
|
||||||
|
// Meta
|
||||||
|
//
|
||||||
|
// Created by david-swift on 01.07.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
/// The app storage protocol.
|
||||||
|
public protocol AppStorage: AnyObject {
|
||||||
|
|
||||||
|
/// The type of scene elements (which should be backend-specific).
|
||||||
|
associatedtype SceneElementType
|
||||||
|
/// The type of widget elements (which should be backend-specific).
|
||||||
|
associatedtype WidgetType
|
||||||
|
|
||||||
|
/// The scene.
|
||||||
|
var app: () -> any App { get }
|
||||||
|
|
||||||
|
/// The scene storage.
|
||||||
|
var sceneStorage: [SceneStorage] { get set }
|
||||||
|
|
||||||
|
/// Initialize the app storage.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The app's identifier.
|
||||||
|
/// - app: Get the application.
|
||||||
|
init(id: String, app: @escaping () -> any App)
|
||||||
|
|
||||||
|
/// Run the application.
|
||||||
|
/// - Parameter setup: A closure that is expected to be executed right at the beginning.
|
||||||
|
func run(setup: @escaping () -> Void)
|
||||||
|
|
||||||
|
/// Terminate the application.
|
||||||
|
func quit()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppStorage {
|
||||||
|
|
||||||
|
/// Focus the scene element with a certain id (if supported). Create the element if it doesn't already exist.
|
||||||
|
/// - Parameter id: The element's id.
|
||||||
|
public func showSceneElement(_ id: String) {
|
||||||
|
sceneStorage.last { $0.id == id && !$0.destroy }?.show() ?? addSceneElement(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new scene element with the content of the scene element with a certain id.
|
||||||
|
/// - Parameter id: The element's id.
|
||||||
|
public func addSceneElement(_ id: String) {
|
||||||
|
if let element = app().scene.last(where: { $0.id == id }) {
|
||||||
|
let container = element.container(app: self)
|
||||||
|
sceneStorage.append(container)
|
||||||
|
showSceneElement(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus the window with a certain id (if supported). Create the window if it doesn't already exist.
|
||||||
|
/// - Parameter id: The window's id.
|
||||||
|
public func showWindow(_ id: String) {
|
||||||
|
showSceneElement(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new window with the content of the window template with a certain id.
|
||||||
|
/// - Parameter id: The window template's id.
|
||||||
|
public func addWindow(_ id: String) {
|
||||||
|
addSceneElement(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
68
Sources/Model/User Interface/Scene/SceneBuilder.swift
Normal file
68
Sources/Model/User Interface/Scene/SceneBuilder.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// SceneBuilder.swift
|
||||||
|
// Meta
|
||||||
|
//
|
||||||
|
// Created by david-swift on 30.06.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The ``SceneBuilder`` is a result builder for scenes.
|
||||||
|
@resultBuilder
|
||||||
|
public enum SceneBuilder {
|
||||||
|
|
||||||
|
/// A component used in the ``SceneBuilder``.
|
||||||
|
public enum Component {
|
||||||
|
|
||||||
|
/// A scene as a component.
|
||||||
|
case element(_: any SceneElement)
|
||||||
|
/// An array of components as a component.
|
||||||
|
case components(_: [Self])
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build combined results from statement blocks.
|
||||||
|
/// - Parameter components: The components.
|
||||||
|
/// - Returns: The components in a component.
|
||||||
|
public static func buildBlock(_ elements: Component...) -> Component {
|
||||||
|
.components(elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate an element into a ``SceneBuilder.Component``.
|
||||||
|
/// - Parameter element: The element to translate.
|
||||||
|
/// - Returns: A component created from the element.
|
||||||
|
public static func buildExpression(_ element: any SceneElement) -> Component {
|
||||||
|
.element(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate an array of elements into a ``SceneBuilder.Component``.
|
||||||
|
/// - Parameter elements: The elements to translate.
|
||||||
|
/// - Returns: A component created from the element.
|
||||||
|
public static func buildExpression(_ elements: [any SceneElement]) -> Component {
|
||||||
|
var components: [Component] = []
|
||||||
|
for element in elements {
|
||||||
|
components.append(.element(element))
|
||||||
|
}
|
||||||
|
return .components(components)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a component.
|
||||||
|
/// - Parameter component: A component.
|
||||||
|
/// - Returns: The component.
|
||||||
|
public static func buildExpression(_ component: Component) -> Component {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a component to an array of elements.
|
||||||
|
/// - Parameter component: The component to convert.
|
||||||
|
/// - Returns: The generated array of elements.
|
||||||
|
public static func buildFinalResult(_ component: Component) -> Scene {
|
||||||
|
switch component {
|
||||||
|
case let .element(element):
|
||||||
|
return [element]
|
||||||
|
case let .components(components):
|
||||||
|
return components.flatMap { buildFinalResult($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
Sources/Model/User Interface/Scene/SceneElement.swift
Normal file
28
Sources/Model/User Interface/Scene/SceneElement.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// SceneElement.swift
|
||||||
|
// Meta
|
||||||
|
//
|
||||||
|
// Created by david-swift on 30.06.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
/// A structure conforming to `SceneElement` can be added to an app's `scene` property.
|
||||||
|
public protocol SceneElement {
|
||||||
|
|
||||||
|
/// The window type's identifier.
|
||||||
|
var id: String { get }
|
||||||
|
/// Set up the initial scene storages.
|
||||||
|
/// - Parameter app: The app storage.
|
||||||
|
func setupInitialContainers<Storage>(app: Storage) where Storage: AppStorage
|
||||||
|
/// The scene storage.
|
||||||
|
/// - Parameter app: The app storage.
|
||||||
|
func container<Storage>(app: Storage) -> SceneStorage where Storage: AppStorage
|
||||||
|
/// Update the stored content.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - storage: The storage to update.
|
||||||
|
/// - updateProperties: Whether to update the view's properties.
|
||||||
|
func update<Storage>(_ storage: SceneStorage, app: Storage, updateProperties: Bool) where Storage: AppStorage
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Scene` is an array of scene elements.
|
||||||
|
public typealias Scene = [any SceneElement]
|
||||||
55
Sources/Model/User Interface/Scene/SceneStorage.swift
Normal file
55
Sources/Model/User Interface/Scene/SceneStorage.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// SceneStorage.swift
|
||||||
|
// Meta
|
||||||
|
//
|
||||||
|
// Created by david-swift on 30.06.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
/// Store a reference to a rendered scene element in a view storage.
|
||||||
|
public class SceneStorage {
|
||||||
|
|
||||||
|
/// The scene element's identifier.
|
||||||
|
public var id: String
|
||||||
|
/// The pointer.
|
||||||
|
///
|
||||||
|
/// It can be a C pointer, a Swift class, or other information depending on the backend,
|
||||||
|
/// or it can be left out.
|
||||||
|
public var pointer: Any?
|
||||||
|
/// The scene element's view content.
|
||||||
|
public var content: [String: [ViewStorage]]
|
||||||
|
/// Various properties of a scene element.
|
||||||
|
public var fields: [String: Any] = [:]
|
||||||
|
/// Whether the reference to the window should disappear in the next update.
|
||||||
|
public var destroy = false
|
||||||
|
/// Show the scene element (including moving into the foreground, if possible).
|
||||||
|
public var show: () -> Void
|
||||||
|
|
||||||
|
/// The pointer as an opaque pointer, as this may be needed with backends interoperating with C or C++.
|
||||||
|
public var opaquePointer: OpaquePointer? {
|
||||||
|
get {
|
||||||
|
pointer as? OpaquePointer
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
pointer = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a scene storage.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The scene element's identifier.
|
||||||
|
/// - pointer: The pointer to the widget, its type depends on the backend.
|
||||||
|
/// - content: The view's content for container widgets.
|
||||||
|
/// - show: Function called when the scene element should be displayed.
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
pointer: Any?,
|
||||||
|
content: [String: [ViewStorage]] = [:],
|
||||||
|
show: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.pointer = pointer
|
||||||
|
self.content = content
|
||||||
|
self.show = show
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -56,7 +56,7 @@ public enum ViewBuilder {
|
|||||||
/// Convert a component to an array of elements.
|
/// Convert a component to an array of elements.
|
||||||
/// - Parameter component: The component to convert.
|
/// - Parameter component: The component to convert.
|
||||||
/// - Returns: The generated array of elements.
|
/// - Returns: The generated array of elements.
|
||||||
public static func buildFinalResult(_ component: Component) -> [AnyView] {
|
public static func buildFinalResult(_ component: Component) -> Body {
|
||||||
switch component {
|
switch component {
|
||||||
case let .element(element):
|
case let .element(element):
|
||||||
return [element]
|
return [element]
|
||||||
|
|||||||
@ -3,16 +3,44 @@ import Meta
|
|||||||
import Observation
|
import Observation
|
||||||
import SampleBackends
|
import SampleBackends
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TestExecutable {
|
||||||
|
|
||||||
|
public static func main() {
|
||||||
|
DemoApp.main()
|
||||||
|
sleep(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 14, *)
|
||||||
|
@available(iOS 17, *)
|
||||||
|
struct DemoApp: App {
|
||||||
|
|
||||||
|
typealias Storage = Backend1.Backend1App
|
||||||
|
let id = "io.github.AparokshaUI.DemoApp"
|
||||||
|
var app: Backend1.Backend1App!
|
||||||
|
|
||||||
|
var scene: Scene {
|
||||||
|
Backend1.Window("main", spawn: 1) {
|
||||||
|
DemoView(app: app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@available(macOS 14, *)
|
@available(macOS 14, *)
|
||||||
@available(iOS 17, *)
|
@available(iOS 17, *)
|
||||||
struct DemoView: View {
|
struct DemoView: View {
|
||||||
|
|
||||||
@State private var model = TestModel()
|
@State private var model = TestModel()
|
||||||
|
var app: Backend1.Backend1App
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
Backend1.TestWidget1()
|
Backend1.TestWidget1()
|
||||||
Backend1.Button(model.test) {
|
Backend1.Button(model.test) {
|
||||||
model.test = "\(Int.random(in: 1...10))"
|
model.test = "\(Int.random(in: 1...10))"
|
||||||
|
app.addSceneElement("main")
|
||||||
}
|
}
|
||||||
TestView()
|
TestView()
|
||||||
testContent
|
testContent
|
||||||
@ -51,26 +79,3 @@ class TestModel {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
|
||||||
@available(macOS 14, *)
|
|
||||||
@available(iOS 17, *)
|
|
||||||
struct DemoApp {
|
|
||||||
|
|
||||||
static func main() {
|
|
||||||
let backendType = Backend1.BackendWidget.self
|
|
||||||
|
|
||||||
let storage = DemoView().storage(modifiers: [], type: backendType)
|
|
||||||
for round in 0...2 {
|
|
||||||
print("#\(round)")
|
|
||||||
DemoView().updateStorage(storage, modifiers: [], updateProperties: true, type: backendType)
|
|
||||||
}
|
|
||||||
|
|
||||||
StateManager.addUpdateHandler { force in
|
|
||||||
print("#*")
|
|
||||||
DemoView().updateStorage(storage, modifiers: [], updateProperties: force, type: backendType)
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@ -70,7 +70,67 @@ public enum Backend1 {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Window: BackendSceneElement {
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
var spawn: Int
|
||||||
|
var content: Body
|
||||||
|
|
||||||
|
public init(_ id: String, spawn: Int, @ViewBuilder content: () -> Body) {
|
||||||
|
self.id = id
|
||||||
|
self.spawn = spawn
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setupInitialContainers<Storage>(app: Storage) where Storage: AppStorage {
|
||||||
|
for _ in 0..<spawn {
|
||||||
|
app.sceneStorage.append(container(app: app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func container<Storage>(app: Storage) -> SceneStorage where Storage: AppStorage {
|
||||||
|
print("Show \(id)")
|
||||||
|
let viewStorage = content.storage(modifiers: [], type: Storage.WidgetType.self)
|
||||||
|
return .init(id: id, pointer: nil, content: [.mainContent : [viewStorage]]) {
|
||||||
|
print("Make visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update<Storage>(_ storage: SceneStorage, app: Storage, updateProperties: Bool) where Storage: AppStorage {
|
||||||
|
print("Update \(id)")
|
||||||
|
guard let viewStorage = storage.content[.mainContent]?.first else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content.updateStorage(viewStorage, modifiers: [], updateProperties: updateProperties, type: Storage.WidgetType.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public protocol BackendWidget: Widget { }
|
public protocol BackendWidget: Widget { }
|
||||||
|
|
||||||
|
public protocol BackendSceneElement: SceneElement { }
|
||||||
|
|
||||||
|
public class Backend1App: AppStorage {
|
||||||
|
|
||||||
|
public typealias SceneElementType = BackendSceneElement
|
||||||
|
public typealias WidgetType = BackendWidget
|
||||||
|
|
||||||
|
public var app: () -> any App
|
||||||
|
public var sceneStorage: [SceneStorage] = []
|
||||||
|
|
||||||
|
public required init(id: String, app: @escaping () -> any App) {
|
||||||
|
self.app = app
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run(setup: @escaping () -> Void) {
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func quit() {
|
||||||
|
fatalError("Quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,4 +40,28 @@ public enum Backend2 {
|
|||||||
|
|
||||||
public protocol BackendWidget: Widget { }
|
public protocol BackendWidget: Widget { }
|
||||||
|
|
||||||
|
public protocol BackendSceneElement: SceneElement { }
|
||||||
|
|
||||||
|
public class Backend2App: AppStorage {
|
||||||
|
|
||||||
|
public typealias SceneElementType = BackendSceneElement
|
||||||
|
public typealias WidgetType = BackendWidget
|
||||||
|
|
||||||
|
public var app: () -> any App
|
||||||
|
public var sceneStorage: [SceneStorage] = []
|
||||||
|
|
||||||
|
public required init(id: String, app: @escaping () -> any App) {
|
||||||
|
self.app = app
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run(setup: @escaping () -> Void) {
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func quit() {
|
||||||
|
fatalError("Quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user