mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-12-22 22:25:57 +00:00
Refactoring [skip ci]
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Postbox
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import SwiftSignalKit
|
||||
import TelegramCore
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
import AccountContext
|
||||
import Emoji
|
||||
import PersistentStringHash
|
||||
import ChatControllerInteraction
|
||||
import ChatHistoryEntry
|
||||
import ChatMessageItem
|
||||
import ChatMessageItemView
|
||||
import ChatMessageStickerItemNode
|
||||
import ChatMessageAnimatedStickerItemNode
|
||||
import ChatMessageBubbleItemNode
|
||||
|
||||
private func mediaMergeableStyle(_ media: Media) -> ChatMessageMerge {
|
||||
if let story = media as? TelegramMediaStory, story.isMention {
|
||||
return .none
|
||||
}
|
||||
if let file = media as? TelegramMediaFile {
|
||||
for attribute in file.attributes {
|
||||
switch attribute {
|
||||
case .Sticker:
|
||||
return .semanticallyMerged
|
||||
case let .Video(_, _, flags, _):
|
||||
if flags.contains(.instantRoundVideo) {
|
||||
return .none
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return .fullyMerged
|
||||
}
|
||||
if let _ = media as? TelegramMediaAction {
|
||||
return .none
|
||||
}
|
||||
if let _ = media as? TelegramMediaExpiredContent {
|
||||
return .none
|
||||
}
|
||||
|
||||
return .fullyMerged
|
||||
}
|
||||
|
||||
private func messagesShouldBeMerged(accountPeerId: PeerId, _ lhs: Message, _ rhs: Message) -> ChatMessageMerge {
|
||||
var lhsEffectiveAuthor: Peer? = lhs.author
|
||||
var rhsEffectiveAuthor: Peer? = rhs.author
|
||||
for attribute in lhs.attributes {
|
||||
if let attribute = attribute as? SourceReferenceMessageAttribute {
|
||||
lhsEffectiveAuthor = lhs.peers[attribute.messageId.peerId]
|
||||
break
|
||||
}
|
||||
}
|
||||
for attribute in rhs.attributes {
|
||||
if let attribute = attribute as? SourceReferenceMessageAttribute {
|
||||
rhsEffectiveAuthor = rhs.peers[attribute.messageId.peerId]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var sameThread = true
|
||||
if let lhsPeer = lhs.peers[lhs.id.peerId], let rhsPeer = rhs.peers[rhs.id.peerId], arePeersEqual(lhsPeer, rhsPeer), let channel = lhsPeer as? TelegramChannel, channel.flags.contains(.isForum), lhs.threadId != rhs.threadId {
|
||||
sameThread = false
|
||||
}
|
||||
|
||||
var sameAuthor = false
|
||||
if lhsEffectiveAuthor?.id == rhsEffectiveAuthor?.id && lhs.effectivelyIncoming(accountPeerId) == rhs.effectivelyIncoming(accountPeerId) {
|
||||
sameAuthor = true
|
||||
}
|
||||
|
||||
var lhsEffectiveTimestamp = lhs.timestamp
|
||||
var rhsEffectiveTimestamp = rhs.timestamp
|
||||
|
||||
if let lhsForwardInfo = lhs.forwardInfo, lhsForwardInfo.flags.contains(.isImported), let rhsForwardInfo = rhs.forwardInfo, rhsForwardInfo.flags.contains(.isImported) {
|
||||
lhsEffectiveTimestamp = lhsForwardInfo.date
|
||||
rhsEffectiveTimestamp = rhsForwardInfo.date
|
||||
|
||||
if (lhsForwardInfo.author?.id != nil) == (rhsForwardInfo.author?.id != nil) && (lhsForwardInfo.authorSignature != nil) == (rhsForwardInfo.authorSignature != nil) {
|
||||
if let lhsAuthorId = lhsForwardInfo.author?.id, let rhsAuthorId = rhsForwardInfo.author?.id {
|
||||
sameAuthor = lhsAuthorId == rhsAuthorId
|
||||
} else if let lhsAuthorSignature = lhsForwardInfo.authorSignature, let rhsAuthorSignature = rhsForwardInfo.authorSignature {
|
||||
sameAuthor = lhsAuthorSignature == rhsAuthorSignature
|
||||
}
|
||||
} else {
|
||||
sameAuthor = false
|
||||
}
|
||||
}
|
||||
|
||||
if lhs.id.peerId.isRepliesOrSavedMessages(accountPeerId: accountPeerId) {
|
||||
if let forwardInfo = lhs.forwardInfo {
|
||||
lhsEffectiveAuthor = forwardInfo.author
|
||||
}
|
||||
}
|
||||
if rhs.id.peerId.isRepliesOrSavedMessages(accountPeerId: accountPeerId) {
|
||||
if let forwardInfo = rhs.forwardInfo {
|
||||
rhsEffectiveAuthor = forwardInfo.author
|
||||
}
|
||||
}
|
||||
|
||||
if abs(lhsEffectiveTimestamp - rhsEffectiveTimestamp) < Int32(10 * 60) && sameAuthor && sameThread {
|
||||
if let channel = lhs.peers[lhs.id.peerId] as? TelegramChannel, case .group = channel.info, lhsEffectiveAuthor?.id == channel.id, !lhs.effectivelyIncoming(accountPeerId) {
|
||||
return .none
|
||||
}
|
||||
|
||||
var upperStyle: Int32 = ChatMessageMerge.fullyMerged.rawValue
|
||||
var lowerStyle: Int32 = ChatMessageMerge.fullyMerged.rawValue
|
||||
for media in lhs.media {
|
||||
let style = mediaMergeableStyle(media).rawValue
|
||||
if style < upperStyle {
|
||||
upperStyle = style
|
||||
}
|
||||
}
|
||||
for media in rhs.media {
|
||||
let style = mediaMergeableStyle(media).rawValue
|
||||
if style < lowerStyle {
|
||||
lowerStyle = style
|
||||
}
|
||||
}
|
||||
for attribute in lhs.attributes {
|
||||
if let attribute = attribute as? ReplyMarkupMessageAttribute {
|
||||
if attribute.flags.contains(.inline) && !attribute.rows.isEmpty {
|
||||
upperStyle = ChatMessageMerge.none.rawValue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let style = min(upperStyle, lowerStyle)
|
||||
return ChatMessageMerge(rawValue: style)!
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
public func chatItemsHaveCommonDateHeader(_ lhs: ListViewItem, _ rhs: ListViewItem?) -> Bool{
|
||||
let lhsHeader: ChatMessageDateHeader?
|
||||
let rhsHeader: ChatMessageDateHeader?
|
||||
if let lhs = lhs as? ChatMessageItemImpl {
|
||||
lhsHeader = lhs.dateHeader
|
||||
} else if let lhs = lhs as? ChatUnreadItem {
|
||||
lhsHeader = lhs.header
|
||||
} else if let lhs = lhs as? ChatReplyCountItem {
|
||||
lhsHeader = lhs.header
|
||||
} else {
|
||||
lhsHeader = nil
|
||||
}
|
||||
if let rhs = rhs {
|
||||
if let rhs = rhs as? ChatMessageItemImpl {
|
||||
rhsHeader = rhs.dateHeader
|
||||
} else if let rhs = rhs as? ChatUnreadItem {
|
||||
rhsHeader = rhs.header
|
||||
} else if let rhs = rhs as? ChatReplyCountItem {
|
||||
rhsHeader = rhs.header
|
||||
} else {
|
||||
rhsHeader = nil
|
||||
}
|
||||
} else {
|
||||
rhsHeader = nil
|
||||
}
|
||||
if let lhsHeader = lhsHeader, let rhsHeader = rhsHeader {
|
||||
return lhsHeader.id == rhsHeader.id
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public final class ChatMessageItemImpl: ChatMessageItem, CustomStringConvertible {
|
||||
public let presentationData: ChatPresentationData
|
||||
public let context: AccountContext
|
||||
public let chatLocation: ChatLocation
|
||||
public let associatedData: ChatMessageItemAssociatedData
|
||||
public let controllerInteraction: ChatControllerInteraction
|
||||
public let content: ChatMessageItemContent
|
||||
public let disableDate: Bool
|
||||
public let effectiveAuthorId: PeerId?
|
||||
public let additionalContent: ChatMessageItemAdditionalContent?
|
||||
|
||||
let dateHeader: ChatMessageDateHeader
|
||||
let avatarHeader: ChatMessageAvatarHeader?
|
||||
|
||||
public let headers: [ListViewItemHeader]
|
||||
|
||||
public var message: Message {
|
||||
switch self.content {
|
||||
case let .message(message, _, _, _, _):
|
||||
return message
|
||||
case let .group(messages):
|
||||
return messages[0].0
|
||||
}
|
||||
}
|
||||
|
||||
public var read: Bool {
|
||||
switch self.content {
|
||||
case let .message(_, read, _, _, _):
|
||||
return read
|
||||
case let .group(messages):
|
||||
return messages[0].1
|
||||
}
|
||||
}
|
||||
|
||||
public var unsent: Bool {
|
||||
switch self.content {
|
||||
case let .message(message, _, _, _, _):
|
||||
return message.flags.contains(.Unsent)
|
||||
case let .group(messages):
|
||||
return messages[0].0.flags.contains(.Unsent)
|
||||
}
|
||||
}
|
||||
|
||||
public var sending: Bool {
|
||||
switch self.content {
|
||||
case let .message(message, _, _, _, _):
|
||||
return message.flags.contains(.Sending)
|
||||
case let .group(messages):
|
||||
return messages[0].0.flags.contains(.Sending)
|
||||
}
|
||||
}
|
||||
|
||||
public var failed: Bool {
|
||||
switch self.content {
|
||||
case let .message(message, _, _, _, _):
|
||||
return message.flags.contains(.Failed)
|
||||
case let .group(messages):
|
||||
return messages[0].0.flags.contains(.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
public init(presentationData: ChatPresentationData, context: AccountContext, chatLocation: ChatLocation, associatedData: ChatMessageItemAssociatedData, controllerInteraction: ChatControllerInteraction, content: ChatMessageItemContent, disableDate: Bool = false, additionalContent: ChatMessageItemAdditionalContent? = nil) {
|
||||
self.presentationData = presentationData
|
||||
self.context = context
|
||||
self.chatLocation = chatLocation
|
||||
self.associatedData = associatedData
|
||||
self.controllerInteraction = controllerInteraction
|
||||
self.content = content
|
||||
self.disableDate = disableDate
|
||||
self.additionalContent = additionalContent
|
||||
|
||||
var avatarHeader: ChatMessageAvatarHeader?
|
||||
let incoming = content.effectivelyIncoming(self.context.account.peerId)
|
||||
|
||||
var effectiveAuthor: Peer?
|
||||
let displayAuthorInfo: Bool
|
||||
|
||||
let messagePeerId: PeerId = chatLocation.peerId ?? content.firstMessage.id.peerId
|
||||
|
||||
do {
|
||||
let peerId = messagePeerId
|
||||
if peerId.isRepliesOrSavedMessages(accountPeerId: context.account.peerId) {
|
||||
if let forwardInfo = content.firstMessage.forwardInfo {
|
||||
effectiveAuthor = forwardInfo.author
|
||||
if effectiveAuthor == nil, let authorSignature = forwardInfo.authorSignature {
|
||||
effectiveAuthor = TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: PeerId.Id._internalFromInt64Value(Int64(authorSignature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil)
|
||||
}
|
||||
}
|
||||
displayAuthorInfo = incoming && effectiveAuthor != nil
|
||||
} else {
|
||||
effectiveAuthor = content.firstMessage.author
|
||||
for attribute in content.firstMessage.attributes {
|
||||
if let attribute = attribute as? SourceReferenceMessageAttribute {
|
||||
effectiveAuthor = content.firstMessage.peers[attribute.messageId.peerId]
|
||||
break
|
||||
}
|
||||
}
|
||||
displayAuthorInfo = incoming && peerId.isGroupOrChannel && effectiveAuthor != nil
|
||||
}
|
||||
}
|
||||
|
||||
self.effectiveAuthorId = effectiveAuthor?.id
|
||||
|
||||
var isScheduledMessages = false
|
||||
if case .scheduledMessages = associatedData.subject {
|
||||
isScheduledMessages = true
|
||||
}
|
||||
|
||||
self.dateHeader = ChatMessageDateHeader(timestamp: content.index.timestamp, scheduled: isScheduledMessages, presentationData: presentationData, controllerInteraction: controllerInteraction, context: context, action: { timestamp, alreadyThere in
|
||||
var calendar = NSCalendar.current
|
||||
calendar.timeZone = TimeZone(abbreviation: "UTC")!
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
|
||||
if let date = calendar.date(from: components) {
|
||||
controllerInteraction.navigateToFirstDateMessage(Int32(date.timeIntervalSince1970), alreadyThere)
|
||||
}
|
||||
})
|
||||
|
||||
if displayAuthorInfo {
|
||||
let message = content.firstMessage
|
||||
var hasActionMedia = false
|
||||
for media in message.media {
|
||||
if media is TelegramMediaAction {
|
||||
hasActionMedia = true
|
||||
break
|
||||
}
|
||||
}
|
||||
var isBroadcastChannel = false
|
||||
if case .peer = chatLocation {
|
||||
if let peer = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = peer.info {
|
||||
isBroadcastChannel = true
|
||||
}
|
||||
} else if case let .replyThread(replyThreadMessage) = chatLocation, replyThreadMessage.isChannelPost, replyThreadMessage.effectiveTopId == message.id {
|
||||
isBroadcastChannel = true
|
||||
}
|
||||
|
||||
var hasAvatar = false
|
||||
if !hasActionMedia && !isBroadcastChannel {
|
||||
hasAvatar = true
|
||||
}
|
||||
|
||||
if let adAttribute = message.adAttribute {
|
||||
if adAttribute.displayAvatar {
|
||||
hasAvatar = adAttribute.displayAvatar
|
||||
}
|
||||
}
|
||||
|
||||
if hasAvatar {
|
||||
if let effectiveAuthor = effectiveAuthor {
|
||||
var storyStats: PeerStoryStats?
|
||||
if case .peer(id: context.account.peerId) = chatLocation {
|
||||
} else {
|
||||
switch content {
|
||||
case let .message(_, _, _, attributes, _):
|
||||
storyStats = attributes.authorStoryStats
|
||||
case let .group(messages):
|
||||
storyStats = messages.first?.3.authorStoryStats
|
||||
}
|
||||
}
|
||||
|
||||
avatarHeader = ChatMessageAvatarHeader(timestamp: content.index.timestamp, peerId: effectiveAuthor.id, peer: effectiveAuthor, messageReference: MessageReference(message), message: message, presentationData: presentationData, context: context, controllerInteraction: controllerInteraction, storyStats: storyStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.avatarHeader = avatarHeader
|
||||
|
||||
var headers: [ListViewItemHeader] = [self.dateHeader]
|
||||
if case .messageOptions = associatedData.subject {
|
||||
headers = []
|
||||
}
|
||||
if let avatarHeader = self.avatarHeader {
|
||||
headers.append(avatarHeader)
|
||||
}
|
||||
self.headers = headers
|
||||
}
|
||||
|
||||
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) {
|
||||
var viewClassName: AnyClass = ChatMessageBubbleItemNode.self
|
||||
|
||||
loop: for media in self.message.media {
|
||||
if let telegramFile = media as? TelegramMediaFile {
|
||||
if telegramFile.isVideoSticker {
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
break loop
|
||||
}
|
||||
if telegramFile.isAnimatedSticker, let size = telegramFile.size, size > 0 && size <= 128 * 1024 {
|
||||
if self.message.id.peerId.namespace == Namespaces.Peer.SecretChat {
|
||||
if telegramFile.fileId.namespace == Namespaces.Media.CloudFile {
|
||||
var isValidated = false
|
||||
for attribute in telegramFile.attributes {
|
||||
if case .hintIsValidated = attribute {
|
||||
isValidated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
inner: for attribute in telegramFile.attributes {
|
||||
if case let .Sticker(_, packReference, _) = attribute {
|
||||
if case .name = packReference {
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
} else if isValidated {
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
}
|
||||
break inner
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
}
|
||||
break loop
|
||||
}
|
||||
for attribute in telegramFile.attributes {
|
||||
switch attribute {
|
||||
case .Sticker:
|
||||
if let size = telegramFile.size, size > 0 && size <= 512 * 1024 {
|
||||
viewClassName = ChatMessageStickerItemNode.self
|
||||
}
|
||||
break loop
|
||||
case let .Video(_, _, flags, _):
|
||||
if flags.contains(.instantRoundVideo) {
|
||||
viewClassName = ChatMessageBubbleItemNode.self
|
||||
break loop
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if media is TelegramMediaAction {
|
||||
viewClassName = ChatMessageBubbleItemNode.self
|
||||
} else if media is TelegramMediaExpiredContent {
|
||||
viewClassName = ChatMessageBubbleItemNode.self
|
||||
} else if media is TelegramMediaDice {
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
}
|
||||
}
|
||||
|
||||
if viewClassName == ChatMessageBubbleItemNode.self && self.presentationData.largeEmoji && self.message.media.isEmpty {
|
||||
if case let .message(_, _, _, attributes, _) = self.content {
|
||||
switch attributes.contentTypeHint {
|
||||
case .largeEmoji:
|
||||
viewClassName = ChatMessageStickerItemNode.self
|
||||
case .animatedEmoji:
|
||||
viewClassName = ChatMessageAnimatedStickerItemNode.self
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let configure = {
|
||||
let node = (viewClassName as! ChatMessageItemView.Type).init()
|
||||
node.setupItem(self, synchronousLoad: synchronousLoads)
|
||||
|
||||
let nodeLayout = node.asyncLayout()
|
||||
let (top, bottom, dateAtBottom) = self.mergedWithItems(top: previousItem, bottom: nextItem)
|
||||
|
||||
var disableDate = self.disableDate
|
||||
if let subject = self.associatedData.subject, case let .messageOptions(_, _, info) = subject {
|
||||
switch info {
|
||||
case .reply, .link:
|
||||
disableDate = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let (layout, apply) = nodeLayout(self, params, top, bottom, dateAtBottom && !disableDate)
|
||||
|
||||
node.contentSize = layout.contentSize
|
||||
node.insets = layout.insets
|
||||
node.safeInsets = UIEdgeInsets(top: 0.0, left: params.leftInset, bottom: 0.0, right: params.rightInset)
|
||||
|
||||
node.updateSelectionState(animated: false)
|
||||
node.updateHighlightedState(animated: false)
|
||||
|
||||
Queue.mainQueue().async {
|
||||
completion(node, {
|
||||
return (nil, { _ in apply(.None, ListViewItemApply(isOnScreen: false), synchronousLoads) })
|
||||
})
|
||||
}
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
async {
|
||||
configure()
|
||||
}
|
||||
} else {
|
||||
configure()
|
||||
}
|
||||
}
|
||||
|
||||
public func mergedWithItems(top: ListViewItem?, bottom: ListViewItem?) -> (top: ChatMessageMerge, bottom: ChatMessageMerge, dateAtBottom: Bool) {
|
||||
var mergedTop: ChatMessageMerge = .none
|
||||
var mergedBottom: ChatMessageMerge = .none
|
||||
var dateAtBottom = false
|
||||
if let top = top as? ChatMessageItemImpl {
|
||||
if top.dateHeader.id != self.dateHeader.id {
|
||||
mergedBottom = .none
|
||||
} else {
|
||||
mergedBottom = messagesShouldBeMerged(accountPeerId: self.context.account.peerId, message, top.message)
|
||||
}
|
||||
}
|
||||
if let bottom = bottom as? ChatMessageItemImpl {
|
||||
if bottom.dateHeader.id != self.dateHeader.id {
|
||||
mergedTop = .none
|
||||
dateAtBottom = true
|
||||
} else {
|
||||
mergedTop = messagesShouldBeMerged(accountPeerId: self.context.account.peerId, bottom.message, message)
|
||||
}
|
||||
} else if let bottom = bottom as? ChatUnreadItem {
|
||||
if bottom.header.id != self.dateHeader.id {
|
||||
dateAtBottom = true
|
||||
}
|
||||
} else if let bottom = bottom as? ChatReplyCountItem {
|
||||
if bottom.header.id != self.dateHeader.id {
|
||||
dateAtBottom = true
|
||||
}
|
||||
} else {
|
||||
dateAtBottom = true
|
||||
}
|
||||
|
||||
return (mergedTop, mergedBottom, dateAtBottom)
|
||||
}
|
||||
|
||||
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 {
|
||||
if let nodeValue = node() as? ChatMessageItemView {
|
||||
nodeValue.setupItem(self, synchronousLoad: false)
|
||||
|
||||
let nodeLayout = nodeValue.asyncLayout()
|
||||
|
||||
async {
|
||||
let (top, bottom, dateAtBottom) = self.mergedWithItems(top: previousItem, bottom: nextItem)
|
||||
|
||||
let (layout, apply) = nodeLayout(self, params, top, bottom, dateAtBottom && !self.disableDate)
|
||||
Queue.mainQueue().async {
|
||||
completion(layout, { info in
|
||||
apply(animation, info, false)
|
||||
if let nodeValue = node() as? ChatMessageItemView {
|
||||
nodeValue.safeInsets = UIEdgeInsets(top: 0.0, left: params.leftInset, bottom: 0.0, right: params.rightInset)
|
||||
nodeValue.updateSelectionState(animated: false)
|
||||
nodeValue.updateHighlightedState(animated: false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
return "(ChatMessageItem id: \(self.message.id), text: \"\(self.message.text)\")"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user