Skip to content

Commit 04bff39

Browse files
morethanwordsclaude
andcommitted
Chat: an unread reaction taken off leaves the badge, Read all reactions works the first time, emoji suggestions are never an empty strip, the emoji panel drops thumbnails on reopening, search highlights land on their words
Four bugs, each with its cause: - Unread reactions: the last reaction taken off comes in an updateMessageReactions without `recent_reactions`, which made the message's unread state `undefined`, and the counter was never brought down. It is a boolean now; a `min` object, which leaves the recent reactions out whatever they are, keeps the state it had. - Read all reactions (and mentions, and poll votes) brought the counter down by a negative number, which the cached list of unread messages took for an addition: it put `undefined` into the list and threw after the counter was zeroed but before the chat was told, so the badge stayed until a second try. A negative count now clears the list. - Emoji suggestions: the custom emoji a user without Premium cannot use in the chat were dropped after the strip had decided to show, so a query whose answers were all such emoji showed an empty strip; and they were dropped after the 40 answers were cut, taking the places of the emoji that can be used. They are dropped first now, the same way the suggestions for a typed emoji already were. - Emoji panel: a category going out of sight puts thumbnails on its custom emoji, which rejoin the same group in the same pass. The group was still taken for drawn, so once the category was shown again its thumbnails were never cleared and showed under the emoji. Thumbnails put on a drawn group now show it by its placeholders again, as one leaving the viewport does. - Search highlight: opening a chat at a found message lit its words up while the bubbles were still zooming in (scale .8 to 1), measuring them at that scale, and nothing measured them again - the highlight stood off its words. The highlight (and the flash) of a jump now waits for the zoom of a list rendered anew, which never keeps it waiting when the zoom does not come; and the line boxes are measured in the bubble's own pixels, whatever scale an ancestor has. - Tests: the reaction counter on a reaction taken off, on a `min` update and on Read all; line boxes under a scaled ancestor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 61c89e9 commit 04bff39

7 files changed

Lines changed: 244 additions & 35 deletions

File tree

‎src/components/chat/bubbles.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,8 @@ export default class ChatBubbles {
929929
}> = {};
930930

931931
private onAnimateLadder: () => Promise<any> | void;
932-
// private ladderDeferred: CancellablePromise<void>;
932+
/** the zoom a list rendered anew comes in with (`animateAsLadder`), from the moment it is armed till it is over */
933+
private ladderAppearance: CancellablePromise<void>;
933934
private resolveLadderAnimation: () => Promise<any>;
934935
private emptyPlaceholderBubble: HTMLElement;
935936

@@ -6020,6 +6021,15 @@ export default class ChatBubbles {
60206021
}
60216022
}
60226023

