[WIP] Chatlist suggestions

This commit is contained in:
Isaac 2025-04-28 23:35:47 +02:00
parent 084bb5bcd5
commit e77402d7b3
18 changed files with 334 additions and 27 deletions

View File

@ -3368,6 +3368,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
}, openAdInfo: { node, adPeer in
interaction.openAdInfo(node, adPeer)
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
chatListInteraction.isSearchMode = true
@ -5363,6 +5364,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode {
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
var isInlineMode = false
if case .topics = key {

View File

@ -163,6 +163,7 @@ public final class ChatListShimmerNode: ASDisplayNode {
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
interaction.isInlineMode = isInlineMode

View File

@ -116,6 +116,7 @@ public final class ChatListNodeInteraction {
let openPhotoSetup: () -> Void
let openAdInfo: (ASDisplayNode, AdPeer) -> Void
let openAccountFreezeInfo: () -> Void
let openUrl: (String) -> Void
public var searchTextHighightState: String?
var highlightedChatLocation: ChatListHighlightedLocation?
@ -175,7 +176,8 @@ public final class ChatListNodeInteraction {
openWebApp: @escaping (TelegramUser) -> Void,
openPhotoSetup: @escaping () -> Void,
openAdInfo: @escaping (ASDisplayNode, AdPeer) -> Void,
openAccountFreezeInfo: @escaping () -> Void
openAccountFreezeInfo: @escaping () -> Void,
openUrl: @escaping (String) -> Void
) {
self.activateSearch = activateSearch
self.peerSelected = peerSelected
@ -223,6 +225,7 @@ public final class ChatListNodeInteraction {
self.openPhotoSetup = openPhotoSetup
self.openAdInfo = openAdInfo
self.openAccountFreezeInfo = openAccountFreezeInfo
self.openUrl = openUrl
}
}
@ -775,6 +778,8 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL
nodeInteraction?.openPhotoSetup()
case .accountFreeze:
nodeInteraction?.openAccountFreezeInfo()
case let .link(url, _, _):
nodeInteraction?.openUrl(url)
}
case .hide:
nodeInteraction?.dismissNotice(notice)
@ -1123,6 +1128,8 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL
nodeInteraction?.openPhotoSetup()
case .accountFreeze:
nodeInteraction?.openAccountFreezeInfo()
case let .link(url, _, _):
nodeInteraction?.openUrl(url)
}
case .hide:
nodeInteraction?.dismissNotice(notice)
@ -1906,6 +1913,12 @@ public final class ChatListNode: ListView {
self?.openAdInfo?(node, adPeer)
}, openAccountFreezeInfo: { [weak self] in
self?.openAccountFreezeInfo?()
}, openUrl: { [weak self] url in
guard let self else {
return
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: self.context.sharedContext.mainWindow?.viewController as? NavigationController, dismissInput: {})
})
nodeInteraction.isInlineMode = isInlineMode
@ -2134,6 +2147,8 @@ public final class ChatListNode: ListView {
}
return .birthdayPremiumGift(peers: todayBirthdayPeers, birthdays: birthdays)
}
} else if case let .link(url, title, subtitle) = suggestions.first(where: { if case .link = $0 { return true } else { return false} }) {
return .single(.link(url: url, title: title, subtitle: subtitle))
} else {
return .single(nil)
}

View File

