From c44bba5560265e943c7ff774a4061675c4c21b6a Mon Sep 17 00:00:00 2001 From: morethanwords Date: Sat, 15 Jan 2022 03:20:59 +0400 Subject: [PATCH] Sponsored messages --- src/components/chat/bubbles.ts | 37 +++++++++++++--- src/components/chat/contextMenu.ts | 56 ++++++++++++++++-------- src/components/chat/selection.ts | 2 +- src/components/popups/peer.ts | 4 +- src/components/popups/sponsored.ts | 42 ++++++++++++++++++ src/lang.ts | 4 ++ src/lib/appManagers/appImManager.ts | 5 ++- src/lib/rootScope.ts | 2 +- src/scss/partials/_chat.scss | 6 +++ src/scss/partials/popups/_sponsored.scss | 26 +++++++++++ src/scss/style.scss | 1 + 11 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 src/components/popups/sponsored.ts create mode 100644 src/scss/partials/popups/_sponsored.scss diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 20298870..23025815 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -194,6 +194,7 @@ export default class ChatBubbles { private getSponsoredMessagePromise: Promise; private previousStickyDate: HTMLElement; + sponsoredMessage: import("/Users/kuzmenko/Documents/projects/tweb/src/layer").SponsoredMessage.sponsoredMessage; constructor( private chat: Chat, @@ -713,9 +714,22 @@ export default class ChatBubbles { this.viewsObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if(entry.isIntersecting) { - this.viewsMids.add(+(entry.target as HTMLElement).dataset.mid); + const mid = +(entry.target as HTMLElement).dataset.mid; this.viewsObserver.unobserve(entry.target); - this.sendViewCountersDebounced(); + + if(mid) { + this.viewsMids.add(mid); + this.sendViewCountersDebounced(); + } else { + const {sponsoredMessage} = this; + if(sponsoredMessage && sponsoredMessage.random_id) { + delete sponsoredMessage.random_id; + this.chat.apiManager.invokeApiSingle('channels.viewSponsoredMessage', { + channel: this.appChatsManager.getChannelInput(this.peerId.toChatId()), + random_id: sponsoredMessage.random_id + }); + } + } } }); }); @@ -1946,6 +1960,7 @@ export default class ChatBubbles { this.onAnimateLadder = undefined; this.resolveLadderAnimation = undefined; this.emptyPlaceholderMid = undefined; + this.sponsoredMessage = undefined; this.scrollingToBubble = undefined; ////console.timeEnd('appImManager cleanup'); @@ -3869,9 +3884,9 @@ export default class ChatBubbles { const elements: (Node | string)[] = []; const isBot = this.appPeersManager.isBot(this.peerId); if(isSponsored) { - let text: LangPackKey, mid: number, callback: () => void; + let text: LangPackKey, mid: number, startParam: string, callback: () => void; - const sponsoredMessage = (message as Message.message).sponsoredMessage; + const sponsoredMessage = this.sponsoredMessage = (message as Message.message).sponsoredMessage; const peerId = this.appPeersManager.getPeerId(sponsoredMessage.from_id); // const peer = this.appPeersManager.getPeer(peerId); if(sponsoredMessage.channel_post) { @@ -3879,6 +3894,7 @@ export default class ChatBubbles { mid = this.appMessagesIdsManager.generateMessageId(sponsoredMessage.channel_post); } else if(sponsoredMessage.start_param) { text = 'Chat.Message.ViewBot'; + startParam = sponsoredMessage.start_param; } else { text = this.appPeersManager.isAnyGroup(peerId) ? 'Chat.Message.ViewGroup' : 'Chat.Message.ViewChannel'; } @@ -3886,7 +3902,8 @@ export default class ChatBubbles { callback = () => { rootScope.dispatchEvent('history_focus', { peerId, - mid + mid, + startParam }); }; @@ -3894,6 +3911,8 @@ export default class ChatBubbles { text }); + this.viewsObserver.observe(button); + if(callback) { attachClickEvent(button, callback); } @@ -3996,7 +4015,7 @@ export default class ChatBubbles { return; } */ - if(side === 'bottom' && this.appPeersManager.isBroadcast(this.peerId) && false) { + if(side === 'bottom' && this.appPeersManager.isBroadcast(this.peerId)/* && false */) { const {mid} = this.generateLocalMessageId(SPONSORED_MESSAGE_ID_OFFSET); if(value) { const middleware = this.getMiddleware(() => { @@ -4008,6 +4027,12 @@ export default class ChatBubbles { }, {cacheSeconds: 300}).then(sponsoredMessages => { if(!middleware()) return; + forEachReverse(sponsoredMessages.messages, (message, idx, arr) => { + if(message.chat_invite || message.chat_invite_hash) { + arr.splice(idx, 1); + } + }); + this.appUsersManager.saveApiUsers(sponsoredMessages.users); this.appChatsManager.saveApiChats(sponsoredMessages.chats); diff --git a/src/components/chat/contextMenu.ts b/src/components/chat/contextMenu.ts index 7c760db1..ee5efc1a 100644 --- a/src/components/chat/contextMenu.ts +++ b/src/components/chat/contextMenu.ts @@ -19,7 +19,7 @@ import PopupPinMessage from "../popups/unpinMessage"; import { copyTextToClipboard } from "../../helpers/clipboard"; import PopupSendNow from "../popups/sendNow"; import { toast } from "../toast"; -import I18n, { LangPackKey } from "../../lib/langPack"; +import I18n, { i18n, LangPackKey } from "../../lib/langPack"; import findUpClassName from "../../helpers/dom/findUpClassName"; import { cancelEvent } from "../../helpers/dom/cancelEvent"; import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; @@ -27,9 +27,10 @@ import isSelectionEmpty from "../../helpers/dom/isSelectionEmpty"; import { Message, Poll, Chat as MTChat, MessageMedia } from "../../layer"; import PopupReportMessages from "../popups/reportMessages"; import assumeType from "../../helpers/assumeType"; +import PopupSponsored from "../popups/sponsored"; export default class ChatContextMenu { - private buttons: (ButtonMenuItemOptions & {verify: () => boolean, notDirect?: () => boolean, withSelection?: true})[]; + private buttons: (ButtonMenuItemOptions & {verify: () => boolean, notDirect?: () => boolean, withSelection?: true, isSponsored?: true})[]; private element: HTMLElement; private isSelectable: boolean; @@ -77,6 +78,7 @@ export default class ChatContextMenu { let mid = +bubble.dataset.mid; if(!mid) return; + const isSponsored = mid < 0; this.isSelectable = this.chat.selection.canSelectBubble(bubble); this.peerId = this.chat.peerId; //this.msgID = msgID; @@ -90,6 +92,10 @@ export default class ChatContextMenu { // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты if(chat.selection.isSelecting && !contentWrapper) { + if(isSponsored) { + return; + } + const mids = this.chat.getMidsByMid(mid); if(mids.length > 1) { const selectedMid = this.chat.selection.isMidSelected(this.peerId, mid) ? @@ -111,22 +117,28 @@ export default class ChatContextMenu { this.isSelected = this.chat.selection.isMidSelected(this.peerId, this.mid); this.message = this.chat.getMessage(this.mid); - this.noForwards = !this.appMessagesManager.canForward(this.message); - - this.buttons.forEach(button => { - let good: boolean; - - //if((appImManager.chatSelection.isSelecting && !button.withSelection) || (button.withSelection && !appImManager.chatSelection.isSelecting)) { - if(chat.selection.isSelecting && !button.withSelection) { - good = false; - } else { - good = contentWrapper || IS_TOUCH_SUPPORTED || true ? - button.verify() : - button.notDirect && button.verify() && button.notDirect(); - } - - button.element.classList.toggle('hide', !good); - }); + if(isSponsored) { + this.buttons.forEach(button => { + button.element.classList.toggle('hide', !button.isSponsored); + }); + } else { + this.noForwards = !this.appMessagesManager.canForward(this.message); + + this.buttons.forEach(button => { + let good: boolean; + + //if((appImManager.chatSelection.isSelecting && !button.withSelection) || (button.withSelection && !appImManager.chatSelection.isSelecting)) { + if(chat.selection.isSelecting && !button.withSelection) { + good = false; + } else { + good = contentWrapper || IS_TOUCH_SUPPORTED || true ? + button.verify() : + button.notDirect && button.verify() && button.notDirect(); + } + + button.element.classList.toggle('hide', !good); + }); + } const side: 'left' | 'right' = bubble.classList.contains('is-in') ? 'left' : 'right'; //bubble.parentElement.append(this.element); @@ -361,6 +373,14 @@ export default class ChatContextMenu { verify: () => this.isSelected && !this.chat.selection.selectionDeleteBtn.hasAttribute('disabled'), notDirect: () => true, withSelection: true + }, { + icon: 'info', + text: 'Chat.Message.Sponsored.What', + onClick: () => { + new PopupSponsored(); + }, + verify: () => false, + isSponsored: true }]; this.element = ButtonMenu(this.buttons, this.chat.bubbles.listenerSetter); diff --git a/src/components/chat/selection.ts b/src/components/chat/selection.ts index 516648f1..a3243f93 100644 --- a/src/components/chat/selection.ts +++ b/src/components/chat/selection.ts @@ -820,7 +820,7 @@ export default class ChatSelection extends AppSelection { } public canSelectBubble(bubble: HTMLElement) { - return !bubble.classList.contains('service') && !bubble.classList.contains('is-sending') && !bubble.classList.contains('bubble-first'); + return !bubble.classList.contains('service') && !bubble.classList.contains('is-sending') && !bubble.classList.contains('bubble-first') && bubble.parentElement.classList.contains('bubbles-date-group'); } protected onToggleSelection = (forwards: boolean, animate: boolean) => { diff --git a/src/components/popups/peer.ts b/src/components/popups/peer.ts index fdae061c..3afb1953 100644 --- a/src/components/popups/peer.ts +++ b/src/components/popups/peer.ts @@ -27,6 +27,8 @@ export type PopupPeerOptions = PopupOptions & Partial<{ checkboxes: Array }>; export default class PopupPeer extends PopupElement { + protected description: HTMLParagraphElement; + constructor(private className: string, options: PopupPeerOptions = {}) { super('popup-peer' + (className ? ' ' + className : ''), options.buttons && addCancelButton(options.buttons), {overlayClosable: true, ...options}); @@ -48,7 +50,7 @@ export default class PopupPeer extends PopupElement { const fragment = document.createDocumentFragment(); if(options.descriptionLangKey || options.description) { - const p = document.createElement('p'); + const p = this.description = document.createElement('p'); p.classList.add('popup-description'); if(options.descriptionLangKey) p.append(i18n(options.descriptionLangKey, options.descriptionLangArgs)); else if(options.description) p.innerHTML = options.description; diff --git a/src/components/popups/sponsored.ts b/src/components/popups/sponsored.ts new file mode 100644 index 00000000..ce58eb3a --- /dev/null +++ b/src/components/popups/sponsored.ts @@ -0,0 +1,42 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import I18n, { i18n } from "../../lib/langPack"; +import Scrollable from "../scrollable"; +import PopupPeer from "./peer"; + +export default class PopupSponsored extends PopupPeer { + constructor() { + super('popup-sponsored', { + titleLangKey: 'Chat.Message.Sponsored.What', + descriptionLangKey: 'Chat.Message.Ad.Text', + descriptionLangArgs: [i18n('Chat.Message.Sponsored.Link')], + buttons: [{ + langKey: 'OK', + isCancel: true + }, { + langKey: 'Chat.Message.Ad.ReadMore', + callback: () => { + window.open(I18n.format('Chat.Message.Sponsored.Link', true)); + }, + isCancel: true + }] + }); + + const scrollable = new Scrollable(undefined); + scrollable.onAdditionalScroll = () => { + scrollable.container.classList.toggle('scrolled-top', !scrollable.scrollTop); + scrollable.container.classList.toggle('scrolled-bottom', scrollable.isScrolledDown); + }; + + this.description.replaceWith(scrollable.container); + + scrollable.container.append(this.description); + scrollable.container.classList.add('scrolled-top'); + + this.show(); + } +} diff --git a/src/lang.ts b/src/lang.ts index ccc23b31..9579da5a 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -744,9 +744,13 @@ const lang = { "one_value": "Do you want to unpin %d message in this chat?", "other_value": "Do you want to unpin all %d messages in this chat?" }, + "Chat.Message.Ad.Text": "Unlike other apps, Telegram never uses your private data to target ads. Sponsored messages on Telegram are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored messages.\n\nUnlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:\n\n%@\n\nSponsored Messages are currently in test mode. Once they are fully launched and allow Telegram to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together.", + "Chat.Message.Ad.ReadMore": "Read More", "Chat.Message.ViewChannel": "VIEW CHANNEL", "Chat.Message.ViewBot": "VIEW BOT", "Chat.Message.ViewGroup": "VIEW GROUP", + "Chat.Message.Sponsored.What": "What are sponsored messages?", + "Chat.Message.Sponsored.Link": "https://promote.telegram.org", "ChatList.Context.Mute": "Mute", "ChatList.Context.Unmute": "Unmute", "ChatList.Context.Pin": "Pin", diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 6df5b162..d23122d5 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -207,7 +207,7 @@ export class AppImManager { }); rootScope.addEventListener('history_focus', (e) => { - let {peerId, threadId, mid} = e; + let {peerId, threadId, mid, startParam} = e; if(threadId) threadId = appMessagesIdsManager.generateMessageId(threadId); if(mid) mid = appMessagesIdsManager.generateMessageId(mid); // because mid can come from notification, i.e. server message id @@ -215,7 +215,8 @@ export class AppImManager { peerId, lastMsgId: mid, type: threadId ? 'discussion' : undefined, - threadId + threadId, + startParam }); }); diff --git a/src/lib/rootScope.ts b/src/lib/rootScope.ts index f4ebfc61..87b3a2ff 100644 --- a/src/lib/rootScope.ts +++ b/src/lib/rootScope.ts @@ -69,7 +69,7 @@ export type BroadcastEvents = { 'history_delete': {peerId: PeerId, msgs: Set}, 'history_forbidden': PeerId, 'history_reload': PeerId, - 'history_focus': {peerId: PeerId, threadId?: number, mid?: number}, + 'history_focus': {peerId: PeerId, threadId?: number, mid?: number, startParam?: string}, //'history_request': void, 'message_edit': {storage: MessagesStorage, peerId: PeerId, mid: number}, diff --git a/src/scss/partials/_chat.scss b/src/scss/partials/_chat.scss index d4ce933e..de2b9108 100644 --- a/src/scss/partials/_chat.scss +++ b/src/scss/partials/_chat.scss @@ -1412,6 +1412,12 @@ $background-transition-total-time: #{$input-transition-time - $background-transi > .bubble.is-in { // margin-left: 0; width: 100%; + + @include respond-to(medium-screens) { + .bubble-content-wrapper { + max-width: 85%; + } + } } } diff --git a/src/scss/partials/popups/_sponsored.scss b/src/scss/partials/popups/_sponsored.scss new file mode 100644 index 00000000..e17c26ce --- /dev/null +++ b/src/scss/partials/popups/_sponsored.scss @@ -0,0 +1,26 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +.popup-sponsored { + .scrollable-y { + position: relative; + max-height: 25rem; + margin: 0 -1.5rem; + width: calc(100% + 3rem); + padding: .5rem 1.5rem; + user-select: text; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + + &:not(.scrolled-top) { + border-top: 1px solid var(--border-color); + } + + &:not(.scrolled-bottom) { + border-bottom: 1px solid var(--border-color); + } + } +} diff --git a/src/scss/style.scss b/src/scss/style.scss index 830683fe..f5c074b0 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -313,6 +313,7 @@ $chat-input-inner-padding-handhelds: .25rem; @import "partials/popups/reportMessages"; @import "partials/popups/groupCall"; @import "partials/popups/call"; +@import "partials/popups/sponsored"; @import "partials/pages/pages"; @import "partials/pages/authCode";