Skip to content

Commit 42de16e

Browse files
authored
Load forward topics and folder chats only when their submenu opens (#113)
* Load context submenu items asynchronously when the submenu opens ContextMenu.asyncItemsProvider starts loading when AppMenuController presents the submenu. The submenu appears once the items arrive, if the pointer is still on its parent item. Native context menus start loading immediately. * Load forward topics and folder chats only when their submenu opens Forum topics are read from Postbox when the forum's submenu is hovered and built 100 at a time, with the next page added on scroll. Folder chats are no longer loaded before the message context menu opens; each folder loads its chats when hovered. * Open loaded submenus from the keyboard and build every forward topic - Present an asynchronously loaded submenu when its row is selected (Return, VoiceOver press), not only while the pointer is on it. VoiceOver press now selects a submenu row first. - Build all topics when a forum submenu opens, so type-to-select can reach every topic. - Remove the submenu arrow from folders that have no chats. - Remove ContextMenu.itemsProvider, which no longer has a caller. - Update the context-menu-topic-submenus README. * Keep a pressed submenu row selected when its sibling submenu closes - Cancelling a submenu deselects only its own parent row, so a VoiceOver press on a row whose submenu is still loading is no longer dropped when an open sibling submenu is closed. - Remove a code comment from AppMenuController. - Test opening a submenu, pressing a loading sibling row and opening its submenu when the items arrive. The test bootstrap registers a stand-in Icon_Menu_More so submenu rows can draw.
1 parent 78be930 commit 42de16e

9 files changed

Lines changed: 232 additions & 65 deletions

File tree

‎packages/ChatUI/Sources/ChatUI/ChatMessageMenuItems.swift‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,12 @@ public final class ChatMenuItemsData {
9595
public let notifications: NotificationSoundList?
9696
public let cachedData: CachedPeerData?
9797
public let groupped:[Message]?
98-
public let folders: [(ChatListFilter, [Peer])]
98+
public let folders: [ChatListFilter]
99+
public let folderPeers: (ChatListFilter) -> Signal<[Peer], NoError>
99100
public let isMediaStory: Bool
100101
public let isRead: Bool
101102
public let canViewAuthor: Bool
102-
public init(chatInteraction: ChatInteraction, message: Message, accountPeer: Peer, resourceData: MediaResourceData?, chatState: ChatState, chatMode: ChatMode, chatLocation: ChatLocation, disableSelectAbility: Bool, isLogInteraction: Bool, canPinMessage: Bool, pinnedMessage: ChatPinnedMessage?, peer: Peer?, peerId: PeerId, fileFinderPath: String?, isStickerSaved: Bool?, dialogs: [Peer], recentUsedPeers: [Peer], favoritePeers: [Peer], recentMedia: [RecentMediaItem], updatingMessageMedia: [MessageId: ChatUpdatingMessageMedia], additionalData: MessageEntryAdditionalData, file: TelegramMediaFile?, image: TelegramMediaImage?, textLayout: (TextViewLayout?, LinkType?)?, availableReactions: AvailableReactions?, notifications: NotificationSoundList?, cachedData: CachedPeerData?, savedStickersCount: Int, savedGifsCount: Int, groupped: [Message]?, folders: [(ChatListFilter, [Peer])], isMediaStory: Bool, isRead: Bool, canViewAuthor: Bool) {
103+
public init(chatInteraction: ChatInteraction, message: Message, accountPeer: Peer, resourceData: MediaResourceData?, chatState: ChatState, chatMode: ChatMode, chatLocation: ChatLocation, disableSelectAbility: Bool, isLogInteraction: Bool, canPinMessage: Bool, pinnedMessage: ChatPinnedMessage?, peer: Peer?, peerId: PeerId, fileFinderPath: String?, isStickerSaved: Bool?, dialogs: [Peer], recentUsedPeers: [Peer], favoritePeers: [Peer], recentMedia: [RecentMediaItem], updatingMessageMedia: [MessageId: ChatUpdatingMessageMedia], additionalData: MessageEntryAdditionalData, file: TelegramMediaFile?, image: TelegramMediaImage?, textLayout: (TextViewLayout?, LinkType?)?, availableReactions: AvailableReactions?, notifications: NotificationSoundList?, cachedData: CachedPeerData?, savedStickersCount: Int, savedGifsCount: Int, groupped: [Message]?, folders: [ChatListFilter], folderPeers: @escaping (ChatListFilter) -> Signal<[Peer], NoError>, isMediaStory: Bool, isRead: Bool, canViewAuthor: Bool) {
103104
self.chatInteraction = chatInteraction
104105
self.message = message
105106
self.accountPeer = accountPeer
@@ -131,6 +132,7 @@ public final class ChatMenuItemsData {
131132
self.savedGifsCount = savedGifsCount
132133
self.groupped = groupped
133134
self.folders = folders
135+
self.folderPeers = folderPeers
134136
self.isMediaStory = isMediaStory
135137
self.isRead = isRead
136138
self.canViewAuthor = canViewAuthor
@@ -215,15 +217,18 @@ public func chatMenuItemsData(for message: Message, textLayout: (TextViewLayout?
215217

216218
let _dialogs: Signal<[Peer], NoError> = request(nil)
217219

218-
let _folders: Signal<[(ChatListFilter, [Peer])], NoError> = combineLatest(
219-
chatListFilterPreferences(engine: context.engine),
220-
ChatHistoryUIHooks.shared.communityFilterCategories(account)
221-
) |> mapToSignal { value, communityCategories in
222-
return combineLatest(value.list.filter { !ChatHistoryUIHooks.shared.chatListFilterIsAllChats($0) }.map { item in
223-
return request(ChatHistoryUIHooks.shared.chatListFilterPredicate(item, communityCategories)) |> map {
224-
(item, $0)
225-
}
226-
})
220+
let _folders: Signal<[ChatListFilter], NoError> = chatListFilterPreferences(engine: context.engine)
221+
|> take(1)
222+
|> map { value in
223+
return value.list.filter { !ChatHistoryUIHooks.shared.chatListFilterIsAllChats($0) }
224+
}
225+
226+
let folderPeers: (ChatListFilter) -> Signal<[Peer], NoError> = { item in
227+
return ChatHistoryUIHooks.shared.communityFilterCategories(account)
228+
|> take(1)
229+
|> mapToSignal { communityCategories in
230+
return request(ChatHistoryUIHooks.shared.chatListFilterPredicate(item, communityCategories))
231+
}
227232
}
228233

229234

@@ -295,7 +300,7 @@ public func chatMenuItemsData(for message: Message, textLayout: (TextViewLayout?
295300

296301

297302
return combined |> map { dialogs, recentUsedPeers, favoritePeers, accountPeer, resourceData, fileFinderPath, isStickerSaved, recentMedia, updatingMessageMedia, availableReactions, notifications, cachedData, savedStickersCount, savedGifsCount, groupped, folders in
298-
return .init(chatInteraction: chatInteraction, message: message, accountPeer: accountPeer, resourceData: resourceData, chatState: chatState, chatMode: chatMode, chatLocation: chatLocation, disableSelectAbility: disableSelectAbility, isLogInteraction: isLogInteraction, canPinMessage: canPinMessage, pinnedMessage: pinnedMessage, peer: peer, peerId: peerId, fileFinderPath: fileFinderPath, isStickerSaved: isStickerSaved, dialogs: dialogs, recentUsedPeers: recentUsedPeers, favoritePeers: favoritePeers, recentMedia: recentMedia, updatingMessageMedia: updatingMessageMedia, additionalData: additionalData, file: file, image: image, textLayout: textLayout, availableReactions: availableReactions, notifications: notifications, cachedData: cachedData, savedStickersCount: savedStickersCount, savedGifsCount: savedGifsCount, groupped: groupped, folders: folders, isMediaStory: isMediaStory, isRead: isRead, canViewAuthor: canViewAuthor)
303+
return .init(chatInteraction: chatInteraction, message: message, accountPeer: accountPeer, resourceData: resourceData, chatState: chatState, chatMode: chatMode, chatLocation: chatLocation, disableSelectAbility: disableSelectAbility, isLogInteraction: isLogInteraction, canPinMessage: canPinMessage, pinnedMessage: pinnedMessage, peer: peer, peerId: peerId, fileFinderPath: fileFinderPath, isStickerSaved: isStickerSaved, dialogs: dialogs, recentUsedPeers: recentUsedPeers, favoritePeers: favoritePeers, recentMedia: recentMedia, updatingMessageMedia: updatingMessageMedia, additionalData: additionalData, file: file, image: image, textLayout: textLayout, availableReactions: availableReactions, notifications: notifications, cachedData: cachedData, savedStickersCount: savedStickersCount, savedGifsCount: savedGifsCount, groupped: groupped, folders: folders, folderPeers: folderPeers, isMediaStory: isMediaStory, isRead: isRead, canViewAuthor: canViewAuthor)
299304
}
300305
}
301306

@@ -1286,11 +1291,16 @@ public func chatMenuItems(for message: Message, entry: ChatHistoryEntry?, textLa
12861291

12871292
let folderSubmenu = ContextMenu()
12881293
for folder in data.folders {
1289-
let item = ContextMenuItem(ChatHistoryUIHooks.shared.chatListFilterTitle(folder.0), itemImage: ChatHistoryUIHooks.shared.folderEmoticonDrawable(FolderIcon(folder.0).emoticon).value)
1294+
let item = ContextMenuItem(ChatHistoryUIHooks.shared.chatListFilterTitle(folder), itemImage: ChatHistoryUIHooks.shared.folderEmoticonDrawable(FolderIcon(folder).emoticon).value)
12901295
let submenu = ContextMenu()
1291-
1292-
for peer in folder.1 {
1293-
submenu.addItem(makeItem(peer))
1296+
submenu.asyncItemsProvider = { [weak item] completion in
1297+
return (data.folderPeers(folder) |> deliverOnMainQueue).start(next: { peers in
1298+
if peers.isEmpty, let item {
1299+
item.submenu = nil
1300+
item.redraw?()
1301+
}
1302+
completion(peers.map { makeItem($0) })
1303+
})
12941304
}
12951305
item.submenu = submenu
12961306
folderSubmenu.addItem(item)

‎packages/ChatUI/Sources/ChatUI/MessageReadMenuItem.swift‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -926,20 +926,21 @@ public final class ReactionPeerMenu : ContextMenuItem {
926926
}
927927
}))
928928
} else if TelegramUtilsHooks.shared.peerIsForum(peer) || (peer.isAdmin && peer.isMonoForum), case let .forward(callback, _) = destination {
929-
let signal = ChatHistoryUIHooks.shared.forumChatListItems(peer.id, context.account) |> take(1) |> deliverOnMainQueue
930-
disposable.set(signal.start(next: { [weak self] list in
931-
let topics = forwardMenuTopicItems(list, canSend: { item in
932-
peer.canSendMessage(true, media: message?.media.first, threadData: item.threadData)
929+
let menu = ContextMenu()
930+
self.submenu = menu
931+
menu.asyncItemsProvider = { [weak self] completion in
932+
let signal = ChatHistoryUIHooks.shared.forumChatListItems(peer.id, context.account) |> take(1) |> deliverOnMainQueue
933+
return signal.start(next: { list in
934+
let topics = forwardMenuTopicItems(list, canSend: { item in
935+
peer.canSendMessage(true, media: message?.media.first, threadData: item.threadData)
936+
})
937+
if topics.isEmpty, let self {
938+
self.submenu = nil
939+
self.redraw?()
940+
}
941+
completion(topics.compactMap { forwardTopicMenuItem($0, in: peer, context: context, callback: callback) })
933942
})
934-
guard let self, !topics.isEmpty else {
935-
return
936-
}
937-
let menu = ContextMenu()
938-
menu.itemsProvider = {
939-
topics.compactMap { forwardTopicMenuItem($0, in: peer, context: context, callback: callback) }
940-
}
941-
self.submenu = menu
942-
}))
943+
}
943944
}
944945
ContextMenuItem.makeItemAvatar(self, account: context.account, peer: peer, source: .peer(peer, peer.smallProfileImage, peer.namePeerColor, TelegramUtilsHooks.shared.peerLetters(peer), message, nil), selfAsSaved: selfSaved)
945946
if !peer.isCommunity {

‎packages/TGUIKit/Sources/AppMenuController.swift‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,9 @@ final class AppMenuController : NSObject {
795795
self.windows = self.windows.filter({
796796
$0.key.submenuId != submenu.view.submenuId
797797
})
798-
submenu.view.parentView?.view.tableView.cancelSelection()
798+
if let tableView = submenu.view.parentView?.view.tableView, let submenuId = submenu.view.submenuId, tableView.selectedItem()?.stableId == AnyHashable(submenuId) {
799+
tableView.cancelSelection()
800+
}
799801

800802
submenu.view.parentView?.view.childView = nil
801803
submenu.view.parentView = nil
@@ -850,7 +852,27 @@ final class AppMenuController : NSObject {
850852
if parentView.view.dismissed {
851853
return
852854
}
853-
menu.loadProvidedItems()
855+
menu.loadAsyncItems()
856+
if menu.isLoadingAsyncItems {
857+
menu.asyncItemsLoaded = { [weak self, weak parentView, weak menu] in
858+
guard let self, let parentView, let menu, !self.isClosed, !menu.contextItems.isEmpty else {
859+
return
860+
}
861+
guard let parentItem = parentView.view.item(for: id) else {
862+
return
863+
}
864+
let isHovered = parentItem.view?.mouseInside() == true
865+
guard isHovered || parentItem.isSelected else {
866+
return
867+
}
868+
self.presentSubmenu(menu, parentView: parentView, for: id)
869+
if !isHovered, let view = self.findSubmenu(id)?.view {
870+
view.tableView.selectNext()
871+
view.updateScroll()
872+
}
873+
}
874+
return
875+
}
854876

855877
let view = self.getView(for: menu, screen: screen, parentView: parentView, submenuId: id)
856878
guard let parentItem = parentView.view.item(for: id), let parentItemView = parentItem.view else {

‎packages/TGUIKit/Sources/AppMenuRowItem.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ open class AppMenuRowItem : AppMenuBasicItem {
228228
return false
229229
}
230230
if item.submenu != nil {
231+
_ = table?.select(item: self)
231232
interaction.presentSubmenu(item)
232233
} else {
233234
interaction.action(item)

‎packages/TGUIKit/Sources/ContextMenu.swift‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,6 @@ public final class ContextMenu : NSMenu, NSMenuDelegate {
172172

173173
public var loadMore: (()->Void)? = nil
174174

175-
public var itemsProvider: (() -> [NSMenuItem])? {
176-
didSet {
177-
if System.legacyMenu {
178-
loadProvidedItems()
179-
}
180-
}
181-
}
182-
183175
private var isAppendingItem = false
184176

185177
@objc dynamic internal var _items:[NSMenuItem] = [] {
@@ -205,12 +197,34 @@ public final class ContextMenu : NSMenu, NSMenuDelegate {
205197
_items.append(newItem)
206198
}
207199

208-
func loadProvidedItems() {
209-
guard let itemsProvider else {
200+
public var asyncItemsProvider: ((@escaping ([NSMenuItem]) -> Void) -> Disposable)? {
201+
didSet {
202+
if System.legacyMenu {
203+
loadAsyncItems()
204+
}
205+
}
206+
}
207+
208+
private let asyncItemsDisposable = MetaDisposable()
209+
private(set) var isLoadingAsyncItems = false
210+
var asyncItemsLoaded: (() -> Void)?
211+
212+
func loadAsyncItems() {
213+
guard let asyncItemsProvider else {
210214
return
211215
}
212-
self.itemsProvider = nil
213-
self.items = itemsProvider()
216+
self.asyncItemsProvider = nil
217+
self.isLoadingAsyncItems = true
218+
asyncItemsDisposable.set(asyncItemsProvider({ [weak self] items in
219+
guard let self else {
220+
return
221+
}
222+
self.isLoadingAsyncItems = false
223+
self.items = items
224+
let loaded = self.asyncItemsLoaded
225+
self.asyncItemsLoaded = nil
226+
loaded?()
227+
}))
214228
}
215229

216230
public override var items: [NSMenuItem] {
@@ -265,6 +279,7 @@ public final class ContextMenu : NSMenu, NSMenuDelegate {
265279
}
266280
deinit {
267281
NotificationCenter.default.removeObserver(self)
282+
asyncItemsDisposable.dispose()
268283
}
269284

270285
}

‎packages/TGUIKit/Tests/TGUIKitTests/AccessibilityBaseTests.swift‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,29 @@ final class AccessibilityBaseTests: XCTestCase {
283283
XCTAssertEqual(invoked, ["Copy"])
284284
XCTAssertEqual(submenus, ["More"])
285285
}
286+
func testContextMenuSubmenuRowIsSelectedWhenPressed() {
287+
var invoked: [String] = []
288+
var submenus: [String] = []
289+
let interaction = AppMenuBasicItem.Interaction(action: { invoked.append($0.title) }, presentSubmenu: { submenus.append($0.title) }, cancelSubmenu: { _ in }, hover: { _ in }, close: {})
290+
let copy = ContextMenuItem("Copy", handler: {})
291+
let more = ContextMenuItem("More")
292+
more.submenu = ContextMenu()
293+
let menu = ContextMenu()
294+
[copy, more].forEach(menu.addItem)
295+
let view = MenuView(frame: NSMakeRect(0, 0, 300, 400))
296+
view.merge(menu: menu, presentation: .current(PresentationTheme.current.colors), interaction: interaction)
297+
guard let copyRow = view.item(for: copy.id), let moreRow = view.item(for: more.id) else {
298+
return XCTFail("menu rows")
299+
}
300+
301+
XCTAssertTrue(moreRow.accessibilityPerformPress())
302+
XCTAssertTrue(moreRow.isSelected)
303+
XCTAssertTrue(copyRow.accessibilityPerformPress())
304+
XCTAssertTrue(moreRow.isSelected)
305+
XCTAssertFalse(copyRow.isSelected)
306+
XCTAssertEqual(invoked, ["Copy"])
307+
XCTAssertEqual(submenus, ["More"])
308+
}
286309
func testSegmentTabsAreRadioButtons() {
287310
let segment = ScrollableSegmentView(frame: NSMakeRect(0, 0, 400, 40))
288311
host(segment)

0 commit comments

Comments
 (0)