This commit is contained in:
Ali 2023-08-08 23:23:47 +03:00
parent 2d1bac5a46
commit f2da1e2315
30 changed files with 2813 additions and 982 deletions

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
})
}
}

View File

@ -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 {

View File

@ -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>

View File

@ -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()
}
}
}

View File

@ -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 {

View File

@ -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> {

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -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",
],
)

View File

@ -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)
}
}

View 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",
],
)

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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",

View File

@ -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,

View File

@ -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

View 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",
],
)

View File

@ -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)
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "time_24.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "StoryEmbeddedViewIcon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "arrow_down.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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