mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Stories
This commit is contained in:
parent
2d1bac5a46
commit
f2da1e2315
@ -98,7 +98,7 @@ public struct Transition {
|
||||
private var _userData: [Any] = []
|
||||
|
||||
public func userData<T>(_ type: T.Type) -> T? {
|
||||
for item in self._userData {
|
||||
for item in self._userData.reversed() {
|
||||
if let item = item as? T {
|
||||
return item
|
||||
}
|
||||
|
@ -116,6 +116,7 @@ public final class ContextMenuActionItem {
|
||||
public let parseMarkdown: Bool
|
||||
public let badge: ContextMenuActionBadge?
|
||||
public let icon: (PresentationTheme) -> UIImage?
|
||||
public let additionalLeftIcon: ((PresentationTheme) -> UIImage?)?
|
||||
public let iconSource: ContextMenuActionItemIconSource?
|
||||
public let iconPosition: ContextMenuActionItemIconPosition
|
||||
public let animationName: String?
|
||||
@ -132,6 +133,7 @@ public final class ContextMenuActionItem {
|
||||
parseMarkdown: Bool = false,
|
||||
badge: ContextMenuActionBadge? = nil,
|
||||
icon: @escaping (PresentationTheme) -> UIImage?,
|
||||
additionalLeftIcon: ((PresentationTheme) -> UIImage?)? = nil,
|
||||
iconSource: ContextMenuActionItemIconSource? = nil,
|
||||
iconPosition: ContextMenuActionItemIconPosition = .right,
|
||||
animationName: String? = nil,
|
||||
@ -148,6 +150,7 @@ public final class ContextMenuActionItem {
|
||||
parseMarkdown: parseMarkdown,
|
||||
badge: badge,
|
||||
icon: icon,
|
||||
additionalLeftIcon: additionalLeftIcon,
|
||||
iconSource: iconSource,
|
||||
iconPosition: iconPosition,
|
||||
animationName: animationName,
|
||||
@ -170,6 +173,7 @@ public final class ContextMenuActionItem {
|
||||
parseMarkdown: Bool = false,
|
||||
badge: ContextMenuActionBadge? = nil,
|
||||
icon: @escaping (PresentationTheme) -> UIImage?,
|
||||
additionalLeftIcon: ((PresentationTheme) -> UIImage?)? = nil,
|
||||
iconSource: ContextMenuActionItemIconSource? = nil,
|
||||
iconPosition: ContextMenuActionItemIconPosition = .right,
|
||||
animationName: String? = nil,
|
||||
@ -185,6 +189,7 @@ public final class ContextMenuActionItem {
|
||||
self.parseMarkdown = parseMarkdown
|
||||
self.badge = badge
|
||||
self.icon = icon
|
||||
self.additionalLeftIcon = additionalLeftIcon
|
||||
self.iconSource = iconSource
|
||||
self.iconPosition = iconPosition
|
||||
self.animationName = animationName
|
||||
|
@ -64,6 +64,7 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
private let titleLabelNode: ImmediateTextNode
|
||||
private let subtitleNode: ImmediateTextNode
|
||||
private let iconNode: ASImageNode
|
||||
private let additionalIconNode: ASImageNode
|
||||
private var badgeIconNode: ASImageNode?
|
||||
private var animationNode: AnimationNode?
|
||||
|
||||
@ -99,6 +100,10 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
self.iconNode = ASImageNode()
|
||||
self.iconNode.isAccessibilityElement = false
|
||||
self.iconNode.isUserInteractionEnabled = false
|
||||
|
||||
self.additionalIconNode = ASImageNode()
|
||||
self.additionalIconNode.isAccessibilityElement = false
|
||||
self.additionalIconNode.isUserInteractionEnabled = false
|
||||
|
||||
super.init()
|
||||
|
||||
@ -110,6 +115,7 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
self.addSubnode(self.titleLabelNode)
|
||||
self.addSubnode(self.subtitleNode)
|
||||
self.addSubnode(self.iconNode)
|
||||
self.addSubnode(self.additionalIconNode)
|
||||
|
||||
self.isEnabled = self.canBeHighlighted()
|
||||
|
||||
@ -305,6 +311,14 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
iconSize = iconImage?.size
|
||||
}
|
||||
|
||||
let additionalIcon = self.item.additionalLeftIcon?(presentationData.theme)
|
||||
var additionalIconSize: CGSize?
|
||||
self.additionalIconNode.image = additionalIcon
|
||||
|
||||
if let additionalIcon {
|
||||
additionalIconSize = additionalIcon.size
|
||||
}
|
||||
|
||||
let badgeSize: CGSize?
|
||||
if let badge = self.item.badge {
|
||||
var badgeImage: UIImage?
|
||||
@ -422,7 +436,10 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
titleFrame = titleFrame.offsetBy(dx: 0.0, dy: titleVerticalOffset)
|
||||
}
|
||||
var subtitleFrame = CGRect(origin: CGPoint(x: sideInset, y: titleFrame.maxY + titleSubtitleSpacing), size: subtitleSize)
|
||||
if self.item.iconPosition == .left {
|
||||
if self.item.additionalLeftIcon != nil {
|
||||
titleFrame = titleFrame.offsetBy(dx: 26.0, dy: 0.0)
|
||||
subtitleFrame = subtitleFrame.offsetBy(dx: 26.0, dy: 0.0)
|
||||
} else if self.item.iconPosition == .left {
|
||||
titleFrame = titleFrame.offsetBy(dx: 36.0, dy: 0.0)
|
||||
subtitleFrame = subtitleFrame.offsetBy(dx: 36.0, dy: 0.0)
|
||||
}
|
||||
@ -444,12 +461,24 @@ private final class ContextControllerActionsListActionItemNode: HighlightTrackin
|
||||
x: self.item.iconPosition == .left ? iconSideInset : size.width - iconSideInset - iconWidth + floor((iconWidth - iconSize.width) / 2.0),
|
||||
y: floor((size.height - iconSize.height) / 2.0)
|
||||
),
|
||||
size: iconSize)
|
||||
size: iconSize
|
||||
)
|
||||
transition.updateFrame(node: self.iconNode, frame: iconFrame, beginWithCurrentState: true)
|
||||
if let animationNode = self.animationNode {
|
||||
transition.updateFrame(node: animationNode, frame: iconFrame, beginWithCurrentState: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let additionalIconSize {
|
||||
let iconFrame = CGRect(
|
||||
origin: CGPoint(
|
||||
x: 10.0,
|
||||
y: floor((size.height - additionalIconSize.height) / 2.0)
|
||||
),
|
||||
size: additionalIconSize
|
||||
)
|
||||
transition.updateFrame(node: self.additionalIconNode, frame: iconFrame, beginWithCurrentState: true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -2047,6 +2047,10 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate {
|
||||
targetView.addSubnode(itemNode)
|
||||
itemNode.frame = selfTargetBounds
|
||||
}
|
||||
} else if let targetView = targetView as? UIImageView {
|
||||
itemNode.isHidden = true
|
||||
targetView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12)
|
||||
targetView.layer.animateScale(from: 0.2, to: 1.0, duration: 0.12)
|
||||
}
|
||||
|
||||
if switchToInlineImmediately {
|
||||
|
@ -38,6 +38,18 @@ private func hashForIdsReverse(_ ids: [Int64]) -> Int64 {
|
||||
return Int64(bitPattern: acc)
|
||||
}
|
||||
|
||||
private func hashForIdsReverse(_ ids: [Int64], unreadIds: [Int64]) -> Int64 {
|
||||
var acc: UInt64 = 0
|
||||
|
||||
for id in ids {
|
||||
combineInt64Hash(&acc, with: UInt64(bitPattern: id))
|
||||
if unreadIds.contains(id) {
|
||||
combineInt64Hash(&acc, with: 1 as UInt64)
|
||||
}
|
||||
}
|
||||
return Int64(bitPattern: acc)
|
||||
}
|
||||
|
||||
func manageStickerPacks(network: Network, postbox: Postbox) -> Signal<Void, NoError> {
|
||||
return (postbox.transaction { transaction -> Void in
|
||||
addSynchronizeInstalledStickerPacksOperation(transaction: transaction, namespace: .stickers, content: .sync, noDelay: false)
|
||||
@ -99,15 +111,19 @@ func updatedFeaturedStickerPacks(network: Network, postbox: Postbox, category: F
|
||||
return postbox.transaction { transaction -> Signal<Void, NoError> in
|
||||
let initialPacks = transaction.getOrderedListItems(collectionId: category.itemListNamespace)
|
||||
var initialPackMap: [Int64: FeaturedStickerPackItem] = [:]
|
||||
var unreadIds: [Int64] = []
|
||||
for entry in initialPacks {
|
||||
let item = entry.contents.get(FeaturedStickerPackItem.self)!
|
||||
initialPackMap[FeaturedStickerPackItemId(entry.id).packId] = item
|
||||
if item.unread {
|
||||
unreadIds.append(item.info.id.id)
|
||||
}
|
||||
}
|
||||
|
||||
let initialPackIds = initialPacks.map {
|
||||
return FeaturedStickerPackItemId($0.id).packId
|
||||
}
|
||||
let initialHash: Int64 = hashForIdsReverse(initialPackIds)
|
||||
let initialHash: Int64 = hashForIdsReverse(initialPackIds, unreadIds: unreadIds)
|
||||
|
||||
struct FeaturedListContent {
|
||||
var unreadIds: Set<Int64>
|
||||
|
@ -0,0 +1,525 @@
|
||||
import Foundation
|
||||
import SwiftSignalKit
|
||||
import Postbox
|
||||
import TelegramApi
|
||||
|
||||
public final class EngineStoryViewListContext {
|
||||
public struct LoadMoreToken: Equatable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
public enum ListMode {
|
||||
case everyone
|
||||
case contacts
|
||||
}
|
||||
|
||||
public enum SortMode {
|
||||
case reactionsFirst
|
||||
case recentFirst
|
||||
}
|
||||
|
||||
public final class Item: Equatable {
|
||||
public let peer: EnginePeer
|
||||
public let timestamp: Int32
|
||||
public let storyStats: PeerStoryStats?
|
||||
public let reaction: MessageReaction.Reaction?
|
||||
public let reactionFile: TelegramMediaFile?
|
||||
|
||||
public init(
|
||||
peer: EnginePeer,
|
||||
timestamp: Int32,
|
||||
storyStats: PeerStoryStats?,
|
||||
reaction: MessageReaction.Reaction?,
|
||||
reactionFile: TelegramMediaFile?
|
||||
) {
|
||||
self.peer = peer
|
||||
self.timestamp = timestamp
|
||||
self.storyStats = storyStats
|
||||
self.reaction = reaction
|
||||
self.reactionFile = reactionFile
|
||||
}
|
||||
|
||||
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
if lhs.peer != rhs.peer {
|
||||
return false
|
||||
}
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return false
|
||||
}
|
||||
if lhs.storyStats != rhs.storyStats {
|
||||
return false
|
||||
}
|
||||
if lhs.reaction != rhs.reaction {
|
||||
return false
|
||||
}
|
||||
if lhs.reactionFile?.fileId != rhs.reactionFile?.fileId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public struct State: Equatable {
|
||||
public var totalCount: Int
|
||||
public var totalReactedCount: Int
|
||||
public var items: [Item]
|
||||
public var loadMoreToken: LoadMoreToken?
|
||||
|
||||
public init(
|
||||
totalCount: Int,
|
||||
totalReactedCount: Int,
|
||||
items: [Item],
|
||||
loadMoreToken: LoadMoreToken?
|
||||
) {
|
||||
self.totalCount = totalCount
|
||||
self.totalReactedCount = totalReactedCount
|
||||
self.items = items
|
||||
self.loadMoreToken = loadMoreToken
|
||||
}
|
||||
}
|
||||
|
||||
private final class Impl {
|
||||
struct NextOffset: Equatable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
struct InternalState: Equatable {
|
||||
var totalCount: Int
|
||||
var totalReactedCount: Int
|
||||
var items: [Item]
|
||||
var canLoadMore: Bool
|
||||
var nextOffset: NextOffset?
|
||||
}
|
||||
|
||||
let queue: Queue
|
||||
|
||||
let account: Account
|
||||
let storyId: Int32
|
||||
let listMode: ListMode
|
||||
let sortMode: SortMode
|
||||
let searchQuery: String?
|
||||
|
||||
let disposable = MetaDisposable()
|
||||
let storyStatsDisposable = MetaDisposable()
|
||||
|
||||
var state: InternalState?
|
||||
let statePromise = Promise<InternalState>()
|
||||
|
||||
private var parentSource: Impl?
|
||||
var isLoadingMore: Bool = false
|
||||
|
||||
init(queue: Queue, account: Account, storyId: Int32, views: EngineStoryItem.Views, listMode: ListMode, sortMode: SortMode, searchQuery: String?, parentSource: Impl?) {
|
||||
self.queue = queue
|
||||
self.account = account
|
||||
self.storyId = storyId
|
||||
self.listMode = listMode
|
||||
self.sortMode = sortMode
|
||||
self.searchQuery = searchQuery
|
||||
|
||||
if let parentSource = parentSource, (parentSource.listMode == .everyone || parentSource.listMode == listMode), let parentState = parentSource.state, parentState.totalCount <= 100 {
|
||||
self.parentSource = parentSource
|
||||
|
||||
self.disposable.set((parentSource.statePromise.get()
|
||||
|> mapToSignal { state -> Signal<InternalState, NoError> in
|
||||
let needUpdate: Signal<Void, NoError>
|
||||
if listMode == .contacts {
|
||||
var keys: [PostboxViewKey] = []
|
||||
for item in state.items {
|
||||
keys.append(.isContact(id: item.peer.id))
|
||||
}
|
||||
needUpdate = account.postbox.combinedView(keys: keys)
|
||||
|> map { views -> [Bool] in
|
||||
var result: [Bool] = []
|
||||
for item in state.items {
|
||||
if let view = views.views[.isContact(id: item.peer.id)] as? IsContactView {
|
||||
result.append(view.isContact)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|> map { _ -> Void in
|
||||
return Void()
|
||||
}
|
||||
} else {
|
||||
needUpdate = .single(Void())
|
||||
}
|
||||
|
||||
return needUpdate
|
||||
|> mapToSignal { _ -> Signal<InternalState, NoError> in
|
||||
return account.postbox.transaction { transaction -> InternalState in
|
||||
if state.canLoadMore {
|
||||
return InternalState(
|
||||
totalCount: 0, totalReactedCount: 0, items: [], canLoadMore: true, nextOffset: state.nextOffset)
|
||||
}
|
||||
|
||||
var items: [Item] = []
|
||||
switch listMode {
|
||||
case .everyone:
|
||||
items = state.items
|
||||
case .contacts:
|
||||
items = state.items.filter { item in
|
||||
return transaction.isPeerContact(peerId: item.peer.id)
|
||||
}
|
||||
}
|
||||
if let searchQuery = searchQuery, !searchQuery.isEmpty {
|
||||
let normalizedQuery = searchQuery.lowercased()
|
||||
items = state.items.filter { item in
|
||||
return item.peer.indexName.matchesByTokens(normalizedQuery)
|
||||
}
|
||||
}
|
||||
switch sortMode {
|
||||
case .reactionsFirst:
|
||||
items.sort(by: { lhs, rhs in
|
||||
if (lhs.reaction == nil) != (rhs.reaction == nil) {
|
||||
return lhs.reaction != nil
|
||||
}
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return lhs.timestamp > rhs.timestamp
|
||||
}
|
||||
return lhs.peer.id < rhs.peer.id
|
||||
})
|
||||
case .recentFirst:
|
||||
items.sort(by: { lhs, rhs in
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return lhs.timestamp > rhs.timestamp
|
||||
}
|
||||
return lhs.peer.id < rhs.peer.id
|
||||
})
|
||||
}
|
||||
|
||||
var totalReactedCount = 0
|
||||
for item in items {
|
||||
if item.reaction != nil {
|
||||
totalReactedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return InternalState(
|
||||
totalCount: items.count, totalReactedCount: totalReactedCount, items: items, canLoadMore: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] state in
|
||||
guard let `self` = self else {
|
||||
return
|
||||
}
|
||||
self.updateInternalState(state: state)
|
||||
}))
|
||||
} else {
|
||||
let initialState = State(totalCount: views.seenCount, totalReactedCount: views.reactedCount, items: [], loadMoreToken: LoadMoreToken(value: ""))
|
||||
let state = InternalState(totalCount: initialState.totalCount, totalReactedCount: initialState.totalReactedCount, items: initialState.items, canLoadMore: initialState.loadMoreToken != nil, nextOffset: nil)
|
||||
self.state = state
|
||||
self.statePromise.set(.single(state))
|
||||
|
||||
if initialState.loadMoreToken != nil {
|
||||
self.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
self.disposable.dispose()
|
||||
self.storyStatsDisposable.dispose()
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
if let parentSource = self.parentSource {
|
||||
parentSource.loadMore()
|
||||
return
|
||||
}
|
||||
|
||||
guard let state = self.state else {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.canLoadMore {
|
||||
return
|
||||
}
|
||||
if self.isLoadingMore {
|
||||
return
|
||||
}
|
||||
self.isLoadingMore = true
|
||||
|
||||
let account = self.account
|
||||
let accountPeerId = account.peerId
|
||||
let storyId = self.storyId
|
||||
let listMode = self.listMode
|
||||
let sortMode = self.sortMode
|
||||
let searchQuery = self.searchQuery
|
||||
let currentOffset = state.nextOffset
|
||||
let limit = state.items.isEmpty ? 50 : 100
|
||||
let signal: Signal<InternalState, NoError> = self.account.postbox.transaction { transaction -> Void in
|
||||
}
|
||||
|> mapToSignal { _ -> Signal<InternalState, NoError> in
|
||||
var flags: Int32 = 0
|
||||
switch listMode {
|
||||
case .everyone:
|
||||
break
|
||||
case .contacts:
|
||||
flags |= (1 << 0)
|
||||
}
|
||||
switch sortMode {
|
||||
case .reactionsFirst:
|
||||
flags |= (1 << 2)
|
||||
case .recentFirst:
|
||||
break
|
||||
}
|
||||
if searchQuery != nil {
|
||||
flags |= (1 << 1)
|
||||
}
|
||||
|
||||
return account.network.request(Api.functions.stories.getStoryViewsList(flags: flags, q: searchQuery, id: storyId, offset: currentOffset?.value ?? "", limit: Int32(limit)))
|
||||
|> map(Optional.init)
|
||||
|> `catch` { _ -> Signal<Api.stories.StoryViewsList?, NoError> in
|
||||
return .single(nil)
|
||||
}
|
||||
|> mapToSignal { result -> Signal<InternalState, NoError> in
|
||||
return account.postbox.transaction { transaction -> InternalState in
|
||||
switch result {
|
||||
case let .storyViewsList(_, count, reactionsCount, views, users, nextOffset):
|
||||
updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: AccumulatedPeers(users: users))
|
||||
|
||||
var items: [Item] = []
|
||||
for view in views {
|
||||
switch view {
|
||||
case let .storyView(flags, userId, date, reaction):
|
||||
let isBlocked = (flags & (1 << 0)) != 0
|
||||
let isBlockedFromStories = (flags & (1 << 1)) != 0
|
||||
|
||||
let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))
|
||||
transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData in
|
||||
let previousData: CachedUserData
|
||||
if let current = cachedData as? CachedUserData {
|
||||
previousData = current
|
||||
} else {
|
||||
previousData = CachedUserData()
|
||||
}
|
||||
var updatedFlags = previousData.flags
|
||||
if isBlockedFromStories {
|
||||
updatedFlags.insert(.isBlockedFromStories)
|
||||
} else {
|
||||
updatedFlags.remove(.isBlockedFromStories)
|
||||
}
|
||||
return previousData.withUpdatedIsBlocked(isBlocked).withUpdatedFlags(updatedFlags)
|
||||
})
|
||||
if let peer = transaction.getPeer(peerId) {
|
||||
let parsedReaction = reaction.flatMap(MessageReaction.Reaction.init(apiReaction:))
|
||||
items.append(Item(
|
||||
peer: EnginePeer(peer),
|
||||
timestamp: date,
|
||||
storyStats: transaction.getPeerStoryStats(peerId: peerId),
|
||||
reaction: parsedReaction,
|
||||
reactionFile: parsedReaction.flatMap { reaction -> TelegramMediaFile? in
|
||||
switch reaction {
|
||||
case .builtin:
|
||||
return nil
|
||||
case let .custom(fileId):
|
||||
return transaction.getMedia(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)) as? TelegramMediaFile
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if listMode == .everyone, searchQuery == nil {
|
||||
if let storedItem = transaction.getStory(id: StoryId(peerId: account.peerId, id: storyId))?.get(Stories.StoredItem.self), case let .item(item) = storedItem, let currentViews = item.views {
|
||||
let updatedItem: Stories.StoredItem = .item(Stories.Item(
|
||||
id: item.id,
|
||||
timestamp: item.timestamp,
|
||||
expirationTimestamp: item.expirationTimestamp,
|
||||
media: item.media,
|
||||
mediaAreas: item.mediaAreas,
|
||||
text: item.text,
|
||||
entities: item.entities,
|
||||
views: Stories.Item.Views(seenCount: Int(count), reactedCount: Int(reactionsCount), seenPeerIds: currentViews.seenPeerIds),
|
||||
privacy: item.privacy,
|
||||
isPinned: item.isPinned,
|
||||
isExpired: item.isExpired,
|
||||
isPublic: item.isPublic,
|
||||
isCloseFriends: item.isCloseFriends,
|
||||
isContacts: item.isContacts,
|
||||
isSelectedContacts: item.isSelectedContacts,
|
||||
isForwardingDisabled: item.isForwardingDisabled,
|
||||
isEdited: item.isEdited,
|
||||
myReaction: item.myReaction
|
||||
))
|
||||
if let entry = CodableEntry(updatedItem) {
|
||||
transaction.setStory(id: StoryId(peerId: account.peerId, id: storyId), value: entry)
|
||||
}
|
||||
}
|
||||
|
||||
var currentItems = transaction.getStoryItems(peerId: account.peerId)
|
||||
for i in 0 ..< currentItems.count {
|
||||
if currentItems[i].id == storyId {
|
||||
if case let .item(item) = currentItems[i].value.get(Stories.StoredItem.self), let currentViews = item.views {
|
||||
let updatedItem: Stories.StoredItem = .item(Stories.Item(
|
||||
id: item.id,
|
||||
timestamp: item.timestamp,
|
||||
expirationTimestamp: item.expirationTimestamp,
|
||||
media: item.media,
|
||||
mediaAreas: item.mediaAreas,
|
||||
text: item.text,
|
||||
entities: item.entities,
|
||||
views: Stories.Item.Views(seenCount: Int(count), reactedCount: Int(reactionsCount), seenPeerIds: currentViews.seenPeerIds),
|
||||
privacy: item.privacy,
|
||||
isPinned: item.isPinned,
|
||||
isExpired: item.isExpired,
|
||||
isPublic: item.isPublic,
|
||||
isCloseFriends: item.isCloseFriends,
|
||||
isContacts: item.isContacts,
|
||||
isSelectedContacts: item.isSelectedContacts,
|
||||
isForwardingDisabled: item.isForwardingDisabled,
|
||||
isEdited: item.isEdited,
|
||||
myReaction: item.myReaction
|
||||
))
|
||||
if let entry = CodableEntry(updatedItem) {
|
||||
currentItems[i] = StoryItemsTableEntry(value: entry, id: updatedItem.id, expirationTimestamp: updatedItem.expirationTimestamp, isCloseFriends: updatedItem.isCloseFriends)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
transaction.setStoryItems(peerId: account.peerId, items: currentItems)
|
||||
}
|
||||
|
||||
return InternalState(totalCount: Int(count), totalReactedCount: Int(reactionsCount), items: items, canLoadMore: nextOffset != nil, nextOffset: nextOffset.flatMap { NextOffset(value: $0) })
|
||||
case .none:
|
||||
return InternalState(totalCount: 0, totalReactedCount: 0, items: [], canLoadMore: false, nextOffset: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.disposable.set((signal
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] state in
|
||||
guard let `self` = self else {
|
||||
return
|
||||
}
|
||||
self.updateInternalState(state: state)
|
||||
}))
|
||||
}
|
||||
|
||||
private func updateInternalState(state: InternalState) {
|
||||
var currentState = self.state ?? InternalState(
|
||||
totalCount: 0, totalReactedCount: 0, items: [], canLoadMore: false, nextOffset: nil)
|
||||
|
||||
struct ItemHash: Hashable {
|
||||
var peerId: EnginePeer.Id
|
||||
}
|
||||
|
||||
if self.parentSource != nil {
|
||||
currentState.items.removeAll()
|
||||
}
|
||||
|
||||
var existingItems = Set<ItemHash>()
|
||||
for item in currentState.items {
|
||||
existingItems.insert(ItemHash(peerId: item.peer.id))
|
||||
}
|
||||
|
||||
for item in state.items {
|
||||
let itemHash = ItemHash(peerId: item.peer.id)
|
||||
if existingItems.contains(itemHash) {
|
||||
continue
|
||||
}
|
||||
existingItems.insert(itemHash)
|
||||
currentState.items.append(item)
|
||||
}
|
||||
|
||||
var allReactedCount = 0
|
||||
for item in currentState.items {
|
||||
if item.reaction != nil {
|
||||
allReactedCount += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if state.canLoadMore {
|
||||
currentState.totalCount = max(state.totalCount, currentState.items.count)
|
||||
currentState.totalReactedCount = max(state.totalReactedCount, allReactedCount)
|
||||
} else {
|
||||
currentState.totalCount = currentState.items.count
|
||||
currentState.totalReactedCount = allReactedCount
|
||||
}
|
||||
currentState.canLoadMore = state.canLoadMore
|
||||
currentState.nextOffset = state.nextOffset
|
||||
|
||||
self.isLoadingMore = false
|
||||
self.state = currentState
|
||||
self.statePromise.set(.single(currentState))
|
||||
|
||||
let statsKey: PostboxViewKey = .peerStoryStats(peerIds: Set(currentState.items.map(\.peer.id)))
|
||||
self.storyStatsDisposable.set((self.account.postbox.combinedView(keys: [statsKey])
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] views in
|
||||
guard let `self` = self, var state = self.state else {
|
||||
return
|
||||
}
|
||||
guard let view = views.views[statsKey] as? PeerStoryStatsView else {
|
||||
return
|
||||
}
|
||||
var updated = false
|
||||
var items = state.items
|
||||
for i in 0 ..< state.items.count {
|
||||
let item = items[i]
|
||||
let value = view.storyStats[item.peer.id]
|
||||
if item.storyStats != value {
|
||||
updated = true
|
||||
items[i] = Item(
|
||||
peer: item.peer,
|
||||
timestamp: item.timestamp,
|
||||
storyStats: value,
|
||||
reaction: item.reaction,
|
||||
reactionFile: item.reactionFile
|
||||
)
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
state.items = items
|
||||
self.state = state
|
||||
self.statePromise.set(.single(state))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private let queue: Queue
|
||||
private let impl: QueueLocalObject<Impl>
|
||||
|
||||
public var state: Signal<State, NoError> {
|
||||
return Signal { subscriber in
|
||||
let disposable = MetaDisposable()
|
||||
self.impl.with { impl in
|
||||
disposable.set(impl.statePromise.get().start(next: { state in
|
||||
var loadMoreToken: LoadMoreToken?
|
||||
if let nextOffset = state.nextOffset {
|
||||
loadMoreToken = LoadMoreToken(value: nextOffset.value)
|
||||
}
|
||||
subscriber.putNext(State(
|
||||
totalCount: state.totalCount,
|
||||
totalReactedCount: state.totalReactedCount,
|
||||
items: state.items,
|
||||
loadMoreToken: loadMoreToken
|
||||
))
|
||||
}))
|
||||
}
|
||||
return disposable
|
||||
}
|
||||
}
|
||||
|
||||
init(account: Account, storyId: Int32, views: EngineStoryItem.Views, listMode: ListMode, sortMode: SortMode, searchQuery: String?, parentSource: EngineStoryViewListContext?) {
|
||||
let queue = Queue.mainQueue()
|
||||
self.queue = queue
|
||||
self.impl = QueueLocalObject(queue: queue, generate: {
|
||||
return Impl(queue: queue, account: account, storyId: storyId, views: views, listMode: listMode, sortMode: sortMode, searchQuery: searchQuery, parentSource: parentSource?.impl.syncWith { $0 })
|
||||
})
|
||||
}
|
||||
|
||||
public func loadMore() {
|
||||
self.impl.with { impl in
|
||||
impl.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1583,373 +1583,6 @@ func _internal_getStoryViews(account: Account, ids: [Int32]) -> Signal<[Int32: S
|
||||
}
|
||||
}
|
||||
|
||||
public final class EngineStoryViewListContext {
|
||||
public struct LoadMoreToken: Equatable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
public final class Item: Equatable {
|
||||
public let peer: EnginePeer
|
||||
public let timestamp: Int32
|
||||
public let storyStats: PeerStoryStats?
|
||||
public let reaction: MessageReaction.Reaction?
|
||||
public let reactionFile: TelegramMediaFile?
|
||||
|
||||
public init(
|
||||
peer: EnginePeer,
|
||||
timestamp: Int32,
|
||||
storyStats: PeerStoryStats?,
|
||||
reaction: MessageReaction.Reaction?,
|
||||
reactionFile: TelegramMediaFile?
|
||||
) {
|
||||
self.peer = peer
|
||||
self.timestamp = timestamp
|
||||
self.storyStats = storyStats
|
||||
self.reaction = reaction
|
||||
self.reactionFile = reactionFile
|
||||
}
|
||||
|
||||
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
if lhs.peer != rhs.peer {
|
||||
return false
|
||||
}
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return false
|
||||
}
|
||||
if lhs.storyStats != rhs.storyStats {
|
||||
return false
|
||||
}
|
||||
if lhs.reaction != rhs.reaction {
|
||||
return false
|
||||
}
|
||||
if lhs.reactionFile?.fileId != rhs.reactionFile?.fileId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public struct State: Equatable {
|
||||
public var totalCount: Int
|
||||
public var totalReactedCount: Int
|
||||
public var items: [Item]
|
||||
public var loadMoreToken: LoadMoreToken?
|
||||
|
||||
public init(
|
||||
totalCount: Int,
|
||||
totalReactedCount: Int,
|
||||
items: [Item],
|
||||
loadMoreToken: LoadMoreToken?
|
||||
) {
|
||||
self.totalCount = totalCount
|
||||
self.totalReactedCount = totalReactedCount
|
||||
self.items = items
|
||||
self.loadMoreToken = loadMoreToken
|
||||
}
|
||||
}
|
||||
|
||||
private final class Impl {
|
||||
struct NextOffset: Equatable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
struct InternalState: Equatable {
|
||||
var totalCount: Int
|
||||
var totalReactedCount: Int
|
||||
var items: [Item]
|
||||
var canLoadMore: Bool
|
||||
var nextOffset: NextOffset?
|
||||
}
|
||||
|
||||
let queue: Queue
|
||||
|
||||
let account: Account
|
||||
let storyId: Int32
|
||||
|
||||
let disposable = MetaDisposable()
|
||||
let storyStatsDisposable = MetaDisposable()
|
||||
|
||||
var state: InternalState
|
||||
let statePromise = Promise<InternalState>()
|
||||
|
||||
var isLoadingMore: Bool = false
|
||||
|
||||
init(queue: Queue, account: Account, storyId: Int32, views: EngineStoryItem.Views) {
|
||||
self.queue = queue
|
||||
self.account = account
|
||||
self.storyId = storyId
|
||||
|
||||
let initialState = State(totalCount: views.seenCount, totalReactedCount: views.reactedCount, items: [], loadMoreToken: LoadMoreToken(value: ""))
|
||||
self.state = InternalState(totalCount: initialState.totalCount, totalReactedCount: initialState.totalReactedCount, items: initialState.items, canLoadMore: initialState.loadMoreToken != nil, nextOffset: nil)
|
||||
self.statePromise.set(.single(self.state))
|
||||
|
||||
if initialState.loadMoreToken != nil {
|
||||
self.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
self.disposable.dispose()
|
||||
self.storyStatsDisposable.dispose()
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
if !self.state.canLoadMore {
|
||||
return
|
||||
}
|
||||
if self.isLoadingMore {
|
||||
return
|
||||
}
|
||||
self.isLoadingMore = true
|
||||
|
||||
let account = self.account
|
||||
let accountPeerId = account.peerId
|
||||
let storyId = self.storyId
|
||||
let currentOffset = self.state.nextOffset
|
||||
let limit = self.state.items.isEmpty ? 50 : 100
|
||||
let signal: Signal<InternalState, NoError> = self.account.postbox.transaction { transaction -> Void in
|
||||
}
|
||||
|> mapToSignal { _ -> Signal<InternalState, NoError> in
|
||||
return account.network.request(Api.functions.stories.getStoryViewsList(flags: 0, q: nil, id: storyId, offset: currentOffset?.value ?? "", limit: Int32(limit)))
|
||||
|> map(Optional.init)
|
||||
|> `catch` { _ -> Signal<Api.stories.StoryViewsList?, NoError> in
|
||||
return .single(nil)
|
||||
}
|
||||
|> mapToSignal { result -> Signal<InternalState, NoError> in
|
||||
return account.postbox.transaction { transaction -> InternalState in
|
||||
switch result {
|
||||
case let .storyViewsList(_, count, reactionsCount, views, users, nextOffset):
|
||||
updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: AccumulatedPeers(users: users))
|
||||
|
||||
var items: [Item] = []
|
||||
for view in views {
|
||||
switch view {
|
||||
case let .storyView(flags, userId, date, reaction):
|
||||
let isBlocked = (flags & (1 << 0)) != 0
|
||||
let isBlockedFromStories = (flags & (1 << 1)) != 0
|
||||
|
||||
let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))
|
||||
transaction.updatePeerCachedData(peerIds: Set([peerId]), update: { _, cachedData in
|
||||
let previousData: CachedUserData
|
||||
if let current = cachedData as? CachedUserData {
|
||||
previousData = current
|
||||
} else {
|
||||
previousData = CachedUserData()
|
||||
}
|
||||
var updatedFlags = previousData.flags
|
||||
if isBlockedFromStories {
|
||||
updatedFlags.insert(.isBlockedFromStories)
|
||||
} else {
|
||||
updatedFlags.remove(.isBlockedFromStories)
|
||||
}
|
||||
return previousData.withUpdatedIsBlocked(isBlocked).withUpdatedFlags(updatedFlags)
|
||||
})
|
||||
if let peer = transaction.getPeer(peerId) {
|
||||
let parsedReaction = reaction.flatMap(MessageReaction.Reaction.init(apiReaction:))
|
||||
items.append(Item(
|
||||
peer: EnginePeer(peer),
|
||||
timestamp: date,
|
||||
storyStats: transaction.getPeerStoryStats(peerId: peerId),
|
||||
reaction: parsedReaction,
|
||||
reactionFile: parsedReaction.flatMap { reaction -> TelegramMediaFile? in
|
||||
switch reaction {
|
||||
case .builtin:
|
||||
return nil
|
||||
case let .custom(fileId):
|
||||
return transaction.getMedia(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)) as? TelegramMediaFile
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let storedItem = transaction.getStory(id: StoryId(peerId: account.peerId, id: storyId))?.get(Stories.StoredItem.self), case let .item(item) = storedItem, let currentViews = item.views {
|
||||
let updatedItem: Stories.StoredItem = .item(Stories.Item(
|
||||
id: item.id,
|
||||
timestamp: item.timestamp,
|
||||
expirationTimestamp: item.expirationTimestamp,
|
||||
media: item.media,
|
||||
mediaAreas: item.mediaAreas,
|
||||
text: item.text,
|
||||
entities: item.entities,
|
||||
views: Stories.Item.Views(seenCount: Int(count), reactedCount: Int(reactionsCount), seenPeerIds: currentViews.seenPeerIds),
|
||||
privacy: item.privacy,
|
||||
isPinned: item.isPinned,
|
||||
isExpired: item.isExpired,
|
||||
isPublic: item.isPublic,
|
||||
isCloseFriends: item.isCloseFriends,
|
||||
isContacts: item.isContacts,
|
||||
isSelectedContacts: item.isSelectedContacts,
|
||||
isForwardingDisabled: item.isForwardingDisabled,
|
||||
isEdited: item.isEdited,
|
||||
myReaction: item.myReaction
|
||||
))
|
||||
if let entry = CodableEntry(updatedItem) {
|
||||
transaction.setStory(id: StoryId(peerId: account.peerId, id: storyId), value: entry)
|
||||
}
|
||||
}
|
||||
|
||||
var currentItems = transaction.getStoryItems(peerId: account.peerId)
|
||||
for i in 0 ..< currentItems.count {
|
||||
if currentItems[i].id == storyId {
|
||||
if case let .item(item) = currentItems[i].value.get(Stories.StoredItem.self), let currentViews = item.views {
|
||||
let updatedItem: Stories.StoredItem = .item(Stories.Item(
|
||||
id: item.id,
|
||||
timestamp: item.timestamp,
|
||||
expirationTimestamp: item.expirationTimestamp,
|
||||
media: item.media,
|
||||
mediaAreas: item.mediaAreas,
|
||||
text: item.text,
|
||||
entities: item.entities,
|
||||
views: Stories.Item.Views(seenCount: Int(count), reactedCount: Int(reactionsCount), seenPeerIds: currentViews.seenPeerIds),
|
||||
privacy: item.privacy,
|
||||
isPinned: item.isPinned,
|
||||
isExpired: item.isExpired,
|
||||
isPublic: item.isPublic,
|
||||
isCloseFriends: item.isCloseFriends,
|
||||
isContacts: item.isContacts,
|
||||
isSelectedContacts: item.isSelectedContacts,
|
||||
isForwardingDisabled: item.isForwardingDisabled,
|
||||
isEdited: item.isEdited,
|
||||
myReaction: item.myReaction
|
||||
))
|
||||
if let entry = CodableEntry(updatedItem) {
|
||||
currentItems[i] = StoryItemsTableEntry(value: entry, id: updatedItem.id, expirationTimestamp: updatedItem.expirationTimestamp, isCloseFriends: updatedItem.isCloseFriends)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
transaction.setStoryItems(peerId: account.peerId, items: currentItems)
|
||||
|
||||
return InternalState(totalCount: Int(count), totalReactedCount: Int(reactionsCount), items: items, canLoadMore: nextOffset != nil, nextOffset: nextOffset.flatMap { NextOffset(value: $0) })
|
||||
case .none:
|
||||
return InternalState(totalCount: 0, totalReactedCount: 0, items: [], canLoadMore: false, nextOffset: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.disposable.set((signal
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] state in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
struct ItemHash: Hashable {
|
||||
var peerId: EnginePeer.Id
|
||||
}
|
||||
|
||||
var existingItems = Set<ItemHash>()
|
||||
for item in strongSelf.state.items {
|
||||
existingItems.insert(ItemHash(peerId: item.peer.id))
|
||||
}
|
||||
|
||||
for item in state.items {
|
||||
let itemHash = ItemHash(peerId: item.peer.id)
|
||||
if existingItems.contains(itemHash) {
|
||||
continue
|
||||
}
|
||||
existingItems.insert(itemHash)
|
||||
strongSelf.state.items.append(item)
|
||||
}
|
||||
|
||||
var allReactedCount = 0
|
||||
for item in strongSelf.state.items {
|
||||
if item.reaction != nil {
|
||||
allReactedCount += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if state.canLoadMore {
|
||||
strongSelf.state.totalCount = max(state.totalCount, strongSelf.state.items.count)
|
||||
strongSelf.state.totalReactedCount = max(state.totalReactedCount, allReactedCount)
|
||||
} else {
|
||||
strongSelf.state.totalCount = strongSelf.state.items.count
|
||||
strongSelf.state.totalReactedCount = allReactedCount
|
||||
}
|
||||
strongSelf.state.canLoadMore = state.canLoadMore
|
||||
strongSelf.state.nextOffset = state.nextOffset
|
||||
|
||||
strongSelf.isLoadingMore = false
|
||||
strongSelf.statePromise.set(.single(strongSelf.state))
|
||||
|
||||
let statsKey: PostboxViewKey = .peerStoryStats(peerIds: Set(strongSelf.state.items.map(\.peer.id)))
|
||||
strongSelf.storyStatsDisposable.set((strongSelf.account.postbox.combinedView(keys: [statsKey])
|
||||
|> deliverOn(strongSelf.queue)).start(next: { views in
|
||||
guard let `self` = self else {
|
||||
return
|
||||
}
|
||||
guard let view = views.views[statsKey] as? PeerStoryStatsView else {
|
||||
return
|
||||
}
|
||||
var updated = false
|
||||
var items = self.state.items
|
||||
for i in 0 ..< strongSelf.state.items.count {
|
||||
let item = items[i]
|
||||
let value = view.storyStats[item.peer.id]
|
||||
if item.storyStats != value {
|
||||
updated = true
|
||||
items[i] = Item(
|
||||
peer: item.peer,
|
||||
timestamp: item.timestamp,
|
||||
storyStats: value,
|
||||
reaction: item.reaction,
|
||||
reactionFile: item.reactionFile
|
||||
)
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
self.state.items = items
|
||||
self.statePromise.set(.single(self.state))
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private let queue: Queue
|
||||
private let impl: QueueLocalObject<Impl>
|
||||
|
||||
public var state: Signal<State, NoError> {
|
||||
return Signal { subscriber in
|
||||
let disposable = MetaDisposable()
|
||||
self.impl.with { impl in
|
||||
disposable.set(impl.statePromise.get().start(next: { state in
|
||||
var loadMoreToken: LoadMoreToken?
|
||||
if let nextOffset = state.nextOffset {
|
||||
loadMoreToken = LoadMoreToken(value: nextOffset.value)
|
||||
}
|
||||
subscriber.putNext(State(
|
||||
totalCount: state.totalCount,
|
||||
totalReactedCount: state.totalReactedCount,
|
||||
items: state.items,
|
||||
loadMoreToken: loadMoreToken
|
||||
))
|
||||
}))
|
||||
}
|
||||
return disposable
|
||||
}
|
||||
}
|
||||
|
||||
init(account: Account, storyId: Int32, views: EngineStoryItem.Views) {
|
||||
let queue = Queue.mainQueue()
|
||||
self.queue = queue
|
||||
self.impl = QueueLocalObject(queue: queue, generate: {
|
||||
return Impl(queue: queue, account: account, storyId: storyId, views: views)
|
||||
})
|
||||
}
|
||||
|
||||
public func loadMore() {
|
||||
self.impl.with { impl in
|
||||
impl.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func _internal_updatePeerStoriesHidden(account: Account, id: PeerId, isHidden: Bool) -> Signal<Never, NoError> {
|
||||
return account.postbox.transaction { transaction -> Api.InputUser? in
|
||||
guard let peer = transaction.getPeer(id) else {
|
||||
|
@ -1102,8 +1102,8 @@ public extension TelegramEngine {
|
||||
return _internal_updateStoriesArePinned(account: self.account, ids: ids, isPinned: isPinned)
|
||||
}
|
||||
|
||||
public func storyViewList(id: Int32, views: EngineStoryItem.Views) -> EngineStoryViewListContext {
|
||||
return EngineStoryViewListContext(account: self.account, storyId: id, views: views)
|
||||
public func storyViewList(id: Int32, views: EngineStoryItem.Views, listMode: EngineStoryViewListContext.ListMode, sortMode: EngineStoryViewListContext.SortMode, searchQuery: String? = nil, parentSource: EngineStoryViewListContext? = nil) -> EngineStoryViewListContext {
|
||||
return EngineStoryViewListContext(account: self.account, storyId: id, views: views, listMode: listMode, sortMode: sortMode, searchQuery: searchQuery, parentSource: parentSource)
|
||||
}
|
||||
|
||||
public func exportStoryLink(peerId: EnginePeer.Id, id: Int32) -> Signal<String?, NoError> {
|
||||
|
@ -1210,7 +1210,8 @@ final class MediaEditorScreenComponent: Component {
|
||||
isFormattingLocked: !state.isPremium,
|
||||
hideKeyboard: self.currentInputMode == .emoji,
|
||||
forceIsEditing: self.currentInputMode == .emoji,
|
||||
disabledPlaceholder: nil
|
||||
disabledPlaceholder: nil,
|
||||
storyId: nil
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: inputPanelAvailableWidth, height: inputPanelAvailableHeight)
|
||||
|
@ -290,7 +290,8 @@ final class StoryPreviewComponent: Component {
|
||||
isFormattingLocked: false,
|
||||
hideKeyboard: false,
|
||||
forceIsEditing: false,
|
||||
disabledPlaceholder: nil
|
||||
disabledPlaceholder: nil,
|
||||
storyId: nil
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: availableSize.width, height: 200.0)
|
||||
|
@ -50,6 +50,7 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
}
|
||||
|
||||
public let mode: Mode
|
||||
public let storyId: Int32?
|
||||
public let action: (Mode, Action, Bool) -> Void
|
||||
public let longPressAction: ((UIView, ContextGesture?) -> Void)?
|
||||
public let switchMediaInputMode: () -> Void
|
||||
@ -66,6 +67,7 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
|
||||
public init(
|
||||
mode: Mode,
|
||||
storyId: Int32?,
|
||||
action: @escaping (Mode, Action, Bool) -> Void,
|
||||
longPressAction: ((UIView, ContextGesture?) -> Void)?,
|
||||
switchMediaInputMode: @escaping () -> Void,
|
||||
@ -81,6 +83,7 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
videoRecordingStatus: InstantVideoControllerRecordingStatus?
|
||||
) {
|
||||
self.mode = mode
|
||||
self.storyId = storyId
|
||||
self.action = action
|
||||
self.longPressAction = longPressAction
|
||||
self.switchMediaInputMode = switchMediaInputMode
|
||||
@ -100,6 +103,9 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
if lhs.mode != rhs.mode {
|
||||
return false
|
||||
}
|
||||
if lhs.storyId != rhs.storyId {
|
||||
return false
|
||||
}
|
||||
if lhs.context !== rhs.context {
|
||||
return false
|
||||
}
|
||||
@ -125,6 +131,7 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
public let referenceNode: ContextReferenceContentNode
|
||||
public let containerNode: ContextControllerSourceNode
|
||||
private let sendIconView: UIImageView
|
||||
private var reactionHeartView: UIImageView?
|
||||
private var moreButton: MoreHeaderButton?
|
||||
private var reactionIconView: ReactionIconView?
|
||||
|
||||
@ -134,7 +141,11 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
private var acceptNextButtonPress: Bool = false
|
||||
|
||||
public var likeIconView: UIView? {
|
||||
return self.reactionIconView
|
||||
if let reactionHeartView = self.reactionHeartView {
|
||||
return reactionHeartView
|
||||
} else {
|
||||
return self.reactionIconView
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -214,10 +225,12 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
self.component = component
|
||||
self.componentState = state
|
||||
|
||||
let isFirstTimeForStory = previousComponent?.storyId != component.storyId
|
||||
|
||||
let themeUpdated = previousComponent?.theme !== component.theme
|
||||
|
||||
var transition = transition
|
||||
if transition.animation.isImmediate, let previousComponent, case .like = previousComponent.mode, case .like = component.mode, previousComponent.mode != component.mode {
|
||||
if transition.animation.isImmediate, let previousComponent, case .like = previousComponent.mode, case .like = component.mode, previousComponent.mode != component.mode, !isFirstTimeForStory {
|
||||
transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut))
|
||||
}
|
||||
|
||||
@ -408,7 +421,7 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
self.reactionIconView = reactionIconView
|
||||
self.addSubview(reactionIconView)
|
||||
|
||||
if previousComponent != nil {
|
||||
if !isFirstTimeForStory {
|
||||
reactionIconView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
||||
reactionIconView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25)
|
||||
}
|
||||
@ -428,10 +441,56 @@ public final class MessageInputActionButtonComponent: Component {
|
||||
)
|
||||
} else if let reactionIconView = self.reactionIconView {
|
||||
self.reactionIconView = nil
|
||||
reactionIconView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak reactionIconView] _ in
|
||||
reactionIconView?.removeFromSuperview()
|
||||
})
|
||||
reactionIconView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
|
||||
|
||||
if !isFirstTimeForStory {
|
||||
reactionIconView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak reactionIconView] _ in
|
||||
reactionIconView?.removeFromSuperview()
|
||||
})
|
||||
reactionIconView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
|
||||
} else {
|
||||
reactionIconView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
if case let .like(reactionValue, _, _) = component.mode, let reaction = reactionValue, case .builtin("❤") = reaction {
|
||||
self.reactionIconView?.isHidden = true
|
||||
|
||||
var reactionHeartTransition = transition
|
||||
let reactionHeartView: UIImageView
|
||||
if let current = self.reactionHeartView {
|
||||
reactionHeartView = current
|
||||
} else {
|
||||
reactionHeartTransition = reactionHeartTransition.withAnimation(.none)
|
||||
reactionHeartView = UIImageView()
|
||||
self.reactionHeartView = reactionHeartView
|
||||
reactionHeartView.image = PresentationResourcesChat.storyViewListLikeIcon(component.theme)
|
||||
self.addSubview(reactionHeartView)
|
||||
}
|
||||
|
||||
if let image = reactionHeartView.image {
|
||||
let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - image.size.width) * 0.5), y: floorToScreenPixels((availableSize.height - image.size.height) * 0.5)), size: image.size)
|
||||
reactionHeartTransition.setPosition(view: reactionHeartView, position: iconFrame.center)
|
||||
reactionHeartTransition.setBounds(view: reactionHeartView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
|
||||
}
|
||||
|
||||
if !isFirstTimeForStory {
|
||||
reactionHeartView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
||||
reactionHeartView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.25)
|
||||
}
|
||||
} else {
|
||||
self.reactionIconView?.isHidden = false
|
||||
|
||||
if let reactionHeartView = self.reactionHeartView {
|
||||
self.reactionHeartView = nil
|
||||
if !isFirstTimeForStory {
|
||||
reactionHeartView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak reactionHeartView] _ in
|
||||
reactionHeartView?.removeFromSuperview()
|
||||
})
|
||||
reactionHeartView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false)
|
||||
} else {
|
||||
reactionHeartView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transition.setFrame(view: self.button.view, frame: CGRect(origin: .zero, size: availableSize))
|
||||
|
@ -116,6 +116,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
public let hideKeyboard: Bool
|
||||
public let forceIsEditing: Bool
|
||||
public let disabledPlaceholder: String?
|
||||
public let storyId: Int32?
|
||||
|
||||
public init(
|
||||
externalState: ExternalState,
|
||||
@ -163,7 +164,8 @@ public final class MessageInputPanelComponent: Component {
|
||||
isFormattingLocked: Bool,
|
||||
hideKeyboard: Bool,
|
||||
forceIsEditing: Bool,
|
||||
disabledPlaceholder: String?
|
||||
disabledPlaceholder: String?,
|
||||
storyId: Int32?
|
||||
) {
|
||||
self.externalState = externalState
|
||||
self.context = context
|
||||
@ -211,6 +213,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
self.hideKeyboard = hideKeyboard
|
||||
self.forceIsEditing = forceIsEditing
|
||||
self.disabledPlaceholder = disabledPlaceholder
|
||||
self.storyId = storyId
|
||||
}
|
||||
|
||||
public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool {
|
||||
@ -304,6 +307,9 @@ public final class MessageInputPanelComponent: Component {
|
||||
if (lhs.likeOptionsAction == nil) != (rhs.likeOptionsAction == nil) {
|
||||
return false
|
||||
}
|
||||
if lhs.storyId != rhs.storyId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -803,6 +809,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
transition: transition,
|
||||
component: AnyComponent(MessageInputActionButtonComponent(
|
||||
mode: attachmentButtonMode,
|
||||
storyId: component.storyId,
|
||||
action: { [weak self] mode, action, sendAction in
|
||||
guard let self, let component = self.component, case .up = action else {
|
||||
return
|
||||
@ -951,6 +958,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
transition: transition,
|
||||
component: AnyComponent(MessageInputActionButtonComponent(
|
||||
mode: inputActionButtonMode,
|
||||
storyId: component.storyId,
|
||||
action: { [weak self] mode, action, sendAction in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
@ -1087,6 +1095,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
transition: transition,
|
||||
component: AnyComponent(MessageInputActionButtonComponent(
|
||||
mode: .like(reaction: component.myReaction?.reaction, file: component.myReaction?.file, animationFileId: component.myReaction?.animationFileId),
|
||||
storyId: component.storyId,
|
||||
action: { [weak self] _, action, _ in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
|
@ -0,0 +1,21 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "NavigationSearchComponent",
|
||||
module_name = "NavigationSearchComponent",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//submodules/Display",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/AppBundle",
|
||||
"//submodules/Components/BundleIconComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -0,0 +1,292 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import AppBundle
|
||||
import BundleIconComponent
|
||||
|
||||
public final class NavigationSearchComponent: Component {
|
||||
public struct Colors: Equatable {
|
||||
public var background: UIColor
|
||||
public var inactiveForeground: UIColor
|
||||
public var foreground: UIColor
|
||||
public var button: UIColor
|
||||
|
||||
public init(
|
||||
background: UIColor,
|
||||
inactiveForeground: UIColor,
|
||||
foreground: UIColor,
|
||||
button: UIColor
|
||||
) {
|
||||
self.background = background
|
||||
self.inactiveForeground = inactiveForeground
|
||||
self.foreground = foreground
|
||||
self.button = button
|
||||
}
|
||||
}
|
||||
|
||||
public let colors: Colors
|
||||
public let placeholder: String
|
||||
public let isSearchActive: Bool
|
||||
public let collapseFraction: CGFloat
|
||||
public let activateSearch: () -> Void
|
||||
public let deactivateSearch: () -> Void
|
||||
public let updateQuery: (String) -> Void
|
||||
|
||||
public init(
|
||||
colors: Colors,
|
||||
placeholder: String,
|
||||
isSearchActive: Bool,
|
||||
collapseFraction: CGFloat,
|
||||
activateSearch: @escaping () -> Void,
|
||||
deactivateSearch: @escaping () -> Void,
|
||||
updateQuery: @escaping (String) -> Void
|
||||
) {
|
||||
self.colors = colors
|
||||
self.placeholder = placeholder
|
||||
self.isSearchActive = isSearchActive
|
||||
self.collapseFraction = collapseFraction
|
||||
self.activateSearch = activateSearch
|
||||
self.deactivateSearch = deactivateSearch
|
||||
self.updateQuery = updateQuery
|
||||
}
|
||||
|
||||
public static func ==(lhs: NavigationSearchComponent, rhs: NavigationSearchComponent) -> Bool {
|
||||
if lhs.colors != rhs.colors {
|
||||
return false
|
||||
}
|
||||
if lhs.placeholder != rhs.placeholder {
|
||||
return false
|
||||
}
|
||||
if lhs.isSearchActive != rhs.isSearchActive {
|
||||
return false
|
||||
}
|
||||
if lhs.collapseFraction != rhs.collapseFraction {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public final class View: UIView, UITextFieldDelegate {
|
||||
private var component: NavigationSearchComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
|
||||
private let backgroundView: UIView
|
||||
private let searchIconView: UIImageView
|
||||
private let placeholderText = ComponentView<Empty>()
|
||||
|
||||
private var button: ComponentView<Empty>?
|
||||
|
||||
private var textField: UITextField?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.backgroundView = UIView()
|
||||
self.backgroundView.layer.cornerRadius = 10.0
|
||||
|
||||
self.searchIconView = UIImageView(image: UIImage(bundleImageName: "Components/Search Bar/Loupe")?.withRenderingMode(.alwaysTemplate))
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubview(self.backgroundView)
|
||||
self.addSubview(self.searchIconView)
|
||||
|
||||
self.backgroundView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.backgroundTapGesture(_:))))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
}
|
||||
|
||||
@objc private func backgroundTapGesture(_ recognizer: UITapGestureRecognizer) {
|
||||
if case .ended = recognizer.state {
|
||||
if self.textField == nil {
|
||||
let textField = UITextField()
|
||||
self.textField = textField
|
||||
textField.delegate = self
|
||||
textField.addTarget(self, action: #selector(self.textChanged), for: .editingChanged)
|
||||
self.addSubview(textField)
|
||||
textField.keyboardAppearance = .dark
|
||||
}
|
||||
|
||||
self.textField?.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
public func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
guard let component = self.component else {
|
||||
return
|
||||
}
|
||||
if !component.isSearchActive {
|
||||
component.activateSearch()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func textChanged() {
|
||||
self.updateText(updateComponent: true)
|
||||
}
|
||||
|
||||
@objc private func updateText(updateComponent: Bool) {
|
||||
let isEmpty = self.textField?.text?.isEmpty ?? true
|
||||
self.placeholderText.view?.isHidden = !isEmpty
|
||||
|
||||
if updateComponent, let component = self.component {
|
||||
component.updateQuery(self.textField?.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
func update(component: NavigationSearchComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
let previousComponent = self.component
|
||||
|
||||
self.component = component
|
||||
self.state = state
|
||||
|
||||
let baseHeight: CGFloat = 52.0
|
||||
let size = CGSize(width: availableSize.width, height: baseHeight)
|
||||
|
||||
let sideInset: CGFloat = 16.0
|
||||
let fieldHeight: CGFloat = 36.0
|
||||
let fieldSideInset: CGFloat = 8.0
|
||||
let searchIconSpacing: CGFloat = 4.0
|
||||
let buttonSpacing: CGFloat = 8.0
|
||||
|
||||
let rightInset: CGFloat
|
||||
if component.isSearchActive {
|
||||
var buttonTransition = transition
|
||||
let button: ComponentView<Empty>
|
||||
if let current = self.button {
|
||||
button = current
|
||||
} else {
|
||||
buttonTransition = buttonTransition.withAnimation(.none)
|
||||
button = ComponentView()
|
||||
self.button = button
|
||||
}
|
||||
|
||||
//TODO:localize
|
||||
let buttonSize = button.update(
|
||||
transition: buttonTransition,
|
||||
component: AnyComponent(Button(
|
||||
content: AnyComponent(Text(text: "Cancel", font: Font.regular(17.0), color: component.colors.button)),
|
||||
action: { [weak self] in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.deactivateSearch()
|
||||
}
|
||||
).minSize(CGSize(width: 8.0, height: baseHeight))),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 200.0, height: 100.0)
|
||||
)
|
||||
let buttonFrame = CGRect(origin: CGPoint(x: size.width - sideInset - buttonSize.width, y: floor((size.height - buttonSize.height) * 0.5)), size: buttonSize)
|
||||
if let buttonView = button.view {
|
||||
var animateIn = false
|
||||
if buttonView.superview == nil {
|
||||
animateIn = true
|
||||
self.addSubview(buttonView)
|
||||
}
|
||||
buttonTransition.setFrame(view: buttonView, frame: buttonFrame)
|
||||
if animateIn {
|
||||
transition.animatePosition(view: buttonView, from: CGPoint(x: size.width - buttonFrame.minX, y: 0.0), to: CGPoint(), additive: true)
|
||||
}
|
||||
}
|
||||
|
||||
rightInset = sideInset + buttonSize.width + buttonSpacing
|
||||
} else {
|
||||
if let button = self.button {
|
||||
self.button = nil
|
||||
|
||||
if let buttonView = button.view {
|
||||
transition.setFrame(view: buttonView, frame: CGRect(origin: CGPoint(x: size.width, y: buttonView.frame.minY), size: buttonView.bounds.size))
|
||||
}
|
||||
}
|
||||
|
||||
rightInset = sideInset
|
||||
}
|
||||
|
||||
let backgroundFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - fieldHeight) * 0.5)), size: CGSize(width: availableSize.width - sideInset - rightInset, height: fieldHeight))
|
||||
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
|
||||
if previousComponent?.colors.background != component.colors.background {
|
||||
self.backgroundView.backgroundColor = component.colors.background
|
||||
}
|
||||
if previousComponent?.colors.inactiveForeground != component.colors.inactiveForeground {
|
||||
self.searchIconView.tintColor = component.colors.inactiveForeground
|
||||
}
|
||||
|
||||
let placeholderSize = self.placeholderText.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: component.colors.inactiveForeground)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: backgroundFrame.width - fieldSideInset * 2.0, height: backgroundFrame.height)
|
||||
)
|
||||
|
||||
let searchIconSize = self.searchIconView.image?.size ?? CGSize(width: 20.0, height: 20.0)
|
||||
//let searchPlaceholderCombinedWidth = searchIconSize.width + searchIconSpacing + placeholderSize.width
|
||||
|
||||
let placeholderTextFrame = CGRect(origin: CGPoint(x: component.isSearchActive ? (backgroundFrame.minX + fieldSideInset + searchIconSize.width + searchIconSpacing) : floor(backgroundFrame.midX - placeholderSize.width * 0.5), y: backgroundFrame.minY + floor((fieldHeight - placeholderSize.height) * 0.5)), size: placeholderSize)
|
||||
var placeholderDeltaX: CGFloat = 0.0
|
||||
if let placeholderTextView = self.placeholderText.view {
|
||||
if placeholderTextView.superview == nil {
|
||||
placeholderTextView.layer.anchorPoint = CGPoint()
|
||||
placeholderTextView.isUserInteractionEnabled = false
|
||||
self.insertSubview(placeholderTextView, aboveSubview: self.searchIconView)
|
||||
} else {
|
||||
placeholderDeltaX = placeholderTextFrame.minX - placeholderTextView.frame.minX
|
||||
}
|
||||
transition.setPosition(view: placeholderTextView, position: placeholderTextFrame.origin)
|
||||
transition.setBounds(view: placeholderTextView, bounds: CGRect(origin: CGPoint(), size: placeholderTextFrame.size))
|
||||
}
|
||||
|
||||
let searchIconFrame = CGRect(origin: CGPoint(x: placeholderTextFrame.minX - searchIconSpacing - searchIconSize.width, y: backgroundFrame.minY + floor((fieldHeight - searchIconSize.height) * 0.5)), size: searchIconSize)
|
||||
transition.setFrame(view: self.searchIconView, frame: searchIconFrame)
|
||||
|
||||
if let textField = self.textField {
|
||||
var textFieldTransition = transition
|
||||
var animateIn = false
|
||||
if textField.bounds.isEmpty {
|
||||
textFieldTransition = textFieldTransition.withAnimation(.none)
|
||||
animateIn = true
|
||||
}
|
||||
|
||||
if textField.textColor != component.colors.foreground {
|
||||
textField.textColor = component.colors.foreground
|
||||
textField.font = Font.regular(17.0)
|
||||
}
|
||||
|
||||
let textLeftInset: CGFloat = fieldSideInset + searchIconSize.width + searchIconSpacing
|
||||
let textRightInset: CGFloat = 8.0
|
||||
textFieldTransition.setFrame(view: textField, frame: CGRect(origin: CGPoint(x: placeholderTextFrame.minX, y: backgroundFrame.minY - 1.0), size: CGSize(width: backgroundFrame.width - textLeftInset - textRightInset, height: backgroundFrame.height)))
|
||||
|
||||
if animateIn {
|
||||
transition.animatePosition(view: textField, from: CGPoint(x: -placeholderDeltaX, y: 0.0), to: CGPoint(), additive: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let textField = self.textField {
|
||||
if !component.isSearchActive {
|
||||
if !(textField.text?.isEmpty ?? true) {
|
||||
textField.text = ""
|
||||
self.updateText(updateComponent: false)
|
||||
}
|
||||
|
||||
if textField.isFirstResponder {
|
||||
DispatchQueue.main.async { [weak textField] in
|
||||
textField?.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
public func makeView() -> View {
|
||||
return View(frame: CGRect())
|
||||
}
|
||||
|
||||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||
}
|
||||
}
|
20
submodules/TelegramUI/Components/OptionButtonComponent/BUILD
Normal file
20
submodules/TelegramUI/Components/OptionButtonComponent/BUILD
Normal file
@ -0,0 +1,20 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "OptionButtonComponent",
|
||||
module_name = "OptionButtonComponent",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//submodules/Display",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/AppBundle",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import AppBundle
|
||||
|
||||
public final class OptionButtonComponent: Component {
|
||||
public struct Colors: Equatable {
|
||||
public var background: UIColor
|
||||
public var foreground: UIColor
|
||||
|
||||
public init(
|
||||
background: UIColor,
|
||||
foreground: UIColor
|
||||
) {
|
||||
self.background = background
|
||||
self.foreground = foreground
|
||||
}
|
||||
}
|
||||
|
||||
public let colors: Colors
|
||||
public let icon: String
|
||||
public let action: () -> Void
|
||||
|
||||
public init(
|
||||
colors: Colors,
|
||||
icon: String,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.colors = colors
|
||||
self.icon = icon
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public static func ==(lhs: OptionButtonComponent, rhs: OptionButtonComponent) -> Bool {
|
||||
if lhs.colors != rhs.colors {
|
||||
return false
|
||||
}
|
||||
if lhs.icon != rhs.icon {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public final class View: HighlightTrackingButton {
|
||||
private var component: OptionButtonComponent?
|
||||
|
||||
private let backgroundView: UIImageView
|
||||
private let iconView: UIImageView
|
||||
private let arrowView: UIImageView
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.backgroundView = UIImageView()
|
||||
self.iconView = UIImageView()
|
||||
self.arrowView = UIImageView()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubview(self.backgroundView)
|
||||
self.addSubview(self.iconView)
|
||||
self.addSubview(self.arrowView)
|
||||
|
||||
self.highligthedChanged = { [weak self] highlighed in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
let transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut))
|
||||
let scale: CGFloat = highlighed ? 0.8 : 1.0
|
||||
transition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(scale, scale, 1.0))
|
||||
}
|
||||
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
}
|
||||
|
||||
@objc private func pressed() {
|
||||
guard let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.action()
|
||||
}
|
||||
|
||||
func update(component: OptionButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
let previousComponent = self.component
|
||||
|
||||
self.component = component
|
||||
|
||||
let size = CGSize(width: 52.0, height: 28.0)
|
||||
|
||||
if previousComponent?.colors.background != component.colors.background {
|
||||
self.backgroundView.image = generateStretchableFilledCircleImage(diameter: size.height, color: component.colors.background)
|
||||
}
|
||||
if previousComponent?.icon != component.icon {
|
||||
if previousComponent != nil, let previousImage = self.iconView.image {
|
||||
let tempView = UIImageView(image: previousImage)
|
||||
tempView.tintColor = component.colors.foreground
|
||||
tempView.frame = self.iconView.frame
|
||||
self.insertSubview(tempView, belowSubview: self.iconView)
|
||||
|
||||
tempView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak tempView] _ in
|
||||
tempView?.removeFromSuperview()
|
||||
})
|
||||
tempView.layer.animateScale(from: 1.0, to: 0.2, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
|
||||
|
||||
self.iconView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
||||
self.iconView.layer.animateScale(from: 0.2, to: 1.0, duration: 0.3)
|
||||
}
|
||||
self.iconView.image = UIImage(bundleImageName: component.icon)?.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
if previousComponent == nil {
|
||||
self.arrowView.image = UIImage(bundleImageName: "Stories/SelectorArrowDown")?.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
if previousComponent?.colors.foreground != component.colors.foreground {
|
||||
self.iconView.tintColor = component.colors.foreground
|
||||
self.arrowView.tintColor = component.colors.foreground
|
||||
}
|
||||
|
||||
if let iconSize = self.iconView.image?.size, let arrowSize = self.arrowView.image?.size {
|
||||
transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: 3.0, y: floor((size.height - iconSize.height) * 0.5)), size: iconSize))
|
||||
transition.setFrame(view: self.arrowView, frame: CGRect(origin: CGPoint(x: size.width - 8.0 - arrowSize.width, y: floor((size.height - arrowSize.height) * 0.5)), size: arrowSize))
|
||||
}
|
||||
|
||||
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
public func makeView() -> View {
|
||||
return View(frame: CGRect())
|
||||
}
|
||||
|
||||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||
}
|
||||
}
|
@ -196,6 +196,7 @@ public final class PeerListItemComponent: Component {
|
||||
private var checkLayer: CheckLayer?
|
||||
|
||||
private var reactionLayer: InlineStickerItemLayer?
|
||||
private var heartReactionIcon: UIImageView?
|
||||
private var iconFrame: CGRect?
|
||||
private var file: TelegramMediaFile?
|
||||
private var fileDisposable: Disposable?
|
||||
@ -668,24 +669,50 @@ public final class PeerListItemComponent: Component {
|
||||
self.iconFrame = CGRect(origin: CGPoint(x: availableSize.width - (contextInset * 2.0 + 14.0 + component.sideInset) - imageSize.width, y: floor((height - verticalInset * 2.0 - imageSize.height) * 0.5)), size: imageSize)
|
||||
|
||||
if previousComponent?.reaction != component.reaction {
|
||||
if let reaction = component.reaction {
|
||||
switch reaction.reaction {
|
||||
case .builtin:
|
||||
self.file = reaction.file
|
||||
self.updateReactionLayer()
|
||||
case let .custom(fileId):
|
||||
self.fileDisposable = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|
||||
|> deliverOnMainQueue).start(next: { [weak self] files in
|
||||
guard let self, let file = files[fileId] else {
|
||||
return
|
||||
}
|
||||
self.file = file
|
||||
self.updateReactionLayer()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if let reaction = component.reaction, case .builtin("❤") = reaction.reaction {
|
||||
self.file = nil
|
||||
self.updateReactionLayer()
|
||||
|
||||
var reactionTransition = transition
|
||||
let heartReactionIcon: UIImageView
|
||||
if let current = self.heartReactionIcon {
|
||||
heartReactionIcon = current
|
||||
} else {
|
||||
reactionTransition = reactionTransition.withAnimation(.none)
|
||||
heartReactionIcon = UIImageView()
|
||||
self.heartReactionIcon = heartReactionIcon
|
||||
self.containerButton.addSubview(heartReactionIcon)
|
||||
heartReactionIcon.image = PresentationResourcesChat.storyViewListLikeIcon(component.theme)
|
||||
}
|
||||
|
||||
if let image = heartReactionIcon.image, let iconFrame = self.iconFrame {
|
||||
reactionTransition.setFrame(view: heartReactionIcon, frame: image.size.centered(around: iconFrame.center))
|
||||
}
|
||||
} else {
|
||||
if let heartReactionIcon = self.heartReactionIcon {
|
||||
self.heartReactionIcon = nil
|
||||
heartReactionIcon.removeFromSuperview()
|
||||
}
|
||||
|
||||
if let reaction = component.reaction {
|
||||
switch reaction.reaction {
|
||||
case .builtin:
|
||||
self.file = reaction.file
|
||||
self.updateReactionLayer()
|
||||
case let .custom(fileId):
|
||||
self.fileDisposable = (component.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|
||||
|> deliverOnMainQueue).start(next: { [weak self] files in
|
||||
guard let self, let file = files[fileId] else {
|
||||
return
|
||||
}
|
||||
self.file = file
|
||||
self.updateReactionLayer()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
self.file = nil
|
||||
self.updateReactionLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,6 +86,9 @@ swift_library(
|
||||
"//submodules/TelegramNotices",
|
||||
"//submodules/MediaPlayer:UniversalMediaPlayer",
|
||||
"//submodules/TelegramUniversalVideoContent",
|
||||
"//submodules/TelegramUI/Components/NavigationSearchComponent",
|
||||
"//submodules/TelegramUI/Components/TabSelectorComponent",
|
||||
"//submodules/TelegramUI/Components/OptionButtonComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -343,7 +343,6 @@ private final class StoryContainerScreenComponent: Component {
|
||||
|
||||
private var itemSetPinchState: StoryItemSetContainerComponent.PinchState?
|
||||
private var itemSetPanState: ItemSetPanState?
|
||||
private var verticalPanState: ItemSetPanState?
|
||||
private var isHoldingTouch: Bool = false
|
||||
|
||||
private var transitionCloneMasterView: UIView
|
||||
@ -377,6 +376,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
|
||||
private var isAnimatingOut: Bool = false
|
||||
private var didAnimateOut: Bool = false
|
||||
private var isDismissedExlusively: Bool = false
|
||||
|
||||
var dismissWithoutTransitionOut: Bool = false
|
||||
|
||||
@ -422,7 +422,8 @@ private final class StoryContainerScreenComponent: Component {
|
||||
})
|
||||
self.addGestureRecognizer(horizontalPanRecognizer)
|
||||
|
||||
let verticalPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.dismissPanGesture(_:)), allowedDirections: { [weak self] point in
|
||||
//TODO:move dismiss pan
|
||||
/*let verticalPanRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.dismissPanGesture(_:)), allowedDirections: { [weak self] point in
|
||||
guard let self, let component = self.component, let stateValue = component.content.stateValue, let slice = stateValue.slice, let itemSetView = self.visibleItemSetViews[slice.peer.id], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View else {
|
||||
return []
|
||||
}
|
||||
@ -438,7 +439,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
|
||||
return [.down]
|
||||
})
|
||||
self.addGestureRecognizer(verticalPanRecognizer)
|
||||
self.addGestureRecognizer(verticalPanRecognizer)*/
|
||||
|
||||
let longPressRecognizer = StoryLongPressRecognizer(target: self, action: #selector(self.longPressGesture(_:)))
|
||||
longPressRecognizer.delegate = self
|
||||
@ -755,7 +756,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func dismissPanGesture(_ recognizer: UIPanGestureRecognizer) {
|
||||
/*@objc private func dismissPanGesture(_ recognizer: UIPanGestureRecognizer) {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
self.dismissAllTooltips()
|
||||
@ -821,7 +822,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@objc private func longPressGesture(_ recognizer: StoryLongPressRecognizer) {
|
||||
switch recognizer.state {
|
||||
@ -975,7 +976,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut))
|
||||
}
|
||||
|
||||
self.verticalPanState = ItemSetPanState(fraction: 1.0, didBegin: true)
|
||||
self.isDismissedExlusively = true
|
||||
self.state?.updated(transition: transition)
|
||||
|
||||
let focusedItemPromise = self.component?.focusedItemPromise
|
||||
@ -1267,9 +1268,6 @@ private final class StoryContainerScreenComponent: Component {
|
||||
if self.itemSetPanState != nil {
|
||||
isProgressPaused = true
|
||||
}
|
||||
if self.verticalPanState != nil {
|
||||
isProgressPaused = true
|
||||
}
|
||||
if self.isAnimatingOut {
|
||||
isProgressPaused = true
|
||||
}
|
||||
@ -1283,21 +1281,6 @@ private final class StoryContainerScreenComponent: Component {
|
||||
isProgressPaused = true
|
||||
}
|
||||
|
||||
var dismissPanOffset: CGFloat = 0.0
|
||||
var dismissPanScale: CGFloat = 1.0
|
||||
var dismissAlphaScale: CGFloat = 1.0
|
||||
var verticalPanFraction: CGFloat = 0.0
|
||||
if let verticalPanState = self.verticalPanState {
|
||||
let dismissFraction = max(0.0, verticalPanState.fraction)
|
||||
verticalPanFraction = max(0.0, min(1.0, -verticalPanState.fraction))
|
||||
|
||||
dismissPanOffset = dismissFraction * availableSize.height
|
||||
dismissPanScale = 1.0 * (1.0 - dismissFraction) + 0.6 * dismissFraction
|
||||
dismissAlphaScale = 1.0 * (1.0 - dismissFraction) + 0.2 * dismissFraction
|
||||
}
|
||||
|
||||
transition.setAlpha(layer: self.backgroundLayer, alpha: max(0.5, dismissAlphaScale))
|
||||
|
||||
var contentDerivedBottomInset: CGFloat = environment.safeInsets.bottom
|
||||
|
||||
var validIds: [AnyHashable] = []
|
||||
@ -1317,6 +1300,13 @@ private final class StoryContainerScreenComponent: Component {
|
||||
}
|
||||
}
|
||||
|
||||
var dismissPanOffset: CGFloat = 0.0
|
||||
if self.isDismissedExlusively {
|
||||
dismissPanOffset = availableSize.height
|
||||
}
|
||||
|
||||
var centerDismissFraction: CGFloat = 0.0
|
||||
|
||||
var presentationContextInsets = UIEdgeInsets()
|
||||
if !currentSlices.isEmpty, let focusedIndex {
|
||||
for i in max(0, focusedIndex - 1) ... min(focusedIndex + 1, currentSlices.count - 1) {
|
||||
@ -1383,6 +1373,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
presentationContextInsets.bottom = itemSetContainerInsets.bottom
|
||||
}
|
||||
|
||||
itemSetView.view.parentState = self.state
|
||||
let _ = itemSetView.view.update(
|
||||
transition: itemSetTransition,
|
||||
component: AnyComponent(StoryItemSetContainerComponent(
|
||||
@ -1405,7 +1396,6 @@ private final class StoryContainerScreenComponent: Component {
|
||||
hideUI: (i == focusedIndex && (self.itemSetPanState?.didBegin == false || self.itemSetPinchState != nil)),
|
||||
visibilityFraction: 1.0 - abs(panFraction + cubeAdditionalRotationFraction),
|
||||
isPanning: self.itemSetPanState?.didBegin == true,
|
||||
verticalPanFraction: verticalPanFraction,
|
||||
pinchState: self.itemSetPinchState,
|
||||
presentController: { [weak self] c, a in
|
||||
guard let self, let environment = self.environment else {
|
||||
@ -1519,6 +1509,7 @@ private final class StoryContainerScreenComponent: Component {
|
||||
|
||||
if i == focusedIndex {
|
||||
contentDerivedBottomInset = itemSetView.externalState.derivedBottomInset
|
||||
centerDismissFraction = itemSetView.externalState.dismissFraction
|
||||
}
|
||||
|
||||
let itemFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - itemSetContainerSize.width) / 2.0), y: floorToScreenPixels((availableSize.height - itemSetContainerSize.height) / 2.0)), size: itemSetContainerSize)
|
||||
@ -1537,11 +1528,9 @@ private final class StoryContainerScreenComponent: Component {
|
||||
|
||||
itemSetTransition.setPosition(view: itemSetView, position: itemFrame.center.offsetBy(dx: 0.0, dy: dismissPanOffset))
|
||||
itemSetTransition.setBounds(view: itemSetView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
|
||||
itemSetTransition.setSublayerTransform(view: itemSetView, transform: CATransform3DMakeScale(dismissPanScale, dismissPanScale, 1.0))
|
||||
|
||||
itemSetTransition.setPosition(view: itemSetComponentView.transitionCloneContainerView, position: itemFrame.center.offsetBy(dx: 0.0, dy: dismissPanOffset))
|
||||
itemSetTransition.setBounds(view: itemSetComponentView.transitionCloneContainerView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
|
||||
itemSetTransition.setSublayerTransform(view: itemSetComponentView.transitionCloneContainerView, transform: CATransform3DMakeScale(dismissPanScale, dismissPanScale, 1.0))
|
||||
|
||||
itemSetTransition.setPosition(view: itemSetComponentView, position: CGRect(origin: CGPoint(), size: itemFrame.size).center)
|
||||
itemSetTransition.setBounds(view: itemSetComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
|
||||
@ -1669,6 +1658,9 @@ private final class StoryContainerScreenComponent: Component {
|
||||
self.visibleItemSetViews.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
let dismissAlphaScale = 1.0 * (1.0 - centerDismissFraction) + 0.2 * centerDismissFraction
|
||||
transition.setAlpha(layer: self.backgroundLayer, alpha: max(0.5, min(1.0, dismissAlphaScale)))
|
||||
|
||||
if let controller = environment.controller() {
|
||||
let subLayout = ContainerViewLayout(
|
||||
size: availableSize,
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@ public final class StoryFooterPanelComponent: Component {
|
||||
|
||||
public let context: AccountContext
|
||||
public let strings: PresentationStrings
|
||||
public let storyItem: EngineStoryItem?
|
||||
public let storyItem: EngineStoryItem
|
||||
public let externalViews: EngineStoryItem.Views?
|
||||
public let expandFraction: CGFloat
|
||||
public let expandViewStats: () -> Void
|
||||
@ -34,7 +34,7 @@ public final class StoryFooterPanelComponent: Component {
|
||||
public init(
|
||||
context: AccountContext,
|
||||
strings: PresentationStrings,
|
||||
storyItem: EngineStoryItem?,
|
||||
storyItem: EngineStoryItem,
|
||||
externalViews: EngineStoryItem.Views?,
|
||||
expandFraction: CGFloat,
|
||||
expandViewStats: @escaping () -> Void,
|
||||
@ -72,8 +72,8 @@ public final class StoryFooterPanelComponent: Component {
|
||||
|
||||
public final class View: UIView {
|
||||
private let viewStatsButton: HighlightTrackingButton
|
||||
private let viewStatsText: AnimatedCountLabelView
|
||||
private let viewStatsExpandedText: AnimatedCountLabelView
|
||||
private let viewStatsCountText: AnimatedCountLabelView
|
||||
private let viewStatsLabelText = ComponentView<Empty>()
|
||||
private let deleteButton = ComponentView<Empty>()
|
||||
|
||||
private var reactionStatsIcon: UIImageView?
|
||||
@ -83,6 +83,8 @@ public final class StoryFooterPanelComponent: Component {
|
||||
private var statusNode: SemanticStatusNode?
|
||||
private var uploadingText: ComponentView<Empty>?
|
||||
|
||||
private let viewsIconView: UIImageView
|
||||
|
||||
private let avatarsContext: AnimatedAvatarSetContext
|
||||
private let avatarsView: AnimatedAvatarSetView
|
||||
|
||||
@ -96,8 +98,9 @@ public final class StoryFooterPanelComponent: Component {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.viewStatsButton = HighlightTrackingButton()
|
||||
self.viewStatsText = AnimatedCountLabelView(frame: CGRect())
|
||||
self.viewStatsExpandedText = AnimatedCountLabelView(frame: CGRect())
|
||||
self.viewStatsCountText = AnimatedCountLabelView(frame: CGRect())
|
||||
|
||||
self.viewsIconView = UIImageView()
|
||||
|
||||
self.avatarsContext = AnimatedAvatarSetContext()
|
||||
self.avatarsView = AnimatedAvatarSetView()
|
||||
@ -106,8 +109,12 @@ public final class StoryFooterPanelComponent: Component {
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.viewsIconView.image = UIImage(bundleImageName: "Stories/EmbeddedViewIcon")
|
||||
self.externalContainerView.addSubview(self.viewsIconView)
|
||||
|
||||
self.avatarsView.isUserInteractionEnabled = false
|
||||
self.externalContainerView.addSubview(self.avatarsView)
|
||||
self.addSubview(self.externalContainerView)
|
||||
self.addSubview(self.viewStatsButton)
|
||||
|
||||
self.viewStatsButton.highligthedChanged = { [weak self] highlighted in
|
||||
@ -116,12 +123,20 @@ public final class StoryFooterPanelComponent: Component {
|
||||
}
|
||||
if highlighted {
|
||||
self.avatarsView.alpha = 0.7
|
||||
self.viewStatsText.alpha = 0.7
|
||||
self.viewStatsCountText.alpha = 0.7
|
||||
self.viewStatsLabelText.view?.alpha = 0.7
|
||||
self.reactionStatsIcon?.alpha = 0.7
|
||||
self.reactionStatsText?.alpha = 0.7
|
||||
} else {
|
||||
self.avatarsView.alpha = 1.0
|
||||
self.viewStatsCountText.alpha = 1.0
|
||||
self.viewStatsLabelText.view?.alpha = 1.0
|
||||
self.reactionStatsIcon?.alpha = 1.0
|
||||
self.reactionStatsText?.alpha = 1.0
|
||||
|
||||
self.avatarsView.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
self.viewStatsText.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
self.viewStatsCountText.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
self.viewStatsLabelText.view?.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
self.reactionStatsIcon?.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
self.reactionStatsText?.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
}
|
||||
@ -148,27 +163,26 @@ public final class StoryFooterPanelComponent: Component {
|
||||
guard let component = self.component else {
|
||||
return
|
||||
}
|
||||
guard let storyItem = component.storyItem else {
|
||||
return
|
||||
}
|
||||
component.context.engine.messages.cancelStoryUpload(stableId: storyItem.id)
|
||||
component.context.engine.messages.cancelStoryUpload(stableId: component.storyItem.id)
|
||||
}
|
||||
|
||||
func update(component: StoryFooterPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
let isFirstTime = self.component == nil
|
||||
|
||||
self.isUserInteractionEnabled = component.expandFraction == 0.0
|
||||
|
||||
var synchronousLoad = true
|
||||
if let hint = transition.userData(AnimationHint.self) {
|
||||
synchronousLoad = hint.synchronousLoad
|
||||
}
|
||||
|
||||
if self.component?.storyItem?.id != component.storyItem?.id || self.component?.storyItem?.isPending != component.storyItem?.isPending {
|
||||
if self.component?.storyItem.id != component.storyItem.id || self.component?.storyItem.isPending != component.storyItem.isPending {
|
||||
self.uploadProgressDisposable?.dispose()
|
||||
self.uploadProgress = 0.0
|
||||
|
||||
if let storyItem = component.storyItem, storyItem.isPending {
|
||||
if component.storyItem.isPending {
|
||||
var applyState = false
|
||||
self.uploadProgressDisposable = (component.context.engine.messages.storyUploadProgress(stableId: storyItem.id)
|
||||
self.uploadProgressDisposable = (component.context.engine.messages.storyUploadProgress(stableId: component.storyItem.id)
|
||||
|> deliverOnMainQueue).start(next: { [weak self] progress in
|
||||
guard let self else {
|
||||
return
|
||||
@ -188,13 +202,12 @@ public final class StoryFooterPanelComponent: Component {
|
||||
let baseHeight: CGFloat = 44.0
|
||||
let size = CGSize(width: availableSize.width, height: baseHeight)
|
||||
|
||||
var leftOffset: CGFloat = 16.0
|
||||
|
||||
let avatarSpacing: CGFloat = 18.0
|
||||
let sideContentMaxFraction: CGFloat = 0.2
|
||||
let sideContentFraction = min(component.expandFraction, sideContentMaxFraction) / sideContentMaxFraction
|
||||
|
||||
let avatarsAlpha: CGFloat
|
||||
let baseViewCountAlpha: CGFloat
|
||||
if let storyItem = component.storyItem, storyItem.isPending {
|
||||
if component.storyItem.isPending {
|
||||
baseViewCountAlpha = 0.0
|
||||
|
||||
let statusButton: HighlightableButton
|
||||
@ -247,8 +260,11 @@ public final class StoryFooterPanelComponent: Component {
|
||||
}
|
||||
innerLeftOffset += uploadingTextSize.width + 8.0
|
||||
|
||||
transition.setFrame(view: statusButton, frame: CGRect(origin: CGPoint(x: leftOffset, y: 0.0), size: CGSize(width: innerLeftOffset, height: size.height)))
|
||||
leftOffset += innerLeftOffset
|
||||
var statusButtonFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: CGSize(width: innerLeftOffset, height: size.height))
|
||||
statusButtonFrame.origin.y += component.expandFraction * 45.0
|
||||
transition.setFrame(view: statusButton, frame: statusButtonFrame)
|
||||
|
||||
transition.setAlpha(view: statusButton, alpha: 1.0 - sideContentFraction)
|
||||
|
||||
avatarsAlpha = 0.0
|
||||
} else {
|
||||
@ -269,43 +285,29 @@ public final class StoryFooterPanelComponent: Component {
|
||||
baseViewCountAlpha = 1.0
|
||||
}
|
||||
|
||||
//TODO:upload
|
||||
let _ = baseViewCountAlpha
|
||||
|
||||
var peers: [EnginePeer] = []
|
||||
if let seenPeers = component.externalViews?.seenPeers ?? component.storyItem?.views?.seenPeers {
|
||||
if let seenPeers = component.externalViews?.seenPeers ?? component.storyItem.views?.seenPeers {
|
||||
peers = Array(seenPeers.prefix(3))
|
||||
}
|
||||
let avatarsContent = self.avatarsContext.update(peers: peers, animated: false)
|
||||
let avatarsSize = self.avatarsView.update(context: component.context, content: avatarsContent, itemSize: CGSize(width: 30.0, height: 30.0), animation: isFirstTime ? ListViewItemUpdateAnimation.None : ListViewItemUpdateAnimation.System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .easeInOut, interactive: false)), synchronousLoad: synchronousLoad)
|
||||
|
||||
let avatarsNodeFrame = CGRect(origin: CGPoint(x: leftOffset, y: floor((size.height - avatarsSize.height) * 0.5)), size: avatarsSize)
|
||||
self.avatarsView.center = avatarsNodeFrame.center
|
||||
self.avatarsView.bounds = CGRect(origin: CGPoint(), size: avatarsNodeFrame.size)
|
||||
transition.setAlpha(view: self.avatarsView, alpha: avatarsAlpha)
|
||||
if !avatarsSize.width.isZero {
|
||||
leftOffset = avatarsNodeFrame.maxX + avatarSpacing
|
||||
}
|
||||
|
||||
var viewCount = 0
|
||||
var reactionCount = 0
|
||||
if let views = component.externalViews ?? component.storyItem?.views, views.seenCount != 0 {
|
||||
if let views = component.externalViews ?? component.storyItem.views, views.seenCount != 0 {
|
||||
viewCount = views.seenCount
|
||||
reactionCount = views.reactedCount
|
||||
}
|
||||
|
||||
let viewsText: String
|
||||
if viewCount == 0 {
|
||||
viewsText = component.strings.Story_Footer_NoViews
|
||||
} else {
|
||||
viewsText = component.strings.Story_Footer_Views(Int32(viewCount))
|
||||
}
|
||||
let _ = viewsText
|
||||
|
||||
self.viewStatsButton.isEnabled = viewCount != 0
|
||||
|
||||
//TODO:localize
|
||||
var regularSegments: [AnimatedCountLabelView.Segment] = []
|
||||
if viewCount != 0 {
|
||||
regularSegments.append(.number(viewCount, NSAttributedString(string: "\(viewCount)", font: Font.regular(15.0), textColor: .white)))
|
||||
}
|
||||
regularSegments.append(.number(viewCount, NSAttributedString(string: "\(viewCount)", font: Font.regular(15.0), textColor: .white)))
|
||||
|
||||
let viewPart: String
|
||||
if viewCount == 0 {
|
||||
viewPart = "No Views"
|
||||
@ -314,55 +316,22 @@ public final class StoryFooterPanelComponent: Component {
|
||||
} else {
|
||||
viewPart = " Views"
|
||||
}
|
||||
regularSegments.append(.text(1, NSAttributedString(string: viewPart, font: Font.regular(15.0), textColor: .white)))
|
||||
|
||||
var expandedSegments: [AnimatedCountLabelView.Segment] = []
|
||||
if viewCount != 0 {
|
||||
expandedSegments.append(.number(viewCount, NSAttributedString(string: "\(viewCount)", font: Font.semibold(17.0), textColor: .white)))
|
||||
}
|
||||
expandedSegments.append(.text(1, NSAttributedString(string: viewPart, font: Font.semibold(17.0), textColor: .white)))
|
||||
|
||||
let viewStatsTextLayout = self.viewStatsText.update(size: CGSize(width: availableSize.width, height: size.height), segments: regularSegments, transition: isFirstTime ? .immediate : ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut))
|
||||
let expandedViewStatsTextLayout = self.viewStatsExpandedText.update(size: CGSize(width: availableSize.width, height: size.height), segments: expandedSegments, transition: isFirstTime ? .immediate : ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut))
|
||||
|
||||
let viewStatsTextSize = viewStatsTextLayout.size
|
||||
let viewStatsExpandedTextSize = expandedViewStatsTextLayout.size
|
||||
|
||||
let viewStatsCollapsedFrame = CGRect(origin: CGPoint(x: leftOffset, y: floor((size.height - viewStatsTextSize.height) * 0.5)), size: viewStatsTextSize)
|
||||
let viewStatsExpandedFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - viewStatsExpandedTextSize.width) * 0.5), y: 3.0 + floor((size.height - viewStatsExpandedTextSize.height) * 0.5)), size: viewStatsExpandedTextSize)
|
||||
let viewStatsCurrentFrame = viewStatsCollapsedFrame.interpolate(to: viewStatsExpandedFrame, amount: component.expandFraction)
|
||||
|
||||
let viewStatsTextCenter = viewStatsCollapsedFrame.center.interpolate(to: viewStatsExpandedFrame.center, amount: component.expandFraction)
|
||||
|
||||
let viewStatsTextFrame = viewStatsCollapsedFrame.size.centered(around: viewStatsTextCenter)
|
||||
do {
|
||||
let viewStatsTextView = self.viewStatsText
|
||||
|
||||
if viewStatsTextView.superview == nil {
|
||||
viewStatsTextView.isUserInteractionEnabled = false
|
||||
self.externalContainerView.addSubview(viewStatsTextView)
|
||||
}
|
||||
transition.setPosition(view: viewStatsTextView, position: viewStatsTextFrame.center)
|
||||
transition.setBounds(view: viewStatsTextView, bounds: CGRect(origin: CGPoint(), size: viewStatsTextFrame.size))
|
||||
transition.setAlpha(view: viewStatsTextView, alpha: pow(1.0 - component.expandFraction, 1.2) * baseViewCountAlpha)
|
||||
transition.setScale(view: viewStatsTextView, scale: viewStatsCurrentFrame.width / viewStatsTextFrame.width)
|
||||
let viewStatsTextLayout = self.viewStatsCountText.update(size: CGSize(width: availableSize.width, height: size.height), segments: regularSegments, transition: isFirstTime ? .immediate : ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut))
|
||||
if self.viewStatsCountText.superview == nil {
|
||||
self.viewStatsCountText.isUserInteractionEnabled = false
|
||||
self.externalContainerView.addSubview(self.viewStatsCountText)
|
||||
}
|
||||
|
||||
let viewStatsExpandedTextFrame = viewStatsExpandedFrame.size.centered(around: viewStatsTextCenter)
|
||||
do {
|
||||
let viewStatsExpandedTextView = self.viewStatsExpandedText
|
||||
|
||||
if viewStatsExpandedTextView.superview == nil {
|
||||
viewStatsExpandedTextView.isUserInteractionEnabled = false
|
||||
self.addSubview(viewStatsExpandedTextView)
|
||||
}
|
||||
transition.setPosition(view: viewStatsExpandedTextView, position: viewStatsExpandedTextFrame.center)
|
||||
transition.setBounds(view: viewStatsExpandedTextView, bounds: CGRect(origin: CGPoint(), size: viewStatsExpandedTextFrame.size))
|
||||
transition.setAlpha(view: viewStatsExpandedTextView, alpha: pow(component.expandFraction, 1.2) * baseViewCountAlpha)
|
||||
transition.setScale(view: viewStatsExpandedTextView, scale: viewStatsCurrentFrame.width / viewStatsExpandedTextFrame.width)
|
||||
}
|
||||
let viewStatsLabelSize = self.viewStatsLabelText.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(Text(text: viewPart, font: Font.regular(15.0), color: .white)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 200.0, height: 100.0)
|
||||
)
|
||||
|
||||
var statsButtonWidth = viewStatsTextFrame.maxY + 8.0
|
||||
var reactionsIconSize: CGSize?
|
||||
var reactionsTextSize: CGSize?
|
||||
|
||||
if reactionCount != 0 {
|
||||
var reactionsTransition = transition
|
||||
@ -373,12 +342,13 @@ public final class StoryFooterPanelComponent: Component {
|
||||
reactionsTransition = reactionsTransition.withAnimation(.none)
|
||||
reactionStatsIcon = UIImageView()
|
||||
reactionStatsIcon.image = UIImage(bundleImageName: "Stories/InputLikeOn")?.withRenderingMode(.alwaysTemplate)
|
||||
reactionStatsIcon.tintColor = UIColor(rgb: 0xFF3B30)
|
||||
|
||||
self.reactionStatsIcon = reactionStatsIcon
|
||||
self.externalContainerView.addSubview(reactionStatsIcon)
|
||||
}
|
||||
|
||||
transition.setTintColor(view: reactionStatsIcon, color: UIColor(rgb: 0xFF3B30).mixedWith(.white, alpha: component.expandFraction))
|
||||
|
||||
let reactionStatsText: AnimatedCountLabelView
|
||||
if let current = self.reactionStatsText {
|
||||
reactionStatsText = current
|
||||
@ -396,14 +366,10 @@ public final class StoryFooterPanelComponent: Component {
|
||||
],
|
||||
transition: (isFirstTime || reactionsTransition.animation.isImmediate) ? .immediate : ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut)
|
||||
)
|
||||
reactionsTextSize = reactionStatsLayout.size
|
||||
|
||||
let imageSize = CGSize(width: 23.0, height: 23.0)
|
||||
reactionsTransition.setFrame(view: reactionStatsIcon, frame: CGRect(origin: CGPoint(x: viewStatsTextFrame.maxX + 7.0, y: viewStatsTextFrame.minY - 3.0), size: imageSize))
|
||||
|
||||
let reactionStatsFrame = CGRect(origin: CGPoint(x: viewStatsTextFrame.maxX + 7.0 + imageSize.width + 3.0, y: viewStatsTextFrame.minY), size: reactionStatsLayout.size)
|
||||
reactionsTransition.setFrame(view: reactionStatsText, frame: reactionStatsFrame)
|
||||
|
||||
statsButtonWidth = reactionStatsFrame.maxX + 8.0
|
||||
reactionsIconSize = imageSize
|
||||
} else {
|
||||
if let reactionStatsIcon = self.reactionStatsIcon {
|
||||
self.reactionStatsIcon = nil
|
||||
@ -419,12 +385,108 @@ public final class StoryFooterPanelComponent: Component {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let viewsReactionsCollapsedSpacing: CGFloat = 6.0
|
||||
let viewsReactionsExpandedSpacing: CGFloat = 8.0
|
||||
let viewsReactionsSpacing = viewsReactionsCollapsedSpacing.interpolate(to: viewsReactionsExpandedSpacing, amount: component.expandFraction)
|
||||
|
||||
let avatarViewsSpacing: CGFloat = 18.0
|
||||
let viewsIconSpacing: CGFloat = 2.0
|
||||
|
||||
let reactionsIconSpacing: CGFloat = 2.0
|
||||
|
||||
var contentWidth: CGFloat = 0.0
|
||||
|
||||
contentWidth += (avatarsSize.width + avatarViewsSpacing) * (1.0 - component.expandFraction)
|
||||
if let image = self.viewsIconView.image {
|
||||
contentWidth += (image.size.width + viewsIconSpacing) * component.expandFraction
|
||||
}
|
||||
|
||||
if viewCount == 0 {
|
||||
contentWidth += viewStatsTextLayout.size.width * component.expandFraction
|
||||
} else {
|
||||
contentWidth += viewStatsTextLayout.size.width
|
||||
}
|
||||
contentWidth += viewStatsLabelSize.width * (1.0 - component.expandFraction)
|
||||
|
||||
if let reactionsIconSize, let reactionsTextSize {
|
||||
contentWidth += viewsReactionsSpacing
|
||||
contentWidth += reactionsIconSize.width
|
||||
contentWidth += reactionsIconSpacing
|
||||
contentWidth += reactionsTextSize.width
|
||||
}
|
||||
|
||||
let minContentX: CGFloat = 16.0
|
||||
let maxContentX: CGFloat = floor((availableSize.width - contentWidth) * 0.5)
|
||||
var contentX: CGFloat = minContentX.interpolate(to: maxContentX, amount: component.expandFraction)
|
||||
|
||||
let avatarsNodeFrame = CGRect(origin: CGPoint(x: contentX, y: floor((size.height - avatarsSize.height) * 0.5)), size: avatarsSize)
|
||||
transition.setPosition(view: self.avatarsView, position: avatarsNodeFrame.center)
|
||||
transition.setBounds(view: self.avatarsView, bounds: CGRect(origin: CGPoint(), size: avatarsNodeFrame.size))
|
||||
transition.setAlpha(view: self.avatarsView, alpha: avatarsAlpha)
|
||||
transition.setScale(view: self.avatarsView, scale: CGFloat(1.0).interpolate(to: CGFloat(0.1), amount: component.expandFraction))
|
||||
|
||||
if let image = self.viewsIconView.image {
|
||||
let viewsIconFrame = CGRect(origin: CGPoint(x: contentX, y: floor((size.height - image.size.height) * 0.5)), size: image.size)
|
||||
transition.setPosition(view: self.viewsIconView, position: viewsIconFrame.center)
|
||||
transition.setBounds(view: self.viewsIconView, bounds: CGRect(origin: CGPoint(), size: viewsIconFrame.size))
|
||||
transition.setAlpha(view: self.viewsIconView, alpha: component.expandFraction)
|
||||
transition.setScale(view: self.viewsIconView, scale: CGFloat(1.0).interpolate(to: CGFloat(0.1), amount: 1.0 - component.expandFraction))
|
||||
}
|
||||
|
||||
if !avatarsSize.width.isZero {
|
||||
contentX += (avatarsSize.width + avatarViewsSpacing) * (1.0 - component.expandFraction)
|
||||
}
|
||||
if let image = self.viewsIconView.image {
|
||||
contentX += (image.size.width + viewsIconSpacing) * component.expandFraction
|
||||
}
|
||||
|
||||
transition.setFrame(view: self.viewStatsCountText, frame: CGRect(origin: CGPoint(x: contentX, y: floor((size.height - viewStatsTextLayout.size.height) * 0.5)), size: viewStatsTextLayout.size))
|
||||
if viewCount == 0 {
|
||||
contentX += viewStatsTextLayout.size.width * component.expandFraction
|
||||
transition.setAlpha(view: self.viewStatsCountText, alpha: component.expandFraction)
|
||||
} else {
|
||||
contentX += viewStatsTextLayout.size.width
|
||||
transition.setAlpha(view: self.viewStatsCountText, alpha: 1.0)
|
||||
}
|
||||
|
||||
let viewStatsLabelTextFrame = CGRect(origin: CGPoint(x: contentX, y: floor((size.height - viewStatsLabelSize.height) * 0.5)), size: viewStatsLabelSize)
|
||||
if let viewStatsLabelTextView = self.viewStatsLabelText.view {
|
||||
if viewStatsLabelTextView.superview == nil {
|
||||
viewStatsLabelTextView.isUserInteractionEnabled = false
|
||||
viewStatsLabelTextView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5)
|
||||
self.externalContainerView.addSubview(viewStatsLabelTextView)
|
||||
}
|
||||
transition.setPosition(view: viewStatsLabelTextView, position: CGPoint(x: viewStatsLabelTextFrame.minX, y: viewStatsLabelTextFrame.midY))
|
||||
transition.setBounds(view: viewStatsLabelTextView, bounds: CGRect(origin: CGPoint(), size: viewStatsLabelTextFrame.size))
|
||||
transition.setAlpha(view: viewStatsLabelTextView, alpha: 1.0 - component.expandFraction)
|
||||
transition.setScale(view: viewStatsLabelTextView, scale: CGFloat(1.0).interpolate(to: CGFloat(0.1), amount: component.expandFraction))
|
||||
}
|
||||
contentX += viewStatsLabelSize.width * (1.0 - component.expandFraction)
|
||||
|
||||
if let reactionStatsIcon = self.reactionStatsIcon, let reactionsIconSize, let reactionStatsText = self.reactionStatsText, let reactionsTextSize {
|
||||
contentX += viewsReactionsSpacing
|
||||
|
||||
transition.setFrame(view: reactionStatsIcon, frame: CGRect(origin: CGPoint(x: contentX, y: floor((size.height - reactionsIconSize.height) * 0.5)), size: reactionsIconSize))
|
||||
contentX += reactionsIconSize.width
|
||||
contentX += reactionsIconSpacing
|
||||
|
||||
transition.setFrame(view: reactionStatsText, frame: CGRect(origin: CGPoint(x: contentX, y: floor((size.height - reactionsTextSize.height) * 0.5)), size: reactionsTextSize))
|
||||
contentX += reactionsTextSize.width
|
||||
}
|
||||
|
||||
let statsButtonWidth = availableSize.width - 80.0
|
||||
|
||||
transition.setFrame(view: self.viewStatsButton, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: statsButtonWidth, height: baseHeight)))
|
||||
self.viewStatsButton.isUserInteractionEnabled = component.expandFraction == 0.0
|
||||
|
||||
var rightContentOffset: CGFloat = availableSize.width - 12.0
|
||||
|
||||
let isPending = component.storyItem.isPending
|
||||
self.viewsIconView.isHidden = isPending
|
||||
self.viewStatsCountText.isHidden = isPending
|
||||
self.viewStatsLabelText.view?.isHidden = isPending
|
||||
|
||||
let deleteButtonSize = self.deleteButton.update(
|
||||
transition: transition,
|
||||
component: AnyComponent(Button(
|
||||
@ -444,12 +506,17 @@ public final class StoryFooterPanelComponent: Component {
|
||||
)
|
||||
if let deleteButtonView = self.deleteButton.view {
|
||||
if deleteButtonView.superview == nil {
|
||||
self.externalContainerView.addSubview(deleteButtonView)
|
||||
self.addSubview(deleteButtonView)
|
||||
}
|
||||
transition.setFrame(view: deleteButtonView, frame: CGRect(origin: CGPoint(x: rightContentOffset - deleteButtonSize.width, y: floor((size.height - deleteButtonSize.height) * 0.5)), size: deleteButtonSize))
|
||||
var deleteButtonFrame = CGRect(origin: CGPoint(x: rightContentOffset - deleteButtonSize.width, y: floor((size.height - deleteButtonSize.height) * 0.5)), size: deleteButtonSize)
|
||||
deleteButtonFrame.origin.y += component.expandFraction * 45.0
|
||||
transition.setPosition(view: deleteButtonView, position: deleteButtonFrame.center)
|
||||
transition.setBounds(view: deleteButtonView, bounds: CGRect(origin: CGPoint(), size: deleteButtonFrame.size))
|
||||
|
||||
rightContentOffset -= deleteButtonSize.width + 8.0
|
||||
|
||||
transition.setAlpha(view: deleteButtonView, alpha: pow(1.0 - component.expandFraction, 1.0) * baseViewCountAlpha)
|
||||
transition.setAlpha(view: deleteButtonView, alpha: 1.0 - sideContentFraction)
|
||||
transition.setScale(view: deleteButtonView, scale: CGFloat(1.0).interpolate(to: CGFloat(0.1), amount: sideContentFraction))
|
||||
}
|
||||
|
||||
return size
|
||||
|
20
submodules/TelegramUI/Components/TabSelectorComponent/BUILD
Normal file
20
submodules/TelegramUI/Components/TabSelectorComponent/BUILD
Normal file
@ -0,0 +1,20 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "TabSelectorComponent",
|
||||
module_name = "TabSelectorComponent",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//submodules/Display",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/TelegramUI/Components/PlainButtonComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import PlainButtonComponent
|
||||
|
||||
public final class TabSelectorComponent: Component {
|
||||
public struct Colors: Equatable {
|
||||
public var foreground: UIColor
|
||||
public var selection: UIColor
|
||||
|
||||
public init(
|
||||
foreground: UIColor,
|
||||
selection: UIColor
|
||||
) {
|
||||
self.foreground = foreground
|
||||
self.selection = selection
|
||||
}
|
||||
}
|
||||
|
||||
public struct Item: Equatable {
|
||||
public var id: AnyHashable
|
||||
public var title: String
|
||||
|
||||
public init(
|
||||
id: AnyHashable,
|
||||
title: String
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
public let colors: Colors
|
||||
public let items: [Item]
|
||||
public let selectedId: AnyHashable?
|
||||
public let setSelectedId: (AnyHashable) -> Void
|
||||
|
||||
public init(
|
||||
colors: Colors,
|
||||
items: [Item],
|
||||
selectedId: AnyHashable?,
|
||||
setSelectedId: @escaping (AnyHashable) -> Void
|
||||
) {
|
||||
self.colors = colors
|
||||
self.items = items
|
||||
self.selectedId = selectedId
|
||||
self.setSelectedId = setSelectedId
|
||||
}
|
||||
|
||||
public static func ==(lhs: TabSelectorComponent, rhs: TabSelectorComponent) -> Bool {
|
||||
if lhs.colors != rhs.colors {
|
||||
return false
|
||||
}
|
||||
if lhs.items != rhs.items {
|
||||
return false
|
||||
}
|
||||
if lhs.selectedId != rhs.selectedId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private final class VisibleItem {
|
||||
let title = ComponentView<Empty>()
|
||||
|
||||
init() {
|
||||
}
|
||||
}
|
||||
|
||||
public final class View: UIView {
|
||||
private var component: TabSelectorComponent?
|
||||
private weak var state: EmptyComponentState?
|
||||
|
||||
private let selectionView: UIImageView
|
||||
private var visibleItems: [AnyHashable: VisibleItem] = [:]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.selectionView = UIImageView()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubview(self.selectionView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
}
|
||||
|
||||
func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
self.component = component
|
||||
self.state = state
|
||||
|
||||
let baseHeight: CGFloat = 28.0
|
||||
let innerInset: CGFloat = 12.0
|
||||
let spacing: CGFloat = 2.0
|
||||
|
||||
if self.selectionView.image == nil {
|
||||
self.selectionView.image = generateStretchableFilledCircleImage(diameter: baseHeight, color: component.colors.selection)
|
||||
}
|
||||
|
||||
var contentWidth: CGFloat = 0.0
|
||||
var selectedBackgroundRect: CGRect?
|
||||
|
||||
var validIds: [AnyHashable] = []
|
||||
for item in component.items {
|
||||
var itemTransition = transition
|
||||
let itemView: VisibleItem
|
||||
if let current = self.visibleItems[item.id] {
|
||||
itemView = current
|
||||
} else {
|
||||
itemView = VisibleItem()
|
||||
self.visibleItems[item.id] = itemView
|
||||
itemTransition = itemTransition.withAnimation(.none)
|
||||
}
|
||||
|
||||
let itemId = item.id
|
||||
validIds.append(itemId)
|
||||
|
||||
let itemSize = itemView.title.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(PlainButtonComponent(
|
||||
content: AnyComponent(Text(text: item.title, font: Font.semibold(14.0), color: component.colors.foreground)),
|
||||
effectAlignment: .center,
|
||||
minSize: nil,
|
||||
action: { [weak self] in
|
||||
guard let self, let component = self.component else {
|
||||
return
|
||||
}
|
||||
component.setSelectedId(itemId)
|
||||
}
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 200.0, height: 100.0)
|
||||
)
|
||||
|
||||
if !contentWidth.isZero {
|
||||
contentWidth += spacing
|
||||
}
|
||||
let itemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: floor((baseHeight - itemSize.height) * 0.5)), size: itemSize)
|
||||
let itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight))
|
||||
contentWidth = itemBackgroundRect.maxX
|
||||
|
||||
if item.id == component.selectedId {
|
||||
selectedBackgroundRect = itemBackgroundRect
|
||||
}
|
||||
|
||||
if let itemTitleView = itemView.title.view {
|
||||
if itemTitleView.superview == nil {
|
||||
itemTitleView.layer.anchorPoint = CGPoint()
|
||||
self.addSubview(itemTitleView)
|
||||
}
|
||||
itemTransition.setPosition(view: itemTitleView, position: itemTitleFrame.origin)
|
||||
itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size))
|
||||
itemTransition.setAlpha(view: itemTitleView, alpha: item.id == component.selectedId ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
var removeIds: [AnyHashable] = []
|
||||
for (id, itemView) in self.visibleItems {
|
||||
if !validIds.contains(id) {
|
||||
removeIds.append(id)
|
||||
itemView.title.view?.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
for id in removeIds {
|
||||
self.visibleItems.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
if let selectedBackgroundRect {
|
||||
self.selectionView.alpha = 1.0
|
||||
transition.setFrame(view: self.selectionView, frame: selectedBackgroundRect)
|
||||
} else {
|
||||
self.selectionView.alpha = 0.0
|
||||
}
|
||||
|
||||
return CGSize(width: contentWidth, height: baseHeight)
|
||||
}
|
||||
}
|
||||
|
||||
public func makeView() -> View {
|
||||
return View(frame: CGRect())
|
||||
}
|
||||
|
||||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||
}
|
||||
}
|
12
submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Time.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Time.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "time_24.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
3
submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Time.imageset/time_24.svg
vendored
Normal file
3
submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Time.imageset/time_24.svg
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.66496 12C4.66496 7.94895 7.94895 4.66496 12 4.66496C16.051 4.66496 19.335 7.94895 19.335 12C19.335 16.051 16.051 19.335 12 19.335C7.94895 19.335 4.66496 16.051 4.66496 12ZM12 3.33496C7.21441 3.33496 3.33496 7.21441 3.33496 12C3.33496 16.7855 7.21441 20.665 12 20.665C16.7855 20.665 20.665 16.7855 20.665 12C20.665 7.21441 16.7855 3.33496 12 3.33496ZM12.665 6.99996C12.665 6.63269 12.3672 6.33496 12 6.33496C11.6327 6.33496 11.335 6.63269 11.335 6.99996V12C11.335 12.1763 11.405 12.3455 11.5297 12.4702L14.5297 15.4702C14.7894 15.7299 15.2105 15.7299 15.4702 15.4702C15.7299 15.2105 15.7299 14.7894 15.4702 14.5297L12.665 11.7245V6.99996Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 809 B |
12
submodules/TelegramUI/Images.xcassets/Stories/EmbeddedViewIcon.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Stories/EmbeddedViewIcon.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "StoryEmbeddedViewIcon.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 8C9.31976 8 5.83718 12.0484 4.55746 13.8704C4.18393 14.4022 4.17081 15.0883 4.51968 15.6366C5.75494 17.578 9.20464 22 15 22C20.7954 22 24.2451 17.578 25.4803 15.6366C25.8292 15.0883 25.8161 14.4022 25.4426 13.8704C24.1628 12.0484 20.6803 8 15 8ZM20 15C20 17.7614 17.7614 20 15 20C12.2386 20 10 17.7614 10 15C10 12.2386 12.2386 10 15 10C17.7614 10 20 12.2386 20 15ZM15 18C16.6569 18 18 16.6569 18 15C18 13.3431 16.6569 12 15 12C14.912 12 14.8249 12.0038 14.7389 12.0112C14.9051 12.3028 15 12.6403 15 13C15 14.1046 14.1046 15 13 15C12.6403 15 12.3028 14.9051 12.0112 14.7389C12.0038 14.8249 12 14.912 12 15C12 16.6569 13.3432 18 15 18Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 806 B |
12
submodules/TelegramUI/Images.xcassets/Stories/SelectorArrowDown.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Stories/SelectorArrowDown.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "arrow_down.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
3
submodules/TelegramUI/Images.xcassets/Stories/SelectorArrowDown.imageset/arrow_down.svg
vendored
Normal file
3
submodules/TelegramUI/Images.xcassets/Stories/SelectorArrowDown.imageset/arrow_down.svg
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="7" viewBox="0 0 12 7" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.52974 6.47019C5.78943 6.72989 6.21049 6.72989 6.47019 6.47019L11.4702 1.47019C11.7299 1.21049 11.7299 0.789433 11.4702 0.529735C11.2105 0.270036 10.7894 0.270036 10.5297 0.529735L5.99996 5.05951L1.47019 0.529735C1.21049 0.270036 0.789434 0.270036 0.529735 0.529735C0.270036 0.789433 0.270036 1.21049 0.529735 1.47019L5.52974 6.47019Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 503 B |
Loading…
x
Reference in New Issue
Block a user