@ -93,6 +93,7 @@ public enum ChatListNotice: Equatable {
case starsSubscriptionLowBalance(amount: StarsAmount, peers: [EnginePeer])
case setupPhoto(EnginePeer)
case accountFreeze
case link(url: String, title: String, subtitle: String)
}
enum ChatListNodeEntry: Comparable, Identifiable {

View File

@ -291,6 +291,9 @@ final class ChatListNoticeItemNode: ItemListRevealOptionsItemNode {
case .accountFreeze:
titleString = NSAttributedString(string: item.strings.ChatList_FrozenAccount_Title, font: titleFont, textColor: item.theme.list.itemDestructiveColor)
textString = NSAttributedString(string: item.strings.ChatList_FrozenAccount_Text, font: smallTextFont, textColor: item.theme.rootController.navigationBar.secondaryTextColor)
case let .link(_, title, subtitle):
titleString = NSAttributedString(string: title, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor)
textString = NSAttributedString(string: subtitle, font: smallTextFont, textColor: item.theme.rootController.navigationBar.secondaryTextColor)
}
var leftInset: CGFloat = sideInset
@ -383,7 +386,7 @@ final class ChatListNoticeItemNode: ItemListRevealOptionsItemNode {
let hasCloseButton: Bool
switch item.notice {
case .xmasPremiumGift, .setupBirthday, .birthdayPremiumGift, .premiumGrace, .starsSubscriptionLowBalance, .setupPhoto:
case .xmasPremiumGift, .setupBirthday, .birthdayPremiumGift, .premiumGrace, .starsSubscriptionLowBalance, .setupPhoto, .link:
hasCloseButton = true
default:
hasCloseButton = false

View File

@ -234,6 +234,7 @@ private final class TextSizeSelectionControllerNode: ASDisplayNode, ASScrollView
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
let chatListPresentationData = ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true)

View File

@ -383,6 +383,7 @@ final class ThemePreviewControllerNode: ASDisplayNode, ASScrollViewDelegate {
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
func makeChatListItem(

View File

@ -72,6 +72,112 @@ public final class PromoChatListItem: AdditionalChatListItem {
}
}
public final class ServerSuggestionInfo: Codable, Equatable {
public final class Item: Codable, Equatable {
public final class Text: Codable, Equatable {
public let string: String
public let entities: [MessageTextEntity]
public init(string: String, entities: [MessageTextEntity]) {
self.string = string
self.entities = entities
}
public static func ==(lhs: Text, rhs: Text) -> Bool {
if lhs.string != rhs.string {
return false
}
if lhs.entities != rhs.entities {
return false
}
return true
}
}
public enum Action: Codable, Equatable {
private enum CodingKeys: String, CodingKey {
case link
}
case link(url: String)
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self = .link(url: try container.decode(String.self, forKey: .link))
}
}
public let id: String
public let title: Text
public let text: Text
public let action: Action
public init(id: String, title: Text, text: Text, action: Action) {
self.id = id
self.title = title
self.text = text
self.action = action
}
public static func ==(lhs: Item, rhs: Item) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.action != rhs.action {
return false
}
return true
}
}
public let legacyItems: [String]
public let items: [Item]
public let dismissedIds: [String]
public init(legacyItems: [String], items: [Item], dismissedIds: [String]) {
self.legacyItems = legacyItems
self.items = items
self.dismissedIds = dismissedIds
}
public static func ==(lhs: ServerSuggestionInfo, rhs: ServerSuggestionInfo) -> Bool {
if lhs.items != rhs.items {
return false
}
return true
}
}
extension ServerSuggestionInfo.Item.Text {
convenience init(_ apiText: Api.TextWithEntities) {
switch apiText {
case let .textWithEntities(text, entities):
self.init(string: text, entities: messageTextEntitiesFromApiEntities(entities))
}
}
}
extension ServerSuggestionInfo.Item {
convenience init(_ apiItem: Api.PendingSuggestion) {
switch apiItem {
case let .pendingSuggestion(suggestion, title, description, url):
self.init(
id: suggestion,
title: ServerSuggestionInfo.Item.Text(title),
text: ServerSuggestionInfo.Item.Text(description),
action: .link(url: url)
)
}
}
}
func managedPromoInfoUpdates(accountPeerId: PeerId, postbox: Postbox, network: Network, viewTracker: AccountViewTracker) -> Signal<Void, NoError> {
return Signal { subscriber in
let queue = Queue()
@ -88,24 +194,38 @@ func managedPromoInfoUpdates(accountPeerId: PeerId, postbox: Postbox, network: N
switch data {
case .promoDataEmpty:
transaction.replaceAdditionalChatListItems([])
case let .promoData(_, _, peer, chats, users, psaType, psaMessage):
let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users)
case let .promoData(flags, expires, peer, psaType, psaMessage, pendingSuggestions, dismissedSuggestions, customPendingSuggestion, chats, users):
let _ = expires
let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users)
updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers)
let kind: PromoChatListItem.Kind
if let psaType = psaType {
var kind: PromoChatListItem.Kind?
if let psaType {
kind = .psa(type: psaType, message: psaMessage)
} else {
} else if ((flags & 1) << 0) != 0 {
kind = .proxy
}
var additionalChatListItems: [AdditionalChatListItem] = []
if let parsedPeer = transaction.getPeer(peer.peerId) {
if let kind, let peer, let parsedPeer = transaction.getPeer(peer.peerId) {
additionalChatListItems.append(PromoChatListItem(peerId: parsedPeer.id, kind: kind))
}
transaction.replaceAdditionalChatListItems(additionalChatListItems)
var customItems: [ServerSuggestionInfo.Item] = []
if let customPendingSuggestion {
customItems.append(ServerSuggestionInfo.Item(customPendingSuggestion))
}
let suggestionInfo = ServerSuggestionInfo(
legacyItems: pendingSuggestions,
items: customItems,
dismissedIds: dismissedSuggestions
)
transaction.updatePreferencesEntry(key: PreferencesKeys.serverSuggestionInfo(), { _ in
return PreferencesEntry(suggestionInfo)
})
}
}
}

View File

@ -3,21 +3,124 @@ import Postbox
import SwiftSignalKit
import TelegramApi
public enum ServerProvidedSuggestion: String {
case autoarchivePopular = "AUTOARCHIVE_POPULAR"
case newcomerTicks = "NEWCOMER_TICKS"
case validatePhoneNumber = "VALIDATE_PHONE_NUMBER"
case validatePassword = "VALIDATE_PASSWORD"
case setupPassword = "SETUP_PASSWORD"
case upgradePremium = "PREMIUM_UPGRADE"
case annualPremium = "PREMIUM_ANNUAL"
case restorePremium = "PREMIUM_RESTORE"
case xmasPremiumGift = "PREMIUM_CHRISTMAS"
case setupBirthday = "BIRTHDAY_SETUP"
case todayBirthdays = "BIRTHDAY_CONTACTS_TODAY"
case gracePremium = "PREMIUM_GRACE"
case starsSubscriptionLowBalance = "STARS_SUBSCRIPTION_LOW_BALANCE"
case setupPhoto = "USERPIC_SETUP"
public enum ServerProvidedSuggestion: Hashable {
case autoarchivePopular
case newcomerTicks
case validatePhoneNumber
case validatePassword
case setupPassword
case upgradePremium
case annualPremium
case restorePremium
case xmasPremiumGift
case setupBirthday
case todayBirthdays
case gracePremium
case starsSubscriptionLowBalance
case setupPhoto
case link(url: String, title: String, subtitle: String)
public init?(string: String) {
switch string {
case "AUTOARCHIVE_POPULAR":
self = .autoarchivePopular
case "NEWCOMER_TICKS":
self = .newcomerTicks
case "VALIDATE_PHONE_NUMBER":
self = .validatePhoneNumber
case "VALIDATE_PASSWORD":
self = .validatePassword
case "SETUP_PASSWORD":
self = .setupPassword
case "PREMIUM_UPGRADE":
self = .upgradePremium
case "PREMIUM_ANNUAL":
self = .annualPremium
case "PREMIUM_RESTORE":
self = .restorePremium
case "PREMIUM_CHRISTMAS":
self = .xmasPremiumGift
case "BIRTHDAY_SETUP":
self = .setupBirthday
case "BIRTHDAY_CONTACTS_TODAY":
self = .todayBirthdays
case "PREMIUM_GRACE":
self = .gracePremium
case "STARS_SUBSCRIPTION_LOW_BALANCE":
self = .starsSubscriptionLowBalance
case "USERPIC_SETUP":
self = .setupPhoto
default:
if string.hasPrefix("LINK_") {
let rawString = string.dropFirst(Int("LINK_".count))
if let dict = try? JSONSerialization.jsonObject(with: rawString.data(using: .utf8) ?? Data()) as? [String: Any] {
var url: String?
var title: String?
var subtitle: String?
if let urlValue = dict["url"] as? String {
url = urlValue
}
if let titleValue = dict["title"] as? String {
title = titleValue
}
if let subtitleValue = dict["subtitle"] as? String {
subtitle = subtitleValue
}
if let url = url, let title = title, let subtitle = subtitle {
self = .link(url: url, title: title, subtitle: subtitle)
return
}
}
}
return nil
}
}
var stringValue: String {
switch self {
case .autoarchivePopular:
return "AUTOARCHIVE_POPULAR"
case .newcomerTicks:
return "NEWCOMER_TICKS"
case .validatePhoneNumber:
return "VALIDATE_PHONE_NUMBER"
case .validatePassword:
return "VALIDATE_PASSWORD"
case .setupPassword:
return "SETUP_PASSWORD"
case .upgradePremium:
return "PREMIUM_UPGRADE"
case .annualPremium:
return "PREMIUM_ANNUAL"
case .restorePremium:
return "PREMIUM_RESTORE"
case .xmasPremiumGift:
return "PREMIUM_CHRISTMAS"
case .setupBirthday:
return "BIRTHDAY_SETUP"
case .todayBirthdays:
return "BIRTHDAY_CONTACTS_TODAY"
case .gracePremium:
return "PREMIUM_GRACE"
case .starsSubscriptionLowBalance:
return "STARS_SUBSCRIPTION_LOW_BALANCE"
case .setupPhoto:
return "USERPIC_SETUP"
case let .link(url, title, subtitle):
let dict: [String: String] = [
"url": url,
"title": title,
"subtitle": subtitle
]
if let data = try? JSONSerialization.data(withJSONObject: dict, options: []), let string = String(data: data, encoding: .utf8) {
return "LINK_\(string)"
} else {
// Fallback or error handling, though unlikely to fail with basic strings
return "LINK_{}"
}
}
}
}
private var dismissedSuggestionsPromise = ValuePromise<[AccountRecordId: Set<ServerProvidedSuggestion>]>([:])
@ -38,11 +141,20 @@ func _internal_getServerProvidedSuggestions(account: Account) -> Signal<[ServerP
guard let appConfiguration = view.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) else {
return []
}
#if DEBUG
guard let data = appConfiguration.data, var listItems = data["pending_suggestions"] as? [String] else {
return []
}
listItems.insert("LINK_{\"url\": \"https://t.me/durov\", \"title\": \"📣 Stay updated!\", \"subtitle\": \"Subscribe to the channel of Telegram's founder.\"}", at: 0)
#else
guard let data = appConfiguration.data, let listItems = data["pending_suggestions"] as? [String] else {
return []
}
#endif
return listItems.compactMap { item -> ServerProvidedSuggestion? in
return ServerProvidedSuggestion(rawValue: item)
return ServerProvidedSuggestion(string: item)
}.filter { !dismissedSuggestions.contains($0) }
}
|> distinctUntilChanged
@ -64,7 +176,7 @@ func _internal_getServerDismissedSuggestions(account: Account) -> Signal<[Server
listItems.append(contentsOf: listItemsValues)
}
var items = listItems.compactMap { item -> ServerProvidedSuggestion? in
return ServerProvidedSuggestion(rawValue: item)
return ServerProvidedSuggestion(string: item)
}
items.append(contentsOf: dismissedSuggestions)
return items
@ -78,7 +190,7 @@ func _internal_dismissServerProvidedSuggestion(account: Account, suggestion: Ser
} else {
dismissedSuggestions[account.id] = Set([suggestion])
}
return account.network.request(Api.functions.help.dismissSuggestion(peer: .inputPeerEmpty, suggestion: suggestion.rawValue))
return account.network.request(Api.functions.help.dismissSuggestion(peer: .inputPeerEmpty, suggestion: suggestion.stringValue))
|> `catch` { _ -> Signal<Api.Bool, NoError> in
return .single(.boolFalse)
}

View File

@ -315,6 +315,7 @@ private enum PreferencesKeyValues: Int32 {
case starGifts = 41
case botStorageState = 42
case secureBotStorageState = 43
case serverSuggestionInfo = 44
}
public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey {
@ -558,6 +559,12 @@ public struct PreferencesKeys {
key.setInt32(0, value: PreferencesKeyValues.secureBotStorageState.rawValue)
return key
}
public static func serverSuggestionInfo() -> ValueBoxKey {
let key = ValueBoxKey(length: 4 + 8)
key.setInt32(0, value: PreferencesKeyValues.serverSuggestionInfo.rawValue)
return key
}
}
private enum SharedDataKeyValues: Int32 {

View File

@ -202,6 +202,7 @@ private enum ApplicationSpecificGlobalNotice: Int32 {
case captionAboveMediaTooltip = 75
case channelSendGiftTooltip = 76
case starGiftWearTips = 77
case channelSuggestTooltip = 78
var key: ValueBoxKey {
let v = ValueBoxKey(length: 4)
@ -559,6 +560,10 @@ private struct ApplicationSpecificNoticeKeys {
static func starGiftWearTips() -> NoticeEntryKey {
return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.starGiftWearTips.key)
}
static func channelSuggestTooltip() -> NoticeEntryKey {
return NoticeEntryKey(namespace: noticeNamespace(namespace: globalNamespace), key: ApplicationSpecificGlobalNotice.channelSuggestTooltip.key)
}
}
public struct ApplicationSpecificNotice {
@ -2368,6 +2373,33 @@ public struct ApplicationSpecificNotice {
}
}
public static func getChannelSuggestTooltip(accountManager: AccountManager<TelegramAccountManagerTypes>) -> Signal<Int32, NoError> {
return accountManager.transaction { transaction -> Int32 in
if let value = transaction.getNotice(ApplicationSpecificNoticeKeys.channelSuggestTooltip())?.get(ApplicationSpecificCounterNotice.self) {
return value.value
} else {
return 0
}
}
}
public static func incrementChannelSuggestTooltip(accountManager: AccountManager<TelegramAccountManagerTypes>, count: Int = 1) -> Signal<Int, NoError> {
return accountManager.transaction { transaction -> Int in
var currentValue: Int32 = 0
if let value = transaction.getNotice(ApplicationSpecificNoticeKeys.channelSuggestTooltip())?.get(ApplicationSpecificCounterNotice.self) {
currentValue = value.value
}
let previousValue = currentValue
currentValue += Int32(count)
if let entry = CodableEntry(ApplicationSpecificCounterNotice(value: currentValue)) {
transaction.setNotice(ApplicationSpecificNoticeKeys.channelSuggestTooltip(), entry)
}
return Int(previousValue)
}
}
public static func getStarGiftWearTips(accountManager: AccountManager<TelegramAccountManagerTypes>) -> Signal<Int32, NoError> {
return accountManager.transaction { transaction -> Int32 in
if let value = transaction.getNotice(ApplicationSpecificNoticeKeys.starGiftWearTips())?.get(ApplicationSpecificCounterNotice.self) {

View File

@ -689,6 +689,7 @@ public final class ChatInlineSearchResultsListComponent: Component {
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
}, openUrl: { _ in
}
)
self.chatListNodeInteraction = chatListNodeInteraction

View File

@ -189,6 +189,7 @@ public final class LoadingOverlayNode: ASDisplayNode {
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
let items = (0 ..< 1).map { _ -> ChatListItem in
@ -551,6 +552,8 @@ private final class PeerInfoScreenPersonalChannelItemNode: PeerInfoScreenItemNod
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
},
openUrl: { _ in
}
)

View File

@ -215,6 +215,8 @@ final class GreetingMessageListItemComponent: Component {
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
},
openUrl: { _ in
}
)
self.chatListNodeInteraction = chatListNodeInteraction

View File

@ -236,6 +236,8 @@ final class QuickReplySetupScreenComponent: Component {
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
},
openUrl: { _ in
}
)

View File

@ -877,6 +877,7 @@ final class ThemeAccentColorControllerNode: ASDisplayNode, ASScrollViewDelegate
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
let chatListPresentationData = ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true)

View File

@ -297,6 +297,7 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe
}, openPhotoSetup: {
}, openAdInfo: { _, _ in
}, openAccountFreezeInfo: {
}, openUrl: { _ in
})
interaction.searchTextHighightState = searchQuery
self.interaction = interaction

View File

@ -185,6 +185,8 @@ private struct CommandChatInputContextPanelEntry: Comparable, Identifiable {
openAdInfo: { _, _ in
},
openAccountFreezeInfo: {
},
openUrl: { _ in
}
)