增加换肤功能

This commit is contained in:
启星
2025-08-14 10:07:49 +08:00
parent f6964c1e89
commit 4f9318d98e
8789 changed files with 978530 additions and 2 deletions

View File

@@ -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 }))
}
}

View 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
}
}

View 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) }
}

View File

@@ -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)
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View 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)
}
}
}

View 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
}
}

View 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
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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!")
}

View File

@@ -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 = ""
}

View File

@@ -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()
}
}
}

View File

@@ -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))
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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
}
)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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))
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View 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))
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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
})
)

View 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)))
}
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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)")
}
}

View 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}

View 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
}
}
}

View 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")
}
}

View 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)
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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
}

View 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")
}

View File

@@ -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))
}
}

View 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]) {
}
}

View 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)))
}
}
}

View File

@@ -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()
}
}

View 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()
}
}

View 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()
}

View 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()
}
}

View File

@@ -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
}
}

View 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)
}
}

View 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()
}

View 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
}
}

View 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()
}
}

View 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
}

View File

@@ -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)
}

View File

@@ -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))
}
}
}

View File

@@ -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()
})
)

View File

@@ -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)
}

View File

@@ -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"))
}

View File

@@ -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())
}
}
}

View File

@@ -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()
})
)

View File

@@ -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)
}

View 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
}

View 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)
}
}
}

View File

@@ -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
}
}

View 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)
}

View 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)
}
}
}

View 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"))
}

View 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)))
}
}
}

View 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()
})
)

View 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)
}

View 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)
}

View 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()
}
}

View 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
})
)

View 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)
}

View 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
}
}

View 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
})
)

View 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)
}

View 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)")
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)")
}
}

View 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