Skip to content

Commit 61c89e9

Browse files
morethanwordsclaude
andcommitted
Chat: a bot's keyboard fills its height unless it asks to fit its rows, says its placeholder in the field, shows a selective one to whom it is for, and is answered in a group as a reply
Four things the official clients do with a bot's reply keyboard that tweb did not, and a bug of the tab's mirror found on the way. - Height: a keyboard that does not ask to be sized by its rows (no `resize`, most bots') is as tall as it may be - the same 15rem, less on a short screen - its rows sharing that height and its buttons filling their row (`.is-stretched`; desktop's `_maximizeSize`, iOS and Android make it the device keyboard's height). Rows that need more are their own height and scroll as before; one with `resize` is sized by its rows as before. - Placeholder: the markup's `placeholder` (a keyboard's or a force reply's) stands in the empty field instead of its own words while the keyboard is open, or while the field answers the message that brought it (Android's rule). The field's own wait under it in the IntlElement (`ChatInput.updateKeyboardPlaceholder`), and come back once it goes or a language is applied over it. Not while a message is edited, and not where the field says more than that it asks for a message (`yieldsToKeyboard`): it cannot send, a welcome template, an instruction, a story reply, or a paid message's price, which is said before it is sent (Android). The bot's words go in their own direction. - Selective: a markup that is `selective` applies to those its message is `mentioned` for - mentioned, or the author of the message it answers (desktop's `mentionsMe`, Android's). Before, every one was dropped. - A button pressed in a group sends its text as a reply to the keyboard's message, as desktop and Android do: a bot in privacy mode is told only of what is meant for it. The field's own reply stays for the next message; a private chat and an ephemeral keyboard are as they were. - The tab's mirror of a history storage (`apiManagerProxy`) wrote the worker's value with a store setter, which merges an object into the one there: a force reply after a keyboard kept the keyboard's rows and opened as that keyboard, and a keyboard with no placeholder kept the one before's. An object now takes the place of the one there. - Tests: the keyboard spec checks the stretched rows, the placeholder of a keyboard and of a force reply, and a markup handed over by the mirror in place of another; its helper writes `resize` by default and replaces the markup as the mirror now does. Unit tests for selective markups and the press in a group. Left out on purpose: iOS closing the keyboard on a tap in the history (Android and desktop do not), and iOS putting the reply into the field as a group's keyboard comes (desktop's reply on the press instead). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 182e6e9 commit 61c89e9

10 files changed

Lines changed: 318 additions & 43 deletions

‎e2e/replyKeyboardApp.spec.ts‎

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -370,25 +370,8 @@ test.describe('the bot keyboard under the field', () => {
370370

371371
test('a keyboard that comes as the chat opens does not open over the draft on its way back', async({page}) => {
372372
// * the chat opened again with a saved draft, which the worker answers late; nothing is saved or sent
373-
await page.evaluate(() => {
374-
const w = window as any;
375-
const proxy = w.apiManagerProxy;
376-
const invoke = proxy.invoke.bind(proxy);
377-
proxy.invoke = (type: string, payload: any, ack: boolean, ...rest: any[]) => {
378-
if(type === 'manager' && /^(send|saveDraft|setDraft|clearDraft|syncDraft|read)/.test(payload.method)) {
379-
return ack ? {cached: true, result: Promise.resolve()} : Promise.resolve();
380-
}
381-
382-
if(type === 'manager' && payload.method === 'getDraft') {
383-
const draft = {_: 'draftMessage', pFlags: {}, message: 'A saved draft', date: Math.floor(Date.now() / 1000)};
384-
const result = new Promise((resolve) => setTimeout(() => resolve(draft), 300));
385-
return ack ? {cached: false, result} : result;
386-
}
387-
388-
return invoke(type, payload, ack, ...rest);
389-
};
390-
w.appImManager.setPeer({peerId: 0});
391-
});
373+
await holdWrites(page, 'A saved draft');
374+
await page.evaluate(() => (window as any).appImManager.setPeer({peerId: 0}));
392375
await page.waitForTimeout(500);
393376
await page.evaluate(() => {
394377
const w = window as any;
@@ -602,6 +585,110 @@ test.describe('the bot keyboard under the field', () => {
602585
expect(opened.scroll.clientHeight - grown.scroll.clientHeight).toBeCloseTo(48, 0);
603586
});
604587

588+
test('one that does not ask to fit its rows is as tall as it may be, its rows sharing that height', async({page}) => {
589+
const rows = () => page.evaluate(() => Array.from(document.querySelectorAll('.reply-keyboard .reply-markup-row'), (row) => {
590+
const {top, bottom, height} = row.getBoundingClientRect();
591+
return {top, bottom, height, button: row.firstElementChild.getBoundingClientRect().height};
592+
}));
593+
const rem = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize));
594+
await setKeyboard(page, 3);
595+
const closed = await measure(page);
596+
await page.locator('.chat-input-main .toggle-reply-markup').click();
597+
await settled(page);
598+
const [fitted] = await rows();
599+
600+
await setKeyboard(page, 3, {stretched: true, comes: true});
601+
const opened = await settled(page);
602+
expect(opened.scroll.clientHeight).toBeCloseTo(15 * rem, 0);
603+
expect(opened.scroll.scrollHeight).toBe(opened.scroll.clientHeight);
604+
expect(opened.keyboard.height).toBeCloseTo(15.375 * rem, 0);
605+
// * the field and the history standing on it all the way
606+
expect(closed.field.bottom - opened.field.bottom).toBeCloseTo(opened.keyboard.height, 0);
607+
expect(opened.paddingBottom - closed.paddingBottom).toBeCloseTo(opened.keyboard.height, 0);
608+
// * rows of one height, from its top to its bottom, as far apart as ever, their buttons as tall as they are
609+
const stretched = await rows();
610+
expect(stretched[0].height).toBeCloseTo((15 - 2 * .25) * rem / 3, 0);
611+
for(const row of stretched) {
612+
expect(row.height).toBeCloseTo(stretched[0].height, 0);
613+
expect(row.button).toBeCloseTo(row.height, 0);
614+
}
615+
expect(stretched[0].top - opened.field.bottom).toBeCloseTo(.375 * rem, 0);
616+
expect(stretched[2].bottom).toBeCloseTo(opened.keyboard.bottom, 0);
617+
expect(stretched[1].top - stretched[0].bottom).toBeCloseTo(.25 * rem, 0);
618+
619+
// * more of them than fit: as tall as they are, scrolled
620+
await setKeyboard(page, 12, {stretched: true, comes: true});
621+
const scrolled = await settled(page);
622+
expect(scrolled.scroll.clientHeight).toBeCloseTo(15 * rem, 0);
623+
expect(scrolled.scroll.scrollHeight).toBeGreaterThan(scrolled.scroll.clientHeight);
624+
expect((await rows())[0].height).toBeCloseTo(fitted.height, 0);
625+
});
626+
627+
test('the field says what the keyboard asks it to while it is open, and what a force reply does while it answers it', async({page}) => {
628+
await holdWrites(page);
629+
const placeholder = () => page.evaluate(() => {
630+
const element = (window as any).appImManager.chat.input.messageInputField.placeholder as HTMLElement;
631+
return {text: element.textContent, dir: element.querySelector('[dir]')?.getAttribute('dir')};
632+
});
633+
const own = await placeholder();
634+
expect(own.text).toBeTruthy();
635+
636+
await setKeyboard(page, 2, {placeholder: 'Pick a city'});
637+
expect(await placeholder()).toEqual(own);
638+
await page.locator('.chat-input-main .toggle-reply-markup').click();
639+
await settled(page);
640+
// * in the direction of the bot's words
641+
expect(await placeholder()).toEqual({text: 'Pick a city', dir: 'auto'});
642+
await page.keyboard.press('Escape');
643+
await settled(page);
644+
expect(await placeholder()).toEqual(own);
645+
646+
// * a force reply: the field answers its message, in its words, until that answer is dropped
647+
const mid = await page.evaluate(() => {
648+
const chat = (window as any).appImManager.chat;
649+
const mid = Math.max(...Array.from(document.querySelectorAll<HTMLElement>('.bubbles-inner .bubble[data-mid]'), (bubble) => +bubble.dataset.mid));
650+
const [, setStore] = Object.values((window as any).historyStorages).find(([candidate]: any) => candidate === chat.historyStorage) as [any, (key: string, value: any) => void];
651+
setStore('replyMarkup', undefined);
652+
setStore('replyMarkup', {_: 'replyKeyboardForceReply', pFlags: {}, mid, fromId: chat.peerId, placeholder: 'Your name'});
653+
(window as any).rootScope.dispatchEventSingle('history_reply_markup', {peerId: chat.peerId});
654+
return mid;
655+
});
656+
await expect.poll(placeholder).toEqual({text: 'Your name', dir: 'auto'});
657+
expect(await page.evaluate(() => (window as any).appImManager.chat.input.replyToMsgId)).toBe(mid);
658+
await page.evaluate(() => (window as any).appImManager.chat.input.clearHelper());
659+
await expect.poll(placeholder).toEqual(own);
660+
});
661+
662+
test('a markup the worker puts in place of another keeps nothing of the one before', async({page}) => {
663+
await holdWrites(page);
664+
// * as the worker's mirror hands the history's markup over to the tab
665+
const mirror = (markup: any) => page.evaluate((markup) => {
666+
const w = window as any;
667+
const chat = w.appImManager.chat;
668+
w.apiManagerProxy.processMirrorTaskMap.historyStorage({
669+
key: [chat.historyStorage.key, 'replyMarkup'].join('\x01'),
670+
value: {...markup, fromId: chat.peerId}
671+
});
672+
w.rootScope.dispatchEventSingle('history_reply_markup', {peerId: chat.peerId});
673+
}, markup);
674+
const placeholder = () => page.evaluate(() => (window as any).appImManager.chat.input.messageInputField.placeholder.textContent);
675+
const own = await placeholder();
676+
const rows = [{_: 'keyboardButtonRow', buttons: [{_: 'keyboardButton', text: 'Menu', type: {_: 'buttonTypeDefault'}}]}];
677+
678+
await mirror({_: 'replyKeyboardMarkup', pFlags: {resize: true}, mid: 1101, placeholder: 'Pick a city', rows});
679+
expect((await settled(page)).active).toBe(true);
680+
expect(await placeholder()).toBe('Pick a city');
681+
// * another, with no words of its own: the field's
682+
await mirror({_: 'replyKeyboardMarkup', pFlags: {resize: true}, mid: 1102, rows});
683+
expect((await settled(page)).active).toBe(true);
684+
expect(await placeholder()).toBe(own);
685+
// * a force reply after it: no rows left of the keyboard to show
686+
const mid = await page.evaluate(() => Math.max(...Array.from(document.querySelectorAll<HTMLElement>('.bubbles-inner .bubble[data-mid]'), (bubble) => +bubble.dataset.mid)));
687+
await mirror({_: 'replyKeyboardForceReply', pFlags: {}, mid});
688+
expect((await settled(page)).active).toBe(false);
689+
await expect(page.locator('.chat-input-main .toggle-reply-markup')).toHaveClass(/\bhide\b/);
690+
});
691+
605692
test('scrolls past 15rem', async({page}) => {
606693
await setKeyboard(page, 12);
607694
await page.locator('.chat-input-main .toggle-reply-markup').click();
@@ -642,6 +729,10 @@ async function openSavedMessages(page: Page, query = '') {
642729

643730
type KeyboardOptions = {
644731
singleUse?: boolean,
732+
// * not asking to fit its rows (`resize`), as most bots' are
733+
stretched?: boolean,
734+
// * what it asks the empty field to say
735+
placeholder?: string,
645736
// * a single-use one answered already
646737
hidden?: boolean,
647738
// * the message that brought it
@@ -653,7 +744,7 @@ type KeyboardOptions = {
653744
// * the keyboard a bot's last message would have left in the chat, `rows` rows of it (none: what there was, and the
654745
// * user's closing of it forgotten)
655746
async function setKeyboard(page: Page, rows: number, options: KeyboardOptions = {}) {
656-
await page.evaluate(async([rows, {singleUse, hidden, mid = 1, comes}]) => {
747+
await page.evaluate(async([rows, {singleUse, stretched, placeholder, hidden, mid = 1, comes}]) => {
657748
const chat = (window as any).appImManager.chat;
658749
// * the store and its setter, the app's own (`historyStorages`)
659750
const [, setStore] = Object.values((window as any).historyStorages).find(([candidate]: any) => candidate === chat.historyStorage) as [any, (key: string, value: any) => void];
@@ -668,9 +759,12 @@ async function setKeyboard(page: Page, rows: number, options: KeyboardOptions =
668759
}
669760

670761
const button = (text: string) => ({_: 'keyboardButton', text, type: {_: 'buttonTypeDefault'}});
762+
// * in place of the one there was, as the worker's mirror puts it (a store would merge the two)
763+
setStore('replyMarkup', undefined);
671764
setStore('replyMarkup', {
672765
_: 'replyKeyboardMarkup',
673-
pFlags: {...(singleUse && {single_use: true}), ...(hidden && {hidden: true})},
766+
pFlags: {...(!stretched && {resize: true}), ...(singleUse && {single_use: true}), ...(hidden && {hidden: true})},
767+
placeholder,
674768
mid,
675769
fromId: chat.peerId,
676770
rows: Array.from({length: rows}, (_, i) => ({
@@ -686,6 +780,28 @@ async function setKeyboard(page: Page, rows: number, options: KeyboardOptions =
686780
}, [rows, options] as const);
687781
}
688782

783+
// * nothing of the chat is saved or sent from this tab - a draft, a read mark, a message - and a draft asked for is
784+
// * `draft`, answered late, if there is one
785+
async function holdWrites(page: Page, draft?: string) {
786+
await page.evaluate((draft) => {
787+
const proxy = (window as any).apiManagerProxy;
788+
const invoke = proxy.invoke.bind(proxy);
789+
proxy.invoke = (type: string, payload: any, ack: boolean, ...rest: any[]) => {
790+
if(type === 'manager' && /^(send|saveDraft|setDraft|clearDraft|syncDraft|read)/.test(payload.method)) {
791+
return ack ? {cached: true, result: Promise.resolve()} : Promise.resolve();
792+
}
793+
794+
if(draft !== undefined && type === 'manager' && payload.method === 'getDraft') {
795+
const message = {_: 'draftMessage', pFlags: {}, message: draft, date: Math.floor(Date.now() / 1000)};
796+
const result = new Promise((resolve) => setTimeout(() => resolve(message), 300));
797+
return ack ? {cached: false, result} : result;
798+
}
799+
800+
return invoke(type, payload, ack, ...rest);
801+
};
802+
}, draft);
803+
}
804+
689805
// * the same twice over, and nothing of the field or the history moving: a way that has not started yet (a busy machine
690806
// * holds its first frame) measures the same too
691807
async function settled(page: Page) {

‎src/components/chat/input.ts‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,10 @@ export default class ChatInput {
641641
public suggestedPost: SuggestedPostPayload;
642642
private inputHelperNavigationItem: NavigationItem;
643643
private placeholderParamsMiddlewareHelper: MiddlewareHelper;
644+
// * what the field says while empty (`getPlaceholderParams`), and a bot keyboard's words said instead
645+
private placeholderParams: Parameters<ChatInput['updateMessageInputPlaceholder']>[0];
646+
private keyboardPlaceholder: string;
647+
private keyboardPlaceholderElement: HTMLElement;
644648

645649
private savedReplyToPollOption?: {
646650
msgId: number;
@@ -1164,10 +1168,16 @@ export default class ChatInput {
11641168
const element = this.emoticonsDropdown?.getElement();
11651169
if(element?.classList.contains('is-under') && this.emoticonsDropdown.chatInput === this) element.style.display = 'none';
11661170
this.updateReplyMarkupToggle();
1171+
this.updateKeyboardPlaceholder();
11671172
});
11681173
this.listenerSetter.add(this.replyKeyboard)('close', () => {
11691174
this.btnToggleReplyMarkup.classList.remove('active');
11701175
this.updateReplyMarkupToggle();
1176+
this.updateKeyboardPlaceholder();
1177+
});
1178+
// * a language applied over the bot's words puts the field's own back: the bot's go there again
1179+
this.listenerSetter.add(rootScope)('language_apply', () => {
1180+
if(this.keyboardPlaceholder) this.updateKeyboardPlaceholder();
11711181
});
11721182
}
11731183

@@ -3480,17 +3490,23 @@ export default class ChatInput {
34803490
canSend ??= await this.chat.canSend('send_plain');
34813491
const {peerId, threadId, isForum, type} = this.chat;
34823492
let key: LangPackKey, args: FormatterArguments, inputStarsCountEl: HTMLElement;
3493+
// * where the field only asks for a message, a bot's keyboard may ask in its words (`updateKeyboardPlaceholder`) - not
3494+
// * over what it cannot take, what it is for instead (a template, a thread to choose) or what a message costs, which
3495+
// * is said before it is sent (Android)
3496+
let yieldsToKeyboard = false;
34833497
if(!canSend) {
34843498
key = 'Channel.Persmission.MessageBlock';
34853499
} else if(type === ChatType.Welcome) {
34863500
key = 'WelcomeMessages.Placeholder';
34873501
} else if(threadId && !isForum && !peerId.isUser()) {
34883502
key = 'Comment';
3503+
yieldsToKeyboard = true;
34893504
} else if(
34903505
(peerState ? peerState.isBroadcast : await this.managers.appPeersManager.isBroadcast(peerId)) ||
34913506
(peerState ? peerState.isBroadcastGroup : await this.managers.appPeersManager.isBroadcastGroup(peerId))
34923507
) {
34933508
key = 'ChannelBroadcast';
3509+
yieldsToKeyboard = true;
34943510
} else if(this.chat.isMonoforum && this.chat.canManageDirectMessages) {
34953511
key = this.inputState.store.isSuggestingUneditablePostChange ?
34963512
'ChannelDirectMessages.CantChangeSuggestedPostMessage' :
@@ -3504,6 +3520,7 @@ export default class ChatInput {
35043520
(peerState ? peerState.isAnonymousSending : await this.managers.appMessagesManager.isAnonymousSending(peerId))
35053521
) {
35063522
key = 'SendAnonymously';
3523+
yieldsToKeyboard = true;
35073524
} else if(type === ChatType.Stories && !this.chat.starsAmount) {
35083525
// * a reply that costs Stars shows its price below instead, over the stealth mode countdown too:
35093526
// * as Android has it, what a message costs is said before it is sent
@@ -3525,6 +3542,8 @@ export default class ChatInput {
35253542
} else {
35263543
key = 'Message';
35273544
}
3545+
3546+
yieldsToKeyboard = true;
35283547
} else if(this.chat.starsAmount) {
35293548
key = 'PaidMessages.MessageForStars';
35303549
const starsElement = document.createElement('span');
@@ -3534,19 +3553,17 @@ export default class ChatInput {
35343553
args = [starsElement];
35353554
} else {
35363555
key = 'Message';
3556+
yieldsToKeyboard = true;
35373557
}
35383558

3539-
return {key, args, inputStarsCountEl};
3559+
return {key, args, inputStarsCountEl, yieldsToKeyboard};
35403560
}
35413561

3542-
public updateMessageInputPlaceholder({
3543-
key,
3544-
args = [],
3545-
inputStarsCountEl
3546-
}: {
3562+
public updateMessageInputPlaceholder(params: {
35473563
key: LangPackKey,
35483564
args?: FormatterArguments,
3549-
inputStarsCountEl?: HTMLElement
3565+
inputStarsCountEl?: HTMLElement,
3566+
yieldsToKeyboard?: boolean
35503567
}) {
35513568
// console.warn('[input] update placeholder');
35523569
// const i = I18n.weakMap.get(this.messageInput) as I18n.IntlElement;
@@ -3557,12 +3574,47 @@ export default class ChatInput {
35573574

35583575
const oldKey = i.key;
35593576
const oldArgs = i.args;
3560-
i.compareAndUpdateBool({key, args}) &&
3561-
this.inputState.set({inputStarsCountEl});
3577+
this.placeholderParams = params;
3578+
this.updateKeyboardPlaceholder();
35623579

35633580
return {oldKey, oldArgs};
35643581
}
35653582

3583+
/**
3584+
* The field's placeholder: its own words, or a bot's keyboard's while they apply (`ReplyKeyboard.getPlaceholder`),
3585+
* as desktop and Android have them - not while a message is edited, nor where the field says more than that it asks
3586+
* for a message (`yieldsToKeyboard`). The field's own wait under the bot's as the placeholder's, put back once they go
3587+
*/
3588+
public updateKeyboardPlaceholder() {
3589+
const element = this.messageInputField?.placeholder;
3590+
const i = element && I18n.weakMap.get(element) as I18n.IntlElement;
3591+
const params = this.placeholderParams;
3592+
if(!i || !params) {
3593+
return;
3594+
}
3595+
3596+
const {key, args = [], inputStarsCountEl, yieldsToKeyboard} = params;
3597+
const text = yieldsToKeyboard && !this.editMsgId ?
3598+
this.replyKeyboard?.getPlaceholder(this.replyToMsgId) :
3599+
undefined;
3600+
if(text) {
3601+
i.key = key;
3602+
i.args = args;
3603+
const span = this.keyboardPlaceholderElement ??= document.createElement('span');
3604+
// * the bot's words, in their own direction
3605+
span.dir = 'auto';
3606+
if(text !== this.keyboardPlaceholder) span.replaceChildren(wrapEmojiText(text));
3607+
if(span.parentElement !== element) element.replaceChildren(span);
3608+
} else if(this.keyboardPlaceholder) {
3609+
i.update({key, args});
3610+
} else if(!i.compareAndUpdateBool({key, args})) {
3611+
return;
3612+
}
3613+
3614+
this.keyboardPlaceholder = text;
3615+
this.inputState.set({inputStarsCountEl});
3616+
}
3617+
35663618
private filterAttachMenuButtons() {
35673619
if(!this.attachMenuButtons) return;
35683620
return filterAsync(this.attachMenuButtons, (button) => {
@@ -5706,6 +5758,7 @@ export default class ChatInput {
57065758
}
57075759
this.editMsgId = mid;
57085760
this.editMessage = message;
5761+
this.updateKeyboardPlaceholder();
57095762
input = undefined;
57105763
richMessage = undefined;
57115764
// the limit plate steps aside for editing what is already there
@@ -6092,6 +6145,7 @@ export default class ChatInput {
60926145
this.replyIsEphemeral = false;
60936146
}
60946147
this.updateEphemeralComposer();
6148+
this.updateKeyboardPlaceholder();
60956149
this.center(true);
60966150
}
60976151

@@ -6130,6 +6184,7 @@ export default class ChatInput {
61306184

61316185
this.editMsgId = this.editMessage = undefined;
61326186
this.helperType = this.helperFunc = undefined;
6187+
this.updateKeyboardPlaceholder();
61336188
if(this.welcomeLimitReached) void this.center(true);
61346189
this.setCurrentHover();
61356190
this.saveDraftDebounced();

0 commit comments

Comments
 (0)