diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index 32649488a0..c1787ebf68 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -114,6 +114,7 @@ final class VideoChatParticipantsComponent: Component { let layout: Layout let expandedInsets: UIEdgeInsets let safeInsets: UIEdgeInsets + let openParticipantContextMenu: (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void let updateMainParticipant: (VideoParticipantKey?) -> Void let updateIsMainParticipantPinned: (Bool) -> Void let updateIsExpandedUIHidden: (Bool) -> Void @@ -128,6 +129,7 @@ final class VideoChatParticipantsComponent: Component { layout: Layout, expandedInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, + openParticipantContextMenu: @escaping (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void, updateMainParticipant: @escaping (VideoParticipantKey?) -> Void, updateIsMainParticipantPinned: @escaping (Bool) -> Void, updateIsExpandedUIHidden: @escaping (Bool) -> Void @@ -141,6 +143,7 @@ final class VideoChatParticipantsComponent: Component { self.layout = layout self.expandedInsets = expandedInsets self.safeInsets = safeInsets + self.openParticipantContextMenu = openParticipantContextMenu self.updateMainParticipant = updateMainParticipant self.updateIsMainParticipantPinned = updateIsMainParticipantPinned self.updateIsExpandedUIHidden = updateIsExpandedUIHidden @@ -1015,12 +1018,21 @@ final class VideoChatParticipantsComponent: Component { rightAccessoryComponent: rightAccessoryComponent, selectionState: .none, hasNext: false, - action: { [weak self] peer, _, _ in - guard let self else { + extractedTheme: PeerListItemComponent.ExtractedTheme( + inset: 2.0, + background: UIColor(white: 0.1, alpha: 1.0) + ), + action: { [weak self] peer, _, itemView in + guard let self, let component = self.component else { return } - let _ = self - let _ = peer + component.openParticipantContextMenu(peer.id, itemView.extractedContainerView, nil) + }, + contextAction: { [weak self] peer, sourceView, gesture in + guard let self, let component = self.component else { + return + } + component.openParticipantContextMenu(peer.id, sourceView, gesture) } )), environment: {}, @@ -1381,7 +1393,7 @@ final class VideoChatParticipantsComponent: Component { gridParticipants.append(videoParticipant) } } - if !hasVideo { + if !hasVideo || component.layout.videoColumn != nil { if participant.peer.id == component.call.accountContext.account.peerId { listParticipants.insert(participant, at: 0) } else { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index 1e98e70c51..515208393d 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -22,6 +22,14 @@ import ShareController import AvatarNode import TelegramAudio +import DeleteChatPeerActionSheetItem +import PeerListItemComponent +import LegacyComponents +import LegacyUI +import WebSearchUI +import MapResourceToAvatarSizes +import LegacyMediaPickerUI + private final class VideoChatScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -97,6 +105,10 @@ private final class VideoChatScreenComponent: Component { private var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + private let currentAvatarMixin = Atomic(value: nil) + private let updateAvatarDisposable = MetaDisposable() + private var currentUpdatingAvatar: (TelegramMediaImageRepresentation, Float)? + override init(frame: CGRect) { self.containerView = UIView() self.containerView.clipsToBounds = true @@ -125,6 +137,7 @@ private final class VideoChatScreenComponent: Component { self.displayAsPeersDisposable?.dispose() self.audioOutputStateDisposable?.dispose() self.inviteLinksDisposable?.dispose() + self.updateAvatarDisposable.dispose() } func animateIn() { @@ -714,6 +727,637 @@ private final class VideoChatScreenComponent: Component { return items } + private func openParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) { + guard let component = self.component, let environment = self.environment else { + return + } + guard let members = self.members, let participant = members.participants.first(where: { $0.peer.id == id }) else { + return + } + + let muteStatePromise = Promise(participant.muteState) + + let itemsForEntry: (GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { [weak self] muteState in + guard let self, let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + + var hasVolumeSlider = false + let peer = participant.peer + if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { + } else { + if callState.canManageCall || callState.myPeerId != id { + hasVolumeSlider = true + + let minValue: CGFloat + if callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { + minValue = 0.01 + } else { + minValue = 0.0 + } + items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: participant.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { [weak self] newValue, finished in + guard let self, let component = self.component else { + return + } + + if finished && newValue.isZero { + let updatedMuteState = component.call.updateMuteState(peerId: peer.id, isMuted: true) + muteStatePromise.set(.single(updatedMuteState)) + } else { + component.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) + } + }), true)) + } + } + + if callState.myPeerId == id && !hasVolumeSlider && ((participant.about?.isEmpty ?? true) || participant.peer.smallProfileImage == nil) { + items.append(.custom(VoiceChatInfoContextItem(text: environment.strings.VoiceChat_ImproveYourProfileText, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) + }), true)) + } + + if peer.id == callState.myPeerId { + if participant.hasRaiseHand { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_CancelSpeakRequest, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + component.call.lowerHand() + + f(.default) + }))) + } + items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? environment.strings.VoiceChat_AddPhoto : environment.strings.VoiceChat_ChangePhoto, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + Queue.mainQueue().after(0.1) { + guard let self else { + return + } + + self.openAvatarForEditing(fromGallery: false, completion: {}) + } + }))) + + items.append(.action(ContextMenuActionItem(text: (participant.about?.isEmpty ?? true) ? environment.strings.VoiceChat_AddBio : environment.strings.VoiceChat_EditBio, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let maxBioLength: Int + if peer.id.namespace == Namespaces.Peer.CloudUser { + maxBioLength = 70 + } else { + maxBioLength = 100 + } + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_EditBioTitle, text: environment.strings.VoiceChat_EditBioText, placeholder: environment.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, value: participant.about, maxLength: maxBioLength, apply: { [weak self] bio in + guard let self, let component = self.component, let environment = self.environment, let bio else { + return + } + if peer.id.namespace == Namespaces.Peer.CloudUser { + let _ = (component.call.accountContext.engine.accountData.updateAbout(about: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } else { + let _ = (component.call.accountContext.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditBioSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + + if let peer = peer as? TelegramUser { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ChangeName, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = voiceChatUserNameController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: environment.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: environment.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { [weak self] firstAndLastName in + guard let self, let component = self.component, let environment = self.environment, let (firstName, lastName) = firstAndLastName else { + return + } + let _ = component.call.accountContext.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).startStandalone() + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditNameSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + } + } else { + if (callState.canManageCall || callState.adminIds.contains(component.call.accountContext.account.peerId)) { + if callState.adminIds.contains(peer.id) { + if let _ = muteState { + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } else { + if let muteState = muteState, !muteState.canUnmute { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: participant.hasRaiseHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + + self.presentUndoOverlay(content: .voiceChatCanSpeak(text: environment.strings.VoiceChat_UserCanNowSpeak(EnginePeer(participant.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + } else { + if let muteState = muteState, muteState.mutedByYou { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + + let openTitle: String + let openIcon: UIImage? + if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) { + if let peer = peer as? TelegramChannel, case .broadcast = peer.info { + openTitle = environment.strings.VoiceChat_OpenChannel + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") + } else { + openTitle = environment.strings.VoiceChat_OpenGroup + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") + } + } else { + openTitle = environment.strings.Conversation_ContextMenuSendMessage + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message") + } + items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in + return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + guard let controller = environment.controller() as? VideoChatScreenV2Impl, let navigationController = controller.parentNavigationController else { + return + } + + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().after(0.3) { + guard let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) + } + }) + + f(.dismissWithoutContent) + }))) + + if (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != component.call.peerId { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: { + guard let self, let component = self.component else { + return + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + let nameDisplayOrder = presentationData.nameDisplayOrder + items.append(DeleteChatPeerActionSheetItem(context: component.call.accountContext, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: environment.strings, nameDisplayOrder: nameDisplayOrder)) + + items.append(ActionSheetButtonItem(title: environment.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.accountContext.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: component.call.accountContext.engine, peerId: component.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() + component.call.removedPeer(peer.id) + + self.presentUndoOverlay(content: .banned(text: environment.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string), action: { _ in return false }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + }) + }) + }))) + } + } + return items + } + + let items = muteStatePromise.get() + |> map { muteState -> [ContextMenuItem] in + return itemsForEntry(muteState) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)), + items: items |> map { items in + return ContextController.Items(content: .list(items)) + }, + recognizer: nil, + gesture: gesture + ) + + environment.controller()?.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismiss() + } + return true + }) + + environment.controller()?.presentInGlobalOverlay(contextController) + } + + private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + let peerId = callState.myPeerId + + let _ = (component.call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Configuration.SearchBots() + ) + |> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let peer else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let legacyController = LegacyController(presentation: .custom, theme: environment.theme) + legacyController.statusBar.statusBarStyle = .Ignore + + let emptyController = LegacyEmptyController(context: legacyController.context)! + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + legacyController.bind(controller: navigationController) + + self.endEditing(true) + environment.controller()?.present(legacyController, in: .window(.root)) + + var hasPhotos = false + if !peer.profileImageRepresentations.isEmpty { + hasPhotos = true + } + + let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)! + mixin.forceDark = true + mixin.stickersContext = LegacyPaintStickersContext(context: component.call.accountContext) + let _ = self.currentAvatarMixin.swap(mixin) + mixin.requestSearchController = { [weak self] assetsController in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = WebSearchController(context: component.call.accountContext, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in + assetsController?.dismiss() + + guard let self else { + return + } + self.updateProfilePhoto(result) + })) + controller.navigationPresentation = .modal + environment.controller()?.push(controller) + + if fromGallery { + completion() + } + } + mixin.didFinishWithImage = { [weak self] image in + if let image = image { + completion() + self?.updateProfilePhoto(image) + } + } + mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in + if let image = image, let asset = asset { + completion() + self?.updateProfileVideo(image, asset: asset, adjustments: adjustments) + } + } + mixin.didFinishWithDelete = { [weak self] in + guard let self, let environment = self.environment else { + return + } + + let proceed = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = self.currentAvatarMixin.swap(nil) + let postbox = component.call.accountContext.account.postbox + self.updateAvatarDisposable.set((component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + |> deliverOnMainQueue).start()) + } + + let actionSheet = ActionSheetController(presentationData: presentationData) + let items: [ActionSheetItem] = [ + ActionSheetButtonItem(title: environment.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + proceed() + }) + ] + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } + mixin.didDismiss = { [weak self, weak legacyController] in + guard let self else { + return + } + let _ = self.currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + let menuController = mixin.present() + if let menuController = menuController { + menuController.customRemoveFromParentViewController = { [weak legacyController] in + legacyController?.dismiss() + } + } + }) + } + + private func updateProfilePhoto(_ image: UIImage) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + + let peerId = callState.myPeerId + + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + let postbox = component.call.account.postbox + let signal = peerId.namespace == Namespaces.Peer.CloudUser ? component.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) : component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: component.call.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + + self.updateAvatarDisposable.set((signal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, value) + } + })) + + self.state?.updated(transition: .spring(duration: 0.4)) + } + + private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + let peerId = callState.myPeerId + + let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.accountContext.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + var videoStartTimestamp: Double? = nil + if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { + videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue + } + + let context = component.call.accountContext + let account = context.account + let signal = Signal { [weak self] subscriber in + let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in + if let paintingData = adjustments.paintingData, paintingData.hasAnimation { + return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments) + } else { + return nil + } + } + + let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") + let uploadInterface = LegacyLiveUploadInterface(context: context) + let signal: SSignal + if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer { + let durationSignal: SSignal = SSignal(generator: { subscriber in + let disposable = (entityRenderer.duration()).start(next: { duration in + subscriber.putNext(duration) + subscriber.putCompletion() + }) + + return SBlockDisposable(block: { + disposable.dispose() + }) + }) + signal = durationSignal.map(toSignal: { duration -> SSignal in + if let duration = duration as? Double { + return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)! + } else { + return SSignal.single(nil) + } + }) + + } else if let asset = asset as? AVAsset { + signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)! + } else { + signal = SSignal.complete() + } + + let signalDisposable = signal.start(next: { next in + if let result = next as? TGMediaVideoConversionResult { + if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) { + account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + } + + if let timestamp = videoStartTimestamp { + videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05)) + } + + var value = stat() + if stat(result.fileURL.path, &value) == 0 { + if let data = try? Data(contentsOf: result.fileURL) { + let resource: TelegramMediaResource + if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult { + resource = LocalFileMediaResource(fileId: liveUploadData.id) + } else { + resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + } + account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true) + subscriber.putNext(resource) + + EngineTempBox.shared.dispose(tempFile) + } + } + subscriber.putCompletion() + } else if let progress = next as? NSNumber { + Queue.mainQueue().async { [weak self] in + guard let self else { + return + } + self.currentUpdatingAvatar = (representation, Float(truncating: progress) * 0.25) + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + }, error: { _ in + }, completed: nil) + + let disposable = ActionDisposable { + signalDisposable?.dispose() + } + + return ActionDisposable { + disposable.dispose() + } + } + + self.updateAvatarDisposable.set((signal + |> mapToSignal { videoResource -> Signal in + if peerId.namespace == Namespaces.Peer.CloudUser { + return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } else { + return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } + } + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, 0.25 + value * 0.75) + self.state?.updated(transition: .spring(duration: 0.4)) + } + })) + } + private func openTitleEditing() { guard let component = self.component else { return @@ -1499,6 +2143,12 @@ private final class VideoChatScreenComponent: Component { layout: participantsLayout, expandedInsets: participantsExpandedInsets, safeInsets: participantsSafeInsets, + openParticipantContextMenu: { [weak self] id, sourceView, gesture in + guard let self else { + return + } + self.openParticipantContextMenu(id: id, sourceView: sourceView, gesture: gesture) + }, updateMainParticipant: { [weak self] key in guard let self else { return @@ -1866,3 +2516,25 @@ final class VideoChatScreenV2Impl: ViewControllerComponentContainer, VoiceChatCo } } } + +private final class ParticipantExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool = false + let ignoreContentTouches: Bool = false + let blurBackground: Bool = true + + //let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center + + private let contentView: ContextExtractedContentContainingView + + init(contentView: ContextExtractedContentContainingView) { + self.contentView = contentView + } + + func takeView() -> ContextControllerTakeViewInfo? { + return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index 12f99e345a..349aa6da22 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -180,6 +180,29 @@ public final class PeerListItemComponent: Component { } } + public final class ExtractedTheme: Equatable { + public let inset: CGFloat + public let background: UIColor + + public init(inset: CGFloat, background: UIColor) { + self.inset = inset + self.background = background + } + + public static func ==(lhs: ExtractedTheme, rhs: ExtractedTheme) -> Bool { + if lhs === rhs { + return true + } + if lhs.inset != rhs.inset { + return false + } + if lhs.background != rhs.background { + return false + } + return true + } + } + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings @@ -202,7 +225,8 @@ public final class PeerListItemComponent: Component { let selectionPosition: SelectionPosition let isEnabled: Bool let hasNext: Bool - let action: (EnginePeer, EngineMessage.Id?, UIView?) -> Void + let extractedTheme: ExtractedTheme? + let action: (EnginePeer, EngineMessage.Id?, PeerListItemComponent.View) -> Void let inlineActions: InlineActionsState? let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? let openStories: ((EnginePeer, AvatarNode) -> Void)? @@ -230,7 +254,8 @@ public final class PeerListItemComponent: Component { selectionPosition: SelectionPosition = .left, isEnabled: Bool = true, hasNext: Bool, - action: @escaping (EnginePeer, EngineMessage.Id?, UIView?) -> Void, + extractedTheme: ExtractedTheme? = nil, + action: @escaping (EnginePeer, EngineMessage.Id?, PeerListItemComponent.View) -> Void, inlineActions: InlineActionsState? = nil, contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)? = nil, openStories: ((EnginePeer, AvatarNode) -> Void)? = nil @@ -257,6 +282,7 @@ public final class PeerListItemComponent: Component { self.selectionPosition = selectionPosition self.isEnabled = isEnabled self.hasNext = hasNext + self.extractedTheme = extractedTheme self.action = action self.inlineActions = inlineActions self.contextAction = contextAction @@ -337,7 +363,7 @@ public final class PeerListItemComponent: Component { } public final class View: ContextControllerSourceView, ListSectionComponent.ChildView { - private let extractedContainerView: ContextExtractedContentContainingView + public let extractedContainerView: ContextExtractedContentContainingView private let containerButton: HighlightTrackingButton private let swipeOptionContainer: ListItemSwipeOptionContainer @@ -432,8 +458,16 @@ public final class PeerListItemComponent: Component { guard let self, let component = self.component else { return } + + let extractedBackgroundColor: UIColor + if let extractedTheme = component.extractedTheme { + extractedBackgroundColor = extractedTheme.background + } else { + extractedBackgroundColor = component.theme.rootController.navigationBar.blurredBackgroundColor + } + self.containerButton.clipsToBounds = value - self.containerButton.backgroundColor = value ? component.theme.rootController.navigationBar.blurredBackgroundColor : nil + self.containerButton.backgroundColor = value ? extractedBackgroundColor : nil self.containerButton.layer.cornerRadius = value ? 10.0 : 0.0 } self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] value, transition in @@ -500,7 +534,7 @@ public final class PeerListItemComponent: Component { guard let component = self.component, let peer = component.peer else { return } - component.action(peer, component.message?.id, self.imageNode?.view) + component.action(peer, component.message?.id, self) } @objc private func avatarButtonPressed() { @@ -620,7 +654,16 @@ public final class PeerListItemComponent: Component { labelData = ("", .neutral) } - let contextInset: CGFloat = self.isExtractedToContextMenu ? 12.0 : 0.0 + let contextInset: CGFloat + if self.isExtractedToContextMenu { + if let extractedTheme = component.extractedTheme { + contextInset = extractedTheme.inset + } else { + contextInset = 12.0 + } + } else { + contextInset = 0.0 + } let height: CGFloat let titleFont: UIFont @@ -1104,10 +1147,11 @@ public final class PeerListItemComponent: Component { if let rightAccessoryComponentViewImpl = self.rightAccessoryComponentView?.view, let rightAccessoryComponentSize { var rightAccessoryComponentTransition = transition if rightAccessoryComponentViewImpl.superview == nil { + rightAccessoryComponentViewImpl.isUserInteractionEnabled = false rightAccessoryComponentTransition = rightAccessoryComponentTransition.withAnimation(.none) self.containerButton.addSubview(rightAccessoryComponentViewImpl) } - rightAccessoryComponentTransition.setFrame(view: rightAccessoryComponentViewImpl, frame: CGRect(origin: CGPoint(x: availableSize.width - rightAccessoryComponentSize.width, y: floor((height - verticalInset * 2.0 - rightAccessoryComponentSize.width) / 2.0)), size: rightAccessoryComponentSize)) + rightAccessoryComponentTransition.setFrame(view: rightAccessoryComponentViewImpl, frame: CGRect(origin: CGPoint(x: availableSize.width - (contextInset * 2.0 + component.sideInset) - rightAccessoryComponentSize.width, y: floor((height - verticalInset * 2.0 - rightAccessoryComponentSize.width) / 2.0)), size: rightAccessoryComponentSize)) } var reactionIconTransition = transition diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift index ac6f5ed5ca..ccc85856e5 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -589,7 +589,7 @@ final class StoryItemSetViewListComponent: Component { message: item.message, selectionState: .none, hasNext: index != viewListState.totalCount - 1 || itemLayout.premiumFooterSize != nil, - action: { [weak self] peer, messageId, sourceView in + action: { [weak self] peer, messageId, itemView in guard let self, let component = self.component else { return } @@ -598,7 +598,7 @@ final class StoryItemSetViewListComponent: Component { } if let messageId { component.openMessage(peer, messageId) - } else if let storyItem, let sourceView { + } else if let storyItem, let sourceView = itemView.imageNode?.view { component.openReposts(peer, storyItem.id, sourceView) } else { component.openPeer(peer)