6024+
/**
6025+
* What a jump lights up waits for: the scroll to it to settle, and a list rendered anew to have come in — lit up
6026+
* while it zooms in, the bubble flashes unseen and the text is measured at the zoom's scale, the found words
6027+
* painted off their place
6028+
*/
6029+
private waitForJumpToSettle(scroll: Promise<any>) {
6030+
return Promise.all([scroll, this.ladderAppearance]);
6031+
}
6032+
60236033
public highlightBubble(element: HTMLElement, textHighlight?: TextHighlightMatch) {
60246034
const datasetKey = 'highlightTimeout';
60256035
if(element.dataset[datasetKey]) {
@@ -6434,6 +6444,8 @@ export default class ChatBubbles {
64346444

64356445
this.onAnimateLadder = undefined;
64366446
this.resolveLadderAnimation = undefined;
6447+
this.ladderAppearance?.resolve();
6448+
this.ladderAppearance = undefined;
64376449
this.attachPlaceholderOnRender = undefined;
64386450
this.emptyPlaceholderBubble = undefined;
64396451
this.previousStickyDate = undefined;
@@ -6676,7 +6688,7 @@ export default class ChatBubbles {
66766688
// * plays while the message is still travelling
66776689
const targets = this.getJumpTargets(bubble, lastMsgFullMid, wholeAlbum);
66786690
const focusOn = this.getMessageFocus(bubble, lastMsgFullMid, highlight, pollOption, targets);
6679-
this.scrollToBubble(bubble, 'center', undefined, undefined, focusOn).then(() => {
6691+
this.waitForJumpToSettle(this.scrollToBubble(bubble, 'center', undefined, undefined, focusOn)).then(() => {
66806692
if(!middleware()) return;
66816693
targets.forEach((target) => this.highlightBubble(target, highlight));
66826694
this.highlightBubblePollAnswer(bubble, lastMsgFullMid, pollOption);
@@ -7102,7 +7114,7 @@ export default class ChatBubbles {
71027114

71037115
if(willHighlight) {
71047116
// * after the scroll settles, see the same-peer branch above
7105-
promise.then(() => {
7117+
this.waitForJumpToSettle(promise).then(() => {
71067118
if(!middleware()) return;
71077119
targets.forEach((target) => this.highlightBubble(target, highlight));
71087120
this.highlightBubblePollAnswer(bubble, lastMsgFullMid, pollOption);
@@ -13361,10 +13373,12 @@ export default class ChatBubbles {
1336113373
return;
1336213374
} */
1336313375

13376+
const appearance = this.ladderAppearance;
1336413377
const fullMids = this.getRenderedHistory('desc');
1336513378

1336613379
if(!fullMids.length) {
1336713380
log.warn('no bubbles');
13381+
appearance?.resolve();
1336813382
return;
1336913383
}
1337013384

@@ -13488,6 +13502,7 @@ export default class ChatBubbles {
1348813502
});
1348913503

1349013504
this.chatInner.classList.remove('zoom-fading');
13505+
appearance?.resolve();
1349113506
});
1349213507

1349313508
// ! в хроме, каким-то образом из-за zoom-fade класса начинает прыгать скролл при подгрузке сообщений вверх,
@@ -13496,6 +13511,8 @@ export default class ChatBubbles {
1349613511
// this.needReflowScroll = true;
1349713512
// }
1349813513
});
13514+
} else {
13515+
appearance?.resolve();
1349913516
}
1350013517

1350113518
return promise;
@@ -14666,7 +14683,9 @@ export default class ChatBubbles {
1466614683

1466714684
if(isFirstMessageRender && liteMode.isAvailable('motion')/* && false */) {
1466814685
let times = isAdditionRender ? 2 : 1;
14669-
this.messagesQueueOnRenderAdditional = () => {
14686+
this.ladderAppearance?.resolve();
14687+
const appearance = this.ladderAppearance = deferredPromise();
14688+
const arm = this.messagesQueueOnRenderAdditional = () => {
1467014689
this.log('messagesQueueOnRenderAdditional');
1467114690

1467214691
if(--times) return;
@@ -14680,7 +14699,14 @@ export default class ChatBubbles {
1468014699
}, 0);
1468114700
});
1468214701
};
14702+
14703+
// * the renders of this load are through without running it: it does not come
14704+
waitPromise.catch(noop).then(() => {
14705+
if(this.messagesQueueOnRenderAdditional === arm) appearance.resolve();
14706+
});
1468314707
} else {
14708+
// * one armed that has not run by now does not come any more
14709+
if(this.messagesQueueOnRenderAdditional) this.ladderAppearance?.resolve();
1468414710
this.messagesQueueOnRenderAdditional = undefined;
1468514711
}
1468614712

‎src/components/chat/emojiHelper.ts‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import attachMediaPeekListeners from '@components/mediaPeek';
1313
import ListenerSetter from '@helpers/listenerSetter';
1414
import rootScope from '@lib/rootScope';
1515

16+
// * as many as a typed query is answered with
17+
const QUERY_LIMIT = 40;
18+
1619
export default class EmojiHelper extends AutocompleteHelper {
1720
private scrollable: ScrollableX;
1821
private innerList: HTMLElement;
@@ -60,10 +63,6 @@ export default class EmojiHelper extends AutocompleteHelper {
6063
const inner = this.innerList = document.createElement('span');
6164
container.append(inner);
6265

63-
if(!rootScope.premium) {
64-
emojis = emojis.filter((emoji) => this.chatInput.emoticonsDropdown.canUseEmoji(emoji, false));
65-
}
66-
6766
emojis.forEach((emoji) => {
6867
const wrapped = wrapAppEmoji(emoji, true);
6968
inner.append(wrapped);
@@ -148,17 +147,29 @@ export default class EmojiHelper extends AutocompleteHelper {
148147
}); */
149148
}
150149

150+
// * custom emoji a non-premium user can't use here are dropped before render(), which would otherwise show an
151+
// * empty strip instead of hiding it
152+
private filterUsable(emojis: AppEmoji[]) {
153+
if(rootScope.premium) {
154+
return emojis;
155+
}
156+
157+
return emojis.filter((emoji) => this.chatInput.emoticonsDropdown.canUseEmoji(emoji, false));
158+
}
159+
151160
public checkQuery(query: string, firstChar: string) {
152161
const middleware = this.getMiddleware();
153162
this.emoticon = undefined;
154163
const q = query.replace(/^:/, '');
155-
this.managers.appEmojiManager.prepareAndSearchEmojis({q, addCustom: true}).then(async(emojis) => {
164+
// * every custom emoji found comes before its emoji: cut to the limit before the ones that can't be used are
165+
// * dropped, they would take the places of the emoji that can
166+
const premium = rootScope.premium;
167+
this.managers.appEmojiManager.prepareAndSearchEmojis({q, addCustom: true, limit: premium ? QUERY_LIMIT : Infinity}).then((emojis) => {
156168
if(!middleware()) {
157169
return;
158170
}
159171

160-
this.render(emojis, firstChar !== ':', middleware);
161-
// console.log(emojis);
172+
this.render(this.filterUsable(emojis).slice(0, QUERY_LIMIT), firstChar !== ':', middleware);
162173
});
163174
}
164175

@@ -193,14 +204,8 @@ export default class EmojiHelper extends AutocompleteHelper {
193204
return;
194205
}
195206

196-
let emojis: AppEmoji[] = emojiList.document_id.map((docId) => ({docId, emoji: ''}));
197-
// * the result is custom emoji only — drop the ones a non-premium user can't use here,
198-
// * otherwise render() would show an empty strip instead of hiding it
199-
if(!rootScope.premium) {
200-
emojis = emojis.filter((emoji) => this.chatInput.emoticonsDropdown.canUseEmoji(emoji, false));
201-
}
202-
203-
this.render(emojis, true, middleware);
207+
const emojis: AppEmoji[] = emojiList.document_id.map((docId) => ({docId, emoji: ''}));
208+
this.render(this.filterUsable(emojis), true, middleware);
204209
});
205210
}
206211
}

‎src/helpers/dom/textHighlight.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,10 @@ export function findTextRect(container: HTMLElement, match: TextHighlightMatch,
364364
/**
365365
* Turns the ranges' fragment rects into per-line boxes stretched to the line height (glyph rects
366366
* are only font-high; the missing half-leading is added on both sides), unioned along a line
367-
* and expressed relative to `origin`.
367+
* and expressed relative to `origin`, in its own pixels: `scale` is how much bigger the screen
368+
* shows them (an ancestor scaled - a bubble zooming in as its chat opens).
368369
*/
369-
export function computeLineBoxes(ranges: Range[], origin: DOMRect, lineHeight: number): TextLineBox[] {
370+
export function computeLineBoxes(ranges: Range[], origin: DOMRect, lineHeight: number, scale = {x: 1, y: 1}): TextLineBox[] {
370371
type Edges = {left: number, right: number, top: number, bottom: number};
371372
const fragments: Edges[] = [];
372373
for(const range of ranges) {
@@ -375,8 +376,14 @@ export function computeLineBoxes(ranges: Range[], origin: DOMRect, lineHeight: n
375376
continue;
376377
}
377378

378-
const extra = Math.max(0, lineHeight - rect.height) / 2;
379-
fragments.push({left: rect.left, right: rect.right, top: rect.top - extra, bottom: rect.bottom + extra});
379+
const top = (rect.top - origin.top) / scale.y, bottom = (rect.bottom - origin.top) / scale.y;
380+
const extra = Math.max(0, lineHeight - (bottom - top)) / 2;
381+
fragments.push({
382+
left: (rect.left - origin.left) / scale.x,
383+
right: (rect.right - origin.left) / scale.x,
384+
top: top - extra,
385+
bottom: bottom + extra
386+
});
380387
}
381388
}
382389

@@ -412,8 +419,8 @@ export function computeLineBoxes(ranges: Range[], origin: DOMRect, lineHeight: n
412419
}
413420

414421
return boxes.map((box) => ({
415-
left: box.left - origin.left,
416-
top: box.top - origin.top,
422+
left: box.left,
423+
top: box.top,
417424
width: box.right - box.left,
418425
height: box.bottom - box.top
419426
}));
@@ -698,8 +705,14 @@ class TextHighlighter implements TextHighlight {
698705
}
699706

700707
private computeLineBoxes(ranges: Range[]) {
708+
const {overlay} = this;
701709
const lineHeight = parseFloat(getComputedStyle(this.container).lineHeight) || 0;
702-
return computeLineBoxes(ranges, this.overlay.getBoundingClientRect(), lineHeight);
710+
const origin = overlay.getBoundingClientRect();
711+
// * the rects are the screen's, the boxes are painted inside the overlay: a scaled ancestor (a bubble zooming in)
712+
// * is taken out — the transform changes no size, and nothing would measure them again once it is over
713+
const {width, height} = getComputedStyle(overlay);
714+
const scale = {x: origin.width / parseFloat(width) || 1, y: origin.height / parseFloat(height) || 1};
715+
return computeLineBoxes(ranges, origin, lineHeight, scale);
703716
}
704717

705718
private createLineBox(box: TextLineBox) {

‎src/lib/appManagers/appMessagesManager.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9961,7 +9961,8 @@ export class AppMessagesManager extends AppManager {
99619961
threadId: isForumTopic(dialog) ? options.threadId : undefined,
99629962
isReaction: key === 'unread_reactions_count',
99639963
isPollVote: key === 'unread_poll_votes_count',
9964-
add: !!add
9964+
// * a negative count (all of them read) takes off, it does not add
9965+
add: +add > 0
99659966
});
99669967
});
99679968

@@ -11193,8 +11194,12 @@ export class AppMessagesManager extends AppManager {
1119311194
const recentReactions = reactions?.recent_reactions;
1119411195
const previousReactions = message.reactions;
1119511196
const previousRecentReactions = previousReactions?.recent_reactions;
11196-
const isUnread = recentReactions?.some((reaction) => reaction.pFlags.unread);
1119711197
const wasUnread = !!previousRecentReactions?.some((reaction) => reaction.pFlags.unread);
11198+
// * the last reaction taken off comes without `recent_reactions` at all, while a `min` object leaves them out
11199+
// * whatever they are: it says nothing of the unread ones
11200+
const isUnread = !recentReactions && reactions?.pFlags.min && reactions.results.length ?
11201+
wasUnread :
11202+
!!recentReactions?.some((reaction) => reaction.pFlags.unread);
1119811203
if(recentReactions?.length && message.pFlags.out) { // * if user added a reaction to our message
1119911204
const recentReaction = recentReactions[recentReactions.length - 1];
1120011205
if(

‎src/lib/customEmoji/renderer.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -657,20 +657,22 @@ export class CustomEmojiRendererElement extends HTMLElement {
657657
return;
658658
}
659659

660-
if(this.offscreen) { // re-arm the compositor fade so the group fades again on viewport re-entry
661-
const docId = elements.values().next().value?.docId;
662-
if(docId !== undefined) {
663-
this.sendCompositor('resetFade', {groupId: docId});
664-
}
665-
}
666-
667660
elements.forEach((element) => {
668661
const saved = element.savedChildren;
669662
if(saved?.length && !element.firstChild) {
670663
element.replaceChildren(...saved);
671664
}
672665
});
673666

667+
this.onPlaceholdersShown(elements);
668+
}
669+
670+
/** The group is shown by its placeholders again: they are cleared, and it fades in, once it is drawn anew */
671+
private onPlaceholdersShown(elements: CustomEmojiElements, docId: DocId = elements.values().next().value?.docId) {
672+
if(this.offscreen && docId !== undefined) { // re-arm the compositor fade so the group fades again on viewport re-entry
673+
this.sendCompositor('resetFade', {groupId: docId});
674+
}
675+
674676
this.clearedElements.delete(elements);
675677
elementsFadeInStartTimes.delete(elements);
676678
}
@@ -1347,6 +1349,10 @@ export class CustomEmojiRendererElement extends HTMLElement {
13471349
// * placeholder (wrap leaves the thumb out of a cleared group): one under the canvas until it is
13481350
// * painted showed through it for a frame or two - a translucent emoji darkened, a thin one bold
13491351
else if(this.isSelectable) this.clearedElements.delete(elements);
1352+
// * Thumbnails put on a group the canvas has drawn (a category of the emoji panel going out of sight: its emoji
1353+
// * leave the group and come back to it in one pass, which keeps it): the group is shown by its placeholders
1354+
// * again - still taken for drawn, it would never clear them, and they would show under it once it is drawn
1355+
else if(onlyThumb && this.clearedElements.has(elements)) this.onPlaceholdersShown(elements, docId);
13501356

13511357
for(const el of addElements) {
13521358
if(elements.has(el)) {

‎src/tests/textHighlight.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ describe('computeLineBoxes', () => {
191191
const boxes = computeLineBoxes([rangeOf(rect(100, 51, 50, 19), rect(100, 93, 50, 19))], origin, 21);
192192
expect(boxes.map((b) => b.top)).toEqual([0, 42]);
193193
});
194+
195+
it('measures in the overlay\'s own pixels when an ancestor is scaled', () => {
196+
// * the first test's text with the bubble at .8 of its size, as it zooms in when its chat opens
197+
const scaled = rect(100, 50, 320, 240);
198+
const at = (left: number, top: number, width: number, height: number) => rect(100 + left * .8, 50 + top * .8, width * .8, height * .8);
199+
const boxes = computeLineBoxes([rangeOf(at(10, 1, 40, 19), at(50, 1, 60, 19), at(0, 22, 90, 19))], scaled, 21, {x: .8, y: .8});
200+
expect(boxes.map((b) => [b.left, b.top, b.width, b.height].map((v) => +v.toFixed(3) + 0))).toEqual([
201+
[10, 0, 100, 21],
202+
[0, 21, 90, 21]
203+
]);
204+
});
194205
});
195206

196207
describe('computeFullRange', () => {

0 commit comments

Comments
 (0)