mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
4062 lines
233 KiB
Swift
4062 lines
233 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import TelegramPresentationData
|
|
import ItemListUI
|
|
import PresentationDataUtils
|
|
import AvatarNode
|
|
import TelegramStringFormatting
|
|
import AccountContext
|
|
import PeerOnlineMarkerNode
|
|
import LocalizedPeerData
|
|
import PeerPresenceStatusManager
|
|
import PhotoResources
|
|
import ChatListSearchItemNode
|
|
import ContextUI
|
|
import ChatInterfaceState
|
|
import TextFormat
|
|
import InvisibleInkDustNode
|
|
import GalleryUI
|
|
import HierarchyTrackingLayer
|
|
import TextNodeWithEntities
|
|
import ComponentFlow
|
|
import EmojiStatusComponent
|
|
import AvatarVideoNode
|
|
|
|
public enum ChatListItemContent {
|
|
public struct ThreadInfo: Equatable {
|
|
public var id: Int64
|
|
public var info: EngineMessageHistoryThread.Info
|
|
public var isOwnedByMe: Bool
|
|
public var isClosed: Bool
|
|
public var isHidden: Bool
|
|
|
|
public init(id: Int64, info: EngineMessageHistoryThread.Info, isOwnedByMe: Bool, isClosed: Bool, isHidden: Bool) {
|
|
self.id = id
|
|
self.info = info
|
|
self.isOwnedByMe = isOwnedByMe
|
|
self.isClosed = isClosed
|
|
self.isHidden = isHidden
|
|
}
|
|
}
|
|
|
|
public final class DraftState: Equatable {
|
|
let text: String
|
|
let entities: [MessageTextEntity]
|
|
|
|
public init(draft: EngineChatList.Draft) {
|
|
self.text = draft.text
|
|
self.entities = draft.entities
|
|
}
|
|
|
|
public static func ==(lhs: DraftState, rhs: DraftState) -> Bool {
|
|
if lhs.text != rhs.text {
|
|
return false
|
|
}
|
|
if lhs.entities != rhs.entities {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
public struct StoryState: Equatable {
|
|
public var stats: EngineChatList.StoryStats
|
|
public var hasUnseenCloseFriends: Bool
|
|
|
|
public init(
|
|
stats: EngineChatList.StoryStats,
|
|
hasUnseenCloseFriends: Bool
|
|
) {
|
|
self.stats = stats
|
|
self.hasUnseenCloseFriends = hasUnseenCloseFriends
|
|
}
|
|
}
|
|
|
|
public struct PeerData {
|
|
public var messages: [EngineMessage]
|
|
public var peer: EngineRenderedPeer
|
|
public var threadInfo: ThreadInfo?
|
|
public var combinedReadState: EnginePeerReadCounters?
|
|
public var isRemovedFromTotalUnreadCount: Bool
|
|
public var presence: EnginePeer.Presence?
|
|
public var hasUnseenMentions: Bool
|
|
public var hasUnseenReactions: Bool
|
|
public var draftState: DraftState?
|
|
public var inputActivities: [(EnginePeer, PeerInputActivity)]?
|
|
public var promoInfo: ChatListNodeEntryPromoInfo?
|
|
public var ignoreUnreadBadge: Bool
|
|
public var displayAsMessage: Bool
|
|
public var hasFailedMessages: Bool
|
|
public var forumTopicData: EngineChatList.ForumTopicData?
|
|
public var topForumTopicItems: [EngineChatList.ForumTopicData]
|
|
public var autoremoveTimeout: Int32?
|
|
public var storyState: StoryState?
|
|
|
|
public init(
|
|
messages: [EngineMessage],
|
|
peer: EngineRenderedPeer,
|
|
threadInfo: ThreadInfo?,
|
|
combinedReadState: EnginePeerReadCounters?,
|
|
isRemovedFromTotalUnreadCount: Bool,
|
|
presence: EnginePeer.Presence?,
|
|
hasUnseenMentions: Bool,
|
|
hasUnseenReactions: Bool,
|
|
draftState: DraftState?,
|
|
inputActivities: [(EnginePeer, PeerInputActivity)]?,
|
|
promoInfo: ChatListNodeEntryPromoInfo?,
|
|
ignoreUnreadBadge: Bool,
|
|
displayAsMessage: Bool,
|
|
hasFailedMessages: Bool,
|
|
forumTopicData: EngineChatList.ForumTopicData?,
|
|
topForumTopicItems: [EngineChatList.ForumTopicData],
|
|
autoremoveTimeout: Int32?,
|
|
storyState: StoryState?
|
|
) {
|
|
self.messages = messages
|
|
self.peer = peer
|
|
self.threadInfo = threadInfo
|
|
self.combinedReadState = combinedReadState
|
|
self.isRemovedFromTotalUnreadCount = isRemovedFromTotalUnreadCount
|
|
self.presence = presence
|
|
self.hasUnseenMentions = hasUnseenMentions
|
|
self.hasUnseenReactions = hasUnseenReactions
|
|
self.draftState = draftState
|
|
self.inputActivities = inputActivities
|
|
self.promoInfo = promoInfo
|
|
self.ignoreUnreadBadge = ignoreUnreadBadge
|
|
self.displayAsMessage = displayAsMessage
|
|
self.hasFailedMessages = hasFailedMessages
|
|
self.forumTopicData = forumTopicData
|
|
self.topForumTopicItems = topForumTopicItems
|
|
self.autoremoveTimeout = autoremoveTimeout
|
|
self.storyState = storyState
|
|
}
|
|
}
|
|
|
|
public struct GroupReferenceData {
|
|
public var groupId: EngineChatList.Group
|
|
public var peers: [EngineChatList.GroupItem.Item]
|
|
public var message: EngineMessage?
|
|
public var unreadCount: Int
|
|
public var hiddenByDefault: Bool
|
|
public var storyState: StoryState?
|
|
|
|
public init(
|
|
groupId: EngineChatList.Group,
|
|
peers: [EngineChatList.GroupItem.Item],
|
|
message: EngineMessage?,
|
|
unreadCount: Int,
|
|
hiddenByDefault: Bool,
|
|
storyState: StoryState?
|
|
) {
|
|
self.groupId = groupId
|
|
self.peers = peers
|
|
self.message = message
|
|
self.unreadCount = unreadCount
|
|
self.hiddenByDefault = hiddenByDefault
|
|
self.storyState = storyState
|
|
}
|
|
}
|
|
|
|
case peer(PeerData)
|
|
case groupReference(GroupReferenceData)
|
|
|
|
public var chatLocation: ChatLocation? {
|
|
switch self {
|
|
case let .peer(peerData):
|
|
return .peer(id: peerData.peer.peerId)
|
|
case .groupReference:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
public class ChatListItem: ListViewItem, ChatListSearchItemNeighbour {
|
|
let presentationData: ChatListPresentationData
|
|
let context: AccountContext
|
|
let chatListLocation: ChatListControllerLocation
|
|
let filterData: ChatListItemFilterData?
|
|
let index: EngineChatList.Item.Index
|
|
public let content: ChatListItemContent
|
|
let editing: Bool
|
|
let hasActiveRevealControls: Bool
|
|
let selected: Bool
|
|
let enableContextActions: Bool
|
|
let hiddenOffset: Bool
|
|
let interaction: ChatListNodeInteraction
|
|
|
|
public let selectable: Bool = true
|
|
|
|
public var approximateHeight: CGFloat {
|
|
return self.hiddenOffset ? 0.0 : 44.0
|
|
}
|
|
|
|
let header: ListViewItemHeader?
|
|
|
|
public var isPinned: Bool {
|
|
switch self.index {
|
|
case let .chatList(index):
|
|
return index.pinningIndex != nil
|
|
case let .forum(pinnedIndex, _, _, _, _):
|
|
if case .index = pinnedIndex {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
public init(presentationData: ChatListPresentationData, context: AccountContext, chatListLocation: ChatListControllerLocation, filterData: ChatListItemFilterData?, index: EngineChatList.Item.Index, content: ChatListItemContent, editing: Bool, hasActiveRevealControls: Bool, selected: Bool, header: ListViewItemHeader?, enableContextActions: Bool, hiddenOffset: Bool, interaction: ChatListNodeInteraction) {
|
|
self.presentationData = presentationData
|
|
self.chatListLocation = chatListLocation
|
|
self.filterData = filterData
|
|
self.context = context
|
|
self.index = index
|
|
self.content = content
|
|
self.editing = editing
|
|
self.hasActiveRevealControls = hasActiveRevealControls
|
|
self.selected = selected
|
|
self.header = header
|
|
self.enableContextActions = enableContextActions
|
|
self.hiddenOffset = hiddenOffset
|
|
self.interaction = interaction
|
|
}
|
|
|
|
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
|
|
async {
|
|
let node = ChatListItemNode()
|
|
let (first, last, firstWithHeader, nextIsPinned) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
|
|
node.insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader)
|
|
|
|
let (nodeLayout, apply) = node.asyncLayout()(self, params, first, last, firstWithHeader, nextIsPinned)
|
|
|
|
node.insets = nodeLayout.insets
|
|
node.contentSize = nodeLayout.contentSize
|
|
|
|
Queue.mainQueue().async {
|
|
completion(node, {
|
|
return (nil, { _ in
|
|
node.setupItem(item: self, synchronousLoads: synchronousLoads)
|
|
apply(synchronousLoads, false)
|
|
node.updateIsHighlighted(transition: .immediate)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
|
|
Queue.mainQueue().async {
|
|
assert(node() is ChatListItemNode)
|
|
if let nodeValue = node() as? ChatListItemNode {
|
|
nodeValue.setupItem(item: self, synchronousLoads: false)
|
|
let layout = nodeValue.asyncLayout()
|
|
async {
|
|
let (first, last, firstWithHeader, nextIsPinned) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
|
|
var animated = true
|
|
if case .None = animation {
|
|
animated = false
|
|
}
|
|
|
|
let (nodeLayout, apply) = layout(self, params, first, last, firstWithHeader, nextIsPinned)
|
|
Queue.mainQueue().async {
|
|
completion(nodeLayout, { _ in
|
|
apply(false, animated)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func selected(listView: ListView) {
|
|
switch self.content {
|
|
case let .peer(peerData):
|
|
if let message = peerData.messages.last, let peer = peerData.peer.peer {
|
|
var threadId: Int64?
|
|
if case let .forum(_, _, threadIdValue, _, _) = self.index {
|
|
threadId = threadIdValue
|
|
}
|
|
if threadId == nil, self.interaction.searchTextHighightState != nil, case let .channel(channel) = peerData.peer.peer, channel.flags.contains(.isForum) {
|
|
threadId = message.threadId
|
|
}
|
|
self.interaction.messageSelected(peer, threadId, message, peerData.promoInfo)
|
|
} else if let peer = peerData.peer.peer {
|
|
self.interaction.peerSelected(peer, nil, nil, peerData.promoInfo)
|
|
} else if let peer = peerData.peer.peers[peerData.peer.peerId] {
|
|
self.interaction.peerSelected(peer, nil, nil, peerData.promoInfo)
|
|
}
|
|
case let .groupReference(groupReferenceData):
|
|
self.interaction.groupSelected(groupReferenceData.groupId)
|
|
}
|
|
}
|
|
|
|
static func mergeType(item: ChatListItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool, nextIsPinned: Bool) {
|
|
var first = false
|
|
var last = false
|
|
var firstWithHeader = false
|
|
if let previousItem = previousItem {
|
|
if let header = item.header {
|
|
if let previousItem = previousItem as? ChatListItem {
|
|
firstWithHeader = header.id != previousItem.header?.id
|
|
} else {
|
|
firstWithHeader = true
|
|
}
|
|
}
|
|
} else {
|
|
first = true
|
|
firstWithHeader = item.header != nil
|
|
}
|
|
var nextIsPinned = false
|
|
if let nextItem = nextItem as? ChatListItem {
|
|
if case let .chatList(nextIndex) = nextItem.index, nextIndex.pinningIndex != nil {
|
|
nextIsPinned = true
|
|
}
|
|
} else {
|
|
last = true
|
|
}
|
|
return (first, last, firstWithHeader, nextIsPinned)
|
|
}
|
|
}
|
|
|
|
private let pinIcon = ItemListRevealOptionIcon.animation(animation: "anim_pin", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let unpinIcon = ItemListRevealOptionIcon.animation(animation: "anim_unpin", scale: 1.0, offset: 0.0, replaceColors: [0x1993fa], flip: false)
|
|
private let muteIcon = ItemListRevealOptionIcon.animation(animation: "anim_mute", scale: 1.0, offset: 0.0, replaceColors: [0xff9500], flip: false)
|
|
private let unmuteIcon = ItemListRevealOptionIcon.animation(animation: "anim_unmute", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let deleteIcon = ItemListRevealOptionIcon.animation(animation: "anim_delete", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let groupIcon = ItemListRevealOptionIcon.animation(animation: "anim_group", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let ungroupIcon = ItemListRevealOptionIcon.animation(animation: "anim_ungroup", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let readIcon = ItemListRevealOptionIcon.animation(animation: "anim_read", scale: 1.0, offset: 0.0, replaceColors: nil, flip: false)
|
|
private let unreadIcon = ItemListRevealOptionIcon.animation(animation: "anim_unread", scale: 1.0, offset: 0.0, replaceColors: [0x2194fa], flip: false)
|
|
private let archiveIcon = ItemListRevealOptionIcon.animation(animation: "anim_archive", scale: 1.0, offset: 2.0, replaceColors: [0xa9a9ad], flip: false)
|
|
private let unarchiveIcon = ItemListRevealOptionIcon.animation(animation: "anim_unarchive", scale: 0.642, offset: -9.0, replaceColors: [0xa9a9ad], flip: false)
|
|
private let hideIcon = ItemListRevealOptionIcon.animation(animation: "anim_hide", scale: 1.0, offset: 2.0, replaceColors: [0xbdbdc2], flip: false)
|
|
private let unhideIcon = ItemListRevealOptionIcon.animation(animation: "anim_hide", scale: 1.0, offset: -20.0, replaceColors: [0xbdbdc2], flip: true)
|
|
private let startIcon = ItemListRevealOptionIcon.animation(animation: "anim_play", scale: 1.0, offset: 0.0, replaceColors: [0xbdbdc2], flip: false)
|
|
private let closeIcon = ItemListRevealOptionIcon.animation(animation: "anim_pause", scale: 1.0, offset: 0.0, replaceColors: [0xbdbdc2], flip: false)
|
|
|
|
private enum RevealOptionKey: Int32 {
|
|
case pin
|
|
case unpin
|
|
case mute
|
|
case unmute
|
|
case delete
|
|
case group
|
|
case ungroup
|
|
case toggleMarkedUnread
|
|
case archive
|
|
case unarchive
|
|
case hide
|
|
case unhide
|
|
case hidePsa
|
|
case open
|
|
case close
|
|
}
|
|
|
|
private func canArchivePeer(id: EnginePeer.Id, accountPeerId: EnginePeer.Id) -> Bool {
|
|
if id.namespace == Namespaces.Peer.CloudUser && id.id._internalGetInt64Value() == 777000 {
|
|
return false
|
|
}
|
|
if id == accountPeerId {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public struct ChatListItemFilterData: Equatable {
|
|
public var excludesArchived: Bool
|
|
|
|
public init(excludesArchived: Bool) {
|
|
self.excludesArchived = excludesArchived
|
|
}
|
|
}
|
|
|
|
private func revealOptions(strings: PresentationStrings, theme: PresentationTheme, isPinned: Bool, isMuted: Bool?, location: ChatListControllerLocation, peerId: EnginePeer.Id, accountPeerId: EnginePeer.Id, canDelete: Bool, isEditing: Bool, filterData: ChatListItemFilterData?) -> [ItemListRevealOption] {
|
|
var options: [ItemListRevealOption] = []
|
|
if !isEditing {
|
|
if case .chatList(.archive) = location {
|
|
if isPinned {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unpin.rawValue, title: strings.DialogList_Unpin, icon: unpinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.pin.rawValue, title: strings.DialogList_Pin, icon: pinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
}
|
|
} else {
|
|
if let isMuted = isMuted {
|
|
if isMuted {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unmute.rawValue, title: strings.ChatList_Unmute, icon: unmuteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.mute.rawValue, title: strings.ChatList_Mute, icon: muteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if canDelete {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.delete.rawValue, title: strings.Common_Delete, icon: deleteIcon, color: theme.list.itemDisclosureActions.destructive.fillColor, textColor: theme.list.itemDisclosureActions.destructive.foregroundColor))
|
|
}
|
|
if !isEditing {
|
|
var canArchive = false
|
|
var canUnarchive = false
|
|
if let filterData = filterData {
|
|
if filterData.excludesArchived {
|
|
canArchive = true
|
|
}
|
|
} else {
|
|
if case let .chatList(groupId) = location {
|
|
if case .root = groupId {
|
|
canArchive = true
|
|
} else {
|
|
canUnarchive = true
|
|
}
|
|
}
|
|
}
|
|
if canArchive {
|
|
if canArchivePeer(id: peerId, accountPeerId: accountPeerId) {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.archive.rawValue, title: strings.ChatList_ArchiveAction, icon: archiveIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.inactive.foregroundColor))
|
|
}
|
|
} else if canUnarchive {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unarchive.rawValue, title: strings.ChatList_UnarchiveAction, icon: unarchiveIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.inactive.foregroundColor))
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
private func groupReferenceRevealOptions(strings: PresentationStrings, theme: PresentationTheme, isEditing: Bool, hiddenByDefault: Bool) -> [ItemListRevealOption] {
|
|
var options: [ItemListRevealOption] = []
|
|
if !isEditing {
|
|
if hiddenByDefault {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unhide.rawValue, title: strings.ChatList_UnhideAction, icon: unhideIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.hide.rawValue, title: strings.ChatList_HideAction, icon: hideIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.neutral1.foregroundColor))
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
private func forumGeneralRevealOptions(strings: PresentationStrings, theme: PresentationTheme, isMuted: Bool?, isClosed: Bool, isEditing: Bool, canOpenClose: Bool, canHide: Bool, hiddenByDefault: Bool) -> [ItemListRevealOption] {
|
|
var options: [ItemListRevealOption] = []
|
|
if !isEditing {
|
|
if let isMuted = isMuted {
|
|
if isMuted {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unmute.rawValue, title: strings.ChatList_Unmute, icon: unmuteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.mute.rawValue, title: strings.ChatList_Mute, icon: muteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
if canOpenClose && !hiddenByDefault {
|
|
if !isEditing {
|
|
if !isClosed {
|
|
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.open.rawValue, title: strings.ChatList_StartAction, icon: startIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
if canHide {
|
|
if !isEditing {
|
|
if hiddenByDefault {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unhide.rawValue, title: strings.ChatList_ThreadUnhideAction, icon: unhideIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.hide.rawValue, title: strings.ChatList_ThreadHideAction, icon: hideIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.neutral1.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
private func forumThreadRevealOptions(strings: PresentationStrings, theme: PresentationTheme, isMuted: Bool?, isClosed: Bool, isEditing: Bool, canOpenClose: Bool, canDelete: Bool) -> [ItemListRevealOption] {
|
|
var options: [ItemListRevealOption] = []
|
|
if !isEditing {
|
|
if let isMuted = isMuted {
|
|
if isMuted {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unmute.rawValue, title: strings.ChatList_Unmute, icon: unmuteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.mute.rawValue, title: strings.ChatList_Mute, icon: muteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
if canDelete {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.delete.rawValue, title: strings.Common_Delete, icon: deleteIcon, color: theme.list.itemDisclosureActions.destructive.fillColor, textColor: theme.list.itemDisclosureActions.destructive.foregroundColor))
|
|
}
|
|
if canOpenClose {
|
|
if !isEditing {
|
|
if !isClosed {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.close.rawValue, title: strings.ChatList_CloseAction, icon: closeIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.inactive.foregroundColor))
|
|
} else {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.open.rawValue, title: strings.ChatList_StartAction, icon: startIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
private func leftRevealOptions(strings: PresentationStrings, theme: PresentationTheme, isUnread: Bool, isEditing: Bool, isPinned: Bool, isSavedMessages: Bool, location: ChatListControllerLocation, peer: EnginePeer, filterData: ChatListItemFilterData?) -> [ItemListRevealOption] {
|
|
switch location {
|
|
case let .chatList(groupId):
|
|
if case .root = groupId {
|
|
var options: [ItemListRevealOption] = []
|
|
if isUnread {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.toggleMarkedUnread.rawValue, title: strings.DialogList_Read, icon: readIcon, color: theme.list.itemDisclosureActions.inactive.fillColor, textColor: theme.list.itemDisclosureActions.neutral1.foregroundColor))
|
|
} else {
|
|
var canMarkUnread = true
|
|
if case let .channel(channel) = peer, channel.flags.contains(.isForum) {
|
|
canMarkUnread = false
|
|
}
|
|
|
|
if canMarkUnread {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.toggleMarkedUnread.rawValue, title: strings.DialogList_Unread, icon: unreadIcon, color: theme.list.itemDisclosureActions.accent.fillColor, textColor: theme.list.itemDisclosureActions.accent.foregroundColor))
|
|
}
|
|
}
|
|
if !isEditing {
|
|
if isPinned {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.unpin.rawValue, title: strings.DialogList_Unpin, icon: unpinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
} else {
|
|
if filterData == nil || peer.id.namespace != Namespaces.Peer.SecretChat {
|
|
options.append(ItemListRevealOption(key: RevealOptionKey.pin.rawValue, title: strings.DialogList_Pin, icon: pinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor))
|
|
}
|
|
}
|
|
}
|
|
return options
|
|
} else {
|
|
return []
|
|
}
|
|
case .forum:
|
|
return []
|
|
case .savedMessagesChats:
|
|
return []
|
|
}
|
|
}
|
|
|
|
private final class ChatListItemAccessibilityCustomAction: UIAccessibilityCustomAction {
|
|
let key: Int32
|
|
|
|
init(name: String, target: Any?, selector: Selector, key: Int32) {
|
|
self.key = key
|
|
|
|
super.init(name: name, target: target, selector: selector)
|
|
}
|
|
}
|
|
|
|
private let separatorHeight = 1.0 / UIScreen.main.scale
|
|
|
|
private final class CachedChatListSearchResult {
|
|
let text: String
|
|
let searchQuery: String
|
|
let resultRanges: [Range<String.Index>]
|
|
|
|
init(text: String, searchQuery: String, resultRanges: [Range<String.Index>]) {
|
|
self.text = text
|
|
self.searchQuery = searchQuery
|
|
self.resultRanges = resultRanges
|
|
}
|
|
|
|
func matches(text: String, searchQuery: String) -> Bool {
|
|
if self.text != text {
|
|
return false
|
|
}
|
|
if self.searchQuery != searchQuery {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private final class CachedCustomTextEntities {
|
|
let text: String
|
|
let textEntities: [MessageTextEntity]
|
|
|
|
init(text: String, textEntities: [MessageTextEntity]) {
|
|
self.text = text
|
|
self.textEntities = textEntities
|
|
}
|
|
|
|
func matches(text: String) -> Bool {
|
|
if self.text != text {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private let playIconImage = UIImage(bundleImageName: "Chat List/MiniThumbnailPlay")?.precomposed()
|
|
|
|
private final class ChatListMediaPreviewNode: ASDisplayNode {
|
|
private let context: AccountContext
|
|
private let message: EngineMessage
|
|
private let media: EngineMedia
|
|
|
|
private let imageNode: TransformImageNode
|
|
private let playIcon: ASImageNode
|
|
|
|
private var requestedImage: Bool = false
|
|
private var disposable: Disposable?
|
|
|
|
init(context: AccountContext, message: EngineMessage, media: EngineMedia) {
|
|
self.context = context
|
|
self.message = message
|
|
self.media = media
|
|
|
|
self.imageNode = TransformImageNode()
|
|
self.playIcon = ASImageNode()
|
|
self.playIcon.image = playIconImage
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.imageNode)
|
|
self.addSubnode(self.playIcon)
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
}
|
|
|
|
func updateLayout(size: CGSize, synchronousLoads: Bool) {
|
|
if let image = self.playIcon.image {
|
|
self.playIcon.frame = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
let hasSpoiler = self.message.attributes.contains(where: { $0 is MediaSpoilerMessageAttribute })
|
|
|
|
var isRound = false
|
|
var dimensions = CGSize(width: 100.0, height: 100.0)
|
|
if case let .image(image) = self.media {
|
|
self.playIcon.isHidden = true
|
|
if let largest = largestImageRepresentation(image.representations) {
|
|
dimensions = largest.dimensions.cgSize
|
|
if !self.requestedImage {
|
|
self.requestedImage = true
|
|
let signal = mediaGridMessagePhoto(account: self.context.account, userLocation: .peer(self.message.id.peerId), photoReference: .message(message: MessageReference(self.message._asMessage()), media: image), fullRepresentationSize: CGSize(width: 36.0, height: 36.0), blurred: hasSpoiler, synchronousLoad: synchronousLoads)
|
|
self.imageNode.setSignal(signal, attemptSynchronously: synchronousLoads)
|
|
}
|
|
}
|
|
} else if case let .action(action) = self.media, case let .suggestedProfilePhoto(image) = action.action, let image = image {
|
|
isRound = true
|
|
self.playIcon.isHidden = true
|
|
if let largest = largestImageRepresentation(image.representations) {
|
|
dimensions = largest.dimensions.cgSize
|
|
if !self.requestedImage {
|
|
self.requestedImage = true
|
|
let signal = mediaGridMessagePhoto(account: self.context.account, userLocation: .peer(self.message.id.peerId), photoReference: .message(message: MessageReference(self.message._asMessage()), media: image), fullRepresentationSize: CGSize(width: 36.0, height: 36.0), synchronousLoad: synchronousLoads)
|
|
self.imageNode.setSignal(signal, attemptSynchronously: synchronousLoads)
|
|
}
|
|
}
|
|
} else if case let .file(file) = self.media {
|
|
if file.isInstantVideo {
|
|
isRound = true
|
|
}
|
|
if file.isAnimated {
|
|
self.playIcon.isHidden = true
|
|
} else {
|
|
self.playIcon.isHidden = false
|
|
}
|
|
if let mediaDimensions = file.dimensions {
|
|
dimensions = mediaDimensions.cgSize
|
|
if !self.requestedImage {
|
|
self.requestedImage = true
|
|
let signal = mediaGridMessageVideo(postbox: self.context.account.postbox, userLocation: .peer(self.message.id.peerId), videoReference: .message(message: MessageReference(self.message._asMessage()), media: file), synchronousLoad: synchronousLoads, autoFetchFullSizeThumbnail: true, useMiniThumbnailIfAvailable: true, blurred: hasSpoiler)
|
|
self.imageNode.setSignal(signal, attemptSynchronously: synchronousLoads)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()))
|
|
apply()
|
|
}
|
|
}
|
|
|
|
private let loginCodeRegex = try? NSRegularExpression(pattern: "[\\d\\-]{5,7}", options: [])
|
|
|
|
class ChatListItemNode: ItemListRevealOptionsItemNode {
|
|
final class TopicItemNode: ASDisplayNode {
|
|
let topicTitleNode: TextNode
|
|
let titleTopicIconView: ComponentHostView<Empty>
|
|
var titleTopicIconComponent: EmojiStatusComponent
|
|
|
|
var visibilityStatus: Bool = false {
|
|
didSet {
|
|
if self.visibilityStatus != oldValue {
|
|
let _ = self.titleTopicIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(self.titleTopicIconComponent.withVisibleForAnimations(self.visibilityStatus)),
|
|
environment: {},
|
|
containerSize: self.titleTopicIconView.bounds.size
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private init(topicTitleNode: TextNode, titleTopicIconView: ComponentHostView<Empty>, titleTopicIconComponent: EmojiStatusComponent) {
|
|
self.topicTitleNode = topicTitleNode
|
|
self.titleTopicIconView = titleTopicIconView
|
|
self.titleTopicIconComponent = titleTopicIconComponent
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.topicTitleNode)
|
|
self.view.addSubview(self.titleTopicIconView)
|
|
}
|
|
|
|
static func asyncLayout(_ currentNode: TopicItemNode?) -> (_ constrainedWidth: CGFloat, _ context: AccountContext, _ theme: PresentationTheme, _ threadId: Int64, _ title: NSAttributedString, _ iconId: Int64?, _ iconColor: Int32) -> (CGSize, () -> TopicItemNode) {
|
|
let makeTopicTitleLayout = TextNode.asyncLayout(currentNode?.topicTitleNode)
|
|
|
|
return { constrainedWidth, context, theme, threadId, title, iconId, iconColor in
|
|
let remainingWidth = max(1.0, constrainedWidth - (18.0 + 2.0))
|
|
|
|
let topicTitleArguments = TextNodeLayoutArguments(attributedString: title, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: remainingWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0))
|
|
|
|
let topicTitleLayout = makeTopicTitleLayout(topicTitleArguments)
|
|
|
|
return (CGSize(width: 18.0 + 2.0 + topicTitleLayout.0.size.width, height: topicTitleLayout.0.size.height), {
|
|
let topicTitleNode = topicTitleLayout.1()
|
|
|
|
let titleTopicIconView: ComponentHostView<Empty>
|
|
if let current = currentNode?.titleTopicIconView {
|
|
titleTopicIconView = current
|
|
} else {
|
|
titleTopicIconView = ComponentHostView<Empty>()
|
|
}
|
|
|
|
let titleTopicIconContent: EmojiStatusComponent.Content
|
|
if threadId == 1 {
|
|
titleTopicIconContent = .image(image: PresentationResourcesChatList.generalTopicSmallIcon(theme))
|
|
} else if let fileId = iconId, fileId != 0 {
|
|
titleTopicIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 36.0, height: 36.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .count(0))
|
|
} else {
|
|
titleTopicIconContent = .topic(title: String(title.string.prefix(1)), color: iconColor, size: CGSize(width: 18.0, height: 18.0))
|
|
}
|
|
|
|
let titleTopicIconComponent = EmojiStatusComponent(
|
|
context: context,
|
|
animationCache: context.animationCache,
|
|
animationRenderer: context.animationRenderer,
|
|
content: titleTopicIconContent,
|
|
isVisibleForAnimations: (currentNode?.visibilityStatus ?? false) && context.sharedContext.energyUsageSettings.loopEmoji,
|
|
action: nil
|
|
)
|
|
|
|
let targetNode = currentNode ?? TopicItemNode(topicTitleNode: topicTitleNode, titleTopicIconView: titleTopicIconView, titleTopicIconComponent: titleTopicIconComponent)
|
|
|
|
targetNode.titleTopicIconComponent = titleTopicIconComponent
|
|
|
|
let iconSize = titleTopicIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(titleTopicIconComponent),
|
|
environment: {},
|
|
containerSize: CGSize(width: 18.0, height: 18.0)
|
|
)
|
|
titleTopicIconView.frame = CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: iconSize)
|
|
|
|
topicTitleNode.frame = CGRect(origin: CGPoint(x: 18.0 + 2.0, y: 0.0), size: topicTitleLayout.0.size)
|
|
|
|
return targetNode
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
final class AuthorNode: ASDisplayNode {
|
|
let authorNode: TextNode
|
|
var titleTopicArrowNode: ASImageNode?
|
|
var topicNodes: [Int64: TopicItemNode] = [:]
|
|
var topicNodeOrder: [Int64] = []
|
|
|
|
var visibilityStatus: Bool = false {
|
|
didSet {
|
|
if self.visibilityStatus != oldValue {
|
|
for (_, topicNode) in self.topicNodes {
|
|
topicNode.visibilityStatus = self.visibilityStatus
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override init() {
|
|
self.authorNode = TextNode()
|
|
self.authorNode.displaysAsynchronously = true
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.authorNode)
|
|
}
|
|
|
|
func setFirstTopicHighlighted(_ isHighlighted: Bool) {
|
|
guard let id = self.topicNodeOrder.first, let itemNode = self.topicNodes[id] else {
|
|
return
|
|
}
|
|
if isHighlighted {
|
|
itemNode.layer.removeAnimation(forKey: "opacity")
|
|
itemNode.alpha = 0.65
|
|
} else {
|
|
itemNode.alpha = 1.0
|
|
itemNode.layer.animateAlpha(from: 0.65, to: 1.0, duration: 0.2)
|
|
}
|
|
}
|
|
|
|
func assignParentNode(parentNode: ASDisplayNode?) {
|
|
for (id, topicNode) in self.topicNodes {
|
|
if id == self.topicNodeOrder.first, let parentNode {
|
|
if topicNode.supernode !== parentNode {
|
|
parentNode.addSubnode(topicNode)
|
|
}
|
|
} else {
|
|
if topicNode.supernode !== self {
|
|
self.addSubnode(topicNode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func asyncLayout() -> (_ context: AccountContext, _ constrainedWidth: CGFloat, _ theme: PresentationTheme, _ authorTitle: NSAttributedString?, _ topics: [(id: Int64, title: NSAttributedString, iconId: Int64?, iconColor: Int32)]) -> (CGSize, () -> CGRect?) {
|
|
let makeAuthorLayout = TextNode.asyncLayout(self.authorNode)
|
|
var makeExistingTopicLayouts: [Int64: (_ constrainedWidth: CGFloat, _ context: AccountContext, _ theme: PresentationTheme, _ threadId: Int64, _ title: NSAttributedString, _ iconId: Int64?, _ iconColor: Int32) -> (CGSize, () -> TopicItemNode)] = [:]
|
|
for (topicId, topicNode) in self.topicNodes {
|
|
makeExistingTopicLayouts[topicId] = TopicItemNode.asyncLayout(topicNode)
|
|
}
|
|
|
|
return { [weak self] context, constrainedWidth, theme, authorTitle, topics in
|
|
var maxTitleWidth = constrainedWidth
|
|
if !topics.isEmpty {
|
|
maxTitleWidth = floor(constrainedWidth * 0.7)
|
|
}
|
|
|
|
let authorTitleLayout = makeAuthorLayout(TextNodeLayoutArguments(attributedString: authorTitle, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: maxTitleWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)))
|
|
|
|
var remainingWidth = constrainedWidth - authorTitleLayout.0.size.width
|
|
|
|
var arrowIconImage: UIImage?
|
|
if !topics.isEmpty {
|
|
if authorTitle != nil {
|
|
arrowIconImage = PresentationResourcesChatList.topicArrowIcon(theme)
|
|
if let arrowIconImage = arrowIconImage {
|
|
remainingWidth -= arrowIconImage.size.width + 6.0 * 2.0
|
|
}
|
|
}
|
|
}
|
|
|
|
var topicsSizeAndApply: [(Int64, CGSize, () -> TopicItemNode)] = []
|
|
for topic in topics {
|
|
if remainingWidth <= 22.0 + 2.0 + 10.0 {
|
|
break
|
|
}
|
|
|
|
let makeTopicLayout = makeExistingTopicLayouts[topic.id] ?? TopicItemNode.asyncLayout(nil)
|
|
let (topicSize, topicApply) = makeTopicLayout(remainingWidth, context, theme, topic.id, topic.title, topic.iconId, topic.iconColor)
|
|
topicsSizeAndApply.append((topic.id, topicSize, topicApply))
|
|
|
|
remainingWidth -= topicSize.width + 4.0
|
|
}
|
|
|
|
var size = authorTitleLayout.0.size
|
|
if !topicsSizeAndApply.isEmpty {
|
|
for item in topicsSizeAndApply {
|
|
size.height = max(size.height, item.1.height)
|
|
size.width += 10.0 + item.1.width
|
|
}
|
|
}
|
|
|
|
return (size, {
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
|
|
let _ = authorTitleLayout.1()
|
|
let authorFrame = CGRect(origin: CGPoint(), size: authorTitleLayout.0.size)
|
|
self.authorNode.frame = authorFrame
|
|
|
|
var nextX = authorFrame.maxX - 1.0
|
|
if authorTitle == nil {
|
|
nextX = 0.0
|
|
}
|
|
|
|
if let arrowIconImage = arrowIconImage {
|
|
let titleTopicArrowNode: ASImageNode
|
|
if let current = self.titleTopicArrowNode {
|
|
titleTopicArrowNode = current
|
|
} else {
|
|
titleTopicArrowNode = ASImageNode()
|
|
self.titleTopicArrowNode = titleTopicArrowNode
|
|
self.addSubnode(titleTopicArrowNode)
|
|
}
|
|
titleTopicArrowNode.image = arrowIconImage
|
|
nextX += 6.0
|
|
titleTopicArrowNode.frame = CGRect(origin: CGPoint(x: nextX, y: 5.0), size: arrowIconImage.size)
|
|
nextX += arrowIconImage.size.width + 6.0
|
|
} else {
|
|
if let titleTopicArrowNode = self.titleTopicArrowNode {
|
|
self.titleTopicArrowNode = nil
|
|
titleTopicArrowNode.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
var topTopicRect: CGRect?
|
|
var topicNodeOrder: [Int64] = []
|
|
for item in topicsSizeAndApply {
|
|
topicNodeOrder.append(item.0)
|
|
let itemNode = item.2()
|
|
if self.topicNodes[item.0] != itemNode {
|
|
self.topicNodes[item.0]?.removeFromSupernode()
|
|
self.topicNodes[item.0] = itemNode
|
|
}
|
|
let itemFrame = CGRect(origin: CGPoint(x: nextX - 1.0, y: 0.0), size: item.1)
|
|
itemNode.frame = itemFrame
|
|
if topTopicRect == nil {
|
|
topTopicRect = itemFrame
|
|
}
|
|
nextX += item.1.width + 4.0
|
|
}
|
|
var removeIds: [Int64] = []
|
|
for (id, itemNode) in self.topicNodes {
|
|
if !topicsSizeAndApply.contains(where: { $0.0 == id }) {
|
|
removeIds.append(id)
|
|
itemNode.removeFromSupernode()
|
|
}
|
|
}
|
|
for id in removeIds {
|
|
self.topicNodes.removeValue(forKey: id)
|
|
}
|
|
self.topicNodeOrder = topicNodeOrder
|
|
|
|
return topTopicRect
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
var item: ChatListItem?
|
|
|
|
private let backgroundNode: ASDisplayNode
|
|
private let highlightedBackgroundNode: ASDisplayNode
|
|
|
|
let contextContainer: ContextControllerSourceNode
|
|
let mainContentContainerNode: ASDisplayNode
|
|
|
|
let avatarContainerNode: ASDisplayNode
|
|
let avatarNode: AvatarNode
|
|
var avatarIconView: ComponentHostView<Empty>?
|
|
var avatarIconComponent: EmojiStatusComponent?
|
|
var avatarVideoNode: AvatarVideoNode?
|
|
var avatarTapRecognizer: UITapGestureRecognizer?
|
|
|
|
private var inlineNavigationMarkLayer: SimpleLayer?
|
|
|
|
let titleNode: TextNode
|
|
let authorNode: AuthorNode
|
|
private var compoundHighlightingNode: LinkHighlightingNode?
|
|
private var textArrowNode: ASImageNode?
|
|
private var compoundTextButtonNode: HighlightTrackingButtonNode?
|
|
let measureNode: TextNode
|
|
private var currentItemHeight: CGFloat?
|
|
let forwardedIconNode: ASImageNode
|
|
let textNode: TextNodeWithEntities
|
|
var dustNode: InvisibleInkDustNode?
|
|
let inputActivitiesNode: ChatListInputActivitiesNode
|
|
let dateNode: TextNode
|
|
var dateStatusIconNode: ASImageNode?
|
|
let separatorNode: ASDisplayNode
|
|
let statusNode: ChatListStatusNode
|
|
let badgeNode: ChatListBadgeNode
|
|
let mentionBadgeNode: ChatListBadgeNode
|
|
var avatarBadgeNode: ChatListBadgeNode?
|
|
var avatarBadgeBackground: ASImageNode?
|
|
let onlineNode: PeerOnlineMarkerNode
|
|
var avatarTimerBadge: AvatarBadgeView?
|
|
let pinnedIconNode: ASImageNode
|
|
var secretIconNode: ASImageNode?
|
|
var verifiedIconView: ComponentHostView<Empty>?
|
|
var verifiedIconComponent: EmojiStatusComponent?
|
|
var credibilityIconView: ComponentHostView<Empty>?
|
|
var credibilityIconComponent: EmojiStatusComponent?
|
|
let mutedIconNode: ASImageNode
|
|
|
|
private var hierarchyTrackingLayer: HierarchyTrackingLayer?
|
|
private var cachedDataDisposable = MetaDisposable()
|
|
|
|
private var currentTextLeftCutout: CGFloat = 0.0
|
|
private var currentMediaPreviewSpecs: [(message: EngineMessage, media: EngineMedia, size: CGSize)] = []
|
|
private var mediaPreviewNodes: [EngineMedia.Id: ChatListMediaPreviewNode] = [:]
|
|
|
|
var selectableControlNode: ItemListSelectableControlNode?
|
|
var reorderControlNode: ItemListEditableReorderControlNode?
|
|
|
|
private var peerPresenceManager: PeerPresenceStatusManager?
|
|
|
|
private var cachedChatListText: (String, String)?
|
|
private var cachedChatListSearchResult: CachedChatListSearchResult?
|
|
private var cachedChatListQuoteSearchResult: CachedChatListSearchResult?
|
|
private var cachedCustomTextEntities: CachedCustomTextEntities?
|
|
|
|
var layoutParams: (ChatListItem, first: Bool, last: Bool, firstWithHeader: Bool, nextIsPinned: Bool, ListViewItemLayoutParams, countersSize: CGFloat)?
|
|
|
|
private var isHighlighted: Bool = false
|
|
private var skipFadeout: Bool = false
|
|
private var customAnimationInProgress: Bool = false
|
|
|
|
private var onlineIsVoiceChat: Bool = false
|
|
private var currentOnline: Bool?
|
|
|
|
override var canBeSelected: Bool {
|
|
if self.selectableControlNode != nil || self.item?.editing == true {
|
|
return false
|
|
} else {
|
|
return super.canBeSelected
|
|
}
|
|
}
|
|
|
|
override var defaultAccessibilityLabel: String? {
|
|
get {
|
|
return self.accessibilityLabel
|
|
} set(value) {
|
|
}
|
|
}
|
|
override var accessibilityAttributedLabel: NSAttributedString? {
|
|
get {
|
|
return self.accessibilityLabel.flatMap(NSAttributedString.init(string:))
|
|
} set(value) {
|
|
}
|
|
}
|
|
override var accessibilityAttributedValue: NSAttributedString? {
|
|
get {
|
|
return self.accessibilityValue.flatMap(NSAttributedString.init(string:))
|
|
} set(value) {
|
|
}
|
|
}
|
|
|
|
override var accessibilityLabel: String? {
|
|
get {
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
switch item.content {
|
|
case let .groupReference(groupReferenceData):
|
|
var result = item.presentationData.strings.ChatList_ArchivedChatsTitle
|
|
let allCount = groupReferenceData.unreadCount
|
|
if allCount > 0 {
|
|
result += "\n\(item.presentationData.strings.VoiceOver_Chat_UnreadMessages(Int32(allCount)))"
|
|
}
|
|
return result
|
|
case let .peer(peerData):
|
|
guard let chatMainPeer = peerData.peer.chatMainPeer else {
|
|
return nil
|
|
}
|
|
var result = ""
|
|
if item.context.account.peerId == chatMainPeer.id {
|
|
result += item.presentationData.strings.DialogList_SavedMessages
|
|
} else {
|
|
result += chatMainPeer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
if let combinedReadState = peerData.combinedReadState, combinedReadState.count > 0 {
|
|
result += "\n\(item.presentationData.strings.VoiceOver_Chat_UnreadMessages(combinedReadState.count))"
|
|
}
|
|
return result
|
|
}
|
|
} set(value) {
|
|
}
|
|
}
|
|
|
|
override var accessibilityValue: String? {
|
|
get {
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
switch item.content {
|
|
case let .groupReference(groupReferenceData):
|
|
let peers = groupReferenceData.peers
|
|
let messageValue = groupReferenceData.message
|
|
if let message = messageValue, let peer = peers.first?.peer {
|
|
let messages = [message]
|
|
var result = ""
|
|
if message.flags.contains(.Incoming) {
|
|
result += item.presentationData.strings.VoiceOver_ChatList_Message
|
|
} else {
|
|
result += item.presentationData.strings.VoiceOver_ChatList_OutgoingMessage
|
|
}
|
|
let (_, initialHideAuthor, messageText, _, _) = chatListItemStrings(strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, contentSettings: item.context.currentContentSettings.with { $0 }, messages: messages, chatPeer: peer, accountPeerId: item.context.account.peerId, isPeerGroup: false)
|
|
if message.flags.contains(.Incoming), !initialHideAuthor, let author = message.author, case .user = author {
|
|
result += "\n\(item.presentationData.strings.VoiceOver_ChatList_MessageFrom(author.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)).string)"
|
|
}
|
|
result += "\n\(messageText)"
|
|
return result
|
|
} else if !peers.isEmpty {
|
|
var result = ""
|
|
var isFirst = true
|
|
for peer in peers {
|
|
if let chatMainPeer = peer.peer.chatMainPeer {
|
|
let peerTitle = chatMainPeer.compactDisplayTitle
|
|
if !peerTitle.isEmpty {
|
|
if isFirst {
|
|
isFirst = false
|
|
} else {
|
|
result.append(", ")
|
|
}
|
|
result.append(peerTitle)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
} else {
|
|
return item.presentationData.strings.VoiceOver_ChatList_MessageEmpty
|
|
}
|
|
case let .peer(peerData):
|
|
if let message = peerData.messages.last {
|
|
var result = ""
|
|
if message.flags.contains(.Incoming) {
|
|
result += item.presentationData.strings.VoiceOver_ChatList_Message
|
|
} else {
|
|
result += item.presentationData.strings.VoiceOver_ChatList_OutgoingMessage
|
|
}
|
|
let (_, initialHideAuthor, messageText, _, _) = chatListItemStrings(strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, contentSettings: item.context.currentContentSettings.with { $0 }, messages: peerData.messages, chatPeer: peerData.peer, accountPeerId: item.context.account.peerId, isPeerGroup: false)
|
|
if message.flags.contains(.Incoming), !initialHideAuthor, let author = message.author, case .user = author {
|
|
result += "\n\(item.presentationData.strings.VoiceOver_ChatList_MessageFrom(author.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)).string)"
|
|
}
|
|
if !message.flags.contains(.Incoming), let combinedReadState = peerData.combinedReadState, combinedReadState.isOutgoingMessageIndexRead(message.index) {
|
|
result += "\n\(item.presentationData.strings.VoiceOver_ChatList_MessageRead)"
|
|
}
|
|
result += "\n\(messageText)"
|
|
return result
|
|
} else {
|
|
return item.presentationData.strings.VoiceOver_ChatList_MessageEmpty
|
|
}
|
|
}
|
|
} set(value) {
|
|
}
|
|
}
|
|
|
|
override var visibility: ListViewItemNodeVisibility {
|
|
didSet {
|
|
let wasVisible = self.visibilityStatus
|
|
let isVisible: Bool
|
|
switch self.visibility {
|
|
case let .visible(fraction, _):
|
|
isVisible = fraction > 0.2
|
|
case .none:
|
|
isVisible = false
|
|
}
|
|
if wasVisible != isVisible {
|
|
self.visibilityStatus = isVisible
|
|
}
|
|
}
|
|
}
|
|
|
|
private var visibilityStatus: Bool = false {
|
|
didSet {
|
|
if self.visibilityStatus != oldValue {
|
|
if self.visibilityStatus {
|
|
self.avatarVideoNode?.resetPlayback()
|
|
}
|
|
self.updateVideoVisibility()
|
|
|
|
self.textNode.visibilityRect = self.visibilityStatus ? CGRect.infinite : nil
|
|
|
|
if let verifiedIconView = self.verifiedIconView, let verifiedIconComponent = self.verifiedIconComponent {
|
|
let _ = verifiedIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(verifiedIconComponent.withVisibleForAnimations(self.visibilityStatus)),
|
|
environment: {},
|
|
containerSize: verifiedIconView.bounds.size
|
|
)
|
|
}
|
|
if let credibilityIconView = self.credibilityIconView, let credibilityIconComponent = self.credibilityIconComponent {
|
|
let _ = credibilityIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(credibilityIconComponent.withVisibleForAnimations(self.visibilityStatus)),
|
|
environment: {},
|
|
containerSize: credibilityIconView.bounds.size
|
|
)
|
|
}
|
|
if let avatarIconView = self.avatarIconView, let avatarIconComponent = self.avatarIconComponent {
|
|
let _ = avatarIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(avatarIconComponent.withVisibleForAnimations(self.visibilityStatus)),
|
|
environment: {},
|
|
containerSize: avatarIconView.bounds.size
|
|
)
|
|
}
|
|
self.authorNode.visibilityStatus = self.visibilityStatus
|
|
}
|
|
}
|
|
}
|
|
|
|
private var trackingIsInHierarchy: Bool = false {
|
|
didSet {
|
|
if self.trackingIsInHierarchy != oldValue {
|
|
Queue.mainQueue().justDispatch {
|
|
if self.trackingIsInHierarchy {
|
|
self.avatarVideoNode?.resetPlayback()
|
|
}
|
|
self.updateVideoVisibility()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
required init() {
|
|
self.backgroundNode = ASDisplayNode()
|
|
self.backgroundNode.isLayerBacked = true
|
|
self.backgroundNode.displaysAsynchronously = false
|
|
|
|
self.avatarContainerNode = ASDisplayNode()
|
|
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
|
|
|
|
self.highlightedBackgroundNode = ASDisplayNode()
|
|
self.highlightedBackgroundNode.isLayerBacked = true
|
|
|
|
self.contextContainer = ContextControllerSourceNode()
|
|
|
|
self.mainContentContainerNode = ASDisplayNode()
|
|
self.mainContentContainerNode.clipsToBounds = true
|
|
|
|
self.measureNode = TextNode()
|
|
|
|
self.titleNode = TextNode()
|
|
self.titleNode.isUserInteractionEnabled = false
|
|
self.titleNode.displaysAsynchronously = true
|
|
|
|
self.authorNode = AuthorNode()
|
|
self.authorNode.isUserInteractionEnabled = false
|
|
|
|
self.textNode = TextNodeWithEntities()
|
|
self.textNode.textNode.isUserInteractionEnabled = false
|
|
self.textNode.textNode.displaysAsynchronously = true
|
|
|
|
self.inputActivitiesNode = ChatListInputActivitiesNode()
|
|
self.inputActivitiesNode.isUserInteractionEnabled = false
|
|
self.inputActivitiesNode.alpha = 0.0
|
|
|
|
self.dateNode = TextNode()
|
|
self.dateNode.isUserInteractionEnabled = false
|
|
self.dateNode.displaysAsynchronously = true
|
|
|
|
self.statusNode = ChatListStatusNode()
|
|
self.badgeNode = ChatListBadgeNode()
|
|
self.mentionBadgeNode = ChatListBadgeNode()
|
|
self.onlineNode = PeerOnlineMarkerNode()
|
|
|
|
self.forwardedIconNode = ASImageNode()
|
|
self.forwardedIconNode.isLayerBacked = true
|
|
self.forwardedIconNode.displaysAsynchronously = false
|
|
self.forwardedIconNode.displayWithoutProcessing = true
|
|
|
|
self.pinnedIconNode = ASImageNode()
|
|
self.pinnedIconNode.isLayerBacked = true
|
|
self.pinnedIconNode.displaysAsynchronously = false
|
|
self.pinnedIconNode.displayWithoutProcessing = true
|
|
|
|
self.mutedIconNode = ASImageNode()
|
|
self.mutedIconNode.isLayerBacked = true
|
|
self.mutedIconNode.displaysAsynchronously = false
|
|
self.mutedIconNode.displayWithoutProcessing = true
|
|
|
|
self.separatorNode = ASDisplayNode()
|
|
self.separatorNode.isLayerBacked = true
|
|
|
|
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
|
|
|
|
self.isAccessibilityElement = true
|
|
|
|
self.addSubnode(self.backgroundNode)
|
|
self.addSubnode(self.separatorNode)
|
|
|
|
self.addSubnode(self.contextContainer)
|
|
self.contextContainer.addSubnode(self.mainContentContainerNode)
|
|
|
|
self.avatarContainerNode.addSubnode(self.avatarNode)
|
|
self.contextContainer.addSubnode(self.avatarContainerNode)
|
|
self.avatarNode.addSubnode(self.onlineNode)
|
|
|
|
self.mainContentContainerNode.addSubnode(self.titleNode)
|
|
self.mainContentContainerNode.addSubnode(self.authorNode)
|
|
self.mainContentContainerNode.addSubnode(self.textNode.textNode)
|
|
self.mainContentContainerNode.addSubnode(self.dateNode)
|
|
self.mainContentContainerNode.addSubnode(self.statusNode)
|
|
self.mainContentContainerNode.addSubnode(self.pinnedIconNode)
|
|
self.mainContentContainerNode.addSubnode(self.badgeNode)
|
|
self.mainContentContainerNode.addSubnode(self.mentionBadgeNode)
|
|
self.mainContentContainerNode.addSubnode(self.mutedIconNode)
|
|
|
|
self.peerPresenceManager = PeerPresenceStatusManager(update: { [weak self] in
|
|
if let strongSelf = self, let layoutParams = strongSelf.layoutParams {
|
|
let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.5, layoutParams.1, layoutParams.2, layoutParams.3, layoutParams.4)
|
|
let _ = apply(false, false)
|
|
}
|
|
})
|
|
|
|
self.contextContainer.shouldBegin = { [weak self] location in
|
|
guard let strongSelf = self, let item = strongSelf.item else {
|
|
return false
|
|
}
|
|
|
|
strongSelf.contextContainer.additionalActivationProgressLayer = nil
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation {
|
|
if case let .peer(peerId) = inlineNavigationLocation.location {
|
|
if case let .chatList(index) = item.index, index.messageIndex.id.peerId == peerId {
|
|
return false
|
|
}
|
|
}
|
|
strongSelf.contextContainer.targetNodeForActivationProgress = strongSelf.avatarContainerNode
|
|
} else if let value = strongSelf.hitTest(location, with: nil), value === strongSelf.compoundTextButtonNode?.view {
|
|
strongSelf.contextContainer.targetNodeForActivationProgress = strongSelf.compoundTextButtonNode
|
|
strongSelf.contextContainer.additionalActivationProgressLayer = strongSelf.compoundHighlightingNode?.layer
|
|
} else {
|
|
strongSelf.contextContainer.targetNodeForActivationProgress = nil
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
self.contextContainer.activated = { [weak self] gesture, location in
|
|
guard let strongSelf = self, let item = strongSelf.item else {
|
|
return
|
|
}
|
|
var threadId: Int64?
|
|
if let value = strongSelf.hitTest(location, with: nil), value === strongSelf.compoundTextButtonNode?.view {
|
|
if case let .peer(peerData) = item.content, let topicItem = peerData.topForumTopicItems.first {
|
|
threadId = topicItem.id
|
|
}
|
|
}
|
|
item.interaction.activateChatPreview(item, threadId, strongSelf.contextContainer, gesture, nil)
|
|
}
|
|
|
|
self.onDidLoad { [weak self] _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let avatarTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.avatarStoryTapGesture(_:)))
|
|
self.avatarTapRecognizer = avatarTapRecognizer
|
|
self.avatarNode.view.addGestureRecognizer(avatarTapRecognizer)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.cachedDataDisposable.dispose()
|
|
}
|
|
|
|
override func secondaryAction(at point: CGPoint) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
item.interaction.activateChatPreview(item, nil, self.contextContainer, nil, point)
|
|
}
|
|
|
|
func setupItem(item: ChatListItem, synchronousLoads: Bool) {
|
|
let previousItem = self.item
|
|
self.item = item
|
|
|
|
var storyState: ChatListItemContent.StoryState?
|
|
if case let .peer(peerData) = item.content {
|
|
storyState = peerData.storyState
|
|
} else if case let .groupReference(groupReference) = item.content {
|
|
storyState = groupReference.storyState
|
|
}
|
|
|
|
var peer: EnginePeer?
|
|
var displayAsMessage = false
|
|
var enablePreview = true
|
|
switch item.content {
|
|
case let .peer(peerData):
|
|
displayAsMessage = peerData.displayAsMessage
|
|
if displayAsMessage, case let .user(author) = peerData.messages.last?.author {
|
|
peer = .user(author)
|
|
} else {
|
|
peer = peerData.peer.chatMainPeer
|
|
}
|
|
if peerData.peer.peerId.namespace == Namespaces.Peer.SecretChat {
|
|
enablePreview = false
|
|
}
|
|
case let .groupReference(groupReferenceData):
|
|
if let previousItem = previousItem, case let .groupReference(previousGroupReferenceData) = previousItem.content, groupReferenceData.hiddenByDefault != previousGroupReferenceData.hiddenByDefault {
|
|
UIView.transition(with: self.avatarNode.view, duration: 0.3, options: [.transitionCrossDissolve], animations: {
|
|
}, completion: nil)
|
|
}
|
|
self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: .archivedChatsIcon(hiddenByDefault: groupReferenceData.hiddenByDefault), emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoads)
|
|
}
|
|
|
|
self.avatarNode.setStoryStats(storyStats: storyState.flatMap { storyState in
|
|
return AvatarNode.StoryStats(
|
|
totalCount: storyState.stats.totalCount,
|
|
unseenCount: storyState.stats.unseenCount,
|
|
hasUnseenCloseFriendsItems: storyState.hasUnseenCloseFriends
|
|
)
|
|
}, presentationParams: AvatarNode.StoryPresentationParams(
|
|
colors: AvatarNode.Colors(theme: item.presentationData.theme),
|
|
lineWidth: 2.33,
|
|
inactiveLineWidth: 1.33
|
|
), transition: .immediate)
|
|
self.avatarNode.isUserInteractionEnabled = storyState != nil
|
|
|
|
if let peer = peer {
|
|
var overrideImage: AvatarNodeImageOverride?
|
|
if peer.id.isReplies {
|
|
overrideImage = .repliesIcon
|
|
} else if peer.id.isAnonymousSavedMessages {
|
|
overrideImage = .anonymousSavedMessagesIcon
|
|
} else if peer.id == item.context.account.peerId && !displayAsMessage {
|
|
overrideImage = .savedMessagesIcon
|
|
} else if peer.isDeleted {
|
|
overrideImage = .deletedIcon
|
|
}
|
|
var isForum = false
|
|
if case let .channel(channel) = peer, channel.flags.contains(.isForum) {
|
|
isForum = true
|
|
}
|
|
self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: isForum ? .roundedRect : .round, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: 60.0, height: 60.0))
|
|
|
|
if peer.isPremium && peer.id != item.context.account.peerId {
|
|
let context = item.context
|
|
self.cachedDataDisposable.set((context.account.postbox.peerView(id: peer.id)
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] peerView in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let cachedPeerData = peerView.cachedData as? CachedUserData
|
|
var personalPhoto: TelegramMediaImage?
|
|
var profilePhoto: TelegramMediaImage?
|
|
var isKnown = false
|
|
|
|
if let cachedPeerData = cachedPeerData {
|
|
if case let .known(maybePersonalPhoto) = cachedPeerData.personalPhoto {
|
|
personalPhoto = maybePersonalPhoto
|
|
isKnown = true
|
|
}
|
|
if case let .known(maybePhoto) = cachedPeerData.photo {
|
|
profilePhoto = maybePhoto
|
|
isKnown = true
|
|
}
|
|
}
|
|
|
|
if isKnown {
|
|
let photo = personalPhoto ?? profilePhoto
|
|
if let photo = photo, item.context.sharedContext.energyUsageSettings.loopEmoji, (!photo.videoRepresentations.isEmpty || photo.emojiMarkup != nil) {
|
|
let videoNode: AvatarVideoNode
|
|
if let current = strongSelf.avatarVideoNode {
|
|
videoNode = current
|
|
} else {
|
|
videoNode = AvatarVideoNode(context: item.context)
|
|
strongSelf.avatarNode.contentNode.addSubnode(videoNode)
|
|
strongSelf.avatarVideoNode = videoNode
|
|
}
|
|
videoNode.update(peer: peer, photo: photo, size: CGSize(width: 60.0, height: 60.0))
|
|
|
|
if strongSelf.hierarchyTrackingLayer == nil {
|
|
let hierarchyTrackingLayer = HierarchyTrackingLayer()
|
|
hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.trackingIsInHierarchy = true
|
|
}
|
|
|
|
hierarchyTrackingLayer.didExitHierarchy = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.trackingIsInHierarchy = false
|
|
}
|
|
strongSelf.hierarchyTrackingLayer = hierarchyTrackingLayer
|
|
strongSelf.layer.addSublayer(hierarchyTrackingLayer)
|
|
}
|
|
} else {
|
|
if let avatarVideoNode = strongSelf.avatarVideoNode {
|
|
avatarVideoNode.removeFromSupernode()
|
|
strongSelf.avatarVideoNode = nil
|
|
}
|
|
strongSelf.hierarchyTrackingLayer?.removeFromSuperlayer()
|
|
strongSelf.hierarchyTrackingLayer = nil
|
|
}
|
|
strongSelf.updateVideoVisibility()
|
|
} else {
|
|
if let photo = peer.largeProfileImage, photo.hasVideo {
|
|
let _ = context.engine.peers.fetchAndUpdateCachedPeerData(peerId: peer.id).startStandalone()
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
self.cachedDataDisposable.set(nil)
|
|
|
|
self.avatarVideoNode?.removeFromSupernode()
|
|
self.avatarVideoNode = nil
|
|
|
|
self.hierarchyTrackingLayer?.removeFromSuperlayer()
|
|
self.hierarchyTrackingLayer = nil
|
|
}
|
|
}
|
|
|
|
self.contextContainer.isGestureEnabled = enablePreview && !item.editing
|
|
}
|
|
|
|
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
|
|
let layout = self.asyncLayout()
|
|
let (first, last, firstWithHeader, nextIsPinned) = ChatListItem.mergeType(item: item as! ChatListItem, previousItem: previousItem, nextItem: nextItem)
|
|
let (nodeLayout, apply) = layout(item as! ChatListItem, params, first, last, firstWithHeader, nextIsPinned)
|
|
apply(false, false)
|
|
self.contentSize = nodeLayout.contentSize
|
|
self.insets = nodeLayout.insets
|
|
}
|
|
|
|
class func insets(first: Bool, last: Bool, firstWithHeader: Bool) -> UIEdgeInsets {
|
|
return UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0)
|
|
}
|
|
|
|
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
|
|
super.setHighlighted(highlighted, at: point, animated: animated)
|
|
|
|
self.isHighlighted = highlighted
|
|
|
|
self.updateIsHighlighted(transition: (animated && !highlighted) ? .animated(duration: 0.3, curve: .easeInOut) : .immediate)
|
|
}
|
|
|
|
var reallyHighlighted: Bool {
|
|
var reallyHighlighted = self.isHighlighted
|
|
if let item = self.item {
|
|
if let itemChatLocation = item.content.chatLocation {
|
|
if itemChatLocation == item.interaction.highlightedChatLocation?.location {
|
|
reallyHighlighted = true
|
|
}
|
|
}
|
|
}
|
|
return reallyHighlighted
|
|
}
|
|
|
|
func updateIsHighlighted(transition: ContainedViewLayoutTransition) {
|
|
let highlightProgress: CGFloat = self.item?.interaction.highlightedChatLocation?.progress ?? 1.0
|
|
|
|
if self.reallyHighlighted {
|
|
if self.highlightedBackgroundNode.supernode == nil {
|
|
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
|
|
self.highlightedBackgroundNode.alpha = 0.0
|
|
}
|
|
self.highlightedBackgroundNode.layer.removeAllAnimations()
|
|
transition.updateAlpha(layer: self.highlightedBackgroundNode.layer, alpha: highlightProgress)
|
|
|
|
if let compoundHighlightingNode = self.compoundHighlightingNode {
|
|
transition.updateAlpha(layer: compoundHighlightingNode.layer, alpha: 0.0)
|
|
}
|
|
|
|
if let item = self.item, case .chatList = item.index {
|
|
self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted, voiceChat: self.onlineIsVoiceChat), color: nil, transition: transition)
|
|
}
|
|
} else {
|
|
if self.highlightedBackgroundNode.supernode != nil {
|
|
transition.updateAlpha(layer: self.highlightedBackgroundNode.layer, alpha: 1.0 - highlightProgress, completion: { [weak self] completed in
|
|
if let strongSelf = self {
|
|
if completed {
|
|
strongSelf.highlightedBackgroundNode.removeFromSupernode()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
if let compoundHighlightingNode = self.compoundHighlightingNode {
|
|
transition.updateAlpha(layer: compoundHighlightingNode.layer, alpha: self.authorNode.alpha)
|
|
}
|
|
|
|
if let item = self.item {
|
|
let onlineIcon: UIImage?
|
|
if item.isPinned {
|
|
onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned, voiceChat: self.onlineIsVoiceChat)
|
|
} else {
|
|
onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular, voiceChat: self.onlineIsVoiceChat)
|
|
}
|
|
self.onlineNode.setImage(onlineIcon, color: nil, transition: transition)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func tapped() {
|
|
guard let item = self.item, item.editing else {
|
|
return
|
|
}
|
|
if case let .peer(peerData) = item.content {
|
|
if peerData.promoInfo == nil, let mainPeer = peerData.peer.peer {
|
|
switch item.index {
|
|
case let .forum(_, _, threadIdValue, _, _):
|
|
item.interaction.toggleThreadsSelection([threadIdValue], !item.selected)
|
|
case .chatList:
|
|
item.interaction.togglePeerSelected(mainPeer, nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func asyncLayout() -> (_ item: ChatListItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool, _ nextIsPinned: Bool) -> (ListViewItemNodeLayout, (Bool, Bool) -> Void) {
|
|
let dateLayout = TextNode.asyncLayout(self.dateNode)
|
|
let textLayout = TextNodeWithEntities.asyncLayout(self.textNode)
|
|
let titleLayout = TextNode.asyncLayout(self.titleNode)
|
|
let authorLayout = self.authorNode.asyncLayout()
|
|
let makeMeasureLayout = TextNode.asyncLayout(self.measureNode)
|
|
let inputActivitiesLayout = self.inputActivitiesNode.asyncLayout()
|
|
let badgeLayout = self.badgeNode.asyncLayout()
|
|
let mentionBadgeLayout = self.mentionBadgeNode.asyncLayout()
|
|
let onlineLayout = self.onlineNode.asyncLayout()
|
|
let selectableControlLayout = ItemListSelectableControlNode.asyncLayout(self.selectableControlNode)
|
|
let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode)
|
|
|
|
let currentItem = self.layoutParams?.0
|
|
let currentChatListText = self.cachedChatListText
|
|
let currentChatListSearchResult = self.cachedChatListSearchResult
|
|
let currentChatListQuoteSearchResult = self.cachedChatListQuoteSearchResult
|
|
let currentCustomTextEntities = self.cachedCustomTextEntities
|
|
|
|
return { item, params, first, last, firstWithHeader, nextIsPinned in
|
|
let titleFont = Font.medium(floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0))
|
|
let textFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0))
|
|
let italicTextFont = Font.italic(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0))
|
|
let dateFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0))
|
|
let badgeFont = Font.with(size: floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
|
|
let avatarBadgeFont = Font.with(size: floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
|
|
|
|
let account = item.context.account
|
|
var messages: [EngineMessage]
|
|
enum ContentPeer {
|
|
case chat(EngineRenderedPeer)
|
|
case group([EngineChatList.GroupItem.Item])
|
|
}
|
|
let contentPeer: ContentPeer
|
|
let combinedReadState: EnginePeerReadCounters?
|
|
let unreadCount: (count: Int32, unread: Bool, muted: Bool, mutedCount: Int32?, isProvisonal: Bool)
|
|
let isRemovedFromTotalUnreadCount: Bool
|
|
let peerPresence: EnginePeer.Presence?
|
|
let draftState: ChatListItemContent.DraftState?
|
|
let hasUnseenMentions: Bool
|
|
let hasUnseenReactions: Bool
|
|
let inputActivities: [(EnginePeer, PeerInputActivity)]?
|
|
let isPeerGroup: Bool
|
|
let promoInfo: ChatListNodeEntryPromoInfo?
|
|
let displayAsMessage: Bool
|
|
let hasFailedMessages: Bool
|
|
var threadInfo: ChatListItemContent.ThreadInfo?
|
|
var forumTopicData: EngineChatList.ForumTopicData?
|
|
var topForumTopicItems: [EngineChatList.ForumTopicData] = []
|
|
var autoremoveTimeout: Int32?
|
|
|
|
var groupHiddenByDefault = false
|
|
|
|
switch item.content {
|
|
case let .peer(peerData):
|
|
let messagesValue = peerData.messages
|
|
let peerValue = peerData.peer
|
|
let threadInfoValue = peerData.threadInfo
|
|
let combinedReadStateValue = peerData.combinedReadState
|
|
let isRemovedFromTotalUnreadCountValue = peerData.isRemovedFromTotalUnreadCount
|
|
let peerPresenceValue = peerData.presence
|
|
let hasUnseenMentionsValue = peerData.hasUnseenMentions
|
|
let hasUnseenReactionsValue = peerData.hasUnseenReactions
|
|
let draftStateValue = peerData.draftState
|
|
let inputActivitiesValue = peerData.inputActivities
|
|
let promoInfoValue = peerData.promoInfo
|
|
let ignoreUnreadBadge = peerData.ignoreUnreadBadge
|
|
let displayAsMessageValue = peerData.displayAsMessage
|
|
let forumTopicDataValue = peerData.forumTopicData
|
|
let topForumTopicItemsValue = peerData.topForumTopicItems
|
|
|
|
autoremoveTimeout = peerData.autoremoveTimeout
|
|
|
|
messages = messagesValue
|
|
contentPeer = .chat(peerValue)
|
|
combinedReadState = combinedReadStateValue
|
|
if let combinedReadState = combinedReadState, promoInfoValue == nil && !ignoreUnreadBadge {
|
|
unreadCount = (combinedReadState.count, combinedReadState.isUnread, isRemovedFromTotalUnreadCountValue || combinedReadState.isMuted, nil, !combinedReadState.hasEverRead)
|
|
} else {
|
|
unreadCount = (0, false, false, nil, false)
|
|
}
|
|
if let _ = promoInfoValue {
|
|
isRemovedFromTotalUnreadCount = false
|
|
} else {
|
|
isRemovedFromTotalUnreadCount = isRemovedFromTotalUnreadCountValue
|
|
}
|
|
peerPresence = peerPresenceValue.flatMap { presence -> EnginePeer.Presence in
|
|
return EnginePeer.Presence(status: presence.status, lastActivity: 0)
|
|
}
|
|
draftState = draftStateValue
|
|
threadInfo = threadInfoValue
|
|
hasUnseenMentions = hasUnseenMentionsValue
|
|
hasUnseenReactions = hasUnseenReactionsValue
|
|
forumTopicData = forumTopicDataValue
|
|
topForumTopicItems = topForumTopicItemsValue
|
|
|
|
if item.interaction.searchTextHighightState != nil, threadInfo == nil, topForumTopicItems.isEmpty, let message = messagesValue.first, let threadId = message.threadId, let associatedThreadInfo = message.associatedThreadInfo {
|
|
topForumTopicItems = [EngineChatList.ForumTopicData(id: threadId, title: associatedThreadInfo.title, iconFileId: associatedThreadInfo.icon, iconColor: associatedThreadInfo.iconColor, maxOutgoingReadMessageId: message.id, isUnread: false)]
|
|
}
|
|
|
|
switch peerValue.peer {
|
|
case .user, .secretChat:
|
|
if let peerPresence = peerPresence, case .present = peerPresence.status {
|
|
inputActivities = inputActivitiesValue
|
|
} else {
|
|
inputActivities = nil
|
|
}
|
|
default:
|
|
inputActivities = inputActivitiesValue
|
|
}
|
|
|
|
isPeerGroup = false
|
|
promoInfo = promoInfoValue
|
|
displayAsMessage = displayAsMessageValue
|
|
hasFailedMessages = messagesValue.last?.flags.contains(.Failed) ?? false // hasFailedMessagesValue
|
|
case let .groupReference(groupReferenceData):
|
|
let peers = groupReferenceData.peers
|
|
let messageValue = groupReferenceData.message
|
|
let unreadCountValue = groupReferenceData.unreadCount
|
|
let hiddenByDefault = groupReferenceData.hiddenByDefault
|
|
|
|
if let _ = messageValue, !peers.isEmpty {
|
|
contentPeer = .chat(peers[0].peer)
|
|
} else {
|
|
contentPeer = .group(peers)
|
|
}
|
|
if let message = messageValue {
|
|
messages = [message]
|
|
} else {
|
|
messages = []
|
|
}
|
|
combinedReadState = nil
|
|
isRemovedFromTotalUnreadCount = false
|
|
draftState = nil
|
|
hasUnseenMentions = false
|
|
hasUnseenReactions = false
|
|
inputActivities = nil
|
|
isPeerGroup = true
|
|
groupHiddenByDefault = hiddenByDefault
|
|
unreadCount = (Int32(unreadCountValue), unreadCountValue != 0, true, nil, false)
|
|
peerPresence = nil
|
|
promoInfo = nil
|
|
displayAsMessage = false
|
|
hasFailedMessages = false
|
|
}
|
|
|
|
if let messageValue = messages.last {
|
|
for media in messageValue.media {
|
|
if let media = media as? TelegramMediaAction, case .historyCleared = media.action {
|
|
messages = []
|
|
}
|
|
}
|
|
}
|
|
|
|
let useChatListLayout: Bool
|
|
if case .chatList = item.chatListLocation {
|
|
useChatListLayout = true
|
|
} else if case .savedMessagesChats = item.chatListLocation {
|
|
useChatListLayout = true
|
|
} else if displayAsMessage {
|
|
useChatListLayout = true
|
|
} else {
|
|
useChatListLayout = false
|
|
}
|
|
|
|
let theme = item.presentationData.theme.chatList
|
|
|
|
var updatedTheme: PresentationTheme?
|
|
|
|
if currentItem?.presentationData.theme !== item.presentationData.theme {
|
|
updatedTheme = item.presentationData.theme
|
|
}
|
|
|
|
var authorAttributedString: NSAttributedString?
|
|
var authorIsCurrentChat: Bool = false
|
|
var textAttributedString: NSAttributedString?
|
|
var textLeftCutout: CGFloat = 0.0
|
|
var dateAttributedString: NSAttributedString?
|
|
var titleAttributedString: NSAttributedString?
|
|
var badgeContent = ChatListBadgeContent.none
|
|
var mentionBadgeContent = ChatListBadgeContent.none
|
|
var statusState = ChatListStatusNodeState.none
|
|
|
|
var currentBadgeBackgroundImage: UIImage?
|
|
var currentAvatarBadgeBackgroundImage: UIImage?
|
|
var currentMentionBadgeImage: UIImage?
|
|
var currentPinnedIconImage: UIImage?
|
|
var currentMutedIconImage: UIImage?
|
|
var currentCredibilityIconContent: EmojiStatusComponent.Content?
|
|
var currentVerifiedIconContent: EmojiStatusComponent.Content?
|
|
var currentSecretIconImage: UIImage?
|
|
var currentForwardedIcon: UIImage?
|
|
var currentStoryIcon: UIImage?
|
|
|
|
var selectableControlSizeAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)?
|
|
var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)?
|
|
|
|
let editingOffset: CGFloat
|
|
var reorderInset: CGFloat = 0.0
|
|
if item.editing {
|
|
let sizeAndApply = selectableControlLayout(item.presentationData.theme.list.itemCheckColors.strokeColor, item.presentationData.theme.list.itemCheckColors.fillColor, item.presentationData.theme.list.itemCheckColors.foregroundColor, item.selected, true)
|
|
if promoInfo == nil && !isPeerGroup {
|
|
selectableControlSizeAndApply = sizeAndApply
|
|
}
|
|
editingOffset = sizeAndApply.0
|
|
|
|
if case let .chatList(index) = item.index, index.pinningIndex != nil, promoInfo == nil, !isPeerGroup {
|
|
let sizeAndApply = reorderControlLayout(item.presentationData.theme)
|
|
reorderControlSizeAndApply = sizeAndApply
|
|
reorderInset = sizeAndApply.0
|
|
} else if case let .forum(pinnedIndex, _, _, _, _) = item.index, case .index = pinnedIndex {
|
|
if case let .chat(itemPeer) = contentPeer, case let .channel(channel) = itemPeer.peer {
|
|
let canPin = channel.flags.contains(.isCreator) || channel.hasPermission(.pinMessages)
|
|
if canPin {
|
|
let sizeAndApply = reorderControlLayout(item.presentationData.theme)
|
|
reorderControlSizeAndApply = sizeAndApply
|
|
reorderInset = sizeAndApply.0
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
editingOffset = 0.0
|
|
}
|
|
|
|
let enableChatListPhotos = true
|
|
|
|
let avatarDiameter = min(60.0, floor(item.presentationData.fontSize.baseDisplaySize * 60.0 / 17.0))
|
|
|
|
let avatarLeftInset: CGFloat
|
|
if item.interaction.isInlineMode {
|
|
avatarLeftInset = 12.0
|
|
} else if !useChatListLayout {
|
|
avatarLeftInset = 50.0
|
|
} else {
|
|
avatarLeftInset = 18.0 + avatarDiameter
|
|
}
|
|
|
|
let badgeDiameter = floor(item.presentationData.fontSize.baseDisplaySize * 20.0 / 17.0)
|
|
let avatarBadgeDiameter: CGFloat = floor(floor(item.presentationData.fontSize.itemListBaseFontSize * 22.0 / 17.0))
|
|
let avatarTimerBadgeDiameter: CGFloat = floor(floor(item.presentationData.fontSize.itemListBaseFontSize * 24.0 / 17.0))
|
|
|
|
let currentAvatarBadgeCleanBackgroundImage: UIImage? = PresentationResourcesChatList.badgeBackgroundBorder(item.presentationData.theme, diameter: avatarBadgeDiameter + 4.0)
|
|
|
|
let leftInset: CGFloat = params.leftInset + avatarLeftInset
|
|
|
|
enum ContentData {
|
|
case chat(itemPeer: EngineRenderedPeer, threadInfo: ChatListItemContent.ThreadInfo?, peer: EnginePeer?, hideAuthor: Bool, messageText: String, spoilers: [NSRange]?, customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?)
|
|
case group(peers: [EngineChatList.GroupItem.Item])
|
|
}
|
|
|
|
let contentData: ContentData
|
|
|
|
var hideAuthor = false
|
|
switch contentPeer {
|
|
case let .chat(itemPeer):
|
|
var (peer, initialHideAuthor, messageText, spoilers, customEmojiRanges) = chatListItemStrings(strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, contentSettings: item.context.currentContentSettings.with { $0 }, messages: messages, chatPeer: itemPeer, accountPeerId: item.context.account.peerId, enableMediaEmoji: !enableChatListPhotos, isPeerGroup: isPeerGroup)
|
|
|
|
if case let .psa(_, maybePsaText) = promoInfo, let psaText = maybePsaText {
|
|
initialHideAuthor = true
|
|
messageText = psaText
|
|
}
|
|
|
|
switch itemPeer.peer {
|
|
case .user:
|
|
if let attribute = messages.first?._asMessage().reactionsAttribute {
|
|
loop: for recentPeer in attribute.recentPeers {
|
|
if recentPeer.isUnseen {
|
|
switch recentPeer.value {
|
|
case let .builtin(value):
|
|
messageText = item.presentationData.strings.ChatList_UserReacted(value).string
|
|
case .custom:
|
|
break
|
|
}
|
|
break loop
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
contentData = .chat(itemPeer: itemPeer, threadInfo: threadInfo, peer: peer, hideAuthor: hideAuthor, messageText: messageText, spoilers: spoilers, customEmojiRanges: customEmojiRanges)
|
|
hideAuthor = initialHideAuthor
|
|
case let .group(groupPeers):
|
|
contentData = .group(peers: groupPeers)
|
|
hideAuthor = true
|
|
}
|
|
|
|
let attributedText: NSAttributedString
|
|
var hasDraft = false
|
|
|
|
var inlineAuthorPrefix: String?
|
|
if case .groupReference = item.content {
|
|
if case let .user(author) = messages.last?.author {
|
|
if author.id == item.context.account.peerId {
|
|
inlineAuthorPrefix = item.presentationData.strings.DialogList_You
|
|
} else if messages.last?.id.peerId.namespace != Namespaces.Peer.CloudUser && messages.last?.id.peerId.namespace != Namespaces.Peer.SecretChat {
|
|
inlineAuthorPrefix = EnginePeer.user(author).compactDisplayTitle
|
|
}
|
|
}
|
|
}
|
|
|
|
var chatListText: (String, String)?
|
|
var chatListSearchResult: CachedChatListSearchResult?
|
|
var chatListQuoteSearchResult: CachedChatListSearchResult?
|
|
var customTextEntities: CachedCustomTextEntities?
|
|
|
|
let contentImageSide: CGFloat = max(10.0, min(20.0, floor(item.presentationData.fontSize.baseDisplaySize * 18.0 / 17.0)))
|
|
let contentImageSize = CGSize(width: contentImageSide, height: contentImageSide)
|
|
let contentImageSpacing: CGFloat = 2.0
|
|
let forwardedIconSpacing: CGFloat = 6.0
|
|
let contentImageTrailingSpace: CGFloat = 5.0
|
|
var contentImageSpecs: [(message: EngineMessage, media: EngineMedia, size: CGSize)] = []
|
|
var forumThread: (id: Int64, title: String, iconId: Int64?, iconColor: Int32, isUnread: Bool)?
|
|
|
|
var displayForwardedIcon = false
|
|
var displayStoryReplyIcon = false
|
|
|
|
switch contentData {
|
|
case let .chat(itemPeer, _, _, _, text, spoilers, customEmojiRanges):
|
|
var isUser = false
|
|
if case .user = itemPeer.chatMainPeer {
|
|
isUser = true
|
|
}
|
|
|
|
var peerText: String?
|
|
if case .savedMessagesChats = item.chatListLocation {
|
|
if let message = messages.last, let forwardInfo = message.forwardInfo, let author = forwardInfo.author {
|
|
if author.id != itemPeer.chatMainPeer?.id {
|
|
peerText = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
}
|
|
} else if case .groupReference = item.content {
|
|
if let messagePeer = itemPeer.chatMainPeer {
|
|
peerText = messagePeer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
} else if let message = messages.last, let author = message.author?._asPeer(), let peer = itemPeer.chatMainPeer, !isUser {
|
|
if case let .channel(peer) = peer, case .broadcast = peer.info {
|
|
} else if !displayAsMessage {
|
|
if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported), let authorSignature = forwardInfo.authorSignature {
|
|
peerText = authorSignature
|
|
} else {
|
|
peerText = author.id == account.peerId ? item.presentationData.strings.DialogList_You : EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
authorIsCurrentChat = author.id == peer.id
|
|
}
|
|
}
|
|
}
|
|
|
|
if let _ = peerText, case let .channel(channel) = itemPeer.chatMainPeer, channel.flags.contains(.isForum), threadInfo == nil {
|
|
if let forumTopicData = forumTopicData {
|
|
forumThread = (forumTopicData.id, forumTopicData.title, forumTopicData.iconFileId, forumTopicData.iconColor, forumTopicData.isUnread)
|
|
} else if let threadInfo = threadInfo {
|
|
forumThread = (threadInfo.id, threadInfo.info.title, threadInfo.info.icon, threadInfo.info.iconColor, false)
|
|
}
|
|
}
|
|
|
|
let messageText: String
|
|
if let currentChatListText = currentChatListText, currentChatListText.0 == text {
|
|
messageText = currentChatListText.1
|
|
chatListText = currentChatListText
|
|
} else {
|
|
if let spoilers = spoilers, !spoilers.isEmpty {
|
|
messageText = text
|
|
} else if let customEmojiRanges = customEmojiRanges, !customEmojiRanges.isEmpty {
|
|
messageText = text
|
|
} else {
|
|
messageText = foldLineBreaks(text)
|
|
}
|
|
chatListText = (text, messageText)
|
|
}
|
|
|
|
if inlineAuthorPrefix == nil, let draftState = draftState {
|
|
hasDraft = true
|
|
authorAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_Draft, font: textFont, textColor: theme.messageDraftTextColor)
|
|
|
|
let draftText = stringWithAppliedEntities(draftState.text, entities: draftState.entities, baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, message: nil)
|
|
|
|
attributedText = foldLineBreaks(draftText)
|
|
} else if let message = messages.first {
|
|
var composedString: NSMutableAttributedString
|
|
|
|
if let peerText = peerText {
|
|
authorAttributedString = NSAttributedString(string: peerText, font: textFont, textColor: theme.authorNameColor)
|
|
}
|
|
|
|
var entities = (message._asMessage().textEntitiesAttribute?.entities ?? []).filter { entity in
|
|
switch entity.type {
|
|
case .Spoiler, .CustomEmoji:
|
|
return true
|
|
case .Strikethrough, .Underline, .Italic, .Bold:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
if message.id.peerId.namespace == Namespaces.Peer.CloudUser && message.id.peerId.id._internalGetInt64Value() == 777000 {
|
|
if let cached = currentCustomTextEntities, cached.matches(text: message.text) {
|
|
customTextEntities = cached
|
|
} else if let matches = loginCodeRegex?.matches(in: message.text, options: [], range: NSMakeRange(0, (message.text as NSString).length)) {
|
|
var entities: [MessageTextEntity] = []
|
|
if let first = matches.first {
|
|
entities.append(MessageTextEntity(range: first.range.location ..< first.range.location + first.range.length, type: .Spoiler))
|
|
}
|
|
customTextEntities = CachedCustomTextEntities(text: message.text, textEntities: entities)
|
|
}
|
|
}
|
|
|
|
if let customTextEntities, !customTextEntities.textEntities.isEmpty {
|
|
entities.append(contentsOf: customTextEntities.textEntities)
|
|
}
|
|
|
|
let messageString: NSAttributedString
|
|
if !message.text.isEmpty && entities.count > 0 {
|
|
var messageText = message.text
|
|
var entities = entities
|
|
if !"".isEmpty, let translation = message.attributes.first(where: { $0 is TranslationMessageAttribute }) as? TranslationMessageAttribute, !translation.text.isEmpty {
|
|
messageText = translation.text
|
|
entities = translation.entities
|
|
}
|
|
|
|
messageString = foldLineBreaks(stringWithAppliedEntities(messageText, entities: entities, baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: italicTextFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: message._asMessage()))
|
|
} else if spoilers != nil || customEmojiRanges != nil {
|
|
let mutableString = NSMutableAttributedString(string: messageText, font: textFont, textColor: theme.messageTextColor)
|
|
if let spoilers = spoilers {
|
|
for range in spoilers {
|
|
var range = range
|
|
if range.location > mutableString.length {
|
|
continue
|
|
} else if range.location + range.length > mutableString.length {
|
|
range.length = mutableString.length - range.location
|
|
}
|
|
mutableString.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler), value: true, range: range)
|
|
}
|
|
}
|
|
if let customEmojiRanges = customEmojiRanges {
|
|
for (range, attribute) in customEmojiRanges {
|
|
var range = range
|
|
if range.location > mutableString.length {
|
|
continue
|
|
} else if range.location + range.length > mutableString.length {
|
|
range.length = mutableString.length - range.location
|
|
}
|
|
mutableString.addAttribute(ChatTextInputAttributes.customEmoji, value: attribute, range: range)
|
|
}
|
|
}
|
|
messageString = mutableString
|
|
} else {
|
|
messageString = NSAttributedString(string: messageText, font: textFont, textColor: theme.messageTextColor)
|
|
}
|
|
if let inlineAuthorPrefix = inlineAuthorPrefix {
|
|
composedString = NSMutableAttributedString()
|
|
composedString.append(NSAttributedString(string: "\(inlineAuthorPrefix): ", font: textFont, textColor: theme.titleColor))
|
|
composedString.append(messageString)
|
|
} else {
|
|
composedString = NSMutableAttributedString(attributedString: messageString)
|
|
}
|
|
|
|
var composedReplyString: NSMutableAttributedString?
|
|
if let searchQuery = item.interaction.searchTextHighightState {
|
|
var quoteText: String?
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? ReplyMessageAttribute {
|
|
if let quote = attribute.quote {
|
|
quoteText = quote.text
|
|
}
|
|
} else if let attribute = attribute as? QuotedReplyMessageAttribute {
|
|
if let quote = attribute.quote {
|
|
quoteText = quote.text
|
|
}
|
|
}
|
|
}
|
|
if let quoteText {
|
|
let quoteString = foldLineBreaks(stringWithAppliedEntities(quoteText, entities: [], baseColor: theme.messageTextColor, linkColor: theme.messageTextColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: italicTextFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil))
|
|
composedReplyString = NSMutableAttributedString(attributedString: quoteString)
|
|
}
|
|
|
|
if let cached = currentChatListSearchResult, cached.matches(text: composedString.string, searchQuery: searchQuery) {
|
|
chatListSearchResult = cached
|
|
} else {
|
|
let (ranges, text) = findSubstringRanges(in: composedString.string, query: searchQuery)
|
|
chatListSearchResult = CachedChatListSearchResult(text: text, searchQuery: searchQuery, resultRanges: ranges)
|
|
}
|
|
|
|
if let composedReplyString {
|
|
if let cached = currentChatListQuoteSearchResult, cached.matches(text: composedReplyString.string, searchQuery: searchQuery) {
|
|
chatListQuoteSearchResult = cached
|
|
} else {
|
|
let (ranges, text) = findSubstringRanges(in: composedReplyString.string, query: searchQuery)
|
|
chatListQuoteSearchResult = CachedChatListSearchResult(text: text, searchQuery: searchQuery, resultRanges: ranges)
|
|
}
|
|
} else {
|
|
chatListQuoteSearchResult = nil
|
|
}
|
|
} else {
|
|
chatListSearchResult = nil
|
|
chatListQuoteSearchResult = nil
|
|
}
|
|
|
|
if let chatListSearchResult = chatListSearchResult, let firstRange = chatListSearchResult.resultRanges.first {
|
|
for range in chatListSearchResult.resultRanges {
|
|
let stringRange = NSRange(range, in: chatListSearchResult.text)
|
|
if stringRange.location >= 0 && stringRange.location + stringRange.length <= composedString.length {
|
|
var stringRange = stringRange
|
|
if stringRange.location > composedString.length {
|
|
continue
|
|
} else if stringRange.location + stringRange.length > composedString.length {
|
|
stringRange.length = composedString.length - stringRange.location
|
|
}
|
|
composedString.addAttribute(.foregroundColor, value: theme.messageHighlightedTextColor, range: stringRange)
|
|
}
|
|
}
|
|
|
|
let firstRangeOrigin = chatListSearchResult.text.distance(from: chatListSearchResult.text.startIndex, to: firstRange.lowerBound)
|
|
if firstRangeOrigin > 24 {
|
|
var leftOrigin: Int = 0
|
|
(composedString.string as NSString).enumerateSubstrings(in: NSMakeRange(0, firstRangeOrigin), options: [.byWords, .reverse]) { (str, range1, _, _) in
|
|
let distanceFromEnd = firstRangeOrigin - range1.location
|
|
if (distanceFromEnd > 12 || range1.location == 0) && leftOrigin == 0 {
|
|
leftOrigin = range1.location
|
|
}
|
|
}
|
|
composedString = composedString.attributedSubstring(from: NSMakeRange(leftOrigin, composedString.length - leftOrigin)).mutableCopy() as! NSMutableAttributedString
|
|
composedString.insert(NSAttributedString(string: "\u{2026}", attributes: [NSAttributedString.Key.font: textFont, NSAttributedString.Key.foregroundColor: theme.messageTextColor]), at: 0)
|
|
}
|
|
} else if var composedReplyString, let chatListQuoteSearchResult, let firstRange = chatListQuoteSearchResult.resultRanges.first {
|
|
for range in chatListQuoteSearchResult.resultRanges {
|
|
let stringRange = NSRange(range, in: chatListQuoteSearchResult.text)
|
|
if stringRange.location >= 0 && stringRange.location + stringRange.length <= composedReplyString.length {
|
|
var stringRange = stringRange
|
|
if stringRange.location > composedReplyString.length {
|
|
continue
|
|
} else if stringRange.location + stringRange.length > composedReplyString.length {
|
|
stringRange.length = composedReplyString.length - stringRange.location
|
|
}
|
|
composedReplyString.addAttribute(.foregroundColor, value: theme.messageHighlightedTextColor, range: stringRange)
|
|
}
|
|
}
|
|
|
|
let firstRangeOrigin = chatListQuoteSearchResult.text.distance(from: chatListQuoteSearchResult.text.startIndex, to: firstRange.lowerBound)
|
|
if firstRangeOrigin > 24 {
|
|
var leftOrigin: Int = 0
|
|
(composedReplyString.string as NSString).enumerateSubstrings(in: NSMakeRange(0, firstRangeOrigin), options: [.byWords, .reverse]) { (str, range1, _, _) in
|
|
let distanceFromEnd = firstRangeOrigin - range1.location
|
|
if (distanceFromEnd > 12 || range1.location == 0) && leftOrigin == 0 {
|
|
leftOrigin = range1.location
|
|
}
|
|
}
|
|
composedReplyString = composedReplyString.attributedSubstring(from: NSMakeRange(leftOrigin, composedReplyString.length - leftOrigin)).mutableCopy() as! NSMutableAttributedString
|
|
composedReplyString.insert(NSAttributedString(string: "\u{2026}", attributes: [NSAttributedString.Key.font: textFont, NSAttributedString.Key.foregroundColor: theme.messageTextColor]), at: 0)
|
|
}
|
|
|
|
composedString = composedReplyString
|
|
}
|
|
|
|
attributedText = composedString
|
|
|
|
if case .savedMessagesChats = item.chatListLocation {
|
|
displayForwardedIcon = false
|
|
} else if let forwardInfo = message.forwardInfo, !forwardInfo.flags.contains(.isImported) {
|
|
displayForwardedIcon = true
|
|
} else if let _ = message.attributes.first(where: { $0 is ReplyStoryAttribute }) {
|
|
displayStoryReplyIcon = true
|
|
}
|
|
|
|
var displayMediaPreviews = true
|
|
if message._asMessage().containsSecretMedia {
|
|
displayMediaPreviews = false
|
|
} else if let _ = message.peers[message.id.peerId] as? TelegramSecretChat {
|
|
displayMediaPreviews = false
|
|
}
|
|
if displayMediaPreviews {
|
|
let contentImageFillSize = CGSize(width: 8.0, height: contentImageSize.height)
|
|
_ = contentImageFillSize
|
|
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))
|
|
}
|
|
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))
|
|
}
|
|
break inner
|
|
} else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
|
|
let imageTypes = ["photo", "video", "embed", "gif", "document", "telegram_album"]
|
|
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))
|
|
}
|
|
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))
|
|
}
|
|
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))
|
|
} 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))
|
|
}
|
|
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))
|
|
}
|
|
break inner
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
attributedText = NSAttributedString(string: messageText, font: textFont, textColor: theme.messageTextColor)
|
|
|
|
var peerText: String?
|
|
if case .groupReference = item.content {
|
|
if let messagePeer = itemPeer.chatMainPeer {
|
|
peerText = messagePeer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
}
|
|
|
|
if let peerText = peerText {
|
|
authorAttributedString = NSAttributedString(string: peerText, font: textFont, textColor: theme.authorNameColor)
|
|
}
|
|
}
|
|
case let .group(peers):
|
|
let textString = NSMutableAttributedString(string: "")
|
|
var isFirst = true
|
|
for peer in peers {
|
|
if let chatMainPeer = peer.peer.chatMainPeer {
|
|
let peerTitle = chatMainPeer.compactDisplayTitle
|
|
if !peerTitle.isEmpty {
|
|
if isFirst {
|
|
isFirst = false
|
|
} else {
|
|
textString.append(NSAttributedString(string: ", ", font: textFont, textColor: theme.messageTextColor))
|
|
}
|
|
textString.append(NSAttributedString(string: peerTitle, font: textFont, textColor: peer.isUnread ? theme.authorNameColor : theme.messageTextColor))
|
|
}
|
|
}
|
|
}
|
|
if textString.length == 0, case let .groupReference(data) = item.content, let storyState = data.storyState, storyState.stats.totalCount != 0 {
|
|
let storyText: String = item.presentationData.strings.ChatList_ArchiveStoryCount(Int32(storyState.stats.totalCount))
|
|
textString.append(NSAttributedString(string: storyText, font: textFont, textColor: theme.messageTextColor))
|
|
}
|
|
attributedText = textString
|
|
}
|
|
|
|
if displayForwardedIcon {
|
|
currentForwardedIcon = PresentationResourcesChatList.forwardedIcon(item.presentationData.theme)
|
|
}
|
|
|
|
if displayStoryReplyIcon {
|
|
currentStoryIcon = PresentationResourcesChatList.storyReplyIcon(item.presentationData.theme)
|
|
}
|
|
|
|
if let currentForwardedIcon {
|
|
textLeftCutout += currentForwardedIcon.size.width
|
|
if !contentImageSpecs.isEmpty {
|
|
textLeftCutout += forwardedIconSpacing
|
|
} else {
|
|
textLeftCutout += contentImageTrailingSpace
|
|
}
|
|
}
|
|
|
|
if let currentStoryIcon {
|
|
textLeftCutout += currentStoryIcon.size.width
|
|
if !contentImageSpecs.isEmpty {
|
|
textLeftCutout += forwardedIconSpacing
|
|
} else {
|
|
textLeftCutout += contentImageTrailingSpace
|
|
}
|
|
}
|
|
|
|
for i in 0 ..< contentImageSpecs.count {
|
|
if i != 0 {
|
|
textLeftCutout += contentImageSpacing
|
|
}
|
|
textLeftCutout += contentImageSpecs[i].size.width
|
|
if i == contentImageSpecs.count - 1 {
|
|
textLeftCutout += contentImageTrailingSpace
|
|
}
|
|
}
|
|
|
|
switch contentData {
|
|
case let .chat(itemPeer, threadInfo, _, _, _, _, _):
|
|
if let threadInfo = threadInfo {
|
|
titleAttributedString = NSAttributedString(string: threadInfo.info.title, font: titleFont, textColor: theme.titleColor)
|
|
} else if let message = messages.last, case let .user(author) = message.author, displayAsMessage {
|
|
titleAttributedString = NSAttributedString(string: author.id == account.peerId ? item.presentationData.strings.DialogList_You : EnginePeer.user(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder), font: titleFont, textColor: theme.titleColor)
|
|
} else if isPeerGroup {
|
|
titleAttributedString = NSAttributedString(string: item.presentationData.strings.ChatList_ArchivedChatsTitle, font: titleFont, textColor: theme.titleColor)
|
|
} else if itemPeer.chatMainPeer?.id == item.context.account.peerId {
|
|
titleAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_SavedMessages, font: titleFont, textColor: theme.titleColor)
|
|
} else if let id = itemPeer.chatMainPeer?.id, id.isReplies {
|
|
titleAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_Replies, font: titleFont, textColor: theme.titleColor)
|
|
} else if let id = itemPeer.chatMainPeer?.id, id.isAnonymousSavedMessages {
|
|
//TODO:localize
|
|
titleAttributedString = NSAttributedString(string: "Author Hidden", font: titleFont, textColor: theme.titleColor)
|
|
} else if let displayTitle = itemPeer.chatMainPeer?.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) {
|
|
let textColor: UIColor
|
|
if case let .chatList(index) = item.index, index.messageIndex.id.peerId.namespace == Namespaces.Peer.SecretChat {
|
|
textColor = theme.secretTitleColor
|
|
} else {
|
|
textColor = theme.titleColor
|
|
}
|
|
titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: textColor)
|
|
}
|
|
case .group:
|
|
titleAttributedString = NSAttributedString(string: item.presentationData.strings.ChatList_ArchivedChatsTitle, font: titleFont, textColor: theme.titleColor)
|
|
}
|
|
|
|
textAttributedString = attributedText
|
|
|
|
let dateText: String
|
|
var topIndex: MessageIndex?
|
|
switch item.content {
|
|
case let .groupReference(groupReferenceData):
|
|
topIndex = groupReferenceData.message?.index
|
|
case let .peer(peerData):
|
|
topIndex = peerData.messages.first?.index
|
|
}
|
|
if let topIndex {
|
|
var t = Int(topIndex.timestamp)
|
|
var timeinfo = tm()
|
|
localtime_r(&t, &timeinfo)
|
|
|
|
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
|
|
|
|
dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: topIndex.timestamp, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat)
|
|
} else {
|
|
dateText = ""
|
|
}
|
|
|
|
if isPeerGroup {
|
|
dateAttributedString = NSAttributedString(string: "", font: dateFont, textColor: theme.dateTextColor)
|
|
} else if let promoInfo = promoInfo {
|
|
switch promoInfo {
|
|
case .proxy:
|
|
dateAttributedString = NSAttributedString(string: item.presentationData.strings.DialogList_AdLabel, font: dateFont, textColor: theme.dateTextColor)
|
|
case let .psa(type, _):
|
|
var text = item.presentationData.strings.ChatList_GenericPsaLabel
|
|
let key = "ChatList.PsaLabel.\(type)"
|
|
if let string = item.presentationData.strings.primaryComponent.dict[key] {
|
|
text = string
|
|
} else if let string = item.presentationData.strings.secondaryComponent?.dict[key] {
|
|
text = string
|
|
}
|
|
dateAttributedString = NSAttributedString(string: text, font: dateFont, textColor: theme.dateTextColor)
|
|
}
|
|
} else {
|
|
dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: theme.dateTextColor)
|
|
}
|
|
|
|
if !isPeerGroup, let message = messages.last, message.author?.id == account.peerId && !hasDraft {
|
|
if message.flags.isSending && !message._asMessage().isSentOrAcknowledged {
|
|
statusState = .clock(PresentationResourcesChatList.clockFrameImage(item.presentationData.theme), PresentationResourcesChatList.clockMinImage(item.presentationData.theme))
|
|
} else if message.id.peerId != account.peerId {
|
|
if hasFailedMessages {
|
|
statusState = .failed(item.presentationData.theme.chatList.failedFillColor, item.presentationData.theme.chatList.failedForegroundColor)
|
|
} else {
|
|
if let forumTopicData = forumTopicData {
|
|
if message.id.namespace == forumTopicData.maxOutgoingReadMessageId.namespace, message.id.id >= forumTopicData.maxOutgoingReadMessageId.id {
|
|
statusState = .read(item.presentationData.theme.chatList.checkmarkColor)
|
|
} else {
|
|
statusState = .delivered(item.presentationData.theme.chatList.checkmarkColor)
|
|
}
|
|
} else {
|
|
if let combinedReadState = combinedReadState, combinedReadState.isOutgoingMessageIndexRead(message.index) {
|
|
statusState = .read(item.presentationData.theme.chatList.checkmarkColor)
|
|
} else {
|
|
statusState = .delivered(item.presentationData.theme.chatList.checkmarkColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if unreadCount.unread {
|
|
if !isPeerGroup, let message = messages.last, message.tags.contains(.unseenPersonalMessage), unreadCount.count == 1 {
|
|
} else {
|
|
let badgeTextColor: UIColor
|
|
if unreadCount.muted {
|
|
if unreadCount.isProvisonal, case .forum = item.chatListLocation {
|
|
badgeTextColor = theme.unreadBadgeInactiveBackgroundColor
|
|
currentBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundInactiveProvisional(item.presentationData.theme, diameter: badgeDiameter)
|
|
currentAvatarBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundInactiveProvisional(item.presentationData.theme, diameter: avatarBadgeDiameter)
|
|
} else {
|
|
badgeTextColor = theme.unreadBadgeInactiveTextColor
|
|
currentBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundInactive(item.presentationData.theme, diameter: badgeDiameter)
|
|
currentAvatarBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundInactive(item.presentationData.theme, diameter: avatarBadgeDiameter)
|
|
}
|
|
} else {
|
|
if unreadCount.isProvisonal, case .forum = item.chatListLocation {
|
|
badgeTextColor = theme.unreadBadgeActiveBackgroundColor
|
|
currentBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundActiveProvisional(item.presentationData.theme, diameter: badgeDiameter)
|
|
currentAvatarBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundActiveProvisional(item.presentationData.theme, diameter: avatarBadgeDiameter)
|
|
} else {
|
|
badgeTextColor = theme.unreadBadgeActiveTextColor
|
|
currentBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundActive(item.presentationData.theme, diameter: badgeDiameter)
|
|
currentAvatarBadgeBackgroundImage = PresentationResourcesChatList.badgeBackgroundActive(item.presentationData.theme, diameter: avatarBadgeDiameter)
|
|
}
|
|
}
|
|
let unreadCountText = compactNumericCountString(Int(unreadCount.count), decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator)
|
|
if unreadCount.count > 0 {
|
|
badgeContent = .text(NSAttributedString(string: unreadCountText, font: badgeFont, textColor: badgeTextColor))
|
|
} else if isPeerGroup {
|
|
badgeContent = .none
|
|
} else {
|
|
badgeContent = .blank
|
|
}
|
|
|
|
if let mutedCount = unreadCount.mutedCount, mutedCount > 0 {
|
|
let mutedUnreadCountText = compactNumericCountString(Int(mutedCount), decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator)
|
|
currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundInactive(item.presentationData.theme, diameter: badgeDiameter)
|
|
mentionBadgeContent = .text(NSAttributedString(string: mutedUnreadCountText, font: badgeFont, textColor: theme.unreadBadgeInactiveTextColor))
|
|
}
|
|
}
|
|
}
|
|
|
|
if !isPeerGroup {
|
|
if hasUnseenMentions {
|
|
if case .chatList(.archive) = item.chatListLocation {
|
|
currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundInactiveMention(item.presentationData.theme, diameter: badgeDiameter)
|
|
} else {
|
|
currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundMention(item.presentationData.theme, diameter: badgeDiameter)
|
|
}
|
|
mentionBadgeContent = .mention
|
|
} else if hasUnseenReactions {
|
|
if isRemovedFromTotalUnreadCount {
|
|
currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundInactiveReactions(item.presentationData.theme, diameter: badgeDiameter)
|
|
} else {
|
|
currentMentionBadgeImage = PresentationResourcesChatList.badgeBackgroundReactions(item.presentationData.theme, diameter: badgeDiameter)
|
|
}
|
|
mentionBadgeContent = .mention
|
|
} else if item.isPinned, promoInfo == nil, currentBadgeBackgroundImage == nil {
|
|
currentPinnedIconImage = PresentationResourcesChatList.badgeBackgroundPinned(item.presentationData.theme, diameter: badgeDiameter)
|
|
}
|
|
}
|
|
|
|
let isMuted = isRemovedFromTotalUnreadCount
|
|
if isMuted {
|
|
currentMutedIconImage = PresentationResourcesChatList.mutedIcon(item.presentationData.theme)
|
|
}
|
|
|
|
var statusWidth: CGFloat
|
|
if case .none = statusState {
|
|
statusWidth = 0.0
|
|
} else {
|
|
statusWidth = 24.0
|
|
}
|
|
|
|
var dateIconImage: UIImage?
|
|
if let threadInfo, threadInfo.isClosed {
|
|
dateIconImage = PresentationResourcesChatList.statusLockIcon(item.presentationData.theme)
|
|
}
|
|
|
|
if let dateIconImage {
|
|
statusWidth += dateIconImage.size.width + 4.0
|
|
}
|
|
|
|
var titleIconsWidth: CGFloat = 0.0
|
|
if let currentMutedIconImage = currentMutedIconImage {
|
|
if titleIconsWidth.isZero {
|
|
titleIconsWidth += 4.0
|
|
}
|
|
titleIconsWidth += currentMutedIconImage.size.width
|
|
}
|
|
|
|
var isSecret = false
|
|
if !isPeerGroup {
|
|
if case let .chatList(index) = item.index, index.messageIndex.id.peerId.namespace == Namespaces.Peer.SecretChat {
|
|
isSecret = true
|
|
}
|
|
}
|
|
if isSecret {
|
|
currentSecretIconImage = PresentationResourcesChatList.secretIcon(item.presentationData.theme)
|
|
}
|
|
|
|
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: item.context.currentAppConfiguration.with { $0 })
|
|
var isAccountPeer = false
|
|
if case let .chatList(index) = item.index, index.messageIndex.id.peerId == item.context.account.peerId {
|
|
isAccountPeer = true
|
|
}
|
|
if !isPeerGroup && !isAccountPeer && threadInfo == nil {
|
|
if displayAsMessage {
|
|
switch item.content {
|
|
case let .peer(peerData):
|
|
if let peer = peerData.messages.last?.author {
|
|
if peer.isScam {
|
|
currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased())
|
|
} else if peer.isFake {
|
|
currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_FakeAccount.uppercased())
|
|
} else if let emojiStatus = peer.emojiStatus, !premiumConfiguration.isPremiumDisabled {
|
|
if case .channel = peer, peer.isVerified {
|
|
currentVerifiedIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
|
|
}
|
|
|
|
currentCredibilityIconContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(2))
|
|
} else if peer.isVerified {
|
|
currentCredibilityIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
|
|
} else if peer.isPremium && !premiumConfiguration.isPremiumDisabled {
|
|
currentCredibilityIconContent = .premium(color: item.presentationData.theme.list.itemAccentColor)
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
} else if case let .chat(itemPeer) = contentPeer, let peer = itemPeer.chatMainPeer {
|
|
if peer.isScam {
|
|
currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_ScamAccount.uppercased())
|
|
} else if peer.isFake {
|
|
currentCredibilityIconContent = .text(color: item.presentationData.theme.chat.message.incoming.scamColor, string: item.presentationData.strings.Message_FakeAccount.uppercased())
|
|
} else if let emojiStatus = peer.emojiStatus, !premiumConfiguration.isPremiumDisabled {
|
|
if case .channel = peer, peer.isVerified {
|
|
currentVerifiedIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
|
|
}
|
|
|
|
currentCredibilityIconContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 32.0, height: 32.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(2))
|
|
} else if peer.isVerified {
|
|
currentCredibilityIconContent = .verified(fillColor: item.presentationData.theme.list.itemCheckColors.fillColor, foregroundColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
|
|
} else if peer.isPremium && !premiumConfiguration.isPremiumDisabled {
|
|
currentCredibilityIconContent = .premium(color: item.presentationData.theme.list.itemAccentColor)
|
|
}
|
|
}
|
|
}
|
|
if let currentSecretIconImage = currentSecretIconImage {
|
|
titleIconsWidth += currentSecretIconImage.size.width + 2.0
|
|
}
|
|
|
|
if let currentVerifiedIconContent {
|
|
if titleIconsWidth.isZero {
|
|
titleIconsWidth += 4.0
|
|
} else {
|
|
titleIconsWidth += 2.0
|
|
}
|
|
switch currentVerifiedIconContent {
|
|
case let .text(_, string):
|
|
let textString = NSAttributedString(string: string, font: Font.bold(10.0), textColor: .black, paragraphAlignment: .center)
|
|
let stringRect = textString.boundingRect(with: CGSize(width: 100.0, height: 16.0), options: .usesLineFragmentOrigin, context: nil)
|
|
titleIconsWidth += floor(stringRect.width) + 11.0
|
|
default:
|
|
titleIconsWidth += 8.0
|
|
}
|
|
}
|
|
|
|
if let currentCredibilityIconContent {
|
|
if titleIconsWidth.isZero {
|
|
titleIconsWidth += 4.0
|
|
} else {
|
|
titleIconsWidth += 2.0
|
|
}
|
|
switch currentCredibilityIconContent {
|
|
case let .text(_, string):
|
|
let textString = NSAttributedString(string: string, font: Font.bold(10.0), textColor: .black, paragraphAlignment: .center)
|
|
let stringRect = textString.boundingRect(with: CGSize(width: 100.0, height: 16.0), options: .usesLineFragmentOrigin, context: nil)
|
|
titleIconsWidth += floor(stringRect.width) + 11.0
|
|
default:
|
|
titleIconsWidth += 8.0
|
|
}
|
|
}
|
|
|
|
let layoutOffset: CGFloat = 0.0
|
|
|
|
let rawContentWidth = params.width - leftInset - params.rightInset - 10.0 - editingOffset
|
|
|
|
let (dateLayout, dateApply) = dateLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
|
|
let (badgeLayout, badgeApply) = badgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), badgeDiameter, badgeFont, currentBadgeBackgroundImage, badgeContent)
|
|
|
|
let (mentionBadgeLayout, mentionBadgeApply) = mentionBadgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), badgeDiameter, badgeFont, currentMentionBadgeImage, mentionBadgeContent)
|
|
|
|
var badgeSize: CGFloat = 0.0
|
|
if !badgeLayout.width.isZero {
|
|
badgeSize += badgeLayout.width + 5.0
|
|
}
|
|
if !mentionBadgeLayout.width.isZero {
|
|
if !badgeSize.isZero {
|
|
badgeSize += mentionBadgeLayout.width + 4.0
|
|
} else {
|
|
badgeSize += mentionBadgeLayout.width + 5.0
|
|
}
|
|
}
|
|
let countersSize = badgeSize
|
|
if let currentPinnedIconImage = currentPinnedIconImage {
|
|
if !badgeSize.isZero {
|
|
badgeSize += 4.0
|
|
} else {
|
|
badgeSize += 5.0
|
|
}
|
|
badgeSize += currentPinnedIconImage.size.width
|
|
}
|
|
badgeSize = max(badgeSize, reorderInset)
|
|
|
|
var effectiveAuthorTitle = (hideAuthor && !hasDraft) ? nil : authorAttributedString
|
|
|
|
let isSearching = item.interaction.searchTextHighightState != nil
|
|
|
|
var isFirstForumThreadSelectable = false
|
|
var forumThreads: [(id: Int64, title: NSAttributedString, iconId: Int64?, iconColor: Int32)] = []
|
|
if forumThread != nil || !topForumTopicItems.isEmpty {
|
|
if let forumThread = forumThread {
|
|
isFirstForumThreadSelectable = forumThread.isUnread
|
|
forumThreads.append((id: forumThread.id, title: NSAttributedString(string: forumThread.title, font: textFont, textColor: forumThread.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: forumThread.iconId, iconColor: forumThread.iconColor))
|
|
}
|
|
for item in topForumTopicItems {
|
|
if forumThread?.id != item.id {
|
|
forumThreads.append((id: item.id, title: NSAttributedString(string: item.title, font: textFont, textColor: item.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: item.iconFileId, iconColor: item.iconColor))
|
|
}
|
|
}
|
|
|
|
if let effectiveAuthorTitle, let textAttributedStringValue = textAttributedString {
|
|
let mutableTextAttributedString = NSMutableAttributedString()
|
|
mutableTextAttributedString.append(NSAttributedString(string: effectiveAuthorTitle.string + ": ", font: textFont, textColor: theme.authorNameColor))
|
|
mutableTextAttributedString.append(textAttributedStringValue)
|
|
|
|
textAttributedString = mutableTextAttributedString
|
|
}
|
|
|
|
effectiveAuthorTitle = nil
|
|
}
|
|
|
|
if authorIsCurrentChat {
|
|
effectiveAuthorTitle = nil
|
|
}
|
|
|
|
let (authorLayout, authorApply) = authorLayout(item.context, rawContentWidth - badgeSize, item.presentationData.theme, effectiveAuthorTitle, forumThreads)
|
|
|
|
var textCutout: TextNodeCutout?
|
|
if !textLeftCutout.isZero {
|
|
textCutout = TextNodeCutout(topLeft: CGSize(width: textLeftCutout, height: 10.0), topRight: nil, bottomRight: nil)
|
|
}
|
|
|
|
var textMaxWidth = rawContentWidth - badgeSize
|
|
|
|
var textArrowImage: UIImage?
|
|
if isFirstForumThreadSelectable {
|
|
textArrowImage = PresentationResourcesItemList.disclosureArrowImage(item.presentationData.theme)
|
|
textMaxWidth -= 18.0
|
|
}
|
|
|
|
let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: textAttributedString, backgroundColor: nil, maximumNumberOfLines: authorAttributedString == nil ? 2 : 1, truncationType: .end, constrainedSize: CGSize(width: textMaxWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: textCutout, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)))
|
|
|
|
let maxTitleLines: Int
|
|
switch item.index {
|
|
case .forum:
|
|
maxTitleLines = 2
|
|
case .chatList:
|
|
maxTitleLines = 1
|
|
}
|
|
|
|
var titleLeftCutout: CGFloat = 0.0
|
|
if item.interaction.isInlineMode {
|
|
titleLeftCutout = 22.0
|
|
}
|
|
|
|
if let titleAttributedStringValue = titleAttributedString, titleAttributedStringValue.length == 0 {
|
|
titleAttributedString = NSAttributedString(string: " ", font: titleFont, textColor: theme.titleColor)
|
|
}
|
|
|
|
let titleRectWidth = rawContentWidth - dateLayout.size.width - 10.0 - statusWidth - titleIconsWidth
|
|
var titleCutout: TextNodeCutout?
|
|
if !titleLeftCutout.isZero {
|
|
titleCutout = TextNodeCutout(topLeft: CGSize(width: titleLeftCutout, height: 10.0), topRight: nil, bottomRight: nil)
|
|
}
|
|
let (titleLayout, titleApply) = titleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: maxTitleLines, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: titleCutout, insets: UIEdgeInsets()))
|
|
|
|
var inputActivitiesSize: CGSize?
|
|
var inputActivitiesApply: (() -> Void)?
|
|
var chatPeerId: EnginePeer.Id?
|
|
if case let .chatList(index) = item.index {
|
|
chatPeerId = index.messageIndex.id.peerId
|
|
} else if case let .forum(peerId) = item.chatListLocation {
|
|
chatPeerId = peerId
|
|
}
|
|
if let inputActivities = inputActivities, !inputActivities.isEmpty, let chatPeerId {
|
|
let (size, apply) = inputActivitiesLayout(CGSize(width: rawContentWidth - badgeSize, height: 40.0), item.presentationData, item.presentationData.theme.chatList.messageTextColor, chatPeerId, inputActivities)
|
|
inputActivitiesSize = size
|
|
inputActivitiesApply = apply
|
|
} else {
|
|
let (size, apply) = inputActivitiesLayout(CGSize(width: rawContentWidth - badgeSize, height: 40.0), item.presentationData, item.presentationData.theme.chatList.messageTextColor, nil, [])
|
|
inputActivitiesSize = size
|
|
inputActivitiesApply = apply
|
|
}
|
|
|
|
var online = false
|
|
var animateOnline = false
|
|
var onlineIsVoiceChat = false
|
|
|
|
var isPinned = false
|
|
if case let .chatList(index) = item.index {
|
|
isPinned = index.pinningIndex != nil
|
|
} else if case let .forum(pinnedIndex, _, _, _, _) = item.index {
|
|
if case .index = pinnedIndex {
|
|
isPinned = true
|
|
}
|
|
}
|
|
|
|
var peerRevealOptions: [ItemListRevealOption]
|
|
var peerLeftRevealOptions: [ItemListRevealOption]
|
|
switch item.content {
|
|
case let .peer(peerData):
|
|
let renderedPeer = peerData.peer
|
|
let presence = peerData.presence
|
|
let displayAsMessage = peerData.displayAsMessage
|
|
|
|
if !displayAsMessage {
|
|
if case let .user(peer) = renderedPeer.chatMainPeer, let presence = presence, !isServicePeer(peer) && !peer.flags.contains(.isSupport) && peer.id != item.context.account.peerId {
|
|
let updatedPresence = EnginePeer.Presence(status: presence.status, lastActivity: 0)
|
|
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
|
|
let relativeStatus = relativeUserPresenceStatus(updatedPresence, relativeTo: timestamp)
|
|
if case .online = relativeStatus {
|
|
online = true
|
|
}
|
|
animateOnline = true
|
|
} else if case let .channel(channel) = renderedPeer.peer, case .chatList = item.index {
|
|
onlineIsVoiceChat = true
|
|
if channel.flags.contains(.hasActiveVoiceChat) && item.interaction.searchTextHighightState == nil {
|
|
online = true
|
|
}
|
|
animateOnline = true
|
|
} else if case let .legacyGroup(group) = renderedPeer.peer, case .chatList = item.index {
|
|
onlineIsVoiceChat = true
|
|
if group.flags.contains(.hasActiveVoiceChat) && item.interaction.searchTextHighightState == nil {
|
|
online = true
|
|
}
|
|
animateOnline = true
|
|
}
|
|
}
|
|
|
|
if item.enableContextActions {
|
|
if case .forum = item.chatListLocation {
|
|
if case let .chat(itemPeer) = contentPeer, case let .channel(channel) = itemPeer.peer {
|
|
var canOpenClose = false
|
|
if channel.flags.contains(.isCreator) {
|
|
canOpenClose = true
|
|
} else if channel.hasPermission(.manageTopics) {
|
|
canOpenClose = true
|
|
} else if let threadInfo = threadInfo, threadInfo.isOwnedByMe {
|
|
canOpenClose = true
|
|
}
|
|
let canDelete = channel.hasPermission(.deleteAllMessages)
|
|
var isClosed = false
|
|
if let threadInfo {
|
|
isClosed = threadInfo.isClosed
|
|
}
|
|
if let threadInfo, threadInfo.id == 1 {
|
|
peerRevealOptions = forumGeneralRevealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isMuted: (currentMutedIconImage != nil), isClosed: isClosed, isEditing: item.editing, canOpenClose: canOpenClose, canHide: channel.flags.contains(.isCreator) || channel.hasPermission(.manageTopics), hiddenByDefault: threadInfo.isHidden)
|
|
} else {
|
|
peerRevealOptions = forumThreadRevealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isMuted: (currentMutedIconImage != nil), isClosed: isClosed, isEditing: item.editing, canOpenClose: canOpenClose, canDelete: canDelete)
|
|
}
|
|
peerLeftRevealOptions = []
|
|
} else {
|
|
peerRevealOptions = []
|
|
peerLeftRevealOptions = []
|
|
}
|
|
} else if case .psa = promoInfo {
|
|
peerRevealOptions = [
|
|
ItemListRevealOption(key: RevealOptionKey.hidePsa.rawValue, title: item.presentationData.strings.ChatList_HideAction, icon: deleteIcon, color: item.presentationData.theme.list.itemDisclosureActions.inactive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.neutral1.foregroundColor)
|
|
]
|
|
peerLeftRevealOptions = []
|
|
} else if promoInfo == nil {
|
|
peerRevealOptions = revealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isPinned: isPinned, isMuted: !isAccountPeer ? (currentMutedIconImage != nil) : nil, location: item.chatListLocation, peerId: renderedPeer.peerId, accountPeerId: item.context.account.peerId, canDelete: true, isEditing: item.editing, filterData: item.filterData)
|
|
if case let .chat(itemPeer) = contentPeer {
|
|
peerLeftRevealOptions = leftRevealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isUnread: unreadCount.unread, isEditing: item.editing, isPinned: isPinned, isSavedMessages: itemPeer.peerId == item.context.account.peerId, location: item.chatListLocation, peer: itemPeer.peers[itemPeer.peerId]!, filterData: item.filterData)
|
|
} else {
|
|
peerLeftRevealOptions = []
|
|
}
|
|
} else {
|
|
peerRevealOptions = []
|
|
peerLeftRevealOptions = []
|
|
}
|
|
} else {
|
|
peerRevealOptions = []
|
|
peerLeftRevealOptions = []
|
|
}
|
|
case .groupReference:
|
|
peerRevealOptions = groupReferenceRevealOptions(strings: item.presentationData.strings, theme: item.presentationData.theme, isEditing: item.editing, hiddenByDefault: groupHiddenByDefault)
|
|
peerLeftRevealOptions = []
|
|
}
|
|
|
|
if item.interaction.inlineNavigationLocation != nil {
|
|
peerRevealOptions = []
|
|
peerLeftRevealOptions = []
|
|
}
|
|
|
|
let (onlineLayout, onlineApply) = onlineLayout(online, onlineIsVoiceChat)
|
|
var animateContent = false
|
|
if let currentItem = currentItem, currentItem.content.chatLocation == item.content.chatLocation {
|
|
animateContent = true
|
|
}
|
|
|
|
let (measureLayout, measureApply) = makeMeasureLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
|
|
let titleSpacing: CGFloat = -1.0
|
|
let authorSpacing: CGFloat = -3.0
|
|
var itemHeight: CGFloat = 8.0 * 2.0 + 1.0
|
|
itemHeight -= 21.0
|
|
itemHeight += titleLayout.size.height
|
|
itemHeight += measureLayout.size.height * 3.0
|
|
itemHeight += titleSpacing
|
|
itemHeight += authorSpacing
|
|
|
|
let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: layoutOffset + floor(item.presentationData.fontSize.itemListBaseFontSize * 8.0 / 17.0)), size: CGSize(width: rawContentWidth, height: itemHeight - 12.0 - 9.0))
|
|
|
|
let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader)
|
|
var heightOffset: CGFloat = 0.0
|
|
if item.hiddenOffset {
|
|
heightOffset = -itemHeight
|
|
}
|
|
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: max(0.0, itemHeight + heightOffset)), insets: insets)
|
|
|
|
var customActions: [ChatListItemAccessibilityCustomAction] = []
|
|
for option in peerLeftRevealOptions {
|
|
customActions.append(ChatListItemAccessibilityCustomAction(name: option.title, target: nil, selector: #selector(ChatListItemNode.performLocalAccessibilityCustomAction(_:)), key: option.key))
|
|
}
|
|
for option in peerRevealOptions {
|
|
customActions.append(ChatListItemAccessibilityCustomAction(name: option.title, target: nil, selector: #selector(ChatListItemNode.performLocalAccessibilityCustomAction(_:)), key: option.key))
|
|
}
|
|
|
|
return (layout, { [weak self] synchronousLoads, animated in
|
|
if let strongSelf = self {
|
|
strongSelf.layoutParams = (item, first, last, firstWithHeader, nextIsPinned, params, countersSize)
|
|
strongSelf.currentItemHeight = itemHeight
|
|
strongSelf.cachedChatListText = chatListText
|
|
strongSelf.cachedChatListSearchResult = chatListSearchResult
|
|
strongSelf.cachedChatListQuoteSearchResult = chatListQuoteSearchResult
|
|
strongSelf.cachedCustomTextEntities = customTextEntities
|
|
strongSelf.onlineIsVoiceChat = onlineIsVoiceChat
|
|
|
|
var animateOnline = animateOnline
|
|
if let currentOnline = strongSelf.currentOnline, currentOnline == online {
|
|
animateOnline = false
|
|
}
|
|
strongSelf.currentOnline = online
|
|
|
|
if item.hiddenOffset {
|
|
strongSelf.layer.zPosition = -1.0
|
|
}
|
|
|
|
if case .groupReference = item.content {
|
|
strongSelf.layer.sublayerTransform = CATransform3DMakeTranslation(0.0, layout.contentSize.height - itemHeight, 0.0)
|
|
}
|
|
|
|
if let _ = updatedTheme {
|
|
strongSelf.separatorNode.backgroundColor = item.presentationData.theme.chatList.itemSeparatorColor
|
|
}
|
|
|
|
let revealOffset = 0.0
|
|
|
|
let transition: ContainedViewLayoutTransition
|
|
if animated {
|
|
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
|
|
} else {
|
|
transition = .immediate
|
|
}
|
|
|
|
let contextContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: itemHeight))
|
|
// strongSelf.contextContainer.position = contextContainerFrame.center
|
|
transition.updatePosition(node: strongSelf.contextContainer, position: contextContainerFrame.center)
|
|
transition.updateBounds(node: strongSelf.contextContainer, bounds: contextContainerFrame.offsetBy(dx: -strongSelf.revealOffset, dy: 0.0))
|
|
|
|
var mainContentFrame: CGRect
|
|
var mainContentBoundsOffset: CGFloat
|
|
var mainContentAlpha: CGFloat = 1.0
|
|
|
|
if useChatListLayout {
|
|
mainContentFrame = CGRect(origin: CGPoint(x: leftInset - 2.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height))
|
|
mainContentBoundsOffset = mainContentFrame.origin.x
|
|
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation {
|
|
mainContentAlpha = 1.0 - inlineNavigationLocation.progress
|
|
mainContentBoundsOffset += (mainContentFrame.width - mainContentFrame.minX) * inlineNavigationLocation.progress
|
|
}
|
|
} else {
|
|
mainContentFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height))
|
|
mainContentBoundsOffset = 0.0
|
|
}
|
|
|
|
transition.updatePosition(node: strongSelf.mainContentContainerNode, position: mainContentFrame.center)
|
|
|
|
transition.updateBounds(node: strongSelf.mainContentContainerNode, bounds: CGRect(origin: CGPoint(x: mainContentBoundsOffset, y: 0.0), size: mainContentFrame.size))
|
|
transition.updateAlpha(node: strongSelf.mainContentContainerNode, alpha: mainContentAlpha)
|
|
|
|
var crossfadeContent = false
|
|
if let selectableControlSizeAndApply = selectableControlSizeAndApply {
|
|
let selectableControlSize = CGSize(width: selectableControlSizeAndApply.0, height: layout.contentSize.height)
|
|
let selectableControlFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset, y: layoutOffset), size: selectableControlSize)
|
|
if strongSelf.selectableControlNode == nil {
|
|
crossfadeContent = true
|
|
let selectableControlNode = selectableControlSizeAndApply.1(selectableControlSize, false)
|
|
strongSelf.selectableControlNode = selectableControlNode
|
|
strongSelf.addSubnode(selectableControlNode)
|
|
selectableControlNode.frame = selectableControlFrame
|
|
transition.animatePosition(node: selectableControlNode, from: CGPoint(x: -selectableControlFrame.size.width / 2.0, y: layoutOffset + selectableControlFrame.midY))
|
|
selectableControlNode.alpha = 0.0
|
|
transition.updateAlpha(node: selectableControlNode, alpha: 1.0)
|
|
} else if let selectableControlNode = strongSelf.selectableControlNode {
|
|
transition.updateFrame(node: selectableControlNode, frame: selectableControlFrame)
|
|
let _ = selectableControlSizeAndApply.1(selectableControlSize, transition.isAnimated)
|
|
}
|
|
} else if let selectableControlNode = strongSelf.selectableControlNode {
|
|
crossfadeContent = true
|
|
var selectableControlFrame = selectableControlNode.frame
|
|
selectableControlFrame.origin.x = -selectableControlFrame.size.width
|
|
strongSelf.selectableControlNode = nil
|
|
transition.updateAlpha(node: selectableControlNode, alpha: 0.0)
|
|
transition.updateFrame(node: selectableControlNode, frame: selectableControlFrame, completion: { [weak selectableControlNode] _ in
|
|
selectableControlNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
|
|
var animateBadges = animateContent
|
|
if let reorderControlSizeAndApply = reorderControlSizeAndApply {
|
|
let reorderControlFrame = CGRect(origin: CGPoint(x: params.width + revealOffset - params.rightInset - reorderControlSizeAndApply.0, y: layoutOffset), size: CGSize(width: reorderControlSizeAndApply.0, height: layout.contentSize.height))
|
|
if strongSelf.reorderControlNode == nil {
|
|
let reorderControlNode = reorderControlSizeAndApply.1(layout.contentSize.height, false, .immediate)
|
|
strongSelf.reorderControlNode = reorderControlNode
|
|
strongSelf.addSubnode(reorderControlNode)
|
|
reorderControlNode.frame = reorderControlFrame
|
|
reorderControlNode.alpha = 0.0
|
|
transition.updateAlpha(node: reorderControlNode, alpha: 1.0)
|
|
|
|
transition.updateAlpha(node: strongSelf.dateNode, alpha: 0.0)
|
|
if let dateStatusIconNode = strongSelf.dateStatusIconNode {
|
|
transition.updateAlpha(node: dateStatusIconNode, alpha: 0.0)
|
|
}
|
|
transition.updateAlpha(node: strongSelf.badgeNode, alpha: 0.0)
|
|
transition.updateAlpha(node: strongSelf.mentionBadgeNode, alpha: 0.0)
|
|
transition.updateAlpha(node: strongSelf.pinnedIconNode, alpha: 0.0)
|
|
transition.updateAlpha(node: strongSelf.statusNode, alpha: 0.0)
|
|
} else if let reorderControlNode = strongSelf.reorderControlNode {
|
|
let _ = reorderControlSizeAndApply.1(layout.contentSize.height, false, .immediate)
|
|
transition.updateFrame(node: reorderControlNode, frame: reorderControlFrame)
|
|
}
|
|
} else if let reorderControlNode = strongSelf.reorderControlNode {
|
|
animateBadges = false
|
|
strongSelf.reorderControlNode = nil
|
|
transition.updateAlpha(node: reorderControlNode, alpha: 0.0, completion: { [weak reorderControlNode] _ in
|
|
reorderControlNode?.removeFromSupernode()
|
|
})
|
|
transition.updateAlpha(node: strongSelf.dateNode, alpha: 1.0)
|
|
if let dateStatusIconNode = strongSelf.dateStatusIconNode {
|
|
transition.updateAlpha(node: dateStatusIconNode, alpha: 1.0)
|
|
}
|
|
transition.updateAlpha(node: strongSelf.badgeNode, alpha: 1.0)
|
|
transition.updateAlpha(node: strongSelf.mentionBadgeNode, alpha: 1.0)
|
|
transition.updateAlpha(node: strongSelf.pinnedIconNode, alpha: 1.0)
|
|
transition.updateAlpha(node: strongSelf.statusNode, alpha: 1.0)
|
|
}
|
|
|
|
let contentRect = rawContentRect.offsetBy(dx: editingOffset + leftInset + revealOffset, dy: 0.0)
|
|
|
|
let avatarFrame = CGRect(origin: CGPoint(x: leftInset - avatarLeftInset + editingOffset + 10.0 + revealOffset, y: floor((itemHeight - avatarDiameter) / 2.0)), size: CGSize(width: avatarDiameter, height: avatarDiameter))
|
|
var avatarScaleOffset: CGFloat = 0.0
|
|
var avatarScale: CGFloat = 1.0
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation {
|
|
let targetAvatarScale: CGFloat = floor(item.presentationData.fontSize.itemListBaseFontSize * 54.0 / 17.0) / avatarFrame.width
|
|
avatarScale = targetAvatarScale * inlineNavigationLocation.progress + 1.0 * (1.0 - inlineNavigationLocation.progress)
|
|
|
|
let targetAvatarScaleOffset: CGFloat = -(avatarFrame.width - avatarFrame.width * avatarScale) * 0.5
|
|
avatarScaleOffset = targetAvatarScaleOffset * inlineNavigationLocation.progress
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.avatarContainerNode, frame: avatarFrame)
|
|
transition.updatePosition(node: strongSelf.avatarNode, position: avatarFrame.offsetBy(dx: -avatarFrame.minX, dy: -avatarFrame.minY).center.offsetBy(dx: avatarScaleOffset, dy: 0.0))
|
|
transition.updateBounds(node: strongSelf.avatarNode, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
|
transition.updateTransformScale(node: strongSelf.avatarNode, scale: avatarScale)
|
|
strongSelf.avatarNode.updateSize(size: avatarFrame.size)
|
|
strongSelf.updateVideoVisibility()
|
|
|
|
var itemPeerId: EnginePeer.Id?
|
|
if case let .chatList(index) = item.index {
|
|
itemPeerId = index.messageIndex.id.peerId
|
|
}
|
|
|
|
if let itemPeerId = itemPeerId, let inlineNavigationLocation = item.interaction.inlineNavigationLocation, inlineNavigationLocation.location.peerId == itemPeerId {
|
|
let inlineNavigationMarkLayer: SimpleLayer
|
|
var animateIn = false
|
|
if let current = strongSelf.inlineNavigationMarkLayer {
|
|
inlineNavigationMarkLayer = current
|
|
} else {
|
|
inlineNavigationMarkLayer = SimpleLayer()
|
|
strongSelf.inlineNavigationMarkLayer = inlineNavigationMarkLayer
|
|
inlineNavigationMarkLayer.cornerRadius = 4.0
|
|
animateIn = true
|
|
strongSelf.layer.addSublayer(inlineNavigationMarkLayer)
|
|
}
|
|
inlineNavigationMarkLayer.backgroundColor = item.presentationData.theme.list.itemAccentColor.cgColor
|
|
let markHeight: CGFloat = 50.0
|
|
var markFrame = CGRect(origin: CGPoint(x: -4.0, y: avatarFrame.midY - markHeight * 0.5), size: CGSize(width: 8.0, height: markHeight))
|
|
markFrame.origin.x -= (1.0 - inlineNavigationLocation.progress) * markFrame.width * 0.5
|
|
if animateIn {
|
|
inlineNavigationMarkLayer.frame = markFrame
|
|
transition.animatePositionAdditive(layer: inlineNavigationMarkLayer, offset: CGPoint(x: -markFrame.width * 0.5, y: 0.0))
|
|
} else {
|
|
transition.updateFrame(layer: inlineNavigationMarkLayer, frame: markFrame)
|
|
}
|
|
} else {
|
|
if let inlineNavigationMarkLayer = strongSelf.inlineNavigationMarkLayer {
|
|
strongSelf.inlineNavigationMarkLayer = nil
|
|
transition.updatePosition(layer: inlineNavigationMarkLayer, position: CGPoint(x: -inlineNavigationMarkLayer.bounds.width * 0.5, y: avatarFrame.midY))
|
|
}
|
|
}
|
|
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation, badgeContent != .none {
|
|
var animateIn = false
|
|
|
|
let avatarBadgeBackground: ASImageNode
|
|
if let current = strongSelf.avatarBadgeBackground {
|
|
avatarBadgeBackground = current
|
|
} else {
|
|
avatarBadgeBackground = ASImageNode()
|
|
strongSelf.avatarBadgeBackground = avatarBadgeBackground
|
|
strongSelf.avatarNode.addSubnode(avatarBadgeBackground)
|
|
}
|
|
|
|
avatarBadgeBackground.image = currentAvatarBadgeCleanBackgroundImage
|
|
|
|
let avatarBadgeNode: ChatListBadgeNode
|
|
if let current = strongSelf.avatarBadgeNode {
|
|
avatarBadgeNode = current
|
|
} else {
|
|
animateIn = true
|
|
avatarBadgeNode = ChatListBadgeNode()
|
|
avatarBadgeNode.disableBounce = true
|
|
strongSelf.avatarBadgeNode = avatarBadgeNode
|
|
strongSelf.avatarNode.addSubnode(avatarBadgeNode)
|
|
}
|
|
|
|
let makeAvatarBadgeLayout = avatarBadgeNode.asyncLayout()
|
|
let (avatarBadgeLayout, avatarBadgeApply) = makeAvatarBadgeLayout(CGSize(width: rawContentWidth, height: CGFloat.greatestFiniteMagnitude), avatarBadgeDiameter, avatarBadgeFont, currentAvatarBadgeBackgroundImage, badgeContent)
|
|
let _ = avatarBadgeApply(animateBadges, false)
|
|
let avatarBadgeFrame = CGRect(origin: CGPoint(x: avatarFrame.width - avatarBadgeLayout.width, y: avatarFrame.height - avatarBadgeLayout.height), size: avatarBadgeLayout)
|
|
avatarBadgeNode.position = avatarBadgeFrame.center
|
|
avatarBadgeNode.bounds = CGRect(origin: CGPoint(), size: avatarBadgeFrame.size)
|
|
|
|
let avatarBadgeBackgroundFrame = avatarBadgeFrame.insetBy(dx: -2.0, dy: -2.0)
|
|
avatarBadgeBackground.position = avatarBadgeBackgroundFrame.center
|
|
avatarBadgeBackground.bounds = CGRect(origin: CGPoint(), size: avatarBadgeBackgroundFrame.size)
|
|
|
|
if animateIn {
|
|
ContainedViewLayoutTransition.immediate.updateSublayerTransformScale(node: avatarBadgeNode, scale: 0.00001)
|
|
ContainedViewLayoutTransition.immediate.updateTransformScale(layer: avatarBadgeBackground.layer, scale: 0.00001)
|
|
}
|
|
transition.updateSublayerTransformScale(node: avatarBadgeNode, scale: max(0.00001, inlineNavigationLocation.progress))
|
|
transition.updateTransformScale(layer: avatarBadgeBackground.layer, scale: max(0.00001, inlineNavigationLocation.progress))
|
|
} else if let avatarBadgeNode = strongSelf.avatarBadgeNode {
|
|
strongSelf.avatarBadgeNode = nil
|
|
transition.updateSublayerTransformScale(node: avatarBadgeNode, scale: 0.00001, completion: { [weak avatarBadgeNode] _ in
|
|
avatarBadgeNode?.removeFromSupernode()
|
|
})
|
|
if let avatarBadgeBackground = strongSelf.avatarBadgeBackground {
|
|
strongSelf.avatarBadgeBackground = nil
|
|
transition.updateTransformScale(layer: avatarBadgeBackground.layer, scale: 0.00001, completion: { [weak avatarBadgeBackground] _ in
|
|
avatarBadgeBackground?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
|
|
if let threadInfo = threadInfo, !displayAsMessage {
|
|
let avatarIconView: ComponentHostView<Empty>
|
|
if let current = strongSelf.avatarIconView {
|
|
avatarIconView = current
|
|
} else {
|
|
avatarIconView = ComponentHostView<Empty>()
|
|
strongSelf.avatarIconView = avatarIconView
|
|
strongSelf.mainContentContainerNode.view.addSubview(avatarIconView)
|
|
}
|
|
|
|
let avatarIconContent: EmojiStatusComponent.Content
|
|
if threadInfo.id == 1 {
|
|
avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(item.presentationData.theme))
|
|
} else if let fileId = threadInfo.info.icon, fileId != 0 {
|
|
avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 48.0, height: 48.0), placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor, themeColor: item.presentationData.theme.list.itemAccentColor, loopMode: .count(0))
|
|
} else {
|
|
avatarIconContent = .topic(title: String(threadInfo.info.title.prefix(1)), color: threadInfo.info.iconColor, size: CGSize(width: 32.0, height: 32.0))
|
|
}
|
|
|
|
let avatarIconComponent = EmojiStatusComponent(
|
|
context: item.context,
|
|
animationCache: item.interaction.animationCache,
|
|
animationRenderer: item.interaction.animationRenderer,
|
|
content: avatarIconContent,
|
|
isVisibleForAnimations: strongSelf.visibilityStatus && item.context.sharedContext.energyUsageSettings.loopEmoji,
|
|
action: nil
|
|
)
|
|
strongSelf.avatarIconComponent = avatarIconComponent
|
|
|
|
let iconSize = avatarIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(avatarIconComponent),
|
|
environment: {},
|
|
containerSize: item.interaction.isInlineMode ? CGSize(width: 18.0, height: 18.0) : CGSize(width: 32.0, height: 32.0)
|
|
)
|
|
|
|
let avatarIconFrame: CGRect
|
|
if item.interaction.isInlineMode {
|
|
avatarIconFrame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.origin.y + 1.0), size: iconSize)
|
|
} else {
|
|
avatarIconFrame = CGRect(origin: CGPoint(x: editingOffset + params.leftInset + floor((leftInset - params.leftInset - iconSize.width) / 2.0) + revealOffset, y: contentRect.origin.y + 2.0), size: iconSize)
|
|
}
|
|
transition.updateFrame(view: avatarIconView, frame: avatarIconFrame)
|
|
} else if let avatarIconView = strongSelf.avatarIconView {
|
|
strongSelf.avatarIconView = nil
|
|
avatarIconView.removeFromSuperview()
|
|
}
|
|
|
|
if !useChatListLayout {
|
|
strongSelf.avatarContainerNode.isHidden = true
|
|
} else {
|
|
strongSelf.avatarContainerNode.isHidden = false
|
|
}
|
|
|
|
let onlineFrame: CGRect
|
|
if onlineIsVoiceChat {
|
|
onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.width - onlineLayout.width + 1.0 - UIScreenPixel, y: avatarFrame.height - onlineLayout.height + 1.0 - UIScreenPixel), size: onlineLayout)
|
|
} else {
|
|
onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.width - onlineLayout.width - 2.0, y: avatarFrame.height - onlineLayout.height - 2.0), size: onlineLayout)
|
|
}
|
|
transition.updateFrame(node: strongSelf.onlineNode, frame: onlineFrame)
|
|
|
|
let onlineInlineNavigationFraction: CGFloat = item.interaction.inlineNavigationLocation?.progress ?? 0.0
|
|
transition.updateAlpha(node: strongSelf.onlineNode, alpha: 1.0 - onlineInlineNavigationFraction)
|
|
transition.updateSublayerTransformScale(node: strongSelf.onlineNode, scale: (1.0 - onlineInlineNavigationFraction) * 1.0 + onlineInlineNavigationFraction * 0.00001)
|
|
|
|
let onlineIcon: UIImage?
|
|
if strongSelf.reallyHighlighted {
|
|
onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted, voiceChat: onlineIsVoiceChat)
|
|
} else if case let .chatList(index) = item.index, index.pinningIndex != nil {
|
|
onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned, voiceChat: onlineIsVoiceChat)
|
|
} else {
|
|
onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular, voiceChat: onlineIsVoiceChat)
|
|
}
|
|
strongSelf.onlineNode.setImage(onlineIcon, color: item.presentationData.theme.list.itemCheckColors.foregroundColor, transition: .immediate)
|
|
|
|
let autoremoveTimeoutFraction: CGFloat
|
|
if online {
|
|
autoremoveTimeoutFraction = 0.0
|
|
} else {
|
|
autoremoveTimeoutFraction = 1.0 - onlineInlineNavigationFraction
|
|
}
|
|
|
|
if let autoremoveTimeout = autoremoveTimeout {
|
|
let avatarTimerBadge: AvatarBadgeView
|
|
var avatarTimerTransition = transition
|
|
if !avatarTimerTransition.isAnimated, animateOnline {
|
|
avatarTimerTransition = .animated(duration: 0.3, curve: .spring)
|
|
}
|
|
if let current = strongSelf.avatarTimerBadge {
|
|
avatarTimerBadge = current
|
|
} else {
|
|
avatarTimerTransition = .immediate
|
|
avatarTimerBadge = AvatarBadgeView(frame: CGRect())
|
|
strongSelf.avatarTimerBadge = avatarTimerBadge
|
|
strongSelf.avatarNode.view.addSubview(avatarTimerBadge)
|
|
}
|
|
let avatarBadgeSize = CGSize(width: avatarTimerBadgeDiameter, height: avatarTimerBadgeDiameter)
|
|
avatarTimerBadge.update(size: avatarBadgeSize, text: shortTimeIntervalString(strings: item.presentationData.strings, value: autoremoveTimeout, useLargeFormat: true))
|
|
let avatarBadgeFrame = CGRect(origin: CGPoint(x: avatarFrame.width - avatarBadgeSize.width, y: avatarFrame.height - avatarBadgeSize.height), size: avatarBadgeSize)
|
|
avatarTimerTransition.updatePosition(layer: avatarTimerBadge.layer, position: avatarBadgeFrame.center)
|
|
avatarTimerTransition.updateBounds(layer: avatarTimerBadge.layer, bounds: CGRect(origin: CGPoint(), size: avatarBadgeFrame.size))
|
|
avatarTimerTransition.updateTransformScale(layer: avatarTimerBadge.layer, scale: autoremoveTimeoutFraction * 1.0 + (1.0 - autoremoveTimeoutFraction) * 0.00001)
|
|
|
|
strongSelf.avatarNode.badgeView = avatarTimerBadge
|
|
} else if let avatarTimerBadge = strongSelf.avatarTimerBadge {
|
|
strongSelf.avatarTimerBadge = nil
|
|
strongSelf.avatarNode.badgeView = nil
|
|
avatarTimerBadge.removeFromSuperview()
|
|
}
|
|
|
|
let _ = measureApply()
|
|
let _ = dateApply()
|
|
|
|
let _ = textApply(TextNodeWithEntities.Arguments(
|
|
context: item.context,
|
|
cache: item.interaction.animationCache,
|
|
renderer: item.interaction.animationRenderer,
|
|
placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor,
|
|
attemptSynchronous: synchronousLoads
|
|
))
|
|
|
|
var topForumTopicRect = authorApply()
|
|
if !isFirstForumThreadSelectable {
|
|
topForumTopicRect = nil
|
|
}
|
|
|
|
let _ = titleApply()
|
|
let _ = badgeApply(animateBadges, !isMuted)
|
|
let _ = mentionBadgeApply(animateBadges, true)
|
|
let _ = onlineApply(animateContent && animateOnline)
|
|
|
|
transition.updateFrame(node: strongSelf.dateNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x + contentRect.size.width - dateLayout.size.width, y: contentRect.origin.y + 2.0), size: dateLayout.size))
|
|
|
|
var statusOffset: CGFloat = 0.0
|
|
if let dateIconImage {
|
|
statusOffset += 2.0 + dateIconImage.size.width + 4.0
|
|
|
|
let dateStatusIconNode: ASImageNode
|
|
if let current = strongSelf.dateStatusIconNode {
|
|
dateStatusIconNode = current
|
|
} else {
|
|
dateStatusIconNode = ASImageNode()
|
|
strongSelf.dateStatusIconNode = dateStatusIconNode
|
|
strongSelf.mainContentContainerNode.addSubnode(dateStatusIconNode)
|
|
}
|
|
dateStatusIconNode.image = dateIconImage
|
|
|
|
var dateStatusX: CGFloat = contentRect.origin.x
|
|
dateStatusX += contentRect.size.width
|
|
dateStatusX += -dateLayout.size.width - 4.0 - dateIconImage.size.width
|
|
|
|
var dateStatusY: CGFloat = contentRect.origin.y + 2.0 + UIScreenPixel
|
|
dateStatusY += -UIScreenPixel + floor((dateLayout.size.height - dateIconImage.size.height) / 2.0)
|
|
|
|
transition.updateFrame(node: dateStatusIconNode, frame: CGRect(origin: CGPoint(x: dateStatusX, y: dateStatusY), size: dateIconImage.size))
|
|
} else if let dateStatusIconNode = strongSelf.dateStatusIconNode {
|
|
strongSelf.dateStatusIconNode = nil
|
|
dateStatusIconNode.removeFromSupernode()
|
|
}
|
|
|
|
let statusSize = CGSize(width: 24.0, height: 24.0)
|
|
|
|
var statusX: CGFloat = contentRect.origin.x
|
|
statusX += contentRect.size.width
|
|
statusX += -dateLayout.size.width - statusSize.width - statusOffset
|
|
|
|
strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: statusX, y: contentRect.origin.y + 2.0 - UIScreenPixel + floor((dateLayout.size.height - statusSize.height) / 2.0)), size: statusSize)
|
|
strongSelf.statusNode.fontSize = item.presentationData.fontSize.itemListBaseFontSize
|
|
let _ = strongSelf.statusNode.transitionToState(statusState, animated: animateContent)
|
|
|
|
if let _ = currentBadgeBackgroundImage {
|
|
let badgeFrame = CGRect(x: contentRect.maxX - badgeLayout.width, y: contentRect.maxY - badgeLayout.height - 2.0, width: badgeLayout.width, height: badgeLayout.height)
|
|
|
|
transition.updateFrame(node: strongSelf.badgeNode, frame: badgeFrame)
|
|
}
|
|
|
|
if currentMentionBadgeImage != nil || currentBadgeBackgroundImage != nil {
|
|
let mentionBadgeOffset: CGFloat
|
|
if badgeLayout.width.isZero {
|
|
mentionBadgeOffset = contentRect.maxX - mentionBadgeLayout.width
|
|
} else {
|
|
mentionBadgeOffset = contentRect.maxX - badgeLayout.width - 6.0 - mentionBadgeLayout.width
|
|
}
|
|
|
|
let badgeFrame = CGRect(x: mentionBadgeOffset, y: contentRect.maxY - mentionBadgeLayout.height - 2.0, width: mentionBadgeLayout.width, height: mentionBadgeLayout.height)
|
|
|
|
transition.updateFrame(node: strongSelf.mentionBadgeNode, frame: badgeFrame)
|
|
}
|
|
|
|
if let currentPinnedIconImage = currentPinnedIconImage {
|
|
strongSelf.pinnedIconNode.image = currentPinnedIconImage
|
|
strongSelf.pinnedIconNode.isHidden = false
|
|
|
|
let pinnedIconSize = currentPinnedIconImage.size
|
|
let pinnedIconFrame = CGRect(x: contentRect.maxX - pinnedIconSize.width, y: contentRect.maxY - pinnedIconSize.height - 2.0, width: pinnedIconSize.width, height: pinnedIconSize.height)
|
|
|
|
strongSelf.pinnedIconNode.frame = pinnedIconFrame
|
|
} else {
|
|
strongSelf.pinnedIconNode.image = nil
|
|
strongSelf.pinnedIconNode.isHidden = true
|
|
}
|
|
|
|
var titleOffset: CGFloat = 0.0
|
|
if let currentSecretIconImage = currentSecretIconImage {
|
|
let iconNode: ASImageNode
|
|
if let current = strongSelf.secretIconNode {
|
|
iconNode = current
|
|
} else {
|
|
iconNode = ASImageNode()
|
|
iconNode.isLayerBacked = true
|
|
iconNode.displaysAsynchronously = false
|
|
iconNode.displayWithoutProcessing = true
|
|
strongSelf.mainContentContainerNode.addSubnode(iconNode)
|
|
strongSelf.secretIconNode = iconNode
|
|
}
|
|
iconNode.image = currentSecretIconImage
|
|
transition.updateFrame(node: iconNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.origin.y + floor((titleLayout.size.height - currentSecretIconImage.size.height) / 2.0)), size: currentSecretIconImage.size))
|
|
titleOffset += currentSecretIconImage.size.width + 3.0
|
|
} else if let secretIconNode = strongSelf.secretIconNode {
|
|
strongSelf.secretIconNode = nil
|
|
secretIconNode.removeFromSupernode()
|
|
}
|
|
|
|
let contentDelta = CGPoint(x: contentRect.origin.x - (strongSelf.titleNode.frame.minX - titleOffset), y: contentRect.origin.y - (strongSelf.titleNode.frame.minY - UIScreenPixel))
|
|
let titleFrame = CGRect(origin: CGPoint(x: contentRect.origin.x + titleOffset, y: contentRect.origin.y + UIScreenPixel), size: titleLayout.size)
|
|
strongSelf.titleNode.frame = titleFrame
|
|
let authorNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height), size: authorLayout)
|
|
strongSelf.authorNode.frame = authorNodeFrame
|
|
let textNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 1.0 + UIScreenPixel + (authorLayout.height.isZero ? 0.0 : (authorLayout.height - 3.0))), size: textLayout.size)
|
|
|
|
if let topForumTopicRect, !isSearching {
|
|
let compoundHighlightingNode: LinkHighlightingNode
|
|
if let current = strongSelf.compoundHighlightingNode {
|
|
compoundHighlightingNode = current
|
|
} else {
|
|
compoundHighlightingNode = LinkHighlightingNode(color: .clear)
|
|
compoundHighlightingNode.alpha = strongSelf.authorNode.alpha
|
|
compoundHighlightingNode.useModernPathCalculation = true
|
|
strongSelf.compoundHighlightingNode = compoundHighlightingNode
|
|
strongSelf.mainContentContainerNode.insertSubnode(compoundHighlightingNode, at: 0)
|
|
}
|
|
|
|
let compoundTextButtonNode: HighlightTrackingButtonNode
|
|
if let current = strongSelf.compoundTextButtonNode {
|
|
compoundTextButtonNode = current
|
|
} else {
|
|
compoundTextButtonNode = HighlightTrackingButtonNode()
|
|
strongSelf.compoundTextButtonNode = compoundTextButtonNode
|
|
strongSelf.mainContentContainerNode.addSubnode(compoundTextButtonNode)
|
|
compoundTextButtonNode.addTarget(strongSelf, action: #selector(strongSelf.compoundTextButtonPressed), forControlEvents: .touchUpInside)
|
|
compoundTextButtonNode.highligthedChanged = { highlighted in
|
|
guard let strongSelf = self, let compoundHighlightingNode = strongSelf.compoundHighlightingNode else {
|
|
return
|
|
}
|
|
if highlighted {
|
|
compoundHighlightingNode.layer.removeAnimation(forKey: "opacity")
|
|
compoundHighlightingNode.alpha = 0.65
|
|
strongSelf.textNode.textNode.alpha = strongSelf.authorNode.alpha * 0.65
|
|
strongSelf.authorNode.setFirstTopicHighlighted(true)
|
|
} else {
|
|
compoundHighlightingNode.alpha = 1.0
|
|
compoundHighlightingNode.layer.animateAlpha(from: 0.65, to: 1.0, duration: 0.2)
|
|
|
|
let prevAlpha = strongSelf.textNode.textNode.alpha
|
|
strongSelf.textNode.textNode.alpha = strongSelf.authorNode.alpha
|
|
strongSelf.textNode.textNode.layer.animateAlpha(from: prevAlpha, to: strongSelf.authorNode.alpha, duration: 0.2)
|
|
strongSelf.authorNode.setFirstTopicHighlighted(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
var topRect = topForumTopicRect
|
|
topRect.origin.x -= 1.0
|
|
topRect.size.width += 2.0
|
|
var textRect = textNodeFrame.offsetBy(dx: -authorNodeFrame.minX, dy: -authorNodeFrame.minY)
|
|
textRect.origin.x = topRect.minX
|
|
textRect.size.height -= 1.0
|
|
textRect.size.width += 16.0
|
|
|
|
compoundHighlightingNode.frame = CGRect(origin: CGPoint(x: authorNodeFrame.minX, y: authorNodeFrame.minY), size: CGSize(width: textNodeFrame.maxX - authorNodeFrame.minX, height: textNodeFrame.maxY - authorNodeFrame.minY))
|
|
|
|
let midY = floor((topForumTopicRect.minY + textRect.maxY) / 2.0) + 1.0
|
|
|
|
let finalTopRect = CGRect(origin: topRect.origin, size: CGSize(width: topRect.width, height: midY - topRect.minY))
|
|
var finalBottomRect = CGRect(origin: CGPoint(x: textRect.minX, y: midY), size: CGSize(width: textRect.width, height: textRect.maxY - midY))
|
|
if finalBottomRect.maxX < finalTopRect.maxX && abs(finalBottomRect.maxX - finalTopRect.maxX) < 5.0 {
|
|
finalBottomRect.size.width = finalTopRect.maxX - finalBottomRect.minX
|
|
}
|
|
|
|
compoundHighlightingNode.inset = 0.0
|
|
compoundHighlightingNode.outerRadius = floor(finalBottomRect.height * 0.5)
|
|
compoundHighlightingNode.innerRadius = 4.0
|
|
|
|
compoundHighlightingNode.updateRects([
|
|
finalTopRect,
|
|
finalBottomRect
|
|
], color: theme.pinnedItemBackgroundColor.mixedWith(theme.unreadBadgeInactiveBackgroundColor, alpha: 0.1))
|
|
|
|
transition.updateFrame(node: compoundTextButtonNode, frame: compoundHighlightingNode.frame)
|
|
|
|
if let textArrowImage = textArrowImage {
|
|
let textArrowNode: ASImageNode
|
|
if let current = strongSelf.textArrowNode {
|
|
textArrowNode = current
|
|
} else {
|
|
textArrowNode = ASImageNode()
|
|
strongSelf.textArrowNode = textArrowNode
|
|
compoundHighlightingNode.addSubnode(textArrowNode)
|
|
}
|
|
textArrowNode.image = textArrowImage
|
|
let arrowScale: CGFloat = 0.75
|
|
let textArrowSize = CGSize(width: floor(textArrowImage.size.width * arrowScale), height: floor(textArrowImage.size.height * arrowScale))
|
|
textArrowNode.frame = CGRect(origin: CGPoint(x: finalBottomRect.maxX - 0.0 - textArrowSize.width, y: finalBottomRect.minY + floorToScreenPixels((finalBottomRect.height - textArrowSize.height) / 2.0)), size: textArrowSize)
|
|
} else if let textArrowNode = strongSelf.textArrowNode {
|
|
strongSelf.textArrowNode = nil
|
|
textArrowNode.removeFromSupernode()
|
|
}
|
|
} else {
|
|
if let compoundHighlightingNode = strongSelf.compoundHighlightingNode {
|
|
strongSelf.compoundHighlightingNode = nil
|
|
compoundHighlightingNode.removeFromSupernode()
|
|
}
|
|
if let compoundTextButtonNode = strongSelf.compoundTextButtonNode {
|
|
strongSelf.compoundTextButtonNode = nil
|
|
compoundTextButtonNode.removeFromSupernode()
|
|
}
|
|
if let textArrowNode = strongSelf.textArrowNode {
|
|
strongSelf.textArrowNode = nil
|
|
textArrowNode.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
if let compoundTextButtonNode = strongSelf.compoundTextButtonNode {
|
|
if strongSelf.textNode.textNode.supernode !== compoundTextButtonNode {
|
|
compoundTextButtonNode.addSubnode(strongSelf.textNode.textNode)
|
|
if let dustNode = strongSelf.dustNode {
|
|
compoundTextButtonNode.addSubnode(dustNode)
|
|
}
|
|
}
|
|
strongSelf.textNode.textNode.frame = textNodeFrame.offsetBy(dx: -compoundTextButtonNode.frame.minX, dy: -compoundTextButtonNode.frame.minY)
|
|
|
|
strongSelf.authorNode.assignParentNode(parentNode: compoundTextButtonNode)
|
|
} else {
|
|
if strongSelf.textNode.textNode.supernode !== strongSelf.mainContentContainerNode {
|
|
strongSelf.mainContentContainerNode.addSubnode(strongSelf.textNode.textNode)
|
|
if let dustNode = strongSelf.dustNode {
|
|
strongSelf.mainContentContainerNode.addSubnode(dustNode)
|
|
}
|
|
}
|
|
strongSelf.textNode.textNode.frame = textNodeFrame
|
|
|
|
strongSelf.authorNode.assignParentNode(parentNode: nil)
|
|
}
|
|
|
|
if !textLayout.spoilers.isEmpty {
|
|
let dustNode: InvisibleInkDustNode
|
|
if let current = strongSelf.dustNode {
|
|
dustNode = current
|
|
} else {
|
|
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency)
|
|
dustNode.isUserInteractionEnabled = false
|
|
strongSelf.dustNode = dustNode
|
|
|
|
strongSelf.textNode.textNode.supernode?.insertSubnode(dustNode, aboveSubnode: strongSelf.textNode.textNode)
|
|
}
|
|
dustNode.update(size: textNodeFrame.size, color: theme.messageTextColor, textColor: theme.messageTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 0.0, dy: 1.0) })
|
|
dustNode.frame = textNodeFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0)
|
|
|
|
} else if let dustNode = strongSelf.dustNode {
|
|
strongSelf.dustNode = nil
|
|
dustNode.removeFromSupernode()
|
|
}
|
|
|
|
var animateInputActivitiesFrame = false
|
|
let inputActivities = inputActivities?.filter({
|
|
switch $0.1 {
|
|
case .speakingInGroupCall, .seeingEmojiInteraction:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
})
|
|
|
|
if let inputActivities = inputActivities, !inputActivities.isEmpty {
|
|
if strongSelf.inputActivitiesNode.supernode == nil {
|
|
strongSelf.mainContentContainerNode.addSubnode(strongSelf.inputActivitiesNode)
|
|
} else {
|
|
animateInputActivitiesFrame = true
|
|
}
|
|
|
|
if strongSelf.inputActivitiesNode.alpha.isZero {
|
|
strongSelf.inputActivitiesNode.alpha = 1.0
|
|
strongSelf.textNode.textNode.alpha = 0.0
|
|
strongSelf.authorNode.alpha = 0.0
|
|
strongSelf.compoundHighlightingNode?.alpha = 0.0
|
|
strongSelf.dustNode?.alpha = 0.0
|
|
strongSelf.forwardedIconNode.alpha = 0.0
|
|
|
|
if animated || animateContent {
|
|
strongSelf.inputActivitiesNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
strongSelf.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15)
|
|
strongSelf.authorNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15)
|
|
strongSelf.compoundHighlightingNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15)
|
|
strongSelf.dustNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15)
|
|
strongSelf.forwardedIconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15)
|
|
}
|
|
}
|
|
} else {
|
|
if !strongSelf.inputActivitiesNode.alpha.isZero {
|
|
strongSelf.inputActivitiesNode.alpha = 0.0
|
|
strongSelf.textNode.textNode.alpha = 1.0
|
|
strongSelf.authorNode.alpha = 1.0
|
|
strongSelf.compoundHighlightingNode?.alpha = 1.0
|
|
strongSelf.dustNode?.alpha = 1.0
|
|
strongSelf.forwardedIconNode.alpha = 1.0
|
|
if animated || animateContent {
|
|
strongSelf.inputActivitiesNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, completion: { value in
|
|
if let strongSelf = self, value {
|
|
strongSelf.inputActivitiesNode.removeFromSupernode()
|
|
}
|
|
})
|
|
strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
strongSelf.authorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
strongSelf.compoundHighlightingNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
strongSelf.dustNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
strongSelf.forwardedIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
} else {
|
|
strongSelf.inputActivitiesNode.removeFromSupernode()
|
|
}
|
|
}
|
|
}
|
|
if let inputActivitiesSize = inputActivitiesSize {
|
|
let inputActivitiesFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: authorNodeFrame.minY + UIScreenPixel), size: inputActivitiesSize)
|
|
if animateInputActivitiesFrame {
|
|
transition.updateFrame(node: strongSelf.inputActivitiesNode, frame: inputActivitiesFrame)
|
|
} else {
|
|
strongSelf.inputActivitiesNode.frame = inputActivitiesFrame
|
|
}
|
|
}
|
|
inputActivitiesApply?()
|
|
|
|
var mediaPreviewOffset = textNodeFrame.origin.offsetBy(dx: 1.0, dy: 1.0 + floor((measureLayout.size.height - contentImageSize.height) / 2.0))
|
|
|
|
var messageTypeIcon: UIImage?
|
|
var messageTypeIconOffset = mediaPreviewOffset
|
|
if let currentForwardedIcon {
|
|
messageTypeIcon = currentForwardedIcon
|
|
messageTypeIconOffset.y += 3.0
|
|
} else if let currentStoryIcon {
|
|
messageTypeIcon = currentStoryIcon
|
|
}
|
|
|
|
if let messageTypeIcon {
|
|
strongSelf.forwardedIconNode.image = messageTypeIcon
|
|
if strongSelf.forwardedIconNode.supernode == nil {
|
|
strongSelf.mainContentContainerNode.addSubnode(strongSelf.forwardedIconNode)
|
|
}
|
|
transition.updateFrame(node: strongSelf.forwardedIconNode, frame: CGRect(origin: messageTypeIconOffset, size: messageTypeIcon.size))
|
|
mediaPreviewOffset.x += messageTypeIcon.size.width + forwardedIconSpacing
|
|
} else if strongSelf.forwardedIconNode.supernode != nil {
|
|
strongSelf.forwardedIconNode.removeFromSupernode()
|
|
}
|
|
|
|
var validMediaIds: [EngineMedia.Id] = []
|
|
for (message, media, mediaSize) in contentImageSpecs {
|
|
var mediaId = media.id
|
|
if mediaId == nil, case let .action(action) = media, case let .suggestedProfilePhoto(image) = action.action {
|
|
mediaId = image?.id
|
|
}
|
|
guard let mediaId = mediaId else {
|
|
continue
|
|
}
|
|
validMediaIds.append(mediaId)
|
|
let previewNode: ChatListMediaPreviewNode
|
|
var previewNodeTransition = transition
|
|
var previewNodeAlphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.15, curve: .easeInOut)
|
|
if let current = strongSelf.mediaPreviewNodes[mediaId] {
|
|
previewNode = current
|
|
} else {
|
|
previewNodeTransition = .immediate
|
|
previewNodeAlphaTransition = .immediate
|
|
previewNode = ChatListMediaPreviewNode(context: item.context, message: message, media: media)
|
|
strongSelf.mediaPreviewNodes[mediaId] = previewNode
|
|
strongSelf.mainContentContainerNode.addSubnode(previewNode)
|
|
}
|
|
previewNode.updateLayout(size: mediaSize, synchronousLoads: synchronousLoads)
|
|
previewNodeAlphaTransition.updateAlpha(node: previewNode, alpha: strongSelf.inputActivitiesNode.alpha.isZero ? 1.0 : 0.0)
|
|
previewNodeTransition.updateFrame(node: previewNode, frame: CGRect(origin: mediaPreviewOffset, size: mediaSize))
|
|
mediaPreviewOffset.x += mediaSize.width + contentImageSpacing
|
|
}
|
|
var removeMediaIds: [EngineMedia.Id] = []
|
|
for (mediaId, itemNode) in strongSelf.mediaPreviewNodes {
|
|
if !validMediaIds.contains(mediaId) {
|
|
removeMediaIds.append(mediaId)
|
|
itemNode.removeFromSupernode()
|
|
}
|
|
}
|
|
for mediaId in removeMediaIds {
|
|
strongSelf.mediaPreviewNodes.removeValue(forKey: mediaId)
|
|
}
|
|
strongSelf.currentMediaPreviewSpecs = contentImageSpecs
|
|
strongSelf.currentTextLeftCutout = textLeftCutout
|
|
|
|
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))
|
|
|
|
if strongSelf.textNode.textNode.supernode === strongSelf.mainContentContainerNode {
|
|
transition.animatePositionAdditive(node: strongSelf.textNode.textNode, offset: CGPoint(x: -contentDelta.x, y: -contentDelta.y))
|
|
if let dustNode = strongSelf.dustNode {
|
|
transition.animatePositionAdditive(node: dustNode, offset: CGPoint(x: -contentDelta.x, y: -contentDelta.y))
|
|
}
|
|
}
|
|
|
|
let authorPosition = strongSelf.authorNode.position
|
|
transition.animatePosition(node: strongSelf.authorNode, from: CGPoint(x: authorPosition.x - contentDelta.x, y: authorPosition.y - contentDelta.y))
|
|
if let compoundHighlightingNode = strongSelf.compoundHighlightingNode {
|
|
let compoundHighlightingPosition = compoundHighlightingNode.position
|
|
transition.animatePosition(node: compoundHighlightingNode, from: CGPoint(x: compoundHighlightingPosition.x - contentDelta.x, y: compoundHighlightingPosition.y - contentDelta.y))
|
|
}
|
|
}
|
|
|
|
if crossfadeContent {
|
|
strongSelf.authorNode.recursivelyEnsureDisplaySynchronously(true)
|
|
strongSelf.titleNode.recursivelyEnsureDisplaySynchronously(true)
|
|
strongSelf.textNode.textNode.recursivelyEnsureDisplaySynchronously(true)
|
|
}
|
|
|
|
var nextTitleIconOrigin: CGFloat = contentRect.origin.x + titleLayout.trailingLineWidth + 3.0 + titleOffset
|
|
let lastLineRect: CGRect
|
|
if let rect = titleLayout.linesRects().last {
|
|
lastLineRect = CGRect(origin: CGPoint(x: 0.0, y: titleLayout.size.height - rect.height - 2.0), size: CGSize(width: rect.width, height: rect.height + 2.0))
|
|
} else {
|
|
lastLineRect = CGRect(origin: CGPoint(), size: titleLayout.size)
|
|
}
|
|
|
|
if let currentCredibilityIconContent {
|
|
let credibilityIconView: ComponentHostView<Empty>
|
|
if let current = strongSelf.credibilityIconView {
|
|
credibilityIconView = current
|
|
} else {
|
|
credibilityIconView = ComponentHostView<Empty>()
|
|
strongSelf.credibilityIconView = credibilityIconView
|
|
strongSelf.mainContentContainerNode.view.addSubview(credibilityIconView)
|
|
}
|
|
|
|
let credibilityIconComponent = EmojiStatusComponent(
|
|
context: item.context,
|
|
animationCache: item.interaction.animationCache,
|
|
animationRenderer: item.interaction.animationRenderer,
|
|
content: currentCredibilityIconContent,
|
|
isVisibleForAnimations: strongSelf.visibilityStatus && item.context.sharedContext.energyUsageSettings.loopEmoji,
|
|
action: nil
|
|
)
|
|
strongSelf.credibilityIconComponent = credibilityIconComponent
|
|
|
|
let iconSize = credibilityIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(credibilityIconComponent),
|
|
environment: {},
|
|
containerSize: CGSize(width: 20.0, height: 20.0)
|
|
)
|
|
transition.updateFrame(view: credibilityIconView, frame: CGRect(origin: CGPoint(x: nextTitleIconOrigin, y: floorToScreenPixels(titleFrame.maxY - lastLineRect.height * 0.5 - iconSize.height / 2.0) - UIScreenPixel), size: iconSize))
|
|
nextTitleIconOrigin += credibilityIconView.bounds.width + 4.0
|
|
} else if let credibilityIconView = strongSelf.credibilityIconView {
|
|
strongSelf.credibilityIconView = nil
|
|
credibilityIconView.removeFromSuperview()
|
|
}
|
|
|
|
if let currentVerifiedIconContent {
|
|
let verifiedIconView: ComponentHostView<Empty>
|
|
if let current = strongSelf.verifiedIconView {
|
|
verifiedIconView = current
|
|
} else {
|
|
verifiedIconView = ComponentHostView<Empty>()
|
|
strongSelf.verifiedIconView = verifiedIconView
|
|
strongSelf.mainContentContainerNode.view.addSubview(verifiedIconView)
|
|
}
|
|
|
|
let verifiedIconComponent = EmojiStatusComponent(
|
|
context: item.context,
|
|
animationCache: item.interaction.animationCache,
|
|
animationRenderer: item.interaction.animationRenderer,
|
|
content: currentVerifiedIconContent,
|
|
isVisibleForAnimations: strongSelf.visibilityStatus && item.context.sharedContext.energyUsageSettings.loopEmoji,
|
|
action: nil
|
|
)
|
|
strongSelf.verifiedIconComponent = verifiedIconComponent
|
|
|
|
let iconSize = verifiedIconView.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(verifiedIconComponent),
|
|
environment: {},
|
|
containerSize: CGSize(width: 20.0, height: 20.0)
|
|
)
|
|
transition.updateFrame(view: verifiedIconView, frame: CGRect(origin: CGPoint(x: nextTitleIconOrigin, y: floorToScreenPixels(titleFrame.maxY - lastLineRect.height * 0.5 - iconSize.height / 2.0) - UIScreenPixel), size: iconSize))
|
|
nextTitleIconOrigin += verifiedIconView.bounds.width + 4.0
|
|
} else if let verifiedIconView = strongSelf.verifiedIconView {
|
|
strongSelf.verifiedIconView = nil
|
|
verifiedIconView.removeFromSuperview()
|
|
}
|
|
|
|
if let currentMutedIconImage = currentMutedIconImage {
|
|
strongSelf.mutedIconNode.image = currentMutedIconImage
|
|
strongSelf.mutedIconNode.isHidden = false
|
|
transition.updateFrame(node: strongSelf.mutedIconNode, frame: CGRect(origin: CGPoint(x: nextTitleIconOrigin - 5.0, y: floorToScreenPixels(titleFrame.maxY - lastLineRect.height * 0.5 - currentMutedIconImage.size.height / 2.0)), size: currentMutedIconImage.size))
|
|
nextTitleIconOrigin += currentMutedIconImage.size.width + 1.0
|
|
} else {
|
|
strongSelf.mutedIconNode.image = nil
|
|
strongSelf.mutedIconNode.isHidden = true
|
|
}
|
|
|
|
let separatorInset: CGFloat
|
|
if case let .groupReference(groupReferenceData) = item.content, groupReferenceData.hiddenByDefault {
|
|
separatorInset = 0.0
|
|
} else if (!nextIsPinned && isPinned) || last {
|
|
separatorInset = 0.0
|
|
} else {
|
|
separatorInset = editingOffset + leftInset + rawContentRect.origin.x
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: layoutOffset + itemHeight - separatorHeight), size: CGSize(width: params.width - separatorInset, height: separatorHeight)))
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation {
|
|
transition.updateAlpha(node: strongSelf.separatorNode, alpha: 1.0 - inlineNavigationLocation.progress)
|
|
} else {
|
|
transition.updateAlpha(node: strongSelf.separatorNode, alpha: 1.0)
|
|
}
|
|
|
|
transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.contentSize.width, height: itemHeight)))
|
|
let backgroundColor: UIColor
|
|
let highlightedBackgroundColor: UIColor
|
|
if item.selected {
|
|
backgroundColor = theme.itemSelectedBackgroundColor
|
|
highlightedBackgroundColor = theme.itemHighlightedBackgroundColor
|
|
} else if isPinned {
|
|
if case let .groupReference(groupReferenceData) = item.content, groupReferenceData.hiddenByDefault {
|
|
backgroundColor = theme.itemBackgroundColor
|
|
highlightedBackgroundColor = theme.itemHighlightedBackgroundColor
|
|
} else {
|
|
backgroundColor = theme.pinnedItemBackgroundColor
|
|
highlightedBackgroundColor = theme.pinnedItemHighlightedBackgroundColor
|
|
}
|
|
} else {
|
|
backgroundColor = theme.itemBackgroundColor
|
|
highlightedBackgroundColor = theme.itemHighlightedBackgroundColor
|
|
}
|
|
|
|
if animated {
|
|
transition.updateBackgroundColor(node: strongSelf.backgroundNode, color: backgroundColor)
|
|
} else {
|
|
strongSelf.backgroundNode.backgroundColor = backgroundColor
|
|
}
|
|
|
|
if let inlineNavigationLocation = item.interaction.inlineNavigationLocation {
|
|
transition.updateAlpha(node: strongSelf.backgroundNode, alpha: 1.0 - inlineNavigationLocation.progress)
|
|
} else {
|
|
transition.updateAlpha(node: strongSelf.backgroundNode, alpha: 1.0)
|
|
}
|
|
|
|
strongSelf.highlightedBackgroundNode.backgroundColor = highlightedBackgroundColor
|
|
let topNegativeInset: CGFloat = 0.0
|
|
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: layoutOffset - separatorHeight - topNegativeInset), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height + separatorHeight + topNegativeInset))
|
|
|
|
if let peerPresence = peerPresence {
|
|
strongSelf.peerPresenceManager?.reset(presence: EnginePeer.Presence(status: peerPresence.status, lastActivity: 0), isOnline: online)
|
|
}
|
|
|
|
strongSelf.updateLayout(size: CGSize(width: layout.contentSize.width, height: itemHeight), leftInset: params.leftInset, rightInset: params.rightInset)
|
|
|
|
if item.editing {
|
|
strongSelf.setRevealOptions((left: [], right: []), enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency)
|
|
} else {
|
|
strongSelf.setRevealOptions((left: peerLeftRevealOptions, right: peerRevealOptions), enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency)
|
|
}
|
|
if !strongSelf.customAnimationInProgress {
|
|
strongSelf.setRevealOptionsOpened(item.hasActiveRevealControls, animated: true)
|
|
}
|
|
|
|
strongSelf.view.accessibilityLabel = strongSelf.accessibilityLabel
|
|
strongSelf.view.accessibilityValue = strongSelf.accessibilityValue
|
|
|
|
if !customActions.isEmpty {
|
|
strongSelf.view.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in
|
|
return ChatListItemAccessibilityCustomAction(name: action.name, target: strongSelf, selector: #selector(strongSelf.performLocalAccessibilityCustomAction(_:)), key: action.key)
|
|
})
|
|
} else {
|
|
strongSelf.view.accessibilityCustomActions = nil
|
|
}
|
|
|
|
strongSelf.avatarTapRecognizer?.isEnabled = item.interaction.inlineNavigationLocation == nil
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
@objc private func compoundTextButtonPressed() {
|
|
guard let item else {
|
|
return
|
|
}
|
|
guard case let .peer(peerData) = item.content else {
|
|
return
|
|
}
|
|
guard let topicItem = peerData.topForumTopicItems.first else {
|
|
return
|
|
}
|
|
guard case let .chatList(index) = item.index else {
|
|
return
|
|
}
|
|
item.interaction.openForumThread(index.messageIndex.id.peerId, topicItem.id)
|
|
}
|
|
|
|
override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) {
|
|
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
|
}
|
|
|
|
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
|
|
self.clipsToBounds = true
|
|
if self.skipFadeout {
|
|
self.skipFadeout = false
|
|
} else {
|
|
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
}
|
|
}
|
|
|
|
override public func headers() -> [ListViewItemHeader]? {
|
|
if let item = self.layoutParams?.0 {
|
|
return item.header.flatMap { [$0] }
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func updateVideoVisibility() {
|
|
let isVisible = self.visibilityStatus && self.trackingIsInHierarchy
|
|
self.avatarVideoNode?.updateVisibility(isVisible)
|
|
|
|
if let videoNode = self.avatarVideoNode {
|
|
videoNode.updateLayout(size: self.avatarNode.frame.size, cornerRadius: self.avatarNode.frame.size.width / 2.0, transition: .immediate)
|
|
videoNode.frame = self.avatarNode.bounds
|
|
}
|
|
}
|
|
|
|
override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
super.updateRevealOffset(offset: offset, transition: transition)
|
|
|
|
transition.updateBounds(node: self.contextContainer, bounds: self.contextContainer.frame.offsetBy(dx: -offset, dy: 0.0))
|
|
}
|
|
|
|
override func touchesToOtherItemsPrevented() {
|
|
super.touchesToOtherItemsPrevented()
|
|
if let item = self.item {
|
|
item.interaction.setPeerIdWithRevealedOptions(nil, nil)
|
|
}
|
|
}
|
|
|
|
override func revealOptionsInteractivelyOpened() {
|
|
if let item = self.item {
|
|
switch item.index {
|
|
case let .chatList(index):
|
|
item.interaction.setPeerIdWithRevealedOptions(index.messageIndex.id.peerId, nil)
|
|
case .forum:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override func revealOptionsInteractivelyClosed() {
|
|
if let item = self.item {
|
|
switch item.index {
|
|
case let .chatList(index):
|
|
item.interaction.setPeerIdWithRevealedOptions(nil, index.messageIndex.id.peerId)
|
|
case .forum:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
|
|
var close = true
|
|
if case let .chatList(index) = item.index {
|
|
switch option.key {
|
|
case RevealOptionKey.pin.rawValue:
|
|
switch item.content {
|
|
case .peer:
|
|
let itemId: EngineChatList.PinnedItem.Id = .peer(index.messageIndex.id.peerId)
|
|
item.interaction.setItemPinned(itemId, true)
|
|
case .groupReference:
|
|
break
|
|
}
|
|
case RevealOptionKey.unpin.rawValue:
|
|
switch item.content {
|
|
case .peer:
|
|
let itemId: EngineChatList.PinnedItem.Id = .peer(index.messageIndex.id.peerId)
|
|
item.interaction.setItemPinned(itemId, false)
|
|
case .groupReference:
|
|
break
|
|
}
|
|
case RevealOptionKey.mute.rawValue:
|
|
item.interaction.setPeerMuted(index.messageIndex.id.peerId, true)
|
|
close = false
|
|
case RevealOptionKey.unmute.rawValue:
|
|
item.interaction.setPeerMuted(index.messageIndex.id.peerId, false)
|
|
close = false
|
|
case RevealOptionKey.delete.rawValue:
|
|
var joined = false
|
|
if case let .peer(peerData) = item.content, let message = peerData.messages.first {
|
|
for media in message.media {
|
|
if let action = media as? TelegramMediaAction, action.action == .peerJoined {
|
|
joined = true
|
|
}
|
|
}
|
|
}
|
|
item.interaction.deletePeer(index.messageIndex.id.peerId, joined)
|
|
case RevealOptionKey.archive.rawValue:
|
|
item.interaction.updatePeerGrouping(index.messageIndex.id.peerId, true)
|
|
close = false
|
|
self.skipFadeout = true
|
|
self.customAnimationInProgress = true
|
|
self.animateRevealOptionsFill {
|
|
self.revealOptionsInteractivelyClosed()
|
|
self.customAnimationInProgress = false
|
|
}
|
|
case RevealOptionKey.unarchive.rawValue:
|
|
item.interaction.updatePeerGrouping(index.messageIndex.id.peerId, false)
|
|
close = false
|
|
self.skipFadeout = true
|
|
self.animateRevealOptionsFill {
|
|
self.revealOptionsInteractivelyClosed()
|
|
}
|
|
case RevealOptionKey.toggleMarkedUnread.rawValue:
|
|
item.interaction.togglePeerMarkedUnread(index.messageIndex.id.peerId, animated)
|
|
close = false
|
|
case RevealOptionKey.hide.rawValue:
|
|
item.interaction.toggleArchivedFolderHiddenByDefault()
|
|
close = false
|
|
self.skipFadeout = true
|
|
self.customAnimationInProgress = true
|
|
self.animateRevealOptionsFill {
|
|
self.revealOptionsInteractivelyClosed()
|
|
self.customAnimationInProgress = false
|
|
}
|
|
case RevealOptionKey.unhide.rawValue:
|
|
item.interaction.toggleArchivedFolderHiddenByDefault()
|
|
close = false
|
|
case RevealOptionKey.hidePsa.rawValue:
|
|
if let item = self.item, case let .peer(peerData) = item.content {
|
|
item.interaction.hidePsa(peerData.peer.peerId)
|
|
}
|
|
close = false
|
|
self.skipFadeout = true
|
|
self.customAnimationInProgress = true
|
|
self.animateRevealOptionsFill {
|
|
self.revealOptionsInteractivelyClosed()
|
|
self.customAnimationInProgress = false
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
} else if case let .forum(_, _, threadId, _, _) = item.index, case let .forum(peerId) = item.chatListLocation {
|
|
switch option.key {
|
|
case RevealOptionKey.delete.rawValue:
|
|
item.interaction.deletePeerThread(peerId, threadId)
|
|
case RevealOptionKey.mute.rawValue:
|
|
item.interaction.setPeerThreadMuted(peerId, threadId, true)
|
|
close = false
|
|
case RevealOptionKey.unmute.rawValue:
|
|
item.interaction.setPeerThreadMuted(peerId, threadId, false)
|
|
close = false
|
|
case RevealOptionKey.close.rawValue:
|
|
item.interaction.setPeerThreadStopped(peerId, threadId, true)
|
|
case RevealOptionKey.open.rawValue:
|
|
item.interaction.setPeerThreadStopped(peerId, threadId, false)
|
|
case RevealOptionKey.pin.rawValue:
|
|
item.interaction.setPeerThreadPinned(peerId, threadId, true)
|
|
case RevealOptionKey.unpin.rawValue:
|
|
item.interaction.setPeerThreadPinned(peerId, threadId, false)
|
|
case RevealOptionKey.hide.rawValue:
|
|
item.interaction.setPeerThreadHidden(peerId, threadId, true)
|
|
close = false
|
|
self.skipFadeout = true
|
|
self.customAnimationInProgress = true
|
|
self.animateRevealOptionsFill {
|
|
self.revealOptionsInteractivelyClosed()
|
|
self.customAnimationInProgress = false
|
|
}
|
|
case RevealOptionKey.unhide.rawValue:
|
|
item.interaction.setPeerThreadHidden(peerId, threadId, false)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
if close {
|
|
self.setRevealOptionsOpened(false, animated: true)
|
|
self.revealOptionsInteractivelyClosed()
|
|
}
|
|
}
|
|
|
|
override func isReorderable(at point: CGPoint) -> Bool {
|
|
if let reorderControlNode = self.reorderControlNode, reorderControlNode.frame.contains(point) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func flashHighlight() {
|
|
if self.highlightedBackgroundNode.supernode == nil {
|
|
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
|
|
self.highlightedBackgroundNode.alpha = 0.0
|
|
}
|
|
self.highlightedBackgroundNode.layer.removeAllAnimations()
|
|
self.highlightedBackgroundNode.layer.animate(from: 1.0 as NSNumber, to: 0.0 as NSNumber, keyPath: "opacity", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.3, delay: 0.7, completion: { [weak self] _ in
|
|
self?.updateIsHighlighted(transition: .immediate)
|
|
})
|
|
}
|
|
|
|
func playArchiveAnimation() {
|
|
guard let item = self.item, case .groupReference = item.content else {
|
|
return
|
|
}
|
|
self.avatarNode.playArchiveAnimation()
|
|
}
|
|
|
|
override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) {
|
|
super.animateFrameTransition(progress, currentValue)
|
|
|
|
if let item = self.item {
|
|
if case .groupReference = item.content {
|
|
self.layer.sublayerTransform = CATransform3DMakeTranslation(0.0, currentValue - (self.currentItemHeight ?? 0.0), 0.0)
|
|
} else {
|
|
var separatorFrame = self.separatorNode.frame
|
|
separatorFrame.origin.y = currentValue - UIScreenPixel
|
|
self.separatorNode.frame = separatorFrame
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) {
|
|
if let action = action as? ChatListItemAccessibilityCustomAction {
|
|
self.revealOptionSelected(ItemListRevealOption(key: action.key, title: "", icon: .none, color: .black, textColor: .white), animated: false)
|
|
}
|
|
}
|
|
|
|
override func snapshotForReordering() -> UIView? {
|
|
self.backgroundNode.alpha = 0.9
|
|
let result = self.view.snapshotContentTree()
|
|
self.backgroundNode.alpha = 1.0
|
|
return result
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
|
|
if let compoundTextButtonNode = self.compoundTextButtonNode, let compoundHighlightingNode = self.compoundHighlightingNode, compoundHighlightingNode.alpha != 0.0 {
|
|
let localPoint = self.view.convert(point, to: compoundHighlightingNode.view)
|
|
var matches = false
|
|
for rect in compoundHighlightingNode.rects {
|
|
if rect.contains(localPoint) {
|
|
matches = true
|
|
break
|
|
}
|
|
}
|
|
if matches {
|
|
return compoundTextButtonNode.view
|
|
}
|
|
}
|
|
|
|
if let _ = item.interaction.inlineNavigationLocation {
|
|
} else {
|
|
if self.avatarNode.storyStats != nil {
|
|
if let result = self.avatarNode.view.hitTest(self.view.convert(point, to: self.avatarNode.view), with: event) {
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
@objc private func avatarStoryTapGesture(_ recognizer: UITapGestureRecognizer) {
|
|
if case .ended = recognizer.state {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
switch item.content {
|
|
case let .peer(peerData):
|
|
item.interaction.openStories(.peer(peerData.peer.peerId), self)
|
|
case .groupReference:
|
|
item.interaction.openStories(.archive, self)
|
|
}
|
|
}
|
|
}
|
|
}
|