diff --git a/submodules/ChatListUI/BUILD b/submodules/ChatListUI/BUILD index f15878e53f..0b57fe6595 100644 --- a/submodules/ChatListUI/BUILD +++ b/submodules/ChatListUI/BUILD @@ -101,6 +101,7 @@ swift_library( "//submodules/TelegramUI/Components/Settings/ArchiveInfoScreen", "//submodules/TelegramUI/Components/Settings/NewSessionInfoScreen", "//submodules/TelegramUI/Components/Settings/PeerNameColorItem", + "//submodules/Components/MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 0c2544e54f..171afb70d7 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -27,6 +27,7 @@ import ComponentFlow import EmojiStatusComponent import AvatarVideoNode import AppBundle +import MultilineTextComponent public enum ChatListItemContent { public struct ThreadInfo: Equatable { @@ -269,7 +270,9 @@ private final class ChatListItemTagListComponent: Component { func update(context: AccountContext, title: String, backgroundColor: UIColor, foregroundColor: UIColor) -> CGSize { let titleSize = self.title.update( transition: .immediate, - component: AnyComponent(Text(text: title.isEmpty ? " " : title, font: Font.semibold(11.0), color: foregroundColor)), + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: title.isEmpty ? " " : title, font: Font.semibold(11.0), textColor: foregroundColor)) + )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) @@ -800,8 +803,8 @@ private let playIconImage = UIImage(bundleImageName: "Chat List/MiniThumbnailPla private final class ChatListMediaPreviewNode: ASDisplayNode { private let context: AccountContext - private let message: EngineMessage - private let media: EngineMedia + let message: EngineMessage + let media: EngineMedia private let imageNode: TransformImageNode private let playIcon: ASImageNode @@ -862,10 +865,14 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { if file.isInstantVideo { isRound = true } - if file.isAnimated { + if file.isSticker || file.isAnimatedSticker { self.playIcon.isHidden = true } else { - self.playIcon.isHidden = false + if file.isAnimated { + self.playIcon.isHidden = true + } else { + self.playIcon.isHidden = false + } } if let mediaDimensions = file.dimensions { dimensions = mediaDimensions.cgSize @@ -877,9 +884,18 @@ private final class ChatListMediaPreviewNode: ASDisplayNode { } } + let radius: CGFloat + if isRound { + radius = size.width / 2.0 + } else if size.width >= 30.0 { + radius = 8.0 + } else { + radius = 2.0 + } + let makeLayout = self.imageNode.asyncLayout() self.imageNode.frame = CGRect(origin: CGPoint(), size: size) - let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: isRound ? size.width / 2.0 : 2.0), imageSize: dimensions.aspectFilled(size), boundingSize: size, intrinsicInsets: UIEdgeInsets())) + let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: dimensions.aspectFilled(size), boundingSize: size, intrinsicInsets: UIEdgeInsets())) apply() } } @@ -1142,6 +1158,18 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { } } + private struct ContentImageSpec { + var message: EngineMessage + var media: EngineMedia + var size: CGSize + + init(message: EngineMessage, media: EngineMedia, size: CGSize) { + self.message = message + self.media = media + self.size = size + } + } + var item: ChatListItem? private let backgroundNode: ASDisplayNode @@ -1156,7 +1184,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { var avatarIconComponent: EmojiStatusComponent? var avatarVideoNode: AvatarVideoNode? var avatarTapRecognizer: UITapGestureRecognizer? - var avatarMediaNode: AvatarVideoNode? + private var avatarMediaNode: ChatListMediaPreviewNode? private var inlineNavigationMarkLayer: SimpleLayer? @@ -1197,7 +1225,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { private var cachedDataDisposable = MetaDisposable() private var currentTextLeftCutout: CGFloat = 0.0 - private var currentMediaPreviewSpecs: [(message: EngineMessage, media: EngineMedia, size: CGSize)] = [] + private var currentMediaPreviewSpecs: [ContentImageSpec] = [] private var mediaPreviewNodes: [EngineMedia.Id: ChatListMediaPreviewNode] = [:] var selectableControlNode: ItemListSelectableControlNode? @@ -2153,6 +2181,21 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if !itemTags.isEmpty { forumTopicData = nil topForumTopicItems = [] + + if case let .chat(itemPeer, _, _, _, _, _, _) = contentData { + if let messagePeer = itemPeer.chatMainPeer { + switch messagePeer { + case let .channel(channel): + if case .group = channel.info { + useInlineAuthorPrefix = true + } + case .legacyGroup: + useInlineAuthorPrefix = true + default: + break + } + } + } } if useInlineAuthorPrefix { @@ -2175,7 +2218,9 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let contentImageSpacing: CGFloat = 2.0 let forwardedIconSpacing: CGFloat = 6.0 let contentImageTrailingSpace: CGFloat = 5.0 - var contentImageSpecs: [(message: EngineMessage, media: EngineMedia, size: CGSize)] = [] + + var contentImageSpecs: [ContentImageSpec] = [] + var avatarContentImageSpec: ContentImageSpec? var forumThread: (id: Int64, title: String, iconId: Int64?, iconColor: Int32, isUnread: Bool)? var displayForwardedIcon = false @@ -2494,21 +2539,31 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if displayMediaPreviews { let contentImageFillSize = CGSize(width: 8.0, height: contentImageSize.height) _ = contentImageFillSize + + var contentImageIsDisplayedAsAvatar = false + if case let .peer(peerData) = item.content, let customMessageListData = peerData.customMessageListData, customMessageListData.commandPrefix != nil { + contentImageIsDisplayedAsAvatar = true + } + for message in messages { if contentImageSpecs.count >= 3 { break } + inner: for media in message.media { if let image = media as? TelegramMediaImage { if let _ = largestImageRepresentation(image.representations) { let fitSize = contentImageSize - contentImageSpecs.append((message, .image(image), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .image(image), size: fitSize)) } break inner } else if let file = media as? TelegramMediaFile { if file.isVideo, !file.isVideoSticker, let _ = file.dimensions { let fitSize = contentImageSize - contentImageSpecs.append((message, .file(file), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(file), size: fitSize)) + } else if contentImageIsDisplayedAsAvatar && (file.isSticker || file.isVideoSticker) { + let fitSize = contentImageSize + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(file), size: fitSize)) } break inner } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { @@ -2516,36 +2571,41 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if let image = content.image, let type = content.type, imageTypes.contains(type) { if let _ = largestImageRepresentation(image.representations) { let fitSize = contentImageSize - contentImageSpecs.append((message, .image(image), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .image(image), size: fitSize)) } break inner } else if let file = content.file { if file.isVideo, !file.isInstantVideo, let _ = file.dimensions { let fitSize = contentImageSize - contentImageSpecs.append((message, .file(file), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(file), size: fitSize)) } break inner } } else if let action = media as? TelegramMediaAction, case let .suggestedProfilePhoto(image) = action.action, let _ = image { let fitSize = contentImageSize - contentImageSpecs.append((message, .action(action), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .action(action), size: fitSize)) } else if let storyMedia = media as? TelegramMediaStory, let story = message.associatedStories[storyMedia.storyId], !story.data.isEmpty, case let .item(storyItem) = story.get(Stories.StoredItem.self) { if let image = storyItem.media as? TelegramMediaImage { if let _ = largestImageRepresentation(image.representations) { let fitSize = contentImageSize - contentImageSpecs.append((message, .image(image), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .image(image), size: fitSize)) } break inner } else if let file = storyItem.media as? TelegramMediaFile { if file.isVideo, !file.isInstantVideo, let _ = file.dimensions { let fitSize = contentImageSize - contentImageSpecs.append((message, .file(file), fitSize)) + contentImageSpecs.append(ContentImageSpec(message: message, media: .file(file), size: fitSize)) } break inner } } } } + + if contentImageIsDisplayedAsAvatar { + avatarContentImageSpec = contentImageSpecs.first + contentImageSpecs.removeAll() + } } } else { attributedText = NSAttributedString(string: messageText, font: textFont, textColor: theme.messageTextColor) @@ -4056,7 +4116,11 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { } var validMediaIds: [EngineMedia.Id] = [] - for (message, media, mediaSize) in contentImageSpecs { + for spec in contentImageSpecs { + let message = spec.message + let media = spec.media + let mediaSize = spec.size + var mediaId = media.id if mediaId == nil, case let .action(action) = media, case let .suggestedProfilePhoto(image) = action.action { mediaId = image?.id @@ -4095,6 +4159,36 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.currentMediaPreviewSpecs = contentImageSpecs strongSelf.currentTextLeftCutout = textLeftCutout + if let avatarContentImageSpec { + strongSelf.avatarNode.isHidden = true + + if let previous = strongSelf.avatarMediaNode, previous.media != avatarContentImageSpec.media { + strongSelf.avatarMediaNode = nil + previous.removeFromSupernode() + } + + var avatarMediaNodeTransition = transition + let avatarMediaNode: ChatListMediaPreviewNode + if let current = strongSelf.avatarMediaNode { + avatarMediaNode = current + } else { + avatarMediaNodeTransition = .immediate + avatarMediaNode = ChatListMediaPreviewNode(context: item.context, message: avatarContentImageSpec.message, media: avatarContentImageSpec.media) + strongSelf.avatarMediaNode = avatarMediaNode + strongSelf.contextContainer.addSubnode(avatarMediaNode) + } + + avatarMediaNodeTransition.updateFrame(node: avatarMediaNode, frame: avatarFrame) + avatarMediaNode.updateLayout(size: avatarFrame.size, synchronousLoads: synchronousLoads) + } else { + strongSelf.avatarNode.isHidden = false + + if let avatarMediaNode = strongSelf.avatarMediaNode { + strongSelf.avatarMediaNode = nil + avatarMediaNode.removeFromSupernode() + } + } + if !contentDelta.x.isZero || !contentDelta.y.isZero { let titlePosition = strongSelf.titleNode.position transition.animatePosition(node: strongSelf.titleNode, from: CGPoint(x: titlePosition.x - contentDelta.x, y: titlePosition.y - contentDelta.y)) diff --git a/submodules/ContactListUI/Sources/ContactListNode.swift b/submodules/ContactListUI/Sources/ContactListNode.swift index 4d2ca355f5..ef2b12b7b0 100644 --- a/submodules/ContactListUI/Sources/ContactListNode.swift +++ b/submodules/ContactListUI/Sources/ContactListNode.swift @@ -702,9 +702,29 @@ private struct ContactsListNodeTransition { } public enum ContactListPresentation { + public struct Search { + public var signal: Signal + public var searchChatList: Bool + public var searchDeviceContacts: Bool + public var searchGroups: Bool + public var searchChannels: Bool + public var globalSearch: Bool + public var displaySavedMessages: Bool + + public init(signal: Signal, searchChatList: Bool, searchDeviceContacts: Bool, searchGroups: Bool, searchChannels: Bool, globalSearch: Bool, displaySavedMessages: Bool) { + self.signal = signal + self.searchChatList = searchChatList + self.searchDeviceContacts = searchDeviceContacts + self.searchGroups = searchGroups + self.searchChannels = searchChannels + self.globalSearch = globalSearch + self.displaySavedMessages = displaySavedMessages + } + } + case orderedByPresence(options: [ContactListAdditionalOption]) case natural(options: [ContactListAdditionalOption], includeChatList: Bool, topPeers: Bool) - case search(signal: Signal, searchChatList: Bool, searchDeviceContacts: Bool, searchGroups: Bool, searchChannels: Bool, globalSearch: Bool) + case search(Search) public var sortOrder: ContactsSortOrder? { switch self { @@ -1097,7 +1117,15 @@ public final class ContactListNode: ASDisplayNode { displayTopPeers = displayTopPeersValue } - if case let .search(query, searchChatList, searchDeviceContacts, searchGroups, searchChannels, globalSearch) = presentation { + if case let .search(search) = presentation { + let query = search.signal + let searchChatList = search.searchChatList + let searchDeviceContacts = search.searchDeviceContacts + let searchGroups = search.searchGroups + let searchChannels = search.searchChannels + let globalSearch = search.globalSearch + let displaySavedMessages = search.displaySavedMessages + return query |> mapToSignal { query in let foundLocalContacts: Signal<([FoundPeer], [EnginePeer.Id: EnginePeer.Presence]), NoError> @@ -1108,6 +1136,12 @@ public final class ContactListNode: ASDisplayNode { var resultPeers: [FoundPeer] = [] for peer in peers { + if !displaySavedMessages { + if peer.peerId == context.account.peerId { + continue + } + } + if searchGroups || searchChannels { let mainPeer = peer.chatMainPeer if let _ = mainPeer as? TelegramUser { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift index 59579261c2..5e9a6f657b 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_CachedUserData.swift @@ -354,7 +354,7 @@ public final class TelegramBusinessHours: Equatable, Codable { } if minutes.isEmpty { return .closed - } else if minutes == IndexSet(integersIn: 0 ..< 24 * 60) { + } else if minutes == IndexSet(integersIn: 0 ..< 24 * 60) || minutes == IndexSet(integersIn: 0 ..< (24 * 60 - 1)) { return .open } else { return .intervals(day) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift index 166c39df8f..4d1d66772f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift @@ -22,6 +22,10 @@ private func dayBusinessHoursText(presentationData: PresentationData, day: Teleg businessHoursText += "closed" case let .intervals(intervals): func clipMinutes(_ value: Int) -> Int { + var value = value + if value < 0 { + value = 24 * 60 + value + } return value % (24 * 60) } @@ -478,7 +482,13 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode var dayHeights: CGFloat = 0.0 - for i in 0 ..< businessDays.count { + for rawI in 0 ..< businessDays.count { + if rawI == 0 { + //skip current day + continue + } + let i = (rawI + currentDayIndex) % businessDays.count + dayHeights += daySpacing var dayTransition = transition diff --git a/submodules/TelegramUI/Components/Settings/PeerNameColorItem/Sources/PeerNameColorItem.swift b/submodules/TelegramUI/Components/Settings/PeerNameColorItem/Sources/PeerNameColorItem.swift index 43c1869971..95cfb9911d 100644 --- a/submodules/TelegramUI/Components/Settings/PeerNameColorItem/Sources/PeerNameColorItem.swift +++ b/submodules/TelegramUI/Components/Settings/PeerNameColorItem/Sources/PeerNameColorItem.swift @@ -41,7 +41,7 @@ private func generateRingImage(color: UIColor, size: CGSize = CGSize(width: 40.0 }) } -public func generatePeerNameColorImage(nameColor: PeerNameColors.Colors?, isDark: Bool, isLocked: Bool = false, bounds: CGSize = CGSize(width: 40.0, height: 40.0), size: CGSize = CGSize(width: 40.0, height: 40.0)) -> UIImage? { +public func generatePeerNameColorImage(nameColor: PeerNameColors.Colors?, isDark: Bool, isLocked: Bool = false, isEmpty: Bool = false, bounds: CGSize = CGSize(width: 40.0, height: 40.0), size: CGSize = CGSize(width: 40.0, height: 40.0)) -> UIImage? { return generateImage(bounds, rotatedContext: { contextSize, context in let bounds = CGRect(origin: CGPoint(), size: contextSize) context.clear(bounds) @@ -105,6 +105,25 @@ public func generatePeerNameColorImage(nameColor: PeerNameColors.Colors?, isDark context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -imageFrame.midX, y: -imageFrame.midY) + if let cgImage = image.cgImage { + context.clip(to: imageFrame, mask: cgImage) + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fill(imageFrame) + } + } + } else if isEmpty { + if let image = UIImage(bundleImageName: "Chat/Message/SideCloseIcon") { + let scaleFactor: CGFloat = 1.0 + let imageSize = CGSize(width: floor(image.size.width * scaleFactor), height: floor(image.size.height * scaleFactor)) + var imageFrame = CGRect(origin: CGPoint(x: circleBounds.minX + floor((circleBounds.width - imageSize.width) * 0.5), y: circleBounds.minY + floor((circleBounds.height - imageSize.height) * 0.5)), size: imageSize) + imageFrame.origin.y += 0.5 + imageFrame.origin.x += 0.5 + + context.translateBy(x: imageFrame.midX, y: imageFrame.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -imageFrame.midX, y: -imageFrame.midY) + if let cgImage = image.cgImage { context.clip(to: imageFrame, mask: cgImage) context.setFillColor(UIColor.clear.cgColor) @@ -212,7 +231,7 @@ private final class PeerNameColorIconItemNode : ASDisplayNode { self.item = item if updatedAccentColor { - self.fillNode.image = generatePeerNameColorImage(nameColor: item.colors, isDark: item.isDark, isLocked: item.selected && item.isLocked, bounds: size, size: size) + self.fillNode.image = generatePeerNameColorImage(nameColor: item.colors, isDark: item.isDark, isLocked: item.selected && item.isLocked, isEmpty: item.colors == nil, bounds: size, size: size) self.ringNode.image = generateRingImage(color: item.colors?.main ?? UIColor(rgb: 0x798896), size: size) } diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index 8ed36ffca5..177d376c49 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -252,6 +252,8 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { var searchGroups = false var searchChannels = false var globalSearch = false + var displaySavedMessages = true + var filters = filters switch mode { case .groupCreation, .channelCreation: globalSearch = true @@ -260,15 +262,31 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { searchGroups = searchGroupsValue searchChannels = searchChannelsValue globalSearch = true - case .chatSelection: - searchChatList = true - searchGroups = true - searchChannels = true + case let .chatSelection(chatSelection): + if chatSelection.onlyUsers { + searchChatList = true + searchGroups = false + searchChannels = false + displaySavedMessages = false + filters.append(.excludeSelf) + } else { + searchChatList = true + searchGroups = true + searchChannels = true + } globalSearch = false case .premiumGifting, .requestedUsersSelection: searchChatList = true } - let searchResultsNode = ContactListNode(context: context, presentation: .single(.search(signal: searchText.get(), searchChatList: searchChatList, searchDeviceContacts: false, searchGroups: searchGroups, searchChannels: searchChannels, globalSearch: globalSearch)), filters: filters, onlyWriteable: strongSelf.onlyWriteable, isPeerEnabled: strongSelf.isPeerEnabled, selectionState: selectionState, isSearch: true) + let searchResultsNode = ContactListNode(context: context, presentation: .single(.search(ContactListPresentation.Search( + signal: searchText.get(), + searchChatList: searchChatList, + searchDeviceContacts: false, + searchGroups: searchGroups, + searchChannels: searchChannels, + globalSearch: globalSearch, + displaySavedMessages: displaySavedMessages + ))), filters: filters, onlyWriteable: strongSelf.onlyWriteable, isPeerEnabled: strongSelf.isPeerEnabled, selectionState: selectionState, isSearch: true) searchResultsNode.openPeer = { peer, _ in self?.tokenListNode.setText("") self?.openPeer?(peer)