增加换肤功能
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* AnyCodable
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A type-erased `Encodable` value.
|
||||
|
||||
Heavily inspired by: https://github.com/Flight-School/AnyCodable
|
||||
|
||||
The above can't be used as a dependency, as it triggers linker errors
|
||||
when Fluxor and FluxorTestSupport is used in a test target.
|
||||
*/
|
||||
public struct AnyCodable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
if let dictionary = value as? [String: AnyCodable] {
|
||||
self.value = dictionary as [AnyHashable: AnyCodable]
|
||||
} else if let dictionary = value as? [String: Any] {
|
||||
self.value = dictionary.mapValues(AnyCodable.init) as [AnyHashable: AnyCodable]
|
||||
} else if let array = value as? [Any], !(array is [AnyCodable]) {
|
||||
self.value = array.map(AnyCodable.init)
|
||||
} else {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: Encodable {
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is Void:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let int8 as Int8:
|
||||
try container.encode(int8)
|
||||
case let int16 as Int16:
|
||||
try container.encode(int16)
|
||||
case let int32 as Int32:
|
||||
try container.encode(int32)
|
||||
case let int64 as Int64:
|
||||
try container.encode(int64)
|
||||
case let uint as UInt:
|
||||
try container.encode(uint)
|
||||
case let uint8 as UInt8:
|
||||
try container.encode(uint8)
|
||||
case let uint16 as UInt16:
|
||||
try container.encode(uint16)
|
||||
case let uint32 as UInt32:
|
||||
try container.encode(uint32)
|
||||
case let uint64 as UInt64:
|
||||
try container.encode(uint64)
|
||||
case let float as Float:
|
||||
try container.encode(float)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let date as Date:
|
||||
try container.encode(date)
|
||||
case let url as URL:
|
||||
try container.encode(url)
|
||||
case let array as [Any?]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dictionary as [String: Any?]:
|
||||
try container.encode(dictionary.mapValues { AnyCodable($0) })
|
||||
case let encodable as Encodable:
|
||||
try encodable.encode(to: encoder)
|
||||
default:
|
||||
let debugDescription = "Value cannot be encoded"
|
||||
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: debugDescription)
|
||||
throw EncodingError.invalidValue(value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: Decodable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self.init(())
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
self.init(bool)
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
self.init(int)
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
self.init(double)
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
self.init(string)
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
self.init(array)
|
||||
} else if let dictionary = try? container.decode([String: AnyCodable].self) {
|
||||
self.init(dictionary)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Value cannot be decoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: Equatable {
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case is (Void, Void):
|
||||
return true
|
||||
case let (lhs as Bool, rhs as Bool):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int, rhs as Int):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int8, rhs as Int8):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int16, rhs as Int16):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int32, rhs as Int32):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int64, rhs as Int64):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt, rhs as UInt):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt8, rhs as UInt8):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt16, rhs as UInt16):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt32, rhs as UInt32):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt64, rhs as UInt64):
|
||||
return lhs == rhs
|
||||
case let (lhs as Float, rhs as Float):
|
||||
return lhs == rhs
|
||||
case let (lhs as Double, rhs as Double):
|
||||
return lhs == rhs
|
||||
case let (lhs as String, rhs as String):
|
||||
return lhs == rhs
|
||||
case let (lhs as [AnyHashable: AnyCodable], rhs as [AnyHashable: AnyCodable]):
|
||||
return lhs == rhs
|
||||
case let (lhs as [AnyCodable], rhs as [AnyCodable]):
|
||||
return lhs == rhs
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch value {
|
||||
case is Void:
|
||||
return String(describing: nil as Any?)
|
||||
case let value as CustomStringConvertible:
|
||||
return value.description
|
||||
default:
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch value {
|
||||
case let value as CustomDebugStringConvertible:
|
||||
return "AnyCodable(\(value.debugDescription))"
|
||||
default:
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: ExpressibleByNilLiteral {}
|
||||
extension AnyCodable: ExpressibleByBooleanLiteral {}
|
||||
extension AnyCodable: ExpressibleByIntegerLiteral {}
|
||||
extension AnyCodable: ExpressibleByFloatLiteral {}
|
||||
extension AnyCodable: ExpressibleByExtendedGraphemeClusterLiteral {}
|
||||
extension AnyCodable: ExpressibleByStringLiteral {}
|
||||
extension AnyCodable: ExpressibleByArrayLiteral {}
|
||||
extension AnyCodable: ExpressibleByDictionaryLiteral {}
|
||||
public extension AnyCodable {
|
||||
init(nilLiteral _: ()) {
|
||||
self.init(nil as Any?)
|
||||
}
|
||||
|
||||
init(booleanLiteral value: Bool) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
init(integerLiteral value: Int) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
init(floatLiteral value: Double) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
init(stringLiteral value: String) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
init(arrayLiteral elements: Any...) {
|
||||
self.init(elements)
|
||||
}
|
||||
|
||||
init(dictionaryLiteral elements: (AnyHashable, Any)...) {
|
||||
self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first }))
|
||||
}
|
||||
}
|
||||
146
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Action.swift
Normal file
146
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Action.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
An event happening in an application.
|
||||
|
||||
`Action`s are dispatched on the `Store`.
|
||||
*/
|
||||
public protocol Action {}
|
||||
|
||||
/// An `Action` which can be encoded.
|
||||
public protocol EncodableAction: Action, Encodable {
|
||||
/**
|
||||
To enable encoding of the `Action` this helper function is needed.
|
||||
|
||||
`JSONEncoder` can't encode an `Encodable` type unless it has the specific type.
|
||||
By using an `extension` of the `Action` we have this specific type and can encode it.
|
||||
*/
|
||||
func encode(with encoder: JSONEncoder) -> Data?
|
||||
}
|
||||
|
||||
public extension EncodableAction {
|
||||
func encode(with encoder: JSONEncoder) -> Data? {
|
||||
return try? encoder.encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
A template for creating `Action`s.
|
||||
|
||||
The template can have a `Payload`type which is used when creating an actual `Action` from the template.
|
||||
*/
|
||||
public struct ActionTemplate<Payload> {
|
||||
/// The identifier for the `ActionTemplate`
|
||||
public let id: String
|
||||
/// The type of the `Payload`
|
||||
public let payloadType: Payload.Type
|
||||
|
||||
/**
|
||||
Initializes an `ActionTemplate` with the given `payloadType`.
|
||||
|
||||
- Parameter id: The identifier for the `ActionTemplate`
|
||||
- Parameter payloadType: The type of the `Payload`
|
||||
*/
|
||||
public init(id: String, payloadType: Payload.Type) {
|
||||
self.id = id
|
||||
self.payloadType = payloadType
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an `AnonymousAction` with the `ActionTemplate`s `id` and the given `payload`.
|
||||
|
||||
- Parameter payload: The payload to create the `AnonymousAction` with
|
||||
*/
|
||||
public func createAction(payload: Payload) -> AnonymousAction<Payload> {
|
||||
return .init(id: id, payload: payload)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an `AnonymousAction` with the `ActionTemplate`s `id` and the given `payload`.
|
||||
|
||||
- Parameter payload: The payload to create the `AnonymousAction` with
|
||||
*/
|
||||
public func callAsFunction(payload: Payload) -> AnonymousAction<Payload> {
|
||||
return createAction(payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
public extension ActionTemplate where Payload == Void {
|
||||
/**
|
||||
Initializes an `ActionTemplate` with no `Payload`.
|
||||
|
||||
- Parameter id: The identifier for the `ActionTemplate`
|
||||
*/
|
||||
init(id: String) {
|
||||
self.init(id: id, payloadType: Payload.self)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an `AnonymousAction` with the `ActionTemplate`s `id`.
|
||||
*/
|
||||
func createAction() -> AnonymousAction<Payload> {
|
||||
return .init(id: id, payload: ())
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an `AnonymousAction` with the `ActionTemplate`s `id`.
|
||||
*/
|
||||
func callAsFunction() -> AnonymousAction<Payload> {
|
||||
return createAction()
|
||||
}
|
||||
}
|
||||
|
||||
/// An `Action` with an identifier.
|
||||
public protocol IdentifiableAction: Action {
|
||||
/// The identifier
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
/// An `Action` created from an `ActionTemplate`.
|
||||
public struct AnonymousAction<Payload>: IdentifiableAction {
|
||||
/// The identifier for the `AnonymousAction`
|
||||
public let id: String
|
||||
/// The `Payload` for the `AnonymousAction`
|
||||
public let payload: Payload
|
||||
|
||||
/**
|
||||
Check if the `AnonymousAction` was created from a given `ActionTemplate`.
|
||||
|
||||
- Parameter actionTemplate: The `ActionTemplate` to check
|
||||
*/
|
||||
public func wasCreated(from actionTemplate: ActionTemplate<Payload>) -> Bool {
|
||||
return actionTemplate.id == id
|
||||
}
|
||||
}
|
||||
|
||||
extension AnonymousAction: EncodableAction {
|
||||
private var encodablePayload: [String: AnyCodable]? {
|
||||
guard type(of: payload) != Void.self else { return nil }
|
||||
let mirror = Mirror(reflecting: payload)
|
||||
return mirror.children.reduce(into: [String: AnyCodable]()) {
|
||||
guard let key = $1.label else { return }
|
||||
$0[key] = AnyCodable($1.value)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
if payload is Encodable {
|
||||
try container.encode(AnyCodable(payload), forKey: .payload)
|
||||
} else {
|
||||
try container.encodeIfPresent(encodablePayload, forKey: .payload)
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case payload
|
||||
}
|
||||
}
|
||||
55
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Effects.swift
Normal file
55
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Effects.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
/**
|
||||
A side effect that happens as a response to a dispatched `Action`.
|
||||
|
||||
An `Effect` can:
|
||||
- give a new `Action` to dispatch (a `dispatchingOne` effect)
|
||||
- give an array of new `Action`s to dispatch (a `dispatchingMultiple` effect)
|
||||
- give nothing (a `nonDispatching` effect)
|
||||
*/
|
||||
public enum Effect<Environment> {
|
||||
/// An `Effect` that publishes an `Action` to dispatch.
|
||||
case dispatchingOne(_ publisher: (AnyPublisher<Action, Never>, Environment) -> AnyPublisher<Action, Never>)
|
||||
/// An `Effect` that publishes multiple `Action`s to dispatch.
|
||||
case dispatchingMultiple(_ publisher: (AnyPublisher<Action, Never>, Environment) -> AnyPublisher<[Action], Never>)
|
||||
/// An `Effect` that handles the action but doesn't publish a new `Action`.
|
||||
case nonDispatching(_ cancellable: (AnyPublisher<Action, Never>, Environment) -> AnyCancellable)
|
||||
}
|
||||
|
||||
/**
|
||||
A collection of `Effect`s.
|
||||
|
||||
The only requirement for the protocol is to specify the type of the `Environment`
|
||||
which should be used in the `Effect`s. The properties have default implementations.
|
||||
*/
|
||||
public protocol Effects {
|
||||
/// The environment set up in the `Store` passed to every `Effect`.
|
||||
associatedtype Environment
|
||||
/**
|
||||
The `Effect`s to register on the `Store`. The default implementation takes all the `Effect`s in the type,
|
||||
|
||||
It is possible to implement the property manually to only enable a subset of `Effect`s to enable.
|
||||
*/
|
||||
var enabledEffects: [Effect<Environment>] { get }
|
||||
/// The identifier for the `Effects`. The default implementation is just the name of the type.
|
||||
static var id: String { get }
|
||||
}
|
||||
|
||||
public extension Effects {
|
||||
var enabledEffects: [Effect<Environment>] {
|
||||
Mirror(reflecting: self).children.compactMap { $0.value as? Effect<Environment> }
|
||||
}
|
||||
|
||||
static var id: String { .init(describing: Self.self) }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
/// A type which intercepts all `Action`s and the `State` changes happening in a `Store`.
|
||||
public protocol Interceptor {
|
||||
/// The type of `State` the `Interceptor` will get.
|
||||
associatedtype State
|
||||
/**
|
||||
The function called when an `Action` is dispatched on a `Store`.
|
||||
|
||||
- Parameter action: The `Action` dispatched
|
||||
- Parameter oldState: The `State` before the `Action` was dispatched
|
||||
- Parameter newState: The `State` after the `Action` was dispatched
|
||||
*/
|
||||
func actionDispatched(action: Action, oldState: State, newState: State)
|
||||
/// The identifier for the `Interceptor`
|
||||
static var id: String { get }
|
||||
}
|
||||
|
||||
public extension Interceptor {
|
||||
static var id: String { .init(describing: self) }
|
||||
}
|
||||
|
||||
/// A type-erased `Interceptor` used to store all `Interceptor`s in an array in the `Store`.
|
||||
internal struct AnyInterceptor<State>: Interceptor {
|
||||
let originalId: String
|
||||
private let _actionDispatched: (Action, State, State) -> Void
|
||||
|
||||
init<I: Interceptor>(_ interceptor: I) where I.State == State {
|
||||
originalId = type(of: interceptor).id
|
||||
_actionDispatched = interceptor.actionDispatched
|
||||
}
|
||||
|
||||
func actionDispatched(action: Action, oldState: State, newState: State) {
|
||||
_actionDispatched(action, oldState, newState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A `Interceptor` to use when debugging. Every `Action`s and `State` change are printed to the console.
|
||||
public class PrintInterceptor<State: Encodable>: Interceptor {
|
||||
private let print: (String) -> Void
|
||||
|
||||
/// Initializes the `PrintInterceptor`.
|
||||
public convenience init() {
|
||||
self.init(print: { Swift.print($0) })
|
||||
}
|
||||
|
||||
internal init(print: @escaping (String) -> Void) {
|
||||
self.print = print
|
||||
}
|
||||
|
||||
/**
|
||||
The function called when an `Action` is dispatched on a `Store`.
|
||||
|
||||
- Parameter action: The `Action` dispatched
|
||||
- Parameter oldState: The `State` before the `Action` was dispatched
|
||||
- Parameter newState: The `State` after the `Action` was dispatched
|
||||
*/
|
||||
public func actionDispatched(action: Action, oldState: State, newState: State) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let name = String(describing: type(of: self))
|
||||
|
||||
let actionName = String(describing: type(of: action))
|
||||
var actionLog = "\(name) - action dispatched: \(actionName)"
|
||||
if Mirror(reflecting: action).children.count > 0 {
|
||||
if let encodableAction = action as? EncodableAction,
|
||||
let actionData = encodableAction.encode(with: encoder),
|
||||
let actionJSON = String(data: actionData, encoding: .utf8),
|
||||
actionJSON.replacingOccurrences(of: "\n", with: "") != "{}" {
|
||||
actionLog += ", data: \(actionJSON)"
|
||||
} else {
|
||||
actionLog += "\n⚠️ The payload of the Action has properties but aren't Encodable."
|
||||
actionLog += " Make it Encodable to get them printed."
|
||||
}
|
||||
}
|
||||
self.print(actionLog)
|
||||
|
||||
if let stateData = try? encoder.encode(newState),
|
||||
let newStateJSON = String(data: stateData, encoding: .utf8) {
|
||||
self.print("\(name) - state changed to: \(newStateJSON)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
/// Operators for narrowing down `Action`s in Publisher streams.
|
||||
public extension Publisher where Output == Action {
|
||||
/**
|
||||
Only lets `Action`s of a certain type get through the stream.
|
||||
|
||||
actions
|
||||
.ofType(FetchTodosAction.self)
|
||||
.sink(receiveValue: { action in
|
||||
print("This is a FetchTodosAction: \(action)")
|
||||
})
|
||||
|
||||
- Parameter typeToMatch: A type of `Action` to match
|
||||
*/
|
||||
func ofType<T>(_ typeToMatch: T.Type) -> AnyPublisher<T, Self.Failure> {
|
||||
compactMap { $0 as? T }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
/// Operators for narrowing down `Action`s in Publisher streams.
|
||||
public extension Publisher where Output == Action {
|
||||
/**
|
||||
Only lets `Action`s created from the given `ActionTemplate`s get through the stream.
|
||||
|
||||
actions
|
||||
.wasCreated(from: fetchTodosActionTemplate)
|
||||
.sink(receiveValue: { action in
|
||||
print("This is a FetchTodosAction: \(action)")
|
||||
})
|
||||
|
||||
- Parameter actionTemplate: An `ActionTemplate` to check
|
||||
*/
|
||||
func wasCreated<Payload>(from actionTemplate: ActionTemplate<Payload>)
|
||||
-> AnyPublisher<AnonymousAction<Payload>, Self.Failure> {
|
||||
ofType(AnonymousAction<Payload>.self)
|
||||
.filter { $0.wasCreated(from: actionTemplate) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
/// Operators for narrowing down `Action`s in Publisher streams.
|
||||
public extension Publisher where Output == Action {
|
||||
/**
|
||||
Only lets `AnonymousAction`s with a certain identifier get through the stream.
|
||||
|
||||
actions
|
||||
.withIdentifier("FetchTodosAction")
|
||||
.sink(receiveValue: { action in
|
||||
print("This is an AnonymousAction with the id 'FetchTodosAction': \(action)")
|
||||
})
|
||||
|
||||
- Parameter identifierToMatch: A identifier to match
|
||||
*/
|
||||
func withIdentifier(_ identifierToMatch: String) -> AnyPublisher<Action, Self.Failure> {
|
||||
filter { ($0 as? IdentifiableAction)?.id == identifierToMatch }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
79
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Reducer.swift
Normal file
79
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Reducer.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type which takes a `State` and `Action` returns a new `State`.
|
||||
public struct Reducer<State> {
|
||||
/// An unique identifier used when registering/unregistering the `Reducer` on the `Store`.
|
||||
public let id: String
|
||||
/// A pure function which takes the a `State` and an `Action` and returns a new `State`.
|
||||
public let reduce: (inout State, Action) -> Void
|
||||
|
||||
/**
|
||||
Creates a `Reducer` from a `reduce` function.
|
||||
|
||||
The `reduce` function is a pure function which takes the a `State` and an `Action` and returns a new `State`.
|
||||
|
||||
- Parameter reduce: The `reduce` function to create a `Reducer` from
|
||||
- Parameter state: The `State` to mutate
|
||||
- Parameter action: The `Action` dispatched
|
||||
*/
|
||||
public init(id: String = UUID().uuidString, reduce: @escaping (_ state: inout State, _ action: Action) -> Void) {
|
||||
self.id = id
|
||||
self.reduce = reduce
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Reducer` from a list of `ReduceOn`s.
|
||||
|
||||
- Parameter reduceOns: The `ReduceOn`s which the created `Reducer` should contain
|
||||
*/
|
||||
public init(id: String = UUID().uuidString, _ reduceOns: ReduceOn<State>...) {
|
||||
self.id = id
|
||||
self.reduce = { state, action in
|
||||
reduceOns.forEach { $0.reduce(&state, action) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a `Reducer` which only gets triggered on certain `Action`s or `ActionTemplate`s.
|
||||
public struct ReduceOn<State> {
|
||||
/// A pure function which takes the a `State` and an `Action` and returns a new `State`.
|
||||
public let reduce: (inout State, Action) -> Void
|
||||
|
||||
/**
|
||||
Creates a `ReduceOn` which only runs `reduce` with actions of the type specificed in `actionType`.
|
||||
|
||||
- Parameter actionType: The type of `Action` to filter on
|
||||
- Parameter reduce: A pure function which takes a `State` and an `Action` and returns a new `State`.
|
||||
- Parameter state: The `State` to mutate
|
||||
- Parameter action: The `Action` dispatched
|
||||
*/
|
||||
public init<A: Action>(_ actionType: A.Type, reduce: @escaping (_ state: inout State, _ action: A) -> Void) {
|
||||
self.reduce = { state, action in
|
||||
guard let action = action as? A else { return }
|
||||
reduce(&state, action)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `ReduceOn` which only runs `reduce` with actions created from the specificed `ActionTemplate`s.
|
||||
|
||||
- Parameter actionTemplates: The `ActionTemplate`s to filter on
|
||||
- Parameter reduce: A pure function which takes a `State` and an `Action` and returns a new `State`.
|
||||
- Parameter state: The `State` to mutate
|
||||
- Parameter action: The `Action` dispatched
|
||||
*/
|
||||
public init<Payload>(_ actionTemplates: ActionTemplate<Payload>...,
|
||||
reduce: @escaping (_ state: inout State, _ action: AnonymousAction<Payload>) -> Void) {
|
||||
self.reduce = { state, action in
|
||||
guard let anonymousAction = action as? AnonymousAction<Payload> else { return }
|
||||
guard actionTemplates.contains(where: { anonymousAction.wasCreated(from: $0) }) else { return }
|
||||
reduce(&state, anonymousAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
340
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Selector.swift
Normal file
340
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Selector.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable function_parameter_count
|
||||
|
||||
/// Something which selects a `Value` from the specified `State`.
|
||||
public protocol SelectorProtocol {
|
||||
/// The input for the `Selector`.
|
||||
associatedtype State
|
||||
/// The output of the `Selector`,
|
||||
associatedtype Value
|
||||
|
||||
/**
|
||||
A pure function which takes a `State` and returns a `Value` from it.
|
||||
|
||||
- Parameter state: The `State` to map
|
||||
- Returns: The `Value` mapped from the `State`
|
||||
*/
|
||||
func map(_ state: State) -> Value
|
||||
}
|
||||
|
||||
/**
|
||||
A type which takes a `State` and returns a `Value` from it.
|
||||
|
||||
`Selector`s can be based on other `Selector`s making it possible to select a combined `Value`.
|
||||
*/
|
||||
public class Selector<State, Value>: SelectorProtocol {
|
||||
/// An unique identifier used when overriding the `Selector` on the `MockStore`.
|
||||
public let id = UUID()
|
||||
/// The closue used for the mapping.
|
||||
private let _projector: (State) -> Value
|
||||
/// The latest value for a state hash.
|
||||
internal private(set) var result: (stateHash: UUID, value: Value)?
|
||||
|
||||
/**
|
||||
Creates a `Selector` from a `keyPath`.
|
||||
|
||||
- Parameter keyPath: The `keyPath` to create the `Selector` from
|
||||
*/
|
||||
public convenience init(keyPath: KeyPath<State, Value>) {
|
||||
self.init(projector: { $0[keyPath: keyPath] })
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from a `projector` closure.
|
||||
|
||||
- Parameter projector: The `projector` closure to create the `Selector` from
|
||||
*/
|
||||
public init(projector: @escaping (State) -> Value) {
|
||||
_projector = projector
|
||||
}
|
||||
|
||||
public func map(_ state: State) -> Value {
|
||||
_projector(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Selector` created from a `Selector`s and a `projector` function.
|
||||
public class Selector1<State, S1, Value>: Selector<State, Value> where
|
||||
S1: SelectorProtocol, S1.State == State {
|
||||
/// A pure function which takes the `Value` from the other `Selector` and returns a new `Value`.
|
||||
public let projector: (S1.Value) -> Value
|
||||
|
||||
/**
|
||||
Creates a `Selector` from a `Selector` and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter projector: The closure to pass the value from the `Selector` to
|
||||
*/
|
||||
public init(_ selector1: S1, _ projector: @escaping (S1.Value) -> Value) {
|
||||
self.projector = projector
|
||||
super.init(projector: { projector(selector1.map($0)) })
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from a `Selector` and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter projector: The closure to pass the value from the `Selector` to
|
||||
*/
|
||||
public convenience init(_ selector1: S1, keyPath: KeyPath<S1.Value, Value>) {
|
||||
self.init(selector1) { $0[keyPath: keyPath] }
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Selector` created from two `Selector`s and a `projector` function.
|
||||
public class Selector2<State, S1, S2, Value>: Selector<State, Value> where
|
||||
S1: SelectorProtocol, S1.State == State,
|
||||
S2: SelectorProtocol, S2.State == State {
|
||||
/// A pure function which takes the `Value`s from the other `Selector`s and returns a new `Value`.
|
||||
public let projector: (S1.Value, S2.Value) -> Value
|
||||
|
||||
/**
|
||||
Creates a `Selector` from two `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selector`s to
|
||||
*/
|
||||
public init(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ projector: @escaping (S1.Value, S2.Value) -> Value) {
|
||||
self.projector = projector
|
||||
super.init(projector: { projector(selector1.map($0), selector2.map($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Selector` created from three `Selector`s and a `projector` function.
|
||||
public class Selector3<State, S1, S2, S3, Value>: Selector<State, Value> where
|
||||
S1: SelectorProtocol, S1.State == State,
|
||||
S2: SelectorProtocol, S2.State == State,
|
||||
S3: SelectorProtocol, S3.State == State {
|
||||
/// A pure function which takes the `Value`s from the other `Selector`s and returns a new `Value`.
|
||||
public let projector: (S1.Value, S2.Value, S3.Value) -> Value
|
||||
|
||||
/**
|
||||
Creates a `Selector` from three `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
*/
|
||||
public init(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
_ projector: @escaping (S1.Value, S2.Value, S3.Value) -> Value) {
|
||||
self.projector = projector
|
||||
super.init(projector: { projector(selector1.map($0),
|
||||
selector2.map($0),
|
||||
selector3.map($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Selector` created from four `Selector`s and a `projector` function.
|
||||
public class Selector4<State, S1, S2, S3, S4, Value>: Selector<State, Value> where
|
||||
S1: SelectorProtocol, S1.State == State,
|
||||
S2: SelectorProtocol, S2.State == State,
|
||||
S3: SelectorProtocol, S3.State == State,
|
||||
S4: SelectorProtocol, S4.State == State {
|
||||
/// A pure function which takes the `Value`s from the other `Selector`s and returns a new `Value`.
|
||||
public let projector: (S1.Value, S2.Value, S3.Value, S4.Value) -> Value
|
||||
|
||||
/**
|
||||
Creates a `Selector` from four `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter selector4: The fourth `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
*/
|
||||
public init(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
_ selector4: S4,
|
||||
_ projector: @escaping (S1.Value, S2.Value, S3.Value, S4.Value) -> Value) {
|
||||
self.projector = projector
|
||||
super.init(projector: { projector(selector1.map($0),
|
||||
selector2.map($0),
|
||||
selector3.map($0),
|
||||
selector4.map($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Selector` created from five `Selector`s and a `projector` function.
|
||||
public class Selector5<State, S1, S2, S3, S4, S5, Value>: Selector<State, Value> where
|
||||
S1: SelectorProtocol, S1.State == State,
|
||||
S2: SelectorProtocol, S2.State == State,
|
||||
S3: SelectorProtocol, S3.State == State,
|
||||
S4: SelectorProtocol, S4.State == State,
|
||||
S5: SelectorProtocol, S5.State == State {
|
||||
/// A pure function which takes the `Value`s from the other `Selector`s and returns a new `Value`.
|
||||
public let projector: (S1.Value, S2.Value, S3.Value, S4.Value, S5.Value) -> Value
|
||||
|
||||
/**
|
||||
Creates a `Selector` from five `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter selector4: The fourth `Selector`
|
||||
- Parameter selector5: The fifth `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
*/
|
||||
public init(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
_ selector4: S4,
|
||||
_ selector5: S5,
|
||||
_ projector: @escaping (S1.Value, S2.Value, S3.Value, S4.Value, S5.Value) -> Value) {
|
||||
self.projector = projector
|
||||
super.init(projector: { projector(selector1.map($0),
|
||||
selector2.map($0),
|
||||
selector3.map($0),
|
||||
selector4.map($0),
|
||||
selector5.map($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Creator functions.
|
||||
public extension Selector {
|
||||
/**
|
||||
Creates a `Selector` from a `Selector` and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter projector: The closure to pass the value from the `Selector` to
|
||||
- Returns: A `Selector` from the given `Selector` and the `projector` function
|
||||
*/
|
||||
static func with<S1>(_ selector1: S1,
|
||||
projector: @escaping (S1.Value) -> Value)
|
||||
-> Selector1<State, S1, Value> {
|
||||
.init(selector1, projector)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from a `Selector` and a `KeyPath`.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter keyPath: The `keyPath` to create the `Selector` from
|
||||
- Parameter keyPath: The `KeyPath` to subscript in the value from the `Selector`
|
||||
- Returns: A `Selector` from the given `Selector` and the `KeyPath`
|
||||
*/
|
||||
static func with<S1>(_ selector1: S1,
|
||||
keyPath: KeyPath<S1.Value, Value>)
|
||||
-> Selector1<State, S1, Value> {
|
||||
.init(selector1, keyPath: keyPath)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from two `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selector`s to
|
||||
- Returns: A `Selector` from the given `Selector`s and the `projector` function
|
||||
*/
|
||||
static func with<S1, S2>(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
projector: @escaping (S1.Value, S2.Value) -> Value)
|
||||
-> Selector2<State, S1, S2, Value> {
|
||||
.init(selector1, selector2, projector)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from three `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
- Returns: A `Selector` from the given `Selector`s and the `projector` function
|
||||
*/
|
||||
static func with<S1, S2, S3>(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
projector: @escaping (S1.Value, S2.Value, S3.Value) -> Value)
|
||||
-> Selector3<State, S1, S2, S3, Value> {
|
||||
.init(selector1, selector2, selector3, projector)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from four `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter selector4: The fourth `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
- Returns: A `Selector` from the given `Selector`s and the `projector` function
|
||||
*/
|
||||
static func with<S1, S2, S3, S4>(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
_ selector4: S4,
|
||||
projector: @escaping (S1.Value, S2.Value, S3.Value, S4.Value) -> Value)
|
||||
-> Selector4<State, S1, S2, S3, S4, Value> {
|
||||
.init(selector1, selector2, selector3, selector4, projector)
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Selector` from five `Selector`s and a `projector` function.
|
||||
|
||||
- Parameter selector1: The first `Selector`
|
||||
- Parameter selector2: The second `Selector`
|
||||
- Parameter selector3: The third `Selector`
|
||||
- Parameter selector4: The fourth `Selector`
|
||||
- Parameter selector5: The fifth `Selector`
|
||||
- Parameter projector: The closure to pass the values from the `Selectors` to
|
||||
- Returns: A `Selector` from the given `Selector`s and the `projector` function
|
||||
*/
|
||||
static func with<S1, S2, S3, S4, S5>(_ selector1: S1,
|
||||
_ selector2: S2,
|
||||
_ selector3: S3,
|
||||
_ selector4: S4,
|
||||
_ selector5: S5,
|
||||
projector: @escaping (S1.Value, S2.Value, S3.Value,
|
||||
S4.Value, S5.Value) -> Value)
|
||||
-> Selector5<State, S1, S2, S3, S4, S5, Value> {
|
||||
.init(selector1, selector2, selector3, selector4, selector5, projector)
|
||||
}
|
||||
}
|
||||
|
||||
/// Memoization support, where the `Selector` remembers the last result to speed up mapping.
|
||||
internal extension Selector {
|
||||
/**
|
||||
Sets the value and the corresponding `stateHash`.
|
||||
|
||||
- Parameter value: The value to save
|
||||
- Parameter stateHash: The hash of the state the value was selected from
|
||||
*/
|
||||
func setResult(value: Value, forStateHash stateHash: UUID) {
|
||||
result = (stateHash: stateHash, value: value)
|
||||
}
|
||||
|
||||
/**
|
||||
Selects the `Value` from the `State` based on the subclass's `map` function and saves the result.
|
||||
|
||||
- If a value is already saved and the saved state hash matches the passed, the saved value is returned.
|
||||
- If a value is already saved but the saved state hash doesn't match the passed
|
||||
a new value is selected and saved along with the passed state hash
|
||||
|
||||
- Parameter state: The `State` to select from
|
||||
- Parameter stateHash: The hash of the `State` to select from
|
||||
- Returns: The `Value` mapped with the `projector`
|
||||
*/
|
||||
func map(_ state: State, stateHash: UUID) -> Value {
|
||||
if let result = result, result.stateHash == stateHash {
|
||||
return result.value
|
||||
}
|
||||
let value = map(state)
|
||||
setResult(value: value, forStateHash: stateHash)
|
||||
return value
|
||||
}
|
||||
}
|
||||
296
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Store.swift
Normal file
296
TUIKit/TUIRoomKit/Source/Common/Basic/Fluxor/Store.swift
Normal file
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
import Dispatch
|
||||
import Foundation
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
import OpenCombineDispatch
|
||||
public typealias ObservableObjectCompat = OpenCombine.ObservableObject
|
||||
public typealias PublishedCompat = OpenCombine.Published
|
||||
#else
|
||||
import Combine
|
||||
public typealias ObservableObjectCompat = Combine.ObservableObject
|
||||
public typealias PublishedCompat = Combine.Published
|
||||
#endif
|
||||
|
||||
/**
|
||||
The `Store` is a centralized container for a single-source-of-truth `State`.
|
||||
|
||||
A `Store` is configured by registering all the desired `Reducer`s and `Effects`.
|
||||
|
||||
An `Environment` can be set up to enable dependency injection in `Effect`s.
|
||||
|
||||
## Usage
|
||||
To update the `State` callers dispatch `Action`s on the `Store`.
|
||||
|
||||
## Selecting
|
||||
To select a value in the `State` the callers can either use a `Selector` or a key path.
|
||||
It is possible to get a `Publisher` for the value or just to select the current value.
|
||||
|
||||
## Interceptors
|
||||
It is possible to intercept all `Action`s and `State` changes by registering an `Interceptor`.
|
||||
*/
|
||||
open class Store<State, Environment>: ObservableObjectCompat {
|
||||
/// The state of the `Store`. It can only be modified by the registered `Reducer`s when `Action`s are dispatched.
|
||||
@PublishedCompat public private(set) var state: State
|
||||
/// The environment passed to the `Effects`. The `Environment` can contain services and other dependencies.
|
||||
public let environment: Environment
|
||||
internal private(set) var stateHash = UUID()
|
||||
private let actions = PassthroughSubject<Action, Never>()
|
||||
private var reducers = [KeyedReducer<State>]()
|
||||
private var effects = [String: [AnyCancellable]]()
|
||||
private var interceptors = [AnyInterceptor<State>]()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/**
|
||||
Initializes the `Store` with an initial `State`, an `Environment` and eventually `Reducer`s.
|
||||
|
||||
- Parameter initialState: The initial `State` for the `Store`
|
||||
- Parameter environment: The `Environment` to pass to `Effect`s
|
||||
- Parameter reducers: The `Reducer`s to register
|
||||
*/
|
||||
public init(initialState: State, environment: Environment, reducers: [Reducer<State>] = []) {
|
||||
state = initialState
|
||||
self.environment = environment
|
||||
reducers.forEach(register(reducer:))
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(type(of: self))")
|
||||
}
|
||||
|
||||
// MARK: - Dispatching
|
||||
|
||||
/**
|
||||
Dispatches an `Action` and creates a new `State` by running the current `State` and the `Action`
|
||||
through all registered `Reducer`s.
|
||||
|
||||
After the `State` is set, all registered `Interceptor`s are notified of the change.
|
||||
Lastly the `Action` is dispatched to all registered `Effect`s.
|
||||
|
||||
- Parameter action: The `Action` to dispatch
|
||||
*/
|
||||
public func dispatch(action: Action) {
|
||||
let oldState = state
|
||||
var newState = oldState
|
||||
reducers.forEach { $0.reduce(&newState, action) }
|
||||
stateHash = UUID()
|
||||
state = newState
|
||||
interceptors.forEach { $0.actionDispatched(action: action, oldState: oldState, newState: newState) }
|
||||
actions.send(action)
|
||||
}
|
||||
|
||||
// MARK: - Reducers
|
||||
|
||||
/**
|
||||
Registers the given `Reducer`. The `Reducer` will be run for all subsequent actions.
|
||||
|
||||
- Parameter reducer: The `Reducer` to register
|
||||
*/
|
||||
public func register(reducer: Reducer<State>) {
|
||||
register(reducer: reducer, for: \.self)
|
||||
}
|
||||
|
||||
/**
|
||||
Registers the given `Reducer` for a slice of the `State`. The `Reducer` will be run for all subsequent actions.
|
||||
|
||||
- Parameter reducer: The `Reducer` to register
|
||||
- Parameter keyPath: The `KeyPath` for which the `Reducer` should be run
|
||||
*/
|
||||
public func register<Substate>(reducer: Reducer<Substate>, for keyPath: WritableKeyPath<State, Substate>) {
|
||||
reducers.append(KeyedReducer(keyPath: keyPath, reducer: reducer))
|
||||
}
|
||||
|
||||
/**
|
||||
Unregisters the given `Reducer`. The `Reducer` will no longer be run when `Action`s are dispatched.
|
||||
|
||||
- Parameter reducer: The `Reducer` to unregister
|
||||
*/
|
||||
public func unregister<SomeState>(reducer: Reducer<SomeState>) {
|
||||
reducers.removeAll { $0.id == reducer.id }
|
||||
}
|
||||
|
||||
// MARK: - Effects
|
||||
|
||||
/**
|
||||
Registers the given `Effects`. The `Effects` will receive all subsequent actions.
|
||||
|
||||
- Parameter effects: The `Effects` to register
|
||||
*/
|
||||
public func register<E: Effects>(effects: E) where E.Environment == Environment {
|
||||
self.effects[E.id] = createCancellables(for: effects.enabledEffects)
|
||||
}
|
||||
|
||||
/**
|
||||
Registers the given `Effect`s. The `Effect`s will receive all subsequent actions.
|
||||
|
||||
- Parameter effects: The array of `Effect`s to register
|
||||
- Parameter id: The identifier for the `Effect`s. Only used to enable unregistering the `Effect`s later
|
||||
*/
|
||||
public func register(effects: [Effect<Environment>], id: String = "*") {
|
||||
self.effects[id] = createCancellables(for: effects)
|
||||
}
|
||||
|
||||
/**
|
||||
Registers the given `Effect`. The `Effect` will receive all subsequent actions.
|
||||
|
||||
Only `Effect`s registered from a type conforming to `Effects` can be unregistered.
|
||||
|
||||
- Parameter effect: The `Effect` to register
|
||||
- Parameter id: The identifier for the `Effect`. Only used to enable unregistering the `Effect` later
|
||||
*/
|
||||
public func register(effect: Effect<Environment>, id: String = "*") {
|
||||
effects[id] = (effects[id] ?? []) + [createCancellable(for: effect)]
|
||||
}
|
||||
|
||||
/**
|
||||
Unregisters the given `Effects`. The `Effects` will no longer receive any actions.
|
||||
|
||||
- Parameter effects: The `Effects` to register
|
||||
*/
|
||||
public func unregisterEffects<E: Effects>(ofType effects: E.Type) where E.Environment == Environment {
|
||||
self.effects.removeValue(forKey: effects.id) // An AnyCancellable instance calls cancel() when deinitialized
|
||||
}
|
||||
|
||||
/**
|
||||
Unregisters the `Effect`s registered with the id, so they will no longer receive any actions.
|
||||
|
||||
- Parameter id: The identifier used to register the `Effect`s
|
||||
*/
|
||||
public func unregisterEffects(withId id: String) {
|
||||
effects.removeValue(forKey: id) // An AnyCancellable instance calls cancel() when deinitialized
|
||||
}
|
||||
|
||||
// MARK: - Interceptors
|
||||
|
||||
/**
|
||||
Registers the given `Interceptor`. The `Interceptor` will receive all subsequent `Action`s and state changes.
|
||||
|
||||
- Parameter interceptor: The `Interceptor` to register
|
||||
*/
|
||||
public func register<I: Interceptor>(interceptor: I) where I.State == State {
|
||||
interceptors.append(AnyInterceptor(interceptor))
|
||||
}
|
||||
|
||||
/**
|
||||
Unregisters all registered `Interceptor`s of the given type.
|
||||
The `Interceptor`s will no longer receive any `Action`s or state changes.
|
||||
|
||||
- Parameter interceptor: The type of`Interceptor` to unregister
|
||||
*/
|
||||
|
||||
public func unregisterInterceptors<I: Interceptor>(ofType interceptor: I.Type) where I.State == State {
|
||||
interceptors.removeAll { $0.originalId == interceptor.id }
|
||||
}
|
||||
|
||||
// MARK: - Selecting
|
||||
|
||||
/**
|
||||
Creates a `Publisher` for a `Selector`.
|
||||
|
||||
- Parameter selector: The `Selector` to use when getting the value in the `State`
|
||||
- Returns: A `Publisher` for the `Value` in the `State`
|
||||
*/
|
||||
open func select<Value>(_ selector: Selector<State, Value>) -> AnyPublisher<Value, Never> {
|
||||
$state.map { selector.map($0, stateHash: self.stateHash) }.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/**
|
||||
Gets the current value in the `State` for a `Selector`.
|
||||
|
||||
- Parameter selector: The `Selector` to use when getting the value in the `State`
|
||||
- Returns: The current `Value` in the `State`
|
||||
*/
|
||||
open func selectCurrent<Value>(_ selector: Selector<State, Value>) -> Value {
|
||||
selector.map(state, stateHash: stateHash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Void Environment
|
||||
|
||||
public extension Store where Environment == Void {
|
||||
/**
|
||||
Initializes the `Store` with an initial `State` and eventually `Reducer`s.
|
||||
|
||||
Using this initializer will give all `Effects` a `Void` environment.
|
||||
|
||||
- Parameter initialState: The initial `State` for the `Store`
|
||||
- Parameter reducers: The `Reducer`s to register
|
||||
*/
|
||||
convenience init(initialState: State, reducers: [Reducer<State>] = []) {
|
||||
self.init(initialState: initialState, environment: (), reducers: reducers)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscriptions
|
||||
|
||||
extension Store: Subscriber {
|
||||
public typealias Input = Action
|
||||
public typealias Failure = Never
|
||||
|
||||
public func receive(subscription: Subscription) {
|
||||
subscription.request(.unlimited)
|
||||
}
|
||||
|
||||
public func receive(_ input: Action) -> Subscribers.Demand {
|
||||
dispatch(action: input)
|
||||
return .unlimited
|
||||
}
|
||||
|
||||
public func receive(completion _: Subscribers.Completion<Never>) {}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension Store {
|
||||
/**
|
||||
Creates `Cancellable`s for the given `Effect`s.
|
||||
|
||||
- Parameter effects: The `Effect`s to create `Cancellable`s for
|
||||
- Returns: The `Cancellable`s for the given `Effect`s
|
||||
*/
|
||||
func createCancellables(for effects: [Effect<Environment>]) -> [AnyCancellable] {
|
||||
effects.map(createCancellable(for:))
|
||||
}
|
||||
|
||||
/**
|
||||
Creates `Cancellable` for the given `Effect`.
|
||||
|
||||
- Parameter effect: The `Effect` to create `Cancellable` for
|
||||
- Returns: The `Cancellable` for the given `Effect`
|
||||
*/
|
||||
func createCancellable(for effect: Effect<Environment>) -> AnyCancellable {
|
||||
switch effect {
|
||||
case let .dispatchingOne(effectCreator):
|
||||
return effectCreator(actions.eraseToAnyPublisher(), environment)
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink(receiveValue: dispatch(action:))
|
||||
case let .dispatchingMultiple(effectCreator):
|
||||
return effectCreator(actions.eraseToAnyPublisher(), environment)
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink { $0.forEach(self.dispatch(action:)) }
|
||||
case let .nonDispatching(effectCreator):
|
||||
return effectCreator(actions.eraseToAnyPublisher(), environment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper for a `Reducer` for a specific `KeyPath`.
|
||||
private struct KeyedReducer<State> {
|
||||
let id: String
|
||||
let reduce: (inout State, Action) -> Void
|
||||
|
||||
init<Substate>(keyPath: WritableKeyPath<State, Substate>, reducer: Reducer<Substate>) {
|
||||
id = reducer.id
|
||||
reduce = { state, action in
|
||||
var substate = state[keyPath: keyPath]
|
||||
reducer.reduce(&substate, action)
|
||||
state[keyPath: keyPath] = substate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2020
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SwiftUI bindings
|
||||
|
||||
public extension Store {
|
||||
/**
|
||||
Creates a `Binding` from the given `Selector` and `ActionTemplate`.
|
||||
|
||||
When the `wrappedValue` is updated an `Action`, created from the `ActionTemplate`, is dispatched on the `Store`.
|
||||
|
||||
- Parameter selector: The `Selector`s to use for getting the current value
|
||||
- Parameter actionTemplate: The `ActionTemplate` to use for dispatching an `Action` when the value changes
|
||||
- Returns: A `Binding` based on the given `Selector` and `ActionTemplate`
|
||||
*/
|
||||
@available(iOS 13.0, *)
|
||||
func binding<Value>(get selector: Selector<State, Value>,
|
||||
send actionTemplate: ActionTemplate<Value>) -> Binding<Value> {
|
||||
.init(get: { self.selectCurrent(selector) },
|
||||
set: { self.dispatch(action: actionTemplate.createAction(payload: $0)) })
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Binding` from the given `Selector` and `ActionTemplate`s for enabling and disabling the value.
|
||||
|
||||
When the `wrappedValue` is enabled/disabled, an `Action`, created from one of the `ActionTemplate`s,
|
||||
is dispatched on the `Store`.
|
||||
|
||||
- Parameter selector: The `Selector`s to use for getting the current value
|
||||
- Parameter enableActionTemplate: The `ActionTemplate` to use for dispatching an `Action`
|
||||
when the value should be enabled
|
||||
- Parameter disableActionTemplate: The `ActionTemplate` to use for dispatching an `Action`
|
||||
when the value should be disabled
|
||||
- Returns: A `Binding` based on the given `Selector` and `ActionTemplate`s
|
||||
*/
|
||||
@available(iOS 13.0, *)
|
||||
func binding(get selector: Selector<State, Bool>,
|
||||
enable enableActionTemplate: ActionTemplate<Void>,
|
||||
disable disableActionTemplate: ActionTemplate<Void>)
|
||||
-> Binding<Bool> {
|
||||
return .init(get: { self.selectCurrent(selector) },
|
||||
set: { self.dispatch(action: ($0 ? enableActionTemplate : disableActionTemplate)()) })
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a `Binding` from the given `Selector` and closure.
|
||||
|
||||
When the `wrappedValue` is updated an `Action` (returned by the closure), is dispatched on the `Store`.
|
||||
|
||||
- Parameter selector: The `Selector`s to use for getting the current value
|
||||
- Parameter action: A closure which returns an `Action` to be dispatched when the value changes
|
||||
- Parameter value: The value used to decide which `Action` to be dispatched.
|
||||
- Returns: A `Binding` based on the given `Selector` and closure
|
||||
*/
|
||||
@available(iOS 13.0, *)
|
||||
func binding<Value>(get selector: Selector<State, Value>,
|
||||
send action: @escaping (_ value: Value) -> Action)
|
||||
-> Binding<Value> {
|
||||
return .init(get: { self.selectCurrent(selector) },
|
||||
set: { self.dispatch(action: action($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Fluxor
|
||||
* Copyright (c) Morten Bjerg Gregersen 2021
|
||||
* MIT license, see LICENSE file for details
|
||||
*/
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
import SwiftUI
|
||||
|
||||
/**
|
||||
A property wrapper for observing a value in the `Store`.
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DrawView: View {
|
||||
@StoreValue(Current.store, Selectors.canClear) private var canClear: Bool
|
||||
|
||||
var body: some View {
|
||||
Button(action: { ... }, label: { Text("Clear") })
|
||||
.disabled(!canClear)
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@propertyWrapper public struct StoreValue<State, Value> where Value: Equatable {
|
||||
/// The current value in the `Store`
|
||||
public var wrappedValue: Value { selectCurrent() }
|
||||
/// A `Publisher` for the selecterd value in the `Store`
|
||||
public var projectedValue: AnyPublisher<Value, Never>
|
||||
/// A closure for selecting the current value in the `Store`
|
||||
private let selectCurrent: () -> Value
|
||||
|
||||
/**
|
||||
Initializes the `StoreValue` property wrapper with a `Store` and a `Selector`.
|
||||
|
||||
- Parameter store: The `Store` to select the value from
|
||||
- Parameter selector: The `Selector` to use for selecting the value
|
||||
*/
|
||||
public init<Environment>(_ store: Store<State, Environment>, _ selector: Selector<State, Value>) {
|
||||
projectedValue = store.select(selector)
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
selectCurrent = { store.selectCurrent(selector) }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// FloatchatResolverRegister.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/16.
|
||||
//
|
||||
import Factory
|
||||
|
||||
extension Container {
|
||||
var floatChatService: Factory<FloatChatStoreProvider> {
|
||||
self { FloatChatStore() }.shared
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// FloatChatButton.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/9.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Foundation
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
import OpenCombineDispatch
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
import Factory
|
||||
|
||||
class FloatChatButton: UIView {
|
||||
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
|
||||
private lazy var floatInputViewShowState = self.store.select(FloatChatSelectors.getShowFloatInputView)
|
||||
var cancellableSet = Set<AnyCancellable>()
|
||||
weak var inputController: UIViewController?
|
||||
var roomId = ""
|
||||
private let emojiLabelSpacing: CGFloat = 6
|
||||
private let horizonSpacing: CGFloat = 10
|
||||
|
||||
func updateRoomId(roomId: String) {
|
||||
self.roomId = roomId
|
||||
store.dispatch(action: FloatChatActions.setRoomId(payload: roomId))
|
||||
}
|
||||
|
||||
private let emojiView: UIImageView = {
|
||||
let emojiImage = UIImage(named: "room_emoji_icon", in: tuiRoomKitBundle(), compatibleWith: nil)
|
||||
let imageView = UIImageView(image: emojiImage)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private let label: UILabel = {
|
||||
let view = UILabel()
|
||||
view.backgroundColor = .clear
|
||||
view.textColor = UIColor.tui_color(withHex: "D5E0F2")
|
||||
view.font = UIFont(name: "PingFangSC-Regular", size: 12)
|
||||
view.text = .placeHolderText
|
||||
view.sizeToFit()
|
||||
return view
|
||||
}()
|
||||
|
||||
private let clickView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
view.isUserInteractionEnabled = true
|
||||
return view
|
||||
}()
|
||||
|
||||
private var isViewReady = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard !isViewReady else { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
private func constructViewHierarchy() {
|
||||
backgroundColor = UIColor.tui_color(withHex: "22262E80", alpha: 0.5)
|
||||
layer.cornerRadius = 15
|
||||
addSubview(emojiView)
|
||||
addSubview(label)
|
||||
addSubview(clickView)
|
||||
}
|
||||
|
||||
private func activateConstraints() {
|
||||
emojiView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(horizonSpacing)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.equalTo(24)
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.equalTo(emojiView.snp.trailing).offset(emojiLabelSpacing)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.equalToSuperview().offset(-horizonSpacing)
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
clickView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindInteraction() {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(showInputView))
|
||||
clickView.addGestureRecognizer(tap)
|
||||
|
||||
floatInputViewShowState
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink { [weak self] showFloatChatInput in
|
||||
guard let self = self else { return }
|
||||
if showFloatChatInput {
|
||||
let inputController = FloatChatInputController()
|
||||
inputController.view.backgroundColor = .clear
|
||||
let navController = UINavigationController(rootViewController: inputController)
|
||||
navController.isNavigationBarHidden = true
|
||||
navController.navigationBar.prefersLargeTitles = true
|
||||
navController.modalPresentationStyle = .overFullScreen
|
||||
RoomCommon.getCurrentWindowViewController()?.present(navController, animated: true, completion: nil)
|
||||
self.inputController = inputController
|
||||
} else {
|
||||
self.inputController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
@objc private func showInputView() {
|
||||
store.dispatch(action: FloatViewActions.showFloatInputView(payload: true))
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static var placeHolderText: String {
|
||||
localized("Say something")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// FloatChatDisplayView.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/9.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
import OpenCombineDispatch
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
import Factory
|
||||
|
||||
class FloatChatDisplayView: UIView {
|
||||
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
|
||||
private lazy var messagePublisher = self.store.select(FloatChatSelectors.getLatestMessage)
|
||||
private var messages: [FloatChatMessageView] = []
|
||||
var cancellableSet = Set<AnyCancellable>()
|
||||
private let messageSpacing: CGFloat = 8
|
||||
|
||||
private lazy var blurLayer: CALayer = {
|
||||
let layer = CAGradientLayer()
|
||||
layer.colors = [
|
||||
UIColor.black.withAlphaComponent(0).cgColor,
|
||||
UIColor.black.withAlphaComponent(1).cgColor
|
||||
]
|
||||
layer.locations = [0, 0.2]
|
||||
layer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
layer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
|
||||
return layer
|
||||
}()
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
blurLayer.frame = self.bounds
|
||||
}
|
||||
|
||||
private var isViewReady = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard !isViewReady else { return }
|
||||
constructViewHierarchy()
|
||||
bindInteraction()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
private func constructViewHierarchy() {
|
||||
self.layer.mask = blurLayer
|
||||
}
|
||||
|
||||
func bindInteraction() {
|
||||
messagePublisher
|
||||
.filter{ !$0.content.isEmpty }
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink { [weak self] floatMessage in
|
||||
guard let self = self else { return }
|
||||
self.addMessage(floatMessage)
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func addMessage(_ message: FloatChatMessage) {
|
||||
let messageView = FloatChatMessageView(floatMessage: message)
|
||||
if currentMessageHeight() + messageView.height + messageSpacing > bounds.height {
|
||||
removeOldestMessage()
|
||||
}
|
||||
addSubview(messageView)
|
||||
messageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
make.width.lessThanOrEqualToSuperview()
|
||||
make.height.lessThanOrEqualToSuperview()
|
||||
if let lastMessage = messages.last {
|
||||
make.top.equalTo(lastMessage.snp.bottom).offset(messageSpacing).priority(.high)
|
||||
}
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
messages.append(messageView)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
||||
self.removeMessageWithAnimation(message: messageView)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentMessageHeight() -> CGFloat {
|
||||
return messages.reduce(0) { $0 + $1.height + messageSpacing}
|
||||
}
|
||||
|
||||
private func removeOldestMessage() {
|
||||
guard let oldest = messages.first else { return }
|
||||
removeMessage(message: oldest)
|
||||
}
|
||||
|
||||
private func removeMessageWithAnimation(message: FloatChatMessageView) {
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
message.alpha = 0
|
||||
} completion: { _ in
|
||||
self.removeMessage(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMessage(message: FloatChatMessageView) {
|
||||
if let index = messages.firstIndex(of: message) {
|
||||
message.removeFromSuperview()
|
||||
messages.remove(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FloatChatDisplayView {
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// FloatChatInputController.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/11.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
import Foundation
|
||||
import TUICore
|
||||
import Factory
|
||||
|
||||
class FloatChatInputController: UIViewController {
|
||||
@Injected(\.floatChatService) private var store: FloatChatStoreProvider
|
||||
@Injected(\.conferenceStore) private var operation
|
||||
|
||||
private var textViewBottomConstraint: Constraint?
|
||||
private var textViewHeightConstraint: Constraint?
|
||||
private var emojiPanelTopConstraint: Constraint?
|
||||
private let maxNumberOfLines = 3
|
||||
private let emojiPanelHeight = 274.0
|
||||
|
||||
private let inputBarView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
|
||||
return view
|
||||
}()
|
||||
|
||||
private let emojiButton: LargeTapAreaButton = {
|
||||
let button = LargeTapAreaButton()
|
||||
let img = UIImage(named: "room_emoji_icon", in: tuiRoomKitBundle(), compatibleWith: nil)
|
||||
button.setImage(img, for: .normal)
|
||||
return button
|
||||
}()
|
||||
|
||||
private let inputTextView: UITextView = {
|
||||
let view = UITextView(frame: .zero)
|
||||
view.font = UIFont.systemFont(ofSize: 17.5)
|
||||
view.returnKeyType = UIReturnKeyType.send
|
||||
view.enablesReturnKeyAutomatically = true
|
||||
view.textContainer.lineBreakMode = .byCharWrapping
|
||||
view.textContainerInset = UIEdgeInsets(top: view.textContainerInset.top, left: 10, bottom: view.textContainerInset.bottom, right: 10)
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
view.layer.cornerRadius = view.sizeThatFits(.zero).height / 2
|
||||
view.layer.masksToBounds = true
|
||||
view.isHidden = true
|
||||
view.textColor = UIColor.tui_color(withHex: "#D5F4F2", alpha: 0.6)
|
||||
view.backgroundColor = UIColor.tui_color(withHex: "#4F586B", alpha: 0.3)
|
||||
return view
|
||||
}()
|
||||
|
||||
private let sendButton: UIButton = {
|
||||
let button = UIButton()
|
||||
button.setTitle(.sendText, for: .normal)
|
||||
button.layer.cornerRadius = 18
|
||||
button.backgroundColor = UIColor.tui_color(withHex: "#006CFF")
|
||||
return button
|
||||
}()
|
||||
|
||||
private let backgroundView: UIView = {
|
||||
let view = UITextView(frame: .zero)
|
||||
view.backgroundColor = UIColor.tui_color(withHex: "#22262E")
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var emojiPanel: EmotionBoardView = {
|
||||
let emotionBoardView = EmotionBoardView()
|
||||
let emotionHelper = EmotionHelper.shared
|
||||
emotionBoardView.emotions = emotionHelper.emotions
|
||||
emotionBoardView.delegate = self
|
||||
emotionBoardView.backgroundColor = UIColor.tui_color(withHex: "#22262E")
|
||||
emotionBoardView.isHidden = true
|
||||
return emotionBoardView
|
||||
}()
|
||||
|
||||
private lazy var maxHeightOfTextView: CGFloat = {
|
||||
let lineHeight = inputTextView.font?.lineHeight ?? 0
|
||||
return ceil(lineHeight * CGFloat(maxNumberOfLines) + inputTextView.textContainerInset.top + inputTextView.textContainerInset.bottom)
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
showInputView()
|
||||
}
|
||||
|
||||
private func constructViewHierarchy() {
|
||||
inputBarView.addSubview(emojiButton)
|
||||
inputBarView.addSubview(inputTextView)
|
||||
inputBarView.addSubview(sendButton)
|
||||
view.addSubview(backgroundView)
|
||||
view.addSubview(inputBarView)
|
||||
view.addSubview(emojiPanel)
|
||||
}
|
||||
|
||||
private func activateConstraints() {
|
||||
backgroundView.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalToSuperview()
|
||||
make.top.equalTo(inputBarView.snp.top)
|
||||
}
|
||||
|
||||
inputBarView.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(inputTextView).offset(2 * 12)
|
||||
textViewBottomConstraint = make.bottom.equalTo(view).constraint
|
||||
}
|
||||
emojiButton.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(24)
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.equalTo(view.safeAreaLayoutGuide.snp.leading).offset(10)
|
||||
}
|
||||
sendButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(64)
|
||||
make.height.equalTo(36)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing).offset(-10)
|
||||
}
|
||||
inputTextView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(emojiButton.snp.trailing).offset(10)
|
||||
make.trailing.equalTo(sendButton.snp.leading).offset(-10)
|
||||
let size = inputTextView.sizeThatFits(.zero)
|
||||
textViewHeightConstraint = make.height.equalTo(size.height).constraint
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
emojiPanel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.height.equalTo(emojiPanelHeight)
|
||||
emojiPanelTopConstraint = make.top.equalTo(view.snp.bottom).constraint
|
||||
}
|
||||
}
|
||||
|
||||
private func bindInteraction() {
|
||||
inputTextView.delegate = self
|
||||
emojiButton.addTarget(self, action: #selector(onEmojiButtonTapped), for: .touchUpInside)
|
||||
sendButton.addTarget(self, action: #selector(onSendButtonTapped), for: .touchUpInside)
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(hideInputView))
|
||||
view.addGestureRecognizer(tapGesture)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(keyboardWillShow),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillShow(notification: NSNotification) {
|
||||
guard let keyboardRect: CGRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
|
||||
let curve: UInt = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt,
|
||||
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
|
||||
else {
|
||||
return
|
||||
}
|
||||
let intersection = CGRectIntersection(keyboardRect, self.view.frame)
|
||||
UIView.animate(withDuration: duration, delay: 0.0, options: UIView.AnimationOptions(rawValue: curve)) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.textViewBottomConstraint?.update(offset: -CGRectGetHeight(intersection))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func onSendButtonTapped(sender: UIButton) {
|
||||
if inputTextView.normalText.isEmpty {
|
||||
operation.dispatch(action: ViewActions.showToast(payload: ToastInfo(message: .inputCannotBeEmpty, position: .center)))
|
||||
} else {
|
||||
store.dispatch(action: FloatChatActions.sendMessage(payload: inputTextView.normalText))
|
||||
}
|
||||
hideInputView()
|
||||
}
|
||||
|
||||
private func showInputView() {
|
||||
inputTextView.isHidden = false
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
@objc private func hideInputView() {
|
||||
inputBarView.isHidden = true
|
||||
view.endEditing(true)
|
||||
store.dispatch(action: FloatViewActions.showFloatInputView(payload: false))
|
||||
}
|
||||
|
||||
@objc private func onEmojiButtonTapped(sender: UIButton) {
|
||||
sender.isSelected = !sender.isSelected
|
||||
if sender.isSelected {
|
||||
showEmojiPanel()
|
||||
} else {
|
||||
hideEmojiPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private func showEmojiPanel() {
|
||||
inputTextView.resignFirstResponder()
|
||||
emojiPanel.isHidden = false
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.emojiPanelTopConstraint?.update(offset: -self.emojiPanelHeight)
|
||||
self.textViewBottomConstraint?.update(offset: -self.emojiPanelHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideEmojiPanel() {
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: UIView.AnimationOptions.curveEaseInOut) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.emojiPanelTopConstraint?.update(offset: self.emojiPanelHeight)
|
||||
} completion: {[weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.emojiPanel.isHidden = true
|
||||
self.inputTextView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTextViewHeight() {
|
||||
let currentHeight = ceil(inputTextView.sizeThatFits(CGSize(width: inputTextView.bounds.size.width, height: CGFloat.greatestFiniteMagnitude)).height)
|
||||
inputTextView.isScrollEnabled = currentHeight > maxHeightOfTextView
|
||||
if currentHeight <= maxHeightOfTextView {
|
||||
textViewHeightConstraint?.update(offset: currentHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FloatChatInputController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
updateTextViewHeight()
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
if text == "\n" {
|
||||
store.dispatch(action: FloatChatActions.sendMessage(payload: textView.normalText))
|
||||
hideInputView()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension FloatChatInputController: EmotionBoardViewDelegate {
|
||||
func emotionView(emotionBoardView: EmotionBoardView, didSelectEmotion emotion: Emotion, atIndex index: Int) {
|
||||
let attributedString = EmotionHelper.shared.obtainImageAttributedString(byImageKey: emotion.displayName,
|
||||
font: inputTextView.font ?? UIFont(), useCache: false)
|
||||
inputTextView.insertEmotionAttributedString(emotionAttributedString: attributedString)
|
||||
}
|
||||
|
||||
func emotionViewDidSelectDeleteButton(emotionBoardView: EmotionBoardView) {
|
||||
if !inputTextView.deleteEmotion() {
|
||||
inputTextView.deleteBackward()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LargeTapAreaButton: UIButton {
|
||||
|
||||
var tapAreaPadding: CGFloat = 20
|
||||
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
let largerBounds = bounds.insetBy(dx: -tapAreaPadding, dy: -tapAreaPadding)
|
||||
return largerBounds.contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static var sendText: String {
|
||||
localized("Send")
|
||||
}
|
||||
static let inputCannotBeEmpty = localized("Input can't be empty!")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Emotion.swift
|
||||
// TUILiveKit
|
||||
//
|
||||
// Created by krabyu on 2024/4/3.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class Emotion: Equatable {
|
||||
let identifier: String
|
||||
let displayName: String
|
||||
var image: UIImage = UIImage()
|
||||
|
||||
init(identifier: String, displayName: String) {
|
||||
self.identifier = identifier
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
static func == (left: Emotion, right: Emotion) -> Bool {
|
||||
return left.identifier == right.identifier
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return "identifier:\(identifier), displayName:\(displayName)"
|
||||
}
|
||||
}
|
||||
|
||||
class EmotionAttachment: NSTextAttachment {
|
||||
var displayText: String = ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//
|
||||
// EmotionHelper.swift
|
||||
// TUILiveKit
|
||||
//
|
||||
// Created by krabyu on 2024/4/3.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EmotionHelper {
|
||||
static let shared = {
|
||||
EmotionHelper()
|
||||
}()
|
||||
|
||||
private init() {
|
||||
useDefaultEmotions()
|
||||
}
|
||||
|
||||
var emotions: [Emotion] = []
|
||||
private var cacheTotalImageDictionary: [String: UIImage] = [:]
|
||||
private var cacheAttributedDictionary: [String: NSAttributedString] = [:]
|
||||
private var regularExpression: NSRegularExpression = try! NSRegularExpression(pattern: "\\[[a-zA-Z0-9_\\u4e00-\\u9fa5]+\\]", options: [])
|
||||
|
||||
func useDefaultEmotions() {
|
||||
createTotalEmotions()
|
||||
cacheTotalImage()
|
||||
}
|
||||
|
||||
func setEmotions(emotions: [Emotion]) {
|
||||
self.emotions = emotions
|
||||
cacheTotalImageDictionary = [:]
|
||||
cacheTotalImage()
|
||||
}
|
||||
|
||||
func cacheTotalImage() {
|
||||
if cacheTotalImageDictionary.count == 0 {
|
||||
var emotionImageDictionary: [String: UIImage] = [:]
|
||||
for emotion in emotions {
|
||||
if emotion.image.size.width != 0 {
|
||||
emotion.image = UIImage(named: emotion.identifier, in: tuiRoomKitBundle(), compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
emotionImageDictionary[emotion.displayName] = emotion.image
|
||||
}
|
||||
cacheTotalImageDictionary = emotionImageDictionary
|
||||
}
|
||||
}
|
||||
|
||||
func obtainImagesAttributedString(byText text: String, font: UIFont) -> NSMutableAttributedString {
|
||||
let matches = regularExpression.matches(in: text, range: NSRange(location: 0, length: text.count))
|
||||
let intactAttributedString = NSMutableAttributedString(string: text)
|
||||
for match in matches.reversed() {
|
||||
guard let emojiRange = Range(match.range, in: text) else { return NSMutableAttributedString(string: "") }
|
||||
let emojiKey = String(text[emojiRange])
|
||||
|
||||
var useCache = true
|
||||
if #available(iOS 15.0, *) {
|
||||
// Cached NSAttributedString cannot be used on ios15, only one expression will appear, but it can be used on ios14 and before.
|
||||
useCache = false
|
||||
}
|
||||
let imageAttributedString = obtainImageAttributedString(byImageKey: emojiKey, font: font, useCache: useCache)
|
||||
intactAttributedString.replaceCharacters(in: match.range, with: imageAttributedString)
|
||||
}
|
||||
// Fixed an issue where font changed due to inserting AttributeString;
|
||||
// Prevents the textView font from getting smaller after inserting an expression
|
||||
intactAttributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: intactAttributedString.length))
|
||||
intactAttributedString.addAttribute(.foregroundColor, value: UIColor.tui_color(withHex: "D5E0F2"), range: NSRange(location: 0, length: intactAttributedString.length))
|
||||
return intactAttributedString
|
||||
}
|
||||
|
||||
func obtainImageAttributedString(byImageKey imageKey: String, font: UIFont, useCache: Bool) -> NSAttributedString {
|
||||
if !useCache {
|
||||
let image = cacheTotalImageDictionary[imageKey]
|
||||
if image == nil {
|
||||
return NSAttributedString(string: "")
|
||||
}
|
||||
let emotionAttachment = EmotionAttachment()
|
||||
emotionAttachment.displayText = imageKey
|
||||
emotionAttachment.image = image
|
||||
emotionAttachment.bounds = CGRect(x: 0, y: font.descender, width: font.lineHeight, height: font.lineHeight)
|
||||
let imageAttributedString = NSAttributedString(attachment: emotionAttachment)
|
||||
return imageAttributedString
|
||||
}
|
||||
|
||||
let keyFont = String(format: "%@%.1f", imageKey, font.pointSize)
|
||||
if let result = cacheAttributedDictionary[keyFont] {
|
||||
return result
|
||||
}
|
||||
guard let image = cacheTotalImageDictionary[imageKey] else {
|
||||
return NSAttributedString(string: "")
|
||||
}
|
||||
|
||||
let emotionAttachment = EmotionAttachment()
|
||||
emotionAttachment.displayText = imageKey
|
||||
emotionAttachment.image = image
|
||||
emotionAttachment.bounds = CGRect(x: 0, y: font.descender, width: font.lineHeight, height: font.lineHeight)
|
||||
let result = NSAttributedString(attachment: emotionAttachment)
|
||||
cacheAttributedDictionary[keyFont] = result
|
||||
return result
|
||||
}
|
||||
|
||||
private func createTotalEmotions() {
|
||||
emotions = []
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_0", displayName: "[TUIEmoji_Smile]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_1", displayName: "[TUIEmoji_Expect]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_2", displayName: "[TUIEmoji_Blink]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_3", displayName: "[TUIEmoji_Guffaw]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_4", displayName: "[TUIEmoji_KindSmile]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_5", displayName: "[TUIEmoji_Haha]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_6", displayName: "[TUIEmoji_Cheerful]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_7", displayName: "[TUIEmoji_Speechless]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_8", displayName: "[TUIEmoji_Amazed]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_9", displayName: "[TUIEmoji_Sorrow]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_10", displayName: "[TUIEmoji_Complacent]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_11", displayName: "[TUIEmoji_Silly]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_12", displayName: "[TUIEmoji_Lustful]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_13", displayName: "[TUIEmoji_Giggle]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_14", displayName: "[TUIEmoji_Kiss]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_15", displayName: "[TUIEmoji_Wail]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_16", displayName: "[TUIEmoji_TearsLaugh]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_17", displayName: "[TUIEmoji_Trapped]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_18", displayName: "[TUIEmoji_Mask]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_19", displayName: "[TUIEmoji_Fear]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_20", displayName: "[TUIEmoji_BareTeeth]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_21", displayName: "[TUIEmoji_FlareUp]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_22", displayName: "[TUIEmoji_Yawn]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_23", displayName: "[TUIEmoji_Tact]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_24", displayName: "[TUIEmoji_Stareyes]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_25", displayName: "[TUIEmoji_ShutUp]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_26", displayName: "[TUIEmoji_Sigh]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_27", displayName: "[TUIEmoji_Hehe]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_28", displayName: "[TUIEmoji_Silent]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_29", displayName: "[TUIEmoji_Surprised]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_30", displayName: "[TUIEmoji_Askance]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_31", displayName: "[TUIEmoji_Ok]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_32", displayName: "[TUIEmoji_Shit]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_33", displayName: "[TUIEmoji_Monster]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_34", displayName: "[TUIEmoji_Daemon]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_35", displayName: "[TUIEmoji_Rage]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_36", displayName: "[TUIEmoji_Fool]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_37", displayName: "[TUIEmoji_Pig]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_38", displayName: "[TUIEmoji_Cow]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_39", displayName: "[TUIEmoji_Ai]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_40", displayName: "[TUIEmoji_Skull]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_41", displayName: "[TUIEmoji_Bombs]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_42", displayName: "[TUIEmoji_Coffee]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_43", displayName: "[TUIEmoji_Cake]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_44", displayName: "[TUIEmoji_Beer]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_45", displayName: "[TUIEmoji_Flower]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_46", displayName: "[TUIEmoji_Watermelon]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_47", displayName: "[TUIEmoji_Rich]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_48", displayName: "[TUIEmoji_Heart]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_49", displayName: "[TUIEmoji_Moon]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_50", displayName: "[TUIEmoji_Sun]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_51", displayName: "[TUIEmoji_Star]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_52", displayName: "[TUIEmoji_RedPacket]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_53", displayName: "[TUIEmoji_Celebrate]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_54", displayName: "[TUIEmoji_Bless]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_55", displayName: "[TUIEmoji_Fortune]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_56", displayName: "[TUIEmoji_Convinced]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_57", displayName: "[TUIEmoji_Prohibit]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_58", displayName: "[TUIEmoji_666]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_59", displayName: "[TUIEmoji_857]"))
|
||||
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_60", displayName: "[TUIEmoji_Knife]"))
|
||||
emotions.append(Emotion(identifier: "room_floatChat_emoji_61", displayName: "[TUIEmoji_Like]"))
|
||||
|
||||
for emotion in emotions {
|
||||
emotion.image = UIImage(named: emotion.identifier, in: tuiRoomKitBundle(), compatibleWith: nil) ?? UIImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// FloatChatService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import ImSDK_Plus
|
||||
import TUICore
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
import Factory
|
||||
|
||||
class FloatChatService: NSObject {
|
||||
@WeakLazyInjected(\.floatChatService) private var store: FloatChatStoreProvider?
|
||||
private let imManager = {
|
||||
V2TIMManager.sharedInstance()
|
||||
}()
|
||||
private var roomId: String? {
|
||||
self.store?.selectCurrent(FloatChatSelectors.getRoomId)
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
imManager?.addSimpleMsgListener(listener: self)
|
||||
}
|
||||
|
||||
func sendGroupMessage(_ message: String) -> AnyPublisher<String, Never> {
|
||||
return Future<String, Never> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.imManager?.sendGroupTextMessage(message, to: self.roomId, priority: .PRIORITY_NORMAL, succ: {
|
||||
promise(.success((message)))
|
||||
}, fail: { code, message in
|
||||
let errorMsg = TUITool.convertIMError(Int(code), msg: message)
|
||||
//TODO: show toast from store.dispatch
|
||||
RoomRouter.makeToastInWindow(toast:errorMsg ?? "send message fail", duration: 2)
|
||||
})
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension FloatChatService: V2TIMSimpleMsgListener {
|
||||
func onRecvGroupTextMessage(_ msgID: String!, groupID: String!, sender info: V2TIMGroupMemberInfo!, text: String!) {
|
||||
guard groupID == roomId else {
|
||||
return
|
||||
}
|
||||
let user = FloatChatUser(memberInfo: info)
|
||||
let floatMessage = FloatChatMessage(user: user, content: text)
|
||||
store?.dispatch(action: FloatChatActions.onMessageReceived(payload: floatMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// FloatChatState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/5/17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import ImSDK_Plus
|
||||
|
||||
struct FloatChatState: Codable {
|
||||
var isFloatInputViewShow = false
|
||||
var roomId: String = ""
|
||||
var latestMessage = FloatChatMessage()
|
||||
}
|
||||
|
||||
struct FloatChatMessage: Codable, Equatable {
|
||||
var id = UUID()
|
||||
var user = FloatChatUser()
|
||||
var content: String = ""
|
||||
var extInfo: [String: AnyCodable] = [:]
|
||||
|
||||
init() {}
|
||||
|
||||
init(user: FloatChatUser, content: String) {
|
||||
self.user = user
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
struct FloatChatUser: Codable, Equatable {
|
||||
var userId: String = ""
|
||||
var userName: String = ""
|
||||
var avatarUrl: String?
|
||||
|
||||
init() {}
|
||||
|
||||
init(loginInfo: TUILoginUserInfo) {
|
||||
self.userId = loginInfo.userId
|
||||
self.userName = loginInfo.userName
|
||||
self.avatarUrl = loginInfo.avatarUrl
|
||||
}
|
||||
|
||||
init(userId: String, userName: String, avatarUrl: String?) {
|
||||
self.userId = userId
|
||||
self.userName = userName
|
||||
self.avatarUrl = avatarUrl
|
||||
}
|
||||
|
||||
init(memberInfo: V2TIMGroupMemberInfo) {
|
||||
self.userId = memberInfo.userID ?? ""
|
||||
self.userName = memberInfo.nickName ?? userId
|
||||
self.avatarUrl = memberInfo.faceURL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// FloatChatAction.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FloatChatActions {
|
||||
static let key = "FloatChat.chat"
|
||||
static let sendMessage = ActionTemplate(id: key.appending(".sendMessage"), payloadType: String.self)
|
||||
static let onMessageSended = ActionTemplate(id: key.appending(".messageSended"), payloadType: String.self)
|
||||
static let onMessageReceived = ActionTemplate(id: key.appending(".messageReceived"), payloadType: FloatChatMessage.self)
|
||||
static let setRoomId = ActionTemplate(id: key.appending(".setRoomId"), payloadType: String.self)
|
||||
}
|
||||
|
||||
enum FloatViewActions {
|
||||
static let key = "FloatChat.view"
|
||||
static let showFloatInputView = ActionTemplate(id: key.appending(".show"), payloadType: Bool.self)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// FloatChatEffect.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FloatChatEffect: Effects {
|
||||
typealias Environment = FloatChatService
|
||||
|
||||
let sendMessage = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: FloatChatActions.sendMessage)
|
||||
.flatMap { action in
|
||||
environment.sendGroupMessage(action.payload)
|
||||
.map { FloatChatActions.onMessageSended(payload: $0) }
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// FloatChatReducer.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
let floatChatReducer = Reducer<FloatChatState>(
|
||||
ReduceOn(FloatChatActions.onMessageSended) { state, action in
|
||||
let selfInfo = TUIRoomEngine.getSelfInfo()
|
||||
let user = FloatChatUser(loginInfo: selfInfo)
|
||||
let floatMessage = FloatChatMessage(user: user, content: action.payload)
|
||||
state.latestMessage = floatMessage
|
||||
},
|
||||
ReduceOn(FloatChatActions.onMessageReceived) { state, action in
|
||||
state.latestMessage = action.payload
|
||||
},
|
||||
ReduceOn(FloatViewActions.showFloatInputView) { state, action in
|
||||
state.isFloatInputViewShow = action.payload
|
||||
},
|
||||
ReduceOn(FloatChatActions.setRoomId) { state, action in
|
||||
state.roomId = action.payload
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// FloatChatSelectors.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FloatChatSelectors {
|
||||
static let getLatestMessage = Selector(keyPath: \FloatChatState.latestMessage)
|
||||
static let getShowFloatInputView = Selector(keyPath: \FloatChatState.isFloatInputViewShow)
|
||||
static let getRoomId = Selector(keyPath: \FloatChatState.roomId)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// FloatChatState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
// Copyright © 2024 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
protocol FloatChatStoreProvider {
|
||||
func dispatch(action: Action)
|
||||
|
||||
func select<Value: Equatable>(_ selector: Selector<FloatChatState, Value>) -> AnyPublisher<Value, Never>
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<FloatChatState, Value>) -> Value
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// ConferenceRoomStore.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
class FloatChatStore {
|
||||
private(set) lazy var store: Store<FloatChatState, FloatChatService> = Store(initialState: FloatChatState(), environment: FloatChatService())
|
||||
|
||||
init() {
|
||||
initStore()
|
||||
}
|
||||
|
||||
deinit {
|
||||
store.unregister(reducer: floatChatReducer)
|
||||
store.unregisterEffects(withId: FloatChatEffect.id)
|
||||
}
|
||||
|
||||
private func initStore() {
|
||||
store.register(reducer: floatChatReducer)
|
||||
store.register(effects: FloatChatEffect())
|
||||
}
|
||||
}
|
||||
|
||||
extension FloatChatStore: FloatChatStoreProvider {
|
||||
func dispatch(action: Action) {
|
||||
store.dispatch(action: action)
|
||||
}
|
||||
|
||||
func select<Value>(_ selector: Selector<FloatChatState, Value>) -> AnyPublisher<Value, Never> where Value : Equatable {
|
||||
return store.select(selector)
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<FloatChatState, Value>) -> Value {
|
||||
return store.selectCurrent(selector)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//
|
||||
// EmotionBoardView.swift
|
||||
// TUILiveKit
|
||||
//
|
||||
// Created by krabyu on 2024/4/7.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
protocol EmotionBoardViewDelegate: AnyObject {
|
||||
func emotionView(emotionBoardView: EmotionBoardView, didSelectEmotion emotion: Emotion, atIndex index: Int)
|
||||
func emotionViewDidSelectDeleteButton(emotionBoardView: EmotionBoardView)
|
||||
}
|
||||
|
||||
protocol EmotionPageViewDelegate: AnyObject {
|
||||
func emotionPageView(emotionPageView: EmotionPageView, didSelectEmotion emotion: Emotion, atIndex index: Int)
|
||||
func emotionPageViewDidLayoutEmotions(emotionPageView: EmotionPageView)
|
||||
}
|
||||
|
||||
class EmotionPageView: UIView {
|
||||
private let buttonWidth = 35
|
||||
private let buttonHeight = 30
|
||||
weak var delegate: EmotionPageViewDelegate?
|
||||
let emotionSelectedBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.isUserInteractionEnabled = false
|
||||
view.backgroundColor = UIColor(red: 0 / 255.0, green: 0 / 255.0, blue: 0 / 255.0, alpha: 0.16)
|
||||
view.layer.cornerRadius = 3
|
||||
view.alpha = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
var deleteButton: UIButton = UIButton()
|
||||
var deleteButtonOffset: CGPoint = CGPointZero
|
||||
var emotionLayers: [CALayer] = []
|
||||
var emotions: [Emotion] = []
|
||||
// Record the rect of the clickable area of all emoticons in the current pageView, updated in drawRect: and used in tap events
|
||||
var emotionHittingRects: [NSValue] = []
|
||||
var padding = UIEdgeInsets()
|
||||
var numberOfRows: Int = 4
|
||||
// The size of the drawing area for each expression
|
||||
var emotionSize = CGSize(width: 30, height: 30)
|
||||
var emotionSelectedBackgroundExtension = UIEdgeInsets()
|
||||
var minimumEmotionHorizontalSpacing: CGFloat = 16
|
||||
var needsLayoutEmotions: Bool = true
|
||||
var previousLayoutFrame = CGRect()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(red: 34 / 255.0, green: 38 / 255.0, blue: 46 / 255.0, alpha: 1)
|
||||
|
||||
addSubview(emotionSelectedBackgroundView)
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGestureRecognizer))
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func frameForDeleteButton(deleteButton: UIView) -> CGRect {
|
||||
var rect = deleteButton.frame
|
||||
let x = CGRectGetWidth(bounds) - padding.right - CGRectGetWidth(deleteButton.frame) -
|
||||
(emotionSize.width - CGRectGetWidth(deleteButton.frame)) / 2.0 + deleteButtonOffset.x
|
||||
let y = CGRectGetHeight(bounds) - padding.bottom - CGRectGetHeight(deleteButton.frame) -
|
||||
(emotionSize.height - CGRectGetHeight(deleteButton.frame)) / 2.0 + deleteButtonOffset.y
|
||||
rect.origin = CGPoint(x: x, y: y)
|
||||
return rect
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if deleteButton.superview == self {
|
||||
// The Delete button must be placed in the position of the last expression,
|
||||
// and is centered left and right above and below the expression
|
||||
deleteButton.frame = frameForDeleteButton(deleteButton: deleteButton)
|
||||
}
|
||||
let isSizeChanged = !CGSizeEqualToSize(previousLayoutFrame.size, frame.size)
|
||||
previousLayoutFrame = frame
|
||||
if isSizeChanged {
|
||||
setNeedsLayoutEmotions()
|
||||
}
|
||||
layoutEmotionsIfNeeded()
|
||||
}
|
||||
|
||||
func setNeedsLayoutEmotions() {
|
||||
needsLayoutEmotions = true
|
||||
}
|
||||
|
||||
func setEmotions(emotions: [Emotion]) {
|
||||
if self.emotions == emotions { return }
|
||||
self.emotions = emotions
|
||||
setNeedsLayoutEmotions()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func layoutEmotionsIfNeeded() {
|
||||
if !needsLayoutEmotions { return }
|
||||
needsLayoutEmotions = false
|
||||
emotionHittingRects.removeAll()
|
||||
|
||||
let contentSize = bounds.inset(by: padding).size
|
||||
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
|
||||
let emotionHorizontalSpacing = (contentSize.width - emotionCountPerRow * emotionSize.width) / (emotionCountPerRow - 1)
|
||||
let emotionVerticalSpacing = Int(contentSize.height - CGFloat(numberOfRows) * emotionSize.height) / Int(numberOfRows - 1)
|
||||
emotionSelectedBackgroundExtension = UIEdgeInsets(top: CGFloat(-emotionVerticalSpacing) / 2,
|
||||
left: -emotionHorizontalSpacing / 2,
|
||||
bottom: CGFloat(-emotionVerticalSpacing) / 2,
|
||||
right: -emotionHorizontalSpacing / 2)
|
||||
var emotionOrigin = CGPointZero
|
||||
let emotionCount = emotions.count
|
||||
|
||||
for i in stride(from: 0, to: emotionCount, by: 1) {
|
||||
var emotionLayer: CALayer
|
||||
if i < emotionLayers.count {
|
||||
emotionLayer = emotionLayers[i]
|
||||
} else {
|
||||
emotionLayer = CALayer()
|
||||
emotionLayer.contentsScale = UIScreen.main.scale
|
||||
emotionLayers.append(emotionLayer)
|
||||
layer.addSublayer(emotionLayer)
|
||||
}
|
||||
|
||||
emotionLayer.contents = emotions[i].image.cgImage
|
||||
let row = i / Int(emotionCountPerRow)
|
||||
emotionOrigin.x = padding.left + (emotionSize.width + emotionHorizontalSpacing) * CGFloat(i % Int(emotionCountPerRow))
|
||||
emotionOrigin.y = padding.top + (emotionSize.height + CGFloat(emotionVerticalSpacing)) * CGFloat(row)
|
||||
let emotionRect = CGRect(x: emotionOrigin.x, y: emotionOrigin.y, width: emotionSize.width, height: emotionSize.height)
|
||||
let emotionHittingRect = emotionRect.inset(by: emotionSelectedBackgroundExtension)
|
||||
emotionHittingRects.append(NSValue(cgRect: emotionHittingRect))
|
||||
emotionLayer.frame = emotionRect
|
||||
emotionLayer.isHidden = false
|
||||
}
|
||||
|
||||
if emotionLayers.count > emotionCount {
|
||||
for i in emotionLayers.count - emotionCount ..< emotionLayers.count {
|
||||
emotionLayers[i].isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
delegate?.emotionPageViewDidLayoutEmotions(emotionPageView: self)
|
||||
}
|
||||
|
||||
@objc func handleTapGestureRecognizer(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
for i in 0 ..< emotionHittingRects.count {
|
||||
let rect = emotionHittingRects[i].cgRectValue
|
||||
if rect.contains(location) {
|
||||
let layer = emotionLayers[i]
|
||||
if layer.opacity < 0.2 { return }
|
||||
let emotion = emotions[i]
|
||||
emotionSelectedBackgroundView.frame = rect
|
||||
UIView.animate(withDuration: 0.08, animations: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.emotionSelectedBackgroundView.alpha = 1
|
||||
}, completion: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
UIView.animate(withDuration: 0.08, animations: {
|
||||
self.emotionSelectedBackgroundView.alpha = 0
|
||||
}, completion: nil)
|
||||
})
|
||||
delegate?.emotionPageView(emotionPageView: self, didSelectEmotion: emotion, atIndex: i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verticalSizeThatFits(size: CGSize, emotionVerticalSpacing: CGFloat) -> CGSize {
|
||||
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
let contentSize = rect.inset(by: padding).size
|
||||
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
|
||||
let row = ceil(CGFloat(emotions.count) / (emotionCountPerRow * 1.0))
|
||||
let height = (emotionSize.height + emotionVerticalSpacing) * row - emotionVerticalSpacing + (padding.top + padding.bottom)
|
||||
return CGSize(width: size.width, height: height)
|
||||
}
|
||||
|
||||
func updateDeleteButton(deleteButton: UIButton) {
|
||||
self.deleteButton = deleteButton
|
||||
addSubview(deleteButton)
|
||||
}
|
||||
|
||||
func setDeleteButtonOffset(deleteButtonOffset: CGPoint) {
|
||||
self.deleteButtonOffset = deleteButtonOffset
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
class EmotionVerticalScrollView: UIScrollView {
|
||||
let pageView: EmotionPageView = {
|
||||
let pageView = EmotionPageView()
|
||||
pageView.deleteButton.isHidden = true
|
||||
return pageView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(pageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setEmotions(emotions: [Emotion],
|
||||
emotionSize: CGSize,
|
||||
minimumEmotionHorizontalSpacing: CGFloat,
|
||||
emotionVerticalSpacing: CGFloat,
|
||||
emotionSelectedBackgroundExtension: UIEdgeInsets,
|
||||
paddingInPage: UIEdgeInsets) {
|
||||
let pageView = self.pageView
|
||||
pageView.emotions = emotions
|
||||
pageView.padding = paddingInPage
|
||||
let contentSize = CGSize(width: bounds.size.width - edgeInsetsGetHorizontalValue(insets: paddingInPage),
|
||||
height: bounds.size.height - edgeInsetsGetVerticalValue(insets: paddingInPage))
|
||||
let emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing)
|
||||
pageView.numberOfRows = Int(ceil(CGFloat(emotions.count) / emotionCountPerRow))
|
||||
pageView.emotionSize = emotionSize
|
||||
pageView.emotionSelectedBackgroundExtension = emotionSelectedBackgroundExtension
|
||||
pageView.minimumEmotionHorizontalSpacing = minimumEmotionHorizontalSpacing
|
||||
pageView.setNeedsLayout()
|
||||
let size = pageView.verticalSizeThatFits(size: bounds.size, emotionVerticalSpacing: emotionVerticalSpacing)
|
||||
self.pageView.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
self.contentSize = size
|
||||
}
|
||||
|
||||
func adjustEmotionsAlpha(withFloatingRect floatingRect: CGRect) {
|
||||
let contentSize = CGSize(width: contentSize.width - edgeInsetsGetHorizontalValue(insets: pageView.padding),
|
||||
height: contentSize.height - edgeInsetsGetVerticalValue(insets: pageView.padding))
|
||||
let emotionCountPerRow = (contentSize.width + pageView.minimumEmotionHorizontalSpacing) /
|
||||
((pageView.emotionSize.width) + (pageView.minimumEmotionHorizontalSpacing))
|
||||
let emotionVerticalSpacing = Int(contentSize.height - CGFloat(pageView.numberOfRows) *
|
||||
(pageView.emotionSize.height)) / Int(CGFloat(pageView.numberOfRows) - 1)
|
||||
let emotionHorizontalSpacing = (contentSize.width - emotionCountPerRow * (pageView.emotionSize.width)) / (emotionCountPerRow - 1)
|
||||
let columnIndexLeft = ceil((floatingRect.origin.x - (pageView.padding.left)) / ((pageView.emotionSize.width) +
|
||||
emotionHorizontalSpacing)) - 1
|
||||
let columnIndexRight = emotionCountPerRow - 1
|
||||
let rowIndexTop = ((floatingRect.origin.y - (pageView.padding.top)) / ((pageView.emotionSize.height) +
|
||||
CGFloat(emotionVerticalSpacing))) - 1
|
||||
for i in 0 ..< pageView.emotionLayers.count {
|
||||
let row = i / Int(emotionCountPerRow)
|
||||
let column = i % Int(emotionCountPerRow)
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
if column >= Int(columnIndexLeft) && column <= Int(columnIndexRight) && row > Int(rowIndexTop) {
|
||||
if row == Int(ceil(rowIndexTop)) {
|
||||
let intersectAreaHeight = floatingRect.origin.y - pageView.emotionLayers[i].frame.origin.y
|
||||
let percent = intersectAreaHeight / pageView.emotionSize.height
|
||||
pageView.emotionLayers[i].opacity = Float(percent * percent)
|
||||
} else {
|
||||
pageView.emotionLayers[i].opacity = 0
|
||||
}
|
||||
} else {
|
||||
pageView.emotionLayers[i].opacity = 1.0
|
||||
}
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
func edgeInsetsGetVerticalValue(insets: UIEdgeInsets) -> CGFloat {
|
||||
return insets.top + insets.bottom
|
||||
}
|
||||
|
||||
func edgeInsetsGetHorizontalValue(insets: UIEdgeInsets) -> CGFloat {
|
||||
return insets.left + insets.right
|
||||
}
|
||||
}
|
||||
|
||||
class EmotionBoardView: UIView {
|
||||
private let buttonWidth = 35
|
||||
private let buttonHeight = 30
|
||||
var emotions: [Emotion] = []
|
||||
weak var delegate: EmotionBoardViewDelegate?
|
||||
var deleteButtonMargins = UIEdgeInsets(top: 0, left: 0, bottom: 18, right: 18)
|
||||
var pagedEmotions: [Emotion] = []
|
||||
let emotionVerticalSpacing = 16
|
||||
let paddingInPage = UIEdgeInsets(top: 18, left: 18, bottom: 65, right: 18)
|
||||
let numberOfRowsPerPage: Int = 4
|
||||
let emotionSize = CGSize(width: 34, height: 34)
|
||||
let emotionSelectedBackgroundExtension = UIEdgeInsets(top: -3, left: -3, bottom: -3, right: 03)
|
||||
let minimumEmotionHorizontalSpacing: CGFloat = 16
|
||||
let deleteButtonOffset: CGPoint = CGPointZero
|
||||
let pageControlMarginBottom: CGFloat = 22
|
||||
|
||||
lazy var verticalScrollView: EmotionVerticalScrollView = {
|
||||
let scrollView = EmotionVerticalScrollView()
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
scrollView.delegate = self
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
lazy var deleteButton: UIButton = {
|
||||
let button = UIButton()
|
||||
button.setImage(UIImage(named: "room_floatchat_delete", in: tuiRoomKitBundle(), compatibleWith: nil), for: .normal)
|
||||
button.addTarget(self, action: #selector(didSelectDeleteButton), for: .touchUpInside)
|
||||
button.layer.cornerRadius = 4
|
||||
button.backgroundColor = .white
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var topLineView: UIView = {
|
||||
let view = UIView()
|
||||
view.frame = CGRect(x: 0, y: 0, width: CGRectGetWidth(bounds), height: 1 / UIScreen.main.scale)
|
||||
view.backgroundColor = UIColor(red: 34 / 255.0, green: 38 / 255.0, blue: 46 / 255.0, alpha: 1)
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
didInitialized(withFrame: frame)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
didInitialized(withFrame: CGRectZero)
|
||||
}
|
||||
|
||||
func didInitialized(withFrame frame: CGRect) {
|
||||
addSubview(verticalScrollView)
|
||||
addSubview(deleteButton)
|
||||
addSubview(topLineView)
|
||||
}
|
||||
|
||||
func setEmotions(emotions: [Emotion]) {
|
||||
self.emotions = emotions
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
deleteButton.frame = CGRect(x: Int(Double(Int(bounds.width) - Int(deleteButtonMargins.right) - buttonWidth)),
|
||||
y: Int(Double(bounds.height - safeAreaInsets.bottom) - deleteButtonMargins.bottom) - buttonHeight,
|
||||
width: buttonWidth, height: buttonHeight)
|
||||
|
||||
var paddingInPage = paddingInPage
|
||||
paddingInPage.bottom = paddingInPage.bottom + safeAreaInsets.bottom
|
||||
|
||||
let verticalScrollViewFrame = bounds.inset(by: .zero)
|
||||
verticalScrollView.frame = verticalScrollViewFrame
|
||||
verticalScrollView.setEmotions(emotions: emotions,
|
||||
emotionSize: emotionSize,
|
||||
minimumEmotionHorizontalSpacing: minimumEmotionHorizontalSpacing,
|
||||
emotionVerticalSpacing: CGFloat(emotionVerticalSpacing),
|
||||
emotionSelectedBackgroundExtension: emotionSelectedBackgroundExtension,
|
||||
paddingInPage: paddingInPage)
|
||||
verticalScrollView.pageView.delegate = self
|
||||
topLineView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
func adjustEmotionsAlpha() {
|
||||
let x = deleteButton.frame.origin.x
|
||||
let y = deleteButton.frame.origin.y
|
||||
let width = deleteButton.frame.maxX
|
||||
let height = deleteButton.frame.maxY - deleteButton.frame.minY
|
||||
let buttonGroupRect = CGRect(x: x, y: y, width: width, height: height)
|
||||
let floatingRect = verticalScrollView.convert(buttonGroupRect, from: self)
|
||||
verticalScrollView.adjustEmotionsAlpha(withFloatingRect: floatingRect)
|
||||
}
|
||||
|
||||
@objc func didSelectDeleteButton() {
|
||||
delegate?.emotionViewDidSelectDeleteButton(emotionBoardView: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmotionBoardView: EmotionPageViewDelegate {
|
||||
func emotionPageView(emotionPageView: EmotionPageView, didSelectEmotion emotion: Emotion, atIndex index: Int) {
|
||||
let index = emotions.firstIndex(of: emotion) ?? -1
|
||||
delegate?.emotionView(emotionBoardView: self, didSelectEmotion: emotion, atIndex: index)
|
||||
}
|
||||
|
||||
func emotionPageViewDidLayoutEmotions(emotionPageView: EmotionPageView) {
|
||||
adjustEmotionsAlpha()
|
||||
}
|
||||
}
|
||||
|
||||
extension EmotionBoardView: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
if scrollView == verticalScrollView {
|
||||
adjustEmotionsAlpha()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// FloatChatMessageView.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/10.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
class FloatChatMessageView: UIView {
|
||||
private let messageHorizonSpacing: CGFloat = 8
|
||||
private let messageVerticalSpacing: CGFloat = 5
|
||||
init(floatMessage: FloatChatMessage? = nil) {
|
||||
self.floatMessage = floatMessage
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
lazy var messageLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont(name: "PingFangSC-Regular", size: 14.0)
|
||||
label.numberOfLines = 0
|
||||
label.text = " "
|
||||
label.textAlignment = .left
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.textColor = .white
|
||||
return label
|
||||
}()
|
||||
|
||||
var floatMessage: FloatChatMessage? {
|
||||
didSet {
|
||||
guard let floatMessage = floatMessage else {
|
||||
return
|
||||
}
|
||||
updateMessage(with: floatMessage)
|
||||
}
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
return messageLabel.frame.height + 2 * messageVerticalSpacing
|
||||
}
|
||||
|
||||
func setupUI() {
|
||||
backgroundColor = UIColor.tui_color(withHex: "#22262E", alpha: 0.4)
|
||||
layer.cornerRadius = 13.0
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
updateMessage(with: floatMessage)
|
||||
}
|
||||
|
||||
private func constructViewHierarchy() {
|
||||
addSubview(messageLabel)
|
||||
}
|
||||
|
||||
private func activateConstraints() {
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(messageHorizonSpacing)
|
||||
make.top.equalToSuperview().offset(messageVerticalSpacing)
|
||||
}
|
||||
self.snp.makeConstraints { make in
|
||||
make.width.equalTo(messageLabel).offset(2 * messageHorizonSpacing)
|
||||
make.height.equalTo(messageLabel).offset(2 * messageVerticalSpacing)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMessage(with message: FloatChatMessage?) {
|
||||
guard let message = message else {
|
||||
messageLabel.attributedText = nil
|
||||
return
|
||||
}
|
||||
messageLabel.attributedText = getAttributedText(from: message)
|
||||
}
|
||||
|
||||
private func getAttributedText(from message: FloatChatMessage) -> NSMutableAttributedString {
|
||||
var userName = message.user.userName.isEmpty ? message.user.userId : message.user.userName
|
||||
userName = userName + ": "
|
||||
let userNameAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12),
|
||||
.foregroundColor: UIColor.tui_color(withHex: "B2BBD1")]
|
||||
let userNameAttributedText = NSMutableAttributedString(string: userName,
|
||||
attributes: userNameAttributes)
|
||||
|
||||
let contentAttributedText: NSMutableAttributedString = getFullContentAttributedText(content: message.content)
|
||||
userNameAttributedText.append(contentAttributedText)
|
||||
return userNameAttributedText
|
||||
}
|
||||
|
||||
private func getFullContentAttributedText(content: String) -> NSMutableAttributedString {
|
||||
return EmotionHelper.shared.obtainImagesAttributedString(byText: content,
|
||||
font: UIFont(name: "PingFangSC-Regular", size: 12) ??
|
||||
UIFont.systemFont(ofSize: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// UITextView+Emotion.swift
|
||||
// TUILiveKit
|
||||
//
|
||||
// Created by krabyu on 2024/4/3.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UITextView {
|
||||
func insertEmotionAttributedString(emotionAttributedString: NSAttributedString) {
|
||||
guard let content = attributedText.mutableCopy() as? NSMutableAttributedString
|
||||
else {
|
||||
return
|
||||
}
|
||||
let location = selectedRange.location
|
||||
content.insert(emotionAttributedString, at: location)
|
||||
content.addAttributes([.font: font ?? UIFont.systemFont(ofSize: 14),
|
||||
.foregroundColor: textColor ?? .black,],
|
||||
range: NSRange(location: location, length: emotionAttributedString.length))
|
||||
attributedText = content
|
||||
let newRange = NSRange(location: location + emotionAttributedString.length, length: 0)
|
||||
selectedRange = newRange
|
||||
}
|
||||
|
||||
func insertEmotionKey(emotionKey: String) {
|
||||
guard let content: NSMutableAttributedString = attributedText.copy() as? NSMutableAttributedString else { return }
|
||||
let location = selectedRange.location
|
||||
content.insert(NSAttributedString(string: emotionKey, attributes: [.font: font ?? UIFont.systemFont(ofSize: 14),
|
||||
.foregroundColor: textColor ?? .black,]), at: location)
|
||||
attributedText = content
|
||||
let newRange = NSRange(location: location + emotionKey.count, length: 0)
|
||||
selectedRange = newRange
|
||||
}
|
||||
|
||||
func deleteEmotion() -> Bool {
|
||||
let location = selectedRange.location
|
||||
if location == 0 { return false }
|
||||
|
||||
let headSubstring = text.prefix(location)
|
||||
if headSubstring.hasSuffix("]") {
|
||||
for i in stride(from: headSubstring.count, to: 0 - 1, by: -1) {
|
||||
let index = headSubstring.index(headSubstring.startIndex, offsetBy: i - 1)
|
||||
let tempString = headSubstring[index]
|
||||
if tempString == "[" {
|
||||
guard let content: NSMutableAttributedString = attributedText.copy() as? NSMutableAttributedString else { return false }
|
||||
content.deleteCharacters(in: NSRange(location: i - 1, length: headSubstring.count - (i - 1)))
|
||||
attributedText = content
|
||||
let newRange = NSRange(location: headSubstring.count, length: 0)
|
||||
selectedRange = newRange
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var normalText: String {
|
||||
guard let attributedText = attributedText else { return "" }
|
||||
var normalText = ""
|
||||
attributedText.enumerateAttributes(in: NSRange(location: 0, length: attributedText.length)) { attributes, range, _ in
|
||||
if let attachment = attributes[.attachment] as? EmotionAttachment {
|
||||
let emotionAttachment = attachment
|
||||
normalText += emotionAttachment.displayText
|
||||
} else {
|
||||
let substring = (attributedText.string as NSString).substring(with: range)
|
||||
normalText += substring
|
||||
}
|
||||
}
|
||||
return normalText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// RoomFileBroswerCell.swift
|
||||
// DemoApp
|
||||
//
|
||||
// Created by CY zhao on 2023/7/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public let RoomFileBroswerCellHeight = 40.0
|
||||
|
||||
class RoomFileBroswerCell: UITableViewCell {
|
||||
lazy var titlelabel: UILabel = {
|
||||
let tLabel = UILabel()
|
||||
tLabel.font = .systemFont(ofSize: 16)
|
||||
tLabel.textColor = UIColor.tui_color(withHex: "333333")
|
||||
return tLabel
|
||||
}()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
self.initUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func initUI() {
|
||||
self.contentView.addSubview(titlelabel)
|
||||
titlelabel.snp.makeConstraints {
|
||||
$0.left.equalToSuperview().offset(5)
|
||||
$0.top.equalToSuperview().offset(5)
|
||||
$0.right.equalToSuperview().offset(-20)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUI(model: RoomFileBroswerModel) {
|
||||
self.titlelabel.text = model.title
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// RoomFileBroswerModel.swift
|
||||
// DemoApp
|
||||
//
|
||||
// Created by CY zhao on 2023/7/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class RoomFileBroswerModel: NSObject {
|
||||
var title: String
|
||||
var path: String
|
||||
|
||||
init(title: String = "", path: String) {
|
||||
self.title = title
|
||||
self.path = path
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// RoomFileBrowserViewController.swift
|
||||
// DemoApp
|
||||
//
|
||||
// Created by CY zhao on 2023/7/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@objcMembers public class RoomFileBrowserViewController: UITableViewController {
|
||||
var modelArray: [RoomFileBroswerModel] = []
|
||||
var documentController: UIDocumentInteractionController? = nil
|
||||
var bathPath: String
|
||||
|
||||
public init(bathPath: String) {
|
||||
self.bathPath = bathPath
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.initUI()
|
||||
self.loadData()
|
||||
}
|
||||
|
||||
func initUI() {
|
||||
self.tableView.rowHeight = RoomFileBroswerCellHeight
|
||||
self.tableView.register(RoomFileBroswerCell.self, forCellReuseIdentifier: "RoomFileBroswerCell")
|
||||
|
||||
self.navigationItem.title = self.bathPath
|
||||
self.navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
|
||||
let backBtnItem = UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil)
|
||||
self.navigationItem.backBarButtonItem = backBtnItem
|
||||
}
|
||||
|
||||
func loadData() {
|
||||
guard let fileList = try? FileManager.default.contentsOfDirectory(atPath: self.bathPath) else {
|
||||
return
|
||||
}
|
||||
let sortedList = fileList.sorted { file1, file2 in
|
||||
return file1.localizedStandardCompare(file2) == .orderedAscending
|
||||
}
|
||||
for fileName in sortedList {
|
||||
let path = URL(fileURLWithPath: self.bathPath).appendingPathComponent(fileName).path
|
||||
let model = RoomFileBroswerModel(title: fileName, path: path )
|
||||
self.modelArray.append(model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Table view data source
|
||||
public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.modelArray.count
|
||||
}
|
||||
|
||||
public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let model = self.modelArray[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "RoomFileBroswerCell") as! RoomFileBroswerCell
|
||||
cell.updateUI(model: model)
|
||||
return cell
|
||||
}
|
||||
|
||||
public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let model = self.modelArray[indexPath.row]
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
FileManager.default.fileExists(atPath: model.path, isDirectory: &isDirectory)
|
||||
if isDirectory.boolValue {
|
||||
let vc = RoomFileBrowserViewController(bathPath: self.bathPath)
|
||||
vc.bathPath = model.path
|
||||
self.navigationController?.pushViewController(vc, animated: true)
|
||||
} else {
|
||||
self.handleFile(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
func handleFile(model: RoomFileBroswerModel) {
|
||||
self.documentController = UIDocumentInteractionController(url: URL(fileURLWithPath: model.path))
|
||||
self.documentController?.presentOpenInMenu(from: CGRectZero, in: self.view, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// CGFloat+Extension.swift
|
||||
// Alamofire
|
||||
//
|
||||
// Created by aby on 2022/12/26.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
var kScreenWidth: CGFloat {
|
||||
UIScreen.main.bounds.width
|
||||
}
|
||||
var kScreenHeight: CGFloat {
|
||||
UIScreen.main.bounds.height
|
||||
}
|
||||
|
||||
public let kDeviceIsiPhoneX : Bool = {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
return false
|
||||
}
|
||||
let size = UIScreen.main.bounds.size
|
||||
let notchValue = Int(size.width/size.height*100)
|
||||
if notchValue == 216 || notchValue == 46 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
public let kDeviceSafeBottomHeight : CGFloat = {
|
||||
if kDeviceIsiPhoneX {
|
||||
return 34
|
||||
}
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
}()
|
||||
private var width: CGFloat {
|
||||
return min(kScreenHeight, kScreenWidth)
|
||||
}
|
||||
private var height: CGFloat {
|
||||
return max(kScreenWidth, kScreenHeight)
|
||||
}
|
||||
|
||||
extension CGFloat {
|
||||
|
||||
/// Dimensions in 375 design drawings
|
||||
///
|
||||
/// - Returns: Final result scaling result
|
||||
public func scale375(exceptPad: Bool = true) -> CGFloat {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
return exceptPad ? self * 1.5 : self * (width / 375.00)
|
||||
}
|
||||
return self * (width / 375.00)
|
||||
}
|
||||
|
||||
public func scale375Height(exceptPad: Bool = true) -> CGFloat {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
return exceptPad ? self * 1.5 : self * (height / 812.00)
|
||||
}
|
||||
return self * (height / 812.00)
|
||||
}
|
||||
|
||||
/// iPad proportion adaptation
|
||||
///
|
||||
/// - Returns: Final Results
|
||||
public func fitPad() -> CGFloat {
|
||||
return UIDevice.current.userInterfaceIdiom == .pad ? self * 1.5 : self
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
public func scale375(exceptPad: Bool = true) -> CGFloat {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
return exceptPad ? CGFloat(self) * 1.5 : CGFloat(self) * (width / 375.00)
|
||||
}
|
||||
return CGFloat(self) * (width / 375.00)
|
||||
}
|
||||
|
||||
public func scale375Height(exceptPad: Bool = true) -> CGFloat {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
return exceptPad ? CGFloat(self) * 1.5 : CGFloat(self) * (height / 812.00)
|
||||
}
|
||||
return CGFloat(self) * (height / 812.00)
|
||||
}
|
||||
|
||||
public func fitPad() -> CGFloat {
|
||||
return UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(self) * 1.5 : CGFloat(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Collection+Extension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/3/21.
|
||||
// Copyright © 2023 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Collection {
|
||||
subscript (safe index: Index) -> Element? {
|
||||
return indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// File.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/4/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Dictionary {
|
||||
func convertToString() -> String?{
|
||||
let dicData = try? JSONSerialization.data(withJSONObject: self, options: [])
|
||||
guard let data = dicData else { return nil }
|
||||
let str = String(data: data, encoding: String.Encoding.utf8)
|
||||
return str
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// UIView+Extension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/5/27.
|
||||
//
|
||||
|
||||
#if USE_OPENCOMBINE
|
||||
import OpenCombine
|
||||
import OpenCombineDispatch
|
||||
import OpenCombineFoundation
|
||||
#else
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
extension DispatchQueue {
|
||||
#if USE_OPENCOMBINE
|
||||
static var mainQueue: DispatchQueue.OCombine {
|
||||
return DispatchQueue.main.ocombine
|
||||
}
|
||||
#else
|
||||
static var mainQueue: DispatchQueue {
|
||||
return DispatchQueue.main
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// NSObject+Extension.h
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by WesleyLei on 2022/9/23.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface NSObject (CoreExtension)
|
||||
|
||||
+ (NSString *)getRoomEngineKey;
|
||||
|
||||
+ (NSString *)getRoomInfoKey;
|
||||
|
||||
+ (NSString *)getLocalUserInfoKey;
|
||||
|
||||
+ (NSString *)getTopViewKey;
|
||||
|
||||
+ (NSString *)getBottomViewKey;
|
||||
|
||||
+ (NSString *)getUserListControllerKey;
|
||||
|
||||
+ (NSString *)getExtensionControllerKey;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// NSObject+Extension.m
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by WesleyLei on 2022/9/23.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
#import "NSObject+CoreExtension.h"
|
||||
@implementation NSObject (CoreExtension)
|
||||
|
||||
+ (NSString *)getRoomEngineKey {
|
||||
return @"TUIRoomKit.Room.Engine.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getRoomInfoKey {
|
||||
return @"TUIRoomKit.Room.Info.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getLocalUserInfoKey {
|
||||
return @"TUIRoomKit.Local.User.Info.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getTopViewKey {
|
||||
return @"TUIRoomKit.Top.Menu.View.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getBottomViewKey {
|
||||
return @"TUIRoomKit.Bottom.Menu.View.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getUserListControllerKey {
|
||||
return @"TUIRoomKit.User.List.Controller.Key";
|
||||
}
|
||||
|
||||
+ (NSString *)getExtensionControllerKey {
|
||||
return @"TUIRoomKit.Extension.Controller.Key";
|
||||
}
|
||||
|
||||
@end
|
||||
53
TUIKit/TUIRoomKit/Source/Common/Extension/RoomStyle.swift
Normal file
53
TUIKit/TUIRoomKit/Source/Common/Extension/RoomStyle.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// UIColor+Style.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2022/12/27.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension UIColor {
|
||||
public convenience init(_ hex: Int, alpha: CGFloat = 1.0) {
|
||||
assert(0...0xFFFFFF ~= hex, "The color hex value must between 0 to 0XFFFFFF")
|
||||
let red = (hex & 0xFF0000) >> 16
|
||||
let green = (hex & 0x00FF00) >> 8
|
||||
let blue = (hex & 0x0000FF)
|
||||
self.init(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
|
||||
public convenience init(red: Int, green: Int, blue: Int, alpha: CGFloat = 1.0) {
|
||||
self.init(
|
||||
red: CGFloat(red) / 255.0,
|
||||
green: CGFloat(green) / 255.0,
|
||||
blue: CGFloat(blue) / 255.0,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
|
||||
public func trans2Image() -> UIImage {
|
||||
let rect = CGRect(x: 0, y: 0, width: 1.0, height: 1.0)
|
||||
UIGraphicsBeginImageContext(rect.size)
|
||||
let context = UIGraphicsGetCurrentContext()
|
||||
context?.setFillColor(self.cgColor)
|
||||
context?.fill(rect)
|
||||
let theImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return theImage ?? UIImage()
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
func roundedRect(rect:CGRect, byRoundingCorners: UIRectCorner, cornerRadii: CGSize) {
|
||||
let maskPath = UIBezierPath(roundedRect: rect, byRoundingCorners: byRoundingCorners, cornerRadii: cornerRadii)
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = bounds
|
||||
maskLayer.path = maskPath.cgPath
|
||||
self.layer.mask = maskLayer
|
||||
}
|
||||
|
||||
func roundedCircle(rect: CGRect) {
|
||||
roundedRect(rect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: bounds.size.width / 2, height: bounds.size.height / 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// String+Extension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2022/12/26.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func addIntervalSpace(intervalStr: String, interval: Int) -> String {
|
||||
var output = ""
|
||||
enumerated().forEach { index, c in
|
||||
if (index % interval == 0) && index > 0 {
|
||||
output += intervalStr
|
||||
}
|
||||
output.append(c)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func convertToDic() -> [String : Any]?{
|
||||
guard let data = self.data(using: String.Encoding.utf8) else { return nil }
|
||||
if let dict = try? JSONSerialization.jsonObject(with: data,
|
||||
options: .mutableContainers) as? [String : Any] {
|
||||
return dict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isStringOnlyDigits() -> Bool {
|
||||
let regex = "^[0-9]+$"
|
||||
let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
|
||||
return predicate.evaluate(with: self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// TimeZone+Extension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/6/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TimeZone {
|
||||
func getTimeZoneName() -> String {
|
||||
//todo:
|
||||
let genericName = self.localizedName(for: .generic, locale: .current)
|
||||
let shortStandardName = self.localizedName(for: .shortStandard, locale: .current)
|
||||
let name = "(" + (shortStandardName ?? "") + ")" + (genericName ?? "")
|
||||
return name
|
||||
}
|
||||
}
|
||||
25
TUIKit/TUIRoomKit/Source/Common/Extension/UIImage+RTL.swift
Normal file
25
TUIKit/TUIRoomKit/Source/Common/Extension/UIImage+RTL.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// UIImage+RTL.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/8/31.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension UIImage {
|
||||
func checkOverturn() -> UIImage? {
|
||||
if isRTL {
|
||||
UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale)
|
||||
guard let bitmap: CGContext = UIGraphicsGetCurrentContext() else { return nil }
|
||||
guard let cgImage = self.cgImage else { return nil }
|
||||
bitmap.translateBy(x: self.size.width / 2, y: self.size.height / 2)
|
||||
bitmap.scaleBy(x: -1.0, y: -1.0)
|
||||
bitmap.translateBy(x: -self.size.width / 2, y: -self.size.height / 2)
|
||||
bitmap.draw(cgImage, in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext()
|
||||
return image
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// UIViewController+Extension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIViewController {
|
||||
var interfaceOrientation: UIInterfaceOrientation {
|
||||
if #available(iOS 13.0, *) {
|
||||
return UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
|
||||
} else {
|
||||
return UIApplication.shared.statusBarOrientation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// ConferenceRouteDefine.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Factory
|
||||
|
||||
enum ConferenceRoute {
|
||||
case none
|
||||
case schedule(memberSelectFactory: MemberSelectionFactory?)
|
||||
case main(conferenceParams: ConferenceParamType)
|
||||
case selectMember(memberSelectParams: MemberSelectParams?)
|
||||
case selectedMember(showDeleteButton: Bool, selectedMembers: [UserInfo])
|
||||
case timeZone
|
||||
case scheduleDetails(conferenceInfo: ConferenceInfo)
|
||||
case modifySchedule(conferenceInfo: ConferenceInfo)
|
||||
case popup(view: UIView)
|
||||
case alert(state: AlertState)
|
||||
case invitation(roomInfo: TUIRoomInfo, invitation: TUIInvitation)
|
||||
|
||||
func hideNavigationBar() -> Bool {
|
||||
switch self {
|
||||
case .main, .selectMember, .timeZone, .invitation:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
init(viewController: UIViewController) {
|
||||
switch viewController {
|
||||
case is ConferenceMainViewController:
|
||||
let vc = viewController as? ConferenceMainViewController
|
||||
if let startParams = vc?.startConferenceParams {
|
||||
self = .main(conferenceParams: ConferenceParamType(startParams: startParams))
|
||||
} else if let joinParams = vc?.joinConferenceParams {
|
||||
self = .main(conferenceParams: ConferenceParamType(joinParams: joinParams))
|
||||
} else {
|
||||
self = .none
|
||||
}
|
||||
case is ScheduleConferenceViewController:
|
||||
guard let vc = viewController as? ScheduleConferenceViewController else {
|
||||
self = .none
|
||||
break
|
||||
}
|
||||
self = .schedule(memberSelectFactory: vc.memberSelectionFactory)
|
||||
case _ as ContactViewProtocol:
|
||||
self = .selectMember(memberSelectParams: nil)
|
||||
case is SelectedMembersViewController:
|
||||
let vc = viewController as? SelectedMembersViewController
|
||||
let showDeleteButton = vc?.showDeleteButton ?? true
|
||||
let selectedMember = vc?.selectedMember ?? []
|
||||
self = .selectedMember(showDeleteButton: showDeleteButton, selectedMembers: selectedMember)
|
||||
case is TimeZoneViewController:
|
||||
self = .timeZone
|
||||
case is ScheduleDetailsViewController:
|
||||
let vc = viewController as? ScheduleDetailsViewController
|
||||
self = .scheduleDetails(conferenceInfo: vc?.conferenceInfo ?? ConferenceInfo())
|
||||
case is ModifyScheduleViewController:
|
||||
let vc = viewController as? ModifyScheduleViewController
|
||||
self = .modifySchedule(conferenceInfo: vc?.conferenceInfo ?? ConferenceInfo())
|
||||
case is ConferenceInvitationViewController:
|
||||
let vc = viewController as? ConferenceInvitationViewController
|
||||
self = .invitation(roomInfo: vc?.roomInfo ?? TUIRoomInfo(), invitation: vc?.invitation ?? TUIInvitation())
|
||||
case is PopupViewController:
|
||||
let vc = viewController as? PopupViewController
|
||||
self = .popup(view: vc?.contentView ?? viewController.view)
|
||||
case is UIAlertController:
|
||||
let vc = viewController as? UIAlertController
|
||||
let alertState = AlertState(title: vc?.title, message: vc?.message, sureAction: vc?.actions.first, declineAction: vc?.actions.last)
|
||||
self = .alert(state: alertState)
|
||||
default:
|
||||
self = .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRoute {
|
||||
var viewController: UIViewController {
|
||||
switch self {
|
||||
case .main(let params):
|
||||
let vc = ConferenceMainViewController()
|
||||
if case .startConferecneParams(let startConferenceParams) = params {
|
||||
vc.setStartConferenceParams(params: startConferenceParams)
|
||||
} else if case .joinConferenceParams(let joinConferenceParams) = params {
|
||||
vc.setJoinConferenceParams(params: joinConferenceParams)
|
||||
}
|
||||
return vc
|
||||
case .schedule(memberSelectFactory: let factory):
|
||||
return ScheduleConferenceViewController(memberSelectFactory: factory)
|
||||
case .selectMember(memberSelectParams: let memberSelectParams):
|
||||
guard let params = memberSelectParams else {
|
||||
return UIViewController()
|
||||
}
|
||||
let participants = params.participants
|
||||
guard let vc = getSelectMemberViewController(participants: participants) else {
|
||||
return UIViewController()
|
||||
}
|
||||
vc.delegate = params.delegate
|
||||
return vc
|
||||
case .selectedMember(showDeleteButton: let isShow, let selectedMembers):
|
||||
return SelectedMembersViewController(showDeleteButton: isShow, selectedMembers: selectedMembers)
|
||||
case .timeZone:
|
||||
return TimeZoneViewController()
|
||||
case .scheduleDetails(conferenceInfo: let info):
|
||||
return ScheduleDetailsViewController(conferenceInfo: info)
|
||||
case .modifySchedule(conferenceInfo: let info):
|
||||
return ModifyScheduleViewController(conferenceInfo: info)
|
||||
case .invitation(roomInfo: let info, invitation: let invitation):
|
||||
return ConferenceInvitationViewController(roomInfo: info, invitation: invitation)
|
||||
case .popup(view: let view):
|
||||
return PopupViewController(contentView: view)
|
||||
case .alert(state: let alertState):
|
||||
let alertVC = UIAlertController(title: alertState.title,
|
||||
message: alertState.message,
|
||||
preferredStyle: .alert)
|
||||
if let sureAction = alertState.sureAction {
|
||||
alertVC.addAction(sureAction)
|
||||
}
|
||||
if let declineAction = alertState.declineAction {
|
||||
alertVC.addAction(declineAction)
|
||||
}
|
||||
return alertVC
|
||||
default:
|
||||
return UIViewController()
|
||||
}
|
||||
}
|
||||
|
||||
func getSelectMemberViewController(participants: ConferenceParticipants) -> (ContactViewProtocol & UIViewController)? {
|
||||
guard let vc = Container.shared.contactViewController(participants) as? (ContactViewProtocol & UIViewController) else {
|
||||
return nil
|
||||
}
|
||||
return vc
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRoute: Equatable {
|
||||
static func ==(lhs: ConferenceRoute, rhs: ConferenceRoute) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.none, .none),
|
||||
(.schedule, .schedule),
|
||||
(.main, .main),
|
||||
(.selectMember, .selectMember),
|
||||
(.selectedMember, .selectedMember),
|
||||
(.timeZone, .timeZone),
|
||||
(.scheduleDetails, .scheduleDetails),
|
||||
(.modifySchedule, .modifySchedule),
|
||||
(.invitation, .invitation):
|
||||
return true
|
||||
case let (.popup(l), .popup(r)):
|
||||
return l == r
|
||||
case let (.alert(l), .alert(r)):
|
||||
return l == r
|
||||
case (.none, _),
|
||||
(.schedule, _),
|
||||
(.main, _),
|
||||
(.selectMember, _),
|
||||
(.selectedMember, _),
|
||||
(.timeZone, _),
|
||||
(.scheduleDetails, _),
|
||||
(.modifySchedule, _),
|
||||
(.alert, _),
|
||||
(.popup, _),
|
||||
(.invitation, _):
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AlertState: Equatable {
|
||||
var title: String?
|
||||
var message: String?
|
||||
var sureAction: UIAlertAction?
|
||||
var declineAction: UIAlertAction?
|
||||
}
|
||||
|
||||
enum ConferenceParamType {
|
||||
case startConferecneParams(StartConferenceParams)
|
||||
case joinConferenceParams(JoinConferenceParams)
|
||||
|
||||
init(startParams: StartConferenceParams) {
|
||||
self = .startConferecneParams(startParams)
|
||||
}
|
||||
|
||||
init(joinParams: JoinConferenceParams) {
|
||||
self = .joinConferenceParams(joinParams)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
class PrintRouteInterceptor: Interceptor {
|
||||
typealias State = ConferenceRouteState
|
||||
|
||||
func actionDispatched(action: any Action, oldState: State, newState: State) {
|
||||
let name = String(describing: type(of: self))
|
||||
let actionName = String(describing: type(of: action))
|
||||
print("route action is: \(newState.currentRouteAction)")
|
||||
switch newState.currentRouteAction {
|
||||
case .presented(route: let route):
|
||||
print("route is: \(oldState.currentRoute) to \(route)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// ViewRouteState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NavigationAction<ViewRoute>: Equatable where ViewRoute: Equatable {
|
||||
case present(route: ViewRoute)
|
||||
case push(route: ViewRoute)
|
||||
case presented(route: ViewRoute)
|
||||
}
|
||||
|
||||
typealias ConferenceNavigation = NavigationAction<ConferenceRoute>
|
||||
|
||||
struct ConferenceRouteState {
|
||||
var currentRouteAction: ConferenceNavigation = .presented(route: .none)
|
||||
var currentRoute: ConferenceRoute = .none
|
||||
var memberSelectionFactory: MemberSelectionFactory?
|
||||
}
|
||||
|
||||
enum ConferenceNavigationAction {
|
||||
static let key = "conference.navigation.action"
|
||||
static let navigate = ActionTemplate(id: key.appending("navigate"), payloadType: ConferenceNavigation.self)
|
||||
static let setMemberSelectionFactory = ActionTemplate(id: key.appending(".setMemberSelectionFactory"), payloadType: MemberSelectionFactory.self)
|
||||
}
|
||||
|
||||
let routeReducer = Reducer<ConferenceRouteState>(
|
||||
ReduceOn(ConferenceNavigationAction.navigate, reduce: { state, action in
|
||||
state.currentRouteAction = action.payload
|
||||
switch action.payload {
|
||||
case let .presented(route: route):
|
||||
state.currentRoute = route
|
||||
default:
|
||||
break
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceNavigationAction.setMemberSelectionFactory, reduce: { state, action in
|
||||
state.memberSelectionFactory = action.payload
|
||||
})
|
||||
)
|
||||
236
TUIKit/TUIRoomKit/Source/Common/Route/ConferenceRouteStore.swift
Normal file
236
TUIKit/TUIRoomKit/Source/Common/Route/ConferenceRouteStore.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
//
|
||||
// ConferenceRoute.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/19.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
protocol Route: ActionDispatcher {
|
||||
func initializeRoute(viewController: UIViewController, rootRoute: ConferenceRoute)
|
||||
func uninitialize()
|
||||
func pushTo(route: ConferenceRoute)
|
||||
func present(route: ConferenceRoute)
|
||||
func dismiss(animated: Bool)
|
||||
func pop()
|
||||
func popTo(route: ConferenceRoute)
|
||||
func pop(route: ConferenceRoute)
|
||||
func showContactView(delegate: ContactViewSelectDelegate, participants: ConferenceParticipants)
|
||||
}
|
||||
|
||||
private let currentRouteActionSelector = Selector(keyPath: \ConferenceRouteState.currentRouteAction)
|
||||
private let memberSelectFactorySelector = Selector(keyPath: \ConferenceRouteState.memberSelectionFactory)
|
||||
|
||||
class ConferenceRouter: NSObject {
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
// MARK: - private property.
|
||||
private var cancellableSet = Set<AnyCancellable>()
|
||||
private weak var navigationController: UINavigationController?
|
||||
private weak var rootViewController: UIViewController?
|
||||
private weak var navigationControllerDelegate: UINavigationControllerDelegate?
|
||||
private let store: Store<ConferenceRouteState, Void> = {
|
||||
let store = Store.init(initialState: ConferenceRouteState(), reducers: [routeReducer])
|
||||
#if DEBUG
|
||||
store.register(interceptor: PrintRouteInterceptor())
|
||||
#endif
|
||||
return store
|
||||
}()
|
||||
}
|
||||
|
||||
extension ConferenceRouter: Route {
|
||||
|
||||
func initializeRoute(viewController: UIViewController, rootRoute: ConferenceRoute) {
|
||||
guard self.rootViewController == nil else { return }
|
||||
self.rootViewController = viewController
|
||||
if let nav = viewController.navigationController {
|
||||
self.navigationController = nav
|
||||
if nav.delegate == nil {
|
||||
nav.delegate = self
|
||||
} else {
|
||||
self.navigationControllerDelegate = nav.delegate
|
||||
nav.delegate = self
|
||||
}
|
||||
} else {
|
||||
let navigationController = UINavigationController.init(rootViewController: viewController)
|
||||
self.navigationController = navigationController
|
||||
}
|
||||
store.dispatch(action: ConferenceNavigationAction.navigate(payload: ConferenceNavigation.presented(route: rootRoute)))
|
||||
let navigationPublisher = store.select(currentRouteActionSelector)
|
||||
subscribe(to: navigationPublisher)
|
||||
}
|
||||
|
||||
func pushTo(route: ConferenceRoute) {
|
||||
store.dispatch(action: ConferenceNavigationAction.navigate(payload: .push(route: route)))
|
||||
}
|
||||
|
||||
func present(route: ConferenceRoute) {
|
||||
store.dispatch(action: ConferenceNavigationAction.navigate(payload: .present(route: route)))
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool) {
|
||||
guard let viewController = self.navigationController?.topViewController ?? self.navigationController else { return }
|
||||
if let presentedViewController = viewController.presentedViewController {
|
||||
presentedViewController.dismiss(animated: animated) {
|
||||
[weak self] in
|
||||
guard let self = self else { return }
|
||||
if let rootVC = self.rootViewController {
|
||||
let viewRoute = ConferenceRoute.init(viewController: rootVC)
|
||||
self.store.dispatch(action: ConferenceNavigationAction.navigate(payload: ConferenceNavigation.presented(route: viewRoute)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pop() {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
func popTo(route: ConferenceRoute) {
|
||||
guard let viewControllers = self.navigationController?.viewControllers else { return }
|
||||
if let targetVC = viewControllers.first(where: { viewController in
|
||||
let iterateRoute = ConferenceRoute(viewController: viewController)
|
||||
return iterateRoute == route
|
||||
}) {
|
||||
self.navigationController?.popToViewController(targetVC, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
func pop(route: ConferenceRoute) {
|
||||
guard var viewControllers = self.navigationController?.viewControllers else { return }
|
||||
if let index = viewControllers.firstIndex(where: { viewController in
|
||||
let iterateRoute = ConferenceRoute(viewController: viewController)
|
||||
return iterateRoute == route
|
||||
}) {
|
||||
viewControllers.remove(at: index)
|
||||
self.navigationController?.viewControllers = viewControllers
|
||||
}
|
||||
}
|
||||
|
||||
func uninitialize() {
|
||||
if let delegate = self.navigationControllerDelegate {
|
||||
self.navigationController?.delegate = delegate
|
||||
}
|
||||
}
|
||||
|
||||
func showContactView(delegate: ContactViewSelectDelegate, participants: ConferenceParticipants) {
|
||||
guard let factory = store.selectCurrent(memberSelectFactorySelector) else { return }
|
||||
let selectParams = MemberSelectParams(participants: participants, delegate: delegate, factory: factory)
|
||||
store.dispatch(action: ConferenceNavigationAction.navigate(payload: .push(route: .selectMember(memberSelectParams: selectParams))))
|
||||
}
|
||||
|
||||
func dispatch(action: Action) {
|
||||
store.dispatch(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRouter {
|
||||
var viewController: UIViewController? {
|
||||
return navigationController ?? rootViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRouter {
|
||||
private func subscribe(to navigationPublisher: AnyPublisher<ConferenceNavigation, Never>) {
|
||||
navigationPublisher
|
||||
.receive(on: RunLoop.main)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.respond(to: action)
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func respond(to action: ConferenceNavigation) {
|
||||
switch action {
|
||||
case let .present(route: route):
|
||||
present(viewRoute: route)
|
||||
case let .push(route: route):
|
||||
push(viewRoute: route)
|
||||
case .presented:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func present(viewRoute: ConferenceRoute) {
|
||||
guard let viewController = self.navigationController?.topViewController ?? self.navigationController else { return }
|
||||
if viewController.presentedViewController != nil {
|
||||
self.dismiss(animated: false)
|
||||
}
|
||||
var animated: Bool = true
|
||||
if case .invitation = viewRoute {
|
||||
animated = false
|
||||
}
|
||||
viewController.present(viewRoute.viewController, animated: animated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.store.dispatch(action: ConferenceNavigationAction.navigate(payload: ConferenceNavigation.presented(route: viewRoute)))
|
||||
}
|
||||
}
|
||||
|
||||
private func push(viewRoute: ConferenceRoute) {
|
||||
guard let navigationController = self.navigationController else { return }
|
||||
navigationController.pushViewController(viewRoute.viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRouter {
|
||||
func toggleNavigationBar(for view: ConferenceRoute, animated: Bool, navigationController: UINavigationController) {
|
||||
if view.hideNavigationBar() {
|
||||
hideNavigationBar(navigationController: navigationController, animated: animated)
|
||||
}
|
||||
else {
|
||||
showNavigationBar(navigationController: navigationController, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
func hideNavigationBar(navigationController: UINavigationController, animated: Bool) {
|
||||
if animated {
|
||||
navigationController.transitionCoordinator?.animate(alongsideTransition: {
|
||||
context in
|
||||
navigationController.setNavigationBarHidden(true, animated: true)
|
||||
})
|
||||
}
|
||||
else {
|
||||
navigationController.setNavigationBarHidden(true, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
func showNavigationBar(navigationController: UINavigationController, animated: Bool) {
|
||||
if navigationController.isNavigationBarHidden {
|
||||
if animated {
|
||||
navigationController.transitionCoordinator?.animate(alongsideTransition: {
|
||||
context in
|
||||
navigationController.setNavigationBarHidden(false, animated: true)
|
||||
})
|
||||
}
|
||||
else {
|
||||
navigationController.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceRouter: UINavigationControllerDelegate {
|
||||
/// Animate the navigation bar display with view controller transition.
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
willShow viewController: UIViewController,
|
||||
animated: Bool) {
|
||||
let destRoute = ConferenceRoute.init(viewController: viewController)
|
||||
guard destRoute != .none else { return }
|
||||
toggleNavigationBar(for: destRoute, animated: animated, navigationController: navigationController)
|
||||
}
|
||||
|
||||
/// Trigger a `ConferenceNavigationAction` event according to the destination view type.
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
animated: Bool) {
|
||||
let destRoute = ConferenceRoute.init(viewController: viewController)
|
||||
guard destRoute != .none else { return }
|
||||
self.store.dispatch(action: ConferenceNavigationAction.navigate(payload: ConferenceNavigation.presented(route: destRoute)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Navigation+Injection.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/20.
|
||||
//
|
||||
import Factory
|
||||
|
||||
extension Container {
|
||||
var navigation: Factory<Route> {
|
||||
Factory(self) {
|
||||
ConferenceRouter()
|
||||
}
|
||||
.shared
|
||||
}
|
||||
}
|
||||
103
TUIKit/TUIRoomKit/Source/Common/Route/PopupViewController.swift
Normal file
103
TUIKit/TUIRoomKit/Source/Common/Route/PopupViewController.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// PopupViewController.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by aby on 2024/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Factory
|
||||
|
||||
class PopupViewController: UIViewController {
|
||||
|
||||
let contentView: UIView
|
||||
|
||||
private let visualEffectView: UIView = {
|
||||
let blurEffect = UIBlurEffect(style: .dark)
|
||||
let view = UIVisualEffectView(effect: blurEffect)
|
||||
view.frame = UIScreen.main.bounds
|
||||
view.alpha = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
public init(contentView: UIView) {
|
||||
self.contentView = contentView
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .custom
|
||||
transitioningDelegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
}
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .portrait
|
||||
}
|
||||
|
||||
func constructViewHierarchy() {
|
||||
view.addSubview(contentView)
|
||||
}
|
||||
|
||||
func activateConstraints() {
|
||||
contentView.snp.remakeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
guard let touch = touches.first else { return }
|
||||
let point = touch.location(in: contentView)
|
||||
guard !contentView.layer.contains(point) else { return }
|
||||
route.dismiss(animated: true)
|
||||
}
|
||||
|
||||
// MARK: - private property.
|
||||
@Injected(\.navigation) private var route: Route
|
||||
}
|
||||
|
||||
extension PopupViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) ->
|
||||
UIViewControllerAnimatedTransitioning? {
|
||||
let transitionAnimator = AlertTransitionAnimator()
|
||||
transitionAnimator.alertTransitionStyle = .present
|
||||
if interfaceOrientation.isPortrait {
|
||||
transitionAnimator.alertTransitionPosition = .bottom
|
||||
} else {
|
||||
transitionAnimator.alertTransitionPosition = .right
|
||||
}
|
||||
source.view.addSubview(visualEffectView)
|
||||
UIView.animate(withDuration: transitionAnimator.duration) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.visualEffectView.alpha = 1
|
||||
}
|
||||
return transitionAnimator
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
let transitionAnimator = AlertTransitionAnimator()
|
||||
transitionAnimator.alertTransitionStyle = .dismiss
|
||||
if interfaceOrientation.isPortrait {
|
||||
transitionAnimator.alertTransitionPosition = .bottom
|
||||
} else {
|
||||
transitionAnimator.alertTransitionPosition = .right
|
||||
}
|
||||
UIView.animate(withDuration: transitionAnimator.duration) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.visualEffectView.alpha = 0
|
||||
} completion: { [weak self] finished in
|
||||
guard let self = self else { return }
|
||||
if finished {
|
||||
self.visualEffectView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
return transitionAnimator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// ConferenceSession.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/3/6.
|
||||
//
|
||||
|
||||
import RTCRoomEngine
|
||||
|
||||
class ConferenceOptions {
|
||||
|
||||
static func quickStart(startConferenceParams: StartConferenceParams,
|
||||
onSuccess: TUIRoomInfoBlock? = nil,
|
||||
onError: TUIErrorBlock? = nil) {
|
||||
let roomInfo = createRoomInfo(startConferenceParams: startConferenceParams)
|
||||
quickStartConference(roomInfo: roomInfo,
|
||||
enableAudio: startConferenceParams.isOpenMicrophone,
|
||||
enableVideo: startConferenceParams.isOpenCamera,
|
||||
isSoundOnSpeaker: startConferenceParams.isOpenSpeaker,
|
||||
onSuccess: onSuccess,
|
||||
onError: onError)
|
||||
}
|
||||
|
||||
private static func quickStartConference(roomInfo: TUIRoomInfo,
|
||||
enableAudio: Bool,
|
||||
enableVideo: Bool,
|
||||
isSoundOnSpeaker: Bool,
|
||||
onSuccess: TUIRoomInfoBlock?,
|
||||
onError: TUIErrorBlock?) {
|
||||
EngineManager.shared.createRoom(roomInfo: roomInfo) {
|
||||
EngineManager.shared.enterRoom(roomId: roomInfo.roomId,
|
||||
enableAudio: enableAudio,
|
||||
enableVideo: enableVideo,
|
||||
isSoundOnSpeaker: isSoundOnSpeaker) { roomInfo in
|
||||
onSuccess?(roomInfo)
|
||||
} onError: { code, message in
|
||||
onError?(code, message)
|
||||
}
|
||||
} onError: { code, message in
|
||||
onError?(code, message)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static func join(joinConferenParams: JoinConferenceParams,
|
||||
onSuccess: TUIRoomInfoBlock? = nil,
|
||||
onError: TUIErrorBlock? = nil) {
|
||||
var options:TUIEnterRoomOptions?
|
||||
if let password = joinConferenParams.password, password.count > 0 {
|
||||
options = TUIEnterRoomOptions()
|
||||
options?.password = password
|
||||
}
|
||||
EngineManager.shared.enterRoom(roomId: joinConferenParams.roomId,
|
||||
options: options,
|
||||
enableAudio: joinConferenParams.isOpenMicrophone,
|
||||
enableVideo: joinConferenParams.isOpenCamera,
|
||||
isSoundOnSpeaker: joinConferenParams.isOpenSpeaker) { roomInfo in
|
||||
onSuccess?(roomInfo)
|
||||
} onError: { code, message in
|
||||
onError?(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
private static func createRoomInfo(startConferenceParams: StartConferenceParams) -> TUIRoomInfo {
|
||||
let roomInfo = TUIRoomInfo()
|
||||
roomInfo.roomId = startConferenceParams.roomId
|
||||
roomInfo.isMicrophoneDisableForAllUser = !startConferenceParams.isOpenMicrophone
|
||||
roomInfo.isCameraDisableForAllUser = !startConferenceParams.isOpenCamera
|
||||
roomInfo.isSeatEnabled = startConferenceParams.isSeatEnabled
|
||||
roomInfo.name = startConferenceParams.name ?? ""
|
||||
roomInfo.seatMode = .applyToTake
|
||||
return roomInfo
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
25
TUIKit/TUIRoomKit/Source/Common/Utils/CoreExtension.swift
Normal file
25
TUIKit/TUIRoomKit/Source/Common/Utils/CoreExtension.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// CoreExtension.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by WesleyLei on 2022/9/23.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TUICore
|
||||
|
||||
let gRoomEngineKey = NSObject.getRoomEngineKey()
|
||||
// {roomId:roomId}
|
||||
let gRoomInfoKey = NSObject.getRoomInfoKey()
|
||||
// {roomId:roomId}
|
||||
let gLocalUserInfoKey = NSObject.getLocalUserInfoKey()
|
||||
// {roomId:roomId}
|
||||
let gTopViewKey = NSObject.getTopViewKey()
|
||||
// {roomId:roomId}
|
||||
let gBottomViewKey = NSObject.getBottomViewKey()
|
||||
// {roomId:roomId}
|
||||
let gUserListControllerKey = NSObject.getUserListControllerKey()
|
||||
// {roomEngine:roomEngineObj,currentUserInfo:currentUserInfoObj}
|
||||
let gExtensionControllerKey = NSObject.getExtensionControllerKey()
|
||||
// {roomEngine:roomEngineObj,currentUserInfo:currentUserInfoObj}
|
||||
53
TUIKit/TUIRoomKit/Source/Common/Utils/FetchRoomId.swift
Normal file
53
TUIKit/TUIRoomKit/Source/Common/Utils/FetchRoomId.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// FetchRoomId.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/1/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import TUICore
|
||||
|
||||
class FetchRoomId {
|
||||
class func getRoomId(onGetRoomId: @escaping (String)->()) {
|
||||
let roomId = getRandomRoomId(numberOfDigits: 6)
|
||||
checkIfRoomIdExists(roomId: roomId) {
|
||||
getRoomId(onGetRoomId: onGetRoomId)
|
||||
} onNotExist: {
|
||||
onGetRoomId(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
class func getRandomRoomId(numberOfDigits: Int) -> String {
|
||||
var numberOfDigit = numberOfDigits > 0 ? numberOfDigits : 1
|
||||
numberOfDigit = numberOfDigit < 10 ? numberOfDigit : 9
|
||||
let minNumber = Int(truncating: NSDecimalNumber(decimal: pow(10, numberOfDigit - 1)))
|
||||
let maxNumber = Int(truncating: NSDecimalNumber(decimal: pow(10, numberOfDigit))) - 1
|
||||
let randomNumber = arc4random_uniform(UInt32(maxNumber - minNumber)) + UInt32(minNumber)
|
||||
return String(randomNumber)
|
||||
}
|
||||
|
||||
class func checkIfRoomIdExists(roomId: String, onExist: @escaping () -> (), onNotExist: @escaping () -> ()) {
|
||||
V2TIMManager.sharedInstance().getGroupsInfo([roomId]) { infoResult in
|
||||
if checkIfRoomIdExistsWithResultCode(infoResult?.first?.resultCode) {
|
||||
onExist()
|
||||
} else {
|
||||
onNotExist()
|
||||
}
|
||||
} fail: { code, message in
|
||||
onNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
private class func checkIfRoomIdExistsWithResultCode(_ resultCode: Int32?) -> Bool {
|
||||
let kIMCodeGroupSuccess = 0
|
||||
let kIMCodeGroupInsufficientOperationAuthority = 10007
|
||||
if let resultCode = resultCode {
|
||||
return resultCode == kIMCodeGroupSuccess || resultCode == kIMCodeGroupInsufficientOperationAuthority
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
TUIKit/TUIRoomKit/Source/Common/Utils/RoomCommon.swift
Normal file
128
TUIKit/TUIRoomKit/Source/Common/Utils/RoomCommon.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// RoomCommon.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import TUICore
|
||||
|
||||
var isRTL: Bool {
|
||||
TUIGlobalization.getRTLOption()
|
||||
}
|
||||
|
||||
var isLandscape: Bool {
|
||||
if #available(iOS 13, *) {
|
||||
return UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape == true
|
||||
} else {
|
||||
return UIApplication.shared.statusBarOrientation.isLandscape
|
||||
}
|
||||
}
|
||||
|
||||
class RoomCommon {
|
||||
enum AuthorizationDeniedType {
|
||||
case microphone
|
||||
case camera
|
||||
}
|
||||
class func checkAuthorMicStatusIsDenied() -> Bool {
|
||||
return AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
class func checkAuthorCamaraStatusIsDenied() -> Bool {
|
||||
return AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
}
|
||||
class func micStateActionWithPopCompletion(completion: @escaping (Bool) -> ()) {
|
||||
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
completion(granted)
|
||||
}
|
||||
} else {
|
||||
showAuthorizationAlert(deniedType: .microphone)
|
||||
}
|
||||
}
|
||||
class func cameraStateActionWithPopCompletion(completion: @escaping (Bool) -> () ) {
|
||||
if AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined {
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
completion(granted)
|
||||
}
|
||||
} else {
|
||||
showAuthorizationAlert(deniedType: .camera)
|
||||
}
|
||||
}
|
||||
|
||||
private class func showAuthorizationAlert(deniedType: AuthorizationDeniedType) {
|
||||
let laterMessage: String = .permissionLaterText
|
||||
let openSettingMessage: String = .permissionEnableText
|
||||
let title: String = deniedType == .microphone ? .microphonePermissionTitle : .cameraPermissionTitle
|
||||
let message: String = deniedType == .microphone ? .microphonePermissionTipsText : .cameraPermissionTipsText
|
||||
let alertVC = UIAlertController(title: title,
|
||||
message: message,
|
||||
preferredStyle: .alert)
|
||||
let declineAction = UIAlertAction(title: laterMessage, style: .cancel) { _ in
|
||||
|
||||
}
|
||||
let sureAction = UIAlertAction(title: openSettingMessage, style: .default) { _ in
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
if #available(iOS 10.0, *) {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
} else {
|
||||
UIApplication.shared.openURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
alertVC.addAction(declineAction)
|
||||
alertVC.addAction(sureAction)
|
||||
DispatchQueue.main.async {
|
||||
let vc = getCurrentWindowViewController()
|
||||
vc?.present(alertVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
class func getCurrentWindowViewController() -> UIViewController? {
|
||||
var keyWindow: UIWindow?
|
||||
for window in UIApplication.shared.windows {
|
||||
if window.isMember(of: UIWindow.self), window.isKeyWindow {
|
||||
keyWindow = window
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let rootController = keyWindow?.rootViewController else {
|
||||
return nil
|
||||
}
|
||||
func findCurrentController(from vc: UIViewController?) -> UIViewController? {
|
||||
if let nav = vc as? UINavigationController {
|
||||
return findCurrentController(from: nav.topViewController)
|
||||
} else if let tabBar = vc as? UITabBarController {
|
||||
return findCurrentController(from: tabBar.selectedViewController)
|
||||
} else if let presented = vc?.presentedViewController {
|
||||
return findCurrentController(from: presented)
|
||||
}
|
||||
return vc
|
||||
}
|
||||
let viewController = findCurrentController(from: rootController)
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static var microphonePermissionTitle: String {
|
||||
localized("No access to microphone")
|
||||
}
|
||||
static var microphonePermissionTipsText: String {
|
||||
localized("Unable to use audio function, click \"Authorize Now\" to open the microphone permission.")
|
||||
}
|
||||
static var cameraPermissionTitle: String {
|
||||
localized("No access to camera")
|
||||
}
|
||||
static var cameraPermissionTipsText: String {
|
||||
localized("Unable to use the video function, click \"Authorize Now\" to open the camera permission.")
|
||||
}
|
||||
static var permissionLaterText: String {
|
||||
localized("Later")
|
||||
}
|
||||
static var permissionEnableText: String {
|
||||
localized("Authorize Now")
|
||||
}
|
||||
}
|
||||
70
TUIKit/TUIRoomKit/Source/ConferenceDefine.swift
Normal file
70
TUIKit/TUIRoomKit/Source/ConferenceDefine.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// ConferenceError.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/3/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
@objcMembers public class StartConferenceParams: NSObject {
|
||||
public var roomId = ""
|
||||
|
||||
public var isOpenMicrophone = true
|
||||
public var isOpenCamera = false
|
||||
public var isOpenSpeaker = true
|
||||
|
||||
public var isMicrophoneDisableForAllUser = false
|
||||
public var isCameraDisableForAllUser = false
|
||||
|
||||
public var isSeatEnabled = false
|
||||
public var name: String?
|
||||
|
||||
public init(roomId: String,
|
||||
isOpenMicrophone: Bool = true,
|
||||
isOpenCamera: Bool = false,
|
||||
isOpenSpeaker: Bool = true,
|
||||
isMicrophoneDisableForAllUser: Bool = false,
|
||||
isCameraDisableForAllUser: Bool = false,
|
||||
isSeatEnabled: Bool = false,
|
||||
name: String? = nil) {
|
||||
self.roomId = roomId
|
||||
self.isOpenMicrophone = isOpenMicrophone
|
||||
self.isOpenCamera = isOpenCamera
|
||||
self.isOpenSpeaker = isOpenSpeaker
|
||||
self.isMicrophoneDisableForAllUser = isMicrophoneDisableForAllUser
|
||||
self.isCameraDisableForAllUser = isCameraDisableForAllUser
|
||||
self.isSeatEnabled = isSeatEnabled
|
||||
self.name = name
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers public class JoinConferenceParams: NSObject {
|
||||
public var roomId = ""
|
||||
|
||||
public var isOpenMicrophone = true
|
||||
public var isOpenCamera = false
|
||||
public var isOpenSpeaker = true
|
||||
|
||||
public var password: String?
|
||||
|
||||
public init(roomId: String,
|
||||
isOpenMicrophone: Bool = true,
|
||||
isOpenCamera: Bool = false,
|
||||
isOpenSpeaker: Bool = true) {
|
||||
self.roomId = roomId
|
||||
self.isOpenMicrophone = isOpenMicrophone
|
||||
self.isOpenCamera = isOpenCamera
|
||||
self.isOpenSpeaker = isOpenSpeaker
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
@objc public protocol ConferenceObserver {
|
||||
@objc optional func onConferenceStarted(roomInfo: TUIRoomInfo, error: TUIError, message: String)
|
||||
@objc optional func onConferenceJoined(roomInfo: TUIRoomInfo, error: TUIError, message: String)
|
||||
@objc optional func onConferenceFinished(roomId: String)
|
||||
@objc optional func onConferenceExited(roomId: String)
|
||||
}
|
||||
376
TUIKit/TUIRoomKit/Source/ConferenceListView.swift
Normal file
376
TUIKit/TUIRoomKit/Source/ConferenceListView.swift
Normal file
@@ -0,0 +1,376 @@
|
||||
//
|
||||
// ConferenceListView.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/3.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import Factory
|
||||
import TUICore
|
||||
import RTCRoomEngine
|
||||
|
||||
struct ConferenceSection {
|
||||
let date: Date
|
||||
let conferences: [ConferenceInfo]
|
||||
}
|
||||
|
||||
@objcMembers public class ConferenceListView: UIView {
|
||||
// MARK: - Intailizer
|
||||
public init(viewController: UIViewController, memberSelectFactory: MemberSelectionFactory?) {
|
||||
super.init(frame: .zero)
|
||||
let viewRoute = ConferenceRoute.init(viewController: viewController)
|
||||
navigation.initializeRoute(viewController: viewController, rootRoute: viewRoute)
|
||||
if let factory = memberSelectFactory {
|
||||
navigation.dispatch(action: ConferenceNavigationAction.setMemberSelectionFactory(payload: factory))
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Use init(viewController:) instead")
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Use init(viewController:) instead")
|
||||
override init(frame: CGRect) {
|
||||
fatalError("init(frame:) has not implement ")
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
public func reloadList() {
|
||||
store.dispatch(action: ScheduleViewActions.refreshConferenceList())
|
||||
}
|
||||
|
||||
// MARK: Private Properties
|
||||
private let conferencesPerFetch = 10
|
||||
|
||||
private lazy var conferenceListPublisher = {
|
||||
self.store.select(ConferenceListSelectors.getConferenceList)
|
||||
}()
|
||||
|
||||
private lazy var cursorPublisher = {
|
||||
self.store.select(ConferenceListSelectors.getConferenceListCursor)
|
||||
}()
|
||||
|
||||
private lazy var needRefreshPublisher = {
|
||||
self.store.select(ViewSelectors.getRefreshListFlag)
|
||||
}()
|
||||
|
||||
private var fetchListCursor = ""
|
||||
private var sections: [ConferenceSection] = []
|
||||
var cancellableSet = Set<AnyCancellable>()
|
||||
|
||||
private let historyRooms: UIButton = {
|
||||
let button = UIButton(type: .custom)
|
||||
button.setTitle(.historyConferenceText, for: .normal)
|
||||
button.setTitleColor(UIColor.tui_color(withHex: "1C66E5"), for: .normal)
|
||||
button.titleLabel?.font = UIFont(name: "PingFangSC-Medium", size: 14)
|
||||
let normalIcon = UIImage(named: "room_right_blue_arrow", in: tuiRoomKitBundle(), compatibleWith: nil)
|
||||
button.setImage(normalIcon, for: .normal)
|
||||
button.sizeToFit()
|
||||
|
||||
var imageWidth = button.imageView?.bounds.size.width ?? 0
|
||||
var titleWidth = button.titleLabel?.bounds.size.width ?? 0
|
||||
button.titleEdgeInsets = UIEdgeInsets(top: 0,
|
||||
left: -imageWidth,
|
||||
bottom: 0,
|
||||
right: imageWidth);
|
||||
button.imageEdgeInsets = UIEdgeInsets(top: 0,
|
||||
left: titleWidth,
|
||||
bottom: 0,
|
||||
right: -titleWidth)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var tableview: UITableView = {
|
||||
let tableView = UITableView(frame: .zero, style: .plain)
|
||||
tableView.separatorStyle = .none
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.register(ConferenceListCell.self, forCellReuseIdentifier: ConferenceListCell.reusedIdentifier)
|
||||
if #available(iOS 15.0, *) {
|
||||
tableView.sectionHeaderTopPadding = 0
|
||||
}
|
||||
return tableView
|
||||
}()
|
||||
|
||||
private let noScheduleTipLabel: UILabel = {
|
||||
let tip = UILabel()
|
||||
tip.textAlignment = .center
|
||||
tip.font = UIFont.systemFont(ofSize: 14)
|
||||
tip.textColor = UIColor.tui_color(withHex: "8F9AB2")
|
||||
tip.text = .noScheduleText
|
||||
tip.adjustsFontSizeToFitWidth = true
|
||||
return tip
|
||||
}()
|
||||
|
||||
private let noScheduleImageView: UIImageView = {
|
||||
let image = UIImage(named: "room_no_schedule", in: tuiRoomKitBundle(), compatibleWith: nil)
|
||||
let imageView = UIImageView(image: image)
|
||||
return imageView
|
||||
}()
|
||||
|
||||
private lazy var dateFormater: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale.current
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
dateFormatter.timeZone = .current
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
// MARK: - view layout
|
||||
private var isViewReady: Bool = false
|
||||
public override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard !isViewReady else { return }
|
||||
backgroundColor = .white
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
// MARK: Private Methods
|
||||
private func constructViewHierarchy() {
|
||||
addSubview(noScheduleImageView)
|
||||
addSubview(noScheduleTipLabel)
|
||||
addSubview(tableview)
|
||||
}
|
||||
|
||||
private func activateConstraints() {
|
||||
tableview.snp.makeConstraints { make in
|
||||
make.leading.bottom.trailing.top.equalToSuperview()
|
||||
}
|
||||
noScheduleImageView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.top.equalToSuperview().offset(160)
|
||||
make.width.equalTo(120.scale375())
|
||||
make.height.equalTo(79.scale375())
|
||||
|
||||
}
|
||||
noScheduleTipLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(noScheduleImageView.snp.bottom).offset(20)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func bindInteraction() {
|
||||
subscribeToast()
|
||||
subscribeScheduleSubject()
|
||||
store.dispatch(action: ConferenceListActions.fetchConferenceList(payload: (fetchListCursor, conferencesPerFetch)))
|
||||
conferenceListPublisher
|
||||
.receive(on: DispatchQueue.global(qos: .default))
|
||||
.map { [weak self] newInfos -> (Int, [ConferenceSection]) in
|
||||
guard let self = self else { return (0, []) }
|
||||
let newSections = self.groupAndSortInfos(newInfos)
|
||||
return (newInfos.count, newSections)
|
||||
}
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink { [weak self] (conferenceCount, newSections) in
|
||||
guard let self = self else { return }
|
||||
self.sections = newSections
|
||||
self.tableview.reloadData()
|
||||
if conferenceCount > 0 {
|
||||
self.noScheduleImageView.isHidden = true
|
||||
self.noScheduleTipLabel.isHidden = true
|
||||
} else {
|
||||
self.noScheduleImageView.isHidden = false
|
||||
self.noScheduleTipLabel.isHidden = false
|
||||
}
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
|
||||
cursorPublisher
|
||||
.receive(on: DispatchQueue.mainQueue)
|
||||
.sink { [weak self] cursor in
|
||||
guard let self = self else { return }
|
||||
self.fetchListCursor = cursor
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
|
||||
needRefreshPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] needRefresh in
|
||||
guard let self = self else { return }
|
||||
if needRefresh {
|
||||
store.dispatch(action: ConferenceListActions.resetConferenceList())
|
||||
store.dispatch(action: ConferenceListActions.fetchConferenceList(payload: ("", conferencesPerFetch)))
|
||||
store.dispatch(action: ScheduleViewActions.stopRefreshList())
|
||||
}
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func groupAndSortInfos(_ infos: [ConferenceInfo]) -> [ConferenceSection] {
|
||||
var groupedInfos: [Date: [ConferenceInfo]] = [:]
|
||||
|
||||
let calendar = Calendar.current
|
||||
for info in infos {
|
||||
let date = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(info.scheduleStartTime)))
|
||||
groupedInfos[date, default: []].append(info)
|
||||
}
|
||||
|
||||
var retData: [ConferenceSection] = groupedInfos.map { (date, infos) in
|
||||
print("")
|
||||
return ConferenceSection(date: date, conferences: infos.sorted { (confercence1, conference2) -> Bool in
|
||||
if confercence1.scheduleStartTime == conference2.scheduleStartTime {
|
||||
return confercence1.basicInfo.createTime < conference2.basicInfo.createTime
|
||||
} else {
|
||||
return confercence1.scheduleStartTime < conference2.scheduleStartTime
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
retData.sort(by: { $0.date < $1.date })
|
||||
|
||||
return retData
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
|
||||
// MARK: - private property.
|
||||
@Injected(\.conferenceStore) var store: ConferenceStore
|
||||
@Injected(\.navigation) var navigation: Route
|
||||
}
|
||||
|
||||
extension ConferenceListView: UITableViewDataSource {
|
||||
public func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return sections.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return sections[section].conferences.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConferenceListCell.reusedIdentifier, for: indexPath)
|
||||
if let cell = cell as? ConferenceListCell, indexPath.row < sections[indexPath.section].conferences.count {
|
||||
let info = sections[indexPath.section].conferences[indexPath.row]
|
||||
cell.updateCell(with: info)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let conferenceInfo = sections[indexPath.section].conferences[indexPath.row]
|
||||
navigation.pushTo(route: .scheduleDetails(conferenceInfo: conferenceInfo))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension ConferenceListView: UITableViewDelegate {
|
||||
public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return 68.0
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let headerView = UIView()
|
||||
headerView.backgroundColor = UIColor.white
|
||||
|
||||
let calendarImage = UIImage(named: "room_calendar", in: tuiRoomKitBundle(), compatibleWith: nil)
|
||||
let imageView = UIImageView(image: calendarImage)
|
||||
headerView.addSubview(imageView)
|
||||
|
||||
let headerLabel = UILabel()
|
||||
headerLabel.font = UIFont(name: "PingFangSC-Regular", size: 14)
|
||||
headerLabel.textColor = UIColor.tui_color(withHex: "969EB4")
|
||||
headerLabel.text = self.dateFormater.string(from: sections[section].date)
|
||||
headerView.addSubview(headerLabel)
|
||||
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
make.centerY.equalTo(headerLabel)
|
||||
make.height.width.equalTo(16)
|
||||
}
|
||||
headerLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(imageView.snp.trailing).offset(4)
|
||||
make.top.equalToSuperview()
|
||||
}
|
||||
return headerView
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return 40
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
let offsetY = scrollView.contentOffset.y
|
||||
let contentHeight = scrollView.contentSize.height
|
||||
let height = scrollView.frame.size.height
|
||||
|
||||
if offsetY > contentHeight - height {
|
||||
if !fetchListCursor.isEmpty {
|
||||
store.dispatch(action: ConferenceListActions.fetchConferenceList(payload: (fetchListCursor, conferencesPerFetch)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceListView {
|
||||
private func subscribeScheduleSubject() {
|
||||
store.scheduleActionSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.filter { $0.id == ScheduleResponseActions.onScheduleSuccess.id }
|
||||
.sink { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
if let action = action as? AnonymousAction<TUIConferenceInfo> {
|
||||
let view = InviteEnterRoomView(conferenceInfo: ConferenceInfo(with: action.payload), style: .inviteWhenSuccess)
|
||||
self.navigation.present(route: .popup(view: view))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func subscribeToast() {
|
||||
store.toastSubject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] toast in
|
||||
guard let self = self else { return }
|
||||
var position = TUICSToastPositionBottom
|
||||
switch toast.position {
|
||||
case .center:
|
||||
position = TUICSToastPositionCenter
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isPresenting() {
|
||||
self.makeToast(toast.message, duration: toast.duration, position: position)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static var noScheduleText: String {
|
||||
localized("No Room Scheduled")
|
||||
}
|
||||
static var historyConferenceText: String {
|
||||
localized("History Room")
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
func isPresenting() -> Bool {
|
||||
guard let viewController = self.parentViewController else { return false }
|
||||
return viewController.presentedViewController == nil
|
||||
}
|
||||
|
||||
var parentViewController: UIViewController? {
|
||||
var parentResponder: UIResponder? = self
|
||||
while parentResponder != nil {
|
||||
parentResponder = parentResponder?.next
|
||||
if let viewController = parentResponder as? UIViewController {
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
110
TUIKit/TUIRoomKit/Source/ConferenceMainViewController.swift
Normal file
110
TUIKit/TUIRoomKit/Source/ConferenceMainViewController.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ConferenceMainViewController.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/3/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import Factory
|
||||
import TUICore
|
||||
|
||||
@objcMembers public class ConferenceMainViewController: UIViewController {
|
||||
private var cancellableSet = Set<AnyCancellable>()
|
||||
private var viewModel: ConferenceMainViewModel = ConferenceMainViewModel()
|
||||
public override var shouldAutorotate: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
public override func loadView() {
|
||||
self.view = ConferenceMainView(viewModel: viewModel, viewFactory: viewModel)
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
RoomRouter.shared.initializeNavigationController(rootViewController: self)
|
||||
RoomVideoFloatView.dismiss()
|
||||
#if RTCube_APPSTORE
|
||||
let selector = NSSelectorFromString("showAlertUserLiveTips")
|
||||
if responds(to: selector) {
|
||||
perform(selector)
|
||||
}
|
||||
#endif
|
||||
viewModel.onViewDidLoadAction()
|
||||
subscribeToast()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
|
||||
public override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
viewStore.updateInternalCreation(isInternalCreation: false)
|
||||
}
|
||||
|
||||
func quickStartConference() {
|
||||
viewModel.quickStartConference()
|
||||
}
|
||||
|
||||
func joinConference() {
|
||||
viewModel.joinConference()
|
||||
}
|
||||
|
||||
public func setStartConferenceParams(params: StartConferenceParams) {
|
||||
viewModel.setStartConferenceParams(params: params)
|
||||
}
|
||||
|
||||
public func setJoinConferenceParams(params: JoinConferenceParams) {
|
||||
viewModel.setJoinConferenceParams(params: params)
|
||||
}
|
||||
|
||||
var startConferenceParams: StartConferenceParams? {
|
||||
get {
|
||||
return viewModel.startConferenceParams
|
||||
}
|
||||
}
|
||||
|
||||
var joinConferenceParams: JoinConferenceParams? {
|
||||
get {
|
||||
return viewModel.joinConferenceParams
|
||||
}
|
||||
}
|
||||
|
||||
@Injected(\.conferenceMainViewStore) private var viewStore
|
||||
@Injected(\.conferenceStore) var operation: ConferenceStore
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceMainViewController {
|
||||
private func subscribeToast() {
|
||||
operation.toastSubject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] toast in
|
||||
guard let self = self else { return }
|
||||
var position = TUICSToastPositionBottom
|
||||
switch toast.position {
|
||||
case .center:
|
||||
position = TUICSToastPositionCenter
|
||||
default:
|
||||
break
|
||||
}
|
||||
self.view.makeToast(toast.message, duration: toast.duration, position: position)
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
}
|
||||
38
TUIKit/TUIRoomKit/Source/ConferenceSession.swift
Normal file
38
TUIKit/TUIRoomKit/Source/ConferenceSession.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// ConferenceSession.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/8/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objcMembers public class ConferenceSession: NSObject {
|
||||
@objc public static let sharedInstance = ConferenceSession()
|
||||
let implementation = ConferenceSessionImp()
|
||||
|
||||
@objc public func addObserver(observer: ConferenceObserver) {
|
||||
implementation.addObserver(observer: observer)
|
||||
}
|
||||
|
||||
@objc public func removeObserver(observer: ConferenceObserver) {
|
||||
implementation.removeObserver(observer: observer)
|
||||
}
|
||||
|
||||
@objc public static func destroySharedInstance() {
|
||||
sharedInstance.implementation.destroy()
|
||||
}
|
||||
|
||||
@objc public func enableWaterMark() {
|
||||
implementation.enableWaterMark()
|
||||
}
|
||||
|
||||
@objc public func setWaterMarkText(waterMarkText: String) {
|
||||
implementation.setWaterMarkText(waterMarkText: waterMarkText)
|
||||
}
|
||||
|
||||
@objc public func setContactsViewProvider(_ provider: @escaping (ConferenceParticipants) -> ContactViewProtocol) {
|
||||
implementation.setContactsViewProvider(provider)
|
||||
}
|
||||
|
||||
}
|
||||
56
TUIKit/TUIRoomKit/Source/Localized/TUIRoomKitLocalized.swift
Normal file
56
TUIKit/TUIRoomKit/Source/Localized/TUIRoomKitLocalized.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// RoomKitLocalized.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by WesleyLei on 2022/9/13.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import TUICore
|
||||
|
||||
func localized(_ key: String) -> String {
|
||||
if let bundlePath = tuiRoomKitBundle().path(forResource: TUIGlobalization.tk_localizableLanguageKey() ?? "", ofType: "lproj"),
|
||||
let bundle = Bundle(path: bundlePath) {
|
||||
return bundle.localizedString(forKey: key, value: "", table: "TUIRoomKitLocalized")
|
||||
}
|
||||
return TUIRoomKitLocalized.sharedBundle.localizedString(forKey: key, value: "", table: "TUIRoomKitLocalized")
|
||||
}
|
||||
|
||||
func localizedReplace(_ origin: String, replace: String) -> String {
|
||||
return origin.replacingOccurrences(of: "xx", with: replace)
|
||||
}
|
||||
|
||||
func tuiRoomKitBundle() -> Bundle {
|
||||
return TUIRoomKitLocalized.sharedBundle
|
||||
}
|
||||
|
||||
private class TUIRoomKitLocalized {
|
||||
class var sharedBundle: Bundle {
|
||||
struct Static {
|
||||
static let bundle: Bundle? = tuiRoomKitBundle()
|
||||
}
|
||||
guard let bundle = Static.bundle else {
|
||||
return Bundle()
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
private func tuiRoomKitBundle() -> Bundle? {
|
||||
var url: NSURL? = Bundle.main.url(forResource: "TUIRoomKitBundle", withExtension: "bundle") as NSURL?
|
||||
if let associateBundleURL = url {
|
||||
return Bundle(url: associateBundleURL as URL)
|
||||
}
|
||||
url = Bundle.main.url(forResource: "Frameworks", withExtension: nil) as NSURL?
|
||||
url = url?.appendingPathComponent("TUIRoomKit") as NSURL?
|
||||
url = url?.appendingPathComponent("framework") as NSURL?
|
||||
if let associateBundleURL = url {
|
||||
let bundle = Bundle(url: associateBundleURL as URL)
|
||||
url = bundle?.url(forResource: "TUIRoomKitBundle", withExtension: "bundle") as NSURL?
|
||||
if let associateBundleURL = url {
|
||||
return Bundle(url: associateBundleURL as URL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
181
TUIKit/TUIRoomKit/Source/ScheduleConferenceViewController.swift
Normal file
181
TUIKit/TUIRoomKit/Source/ScheduleConferenceViewController.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// ScheduleConferenceViewController.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/6/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Factory
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
import TUICore
|
||||
|
||||
public typealias MemberSelectionFactory = ([User]) -> ContactViewProtocol
|
||||
|
||||
@objcMembers public class ScheduleConferenceViewController: UIViewController {
|
||||
private var cancellableSet = Set<AnyCancellable>()
|
||||
let memberSelectionFactory: MemberSelectionFactory?
|
||||
private let durationTime = 1800
|
||||
private let reminderSecondsBeforeStart = 600
|
||||
lazy var rootView: ScheduleConferenceTableView = {
|
||||
return ScheduleConferenceTableView(menus: ScheduleConferenceDataHelper.generateScheduleConferenceData(route: route, store: store, operation: operation, viewController: self))
|
||||
}()
|
||||
|
||||
public override var shouldAutorotate: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .portrait
|
||||
}
|
||||
|
||||
public init(memberSelectFactory: MemberSelectionFactory?) {
|
||||
self.memberSelectionFactory = memberSelectFactory
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
if let factory = memberSelectFactory {
|
||||
route.dispatch(action: ConferenceNavigationAction.setMemberSelectionFactory(payload: factory))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func loadView() {
|
||||
self.view = rootView
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
initializeView()
|
||||
initializeRoute()
|
||||
initializeData()
|
||||
|
||||
subscribeScheduleSubject()
|
||||
subscribeToast()
|
||||
|
||||
let selector = Selector(keyPath: \ConferenceInfo.basicInfo.isPasswordEnabled)
|
||||
store.select(selector)
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] isPasswordEnabled in
|
||||
guard let self = self else { return }
|
||||
var conferenceInfo = store.conferenceInfo
|
||||
conferenceInfo.basicInfo.password = isPasswordEnabled ? getRandomNumber(numberOfDigits: 6) : ""
|
||||
store.update(conference: conferenceInfo)
|
||||
self.rootView.menus = ScheduleConferenceDataHelper.generateScheduleConferenceData(route: self.route, store: self.store, operation: self.operation, viewController: self)
|
||||
self.rootView.tableView.reloadData()
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func initializeView() {
|
||||
view.backgroundColor = UIColor(0xF8F9FB)
|
||||
navigationItem.title = .bookRoomText
|
||||
}
|
||||
|
||||
private func initializeRoute() {
|
||||
let viewRoute = ConferenceRoute.init(viewController: self)
|
||||
route.initializeRoute(viewController: self, rootRoute: viewRoute)
|
||||
}
|
||||
|
||||
private func initializeData() {
|
||||
operation.dispatch(action: UserActions.getSelfInfo())
|
||||
getConferenceInfo { [weak self] conferenceInfo in
|
||||
guard let self = self else { return }
|
||||
self.store.update(conference: conferenceInfo)
|
||||
self.store.fetchAttendees(cursor: "")
|
||||
}
|
||||
}
|
||||
|
||||
private func getConferenceInfo(onGetConferenceInfo: @escaping (ConferenceInfo)->()) {
|
||||
FetchRoomId.getRoomId { [weak self] roomId in
|
||||
guard let self = self else { return }
|
||||
var info = ConferenceInfo()
|
||||
info.scheduleStartTime = self.getStartTime()
|
||||
info.durationTime = UInt(self.durationTime)
|
||||
info.reminderSecondsBeforeStart = self.reminderSecondsBeforeStart
|
||||
var basicInfo = RoomInfo()
|
||||
basicInfo.roomId = roomId
|
||||
basicInfo.name = localizedReplace(.temporaryRoomText, replace: self.operation.selectCurrent(UserSelectors.getSelfUserName))
|
||||
basicInfo.isCameraDisableForAllUser = false
|
||||
basicInfo.isMicrophoneDisableForAllUser = false
|
||||
info.basicInfo = basicInfo
|
||||
onGetConferenceInfo(info)
|
||||
}
|
||||
}
|
||||
|
||||
private func getStartTime() -> UInt {
|
||||
let oneMinutesInterval = 60
|
||||
let fiveMinutesInterval = UInt(5 * oneMinutesInterval)
|
||||
var time = UInt(Date().timeIntervalSince1970)
|
||||
let remainder = time % fiveMinutesInterval
|
||||
if remainder > oneMinutesInterval {
|
||||
time = time - remainder
|
||||
}
|
||||
time = time + fiveMinutesInterval
|
||||
return time
|
||||
}
|
||||
|
||||
private func getRandomNumber(numberOfDigits: Int) -> String {
|
||||
let minNumber = Int(truncating: NSDecimalNumber(decimal: pow(10, numberOfDigits - 1)))
|
||||
let maxNumber = Int(truncating: NSDecimalNumber(decimal: pow(10, numberOfDigits))) - 1
|
||||
let randomNumber = arc4random_uniform(UInt32(maxNumber - minNumber)) + UInt32(minNumber)
|
||||
return String(randomNumber)
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
|
||||
@Injected(\.navigation) private var route
|
||||
@Injected(\.modifyScheduleStore) private var store
|
||||
@Injected(\.conferenceStore) private var operation
|
||||
}
|
||||
|
||||
extension ScheduleConferenceViewController {
|
||||
private func subscribeToast() {
|
||||
operation.toastSubject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] toast in
|
||||
guard let self = self else { return }
|
||||
var position = TUICSToastPositionBottom
|
||||
switch toast.position {
|
||||
case .center:
|
||||
position = TUICSToastPositionCenter
|
||||
default:
|
||||
break
|
||||
}
|
||||
self.view.makeToast(toast.message, duration: toast.duration, position: position)
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func subscribeScheduleSubject() {
|
||||
operation.scheduleActionSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.filter { $0.id == ScheduleResponseActions.onScheduleSuccess.id }
|
||||
.sink { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.route.pop()
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScheduleConferenceViewController: ContactViewSelectDelegate {
|
||||
public func onMemberSelected(_ viewController: ContactViewProtocol,
|
||||
invitees: [User]) {
|
||||
var conferenceInfo = store.conferenceInfo
|
||||
conferenceInfo.attendeeListResult.attendeeList = invitees.map { $0.userInfo }
|
||||
conferenceInfo.attendeeListResult.totalCount = UInt(invitees.count)
|
||||
store.update(conference: conferenceInfo)
|
||||
route.pop()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static var bookRoomText: String = localized("Schedule Room")
|
||||
static var temporaryRoomText: String = localized("xx's temporary room")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// ConferenceInvitationService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
import Factory
|
||||
|
||||
class ConferenceInvitationService: NSObject {
|
||||
@WeakLazyInjected(\.conferenceStore) var store: ConferenceStore?
|
||||
|
||||
typealias InviteUsersResult = ([String: NSNumber])
|
||||
typealias InvitationfetchResult = ([TUIInvitation], String)
|
||||
private let timeout: Double = 60
|
||||
private let invitationManager = TUIRoomEngine.sharedInstance().getExtension(extensionType: .conferenceInvitationManager) as? TUIConferenceInvitationManager
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
invitationManager?.addObserver(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
invitationManager?.removeObserver(self)
|
||||
}
|
||||
|
||||
func inviteUsers(roomId: String, userIdList: [String]) -> AnyPublisher<InviteUsersResult, RoomError> {
|
||||
return Future<InviteUsersResult, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.invitationManager?.inviteUsers(roomId, userIdList: userIdList, timeout: timeout, extensionInfo: "") {dic in
|
||||
promise(.success((dic)))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func accept(roomId: String) -> AnyPublisher<String, RoomError> {
|
||||
return Future<String, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.invitationManager?.accept(roomId) {
|
||||
promise(.success(roomId))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func reject(roomId: String, reason: TUIInvitationRejectedReason) -> AnyPublisher<String, RoomError> {
|
||||
return Future<String, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.invitationManager?.reject(roomId, reason: reason) {
|
||||
promise(.success(roomId))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getInvitationList(roomId: String, cursor: String, count: Int = 20) -> AnyPublisher<InvitationfetchResult, RoomError> {
|
||||
return Future<InvitationfetchResult, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.invitationManager?.getInvitationList(roomId, cursor: cursor, count: count) {invitations, cursor in
|
||||
promise(.success((invitations, cursor)))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceInvitationService: TUIConferenceInvitationObserver {
|
||||
func onReceiveInvitation(roomInfo: TUIRoomInfo, invitation: TUIInvitation, extensionInfo: String) {
|
||||
|
||||
}
|
||||
|
||||
func onInvitationHandledByOtherDevice(roomInfo: TUIRoomInfo, accepted: Bool) {
|
||||
guard let store = self.store else { return }
|
||||
store.dispatch(action: InvitationViewActions.dismissInvitationView())
|
||||
}
|
||||
|
||||
func onInvitationCancelled(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
|
||||
}
|
||||
|
||||
func onInvitationAccepted(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
|
||||
}
|
||||
|
||||
func onInvitationRejected(roomInfo: TUIRoomInfo, invitation: TUIInvitation, reason: TUIInvitationRejectedReason) {
|
||||
|
||||
}
|
||||
|
||||
func onInvitationTimeout(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
guard let store = self.store else { return }
|
||||
store.dispatch(action: InvitationViewActions.dismissInvitationView())
|
||||
}
|
||||
|
||||
func onInvitationRevokedByAdmin(roomInfo: TUIRoomInfo, invitation: TUIInvitation, admin: TUIUserInfo) {
|
||||
|
||||
}
|
||||
|
||||
func onInvitationAdded(roomId: String, invitation: TUIInvitation) {
|
||||
guard let store = self.store else { return }
|
||||
let currentRoomId = store.selectCurrent(RoomSelectors.getRoomId)
|
||||
guard currentRoomId == roomId else { return }
|
||||
store.dispatch(action: ConferenceInvitationActions.addInvitation(payload: invitation))
|
||||
}
|
||||
|
||||
func onInvitationRemoved(roomId: String, invitation: TUIInvitation) {
|
||||
guard let store = self.store else { return }
|
||||
let currentRoomId = store.selectCurrent(RoomSelectors.getRoomId)
|
||||
guard currentRoomId == roomId else { return }
|
||||
store.dispatch(action: ConferenceInvitationActions.removeInvitation(payload: invitation.invitee.userId))
|
||||
}
|
||||
|
||||
func onInvitationStatusChanged(roomId: String, invitation: TUIInvitation) {
|
||||
guard let store = self.store else { return }
|
||||
let currentRoomId = store.selectCurrent(RoomSelectors.getRoomId)
|
||||
guard currentRoomId == roomId else { return }
|
||||
store.dispatch(action: ConferenceInvitationActions.changeInvitationStatus(payload: invitation))
|
||||
}
|
||||
|
||||
}
|
||||
191
TUIKit/TUIRoomKit/Source/Service/ConferenceListService.swift
Normal file
191
TUIKit/TUIRoomKit/Source/Service/ConferenceListService.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// ConferenceListService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
import Factory
|
||||
|
||||
class ConferenceListService: NSObject {
|
||||
@WeakLazyInjected(\.conferenceStore) var store: ConferenceStore?
|
||||
|
||||
private let listManager = TUIRoomEngine.sharedInstance().getExtension(extensionType: .conferenceListManager) as? TUIConferenceListManager
|
||||
typealias ConferencesFetchResult = ([ConferenceInfo], String)
|
||||
typealias AttendeesFetchResult = ([UserInfo], String, UInt)
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
listManager?.addObserver(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
listManager?.removeObserver(self)
|
||||
}
|
||||
|
||||
func scheduleConference(conferenceInfo: TUIConferenceInfo) -> AnyPublisher<TUIConferenceInfo, RoomError> {
|
||||
return Future<TUIConferenceInfo, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.scheduleConference(conferenceInfo) {
|
||||
promise(.success(conferenceInfo))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
func cancelConference(conferenceId: String) -> AnyPublisher<Void, RoomError> {
|
||||
return Future<Void, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.cancelConference(conferenceId) {
|
||||
promise(.success(()))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error , message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func updateConferenceInfo(conferenceInfo: TUIConferenceInfo, modifyFlag: TUIConferenceModifyFlag) -> AnyPublisher<Void, RoomError> {
|
||||
return Future<Void, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.updateConferenceInfo(conferenceInfo: conferenceInfo, modifyFlag: modifyFlag) {
|
||||
promise(.success(()))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error , message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func fetchConferenceList(status: TUIConferenceStatus, cursor: String, count: Int = 20) -> AnyPublisher<ConferencesFetchResult, RoomError> {
|
||||
return Future<ConferencesFetchResult, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.fetchScheduledConferenceList(status: status,
|
||||
cursor: cursor,
|
||||
count: count) { conferenceList, cursor in
|
||||
let list = conferenceList.map { ConferenceInfo(with: $0) }
|
||||
promise(.success((list, cursor)))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func fetchAttendeeList(conferenceId: String, cursor: String, count: Int = 20) -> AnyPublisher<AttendeesFetchResult, RoomError> {
|
||||
return Future<AttendeesFetchResult, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.fetchAttendeeList(roomId: conferenceId, cursor: cursor, count: count) { attendeeList, cursor, totalCount in
|
||||
|
||||
let userInfoList = attendeeList.map { UserInfo(userInfo: $0) }
|
||||
promise(.success((userInfoList, cursor, totalCount)))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error, message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func addAttendeesByAdmin(conferenceId: String, userIdList: [String]) -> AnyPublisher<Void, RoomError> {
|
||||
return Future<Void, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.addAttendeesByAdmin(roomId: conferenceId, userIdList: userIdList) {
|
||||
promise(.success(()))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error , message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func removeAttendeesByAdmin(conferenceId: String, userIdList: [String]) -> AnyPublisher<Void, RoomError> {
|
||||
return Future<Void, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.listManager?.removeAttendeesByAdmin(roomId: conferenceId, userIdList: userIdList) {
|
||||
promise(.success(()))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error , message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func fetchConferenceInfo(roomId: String) -> AnyPublisher<ConferenceInfo, RoomError> {
|
||||
return Future<ConferenceInfo, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
EngineManager.shared.fetchRoomInfo(roomId: roomId) { [weak self] roomInfo in
|
||||
guard let self = self, let roomInfo = roomInfo else { return }
|
||||
let currentList = self.store?.selectCurrent(ConferenceListSelectors.getConferenceList)
|
||||
guard var updateConference = currentList?.first(where: { $0.basicInfo.roomId == roomInfo.roomId }) else { return }
|
||||
updateConference.basicInfo = RoomInfo(with: roomInfo)
|
||||
promise(.success(updateConference))
|
||||
} onError: { error, message in
|
||||
promise(.failure(RoomError(error: error , message: message)))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceListService: TUIConferenceListManagerObserver {
|
||||
func onConferenceScheduled(conferenceInfo: TUIConferenceInfo) {
|
||||
guard let store = self.store else { return }
|
||||
let conference = ConferenceInfo(with: conferenceInfo)
|
||||
let currentList = store.selectCurrent(ConferenceListSelectors.getConferenceList)
|
||||
let contain = currentList.contains { $0.basicInfo.roomId == conference.basicInfo.roomId }
|
||||
if !contain {
|
||||
store.dispatch(action: ConferenceListActions.insertConference(payload: conference))
|
||||
}
|
||||
}
|
||||
|
||||
func onConferenceCancelled(roomId: String, reason: TUIConferenceCancelReason, operateUser: TUIUserInfo) {
|
||||
guard let store = self.store else { return }
|
||||
let currentList = store.selectCurrent(ConferenceListSelectors.getConferenceList).map { $0.basicInfo.roomId }
|
||||
if currentList.contains(roomId) {
|
||||
store.dispatch(action: ConferenceListActions.removeConference(payload: roomId))
|
||||
}
|
||||
}
|
||||
|
||||
func onConferenceInfoChanged(conferenceInfo: TUIConferenceInfo, modifyFlag: TUIConferenceModifyFlag) {
|
||||
guard let store = self.store else { return }
|
||||
let currentList = store.selectCurrent(ConferenceListSelectors.getConferenceList)
|
||||
if let index = currentList.firstIndex(where: { $0.basicInfo.roomId == conferenceInfo.basicRoomInfo.roomId }) {
|
||||
var updateConference = currentList[index]
|
||||
if modifyFlag.contains(.roomName) {
|
||||
updateConference.basicInfo.name = conferenceInfo.basicRoomInfo.name
|
||||
}
|
||||
if modifyFlag.contains(.scheduleStartTime) || modifyFlag.contains(.scheduleEndTime) {
|
||||
updateConference.scheduleStartTime = conferenceInfo.scheduleStartTime
|
||||
updateConference.scheduleEndTime = conferenceInfo.scheduleEndTime
|
||||
updateConference.durationTime = conferenceInfo.scheduleEndTime - conferenceInfo.scheduleStartTime
|
||||
}
|
||||
store.dispatch(action: ConferenceListActions.onConferenceUpdated(payload: updateConference))
|
||||
}
|
||||
}
|
||||
|
||||
func onConferenceStatusChanged(roomId: String, status: TUIConferenceStatus) {
|
||||
guard let store = self.store else { return }
|
||||
let currentList = store.selectCurrent(ConferenceListSelectors.getConferenceList)
|
||||
if let index = currentList.firstIndex(where: { $0.basicInfo.roomId == roomId }) {
|
||||
var updateConference = currentList[index]
|
||||
updateConference.status = status
|
||||
store.dispatch(action: ConferenceListActions.onConferenceUpdated(payload: updateConference))
|
||||
}
|
||||
}
|
||||
|
||||
func onConferenceWillStart(conferenceInfo: TUIConferenceInfo) {
|
||||
|
||||
}
|
||||
|
||||
func onScheduleAttendeesChanged(roomId: String, leftUsers: [TUIUserInfo], joinedUsers: [TUIUserInfo]) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
23
TUIKit/TUIRoomKit/Source/Service/ErrorService.swift
Normal file
23
TUIKit/TUIRoomKit/Source/Service/ErrorService.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// ErrorService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
struct RoomError: Error {
|
||||
let error: TUIError
|
||||
let message: String
|
||||
var actions: [Action] = []
|
||||
|
||||
init(error: TUIError, message: String = "", showToast: Bool = true) {
|
||||
self.error = error
|
||||
self.message = message
|
||||
if showToast {
|
||||
actions.append(ViewActions.showToast(payload: ToastInfo(message: message)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// InvitationObserverService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Factory
|
||||
|
||||
class InvitationObserverService: NSObject {
|
||||
static let shared = InvitationObserverService()
|
||||
private var invitationWindow: UIWindow?
|
||||
|
||||
private override init() {
|
||||
}
|
||||
|
||||
func showInvitationWindow(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
DispatchQueue.main.async {
|
||||
let invitationViewController = ConferenceInvitationViewController(roomInfo: roomInfo, invitation: invitation)
|
||||
self.invitationWindow = UIWindow()
|
||||
self.invitationWindow?.windowLevel = .alert + 1
|
||||
self.invitationWindow?.rootViewController = invitationViewController
|
||||
self.invitationWindow?.isHidden = false
|
||||
self.invitationWindow?.t_makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
func dismissInvitationWindow() {
|
||||
DispatchQueue.main.async {
|
||||
self.invitationWindow?.isHidden = true
|
||||
self.invitationWindow = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InvitationObserverService: TUIConferenceInvitationObserver {
|
||||
func onReceiveInvitation(roomInfo: TUIRoomInfo, invitation: TUIInvitation, extensionInfo: String) {
|
||||
let store = Container.shared.conferenceStore()
|
||||
store.dispatch(action: ConferenceInvitationActions.onReceiveInvitation(payload: (roomInfo, invitation)))
|
||||
}
|
||||
|
||||
func onInvitationHandledByOtherDevice(roomInfo: TUIRoomInfo, accepted: Bool) {
|
||||
}
|
||||
|
||||
func onInvitationCancelled(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
}
|
||||
|
||||
func onInvitationAccepted(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
}
|
||||
|
||||
func onInvitationRejected(roomInfo: TUIRoomInfo, invitation: TUIInvitation, reason: TUIInvitationRejectedReason) {
|
||||
}
|
||||
|
||||
func onInvitationTimeout(roomInfo: TUIRoomInfo, invitation: TUIInvitation) {
|
||||
}
|
||||
|
||||
func onInvitationRevokedByAdmin(roomInfo: TUIRoomInfo, invitation: TUIInvitation, admin: TUIUserInfo) {
|
||||
}
|
||||
|
||||
func onInvitationAdded(roomId: String, invitation: TUIInvitation) {
|
||||
}
|
||||
|
||||
func onInvitationRemoved(roomId: String, invitation: TUIInvitation) {
|
||||
}
|
||||
|
||||
func onInvitationStatusChanged(roomId: String, invitation: TUIInvitation) {
|
||||
}
|
||||
}
|
||||
|
||||
extension UIWindow {
|
||||
public func t_makeKeyAndVisible() {
|
||||
if #available(iOS 13.0, *) {
|
||||
for windowScene in UIApplication.shared.connectedScenes {
|
||||
if windowScene.activationState == UIScene.ActivationState.foregroundActive ||
|
||||
windowScene.activationState == UIScene.ActivationState.background {
|
||||
self.windowScene = windowScene as? UIWindowScene
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
self.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
37
TUIKit/TUIRoomKit/Source/Service/RoomService.swift
Normal file
37
TUIKit/TUIRoomKit/Source/Service/RoomService.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// RoomService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import ImSDK_Plus
|
||||
import Combine
|
||||
|
||||
class RoomService {
|
||||
private let engineManager = EngineManager.shared
|
||||
|
||||
func enterRoom(roomId: String,
|
||||
options: TUIEnterRoomOptions? = nil,
|
||||
enableAudio: Bool,
|
||||
enableVideo: Bool,
|
||||
isSoundOnSpeaker: Bool) -> AnyPublisher<Void, RoomError> {
|
||||
return Future<Void, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
//TODO: use RTCRoomEngine directly
|
||||
self.engineManager.enterRoom(roomId: roomId,
|
||||
options: options,
|
||||
enableAudio: enableAudio,
|
||||
enableVideo: enableVideo,
|
||||
isSoundOnSpeaker: isSoundOnSpeaker) { roomInfo in
|
||||
promise(.success(()))
|
||||
} onError: { error, message in
|
||||
let error = RoomError(error: error, message: message)
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
22
TUIKit/TUIRoomKit/Source/Service/ServiceCenter.swift
Normal file
22
TUIKit/TUIRoomKit/Source/Service/ServiceCenter.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// ServiceCenter.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Factory
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
|
||||
class ServiceCenter: NSObject {
|
||||
@WeakLazyInjected(\.conferenceStore) var store: ConferenceStore?
|
||||
@WeakLazyInjected(\.navigation) var navigator: Route?
|
||||
|
||||
let userService = UserService()
|
||||
let conferenceListService = ConferenceListService()
|
||||
let roomService = RoomService()
|
||||
let conferenceInvitationService = ConferenceInvitationService()
|
||||
}
|
||||
|
||||
34
TUIKit/TUIRoomKit/Source/Service/UserService.swift
Normal file
34
TUIKit/TUIRoomKit/Source/Service/UserService.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// UserService.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import ImSDK_Plus
|
||||
import Combine
|
||||
|
||||
class UserService {
|
||||
private let engine = TUIRoomEngine.sharedInstance()
|
||||
private let imManager = V2TIMManager.sharedInstance()
|
||||
|
||||
func fetchUserInfo(_ userId: String) -> AnyPublisher<UserInfo, RoomError> {
|
||||
return Future<UserInfo, RoomError> { [weak self] promise in
|
||||
guard let self = self else { return }
|
||||
self.engine.getUserInfo(userId) { userInfo in
|
||||
if let user = userInfo {
|
||||
promise(.success(UserInfo(userInfo: user)))
|
||||
} else {
|
||||
let error = RoomError(error: TUIError.userNotExist)
|
||||
promise(.failure(error))
|
||||
}
|
||||
} onError: { err, message in
|
||||
let error = RoomError(error: err, message: message, showToast: false)
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// ConferenceInvitationState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/19.
|
||||
//
|
||||
|
||||
import RTCRoomEngine
|
||||
|
||||
struct ConferenceInvitationState {
|
||||
var invitationList: [TUIInvitation] = []
|
||||
}
|
||||
|
||||
extension TUIInvitation {
|
||||
convenience init(userInfo: UserInfo) {
|
||||
self.init()
|
||||
self.invitee = TUIUserInfo()
|
||||
self.invitee.userId = userInfo.userId
|
||||
self.invitee.userName = userInfo.userName
|
||||
self.invitee.avatarUrl = userInfo.avatarUrl
|
||||
}
|
||||
|
||||
static func ==(lhs: TUIInvitation, rhs: TUIInvitation) -> Bool {
|
||||
return lhs.status == rhs.status &&
|
||||
lhs.invitee.userId == rhs.invitee.userId &&
|
||||
lhs.invitee.userName == rhs.invitee.userName &&
|
||||
lhs.invitee.avatarUrl == rhs.invitee.avatarUrl
|
||||
}
|
||||
}
|
||||
78
TUIKit/TUIRoomKit/Source/State/ConferenceListState.swift
Normal file
78
TUIKit/TUIRoomKit/Source/State/ConferenceListState.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// ListState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
struct ConferenceListState {
|
||||
var scheduledConferences: [ConferenceInfo] = []
|
||||
var fetchScheduledConferencesCursor = ""
|
||||
}
|
||||
|
||||
struct ConferenceInfo {
|
||||
var scheduleStartTime: UInt = 0
|
||||
var scheduleEndTime: UInt = 0
|
||||
var status: TUIConferenceStatus = []
|
||||
var timeZone: TimeZone = .current
|
||||
var attendeeListResult = AttendeeListResult()
|
||||
var durationTime: UInt = 0
|
||||
var reminderSecondsBeforeStart = 0
|
||||
// MARK: basic info
|
||||
var basicInfo = RoomInfo()
|
||||
|
||||
init() {}
|
||||
|
||||
init(with conferenceInfo: TUIConferenceInfo) {
|
||||
self.scheduleStartTime = conferenceInfo.scheduleStartTime
|
||||
self.scheduleEndTime = conferenceInfo.scheduleEndTime
|
||||
self.durationTime = self.scheduleEndTime - self.scheduleStartTime
|
||||
self.status = conferenceInfo.status
|
||||
self.reminderSecondsBeforeStart = conferenceInfo.reminderSecondsBeforeStart
|
||||
self.basicInfo = RoomInfo(with: conferenceInfo.basicRoomInfo)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceInfo: Hashable {
|
||||
static func ==(lhs: ConferenceInfo, rhs: ConferenceInfo) -> Bool {
|
||||
return lhs.basicInfo.roomId == rhs.basicInfo.roomId &&
|
||||
lhs.basicInfo.name == rhs.basicInfo.name &&
|
||||
lhs.scheduleStartTime == rhs.scheduleStartTime &&
|
||||
lhs.scheduleEndTime == rhs.scheduleEndTime &&
|
||||
lhs.status == rhs.status
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(basicInfo.roomId)
|
||||
hasher.combine(basicInfo.name)
|
||||
hasher.combine(scheduleStartTime)
|
||||
hasher.combine(scheduleEndTime)
|
||||
hasher.combine(status)
|
||||
}
|
||||
}
|
||||
|
||||
struct AttendeeListResult: Hashable {
|
||||
var attendeeList: [UserInfo] = []
|
||||
var fetchCursor: String = ""
|
||||
var totalCount: UInt = 0
|
||||
}
|
||||
|
||||
extension TUIConferenceInfo {
|
||||
convenience init(conferenceInfo: ConferenceInfo) {
|
||||
self.init()
|
||||
self.scheduleStartTime = UInt(conferenceInfo.scheduleStartTime)
|
||||
self.scheduleEndTime = self.scheduleStartTime + conferenceInfo.durationTime
|
||||
self.scheduleAttendees = conferenceInfo.attendeeListResult.attendeeList.map { $0.userId }
|
||||
self.basicRoomInfo = TUIRoomInfo(roomInfo: conferenceInfo.basicInfo)
|
||||
self.reminderSecondsBeforeStart = conferenceInfo.reminderSecondsBeforeStart
|
||||
}
|
||||
}
|
||||
|
||||
extension TUIConferenceStatus: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.rawValue)
|
||||
}
|
||||
}
|
||||
15
TUIKit/TUIRoomKit/Source/State/OperationState.swift
Normal file
15
TUIKit/TUIRoomKit/Source/State/OperationState.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// OperationState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct OperationState {
|
||||
var roomState = RoomInfo()
|
||||
var userState = UserState()
|
||||
var conferenceListState = ConferenceListState()
|
||||
var conferenceInvitationState = ConferenceInvitationState()
|
||||
}
|
||||
52
TUIKit/TUIRoomKit/Source/State/RoomState.swift
Normal file
52
TUIKit/TUIRoomKit/Source/State/RoomState.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// RoomState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2024/7/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
struct RoomInfo {
|
||||
var roomId = ""
|
||||
var name = ""
|
||||
var ownerId = ""
|
||||
var ownerName = ""
|
||||
var ownerAvatarUrl = ""
|
||||
var isSeatEnabled = false
|
||||
var password = ""
|
||||
var isMicrophoneDisableForAllUser = true
|
||||
var isCameraDisableForAllUser = true
|
||||
var createTime: UInt = 0
|
||||
var isPasswordEnabled: Bool = false
|
||||
var isEnteredRoom = false
|
||||
|
||||
init() {}
|
||||
init(with roomInfo: TUIRoomInfo) {
|
||||
self.roomId = roomInfo.roomId
|
||||
self.name = roomInfo.name
|
||||
self.ownerId = roomInfo.ownerId
|
||||
self.ownerName = roomInfo.ownerName
|
||||
self.ownerAvatarUrl = roomInfo.ownerAvatarUrl
|
||||
self.isSeatEnabled = roomInfo.isSeatEnabled
|
||||
self.password = roomInfo.password
|
||||
self.isMicrophoneDisableForAllUser = roomInfo.isMicrophoneDisableForAllUser
|
||||
self.isCameraDisableForAllUser = roomInfo.isCameraDisableForAllUser
|
||||
self.createTime = roomInfo.createTime
|
||||
self.isPasswordEnabled = roomInfo.password.count > 0
|
||||
}
|
||||
}
|
||||
|
||||
extension TUIRoomInfo {
|
||||
convenience init(roomInfo: RoomInfo) {
|
||||
self.init()
|
||||
self.roomId = roomInfo.roomId
|
||||
self.name = roomInfo.name
|
||||
self.isSeatEnabled = roomInfo.isSeatEnabled
|
||||
self.password = roomInfo.password
|
||||
self.isMicrophoneDisableForAllUser = roomInfo.isMicrophoneDisableForAllUser
|
||||
self.isCameraDisableForAllUser = roomInfo.isCameraDisableForAllUser
|
||||
self.seatMode = .applyToTake
|
||||
}
|
||||
}
|
||||
147
TUIKit/TUIRoomKit/Source/State/UserState.swift
Normal file
147
TUIKit/TUIRoomKit/Source/State/UserState.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// UserState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
struct UserState {
|
||||
var selfInfo: UserInfo = UserInfo()
|
||||
var allUsers: [UserInfo] = []
|
||||
|
||||
var hasAudioStreamUsers = Set<String>()
|
||||
var hasCameraStreamUsers = Set<String>()
|
||||
var hasScreenStreamUsers = Set<String>()
|
||||
var disableMessageUsers = Set<String>()
|
||||
}
|
||||
|
||||
struct UserInfo: Codable {
|
||||
var userId: String = ""
|
||||
var userName: String = ""
|
||||
var avatarUrl: String = ""
|
||||
var userRole: TUIRole? = .generalUser
|
||||
|
||||
init() {}
|
||||
|
||||
init(loginUserInfo: TUILoginUserInfo) {
|
||||
self.userId = loginUserInfo.userId
|
||||
self.userName = loginUserInfo.userName
|
||||
self.avatarUrl = loginUserInfo.avatarUrl
|
||||
}
|
||||
|
||||
init(userInfo: TUIUserInfo) {
|
||||
self.userId = userInfo.userId
|
||||
self.userName = userInfo.userName
|
||||
self.avatarUrl = userInfo.avatarUrl
|
||||
self.userRole = userInfo.userRole
|
||||
}
|
||||
|
||||
init(userEntity: UserEntity) {
|
||||
self.userId = userEntity.userId
|
||||
self.userName = userEntity.userName
|
||||
self.avatarUrl = userEntity.avatarUrl
|
||||
self.userRole = userEntity.userRole
|
||||
}
|
||||
}
|
||||
|
||||
enum UserListType {
|
||||
case allUsers
|
||||
case onStageUsers
|
||||
case offStageUsers
|
||||
case notInRoomUsers
|
||||
}
|
||||
|
||||
extension UserInfo: Hashable {
|
||||
static func == (lhs: UserInfo, rhs: UserInfo) -> Bool {
|
||||
return lhs.userId == rhs.userId && lhs.userName == rhs.userName && lhs.avatarUrl == rhs.avatarUrl
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(userId)
|
||||
hasher.combine(userName)
|
||||
hasher.combine(avatarUrl)
|
||||
hasher.combine(userRole)
|
||||
}
|
||||
}
|
||||
|
||||
extension UserInfo {
|
||||
func convertToUser() -> User {
|
||||
return User(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension TUIRole: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let rawValue = try container.decode(UInt.self)
|
||||
self = TUIRole(rawValue: rawValue) ?? .generalUser
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(self.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public class User: NSObject, Codable {
|
||||
private(set) var userInfo: UserInfo
|
||||
|
||||
@objc public var userId: String {
|
||||
get { return userInfo.userId}
|
||||
}
|
||||
@objc public var userName: String {
|
||||
get { return userInfo.userName}
|
||||
}
|
||||
@objc public var avatarUrl: String {
|
||||
get { return userInfo.avatarUrl}
|
||||
}
|
||||
|
||||
@objc public init(userId: String, userName: String, avatarUrl: String) {
|
||||
var info = UserInfo()
|
||||
info.userId = userId
|
||||
info.userName = userName
|
||||
info.avatarUrl = avatarUrl
|
||||
self.userInfo = info
|
||||
super.init()
|
||||
}
|
||||
|
||||
init(_ userInfo: UserInfo) {
|
||||
self.userInfo = userInfo
|
||||
super.init()
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.userInfo = try container.decode(UserInfo.self)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(userInfo)
|
||||
}
|
||||
|
||||
@objc public func setUserId(_ userId: String) {
|
||||
userInfo.userId = userId
|
||||
}
|
||||
@objc public func setUserName(_ userName: String) {
|
||||
userInfo.userName = userName
|
||||
}
|
||||
@objc public func setAvatarUrl(_ avatarUrl: String) {
|
||||
userInfo.avatarUrl = avatarUrl
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc public class ConferenceParticipants: NSObject {
|
||||
@objc public var selectedList: [User] = []
|
||||
@objc public var unSelectableList : [User] = []
|
||||
|
||||
@objc public init(selectedList: [User] = [], unSelectableList: [User] = []) {
|
||||
self.selectedList = selectedList
|
||||
self.unSelectableList = unSelectableList
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
23
TUIKit/TUIRoomKit/Source/State/ViewState.swift
Normal file
23
TUIKit/TUIRoomKit/Source/State/ViewState.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// ViewState.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ViewState {
|
||||
var scheduleViewState = ScheduleViewState()
|
||||
var invitationViewState = InvitationViewState()
|
||||
}
|
||||
|
||||
struct ScheduleViewState {
|
||||
var shouldRefreshList = false
|
||||
var detailViewPopFlag = false
|
||||
}
|
||||
|
||||
struct InvitationViewState {
|
||||
var invitationViewDismissFlag = true
|
||||
var showInvitationPopupView = false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// ConferenceInvitationActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
enum ConferenceInvitationActions {
|
||||
static let key = "action.conferenceInvitation"
|
||||
|
||||
static let inviteUsers = ActionTemplate(id: key.appending(".inviteUsers"),
|
||||
payloadType: (String, [String]).self)
|
||||
static let accept = ActionTemplate(id: key.appending(".accept"), payloadType: String.self)
|
||||
static let reject = ActionTemplate(id: key.appending(".reject"), payloadType: (String, TUIInvitationRejectedReason).self)
|
||||
static let getInvitationList = ActionTemplate(id: key.appending(".getInvitationList"), payloadType: (String, String, [TUIInvitation]).self)
|
||||
static let fetchAttendees = ActionTemplate(id: key.appending(".fetchAttendees"), payloadType: (String, String, [UserInfo]).self)
|
||||
static let clearInvitationList = ActionTemplate(id: key.appending(".fetchAttendees"))
|
||||
|
||||
// MARK: callback
|
||||
static let updateInvitationList = ActionTemplate(id: key.appending(".setInvitationList"), payloadType: [TUIInvitation].self)
|
||||
static let addInvitation = ActionTemplate(id: key.appending(".addInvitation"), payloadType: TUIInvitation.self)
|
||||
static let removeInvitation = ActionTemplate(id: key.appending(".addInvitation"), payloadType: String.self)
|
||||
static let changeInvitationStatus = ActionTemplate(id: key.appending(".addInvitation"), payloadType: TUIInvitation.self)
|
||||
static let onInviteSuccess = ActionTemplate(id: key.appending("onInviteSuccess"))
|
||||
static let onAcceptSuccess = ActionTemplate(id: key.appending("onAcceptSuccess"), payloadType: String.self)
|
||||
static let onRejectSuccess = ActionTemplate(id: key.appending("onRejectSuccess"))
|
||||
static let onReceiveInvitation = ActionTemplate(id: key.appending("onAcceptSuccess"), payloadType: (TUIRoomInfo, TUIInvitation).self)
|
||||
static let onGetInvitationSuccess = ActionTemplate(id: key.appending("onGetInvitationSuccess"), payloadType: (String, [TUIInvitation]).self)
|
||||
static let onFetchAttendeesSuccess = ActionTemplate(id: key.appending("onFetchAttendeesSuccess"), payloadType: [UserInfo].self)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// ConferenceInvitationEffects.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
import Factory
|
||||
|
||||
class ConferenceInvitationEffects: Effects {
|
||||
typealias Environment = ServiceCenter
|
||||
|
||||
let inviteUsers = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceInvitationActions.inviteUsers)
|
||||
.flatMap { action in
|
||||
environment.conferenceInvitationService.inviteUsers(roomId: action.payload.0, userIdList: action.payload.1)
|
||||
.map { _ in
|
||||
ConferenceInvitationActions.onInviteSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let accept = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceInvitationActions.accept)
|
||||
.flatMap { action in
|
||||
environment.conferenceInvitationService.accept(roomId: action.payload)
|
||||
.map { roomId in
|
||||
ConferenceInvitationActions.onAcceptSuccess(payload: roomId)
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
environment.store?.dispatch(action: InvitationViewActions.dismissInvitationView())
|
||||
return Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let reject = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceInvitationActions.reject)
|
||||
.flatMap { action in
|
||||
environment.conferenceInvitationService.reject(roomId: action.payload.0, reason: action.payload.1)
|
||||
.map { _ in
|
||||
ConferenceInvitationActions.onRejectSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let getInvitationList = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceInvitationActions.getInvitationList)
|
||||
.flatMap { action in
|
||||
environment.conferenceInvitationService.getInvitationList(roomId: action.payload.0, cursor: action.payload.1)
|
||||
.map { invitations, cursor in
|
||||
var invitationList = action.payload.2
|
||||
let newList = invitationList + invitations
|
||||
if cursor.isEmpty {
|
||||
return ConferenceInvitationActions.onGetInvitationSuccess(payload: (action.payload.0, newList))
|
||||
} else {
|
||||
return ConferenceInvitationActions.getInvitationList(payload: (action.payload.0, cursor, newList))
|
||||
}
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let fetchAttendees = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceInvitationActions.fetchAttendees)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.fetchAttendeeList(conferenceId: action.payload.0, cursor: action.payload.1)
|
||||
.map { userInfoList, cursor, totalCount in
|
||||
var attendeesList = action.payload.2
|
||||
attendeesList.append(contentsOf: userInfoList)
|
||||
if cursor.isEmpty {
|
||||
return ConferenceInvitationActions.onFetchAttendeesSuccess(payload: attendeesList)
|
||||
} else {
|
||||
return ConferenceInvitationActions.fetchAttendees(payload:(action.payload.0, cursor, attendeesList))
|
||||
}
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let onAcceptSuccess = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceInvitationActions.onAcceptSuccess)
|
||||
.sink { action in
|
||||
let roomId = action.payload
|
||||
InvitationObserverService.shared.dismissInvitationWindow()
|
||||
let joinParams = JoinConferenceParams(roomId: roomId)
|
||||
joinParams.isOpenMicrophone = true
|
||||
joinParams.isOpenCamera = false
|
||||
joinParams.isOpenSpeaker = true
|
||||
let vc = ConferenceMainViewController()
|
||||
vc.setJoinConferenceParams(params: joinParams)
|
||||
DispatchQueue.main.async {
|
||||
RoomRouter.shared.push(viewController: vc)
|
||||
}
|
||||
environment.store?.dispatch(action: InvitationViewActions.dismissInvitationView())
|
||||
}
|
||||
}
|
||||
|
||||
let onReceiveInvitation = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceInvitationActions.onReceiveInvitation)
|
||||
.sink { action in
|
||||
let roomInfo = action.payload.0
|
||||
let invitation = action.payload.1
|
||||
let isEnteredRoom = environment.store?.selectCurrent(RoomSelectors.getIsEnteredRoom)
|
||||
let isNotBeingInviting = environment.store?.selectCurrent(ViewSelectors.getDismissInvitationFlag)
|
||||
if isEnteredRoom == true {
|
||||
environment.store?.dispatch(action: ConferenceInvitationActions.reject(payload: (roomInfo.roomId, .inOtherConference)))
|
||||
} else if isNotBeingInviting == false {
|
||||
environment.store?.dispatch(action: ConferenceInvitationActions.reject(payload: (roomInfo.roomId, .rejectToEnter)))
|
||||
} else {
|
||||
InvitationObserverService.shared.showInvitationWindow(roomInfo: roomInfo, invitation: invitation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let onGetInvitationSuccess = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceInvitationActions.onGetInvitationSuccess)
|
||||
.sink { action in
|
||||
let roomId = action.payload.0
|
||||
let invitations = action.payload.1
|
||||
environment.store?.dispatch(action: ConferenceInvitationActions.updateInvitationList(payload: invitations))
|
||||
environment.store?.dispatch(action: ConferenceInvitationActions.fetchAttendees(payload: (roomId, "", [])))
|
||||
}
|
||||
}
|
||||
|
||||
let onFetchAttendeesSuccess = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceInvitationActions.onFetchAttendeesSuccess)
|
||||
.sink { action in
|
||||
let attendeeList = action.payload
|
||||
var filteredList: [UserInfo] = []
|
||||
if let allUsers = environment.store?.selectCurrent(UserSelectors.getAllUsers) {
|
||||
filteredList = attendeeList.filter { user in
|
||||
!allUsers.contains { existedUser in
|
||||
user.userId == existedUser.userId
|
||||
}
|
||||
}
|
||||
}
|
||||
let resultList = filteredList.map{ TUIInvitation(userInfo: $0) }
|
||||
environment.store?.dispatch(action: ConferenceInvitationActions.updateInvitationList(payload: resultList))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// ConferenceInvitationReducer.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
let ConferenceInvitationReducer = Reducer<ConferenceInvitationState>(
|
||||
ReduceOn(ConferenceInvitationActions.updateInvitationList, reduce: { state, action in
|
||||
let newInvitations: [TUIInvitation] = action.payload
|
||||
let previousInvitations = state.invitationList
|
||||
var existingIds = Set(previousInvitations.map { $0.invitee.userId })
|
||||
var combinedInvitations = previousInvitations
|
||||
for invitation in newInvitations {
|
||||
if !existingIds.contains(invitation.invitee.userId) {
|
||||
combinedInvitations.append(invitation)
|
||||
existingIds.insert(invitation.invitee.userId)
|
||||
}
|
||||
}
|
||||
state.invitationList = combinedInvitations
|
||||
}),
|
||||
ReduceOn(ConferenceInvitationActions.addInvitation, reduce: { state, action in
|
||||
let userIdToAdd = action.payload.invitee.userId
|
||||
if let index = state.invitationList.firstIndex(where: { $0.invitee.userId == userIdToAdd }) {
|
||||
state.invitationList[index] = action.payload
|
||||
} else {
|
||||
state.invitationList.insert(action.payload, at: 0)
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceInvitationActions.removeInvitation, reduce: { state, action in
|
||||
let userIdToRemove = action.payload
|
||||
if let index = state.invitationList.firstIndex(where: { $0.invitee.userId == userIdToRemove }) {
|
||||
state.invitationList.remove(at: index)
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceInvitationActions.changeInvitationStatus, reduce: { state, action in
|
||||
let userIdToChange = action.payload.invitee.userId
|
||||
if let index = state.invitationList.firstIndex(where: { $0.invitee.userId == userIdToChange }) {
|
||||
state.invitationList[index] = action.payload
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceInvitationActions.clearInvitationList, reduce: { state, action in
|
||||
state.invitationList.removeAll()
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// ConferenceInvitationSelector.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ConferenceInvitationSelectors {
|
||||
static let getConferenceInvitationState = Selector(keyPath: \OperationState.conferenceInvitationState)
|
||||
|
||||
static let getInvitationList = Selector.with(getConferenceInvitationState, keyPath:\ConferenceInvitationState.invitationList)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// ConferenceListActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
enum ConferenceListActions {
|
||||
static let key = "action.conferenceList"
|
||||
|
||||
static let fetchConferenceList = ActionTemplate(id: key.appending(".fetchConferenceList"),
|
||||
payloadType: (String, Int).self)
|
||||
static let scheduleConference = ActionTemplate(id: key.appending(".scheduleConference"),
|
||||
payloadType: TUIConferenceInfo.self)
|
||||
static let cancelConference = ActionTemplate(id: key.appending(".cancelConference"),
|
||||
payloadType: String.self)
|
||||
static let updateConferenceInfo = ActionTemplate(id: key.appending(".updateConferenceInfo"),
|
||||
payloadType: (TUIConferenceInfo, TUIConferenceModifyFlag).self)
|
||||
static let addAttendeesByAdmin = ActionTemplate(id: key.appending(".addAttendeesByAdmin"),
|
||||
payloadType: (String, [String]).self)
|
||||
static let removeAttendeesByAdmin = ActionTemplate(id: key.appending(".removeAttendeesByAdmin"),
|
||||
payloadType: (String, [String]).self)
|
||||
|
||||
static let resetConferenceList = ActionTemplate(id: key.appending(".resetConferenceList"))
|
||||
|
||||
// MARK: callback
|
||||
static let updateConferenceList = ActionTemplate(id: key.appending(".updateConferenceList"), payloadType: ([ConferenceInfo], String).self)
|
||||
static let insertConference = ActionTemplate(id: key.appending(".insertConference"), payloadType: ConferenceInfo.self)
|
||||
static let removeConference = ActionTemplate(id: key.appending(".removeConference"), payloadType: String.self)
|
||||
static let onConferenceUpdated = ActionTemplate(id: key.appending(".onConferenceUpdated"), payloadType: ConferenceInfo.self)
|
||||
static let onScheduleSuccess = ActionTemplate(id: key.appending("onScheduleSuccess"), payloadType: TUIConferenceInfo.self)
|
||||
static let onCancelSuccess = ActionTemplate(id: key.appending("onCancelSuccess"))
|
||||
|
||||
static let onUpdateInfoSuccess = ActionTemplate(id: key.appending("onUpdateInfoSuccess"))
|
||||
static let onAddAttendeesSuccess = ActionTemplate(id: key.appending("onAddAttendeesSuccess"))
|
||||
static let onRemoveAttendeesSuccess = ActionTemplate(id: key.appending("onRemoveAttendeesSuccess"))
|
||||
}
|
||||
|
||||
// MARK: - Subject action, only event, no reduce.
|
||||
enum ScheduleResponseActions {
|
||||
static let key = "action.schedule.response"
|
||||
static let onScheduleSuccess = ActionTemplate(id: key.appending("onScheduleSuccess"), payloadType: TUIConferenceInfo.self)
|
||||
static let onCancelSuccess = ActionTemplate(id: key.appending("onScheduleSuccess"))
|
||||
static let onUpdateInfoSuccess = ActionTemplate(id: key.appending("onUpdateInfoSuccess"))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// ConferenceListEffects.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
import Combine
|
||||
|
||||
class ConferenceListEffects: Effects {
|
||||
typealias Environment = ServiceCenter
|
||||
|
||||
let scheduleConference = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.scheduleConference)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.scheduleConference(conferenceInfo: action.payload)
|
||||
.map { conferenceInfo in
|
||||
ConferenceListActions.onScheduleSuccess(payload: conferenceInfo)
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let cancelConference = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.cancelConference)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.cancelConference(conferenceId: action.payload)
|
||||
.map { _ in
|
||||
ConferenceListActions.onCancelSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let updateConferenceInfo = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.updateConferenceInfo)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.updateConferenceInfo(conferenceInfo: action.payload.0,
|
||||
modifyFlag: action.payload.1)
|
||||
.map { _ in
|
||||
ConferenceListActions.onUpdateInfoSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let fetchConferenceList = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.fetchConferenceList)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.fetchConferenceList(status: [.notStarted, .running],
|
||||
cursor: action.payload.0,
|
||||
count: action.payload.1)
|
||||
.map { (conferenceList, cursor) in
|
||||
ConferenceListActions.updateConferenceList(payload: (conferenceList, cursor))
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let addAttendeesByAdmin = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.addAttendeesByAdmin)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.addAttendeesByAdmin(conferenceId: action.payload.0, userIdList: action.payload.1)
|
||||
.map { _ in
|
||||
ConferenceListActions.onAddAttendeesSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let removeAttendeesByAdmin = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions.wasCreated(from: ConferenceListActions.removeAttendeesByAdmin)
|
||||
.flatMap { action in
|
||||
environment.conferenceListService.removeAttendeesByAdmin(conferenceId: action.payload.0, userIdList: action.payload.1)
|
||||
.map { _ in
|
||||
ConferenceListActions.onRemoveAttendeesSuccess()
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let onScheduleSuccess = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceListActions.onScheduleSuccess)
|
||||
.sink { action in
|
||||
let conferenceInfo = action.payload
|
||||
environment.store?.dispatch(action: ScheduleResponseActions.onScheduleSuccess(payload: conferenceInfo))
|
||||
}
|
||||
}
|
||||
|
||||
let onCancelSuccess = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ConferenceListActions.onCancelSuccess)
|
||||
.sink { action in
|
||||
environment.store?.dispatch(action: ScheduleResponseActions.onCancelSuccess())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Conference.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
let ConferenceListReducer = Reducer<ConferenceListState>(
|
||||
ReduceOn(ConferenceListActions.updateConferenceList, reduce: { state, action in
|
||||
let incomingConferences = action.payload.0
|
||||
let previousConferences = state.scheduledConferences
|
||||
let combinedSet = Set(incomingConferences + previousConferences)
|
||||
state.scheduledConferences = Array(combinedSet)
|
||||
state.fetchScheduledConferencesCursor = action.payload.1
|
||||
}),
|
||||
ReduceOn(ConferenceListActions.insertConference, reduce: { state, action in
|
||||
if !state.scheduledConferences.contains(action.payload) {
|
||||
state.scheduledConferences.append(action.payload)
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceListActions.removeConference, reduce: { state, action in
|
||||
let conferenceToRemove = action.payload
|
||||
let conferences = state.scheduledConferences.map { $0.basicInfo.roomId }
|
||||
if let index = conferences.firstIndex(of: conferenceToRemove) {
|
||||
state.scheduledConferences.remove(at: index)
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceListActions.onConferenceUpdated, reduce: { state, action in
|
||||
let conference = action.payload
|
||||
if let index = state.scheduledConferences.firstIndex(where: { $0.basicInfo.roomId == conference.basicInfo.roomId }) {
|
||||
state.scheduledConferences[index] = conference
|
||||
}
|
||||
}),
|
||||
ReduceOn(ConferenceListActions.resetConferenceList, reduce: { state, action in
|
||||
state.scheduledConferences.removeAll()
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// ConferenceListSelectors.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ConferenceListSelectors {
|
||||
static let getConferenceListState = Selector(keyPath: \OperationState.conferenceListState)
|
||||
|
||||
static let getConferenceList = Selector.with(getConferenceListState, keyPath:\ConferenceListState.scheduledConferences)
|
||||
static let getConferenceListCursor = Selector.with(getConferenceListState, keyPath:\ConferenceListState.fetchScheduledConferencesCursor)
|
||||
}
|
||||
27
TUIKit/TUIRoomKit/Source/Store/ConferenceStore.swift
Normal file
27
TUIKit/TUIRoomKit/Source/Store/ConferenceStore.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// ConferenceStore.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
protocol ActionDispatcher {
|
||||
func dispatch(action: Action)
|
||||
}
|
||||
|
||||
protocol ConferenceStore: ActionDispatcher {
|
||||
var errorSubject: PassthroughSubject<RoomError, Never> { get }
|
||||
var toastSubject: PassthroughSubject<ToastInfo, Never> { get }
|
||||
var scheduleActionSubject: PassthroughSubject<IdentifiableAction, Never> { get }
|
||||
|
||||
func select<Value:Equatable>(_ selector: Selector<OperationState, Value>) -> AnyPublisher<Value, Never>
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<OperationState, Value>) -> Value
|
||||
|
||||
func select<Value:Equatable>(_ selector: Selector<ViewState, Value>) -> AnyPublisher<Value, Never>
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<ViewState, Value>) -> Value
|
||||
}
|
||||
138
TUIKit/TUIRoomKit/Source/Store/ConferenceStoreProvider.swift
Normal file
138
TUIKit/TUIRoomKit/Source/Store/ConferenceStoreProvider.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// ConferenceStoreProvider.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class ConferenceStoreProvider {
|
||||
let errorSubject = PassthroughSubject<RoomError, Never>()
|
||||
let toastSubject = PassthroughSubject<ToastInfo, Never>()
|
||||
let scheduleActionSubject = PassthroughSubject<any IdentifiableAction, Never>()
|
||||
|
||||
private(set) lazy var operation: Store<OperationState, ServiceCenter> = {
|
||||
return Store(initialState: OperationState(), environment: ServiceCenter())
|
||||
}()
|
||||
private(set) lazy var viewStore: Store<ViewState, Void> = Store(initialState: ViewState())
|
||||
private var cancellableSet: Set<AnyCancellable> = []
|
||||
|
||||
init() {
|
||||
initializeStore()
|
||||
}
|
||||
|
||||
private func initializeStore() {
|
||||
initializeUserStore()
|
||||
initializeConferenceListStore()
|
||||
initializeConferenceInvitationStore()
|
||||
initializeRoomStore()
|
||||
initializeErrorEffect()
|
||||
initializedViewStore()
|
||||
}
|
||||
|
||||
private func initializeUserStore() {
|
||||
operation.register(reducer: userReducer, for: \.userState)
|
||||
operation.register(effects: UserEffects())
|
||||
}
|
||||
|
||||
private func initializeConferenceListStore() {
|
||||
operation.register(reducer: ConferenceListReducer, for: \.conferenceListState)
|
||||
operation.register(effects: ConferenceListEffects())
|
||||
}
|
||||
|
||||
private func initializeConferenceInvitationStore() {
|
||||
operation.register(reducer: ConferenceInvitationReducer, for: \.conferenceInvitationState)
|
||||
operation.register(effects: ConferenceInvitationEffects())
|
||||
}
|
||||
|
||||
private func initializeRoomStore() {
|
||||
operation.register(reducer: roomReducer, for: \.roomState)
|
||||
operation.register(effects: RoomEffects())
|
||||
}
|
||||
|
||||
private func initializeErrorEffect() {
|
||||
operation.register(effects: ErrorEffects())
|
||||
errorSubject
|
||||
.sink { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
self.handle(error: error)
|
||||
}
|
||||
.store(in: &cancellableSet)
|
||||
}
|
||||
|
||||
private func initializedViewStore() {
|
||||
viewStore.register(reducer: scheduleViewReducer,for: \ViewState.scheduleViewState)
|
||||
viewStore.register(reducer: invitationViewReducer, for: \ViewState.invitationViewState)
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation.unregister(reducer: userReducer)
|
||||
operation.unregisterEffects(withId: UserEffects.id)
|
||||
|
||||
operation.unregister(reducer: ConferenceListReducer)
|
||||
operation.unregisterEffects(withId: ConferenceListEffects.id)
|
||||
|
||||
operation.unregister(reducer: ConferenceInvitationReducer)
|
||||
operation.unregisterEffects(withId: ConferenceInvitationEffects.id)
|
||||
|
||||
operation.unregister(reducer: roomReducer)
|
||||
operation.unregisterEffects(withId: RoomEffects.id)
|
||||
|
||||
operation.unregisterEffects(withId: ErrorEffects.id)
|
||||
|
||||
viewStore.unregister(reducer: scheduleViewReducer)
|
||||
viewStore.unregister(reducer: invitationViewReducer)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceStoreProvider: ConferenceStore {
|
||||
func dispatch(action: Action) {
|
||||
guard let action = action as? IdentifiableAction else { return }
|
||||
if action.id.hasPrefix(ScheduleResponseActions.key) {
|
||||
scheduleActionSubject.send(action)
|
||||
}
|
||||
|
||||
if action.id.hasPrefix(ViewActions.toastActionKey) {
|
||||
handleToast(action: action)
|
||||
} else if action.id.hasPrefix(ViewActions.key) {
|
||||
viewStore.dispatch(action: action)
|
||||
} else {
|
||||
operation.dispatch(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
func select<Value: Equatable>(_ selector: Selector<OperationState, Value>) -> AnyPublisher<Value, Never> {
|
||||
return operation.select(selector)
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<OperationState, Value>) -> Value {
|
||||
return operation.selectCurrent(selector)
|
||||
}
|
||||
|
||||
func select<Value:Equatable>(_ selector: Selector<ViewState, Value>) -> AnyPublisher<Value, Never> {
|
||||
return viewStore.select(selector)
|
||||
}
|
||||
|
||||
func selectCurrent<Value>(_ selector: Selector<ViewState, Value>) -> Value {
|
||||
return viewStore.selectCurrent(selector)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConferenceStoreProvider {
|
||||
private func handle(error: RoomError) {
|
||||
error.actions.forEach { action in
|
||||
guard let action = action as? IdentifiableAction else { return }
|
||||
dispatch(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleToast(action: Action) {
|
||||
if let viewAction = action as? AnonymousAction<ToastInfo> {
|
||||
toastSubject.send(viewAction.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// ConferenceStoreResolverRegister.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Factory
|
||||
|
||||
extension Container {
|
||||
var conferenceStore: Factory<ConferenceStore> {
|
||||
Factory(self) {
|
||||
ConferenceStoreProvider()
|
||||
}
|
||||
.shared
|
||||
}
|
||||
}
|
||||
13
TUIKit/TUIRoomKit/Source/Store/Error/ErrorActions.swift
Normal file
13
TUIKit/TUIRoomKit/Source/Store/Error/ErrorActions.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// ErrorActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ErrorActions {
|
||||
static let key = "action.error"
|
||||
static let throwError = ActionTemplate(id: key.appending(".throwError"), payloadType: RoomError.self)
|
||||
}
|
||||
20
TUIKit/TUIRoomKit/Source/Store/Error/ErrorEffects.swift
Normal file
20
TUIKit/TUIRoomKit/Source/Store/Error/ErrorEffects.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// ErrorEffects.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ErrorEffects: Effects {
|
||||
typealias Environment = ServiceCenter
|
||||
|
||||
let throwError = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: ErrorActions.throwError)
|
||||
.sink { action in
|
||||
environment.store?.errorSubject.send(action.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
TUIKit/TUIRoomKit/Source/Store/Room/RoomActions.swift
Normal file
19
TUIKit/TUIRoomKit/Source/Store/Room/RoomActions.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// RoomActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
enum RoomActions {
|
||||
static let key = "action.room"
|
||||
|
||||
static let joinConference = ActionTemplate(id: key.appending(".joinConference"), payloadType: (String).self)
|
||||
|
||||
static let updateRoomState = ActionTemplate(id: key.appending(".updateRoomState"), payloadType: RoomInfo.self)
|
||||
static let clearRoomState = ActionTemplate(id: key.appending(".clearRoomState"))
|
||||
static let onJoinSuccess = ActionTemplate(id: key.appending(".onJoinSuccess"))
|
||||
}
|
||||
26
TUIKit/TUIRoomKit/Source/Store/Room/RoomEffects.swift
Normal file
26
TUIKit/TUIRoomKit/Source/Store/Room/RoomEffects.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// RoomEffects.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
class RoomEffects: Effects {
|
||||
typealias Environment = ServiceCenter
|
||||
|
||||
let joinConference = Effect<Environment>.nonDispatching { actions, environment in
|
||||
actions.wasCreated(from: RoomActions.joinConference)
|
||||
.sink { action in
|
||||
let joinParams = JoinConferenceParams(roomId: action.payload)
|
||||
joinParams.isOpenMicrophone = true
|
||||
joinParams.isOpenCamera = false
|
||||
joinParams.isOpenSpeaker = true
|
||||
environment.navigator?.pushTo(route: .main(conferenceParams: ConferenceParamType(joinParams: joinParams)))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
18
TUIKit/TUIRoomKit/Source/Store/Room/RoomReducer.swift
Normal file
18
TUIKit/TUIRoomKit/Source/Store/Room/RoomReducer.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// RoomReducer.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
let roomReducer = Reducer<RoomInfo>(
|
||||
ReduceOn(RoomActions.updateRoomState, reduce: { state, action in
|
||||
state = action.payload
|
||||
state.isEnteredRoom = true
|
||||
}),
|
||||
ReduceOn(RoomActions.clearRoomState, reduce: { state, action in
|
||||
state = RoomInfo()
|
||||
})
|
||||
)
|
||||
15
TUIKit/TUIRoomKit/Source/Store/Room/RoomSelector.swift
Normal file
15
TUIKit/TUIRoomKit/Source/Store/Room/RoomSelector.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// RoomSelector.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by jeremiawang on 2024/8/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RoomSelectors {
|
||||
static let getRoomState = Selector(keyPath: \OperationState.roomState)
|
||||
|
||||
static let getRoomId = Selector.with(getRoomState, keyPath:\RoomInfo.roomId)
|
||||
static let getIsEnteredRoom = Selector.with(getRoomState, keyPath:\RoomInfo.isEnteredRoom)
|
||||
}
|
||||
16
TUIKit/TUIRoomKit/Source/Store/User/UserActions.swift
Normal file
16
TUIKit/TUIRoomKit/Source/Store/User/UserActions.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// UserActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/5.
|
||||
//
|
||||
|
||||
import RTCRoomEngine
|
||||
|
||||
enum UserActions {
|
||||
static let key = "action.user"
|
||||
static let getSelfInfo = ActionTemplate(id: key.appending(".getSelfInfo"))
|
||||
static let updateSelfInfo = ActionTemplate(id: key.appending(".updateSelfInfo"), payloadType: UserInfo.self)
|
||||
static let fetchUserInfo = ActionTemplate(id: key.appending(".fetchUserInfo"), payloadType: String.self)
|
||||
static let updateAllUsers = ActionTemplate(id: key.appending(".updateAllUsers"), payloadType: [UserInfo].self)
|
||||
}
|
||||
32
TUIKit/TUIRoomKit/Source/Store/User/UserEffects.swift
Normal file
32
TUIKit/TUIRoomKit/Source/Store/User/UserEffects.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// UserEffects.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import ImSDK_Plus
|
||||
import RTCRoomEngine
|
||||
|
||||
class UserEffects: Effects {
|
||||
typealias Environment = ServiceCenter
|
||||
|
||||
let getSelfInfo = Effect<Environment>.dispatchingOne { actions, environment in
|
||||
actions
|
||||
.wasCreated(from: UserActions.getSelfInfo)
|
||||
.flatMap { action -> AnyPublisher<Action, Never> in
|
||||
let selfId = environment.store?.selectCurrent(UserSelectors.getSelfId) ?? ""
|
||||
return environment.userService.fetchUserInfo(selfId)
|
||||
.map { userInfo in
|
||||
return UserActions.updateSelfInfo(payload: userInfo)
|
||||
}
|
||||
.catch { error -> Just<Action> in
|
||||
return Just(ErrorActions.throwError(payload: error))
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
25
TUIKit/TUIRoomKit/Source/Store/User/UserReducer.swift
Normal file
25
TUIKit/TUIRoomKit/Source/Store/User/UserReducer.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// UserReducer.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/5.
|
||||
//
|
||||
|
||||
import RTCRoomEngine
|
||||
import TUICore
|
||||
|
||||
let userReducer = Reducer<UserState>(
|
||||
ReduceOn(UserActions.getSelfInfo, reduce: { state, action in
|
||||
var selfInfo = UserInfo()
|
||||
selfInfo.userId = TUILogin.getUserID() ?? ""
|
||||
selfInfo.userName = TUILogin.getNickName() ?? ""
|
||||
selfInfo.avatarUrl = TUILogin.getFaceUrl() ?? ""
|
||||
state.selfInfo = selfInfo
|
||||
}),
|
||||
ReduceOn(UserActions.updateSelfInfo, reduce: { state, action in
|
||||
state.selfInfo = action.payload
|
||||
}),
|
||||
ReduceOn(UserActions.updateAllUsers, reduce: { state, action in
|
||||
state.allUsers = action.payload
|
||||
})
|
||||
)
|
||||
19
TUIKit/TUIRoomKit/Source/Store/User/UserSelectors.swift
Normal file
19
TUIKit/TUIRoomKit/Source/Store/User/UserSelectors.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// UserSelectors.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/6/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum UserSelectors {
|
||||
static let getUserState = Selector(keyPath: \OperationState.userState)
|
||||
|
||||
static let getSelfInfo = Selector.with(getUserState, keyPath:\UserState.selfInfo)
|
||||
static let getSelfId = Selector.with(getSelfInfo, keyPath:\UserInfo.userId)
|
||||
static let getSelfAvatarURL = Selector.with(getSelfInfo, keyPath: \UserInfo.avatarUrl)
|
||||
static let getSelfUserName = Selector.with(getSelfInfo, keyPath: \UserInfo.userName)
|
||||
static let getAllUsers = Selector.with(getUserState, keyPath: \UserState.allUsers)
|
||||
}
|
||||
|
||||
58
TUIKit/TUIRoomKit/Source/Store/View/ViewActions.swift
Normal file
58
TUIKit/TUIRoomKit/Source/Store/View/ViewActions.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// ViewActions.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ViewActions {
|
||||
static let key = "action.view"
|
||||
static let toastActionKey = key + ".showToast"
|
||||
|
||||
static let showToast = ActionTemplate(id: toastActionKey, payloadType: ToastInfo.self)
|
||||
}
|
||||
|
||||
enum ScheduleViewActions {
|
||||
static let key = ViewActions.key + ".scheduleView"
|
||||
|
||||
static let refreshConferenceList = ActionTemplate(id: key.appending(".refreshConferenceList"))
|
||||
static let stopRefreshList = ActionTemplate(id: key.appending(".stopRefreshList"))
|
||||
|
||||
static let popDetailView = ActionTemplate(id: key.appending(".popDetailView"))
|
||||
static let resetPopDetailFlag = ActionTemplate(id: key.appending(".resetPopDetailFlag"))
|
||||
}
|
||||
|
||||
enum InvitationViewActions {
|
||||
static let key = ViewActions.key + ".invitationView"
|
||||
|
||||
static let dismissInvitationView = ActionTemplate(id: key.appending(".dismissInvitationView"))
|
||||
static let resetInvitationFlag = ActionTemplate(id: key.appending(".resetInvitationFlag"))
|
||||
static let showInvitationPopupView = ActionTemplate(id: key.appending(".showInvitationPopupView"))
|
||||
static let resetPopupViewFlag = ActionTemplate(id: key.appending(".resetPopupViewFlag"))
|
||||
}
|
||||
|
||||
struct ToastInfo: Identifiable {
|
||||
enum Position {
|
||||
case center
|
||||
case bottom
|
||||
}
|
||||
let id: UUID
|
||||
let duration: TimeInterval
|
||||
let position: Position
|
||||
let message: String
|
||||
|
||||
init(message: String, position: Position = .center, duration: TimeInterval = 1.5) {
|
||||
id = UUID()
|
||||
self.message = message
|
||||
self.position = position
|
||||
self.duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
extension ToastInfo: Equatable {
|
||||
static func ==(lhs: ToastInfo, rhs: ToastInfo) -> Bool{
|
||||
return lhs.id == rhs.id || lhs.message == rhs.message
|
||||
}
|
||||
}
|
||||
38
TUIKit/TUIRoomKit/Source/Store/View/ViewReducers.swift
Normal file
38
TUIKit/TUIRoomKit/Source/Store/View/ViewReducers.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// ViewReducers.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
let scheduleViewReducer = Reducer<ScheduleViewState>(
|
||||
ReduceOn(ScheduleViewActions.refreshConferenceList, reduce: { state, action in
|
||||
state.shouldRefreshList = true
|
||||
}),
|
||||
ReduceOn(ScheduleViewActions.stopRefreshList, reduce: { state, action in
|
||||
state.shouldRefreshList = false
|
||||
}),
|
||||
ReduceOn(ScheduleViewActions.popDetailView, reduce: { state, action in
|
||||
state.detailViewPopFlag = true
|
||||
}),
|
||||
ReduceOn(ScheduleViewActions.resetPopDetailFlag, reduce: { state, action in
|
||||
state.detailViewPopFlag = false
|
||||
})
|
||||
)
|
||||
|
||||
let invitationViewReducer = Reducer<InvitationViewState> (
|
||||
ReduceOn(InvitationViewActions.dismissInvitationView, reduce: { state, action in
|
||||
state.invitationViewDismissFlag = true
|
||||
}),
|
||||
ReduceOn(InvitationViewActions.resetInvitationFlag, reduce: { state, action in
|
||||
state.invitationViewDismissFlag = false
|
||||
}),
|
||||
ReduceOn(InvitationViewActions.showInvitationPopupView, reduce: { state, action in
|
||||
state.showInvitationPopupView = true
|
||||
}),
|
||||
ReduceOn(InvitationViewActions.resetPopupViewFlag, reduce: { state, action in
|
||||
state.showInvitationPopupView = false
|
||||
})
|
||||
)
|
||||
18
TUIKit/TUIRoomKit/Source/Store/View/ViewSelectors.swift
Normal file
18
TUIKit/TUIRoomKit/Source/Store/View/ViewSelectors.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// ViewSelectors.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by CY zhao on 2024/7/15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ViewSelectors {
|
||||
private static let getScheduleStatus = Selector(keyPath: \ViewState.scheduleViewState)
|
||||
static let getRefreshListFlag = Selector.with(getScheduleStatus, projector: \ScheduleViewState.shouldRefreshList)
|
||||
static let getPopDetailFlag = Selector.with(getScheduleStatus, projector: \ScheduleViewState.detailViewPopFlag)
|
||||
|
||||
private static let getInvitationStatus = Selector(keyPath: \ViewState.invitationViewState)
|
||||
static let getDismissInvitationFlag = Selector.with(getInvitationStatus, projector: \InvitationViewState.invitationViewDismissFlag)
|
||||
static let getShowinvitationPopupView = Selector.with(getInvitationStatus, projector: \InvitationViewState.showInvitationPopupView)
|
||||
}
|
||||
53
TUIKit/TUIRoomKit/Source/TUIRoomKit.swift
Normal file
53
TUIKit/TUIRoomKit/Source/TUIRoomKit.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// TUIRoomKit.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by WesleyLei on 2022/9/13.
|
||||
// Copyright © 2022 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RTCRoomEngine
|
||||
|
||||
@available(*, deprecated, message: "Use ConferenceMainViewController instead.")
|
||||
@objcMembers public class TUIRoomKit: NSObject {
|
||||
private static var _shared: TUIRoomKit?
|
||||
public class func createInstance() -> TUIRoomKit {
|
||||
guard let instance = _shared else {
|
||||
let roomKit = TUIRoomKit()
|
||||
_shared = roomKit
|
||||
return roomKit
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
public class func destroyInstance() {
|
||||
TUIRoomKit._shared = nil
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func setSelfInfo(userName: String, avatarURL: String, onSuccess: @escaping TUISuccessBlock, onError: @escaping TUIErrorBlock) {
|
||||
EngineManager.shared.setSelfInfo(userName: userName, avatarURL: avatarURL, onSuccess: onSuccess, onError: onError)
|
||||
}
|
||||
|
||||
public func createRoom(roomInfo: TUIRoomInfo, onSuccess: @escaping TUISuccessBlock, onError: @escaping TUIErrorBlock) {
|
||||
EngineManager.shared.createRoom(roomInfo: roomInfo, onSuccess: onSuccess, onError: onError)
|
||||
}
|
||||
|
||||
public func enterRoom(roomId: String, enableAudio: Bool, enableVideo: Bool, isSoundOnSpeaker: Bool,
|
||||
onSuccess: @escaping TUISuccessBlock, onError: @escaping TUIErrorBlock) {
|
||||
EngineManager.shared.enterRoom(roomId: roomId, enableAudio: enableAudio, enableVideo: enableVideo,
|
||||
isSoundOnSpeaker: isSoundOnSpeaker) { roomInfo in
|
||||
RoomRouter.shared.pushMainViewController()
|
||||
} onError: { code, message in
|
||||
onError(code, message)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// AlertTransitionAnimator.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AlertTransitionAnimator : NSObject {
|
||||
enum AlertTransitionStyle {
|
||||
case present
|
||||
case dismiss
|
||||
}
|
||||
enum AlertTransitionPosition {
|
||||
case bottom
|
||||
case right
|
||||
}
|
||||
var duration = 0.5
|
||||
var alertTransitionStyle: AlertTransitionStyle = .present
|
||||
var alertTransitionPosition: AlertTransitionPosition = .bottom
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertTransitionAnimator {
|
||||
private func presentTransition(transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromController = transitionContext.viewController(forKey: .from), let toController = transitionContext.viewController(forKey: .to)
|
||||
else { return }
|
||||
guard let fromView = fromController.view, let toView = toController.view else { return }
|
||||
let contentView = transitionContext.containerView
|
||||
fromView.tintAdjustmentMode = .normal
|
||||
fromView.isUserInteractionEnabled = false
|
||||
toView.isUserInteractionEnabled = false
|
||||
contentView.addSubview(toView)
|
||||
switch alertTransitionPosition {
|
||||
case .bottom:
|
||||
toView.frame = CGRect(x: 0, y: contentView.bounds.size.height, width: contentView.bounds.size.width, height:
|
||||
contentView.bounds.size.height)
|
||||
case .right:
|
||||
toView.frame = CGRect(x: contentView.bounds.size.width, y: 0, width: contentView.bounds.size.width/2, height:
|
||||
contentView.bounds.size.height)
|
||||
}
|
||||
UIView.animate(withDuration: duration, animations: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch self.alertTransitionPosition {
|
||||
case .bottom:
|
||||
toView.frame = CGRect(x: 0, y: 0, width: contentView.bounds.size.width, height: contentView.bounds.size.height)
|
||||
case .right:
|
||||
toView.frame = CGRect(x: 0, y: 0, width: contentView.bounds.size.width, height: contentView.bounds.size.height)
|
||||
}
|
||||
}) { (finish) in
|
||||
fromView.isUserInteractionEnabled = true
|
||||
toView.isUserInteractionEnabled = true
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissTransition(transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromController = transitionContext.viewController(forKey: .from), let toController = transitionContext.viewController(forKey: .to)
|
||||
else { return }
|
||||
guard let fromView = fromController.view, let toView = toController.view else { return }
|
||||
fromView.isUserInteractionEnabled = false
|
||||
toView.isUserInteractionEnabled = false
|
||||
let contentView = transitionContext.containerView
|
||||
UIView.animate(withDuration: duration, animations: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch self.alertTransitionPosition {
|
||||
case .bottom:
|
||||
fromView.frame = CGRect(x: 0, y: contentView.bounds.size.height, width: contentView.bounds.size.width, height:
|
||||
contentView.bounds.size.height)
|
||||
case .right:
|
||||
fromView.frame = CGRect(x: contentView.bounds.size.width, y: 0, width: contentView.bounds.size.width, height:
|
||||
contentView.bounds.size.height)
|
||||
}
|
||||
}) { (finish) in
|
||||
fromView.removeFromSuperview()
|
||||
toView.isUserInteractionEnabled = true
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertTransitionAnimator: UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return duration
|
||||
}
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
switch alertTransitionStyle {
|
||||
case .present:
|
||||
presentTransition(transitionContext: transitionContext)
|
||||
case .dismiss:
|
||||
dismissTransition(transitionContext: transitionContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// BroadcastLauncher.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/1/17.
|
||||
// Copyright © 2023 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import ReplayKit
|
||||
import UIKit
|
||||
|
||||
@available(iOS 12.0, *)
|
||||
class BroadcastLauncher: NSObject {
|
||||
var systemExtensionPicker = RPSystemBroadcastPickerView()
|
||||
var prevLaunchEventTime: CFTimeInterval = 0
|
||||
|
||||
static let sharedInstance = BroadcastLauncher()
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
|
||||
picker.showsMicrophoneButton = false
|
||||
picker.autoresizingMask = [.flexibleTopMargin, .flexibleRightMargin]
|
||||
systemExtensionPicker = picker
|
||||
|
||||
if let pluginPath = Bundle.main.builtInPlugInsPath,
|
||||
let contents = try? FileManager.default.contentsOfDirectory(atPath: pluginPath) {
|
||||
for content in contents where content.hasSuffix(".appex") {
|
||||
guard let bundle = Bundle(path: URL(fileURLWithPath: pluginPath).appendingPathComponent(content).path),
|
||||
let identifier: String = (bundle.infoDictionary?["NSExtension"] as? [String: Any])? ["NSExtensionPointIdentifier"] as? String
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if identifier == "com.apple.broadcast-services-upload" {
|
||||
picker.preferredExtension = bundle.bundleIdentifier
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func launch() {
|
||||
BroadcastLauncher.sharedInstance.launch()
|
||||
}
|
||||
|
||||
func launch() {
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
if now - prevLaunchEventTime < 1.0 {
|
||||
return
|
||||
}
|
||||
prevLaunchEventTime = now
|
||||
|
||||
for view in systemExtensionPicker.subviews {
|
||||
if let button = view as? UIButton {
|
||||
button.sendActions(for: .allTouchEvents)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
123
TUIKit/TUIRoomKit/Source/View/Component/ButtonItemView.swift
Normal file
123
TUIKit/TUIRoomKit/Source/View/Component/ButtonItemView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// ButtonItemView.swift
|
||||
// TUIRoomKit
|
||||
//
|
||||
// Created by janejntang on 2023/1/10.
|
||||
// Copyright © 2023 Tencent. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ButtonItemView: UIView {
|
||||
var itemData: ButtonItemData
|
||||
|
||||
lazy var controlButton: UIButton = {
|
||||
let button = UIButton()
|
||||
button.backgroundColor = itemData.backgroundColor
|
||||
if let cornerRadius = itemData.cornerRadius {
|
||||
button.layer.cornerRadius = cornerRadius
|
||||
}
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var label: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textAlignment = isRTL ? .right : .left
|
||||
label.font = itemData.titleFont ?? UIFont(name: "PingFangSC-Regular", size: 14)
|
||||
label.textColor = itemData.titleColor ?? UIColor(0xD5E0F2)
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
return label
|
||||
}()
|
||||
|
||||
lazy var imageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor(0x4F586B,alpha: 0.1)
|
||||
view.isHidden = itemData.hasLineView ? false : true
|
||||
return view
|
||||
}()
|
||||
// MARK: - initialized function
|
||||
init(itemData: ButtonItemData) {
|
||||
self.itemData = itemData
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - view layout
|
||||
private var isViewReady: Bool = false
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
guard !isViewReady else { return }
|
||||
constructViewHierarchy()
|
||||
activateConstraints()
|
||||
bindInteraction()
|
||||
isViewReady = true
|
||||
}
|
||||
|
||||
func constructViewHierarchy() {
|
||||
addSubview(lineView)
|
||||
addSubview(controlButton)
|
||||
controlButton.addSubview(imageView)
|
||||
controlButton.addSubview(label)
|
||||
}
|
||||
|
||||
func activateConstraints() {
|
||||
controlButton.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
if let size = itemData.imageSize {
|
||||
make.size.equalTo(size)
|
||||
} else {
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
if itemData.orientation == .left {
|
||||
make.leading.equalToSuperview()
|
||||
} else {
|
||||
make.trailing.equalToSuperview()
|
||||
}
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.centerY.equalToSuperview()
|
||||
if itemData.orientation == .left {
|
||||
make.leading.equalTo(imageView.snp.trailing).offset(10)
|
||||
} else {
|
||||
make.trailing.equalTo(imageView.snp.leading).offset(-10)
|
||||
}
|
||||
}
|
||||
lineView.snp.makeConstraints { make in
|
||||
make.bottom.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(1.scale375())
|
||||
}
|
||||
}
|
||||
|
||||
func bindInteraction() {
|
||||
setupViewState(item: itemData)
|
||||
controlButton.addTarget(self, action: #selector(clickMenuButton(sender:)), for: .touchUpInside)
|
||||
}
|
||||
|
||||
func setupViewState(item: ButtonItemData) {
|
||||
itemData = item
|
||||
controlButton.isSelected = item.isSelect
|
||||
controlButton.isEnabled = item.isEnabled
|
||||
imageView.image = item.isSelect ? itemData.selectedImage : itemData.normalImage
|
||||
label.text = item.isSelect ? itemData.selectedTitle : itemData.normalTitle
|
||||
}
|
||||
|
||||
@objc func clickMenuButton(sender: UIButton) {
|
||||
itemData.action?(sender)
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("deinit \(self)")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user