Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

3444 lines
156 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import AccountContext
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import BundleIconComponent
import ButtonComponent
import EdgeEffect
import ResizableSheetComponent
import ListActionItemComponent
import ListSectionComponent
import ListItemComponentAdaptor
import PeerListItemComponent
import AlertComponent
import AlertUI
import UndoUI
import ChatListUI
import ChatListHeaderComponent
import ChatListTitleView
import ContextUI
import SearchUI
import ItemListUI
import PeerSelectionScreen
import CommunityPrivateChatScreen
import AvatarComponent
private extension CommunityViewScreenMode {
var usesGroupedStyle: Bool {
return self == .sheet
}
var usesPlainStyle: Bool {
return self != .sheet
}
var usesSheetPresentation: Bool {
return self == .sheet
}
var usesFullscreenPresentation: Bool {
return self != .sheet
}
var isPreview: Bool {
return self == .preview
}
}
private final class CommunityChatPreviewContextContentSource: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
let navigationController: NavigationController?
let passthroughTouches: Bool = true
init(controller: ViewController, sourceNode: ASDisplayNode?, navigationController: NavigationController?) {
self.controller = controller
self.sourceNode = sourceNode
self.navigationController = navigationController
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceNode = self.sourceNode
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceNode {
return (sourceNode.view, sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}
private struct CommunityChatPreviewData: Equatable {
var messages: [EngineMessage]
var readCounters: EnginePeerReadCounters
var timestamp: Int32 {
return self.messages.map(\.timestamp).max() ?? 0
}
var searchText: String {
return self.messages.map(\.text).joined(separator: " ")
}
}
private enum CommunityViewSection: Int {
case joined
case visible
case requestable
case hidden
}
private struct CommunityViewRow: Equatable {
let peer: EnginePeer
let linkedPeer: CachedCommunityData.CommunityLinkedPeer
}
private func communitySelectionPeerMatchesRequestTypes(peer: EnginePeer, requestPeerType: [ReplyMarkupButtonRequestPeerType]?) -> Bool {
guard let requestPeerType else {
return true
}
guard !peer.isDeleted else {
return false
}
for peerType in requestPeerType {
var match = false
switch peerType {
case let .user(userType):
if case let .user(user) = peer {
match = true
if user.id.isVerificationCodes {
match = false
}
if let isBot = userType.isBot, isBot != (user.botInfo != nil) {
match = false
}
if let isPremium = userType.isPremium, isPremium != user.isPremium {
match = false
}
}
case let .group(groupType):
if case let .legacyGroup(group) = peer {
match = true
if groupType.isCreator {
if case .creator = group.role {
} else {
match = false
}
}
if let isForum = groupType.isForum, isForum {
match = false
}
if let hasUsername = groupType.hasUsername, hasUsername {
match = false
}
if let userAdminRights = groupType.userAdminRights {
if case .creator = group.role, userAdminRights.rights.contains(.canBeAnonymous) {
match = false
} else if case let .admin(rights, _) = group.role {
if rights.rights.intersection(userAdminRights.rights) != userAdminRights.rights {
match = false
}
} else if case .member = group.role {
match = false
}
}
} else if case let .channel(channel) = peer, case .group = channel.info {
match = true
if groupType.isCreator, !channel.flags.contains(.isCreator) {
match = false
}
if let isForum = groupType.isForum, isForum != channel.isForum {
match = false
}
if let hasUsername = groupType.hasUsername, hasUsername != (!(channel.addressName ?? "").isEmpty) {
match = false
}
if let userAdminRights = groupType.userAdminRights {
if channel.flags.contains(.isCreator) {
if let rights = channel.adminRights, rights.rights.contains(.canBeAnonymous) != userAdminRights.rights.contains(.canBeAnonymous) {
match = false
}
} else if let rights = channel.adminRights {
if rights.rights.intersection(userAdminRights.rights) != userAdminRights.rights {
match = false
}
} else {
match = false
}
}
}
case let .channel(channelType):
if case let .channel(channel) = peer, case .broadcast = channel.info {
match = true
if channelType.isCreator, !channel.flags.contains(.isCreator) {
match = false
}
if let hasUsername = channelType.hasUsername, hasUsername != (!(channel.addressName ?? "").isEmpty) {
match = false
}
if let userAdminRights = channelType.userAdminRights {
if channel.flags.contains(.isCreator) {
if let rights = channel.adminRights, rights.rights.contains(.canBeAnonymous) != userAdminRights.rights.contains(.canBeAnonymous) {
match = false
}
} else if let rights = channel.adminRights {
if rights.rights.intersection(userAdminRights.rights) != userAdminRights.rights {
match = false
}
} else {
match = false
}
}
}
case .createBot:
break
}
if match {
return true
}
}
return false
}
private func communitySelectionPeerState(peer: EnginePeer, isJoined: Bool, options: CommunityPeerSelectionOptions) -> (isVisible: Bool, isEnabled: Bool, disabledReason: ChatListDisabledPeerReason) {
let filter = options.filter
if options.excludedPeerIds.contains(peer.id) {
return (false, false, .generic)
}
if !communitySelectionPeerMatchesRequestTypes(peer: peer, requestPeerType: options.requestPeerType) {
return (false, false, .generic)
}
if filter.contains(.excludeSecretChats), peer.id.namespace == Namespaces.Peer.SecretChat {
return (false, false, .generic)
}
switch peer {
case let .user(user):
if user.id.isRepliesOrVerificationCodes {
return (false, false, .generic)
}
if user.botInfo != nil {
if filter.contains(.excludeBots) {
return (false, false, .generic)
}
} else if filter.contains(.excludeUsers) {
return (false, false, .generic)
}
case .legacyGroup:
if filter.contains(.excludeGroups) {
return (false, false, .generic)
}
case let .channel(channel):
switch channel.info {
case .broadcast:
if filter.contains(.excludeChannels) {
return (false, false, .generic)
}
case .group:
if filter.contains(.excludeGroups) {
return (false, false, .generic)
}
}
case .secretChat:
break
case .community:
return (false, false, .generic)
}
if filter.contains(.onlyPrivateChats) {
switch peer {
case .user, .secretChat:
break
default:
return (false, false, .generic)
}
}
if filter.contains(.onlyGroupsAndChannels) {
if case .legacyGroup = peer {
} else if case .channel = peer {
} else {
return (false, false, .generic)
}
} else {
if filter.contains(.onlyGroups) {
if case .legacyGroup = peer {
} else if case let .channel(channel) = peer, case .group = channel.info {
} else {
return (false, false, .generic)
}
}
if filter.contains(.onlyChannels) {
if case let .channel(channel) = peer, case .broadcast = channel.info {
} else {
return (false, false, .generic)
}
}
}
var enabled = true
if filter.contains(.onlyWriteable), !canSendMessagesToPeer(peer) {
enabled = false
}
if filter.contains(.onlyManageable) {
var canManage = false
if case let .legacyGroup(group) = peer {
switch group.role {
case .creator, .admin:
canManage = true
default:
break
}
} else if case let .channel(channel) = peer, case .group = channel.info, channel.hasPermission(.inviteMembers) {
canManage = true
} else if case let .channel(channel) = peer, case .broadcast = channel.info, channel.hasPermission(.addAdmins) {
canManage = true
}
if !canManage {
enabled = false
}
}
if !enabled {
if filter.contains(.excludeDisabled) || !isJoined {
return (false, false, .generic)
} else {
return (true, false, .generic)
}
}
return (true, true, .generic)
}
private struct CommunityViewRequestRow: Equatable {
let request: CommunityPeerRequest
let peer: EnginePeer
let requestedBy: EnginePeer?
let memberCount: Int32?
let isPrivate: Bool
let isVisible: Bool
}
private struct PendingCommunityViewRequestAction {
let request: CommunityPeerRequest
let approve: Bool
var isCommitted: Bool
}
private final class CommunityViewPendingAccessoryComponent: Component {
let count: Int32?
let theme: PresentationTheme
init(count: Int32?, theme: PresentationTheme) {
self.count = count
self.theme = theme
}
static func ==(lhs: CommunityViewPendingAccessoryComponent, rhs: CommunityViewPendingAccessoryComponent) -> Bool {
if lhs.count != rhs.count {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private let text = ComponentView<Empty>()
private let badgeBackgroundView = UIView()
private let chevronView = UIImageView()
private var component: CommunityViewPendingAccessoryComponent?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.badgeBackgroundView)
self.addSubview(self.chevronView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: CommunityViewPendingAccessoryComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
self.component = component
if themeUpdated || self.chevronView.image == nil {
self.chevronView.image = PresentationResourcesItemList.disclosureArrowImage(component.theme)
}
let countValue = component.count.flatMap { $0 > 0 ? Int($0) : nil }
var textSize = CGSize()
if let countValue {
textSize = self.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "\(countValue)",
font: Font.medium(12.0),
textColor: component.theme.list.itemCheckColors.foregroundColor
)),
horizontalAlignment: .center,
verticalAlignment: .middle
)),
environment: {},
containerSize: CGSize(width: 80.0, height: 20.0)
)
if let textView = self.text.view {
if textView.superview == nil {
self.insertSubview(textView, aboveSubview: self.badgeBackgroundView)
}
textView.isHidden = false
textView.backgroundColor = nil
}
self.badgeBackgroundView.isHidden = false
self.badgeBackgroundView.backgroundColor = component.theme.list.itemCheckColors.fillColor
self.badgeBackgroundView.layer.cornerRadius = 10.0
self.badgeBackgroundView.clipsToBounds = true
} else {
self.text.view?.isHidden = true
self.badgeBackgroundView.isHidden = true
}
let chevronSize = self.chevronView.image?.size ?? CGSize(width: 8.0, height: 13.0)
let spacing: CGFloat = countValue == nil ? 0.0 : 4.0
let size = CGSize(width: textSize.width + spacing + chevronSize.width - 9.0, height: max(20.0, chevronSize.height))
var currentX: CGFloat = 0.0
if countValue != nil {
let badgeBackgroundSize = CGSize(width: max(20.0, textSize.width), height: 20.0)
transition.setFrame(view: self.badgeBackgroundView, frame: CGRect(
origin: CGPoint(x: currentX + textSize.width * 0.5 - badgeBackgroundSize.width * 0.5, y: floor((size.height - badgeBackgroundSize.height) * 0.5)),
size: badgeBackgroundSize
))
if let textView = self.text.view {
transition.setFrame(view: textView, frame: CGRect(
origin: CGPoint(x: currentX, y: floor((size.height - textSize.height) * 0.5)),
size: textSize
))
}
currentX += textSize.width + spacing
}
transition.setFrame(view: self.chevronView, frame: CGRect(
origin: CGPoint(x: currentX, y: floor((size.height - chevronSize.height) * 0.5)),
size: chevronSize
))
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
private func communityChatPreviewSignal(context: AccountContext, peerId: EnginePeer.Id) -> Signal<(EnginePeer.Id, CommunityChatPreviewData), NoError> {
var ignoredNamespaces = Set<Int32>()
ignoredNamespaces = ignoredNamespaces.union(Namespaces.Message.allNonRegular)
ignoredNamespaces = ignoredNamespaces.union(Namespaces.Message.allEphemeral)
return combineLatest(
context.account.postbox.aroundMessageHistoryViewForLocation(
.peer(peerId: peerId, threadId: nil),
anchor: .upperBound,
ignoreMessagesInTimestampRange: nil,
ignoreMessageIds: Set(),
count: 10,
clipHoles: false,
fixedCombinedReadStates: nil,
topTaggedMessageIdNamespaces: Set(),
tag: nil,
appendMessagesFromTheSameGroup: false,
namespaces: .not(ignoredNamespaces),
orderStatistics: []
),
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.PeerReadCounters(id: peerId)),
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)),
context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peerId)),
context.engine.data.subscribe(TelegramEngine.EngineData.Item.NotificationSettings.Global())
)
|> map { viewData, readCounters, peer, notificationSettings, globalNotificationSettings -> (EnginePeer.Id, CommunityChatPreviewData) in
let (view, _, _) = viewData
var messages: [EngineMessage] = []
for i in (0 ..< view.entries.count).reversed() {
if messages.isEmpty {
messages.append(EngineMessage(view.entries[i].message))
} else if messages[0].groupingKey != nil && messages[0].groupingKey == view.entries[i].message.groupingKey {
messages.append(EngineMessage(view.entries[i].message))
}
}
messages = messages.reversed()
var isMuted = false
if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) {
isMuted = true
} else if case .default = notificationSettings.muteState, let peer {
if case .user = peer {
isMuted = !globalNotificationSettings.privateChats.enabled
} else if case .legacyGroup = peer {
isMuted = !globalNotificationSettings.groupChats.enabled
} else if case let .channel(channel) = peer {
switch channel.info {
case .group:
isMuted = !globalNotificationSettings.groupChats.enabled
case .broadcast:
isMuted = !globalNotificationSettings.channels.enabled
}
}
}
let effectiveReadCounters = EnginePeerReadCounters(state: readCounters._asReadCounters(), isMuted: isMuted)
return (peerId, CommunityChatPreviewData(messages: messages, readCounters: effectiveReadCounters))
}
}
private func communityChatPreviewsSignal(context: AccountContext, peerIds: [EnginePeer.Id]) -> Signal<[EnginePeer.Id: CommunityChatPreviewData], NoError> {
if peerIds.isEmpty {
return .single([:])
}
return combineLatest(peerIds.map { peerId in
return communityChatPreviewSignal(context: context, peerId: peerId)
})
|> map { values -> [EnginePeer.Id: CommunityChatPreviewData] in
var result: [EnginePeer.Id: CommunityChatPreviewData] = [:]
for (peerId, preview) in values {
result[peerId] = preview
}
return result
}
}
private func communityChatContextMenuItems(context: AccountContext, peer: EnginePeer, canMute: Bool, canRemove: Bool, removePeer: @escaping () -> Void) -> Signal<[ContextMenuItem], NoError> {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let strings = presentationData.strings
let peerId = peer.id
let removeItem: () -> ContextMenuItem = {
return .action(ContextMenuActionItem(text: strings.GroupInfo_ActionRemove, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { c, f in
if let c {
c.dismiss(completion: {
removePeer()
})
} else {
f(.dismissWithoutContent)
removePeer()
}
}))
}
if !canMute {
var items: [ContextMenuItem] = []
if canRemove {
items.append(removeItem())
}
return .single(items)
}
return context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peerId),
TelegramEngine.EngineData.Item.NotificationSettings.Global()
)
|> map { notificationSettings, globalNotificationSettings -> [ContextMenuItem] in
var isMuted = false
if case let .muted(until) = notificationSettings.muteState, until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) {
isMuted = true
} else if case .default = notificationSettings.muteState {
if case .user = peer {
isMuted = !globalNotificationSettings.privateChats.enabled
} else if case .legacyGroup = peer {
isMuted = !globalNotificationSettings.groupChats.enabled
} else if case let .channel(channel) = peer {
switch channel.info {
case .group:
isMuted = !globalNotificationSettings.groupChats.enabled
case .broadcast:
isMuted = !globalNotificationSettings.channels.enabled
}
}
}
var items: [ContextMenuItem] = []
if canMute {
items.append(.action(ContextMenuActionItem(text: isMuted ? strings.ChatList_Context_Unmute : strings.ChatList_Context_Mute, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isMuted ? "Chat/Context Menu/Unmute" : "Chat/Context Menu/Muted"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
let _ = (context.engine.peers.togglePeerMuted(peerId: peerId, threadId: nil)
|> deliverOnMainQueue).startStandalone(completed: {
f(.default)
})
})))
}
if canRemove {
items.append(removeItem())
}
return items
}
}
private func communityChatPeerIsCreatedByAccount(_ peer: EnginePeer) -> Bool {
switch peer {
case let .legacyGroup(group):
if case .creator = group.role {
return true
} else {
return false
}
case let .channel(channel):
return channel.flags.contains(.isCreator)
default:
return false
}
}
private func communityChatCanRemovePeer(community: TelegramCommunity?, peer: EnginePeer) -> Bool {
if community?.hasPermission(.manageLinkedPeers) == true {
return true
}
return communityChatPeerIsCreatedByAccount(peer)
}
private func communityCachedMemberCounts(_ data: [EnginePeer.Id: CachedPeerData]) -> [EnginePeer.Id: Int32] {
var result: [EnginePeer.Id: Int32] = [:]
for (peerId, cachedData) in data {
if let cachedData = cachedData as? CachedChannelData, let count = cachedData.participantsSummary.memberCount {
result[peerId] = count
} else if let cachedData = cachedData as? CachedGroupData {
result[peerId] = Int32(cachedData.participants?.participants.count ?? 0)
}
}
return result
}
private func communityChatListContextActionsEqual(_ lhs: ChatListItem.EnabledContextActions?, _ rhs: ChatListItem.EnabledContextActions?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
return true
case (.auto?, .auto?):
return true
case let (.custom(lhsActions)?, .custom(rhsActions)?):
return lhsActions.rawValue == rhsActions.rawValue
default:
return false
}
}
private final class CommunityChatListItemGenerator: ListItemComponentAdaptor.ItemGenerator {
let context: AccountContext
let presentationData: ChatListPresentationData
let peer: EnginePeer
let preview: CommunityChatPreviewData
let interaction: ChatListNodeInteraction
let enabledContextActions: ChatListItem.EnabledContextActions?
let hasActiveRevealControls: Bool
let isHidden: Bool
let hasNext: Bool
init(
context: AccountContext,
presentationData: ChatListPresentationData,
peer: EnginePeer,
preview: CommunityChatPreviewData,
interaction: ChatListNodeInteraction,
enabledContextActions: ChatListItem.EnabledContextActions?,
hasActiveRevealControls: Bool,
isHidden: Bool,
hasNext: Bool
) {
self.context = context
self.presentationData = presentationData
self.peer = peer
self.preview = preview
self.interaction = interaction
self.enabledContextActions = enabledContextActions
self.hasActiveRevealControls = hasActiveRevealControls
self.isHidden = isHidden
self.hasNext = hasNext
}
static func ==(lhs: CommunityChatListItemGenerator, rhs: CommunityChatListItemGenerator) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.preview != rhs.preview {
return false
}
if !communityChatListContextActionsEqual(lhs.enabledContextActions, rhs.enabledContextActions) {
return false
}
if lhs.hasActiveRevealControls != rhs.hasActiveRevealControls {
return false
}
if lhs.isHidden != rhs.isHidden {
return false
}
if lhs.hasNext != rhs.hasNext {
return false
}
if lhs.presentationData.theme !== rhs.presentationData.theme {
return false
}
if lhs.presentationData.fontSize != rhs.presentationData.fontSize {
return false
}
if lhs.presentationData.strings !== rhs.presentationData.strings {
return false
}
if lhs.presentationData.dateTimeFormat != rhs.presentationData.dateTimeFormat {
return false
}
if lhs.presentationData.nameSortOrder != rhs.presentationData.nameSortOrder {
return false
}
if lhs.presentationData.nameDisplayOrder != rhs.presentationData.nameDisplayOrder {
return false
}
if lhs.presentationData.disableAnimations != rhs.presentationData.disableAnimations {
return false
}
return true
}
func item() -> ListViewItem {
let messageIndex: EngineMessage.Index
if let message = self.preview.messages.first {
messageIndex = message.index
} else {
messageIndex = EngineMessage.Index(
id: EngineMessage.Id(peerId: self.peer.id, namespace: Namespaces.Message.Cloud, id: 0),
timestamp: 0
)
}
return ChatListItem(
presentationData: self.presentationData,
context: self.context,
chatListLocation: .chatList(groupId: .root),
filterData: nil,
index: .chatList(EngineChatListIndex(pinningIndex: nil, messageIndex: messageIndex)),
content: .peer(ChatListItemContent.PeerData(
messages: self.preview.messages,
peer: EngineRenderedPeer(peer: self.peer),
threadInfo: nil,
combinedReadState: self.preview.readCounters,
isRemovedFromTotalUnreadCount: self.preview.readCounters.isMuted,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
hasUnseenPollVotes: false,
draftState: nil,
mediaDraftContentType: nil,
inputActivities: [],
promoInfo: nil,
ignoreUnreadBadge: false,
displayAsMessage: false,
hasFailedMessages: false,
forumTopicData: nil,
topForumTopicItems: [],
autoremoveTimeout: nil,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false,
tags: []
)),
editing: false,
hasActiveRevealControls: self.hasActiveRevealControls,
selected: false,
header: nil,
enabledContextActions: self.enabledContextActions,
hiddenOffset: false,
interaction: self.interaction,
useCommunityViewLayout: true,
displayHiddenPeerIcon: self.isHidden
)
}
}
private final class CommunityViewBottomButtonComponent: Component {
let theme: PresentationTheme
let title: String
let iconName: String?
let safeInsets: UIEdgeInsets
let isEnabled: Bool
let displaysProgress: Bool
let action: () -> Void
init(
theme: PresentationTheme,
title: String,
iconName: String?,
safeInsets: UIEdgeInsets,
isEnabled: Bool,
displaysProgress: Bool,
action: @escaping () -> Void
) {
self.theme = theme
self.title = title
self.iconName = iconName
self.safeInsets = safeInsets
self.isEnabled = isEnabled
self.displaysProgress = displaysProgress
self.action = action
}
static func ==(lhs: CommunityViewBottomButtonComponent, rhs: CommunityViewBottomButtonComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.iconName != rhs.iconName {
return false
}
if lhs.safeInsets != rhs.safeInsets {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.displaysProgress != rhs.displaysProgress {
return false
}
return true
}
final class View: UIView {
private let button = ComponentView<Empty>()
func update(component: CommunityViewBottomButtonComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
var buttonItems: [AnyComponentWithIdentity<Empty>] = []
if let iconName = component.iconName {
buttonItems.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(
name: iconName,
tintColor: component.theme.list.itemCheckColors.foregroundColor
))))
}
buttonItems.append(AnyComponentWithIdentity(id: "title", component: AnyComponent(Text(
text: component.title,
font: Font.semibold(17.0),
color: component.theme.list.itemCheckColors.foregroundColor
))))
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: component.theme.list.itemCheckColors.fillColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 26.0
),
content: AnyComponentWithIdentity(id: component.title, component: AnyComponent(HStack(buttonItems, spacing: 8.0))),
isEnabled: component.isEnabled,
displaysProgress: component.displaysProgress,
action: component.action
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 52.0)
)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: CGRect(
origin: CGPoint(x: 0.0, y: 0.0),
size: CGSize(width: availableSize.width, height: buttonSize.height)
))
}
return CGSize(width: availableSize.width, height: buttonSize.height)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
private final class CommunityViewContentComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let communityId: EnginePeer.Id
let mode: CommunityViewScreenMode
let topInset: CGFloat
let community: TelegramCommunity?
let cachedData: CachedCommunityData?
let peers: [EnginePeer.Id: EnginePeer]
let cachedPeerData: [EnginePeer.Id: CachedPeerData]
let previews: [EnginePeer.Id: CommunityChatPreviewData]
let pendingRequests: CommunityPeerLinkRequests?
let pendingRequestCachedPeerData: [EnginePeer.Id: CachedPeerData]
let pendingRequestInFlightPeerId: EnginePeer.Id?
let pendingRequestInFlightApprove: Bool?
let joinedPeerIds: Set<EnginePeer.Id>
let selectionOptions: CommunityPeerSelectionOptions?
let toggleCollapsed: (Bool) -> Void
let setRequestApproval: (CommunityPeerRequest, EnginePeer, Bool) -> Void
let openPeer: (EnginePeer) -> Void
let openPendingRequests: () -> Void
let removePeer: (EnginePeer.Id) -> Void
init(
context: AccountContext,
communityId: EnginePeer.Id,
mode: CommunityViewScreenMode,
topInset: CGFloat,
community: TelegramCommunity?,
cachedData: CachedCommunityData?,
peers: [EnginePeer.Id: EnginePeer],
cachedPeerData: [EnginePeer.Id: CachedPeerData],
previews: [EnginePeer.Id: CommunityChatPreviewData],
pendingRequests: CommunityPeerLinkRequests?,
pendingRequestCachedPeerData: [EnginePeer.Id: CachedPeerData],
pendingRequestInFlightPeerId: EnginePeer.Id?,
pendingRequestInFlightApprove: Bool?,
joinedPeerIds: Set<EnginePeer.Id>,
selectionOptions: CommunityPeerSelectionOptions?,
toggleCollapsed: @escaping (Bool) -> Void,
setRequestApproval: @escaping (CommunityPeerRequest, EnginePeer, Bool) -> Void,
openPeer: @escaping (EnginePeer) -> Void,
openPendingRequests: @escaping () -> Void,
removePeer: @escaping (EnginePeer.Id) -> Void
) {
self.context = context
self.communityId = communityId
self.mode = mode
self.topInset = topInset
self.community = community
self.cachedData = cachedData
self.peers = peers
self.cachedPeerData = cachedPeerData
self.previews = previews
self.pendingRequests = pendingRequests
self.pendingRequestCachedPeerData = pendingRequestCachedPeerData
self.pendingRequestInFlightPeerId = pendingRequestInFlightPeerId
self.pendingRequestInFlightApprove = pendingRequestInFlightApprove
self.joinedPeerIds = joinedPeerIds
self.selectionOptions = selectionOptions
self.toggleCollapsed = toggleCollapsed
self.setRequestApproval = setRequestApproval
self.openPeer = openPeer
self.openPendingRequests = openPendingRequests
self.removePeer = removePeer
}
static func ==(lhs: CommunityViewContentComponent, rhs: CommunityViewContentComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.communityId != rhs.communityId {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.topInset != rhs.topInset {
return false
}
if lhs.community != rhs.community {
return false
}
if lhs.cachedData !== rhs.cachedData {
return false
}
if lhs.peers != rhs.peers {
return false
}
if communityCachedMemberCounts(lhs.cachedPeerData) != communityCachedMemberCounts(rhs.cachedPeerData) {
return false
}
if lhs.previews != rhs.previews {
return false
}
if lhs.pendingRequests != rhs.pendingRequests {
return false
}
if communityCachedMemberCounts(lhs.pendingRequestCachedPeerData) != communityCachedMemberCounts(rhs.pendingRequestCachedPeerData) {
return false
}
if lhs.pendingRequestInFlightPeerId != rhs.pendingRequestInFlightPeerId {
return false
}
if lhs.pendingRequestInFlightApprove != rhs.pendingRequestInFlightApprove {
return false
}
if lhs.joinedPeerIds != rhs.joinedPeerIds {
return false
}
if lhs.selectionOptions !== rhs.selectionOptions {
return false
}
return true
}
final class View: UIView {
private let collapseSection = ComponentView<Empty>()
private let collapseFooter = ComponentView<Empty>()
private let pendingRequestsSection = ComponentView<Empty>()
private var sectionViews: [CommunityViewSection: ComponentView<Empty>] = [:]
private var component: CommunityViewContentComponent?
private weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private var interaction: ChatListNodeInteraction?
private var revealedPeerId: EnginePeer.Id?
private let pendingRequestsIcon = renderSettingsIcon(name: "Item List/Icons/Requests", backgroundColors: [UIColor(rgb: 0x0079ff)])
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func presentRemovePeerConfirmation(peerId: EnginePeer.Id) {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
return
}
controller.present(textAlertController(
context: component.context,
title: environment.strings.Community_RemoveChat_Title,
text: environment.strings.Community_RemoveChat_Text,
actions: [
TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}),
TextAlertAction(type: .defaultDestructiveAction, title: environment.strings.Community_RemoveChat_Remove, action: {
component.removePeer(peerId)
})
]
), in: .window(.root))
}
private func presentHiddenChatToast() {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
controller.present(UndoOverlayController(
presentationData: presentationData,
content: .banned(text: presentationData.strings.Community_View_HiddenChatToast),
position: .bottom,
animateInAsReplacement: false,
action: { _ in return true }
), in: .current)
}
private func activateChatPreview(item: ChatListItem, sourceNode: ASDisplayNode, gesture: ContextGesture?) {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
gesture?.cancel()
return
}
guard case let .peer(peerData) = item.content, let peer = peerData.peer.peer else {
gesture?.cancel()
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
var dismissPreviewingImpl: ((Bool) -> (() -> Void))?
let previewController: ViewController
if case let .channel(channel) = peer, channel.isForum {
let chatListController = ChatListControllerImpl(context: component.context, location: .forum(peerId: channel.id), controlsHistoryPreload: false, hideNetworkActivityStatus: true, previewing: true, enableDebugActions: false)
chatListController.navigationPresentation = .master
previewController = chatListController
} else {
let chatController = component.context.sharedContext.makeChatController(context: component.context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil)
chatController.customNavigationController = controller.navigationController as? NavigationController
chatController.canReadHistory.set(false)
chatController.dismissPreviewing = { animateIn in
return dismissPreviewingImpl?(animateIn) ?? {}
}
previewController = chatController
}
let source: ContextContentSource = .controller(CommunityChatPreviewContextContentSource(
controller: previewController,
sourceNode: sourceNode,
navigationController: controller.navigationController as? NavigationController
))
let contextController = makeContextController(context: component.context, presentationData: presentationData, source: source, items: communityChatContextMenuItems(
context: component.context,
peer: peer,
canMute: component.joinedPeerIds.contains(peer.id),
canRemove: communityChatCanRemovePeer(community: component.community, peer: peer),
removePeer: { [weak self] in
self?.presentRemovePeerConfirmation(peerId: peer.id)
}
) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
controller.presentInGlobalOverlay(contextController)
dismissPreviewingImpl = { [weak controller, weak contextController] animateIn in
if let controller, let contextController {
if animateIn {
contextController.statusBar.statusBarStyle = .Ignore
contextController.animateDismissalIfNeeded()
controller.present(contextController, in: .window(.root))
return {
contextController.dismissNow()
}
} else {
contextController.dismiss()
}
}
return {}
}
}
private func makeInteraction(component: CommunityViewContentComponent) -> ChatListNodeInteraction {
if let current = self.interaction {
return current
}
let interaction = ChatListNodeInteraction(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
activateSearch: {},
peerSelected: { [weak self] peer, _, _, _, _ in
self?.component?.openPeer(peer)
},
disabledPeerSelected: { _, _, _ in },
togglePeerSelected: { _, _ in },
togglePeersSelection: { _, _ in },
additionalCategorySelected: { _ in },
messageSelected: { _, _, _, _ in },
groupSelected: { _ in },
addContact: { _ in },
setPeerIdWithRevealedOptions: { [weak self] peerId, fromPeerId in
guard let self else {
return
}
if (peerId == nil && fromPeerId == self.revealedPeerId) || (peerId != nil && fromPeerId == nil) || (peerId == nil && fromPeerId == nil) {
self.revealedPeerId = peerId
self.state?.updated(transition: .immediate)
}
},
setItemPinned: { _, _ in },
setPeerMuted: { [weak self] peerId, _ in
guard let self, let component = self.component else {
return
}
let _ = (component.context.engine.peers.togglePeerMuted(peerId: peerId, threadId: nil)
|> deliverOnMainQueue).startStandalone(completed: { [weak self] in
guard let self else {
return
}
self.revealedPeerId = nil
self.state?.updated(transition: .immediate)
})
},
setPeerThreadMuted: { _, _, _ in },
deletePeer: { [weak self] peerId, _ in
self?.presentRemovePeerConfirmation(peerId: peerId)
},
deletePeerThread: { _, _ in },
setPeerThreadStopped: { _, _, _ in },
setPeerThreadPinned: { _, _, _ in },
setPeerThreadHidden: { _, _, _ in },
updatePeerGrouping: { _, _ in },
togglePeerMarkedUnread: { _, _ in },
toggleArchivedFolderHiddenByDefault: {},
toggleThreadsSelection: { _, _ in },
hidePsa: { _ in },
activateChatPreview: { [weak self] item, _, node, gesture, _ in
self?.activateChatPreview(item: item, sourceNode: node, gesture: gesture)
},
present: { _ in },
openForumThread: { _, _ in },
openStorageManagement: {},
openPasswordSetup: {},
openPremiumIntro: {},
openPremiumGift: { _, _ in },
openPremiumManagement: {},
openActiveSessions: {},
openBirthdaySetup: {},
performActiveSessionAction: { _, _ in },
performBotConnectionReviewAction: { _, _ in },
openChatFolderUpdates: {},
hideChatFolderUpdates: {},
openStories: { _, _ in },
openStarsTopup: { _ in },
editPeer: { _ in },
openWebApp: { _ in },
openPhotoSetup: {},
openAdInfo: { _, _ in },
openAccountFreezeInfo: {},
openUrl: { _ in }
)
self.interaction = interaction
return interaction
}
private func isBot(peer: EnginePeer) -> Bool {
if case let .user(user) = peer, user.botInfo != nil {
return true
}
return false
}
private func botHasPreview(peerId: EnginePeer.Id, component: CommunityViewContentComponent) -> Bool {
return component.previews[peerId]?.messages.isEmpty == false
}
private func rows(component: CommunityViewContentComponent, section: CommunityViewSection) -> [CommunityViewRow] {
guard let cachedData = component.cachedData else {
return []
}
var result: [CommunityViewRow] = []
for linkedPeer in cachedData.linkedPeers {
guard let peer = component.peers[linkedPeer.peerId] else {
continue
}
var isJoined = false
if case let .channel(channel) = peer, case .member = channel.participationStatus {
isJoined = true
}
let isPrivate = peer.addressName == nil
let isHidden = linkedPeer.visible == false
let canView = linkedPeer.canViewHistory
let isBot = self.isBot(peer: peer)
let botHasPreview = isBot && self.botHasPreview(peerId: peer.id, component: component)
if let selectionOptions = component.selectionOptions {
let selectionState = communitySelectionPeerState(peer: peer, isJoined: isJoined, options: selectionOptions)
if !selectionState.isVisible {
continue
}
}
switch section {
case .joined:
if isJoined || botHasPreview {
result.append(CommunityViewRow(peer: peer, linkedPeer: linkedPeer))
}
case .visible:
if !isJoined && !isBot && canView {
result.append(CommunityViewRow(peer: peer, linkedPeer: linkedPeer))
}
case .requestable:
if !isJoined && !isBot && !canView && !isHidden {
result.append(CommunityViewRow(peer: peer, linkedPeer: linkedPeer))
}
if isBot && !botHasPreview {
result.append(CommunityViewRow(peer: peer, linkedPeer: linkedPeer))
}
case .hidden:
if !isJoined && !isBot && isPrivate && isHidden {
result.append(CommunityViewRow(peer: peer, linkedPeer: linkedPeer))
}
}
}
return result.enumerated().sorted { lhs, rhs in
let lhsTimestamp = component.previews[lhs.element.peer.id]?.timestamp ?? 0
let rhsTimestamp = component.previews[rhs.element.peer.id]?.timestamp ?? 0
if lhsTimestamp != rhsTimestamp {
return lhsTimestamp > rhsTimestamp
}
return lhs.offset < rhs.offset
}.map(\.element)
}
private func memberCountString(component: CommunityViewContentComponent, peerId: EnginePeer.Id, presentationData: PresentationData) -> String? {
guard let count = communityCachedMemberCounts(component.cachedPeerData)[peerId] else {
return nil
}
if count > 0 {
return presentationData.strings.Conversation_StatusMembers(count)
}
return nil
}
private func memberCount(peerId: EnginePeer.Id, cachedPeerData: [EnginePeer.Id: CachedPeerData]) -> Int32? {
guard let cachedData = cachedPeerData[peerId] else {
return nil
}
if let cachedData = cachedData as? CachedChannelData {
return cachedData.participantsSummary.memberCount
} else if let cachedData = cachedData as? CachedGroupData, let participants = cachedData.participants {
return Int32(participants.participants.count)
} else {
return nil
}
}
private func pendingRequestRows(component: CommunityViewContentComponent) -> [CommunityViewRequestRow] {
guard let pendingRequests = component.pendingRequests else {
return []
}
var result: [CommunityViewRequestRow] = []
for request in pendingRequests.requests {
if let peer = pendingRequests.peers[request.peerId] {
var isPrivate = true
if let addressName = peer.addressName, !addressName.isEmpty {
isPrivate = false
}
result.append(CommunityViewRequestRow(
request: request,
peer: peer,
requestedBy: pendingRequests.peers[request.requestedBy],
memberCount: self.memberCount(peerId: request.peerId, cachedPeerData: component.pendingRequestCachedPeerData),
isPrivate: isPrivate,
isVisible: request.isVisible
))
}
}
return result
}
private func openRequest(row: CommunityViewRequestRow) {
if !row.isPrivate {
self.component?.openPeer(row.peer)
return
}
guard let component = self.component, let environment = self.environment else {
return
}
let controller = CommunityPrivateChatScreen(
context: component.context,
chatPeer: row.peer,
requestedByPeer: row.requestedBy,
memberCount: row.memberCount,
messageOwner: { [weak self, requestedBy = row.requestedBy] in
if let requestedBy {
self?.component?.openPeer(requestedBy)
}
}
)
environment.controller()?.present(controller, in: .window(.root))
}
private func sectionTitle(_ section: CommunityViewSection, presentationData: PresentationData) -> String {
switch section {
case .joined:
return presentationData.strings.Community_View_SectionJoined
case .visible:
return presentationData.strings.Community_View_SectionVisible
case .requestable:
return presentationData.strings.Community_View_SectionRequestable
case .hidden:
return presentationData.strings.Community_View_SectionHidden
}
}
private func updateSection(
component: CommunityViewContentComponent,
section: CommunityViewSection,
rows: [CommunityViewRow],
theme: PresentationTheme,
presentationData: PresentationData,
availableWidth: CGFloat,
transition: ComponentTransition
) -> CGSize {
let sectionView: ComponentView<Empty>
if let current = self.sectionViews[section] {
sectionView = current
} else {
sectionView = ComponentView<Empty>()
self.sectionViews[section] = sectionView
}
var items: [AnyComponentWithIdentity<Empty>] = []
let chatListPresentationData = ChatListPresentationData(
theme: theme,
fontSize: presentationData.listsFontSize,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
nameSortOrder: presentationData.nameSortOrder,
nameDisplayOrder: presentationData.nameDisplayOrder,
disableAnimations: true
)
let interaction = self.makeInteraction(component: component)
for index in rows.indices {
let row = rows[index]
if let selectionOptions = component.selectionOptions {
let isJoined = component.joinedPeerIds.contains(row.peer.id)
let selectionState = communitySelectionPeerState(peer: row.peer, isJoined: isJoined, options: selectionOptions)
guard selectionState.isVisible else {
continue
}
let subtitle = self.memberCountString(component: component, peerId: row.peer.id, presentationData: presentationData).flatMap {
PeerListItemComponent.Subtitle(text: $0, color: .neutral)
}
items.append(AnyComponentWithIdentity(id: row.peer.id, component: AnyComponent(PeerListItemComponent(
context: component.context,
theme: theme,
strings: presentationData.strings,
style: .list,
sideInset: 0.0,
title: row.peer.compactDisplayTitle,
peer: row.peer,
subtitle: subtitle,
subtitleAccessory: .none,
presence: nil,
rightAccessory: .none,
selectionState: .none,
isEnabled: selectionState.isEnabled,
hasNext: false,
extractedTheme: PeerListItemComponent.ExtractedTheme(
inset: 2.0,
background: component.mode.usesPlainStyle ? theme.chatList.itemBackgroundColor : theme.list.itemBlocksBackgroundColor
),
insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0),
action: { peer, _, _ in
if selectionState.isEnabled {
component.openPeer(peer)
} else {
selectionOptions.selectDisabledPeer(peer, selectionState.disabledReason)
}
}
))))
continue
}
switch section {
case .joined, .visible:
let preview = component.previews[row.peer.id] ?? CommunityChatPreviewData(messages: [], readCounters: EnginePeerReadCounters())
var enabledContextActions: ChatListItem.EnabledContextActions.Actions = []
if component.joinedPeerIds.contains(row.peer.id) {
enabledContextActions.formUnion(.toggleMuted)
}
if communityChatCanRemovePeer(community: component.community, peer: row.peer) {
enabledContextActions.formUnion(.remove)
}
let communityChatAvatarDiameter = min(60.0, floor(presentationData.listsFontSize.baseDisplaySize * 60.0 / 17.0))
let communityChatSeparatorInset = 10.0 + 8.0 + communityChatAvatarDiameter + 2.0
let hasActiveRevealControls = self.revealedPeerId == row.peer.id
let nextHasActiveRevealControls = index + 1 < rows.count && self.revealedPeerId == rows[index + 1].peer.id
let communityChatSeparatorAlpha: CGFloat = (hasActiveRevealControls || nextHasActiveRevealControls) ? 0.0 : 1.0
items.append(AnyComponentWithIdentity(id: row.peer.id, component: AnyComponent(ListItemComponentAdaptor(
itemGenerator: CommunityChatListItemGenerator(
context: component.context,
presentationData: chatListPresentationData,
peer: row.peer,
preview: preview,
interaction: interaction,
enabledContextActions: enabledContextActions.isEmpty ? nil : .custom(enabledContextActions),
hasActiveRevealControls: hasActiveRevealControls,
isHidden: row.linkedPeer.visible == false,
hasNext: false //index != rows.count - 1
),
params: ListViewItemLayoutParams(
width: availableWidth,
leftInset: 0.0,
rightInset: 0.0,
availableHeight: 10000.0,
isStandalone: true
),
separatorInset: communityChatSeparatorInset,
separatorAlpha: communityChatSeparatorAlpha,
action: { [weak self] in
guard let self, self.revealedPeerId == nil else {
return
}
self.component?.openPeer(row.peer)
},
actionMode: .gesture
))))
case .requestable, .hidden:
let subtitle = self.memberCountString(component: component, peerId: row.peer.id, presentationData: presentationData).flatMap {
PeerListItemComponent.Subtitle(text: $0, color: .neutral)
}
items.append(AnyComponentWithIdentity(id: row.peer.id, component: AnyComponent(PeerListItemComponent(
context: component.context,
theme: theme,
strings: presentationData.strings,
style: .list,
sideInset: 0.0,
title: row.peer.compactDisplayTitle,
titleAccessory: row.linkedPeer.visible == false ? .hidden : .none,
peer: row.peer,
subtitle: subtitle,
subtitleAccessory: .none,
presence: nil,
rightAccessory: .disclosure,
selectionState: .none,
isEnabled: true,
hasNext: false,
extractedTheme: PeerListItemComponent.ExtractedTheme(
inset: 2.0,
background: component.mode.usesPlainStyle ? theme.chatList.itemBackgroundColor : theme.list.itemBlocksBackgroundColor
),
insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0),
action: { [weak self] peer, _, _ in
switch section {
case .requestable:
component.openPeer(peer)
case .hidden:
self?.presentHiddenChatToast()
default:
break
}
}
))))
}
}
return sectionView.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: component.mode.usesPlainStyle ? .plain : .glass,
header: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: self.sectionTitle(section, presentationData: presentationData),
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 1
)),
footer: nil,
items: items
)),
environment: {},
containerSize: CGSize(width: availableWidth, height: 10000.0)
)
}
private func updatePendingRequestsSection(
component: CommunityViewContentComponent,
theme: PresentationTheme,
presentationData: PresentationData,
availableWidth: CGFloat,
transition: ComponentTransition
) -> CGSize? {
let totalCount: Int32
if let pendingRequests = component.pendingRequests {
totalCount = pendingRequests.totalCount
} else {
totalCount = component.cachedData?.pendingRequests ?? 0
}
guard totalCount > 0 else {
return nil
}
var items: [AnyComponentWithIdentity<Empty>] = []
let rows = self.pendingRequestRows(component: component)
if totalCount == 1 {
guard let row = rows.first else {
return nil
}
let isInFlight = component.pendingRequestInFlightPeerId == row.request.peerId
items.append(AnyComponentWithIdentity(id: row.request.peerId, component: AnyComponent(CommunityRequestItemComponent(
context: component.context,
theme: theme,
strings: presentationData.strings,
chatPeer: row.peer,
requestedByPeer: row.requestedBy,
memberCount: row.memberCount,
isPrivate: row.isPrivate,
isVisible: row.isVisible,
isEnabled: component.pendingRequestInFlightPeerId == nil,
declineDisplaysProgress: isInFlight && component.pendingRequestInFlightApprove == false,
addDisplaysProgress: isInFlight && component.pendingRequestInFlightApprove == true,
hasNext: false,
open: { [weak self] _ in
self?.openRequest(row: row)
},
add: { _ in
component.setRequestApproval(row.request, row.peer, true)
},
decline: { _ in
component.setRequestApproval(row.request, row.peer, false)
}
))))
} else {
items.append(AnyComponentWithIdentity(id: "pendingRequests", component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_View_PendingRequests,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)),
contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0),
leftIcon: .custom(AnyComponentWithIdentity(
id: "pendingRequestsIcon",
component: AnyComponent(Image(image: self.pendingRequestsIcon, size: CGSize(width: 30.0, height: 30.0)))
), false),
accessory: .custom(ListActionItemComponent.CustomAccessory(
component: AnyComponentWithIdentity(id: "pendingRequests-accessory-\(totalCount)", component: AnyComponent(CommunityViewPendingAccessoryComponent(
count: totalCount,
theme: theme
))),
insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 16.0),
isInteractive: false
)),
action: { _ in
component.openPendingRequests()
}
))))
}
let header: AnyComponent<Empty>?
if totalCount == 1 {
header = AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_View_PendingRequestHeader,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 1
))
} else {
header = nil
}
return self.pendingRequestsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: component.mode.usesPlainStyle ? .plain : .glass,
header: header,
footer: nil,
items: items
)),
environment: {},
containerSize: CGSize(width: availableWidth, height: 10000.0)
)
}
func update(component: CommunityViewContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let environment = environment[EnvironmentType.self].value
self.environment = environment
let theme: PresentationTheme
let sideInset: CGFloat
switch component.mode {
case .sheet:
theme = environment.theme.withModalBlocksBackground()
sideInset = 16.0 + max(environment.safeInsets.left, environment.safeInsets.right)
case .fullscreen, .preview:
theme = environment.theme
sideInset = max(environment.safeInsets.left, environment.safeInsets.right)
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let sectionSpacing: CGFloat = component.mode.usesGroupedStyle ? 28.0 : 12.0
let contentWidth = availableSize.width - sideInset * 2.0
self.backgroundColor = .clear
let isAdmin = component.community?.hasPermission(.manageLinkedPeers) == true
var contentHeight: CGFloat = component.topInset + 16.0
if component.mode.usesGroupedStyle {
var transition = transition
if self.collapseSection.view == nil {
transition = .immediate
}
let collapseSectionSize = self.collapseSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: [
AnyComponentWithIdentity(id: "collapse", component: AnyComponent(ListActionItemComponent(
theme: theme,
style: .glass,
title: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_View_ShowAsOneChat,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)),
accessory: .toggle(ListActionItemComponent.Toggle(
style: .regular,
isOn: component.community?.collapsedInDialogs == true,
isInteractive: true,
isEnabled: true,
action: { [weak self] value in
self?.component?.toggleCollapsed(value)
}
)),
action: nil
)))
]
)),
environment: {},
containerSize: CGSize(width: contentWidth, height: 10000.0)
)
if let collapseSectionView = self.collapseSection.view {
if collapseSectionView.superview == nil {
self.addSubview(collapseSectionView)
}
transition.setFrame(view: collapseSectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: collapseSectionSize))
}
contentHeight += collapseSectionSize.height + 10.0
let footerSize = self.collapseFooter.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: presentationData.strings.Community_View_ShowAsOneChatInfo,
font: Font.regular(13.0),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: contentWidth - 32.0, height: 10000.0)
)
if let footerView = self.collapseFooter.view {
if footerView.superview == nil {
self.addSubview(footerView)
}
transition.setFrame(view: footerView, frame: CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: footerSize))
}
contentHeight += footerSize.height + sectionSpacing
} else {
self.collapseSection.view?.removeFromSuperview()
self.collapseFooter.view?.removeFromSuperview()
}
if isAdmin && component.selectionOptions == nil, let pendingRequestsSectionSize = self.updatePendingRequestsSection(
component: component,
theme: theme,
presentationData: presentationData,
availableWidth: contentWidth,
transition: transition
) {
if let pendingRequestsSectionView = self.pendingRequestsSection.view {
if pendingRequestsSectionView.superview == nil {
self.addSubview(pendingRequestsSectionView)
}
transition.setAlpha(view: pendingRequestsSectionView, alpha: 1.0)
transition.setFrame(view: pendingRequestsSectionView, frame: CGRect(
origin: CGPoint(x: sideInset, y: contentHeight),
size: pendingRequestsSectionSize
))
}
contentHeight += pendingRequestsSectionSize.height + sectionSpacing
} else if let pendingRequestsSectionView = self.pendingRequestsSection.view {
transition.setAlpha(view: pendingRequestsSectionView, alpha: 0.0, completion: { [weak pendingRequestsSectionView] _ in
pendingRequestsSectionView?.removeFromSuperview()
})
}
let sections: [CommunityViewSection] = component.selectionOptions != nil ? [.joined] : [.joined, .visible, .requestable, .hidden]
for section in sections {
let rows = self.rows(component: component, section: section)
guard !rows.isEmpty else {
if let sectionView = self.sectionViews[section]?.view {
transition.setAlpha(view: sectionView, alpha: 0.0, completion: { [weak sectionView] _ in
sectionView?.removeFromSuperview()
})
}
continue
}
var transition = transition
if self.sectionViews[section] == nil {
transition = .immediate
}
let size = self.updateSection(
component: component,
section: section,
rows: rows,
theme: theme,
presentationData: presentationData,
availableWidth: contentWidth,
transition: transition
)
if let sectionView = self.sectionViews[section]?.view {
if sectionView.superview == nil {
self.addSubview(sectionView)
}
transition.setFrame(view: sectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: size))
}
contentHeight += size.height + sectionSpacing
}
contentHeight += 16.0
contentHeight += 60.0
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class CommunityViewScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let communityId: EnginePeer.Id
let mode: CommunityViewScreenMode
let selectionOptions: CommunityPeerSelectionOptions?
init(context: AccountContext, communityId: EnginePeer.Id, mode: CommunityViewScreenMode, selectionOptions: CommunityPeerSelectionOptions?) {
self.context = context
self.communityId = communityId
self.mode = mode
self.selectionOptions = selectionOptions
}
static func ==(lhs: CommunityViewScreenComponent, rhs: CommunityViewScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.communityId != rhs.communityId {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.selectionOptions !== rhs.selectionOptions {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
private let sheet = ComponentView<(EnvironmentType, ResizableSheetComponentEnvironment)>()
private let sheetExternalState = ResizableSheetComponent<EnvironmentType>.ExternalState()
private let animateOut = ActionSlot<Action<()>>()
private let scrollView = ScrollView()
private let fullscreenContent = ComponentView<EnvironmentType>()
private let fullscreenBottomItem = ComponentView<Empty>()
private let bottomEdgeEffectView = EdgeEffectView()
private let navigationBarView = ComponentView<Empty>()
private let searchOverlayNode = ASDisplayNode()
private let sheetBoundsUpdated = ActionSlot<ResizableSheetComponentEnvironment.BoundsUpdate>()
private let sheetNavigationTopInset: CGFloat = 16.0
private let sheetSearchOverlayTopOffset: CGFloat = 20.0
private var component: CommunityViewScreenComponent?
private weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private var validLayout: (ContainerViewLayout, CGFloat)?
private var isSearchDisplayControllerActive: ChatListNavigationBar.ActiveSearch?
private var searchDisplayController: SearchDisplayController?
private var disappearingSearchDisplayController: SearchDisplayController?
private var currentSheetBounds: CGRect?
private var currentAvailableSize: CGSize = .zero
private var lastInactiveNavigationHeight: CGFloat?
private var sheetNavigationFrame: CGRect = .zero
private var sheetTopInset: CGFloat = 0.0
private var sheetContainerInset: CGFloat = 0.0
private var didApplyInitialOffset = false
private var community: TelegramCommunity?
private var cachedData: CachedCommunityData?
private var peers: [EnginePeer.Id: EnginePeer] = [:]
private var cachedPeerData: [EnginePeer.Id: CachedPeerData] = [:]
private var previews: [EnginePeer.Id: CommunityChatPreviewData] = [:]
private var pendingRequests: CommunityPeerLinkRequests?
private var pendingRequestsContext: CommunityPeerLinkRequestsContext?
private var pendingRequestCachedPeerData: [EnginePeer.Id: CachedPeerData] = [:]
private var pendingRequestActions: [EnginePeer.Id: PendingCommunityViewRequestAction] = [:]
private weak var currentRequestUndoOverlayController: UndoOverlayController?
private var joinedPeerIds = Set<EnginePeer.Id>()
private var isAddActionInProgress = false
private var removingPeerId: EnginePeer.Id?
private var didRequestInitialData = false
private var didRequestJoinedChats = false
private var dataDisposable: Disposable?
private let linkedPeersDisposable = MetaDisposable()
private let linkedPeerDataDisposable = MetaDisposable()
private let previewsDisposable = MetaDisposable()
private let joinedChatsDisposable = MetaDisposable()
private let actionDisposable = MetaDisposable()
private let addChatDisposable = MetaDisposable()
private let removePeerDisposable = MetaDisposable()
private let openSearchResultDisposable = MetaDisposable()
private let pendingRequestsDisposable = MetaDisposable()
private let pendingRequestCachedDataDisposable = MetaDisposable()
private var currentLinkedPeerIds: [EnginePeer.Id] = []
private var currentPendingRequestsCount: Int32?
private var currentPendingRequestCachedPeerIds: [EnginePeer.Id] = []
private var requestedPendingRequestCachedPeerIds = Set<EnginePeer.Id>()
override init(frame: CGRect) {
super.init(frame: frame)
self.scrollView.delegate = self
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.scrollsToTop = true
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.sheetBoundsUpdated.connect { [weak self] update in
guard let self else {
return
}
self.currentSheetBounds = update.bounds
self.updateSheetNavigationBarFrame(bounds: update.bounds, transition: .immediate)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.commitPendingRequestActions()
self.currentRequestUndoOverlayController?.dismiss()
self.dataDisposable?.dispose()
self.linkedPeersDisposable.dispose()
self.linkedPeerDataDisposable.dispose()
self.previewsDisposable.dispose()
self.joinedChatsDisposable.dispose()
self.actionDisposable.dispose()
self.addChatDisposable.dispose()
self.removePeerDisposable.dispose()
self.openSearchResultDisposable.dispose()
self.pendingRequestsDisposable.dispose()
self.pendingRequestCachedDataDisposable.dispose()
}
private var isAdmin: Bool {
if let community = self.community {
if community.flags.contains(.isCreator) {
return true
}
if let adminRights = self.community?.adminRights, !adminRights.rights.isEmpty {
return true
}
return false
} else {
return false
}
}
private var canAddChatsToCommunity: Bool {
guard let community = self.community else {
return false
}
if community.hasPermission(.manageLinkedPeers) {
return true
}
return community.defaultBannedRights?.flags.contains(.banManageLinkedPeers) != true
}
private var isSheetSearchFullscreen: Bool {
guard self.component?.mode.usesSheetPresentation == true else {
return false
}
return self.searchDisplayController != nil
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView === self.scrollView {
self.updateNavigationScrolling(transition: .immediate)
}
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard scrollView === self.scrollView, let targetOffset = self.snappedNavigationSearchOffset(targetContentOffset.pointee.y) else {
return
}
targetContentOffset.pointee.y = targetOffset
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard scrollView === self.scrollView, !decelerate else {
return
}
self.snapNavigationSearchOffsetIfNeeded()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
guard scrollView === self.scrollView else {
return
}
self.snapNavigationSearchOffsetIfNeeded()
}
private func snappedNavigationSearchOffset(_ offset: CGFloat) -> CGFloat? {
guard let component = self.component, component.mode.usesPlainStyle, component.mode.usesFullscreenPresentation, !component.mode.isPreview, component.selectionOptions == nil, self.isSearchDisplayControllerActive == nil else {
return nil
}
let searchScrollHeight = ChatListNavigationBar.searchScrollHeight
guard offset > 0.0 && offset < searchScrollHeight else {
return nil
}
if offset < searchScrollHeight * 0.5 {
return 0.0
} else {
return searchScrollHeight
}
}
private func snapNavigationSearchOffsetIfNeeded() {
guard let targetOffset = self.snappedNavigationSearchOffset(self.scrollView.contentOffset.y) else {
return
}
self.scrollView.setContentOffset(CGPoint(x: self.scrollView.contentOffset.x, y: targetOffset), animated: true)
}
private func updateNavigationScrolling(transition: ComponentTransition) {
guard let component = self.component else {
return
}
if component.mode.isPreview {
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.applyScroll(offset: 0.0, allowAvatarsExpansion: false, forceUpdate: false, transition: transition)
}
return
}
let rawOffset: CGFloat
switch component.mode {
case .sheet:
rawOffset = self.currentSheetBounds?.minY ?? 0.0
case .fullscreen, .preview:
rawOffset = self.scrollView.contentOffset.y
}
var offset = min(max(0.0, rawOffset), ChatListNavigationBar.searchScrollHeight)
if abs(offset) < 0.1 {
offset = 0.0
}
if component.selectionOptions != nil || self.isSearchDisplayControllerActive != nil {
offset = 0.0
}
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.applyScroll(offset: offset, allowAvatarsExpansion: false, forceUpdate: false, transition: transition.withUserData(ChatListNavigationBar.AnimationHint(
disableStoriesAnimations: false,
crossfadeStoryPeers: false
)))
}
}
private func updateLinkedPeerSignals(component: CommunityViewScreenComponent, ids: [EnginePeer.Id]) {
if self.currentLinkedPeerIds == ids {
return
}
self.currentLinkedPeerIds = ids
if ids.isEmpty {
self.peers = [:]
self.cachedPeerData = [:]
self.previews = [:]
self.linkedPeersDisposable.set(nil)
self.linkedPeerDataDisposable.set(nil)
self.previewsDisposable.set(nil)
self.state?.updated(transition: .immediate)
return
}
self.linkedPeersDisposable.set((component.context.engine.data.subscribe(
EngineDataMap(ids.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] peersById in
guard let self else {
return
}
var peers: [EnginePeer.Id: EnginePeer] = [:]
for id in ids {
if let maybePeer = peersById[id], let peer = maybePeer {
peers[id] = peer
}
}
var transition: ComponentTransition = .spring(duration: 0.35)
if self.peers.isEmpty {
transition = .immediate
}
self.peers = peers
self.state?.updated(transition: transition)
}))
self.linkedPeerDataDisposable.set((component.context.engine.data.subscribe(
EngineDataMap(ids.map(TelegramEngine.EngineData.Item.Peer.CachedData.init(id:)))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] cachedDataById in
guard let self else {
return
}
var cachedPeerData: [EnginePeer.Id: CachedPeerData] = [:]
for id in ids {
if let maybeCachedData = cachedDataById[id], let cachedData = maybeCachedData {
cachedPeerData[id] = cachedData
}
}
self.cachedPeerData = cachedPeerData
self.state?.updated(transition: .spring(duration: 0.35))
}))
self.previewsDisposable.set((communityChatPreviewsSignal(context: component.context, peerIds: ids)
|> deliverOnMainQueue).startStrict(next: { [weak self] previews in
guard let self else {
return
}
var transition: ComponentTransition = .spring(duration: 0.35)
if self.previews.isEmpty {
transition = .immediate
}
self.previews = previews
self.state?.updated(transition: transition)
}))
}
private func updatePendingRequestCachedData(component: CommunityViewScreenComponent, transition: ComponentTransition) {
let ids = self.pendingRequests?.requests.map(\.peerId) ?? []
if self.currentPendingRequestCachedPeerIds == ids {
return
}
self.currentPendingRequestCachedPeerIds = ids
if ids.isEmpty {
self.pendingRequestCachedPeerData = [:]
self.pendingRequestCachedDataDisposable.set(nil)
self.state?.updated(transition: transition)
return
}
for peerId in ids {
if !self.requestedPendingRequestCachedPeerIds.contains(peerId) {
self.requestedPendingRequestCachedPeerIds.insert(peerId)
component.context.account.viewTracker.forceUpdateCachedPeerData(peerId: peerId)
}
}
self.pendingRequestCachedDataDisposable.set((component.context.engine.data.subscribe(
EngineDataMap(ids.map(TelegramEngine.EngineData.Item.Peer.CachedData.init(id:)))
)
|> deliverOnMainQueue).startStrict(next: { [weak self] cachedDataById in
guard let self else {
return
}
var cachedPeerData: [EnginePeer.Id: CachedPeerData] = [:]
for id in ids {
if let maybeCachedData = cachedDataById[id], let cachedData = maybeCachedData {
cachedPeerData[id] = cachedData
}
}
self.pendingRequestCachedPeerData = cachedPeerData
self.state?.updated(transition: transition)
}))
}
private func updatePendingRequestsSignal(component: CommunityViewScreenComponent, force: Bool = false) {
let pendingCount: Int32
if self.isAdmin {
pendingCount = self.cachedData?.pendingRequests ?? 0
} else {
pendingCount = 0
}
if !force && self.pendingRequestsContext != nil {
self.currentPendingRequestsCount = pendingCount
return
}
self.currentPendingRequestsCount = pendingCount
let pendingRequestsLimit: Int32 = 100
let requestsContext: CommunityPeerLinkRequestsContext
if let current = self.pendingRequestsContext {
requestsContext = current
if force {
requestsContext.reload(limit: pendingRequestsLimit)
}
} else {
requestsContext = component.context.engine.peers.communityPeerLinkRequestsContext(communityId: component.communityId, initialLimit: pendingRequestsLimit)
self.pendingRequestsContext = requestsContext
}
self.pendingRequestsDisposable.set((requestsContext.state
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
var transition: ComponentTransition = .spring(duration: 0.35)
if self.pendingRequests == nil {
transition = .immediate
}
if result.hasLoadedOnce {
self.pendingRequests = CommunityPeerLinkRequests(
totalCount: result.count,
requests: result.requests,
nextOffset: nil,
peers: result.peers
)
}
self.prunePendingRequestActions()
self.updatePendingRequestCachedData(component: component, transition: transition)
self.state?.updated(transition: transition)
}))
}
private func updateJoinedChatsSignal(component: CommunityViewScreenComponent, force: Bool = false) {
if !force && self.didRequestJoinedChats {
return
}
self.didRequestJoinedChats = true
self.joinedChatsDisposable.set((component.context.engine.peers.communityParticipantJoinedChats(
communityId: component.communityId,
participantId: component.context.account.peerId
)
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
self.joinedPeerIds = Set(result.creatorChatIds + result.joinedChatIds)
self.state?.updated(transition: .immediate)
}))
}
private func ensureDataSignal(component: CommunityViewScreenComponent) {
if self.dataDisposable == nil {
self.dataDisposable = (component.context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Peer.Peer(id: component.communityId),
TelegramEngine.EngineData.Item.Peer.CachedData(id: component.communityId)
)
|> deliverOnMainQueue).startStrict(next: { [weak self] peer, cachedData in
guard let self else {
return
}
var transition: ComponentTransition = .spring(duration: 0.35)
if self.community == nil {
transition = .immediate
}
if let peer, case let .community(community) = peer {
self.community = community
} else {
self.community = nil
}
self.cachedData = cachedData as? CachedCommunityData
let ids = self.cachedData?.linkedPeers.map(\.peerId) ?? []
self.updateLinkedPeerSignals(component: component, ids: ids)
if component.selectionOptions == nil {
self.updatePendingRequestsSignal(component: component)
}
self.state?.updated(transition: transition)
})
}
if !self.didRequestInitialData {
self.didRequestInitialData = true
component.context.account.viewTracker.forceUpdateCachedPeerData(peerId: component.communityId)
}
self.updateJoinedChatsSignal(component: component)
}
private func dismiss(animated: Bool) {
guard let controller = self.environment?.controller else {
return
}
if self.component?.mode.usesFullscreenPresentation == true {
if let navigationController = controller()?.navigationController as? NavigationController {
let _ = navigationController.popViewController(animated: animated)
} else {
controller()?.dismiss(completion: nil)
}
return
}
if animated {
self.animateOut.invoke(Action { _ in
controller()?.dismiss(completion: nil)
})
} else {
controller()?.dismiss(completion: nil)
}
}
private func openEdit() {
guard let component = self.component, let environment = self.environment else {
return
}
let controller = component.context.sharedContext.makeCommunityEditScreen(context: component.context, communityId: component.communityId)
controller.navigationPresentation = .modal
environment.controller()?.push(controller)
}
private func openPendingRequests() {
guard let component = self.component, let environment = self.environment else {
return
}
let controller = component.context.sharedContext.makeCommunityRequestsScreen(context: component.context, communityId: component.communityId, existingContext: self.pendingRequestsContext)
controller.navigationPresentation = .modal
environment.controller()?.push(controller)
}
private func openPeer(_ peer: EnginePeer) {
guard let component = self.component else {
return
}
if let selectionOptions = component.selectionOptions {
self.dismiss(animated: false)
selectionOptions.selectPeer(peer)
return
}
guard let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController else {
return
}
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: component.context,
chatLocation: .peer(peer),
keepStack: .always,
forceOpenChat: true
))
}
private func openPeerFromSearch(peer: EnginePeer, threadId: Int64?, dismissSearch: Bool) {
guard let component = self.component else {
return
}
let navigationController = self.environment?.controller()?.navigationController as? NavigationController
self.openSearchResultDisposable.set((component.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer)
|> deliverOnMainQueue).startStrict(next: { [weak self] actualPeer in
guard let self, let component = self.component else {
return
}
if dismissSearch {
self.deactivateSearch(animated: true)
}
if let selectionOptions = component.selectionOptions {
let selectionState = communitySelectionPeerState(peer: actualPeer, isJoined: self.joinedPeerIds.contains(actualPeer.id), options: selectionOptions)
if selectionState.isVisible {
if selectionState.isEnabled {
self.dismiss(animated: false)
selectionOptions.selectPeer(actualPeer)
} else {
selectionOptions.selectDisabledPeer(actualPeer, selectionState.disabledReason)
}
}
return
}
guard let navigationController else {
return
}
if case let .channel(channel) = actualPeer, channel.isForumOrMonoForum, let threadId {
self.deactivateSearch(animated: false)
let _ = component.context.sharedContext.navigateToForumThread(context: component.context, peerId: actualPeer.id, threadId: threadId, messageId: nil, navigationController: navigationController, activateInput: nil, scrollToEndIfExists: false, keepStack: .never, animated: true).startStandalone()
} else {
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: component.context,
chatLocation: .peer(actualPeer),
keepStack: .always,
purposefulAction: { [weak self] in
self?.deactivateSearch(animated: false)
},
forceOpenChat: true
))
}
}))
}
private func openMessageFromSearch(peer: EnginePeer, threadId: Int64?, messageId: EngineMessage.Id, deactivateOnAction: Bool) {
if self.component?.selectionOptions != nil {
self.openPeerFromSearch(peer: peer, threadId: threadId, dismissSearch: deactivateOnAction)
return
}
guard let component = self.component, let navigationController = self.environment?.controller()?.navigationController as? NavigationController else {
return
}
self.openSearchResultDisposable.set((component.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer)
|> deliverOnMainQueue).startStrict(next: { [weak self] actualPeer in
guard let self, let component = self.component else {
return
}
if case let .channel(channel) = actualPeer, channel.isForumOrMonoForum, let threadId {
self.deactivateSearch(animated: false)
let _ = component.context.sharedContext.navigateToForumThread(context: component.context, peerId: actualPeer.id, threadId: threadId, messageId: messageId, navigationController: navigationController, activateInput: nil, scrollToEndIfExists: false, keepStack: .never, animated: true).startStandalone()
} else {
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
navigationController: navigationController,
context: component.context,
chatLocation: .peer(actualPeer),
subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false),
keepStack: .always,
purposefulAction: { [weak self] in
if deactivateOnAction {
self?.deactivateSearch(animated: false)
}
},
forceOpenChat: true
))
}
}))
}
private func activateSearch(searchContentNode: NavigationBarSearchContentNode?) {
guard let component = self.component, !component.mode.isPreview, let environment = self.environment, let (layout, navigationHeight) = self.validLayout, self.searchDisplayController == nil else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let selectionOptions = component.selectionOptions
let contentNode = ChatListSearchContainerNode(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
filter: selectionOptions?.filter ?? [],
requestPeerType: selectionOptions?.requestPeerType,
excludedPeerIds: selectionOptions?.excludedPeerIds ?? Set(),
location: .chatList(groupId: .root),
communityId: component.communityId,
folder: nil,
displaySearchFilters: false,
hasDownloads: false,
initialFilter: .chats,
openPeer: { [weak self] peer, _, threadId, dismissSearch in
self?.openPeerFromSearch(peer: peer, threadId: threadId, dismissSearch: dismissSearch)
},
openDisabledPeer: { peer, _, reason in
selectionOptions?.selectDisabledPeer(peer, reason)
},
openRecentPeerOptions: { _ in
},
openMessage: { [weak self] peer, threadId, messageId, deactivateOnAction in
self?.openMessageFromSearch(peer: peer, threadId: threadId, messageId: messageId, deactivateOnAction: deactivateOnAction)
},
addContact: nil,
peerContextAction: nil,
present: { [weak self] controller, arguments in
self?.environment?.controller()?.present(controller, in: .window(.root), with: arguments)
},
presentInGlobalOverlay: { [weak self] controller, arguments in
self?.environment?.controller()?.presentInGlobalOverlay(controller, with: arguments)
},
navigationController: environment.controller()?.navigationController as? NavigationController,
parentController: { [weak self] in
return self?.environment?.controller()
}
)
contentNode.dismissSearch = { [weak self] in
self?.deactivateSearch(animated: true)
}
contentNode.dismissSearchImmediately = { [weak self] in
self?.deactivateSearch(animated: false)
}
let searchDisplayController = SearchDisplayController(
presentationData: presentationData,
mode: .list,
contentNode: contentNode,
cancel: { [weak self] in
self?.deactivateSearch(animated: true)
},
fieldStyle: .glass,
searchBarIsExternal: false
)
self.searchDisplayController = searchDisplayController
self.isSearchDisplayControllerActive = ChatListNavigationBar.ActiveSearch(isExternal: false)
self.searchOverlayNode.view.isUserInteractionEnabled = true
self.state?.updated(transition: .spring(duration: 0.4))
searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate)
searchDisplayController.activate(insertSubnode: { [weak self] subnode, isSearchBar in
guard let self else {
return
}
if isSearchBar {
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
if let searchContentNode = navigationBarComponentView.searchContentNode {
searchContentNode.addSubnode(subnode)
} else {
self.searchOverlayNode.addSubnode(subnode)
}
} else {
self.searchOverlayNode.addSubnode(subnode)
}
} else {
self.searchOverlayNode.addSubnode(subnode)
}
}, placeholder: searchContentNode?.placeholderNode)
}
private func deactivateSearch(animated: Bool) {
self.isSearchDisplayControllerActive = nil
if let searchDisplayController = self.searchDisplayController {
self.searchDisplayController = nil
self.disappearingSearchDisplayController = searchDisplayController
let placeholderNode = (self.navigationBarView.view as? ChatListNavigationBar.View)?.searchContentNode?.placeholderNode
searchDisplayController.deactivate(placeholder: placeholderNode, animated: animated, completion: { [weak self, weak searchDisplayController] in
guard let self, let searchDisplayController else {
return
}
if self.disappearingSearchDisplayController === searchDisplayController {
self.disappearingSearchDisplayController = nil
self.searchOverlayNode.view.isUserInteractionEnabled = false
if self.component?.mode.usesSheetPresentation == true {
self.state?.updated(transition: .spring(duration: 0.4))
}
}
})
}
self.state?.updated(transition: .spring(duration: 0.4))
}
private func removePeer(_ peerId: EnginePeer.Id) {
guard let component = self.component, self.isAdmin, self.removingPeerId == nil else {
return
}
self.removingPeerId = peerId
self.state?.updated(transition: .immediate)
self.removePeerDisposable.set((component.context.engine.peers.toggleCommunityPeerLink(
communityId: component.communityId,
peerId: peerId,
action: .unlink
)
|> deliverOnMainQueue).startStrict(error: { [weak self] _ in
guard let self else {
return
}
self.removingPeerId = nil
self.state?.updated(transition: .spring(duration: 0.35))
}, completed: { [weak self] in
guard let self, let component = self.component else {
return
}
self.removingPeerId = nil
if let cachedData = self.cachedData {
self.cachedData = cachedData.withUpdatedLinkedPeers(cachedData.linkedPeers.filter { $0.peerId != peerId })
}
self.peers.removeValue(forKey: peerId)
self.cachedPeerData.removeValue(forKey: peerId)
self.previews.removeValue(forKey: peerId)
self.joinedPeerIds.remove(peerId)
self.currentLinkedPeerIds.removeAll(where: { $0 == peerId })
let _ = component.context.account.postbox.transaction { transaction -> Void in
transaction.updatePeerCachedData(peerIds: Set([component.communityId]), update: { _, current in
guard let current = current as? CachedCommunityData else {
return current
}
return current.withUpdatedLinkedPeers(current.linkedPeers.filter { $0.peerId != peerId })
})
}.startStandalone()
self.state?.updated(transition: .spring(duration: 0.35))
}))
}
private func prunePendingRequestActions() {
guard let pendingRequests = self.pendingRequests else {
if (self.cachedData?.pendingRequests ?? 0) <= 0 {
self.pendingRequestActions.removeAll()
}
return
}
let requestIds = Set(pendingRequests.requests.map(\.peerId))
for peerId in Array(self.pendingRequestActions.keys) {
if !requestIds.contains(peerId) {
self.pendingRequestActions.removeValue(forKey: peerId)
}
}
}
private func commitPendingRequestAction(peerId: EnginePeer.Id) {
guard var pendingAction = self.pendingRequestActions[peerId], !pendingAction.isCommitted else {
return
}
guard let pendingRequestsContext = self.pendingRequestsContext else {
return
}
pendingAction.isCommitted = true
self.pendingRequestActions[peerId] = pendingAction
pendingRequestsContext.update(pendingAction.request, action: pendingAction.approve ? .approve : .deny)
self.state?.updated(transition: .spring(duration: 0.35))
}
private func commitPendingRequestActions() {
let peerIds = Array(self.pendingRequestActions.keys)
for peerId in peerIds {
self.commitPendingRequestAction(peerId: peerId)
}
}
@discardableResult
private func commitCurrentRequestUndoOverlay(animateAsReplacement: Bool) -> Bool {
guard let currentRequestUndoOverlayController = self.currentRequestUndoOverlayController else {
return false
}
if animateAsReplacement {
currentRequestUndoOverlayController.dismissWithCommitActionAndReplacementAnimation()
} else {
currentRequestUndoOverlayController.dismissWithCommitAction()
}
self.currentRequestUndoOverlayController = nil
return true
}
private func setPendingRequestApproval(request: CommunityPeerRequest, peer: EnginePeer, approve: Bool) {
guard let component = self.component, let environment = self.environment, let controller = environment.controller(), self.pendingRequestsContext != nil else {
return
}
let peerId = request.peerId
if self.pendingRequestActions[peerId] != nil {
return
}
let animateInAsReplacement = self.commitCurrentRequestUndoOverlay(animateAsReplacement: true)
self.pendingRequestActions[peerId] = PendingCommunityViewRequestAction(
request: request,
approve: approve,
isCommitted: false
)
self.state?.updated(transition: .spring(duration: 0.35))
let title: String = peer.compactDisplayTitle
let text: String
if approve {
text = environment.strings.Community_View_PendingRequestApprovedToast
} else {
text = environment.strings.Community_View_PendingRequestDeclinedToast
}
let undoController = UndoOverlayController(
presentationData: component.context.sharedContext.currentPresentationData.with { $0 },
content: .invitedToVoiceChat(
context: component.context,
peer: peer,
title: title,
text: text,
action: environment.strings.Undo_Undo,
duration: 3.0
),
animateInAsReplacement: animateInAsReplacement,
action: { [weak self] action in
guard let self else {
return false
}
switch action {
case .commit:
self.commitPendingRequestAction(peerId: peerId)
return true
case .undo:
if let pendingAction = self.pendingRequestActions[peerId], !pendingAction.isCommitted {
self.pendingRequestActions.removeValue(forKey: peerId)
self.state?.updated(transition: .spring(duration: 0.35))
}
return true
case .info:
return false
}
}
)
self.currentRequestUndoOverlayController = undoController
controller.present(undoController, in: .current)
}
private func toggleCollapsed(_ collapsed: Bool) {
guard let component = self.component else {
return
}
self.state?.updated(transition: .spring(duration: 0.35))
self.actionDisposable.set((component.context.engine.peers.toggleCommunityCollapsedInDialogs(
communityId: component.communityId,
collapsed: collapsed
)
|> deliverOnMainQueue).startStrict(completed: { [weak self] in
guard let self else {
return
}
self.state?.updated(transition: .spring(duration: 0.35))
}))
}
private func openAddChat() {
guard let component = self.component, let environment = self.environment, !self.isAddActionInProgress else {
return
}
let communitiesConfiguration = CommunitiesConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
let linkedPeersCount = Int32(self.cachedData?.linkedPeers.count ?? 0)
if linkedPeersCount >= communitiesConfiguration.peersLimit {
let alertText = environment.strings.Community_View_ChatsLimitReached
let alertScreen = textAlertController(context: component.context, title: nil, text: alertText, actions: [
TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})
])
environment.controller()?.present(alertScreen, in: .window(.root))
return
}
self.isAddActionInProgress = true
self.state?.updated(transition: .spring(duration: 0.35))
let excludedPeerIds = Set((self.cachedData?.linkedPeers ?? []).map(\.peerId))
self.addChatDisposable.set((component.context.engine.peers.adminedPublicChannels(scope: .forCommunity)
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak self] channels in
guard let self else {
return
}
self.isAddActionInProgress = false
self.addChatDisposable.set(nil)
self.state?.updated(transition: .spring(duration: 0.35))
guard let component = self.component, let environment = self.environment else {
return
}
let channels = channels.filter { channel in
!excludedPeerIds.contains(channel.peer.id)
}
let selectionController = PeerSelectionScreen(
context: component.context,
initialData: PeerSelectionScreen.communityInitialData(channels: channels),
updatedPresentationData: nil,
completion: { [weak self] channel in
guard let self, let component = self.component, let channel else {
return
}
let controller = component.context.sharedContext.makeCommunityAddScreen(
context: component.context,
communityId: component.communityId,
peerId: channel.peer.id,
requiresConfirmation: !self.isAdmin,
completed: { [weak self] immediate in
guard let self, let component = self.component else {
return
}
component.context.account.viewTracker.forceUpdateCachedPeerData(peerId: component.communityId)
self.updateJoinedChatsSignal(component: component, force: true)
self.state?.updated(transition: .spring(duration: 0.35))
}
)
self.environment?.controller()?.present(controller, in: .window(.root))
}
)
selectionController.navigationPresentation = .modal
environment.controller()?.push(selectionController)
}))
}
private func containerLayout(availableSize: CGSize, environment: EnvironmentType, mode: CommunityViewScreenMode, safeInsets: UIEdgeInsets? = nil) -> ContainerViewLayout {
let effectiveSafeInsets = safeInsets ?? environment.safeInsets
return ContainerViewLayout(
size: availableSize,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: effectiveSafeInsets.bottom, right: 0.0),
safeInsets: effectiveSafeInsets,
additionalInsets: environment.additionalInsets,
statusBarHeight: mode.usesFullscreenPresentation ? environment.statusBarHeight : 0.0,
inputHeight: environment.inputHeight > 0.0 ? environment.inputHeight : nil,
inputHeightIsInteractivellyChanging: false,
inVoiceOver: false,
presentedInFormSheet: false
)
}
private func sheetMetrics(availableSize: CGSize, environment: EnvironmentType) -> (fillingSize: CGFloat, rawSideInset: CGFloat) {
let fillingSize: CGFloat
if case .regular = environment.metrics.widthClass {
fillingSize = min(availableSize.width, 414.0) - environment.safeInsets.left * 2.0
} else {
fillingSize = min(availableSize.width, availableSize.height) - environment.safeInsets.left * 2.0
}
return (fillingSize, floor((availableSize.width - fillingSize) * 0.5))
}
private func updateNavigationBar(component: CommunityViewScreenComponent, availableSize: CGSize, statusBarHeight: CGFloat, leftInset: CGFloat, rightInset: CGFloat, environment: EnvironmentType, transition: ComponentTransition) -> CGSize {
let theme: PresentationTheme
switch component.mode {
case .sheet:
theme = environment.theme.withModalBlocksBackground()
case .fullscreen, .preview:
theme = environment.theme
}
let title = self.community?.title ?? environment.strings.Community_View_Title
let leftButton: AnyComponentWithIdentity<NavigationButtonComponentEnvironment>?
let backPressed: (() -> Void)?
let navigationBackTitle: String?
if component.mode.isPreview {
leftButton = nil
backPressed = nil
navigationBackTitle = nil
} else {
switch component.mode {
case .sheet:
leftButton = AnyComponentWithIdentity(id: "close", component: AnyComponent(NavigationButtonComponent(
content: .icon(imageName: "Navigation/Close"),
pressed: { [weak self] _ in
self?.dismiss(animated: true)
}
)))
backPressed = nil
navigationBackTitle = nil
case .fullscreen:
leftButton = nil
backPressed = { [weak self] in
self?.dismiss(animated: true)
}
navigationBackTitle = environment.strings.Common_Back
case .preview:
leftButton = nil
backPressed = nil
navigationBackTitle = nil
}
}
var titleItems: [AnyComponentWithIdentity<Empty>] = [
AnyComponentWithIdentity(id: "label", component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor)))
))
]
if component.mode.usesFullscreenPresentation, let community = self.community {
titleItems.insert(AnyComponentWithIdentity(id: "avatar", component: AnyComponent(
AvatarComponent(context: component.context, theme: theme, peer: EnginePeer(community), clipStyle: .roundedRect, size: CGSize(width: 20.0, height: 20.0))
)), at: 0)
}
var rightButtons: [AnyComponentWithIdentity<NavigationButtonComponentEnvironment>] = []
if component.mode.usesSheetPresentation && !component.mode.isPreview && component.selectionOptions == nil {
rightButtons.append(AnyComponentWithIdentity(id: "search", component: AnyComponent(NavigationButtonComponent(
content: .icon(imageName: "Navigation/Search"),
pressed: { [weak self] _ in
self?.activateSearch(searchContentNode: nil)
}
))))
}
if self.isAdmin && !component.mode.isPreview && component.selectionOptions == nil {
rightButtons.append(AnyComponentWithIdentity(id: "settings", component: AnyComponent(NavigationButtonComponent(
content: .icon(imageName: "Media Editor/Adjustments"),
pressed: { [weak self] _ in
self?.openEdit()
}
))))
}
let primaryContent = ChatListHeaderComponent.Content(
title: title,
navigationBackTitle: navigationBackTitle,
titleComponent: AnyComponent(
HStack(titleItems, spacing: 7.0)
),
chatListTitle: nil,
leftButton: leftButton,
rightButtons: rightButtons,
backPressed: backPressed
)
var statusBarHeight = statusBarHeight
if component.selectionOptions != nil {
statusBarHeight += 6.0
} else if component.mode.isPreview {
statusBarHeight += 2.0
}
let navigationBarSize = self.navigationBarView.update(
transition: transition,
component: AnyComponent(ChatListNavigationBar(
context: component.context,
theme: theme,
strings: environment.strings,
statusBarHeight: statusBarHeight,
leftInset: leftInset,
rightInset: rightInset,
search: component.mode.usesFullscreenPresentation && !component.mode.isPreview && component.selectionOptions == nil ? ChatListNavigationBar.Search(isEnabled: true) : nil,
activeSearch: self.isSearchDisplayControllerActive,
primaryContent: primaryContent,
secondaryContent: nil,
secondaryTransition: 0.0,
storySubscriptions: nil,
storiesIncludeHidden: true,
uploadProgress: [:],
headerPanels: nil,
tabsNode: nil,
tabsNodeIsSearch: false,
accessoryPanelContainer: nil,
accessoryPanelContainerHeight: 0.0,
hasEdgeEffect: component.mode.usesPlainStyle,
activateSearch: { [weak self] searchContentNode in
if !component.mode.isPreview && component.selectionOptions == nil {
self?.activateSearch(searchContentNode: searchContentNode)
}
},
openStatusSetup: { _ in
},
allowAutomaticOrder: {
}
)),
environment: {},
containerSize: availableSize
)
return navigationBarSize
}
private func currentSheetNavigationBarFrame(bounds: CGRect?) -> CGRect? {
if self.sheetNavigationFrame.size.width <= 0.0 || self.sheetNavigationFrame.size.height <= 0.0 {
return nil
}
var frame = self.sheetNavigationFrame
if let bounds {
let topOffset = max(0.0, -bounds.minY + self.sheetTopInset)
frame.origin.y = topOffset + self.sheetContainerInset + self.sheetNavigationTopInset
}
return frame
}
private func sheetSearchOverlayFrame(navigationBarFrame: CGRect, availableSize: CGSize, statusBarHeight: CGFloat) -> CGRect {
let originY = max(navigationBarFrame.minY + self.sheetSearchOverlayTopOffset, statusBarHeight + self.sheetSearchOverlayTopOffset)
return CGRect(
origin: CGPoint(x: navigationBarFrame.minX, y: originY),
size: CGSize(width: navigationBarFrame.width, height: max(1.0, availableSize.height - originY))
)
}
private func updateSearchDisplayControllers(containerLayout: ContainerViewLayout, navigationHeight: CGFloat, transition: ComponentTransition) {
self.validLayout = (containerLayout, navigationHeight)
if let searchDisplayController = self.searchDisplayController {
searchDisplayController.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationHeight, transition: transition.containedViewLayoutTransition)
}
if let disappearingSearchDisplayController = self.disappearingSearchDisplayController {
disappearingSearchDisplayController.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationHeight, transition: transition.containedViewLayoutTransition)
}
}
private func updateSheetNavigationBarFrame(bounds: CGRect?, transition: ComponentTransition) {
guard let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View, let frame = self.currentSheetNavigationBarFrame(bounds: bounds) else {
return
}
transition.setFrame(view: navigationBarComponentView, frame: frame)
self.updateNavigationScrolling(transition: transition)
if self.searchOverlayNode.view.superview === navigationBarComponentView.superview, let component = self.component, let environment = self.environment, component.mode.usesSheetPresentation {
let overlayFrame = self.sheetSearchOverlayFrame(navigationBarFrame: frame, availableSize: self.currentAvailableSize, statusBarHeight: environment.statusBarHeight)
self.searchOverlayNode.frame = overlayFrame
transition.setFrame(view: self.searchOverlayNode.view, frame: overlayFrame)
let containerLayout = self.containerLayout(
availableSize: overlayFrame.size,
environment: environment,
mode: .sheet,
safeInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0)
)
self.updateSearchDisplayControllers(containerLayout: containerLayout, navigationHeight: frame.height, transition: transition)
}
}
private func placeSearchOverlay(in superview: UIView, frame: CGRect, transition: ComponentTransition) {
self.searchOverlayNode.frame = frame
if self.searchOverlayNode.view.superview !== superview {
self.searchOverlayNode.view.removeFromSuperview()
superview.addSubview(self.searchOverlayNode.view)
}
transition.setFrame(view: self.searchOverlayNode.view, frame: frame)
self.searchOverlayNode.view.isUserInteractionEnabled = self.searchDisplayController != nil || self.disappearingSearchDisplayController != nil
}
private func placeFullscreenNavigationBar(navigationBarSize: CGSize, transition: ComponentTransition) {
guard let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View else {
return
}
if navigationBarComponentView.superview !== self {
navigationBarComponentView.removeFromSuperview()
self.addSubview(navigationBarComponentView)
}
self.sheetNavigationFrame = .zero
transition.setFrame(view: navigationBarComponentView, frame: CGRect(origin: .zero, size: navigationBarSize))
self.updateNavigationScrolling(transition: transition)
}
private func placeSheetNavigationBar(sheetView: ResizableSheetComponent<EnvironmentType>.View, availableSize: CGSize, navigationBarSize: CGSize, sheetMetrics: (fillingSize: CGFloat, rawSideInset: CGFloat), environment: EnvironmentType, transition: ComponentTransition) -> CGRect? {
guard let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View else {
return nil
}
if navigationBarComponentView.superview !== sheetView.containerView {
navigationBarComponentView.removeFromSuperview()
sheetView.containerView.addSubview(navigationBarComponentView)
}
let isSheetSearchFullscreen = self.isSheetSearchFullscreen
let containerInset: CGFloat = isSheetSearchFullscreen ? 0.0 : environment.statusBarHeight + 10.0
let defaultHeight: CGFloat = self.isAdmin ? 620.0 : 700.0
let contentHeight = self.sheetExternalState.contentHeight
let initialContentHeight = isSheetSearchFullscreen ? contentHeight : min(contentHeight, max(0.0, defaultHeight))
let topInset = isSheetSearchFullscreen ? 0.0 : max(0.0, availableSize.height - containerInset - initialContentHeight)
self.sheetTopInset = topInset
self.sheetContainerInset = containerInset
self.sheetNavigationFrame = CGRect(
origin: CGPoint(x: sheetMetrics.rawSideInset, y: topInset + containerInset + self.sheetNavigationTopInset),
size: navigationBarSize
)
self.updateSheetNavigationBarFrame(bounds: self.currentSheetBounds, transition: transition)
sheetView.containerView.bringSubviewToFront(navigationBarComponentView)
return self.currentSheetNavigationBarFrame(bounds: self.currentSheetBounds)
}
private var visiblePendingRequests: CommunityPeerLinkRequests? {
guard let pendingRequests = self.pendingRequests else {
return nil
}
guard !self.pendingRequestActions.isEmpty else {
return pendingRequests
}
let pendingPeerIds = Set(self.pendingRequestActions.keys)
let requests = pendingRequests.requests.filter { request in
!pendingPeerIds.contains(request.peerId)
}
let hiddenCount = pendingRequests.requests.count - requests.count
let totalCount = max(0, pendingRequests.totalCount - Int32(hiddenCount))
return CommunityPeerLinkRequests(
totalCount: totalCount,
requests: requests,
nextOffset: pendingRequests.nextOffset,
peers: pendingRequests.peers
)
}
private func makeContentComponent(component: CommunityViewScreenComponent, topInset: CGFloat) -> CommunityViewContentComponent {
return CommunityViewContentComponent(
context: component.context,
communityId: component.communityId,
mode: component.mode,
topInset: topInset,
community: self.community,
cachedData: self.cachedData,
peers: self.peers,
cachedPeerData: self.cachedPeerData,
previews: self.previews,
pendingRequests: component.selectionOptions == nil ? self.visiblePendingRequests : nil,
pendingRequestCachedPeerData: self.pendingRequestCachedPeerData,
pendingRequestInFlightPeerId: nil,
pendingRequestInFlightApprove: nil,
joinedPeerIds: self.joinedPeerIds,
selectionOptions: component.selectionOptions,
toggleCollapsed: { [weak self] value in
self?.toggleCollapsed(value)
},
setRequestApproval: { [weak self] request, peer, approve in
self?.setPendingRequestApproval(request: request, peer: peer, approve: approve)
},
openPeer: { [weak self] peer in
self?.openPeer(peer)
},
openPendingRequests: { [weak self] in
self?.openPendingRequests()
},
removePeer: { [weak self] peerId in
self?.removePeer(peerId)
}
)
}
private func makeBottomButtonComponent(theme: PresentationTheme, strings: PresentationStrings, safeInsets: UIEdgeInsets) -> CommunityViewBottomButtonComponent {
return CommunityViewBottomButtonComponent(
theme: theme,
title: strings.Community_View_AddChatButton,
iconName: "Item List/Icons/Add",
safeInsets: safeInsets,
isEnabled: !self.isAddActionInProgress,
displaysProgress: self.isAddActionInProgress,
action: { [weak self] in
guard let self else {
return
}
self.openAddChat()
}
)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let component = self.component else {
return nil
}
if component.mode.isPreview {
if self.bounds.contains(point) {
return self.scrollView
} else {
return nil
}
} else {
return super.hitTest(point, with: event)
}
}
func update(component: CommunityViewScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let environment = environment[EnvironmentType.self].value
self.environment = environment
self.currentAvailableSize = availableSize
self.ensureDataSignal(component: component)
let currentSheetMetrics = self.sheetMetrics(availableSize: availableSize, environment: environment)
let navigationAvailableSize: CGSize
let navigationStatusBarHeight: CGFloat
let navigationSideInsets: (left: CGFloat, right: CGFloat)
switch component.mode {
case .sheet:
navigationAvailableSize = CGSize(width: currentSheetMetrics.fillingSize, height: availableSize.height)
navigationStatusBarHeight = 0.0
navigationSideInsets = (0.0, 0.0)
case .fullscreen, .preview:
navigationAvailableSize = availableSize
navigationStatusBarHeight = environment.statusBarHeight
navigationSideInsets = (environment.safeInsets.left, environment.safeInsets.right)
}
let navigationBarSize = self.updateNavigationBar(
component: component,
availableSize: navigationAvailableSize,
statusBarHeight: navigationStatusBarHeight,
leftInset: navigationSideInsets.left,
rightInset: navigationSideInsets.right,
environment: environment,
transition: transition
)
let navigationHeight = navigationBarSize.height
if self.isSearchDisplayControllerActive == nil {
self.lastInactiveNavigationHeight = navigationHeight
}
let contentNavigationHeight = self.lastInactiveNavigationHeight ?? navigationHeight
let displaysBottomItem = !self.isSheetSearchFullscreen && !component.mode.isPreview && component.selectionOptions == nil && self.canAddChatsToCommunity
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
let bottomPanelHeight = 52.0 + buttonInsets.bottom
switch component.mode {
case .sheet:
self.scrollView.removeFromSuperview()
self.fullscreenContent.view?.removeFromSuperview()
self.fullscreenBottomItem.view?.removeFromSuperview()
self.bottomEdgeEffectView.removeFromSuperview()
let theme = environment.theme.withModalBlocksBackground()
let isSheetSearchFullscreen = self.isSheetSearchFullscreen
let bottomItem: AnyComponent<Empty>? = displaysBottomItem ? AnyComponent(self.makeBottomButtonComponent(theme: theme, strings: environment.strings, safeInsets: environment.safeInsets)) : nil
let sheetBackgroundColor = isSheetSearchFullscreen ? theme.list.modalPlainBackgroundColor : theme.list.modalBlocksBackgroundColor
let contentTopInset = contentNavigationHeight + self.sheetNavigationTopInset
let sheetSize = self.sheet.update(
transition: transition,
component: AnyComponent(ResizableSheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(self.makeContentComponent(component: component, topInset: contentTopInset)),
hasTopEdgeEffect: true,
bottomItem: bottomItem,
backgroundColor: .color(sheetBackgroundColor),
isFullscreen: isSheetSearchFullscreen,
defaultHeight: self.isAdmin ? 620.0 : 700.0,
externalState: self.sheetExternalState,
animateOut: self.animateOut
)),
environment: {
environment
ResizableSheetComponentEnvironment(
theme: theme,
statusBarHeight: environment.statusBarHeight,
safeInsets: environment.safeInsets,
inputHeight: 0.0,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.isVisible,
isCentered: environment.metrics.widthClass == .regular,
screenSize: availableSize,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { [weak self] animated in
self?.dismiss(animated: animated)
},
boundsUpdated: self.sheetBoundsUpdated
)
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: .zero, size: sheetSize))
}
if let sheetView = self.sheet.view as? ResizableSheetComponent<EnvironmentType>.View {
let navigationBarFrame = self.placeSheetNavigationBar(
sheetView: sheetView,
availableSize: availableSize,
navigationBarSize: navigationBarSize,
sheetMetrics: currentSheetMetrics,
environment: environment,
transition: transition
)
if let navigationBarFrame {
let searchOverlayFrame = self.sheetSearchOverlayFrame(navigationBarFrame: navigationBarFrame, availableSize: availableSize, statusBarHeight: environment.statusBarHeight)
self.placeSearchOverlay(in: sheetView.containerView, frame: searchOverlayFrame, transition: transition)
let searchContainerLayout = self.containerLayout(
availableSize: searchOverlayFrame.size,
environment: environment,
mode: component.mode,
safeInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0)
)
self.updateSearchDisplayControllers(containerLayout: searchContainerLayout, navigationHeight: navigationHeight, transition: transition)
sheetView.containerView.bringSubviewToFront(self.searchOverlayNode.view)
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
sheetView.containerView.bringSubviewToFront(navigationBarComponentView)
}
}
}
case .fullscreen, .preview:
self.sheet.view?.removeFromSuperview()
self.currentSheetBounds = nil
self.placeSearchOverlay(in: self, frame: CGRect(origin: .zero, size: availableSize), transition: transition)
self.placeFullscreenNavigationBar(navigationBarSize: navigationBarSize, transition: transition)
let searchContainerLayout = self.containerLayout(availableSize: availableSize, environment: environment, mode: component.mode)
self.updateSearchDisplayControllers(containerLayout: searchContainerLayout, navigationHeight: navigationHeight, transition: transition)
let theme = environment.theme
self.backgroundColor = theme.chatList.backgroundColor
let bottomFrame: CGRect
let bottomSize: CGSize
if displaysBottomItem {
let bottomPanelWidth = max(1.0, availableSize.width - environment.safeInsets.left * 2.0 - buttonInsets.left - buttonInsets.right)
bottomSize = self.fullscreenBottomItem.update(
transition: transition,
component: AnyComponent(self.makeBottomButtonComponent(theme: theme, strings: environment.strings, safeInsets: UIEdgeInsets())),
environment: {},
containerSize: CGSize(width: bottomPanelWidth, height: 52.0)
)
bottomFrame = CGRect(
origin: CGPoint(
x: environment.safeInsets.left + buttonInsets.left,
y: availableSize.height - bottomPanelHeight
),
size: bottomSize
)
if let bottomView = self.fullscreenBottomItem.view {
if bottomView.superview == nil {
self.addSubview(bottomView)
}
transition.setFrame(view: bottomView, frame: bottomFrame)
}
} else {
self.fullscreenBottomItem.view?.removeFromSuperview()
bottomSize = .zero
bottomFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height), size: bottomSize)
}
if self.scrollView.superview == nil {
if displaysBottomItem, let bottomView = self.fullscreenBottomItem.view {
self.insertSubview(self.scrollView, belowSubview: bottomView)
} else {
self.addSubview(self.scrollView)
}
}
let displaysBottomEdgeEffect = component.mode.usesPlainStyle && !component.mode.isPreview && (component.selectionOptions != nil || displaysBottomItem)
if displaysBottomEdgeEffect {
let bottomEdgeEffectHeight = bottomSize.height + environment.safeInsets.bottom + 36.0
let bottomEdgeEffectFrame = CGRect(
origin: CGPoint(x: 0.0, y: availableSize.height - bottomEdgeEffectHeight),
size: CGSize(width: availableSize.width, height: bottomEdgeEffectHeight)
)
if self.bottomEdgeEffectView.superview == nil {
self.addSubview(self.bottomEdgeEffectView)
}
if self.scrollView.superview === self {
self.insertSubview(self.bottomEdgeEffectView, aboveSubview: self.scrollView)
}
if let bottomView = self.fullscreenBottomItem.view, bottomView.superview === self {
self.insertSubview(self.bottomEdgeEffectView, belowSubview: bottomView)
}
transition.setFrame(view: self.bottomEdgeEffectView, frame: bottomEdgeEffectFrame)
self.bottomEdgeEffectView.update(content: theme.chatList.backgroundColor, blur: true, alpha: 1.0, rect: bottomEdgeEffectFrame, edge: .bottom, edgeSize: bottomEdgeEffectFrame.height, transition: transition)
} else {
self.bottomEdgeEffectView.removeFromSuperview()
}
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize))
let fullscreenContentTopInset = contentNavigationHeight
let contentSize = self.fullscreenContent.update(
transition: transition,
component: AnyComponent(self.makeContentComponent(component: component, topInset: fullscreenContentTopInset)),
environment: {
environment
},
containerSize: CGSize(width: availableSize.width, height: 10000.0)
)
if let contentView = self.fullscreenContent.view {
if contentView.superview == nil {
self.scrollView.addSubview(contentView)
}
transition.setFrame(view: contentView, frame: CGRect(origin: .zero, size: contentSize))
}
let scrollBottomInset: CGFloat
if displaysBottomItem {
scrollBottomInset = availableSize.height - bottomFrame.minY + 8.0
} else {
scrollBottomInset = environment.safeInsets.bottom
}
let scrollInsets = UIEdgeInsets(top: fullscreenContentTopInset, left: 0.0, bottom: scrollBottomInset, right: 0.0)
if self.scrollView.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
let scrollContentHeight: CGFloat
if component.mode.usesPlainStyle && !component.mode.isPreview && component.selectionOptions == nil {
scrollContentHeight = max(contentSize.height, availableSize.height + ChatListNavigationBar.searchScrollHeight)
} else {
scrollContentHeight = contentSize.height
}
let scrollContentSize = CGSize(width: availableSize.width, height: scrollContentHeight)
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
if component.mode.usesPlainStyle && !component.mode.isPreview && component.selectionOptions == nil && self.isSearchDisplayControllerActive == nil && !self.didApplyInitialOffset {
self.didApplyInitialOffset = true
self.scrollView.setContentOffset(CGPoint(x: 0.0, y: ChatListNavigationBar.searchScrollHeight), animated: false)
self.updateNavigationScrolling(transition: .immediate)
}
self.bringSubviewToFront(self.searchOverlayNode.view)
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
self.bringSubviewToFront(navigationBarComponentView)
}
}
if let controller = environment.controller(), !controller.automaticallyControlPresentationContextLayout, var presentationContextLayout = controller.currentlyAppliedLayout {
if displaysBottomItem {
let baseBottomInset = presentationContextLayout.intrinsicInsets.bottom + presentationContextLayout.safeInsets.bottom
presentationContextLayout.intrinsicInsets.bottom = max(baseBottomInset, bottomPanelHeight)
presentationContextLayout.safeInsets.bottom = 0.0
}
controller.presentationContext.containerLayoutUpdated(presentationContextLayout, transition: transition.containedViewLayoutTransition)
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class CommunityViewScreenImpl: ViewControllerComponentContainer, CommunityViewScreen {
public init(context: AccountContext, communityId: EnginePeer.Id, mode: CommunityViewScreenMode, selectionOptions: CommunityPeerSelectionOptions? = nil) {
super.init(
context: context,
component: CommunityViewScreenComponent(context: context, communityId: communityId, mode: mode, selectionOptions: selectionOptions),
navigationBarAppearance: .none,
theme: .default,
updatedPresentationData: nil
)
self.automaticallyControlPresentationContextLayout = false
switch mode {
case .sheet:
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true
case .fullscreen, .preview:
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .default
self.blocksBackgroundWhenInOverlay = false
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if self.navigationPresentation == .flatModal {
self.view.disablesInteractiveModalDismiss = true
}
}
public func dismissAnimated() {
guard self.navigationPresentation == .flatModal else {
self.dismiss(completion: nil)
return
}
if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}