Skip to content

Commit 7b3a083

Browse files
authored
Add read and unread actions to search chat menus (#152)
* Add read and unread actions to search chat menus * Check live read-state actions and exclusions in search menus * Add a private native search-menu verification window * Record native unread flag toggling without exposing chats * Show private native search-menu before and after comparisons * Match forum unread support and isolate native menu verification * Read forum unread marks from the peer state and retain diagnostic windows * Scope unread diagnostics to the current account and release closed windows * Observe diagnostic window closure without changing initializer inheritance * Close unread diagnostic evidence immediately on account switch * Keep saved sub-chat menus from changing private chat read state * Remove obsolete search-unread review check output
1 parent 8b70726 commit 7b3a083

14 files changed

Lines changed: 545 additions & 49 deletions

‎packages/ChatListUI/Sources/ChatListUI/PopularPeersRowItem.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ private final class PopularPeerItem : TableRowItem {
8181
items.append(ContextMenuItem(strings().searchPopularDelete, handler: {
8282
_ = context.engine.peers.removeRecentPeer(peerId: peer.id).start()
8383
}, itemMode: .destruct, itemImage: MenuAnimation.menu_delete.value))
84+
return searchPeerReadMenuItem(context: context, peerId: peer.id) |> map { readItem in
85+
guard let readItem else { return items }
86+
return [readItem, ContextSeparatorItem()] + items
87+
}
88+
case let .savedMessages(peer):
89+
return searchPeerReadMenuItem(context: context, peerId: peer.id) |> map { readItem in
90+
return readItem.map { [$0] } ?? []
91+
}
8492
default:
8593
break
8694
}

‎packages/ChatListUI/Sources/ChatListUI/SearchController.swift‎

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -384,61 +384,62 @@ private func peerContextMenuItems(peer: Peer, pinnedItems:[PinnedItemId], argume
384384

385385
let peerId = peer.id
386386

387-
return .single(items) |> mapToSignal { items in
388-
return chatListFilterPreferences(engine: arguments.context.engine) |> deliverOnMainQueue |> take(1) |> map { filters -> [ContextMenuItem] in
389-
var items = items
390-
var submenu: [ContextMenuItem] = []
391-
if peerId.namespace != Namespaces.Peer.SecretChat {
392-
for item in filters.list {
393-
loop: switch item {
394-
case .allChats:
395-
break loop
396-
case let .filter(_, _, _, data):
397-
submenu.append(ContextMenuItem(item.title, handler: {
398-
_ = arguments.context.engine.peers.updateChatListFiltersInteractively({ list in
399-
var list = list
400-
for (i, folder) in list.enumerated() {
401-
if ChatListUIHooks.shared.chatListFilterId(folder) == ChatListUIHooks.shared.chatListFilterId(item), var folderData = folder.data {
402-
if data.includePeers.peers.contains(peerId) {
403-
var peers = folderData.includePeers.peers
404-
peers.removeAll(where: { $0 == peerId })
405-
folderData.includePeers.setPeers(peers)
406-
} else {
407-
folderData.excludePeers.removeAll(where: { $0 == peerId })
408-
folderData.includePeers.setPeers(folderData.includePeers.peers + [peerId])
409-
}
410-
list[i] = list[i].withUpdatedData(folderData)
387+
return combineLatest(searchPeerReadMenuItem(context: arguments.context, peerId: peerId, target: arguments.target), chatListFilterPreferences(engine: arguments.context.engine)) |> take(1) |> deliverOnMainQueue |> map { readItem, filters -> [ContextMenuItem] in
388+
var items = items
389+
if let readItem {
390+
items.append(readItem)
391+
}
392+
var submenu: [ContextMenuItem] = []
393+
if peerId.namespace != Namespaces.Peer.SecretChat {
394+
for item in filters.list {
395+
loop: switch item {
396+
case .allChats:
397+
break loop
398+
case let .filter(_, _, _, data):
399+
submenu.append(ContextMenuItem(item.title, handler: {
400+
_ = arguments.context.engine.peers.updateChatListFiltersInteractively({ list in
401+
var list = list
402+
for (i, folder) in list.enumerated() {
403+
if ChatListUIHooks.shared.chatListFilterId(folder) == ChatListUIHooks.shared.chatListFilterId(item), var folderData = folder.data {
404+
if data.includePeers.peers.contains(peerId) {
405+
var peers = folderData.includePeers.peers
406+
peers.removeAll(where: { $0 == peerId })
407+
folderData.includePeers.setPeers(peers)
408+
} else {
409+
folderData.excludePeers.removeAll(where: { $0 == peerId })
410+
folderData.includePeers.setPeers(folderData.includePeers.peers + [peerId])
411411
}
412+
list[i] = list[i].withUpdatedData(folderData)
412413
}
413-
return list
414-
}).start()
415-
}, state: data.includePeers.peers.contains(peerId) ? NSControl.StateValue.on : nil))
416-
}
417-
418-
}
419-
}
420-
421-
if !submenu.isEmpty {
422-
items.append(ContextSeparatorItem())
423-
let item = ContextMenuItem(strings().chatListFilterAddToFolder, itemImage: MenuAnimation.menu_add_to_folder.value)
424-
let menu = ContextMenu()
425-
for item in submenu {
426-
menu.addItem(item)
414+
}
415+
return list
416+
}).start()
417+
}, state: data.includePeers.peers.contains(peerId) ? NSControl.StateValue.on : nil))
427418
}
428-
item.submenu = menu
429-
items.append(item)
419+
430420
}
431-
432-
if isRecent {
433-
items.append(ContextSeparatorItem())
434-
435-
items.append(ContextMenuItem(strings().searchRemoveFromRecent, handler: {
436-
arguments.removeRecentPeerId(peer.id)
437-
}, itemMode: .destruct, itemImage: MenuAnimation.menu_delete.value))
421+
}
422+
423+
if !submenu.isEmpty {
424+
items.append(ContextSeparatorItem())
425+
let item = ContextMenuItem(strings().chatListFilterAddToFolder, itemImage: MenuAnimation.menu_add_to_folder.value)
426+
let menu = ContextMenu()
427+
for item in submenu {
428+
menu.addItem(item)
438429
}
439-
440-
return items
430+
item.submenu = menu
431+
items.append(item)
441432
}
433+
434+
if isRecent {
435+
items.append(ContextSeparatorItem())
436+
437+
items.append(ContextMenuItem(strings().searchRemoveFromRecent, handler: {
438+
arguments.removeRecentPeerId(peer.id)
439+
}, itemMode: .destruct, itemImage: MenuAnimation.menu_delete.value))
440+
}
441+
442+
return items
442443
}
443444
}
444445

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import TGUIKit
2+
import Postbox
3+
import SwiftSignalKit
4+
import TelegramCore
5+
import TelegramContext
6+
import Localization
7+
import LocalAnimations
8+
9+
func searchPeerReadMenuItem(context: any AccountContextProtocol, peerId: PeerId, target: SearchControllerTarget = .common(.root)) -> Signal<ContextMenuItem?, NoError> {
10+
if case .savedMessages = target { return .single(nil) }
11+
guard peerId.namespace != Namespaces.Peer.SecretChat else {
12+
return .single(nil)
13+
}
14+
return context.engine.data.get(
15+
TelegramEngine.EngineData.Item.Messages.PeerReadCounters(id: peerId),
16+
TelegramEngine.EngineData.Item.Messages.ChatListGroup(id: peerId),
17+
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
18+
TelegramEngine.EngineData.Item.Peer.CachedData(id: peerId)
19+
) |> mapToSignal { state, group, peer, cachedData -> Signal<ContextMenuItem?, NoError> in
20+
guard group != nil, let peer else { return .single(nil) }
21+
let forum = peer._asPeer().isForumOrMonoForum
22+
let displaysAsMessages = (peer._asPeer() as? TelegramChannel)?.flags.contains(.displayForumAsTabs) == true || (cachedData as? CachedChannelData)?.viewForumAsMessages.knownValue == true
23+
let readState: Signal<Bool, NoError>
24+
if forum && displaysAsMessages {
25+
readState = context.account.postbox.transaction { transaction in
26+
transaction.getCombinedPeerReadState(peerId)?.isUnread ?? false
27+
}
28+
} else {
29+
readState = .single(state.isUnread)
30+
}
31+
return readState |> deliverOnMainQueue |> map { isUnread -> ContextMenuItem? in
32+
if !isUnread && forum && !displaysAsMessages { return nil }
33+
if isUnread {
34+
return ContextMenuItem(strings().chatListContextMaskAsRead, handler: {
35+
ChatListRowItem.markAsRead(context: context, peerId: peerId)
36+
}, itemImage: MenuAnimation.menu_read.value)
37+
} else {
38+
return ContextMenuItem(strings().chatListContextMaskAsUnread, handler: {
39+
ChatListRowItem.markAsUnread(context: context, peerId: peerId)
40+
}, itemImage: MenuAnimation.menu_unread.value)
41+
}
42+
}
43+
}
44+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import Foundation
2+
protocol AccountContextProtocol: AnyObject { var engine: Engine { get }; var account: Account { get } }
3+
enum NoError: Error {}
4+
struct Signal<T, E> { let value: T; static func single(_ value: T) -> Self { Self(value: value) }; func start() {} }
5+
precedencegroup PipePrecedence { associativity: left }
6+
infix operator |> : PipePrecedence
7+
func |> <A, B>(value: A, transform: (A) -> B) -> B { transform(value) }
8+
func map<A, B, E>(_ f: @escaping (A) -> B) -> (Signal<A, E>) -> Signal<B, E> { { .single(f($0.value)) } }
9+
func mapToSignal<A, B, E>(_ f: @escaping (A) -> Signal<B, E>) -> (Signal<A, E>) -> Signal<B, E> { { f($0.value) } }
10+
func deliverOnMainQueue<A, E>(_ signal: Signal<A, E>) -> Signal<A, E> { signal }
11+
struct PeerId: Equatable { let namespace: Int; let id: Int }
12+
enum Namespaces { enum Peer { static let SecretChat = 3 } }
13+
class Peer { let id: PeerId; var isForumOrMonoForum: Bool; init(_ id: PeerId, forum: Bool = false) { self.id=id; isForumOrMonoForum=forum } }
14+
struct ChannelFlags: OptionSet { let rawValue: Int; static let displayForumAsTabs = ChannelFlags(rawValue: 1) }
15+
class TelegramChannel: Peer { var flags: ChannelFlags = [] }
16+
struct KnownBool { var knownValue: Bool? }
17+
struct CachedChannelData { var viewForumAsMessages = KnownBool() }
18+
struct CachedItem { let id: PeerId }
19+
struct EnginePeer { let peer: Peer; func _asPeer() -> Peer { peer } }
20+
struct ReadState { var count=0; var marked=false; var isUnread: Bool { count>0 || marked } }
21+
enum Group { case root, archive }
22+
enum SearchControllerTarget { case common(Group), forum(PeerId), savedMessages(PeerId), community(PeerId) }
23+
struct ReadItem { let id: PeerId }
24+
struct GroupItem { let id: PeerId }
25+
struct PeerItem { let id: PeerId }
26+
enum TelegramEngine { enum EngineData { enum Item { enum Messages { typealias PeerReadCounters = ReadItem; typealias ChatListGroup = GroupItem }; enum Peer { typealias Peer = PeerItem; typealias CachedData = CachedItem } } } }
27+
final class DataEngine {
28+
var cached: Any?; var state=ReadState(); var group: Group? = .root; var peer: EnginePeer?; var requests: [PeerId]=[]
29+
func get(_ read: ReadItem, _ group: GroupItem, _ peer: PeerItem, _ cached: CachedItem) -> Signal<(ReadState, Group?, EnginePeer?, Any?), NoError> { requests += [read.id,group.id,peer.id,cached.id]; return .single((state,self.group,self.peer,self.cached)) }
30+
func get(_ read: ReadItem, _ group: GroupItem, _ peer: PeerItem) -> Signal<(ReadState, Group?, EnginePeer?), NoError> { requests += [read.id,group.id,peer.id]; return .single((state,self.group,self.peer)) }
31+
}
32+
final class Peers { var removed: [PeerId]=[]; func removeRecentPeer(peerId: PeerId) -> Signal<Int, NoError> { removed.append(peerId); return .single(0) } }
33+
final class Engine { let data=DataEngine(); let peers=Peers() }
34+
final class Transaction {
35+
var rawState = ReadState()
36+
func getCombinedPeerReadState(_ id: PeerId) -> ReadState? { rawState }
37+
}
38+
final class Postbox {
39+
let data = Transaction()
40+
func transaction<T>(_ body: (Transaction) -> T) -> Signal<T, NoError> { .single(body(data)) }
41+
}
42+
final class Account { let postbox = Postbox() }
43+
final class Context: AccountContextProtocol { let account = Account(); let engine=Engine(); var calls: [(PeerId,Bool)]=[] }
44+
struct Labels { let chatListContextMaskAsRead="Read"; let chatListContextMaskAsUnread="Unread"; let searchPopularDelete="Remove" }
45+
func strings() -> Labels { Labels() }
46+
enum MenuAnimation { case menu_read, menu_unread, menu_delete; var value: Int { switch self { case .menu_read:0;case .menu_unread:1;case .menu_delete:2 } } }
47+
enum ItemMode { case destruct }
48+
class ContextMenuItem { let title: String; let handler:(()->Void)?; let image: Int?; init(_ title:String,handler:(()->Void)?=nil,itemMode: ItemMode?=nil,itemImage:Int?=nil) { self.title=title;self.handler=handler;image=itemImage } }
49+
final class ContextSeparatorItem: ContextMenuItem { init() { super.init("separator") } }
50+
enum ChatListRowItem {
51+
static func markAsRead(context:any AccountContextProtocol,peerId:PeerId) { (context as! Context).calls.append((peerId,false)); context.account.postbox.data.rawState.marked = false }
52+
static func markAsUnread(context:any AccountContextProtocol,peerId:PeerId) { (context as! Context).calls.append((peerId,true)); context.account.postbox.data.rawState.marked = true }
53+
}
54+
enum PopularItemType { case peer(Peer,Int?,Bool), savedMessages(Peer), articles(Int32) }
55+
final class Popular {
56+
let context: Context; let type: PopularItemType
57+
init(_ context:Context,_ type:PopularItemType) { self.context=context;self.type=type }
58+
func menuItems() -> Signal<[ContextMenuItem],NoError> { __POPULAR_BODY__ }
59+
}
60+
__HELPER__
61+
__LEASE__
62+
var count=0, failures=0
63+
func check(_ name:String,_ pass:Bool) {count+=1;if !pass {failures+=1;print("FAIL: "+name)} }
64+
let context=Context(); let id=PeerId(namespace:0,id:42);let peer=Peer(id);context.engine.data.peer=EnginePeer(peer:peer)
65+
for group in [Group.root,.archive] {
66+
context.engine.data.group=group
67+
for state in [ReadState(count:0,marked:false),ReadState(count:3,marked:false),ReadState(count:0,marked:true)] {
68+
context.engine.data.state=state;context.calls=[]
69+
let item=searchPeerReadMenuItem(context:context,peerId:id).value
70+
check("current read state determines title",item?.title == (state.isUnread ? "Read":"Unread"))
71+
check("standard read/unread icon",item?.image == (state.isUnread ? 0:1))
72+
item?.handler?()
73+
check("explicit action targets original chat",context.calls.count == 1 && context.calls[0].0 == id && context.calls[0].1 == !state.isUnread)
74+
}
75+
}
76+
context.engine.data.group=nil
77+
check("global peer outside chat list has no read action",searchPeerReadMenuItem(context:context,peerId:id).value == nil)
78+
context.engine.data.group = .root;context.engine.data.peer=nil
79+
check("missing peer has no read action",searchPeerReadMenuItem(context:context,peerId:id).value == nil)
80+
context.engine.data.peer=EnginePeer(peer:peer);context.engine.data.requests=[]
81+
check("secret chat has no unsupported unread action",searchPeerReadMenuItem(context:context,peerId:PeerId(namespace:3,id:99)).value == nil)
82+
check("secret chat does not look up the visible user's read state",context.engine.data.requests.isEmpty)
83+
peer.isForumOrMonoForum=true;context.engine.data.state=ReadState()
84+
check("read forum does not offer an action that would clear topics",searchPeerReadMenuItem(context:context,peerId:id).value == nil)
85+
context.engine.data.state=ReadState(count:2)
86+
check("unread forum can be marked read",searchPeerReadMenuItem(context:context,peerId:id).value?.title == "Read")
87+
peer.isForumOrMonoForum=false;context.engine.data.state=ReadState()
88+
let top=Popular(context,.peer(peer,nil,false)).menuItems().value
89+
check("top peer has unread action",top.first?.title == "Unread")
90+
check("top peer retains remove action",top.last?.title == "Remove")
91+
check("top peer does not remove on menu construction",context.engine.peers.removed.isEmpty)
92+
check("Saved Messages has read action",Popular(context,.savedMessages(peer)).menuItems().value.first?.title == "Unread")
93+
check("articles do not acquire chat read action",Popular(context,.articles(2)).menuItems().value.isEmpty)
94+
context.engine.data.state=ReadState(marked:true)
95+
check("reopened top peer uses live manual unread mark",Popular(context,.peer(peer,nil,false)).menuItems().value.first?.title == "Read")
96+
peer.isForumOrMonoForum = true
97+
context.engine.data.state = ReadState()
98+
context.engine.data.cached = CachedChannelData(viewForumAsMessages: KnownBool(knownValue: true))
99+
check("forum displayed as messages retains Mark as Unread", searchPeerReadMenuItem(context: context, peerId: id).value?.title == "Unread")
100+
context.engine.data.cached = nil
101+
let tabsForum = TelegramChannel(id, forum: true)
102+
tabsForum.flags = [.displayForumAsTabs]
103+
context.engine.data.peer = EnginePeer(peer: tabsForum)
104+
check("forum displayed as tabs retains Mark as Unread", searchPeerReadMenuItem(context: context, peerId: id).value?.title == "Unread")
105+
context.engine.data.state = ReadState()
106+
context.account.postbox.data.rawState = ReadState(marked: true)
107+
let markedForum = searchPeerReadMenuItem(context: context, peerId: id).value
108+
check("regular-chat forum reads its peer-level manual unread mark", markedForum?.title == "Read")
109+
markedForum?.handler?()
110+
check("Read action clears the forum peer-level mark", !context.account.postbox.data.rawState.marked)
111+
check("reopened forum menu reflects cleared mark", searchPeerReadMenuItem(context: context, peerId: id).value?.title == "Unread")
112+
searchPeerReadMenuItem(context: context, peerId: id).value?.handler?()
113+
check("Unread action can set the peer-level mark again", context.account.postbox.data.rawState.marked)
114+
check("forum round trip returns Mark as Read", searchPeerReadMenuItem(context: context, peerId: id).value?.title == "Read")
115+
context.engine.data.requests=[]; context.calls=[]
116+
check("Saved Messages sub-chat search omits the real peer read action",searchPeerReadMenuItem(context:context,peerId:id,target:.savedMessages(id)).value == nil)
117+
check("Saved Messages sub-chat search never reads private-chat counters",context.engine.data.requests.isEmpty)
118+
check("Saved Messages sub-chat menu cannot send read receipts",context.calls.isEmpty)
119+
var firstWindow: NSObject?=NSObject()
120+
weak var retainedRun=firstWindow
121+
check("first diagnostic window acquires the run",SearchReadRunLease.begin(firstWindow!))
122+
firstWindow=nil
123+
check("closing the owner window preserves the active restore operation",retainedRun != nil)
124+
let reopenedWindow=NSObject()
125+
check("reopened window cannot overlap the restore run",!SearchReadRunLease.begin(reopenedWindow))
126+
SearchReadRunLease.end(reopenedWindow)
127+
check("another window cannot release the active lease",!SearchReadRunLease.begin(reopenedWindow))
128+
SearchReadRunLease.end(retainedRun!)
129+
check("finishing releases the old run owner",retainedRun == nil)
130+
check("a later window can start after restoration",SearchReadRunLease.begin(reopenedWindow))
131+
SearchReadRunLease.end(reopenedWindow)
132+
print("\(count-failures)/\(count) search unread checks passed")
133+
exit(failures==0 ? 0:1)

0 commit comments

Comments
 (0)