Skip to content

Commit 477f1be

Browse files
morethanwordsclaude
andcommitted
Chat: sensitive media is hidden whatever its kind — videos, GIFs, round videos, stickers, link previews, paid media
A message marked sensitive, while the account does not show such content, hides its media under a spoiler with an 18+ tag, and a click asks before revealing it. Only a single photo and an album's photos were covered that way; the rule is tdesktop's (isMediaSensitive in Photo, Gif, GroupedMedia, Sticker and the web page attach). - A single video, GIF or round video is covered too, and does not play under its cover; a round one's cover is round. A video album and a bought paid media album got a plain spoiler that revealed on one click with nothing asked: they get the sensitive one, as does a single bought paid media. Media not paid for stays a preview under its price. - Revealed, a video that plays by itself plays as the intersector lets it (in sight, once loaded — the spoiler knows the video, which joins the page only after it loads); a click plays it at once. Revealing all at once leaves videos out of sight paused. A round video's Transcribe, which shows its frames, reveals it first. A GIF that does not play by itself (Power Saving) took the first click on its spoiler to start loading: the click now reaches the spoiler, and the next one plays it. - A link preview's photo or video is covered. The preview lies in the link, which may hold no control of its own, so there the spoiler is a plain box; with the a11y layer a button beside the link, seen only with the focus, reveals it from the keyboard and leaves the focus on the link. A poll's link preview takes that button along and reveals the spoiler on a click instead of swallowing it; a tap on a touch screen no longer follows the link too. A small square preview shows the eye alone. - A sticker stands blurred and still on its first frame under the tag, as in tdesktop, and plays once revealed. A premium one's effect does not play over the chat while it is covered, and its mirrored art keeps the tag readable. Loading a static or video sticker no longer takes its cover for a thumbnail and removes it. - A spoiler covered nothing when the media had no stripped thumbnail, or when its thumbnail or dots failed: it falls back to the cached thumbnail, then to the dots over a plain fill. Media waiting for its cover stays out of sight. - A spoiler revealed from the keyboard (a click at 0, 0) started its animation from far away and never finished it, leaving the media covered for good: the reveal starts at its middle. - The chat stopped rendering when it opened on a peer the page did not know yet (its own user, early): its sensitivity is read when asked, not from a snapshot taken as the chat opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 41034c9 commit 477f1be

10 files changed

Lines changed: 351 additions & 64 deletions

File tree

‎src/components/appSearchSuper.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3108,7 +3108,7 @@ export default class AppSearchSuper {
31083108
}
31093109

31103110
private isMessageSensitive(message: Message.message) {
3111-
return isSensitive((usePeer(message.peerId) as User.user).restriction_reason || []) || isMessageSensitive(message);
3111+
return isSensitive((usePeer(message.peerId) as User.user)?.restriction_reason || []) || isMessageSensitive(message);
31123112
}
31133113

31143114
public getSearchCounters(filters: MessagesFilter[], canCache = true) {

‎src/components/chat/bubbleParts/roundVideoBubble.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import wrapDocument from '@components/wrappers/document';
1414
import {SpinnerElement} from '@components/spinner';
1515
import {hotReloadGuard} from '@lib/solidjs/hotReloadGuardProvider';
1616
import {getBubbleBackground} from '@components/chat/bubbleShape';
17+
import {onMediaSpoilerClick} from '@components/wrappers/mediaSpoiler';
1718

1819

1920
type WrapRoundVideoBubbleOptions = {
@@ -394,7 +395,14 @@ export function wrapRoundVideoBubble({
394395
);
395396
}
396397

397-
transcribe.addEventListener('click', () => {
398+
transcribe.addEventListener('click', (event) => {
399+
// * the text shows the video's frames: one still under its spoiler is revealed first
400+
const mediaSpoiler = mediaContainer.querySelector<HTMLElement>('.media-spoiler-container');
401+
if(mediaSpoiler) {
402+
onMediaSpoilerClick({mediaSpoiler, event});
403+
return;
404+
}
405+
398406
showAudio();
399407
});
400408

‎src/components/chat/bubbles.ts‎

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ import {BatchProcessor} from '@helpers/sortedList';
128128
import wrapUrl from '@lib/richTextProcessor/wrapUrl';
129129
import {isMaskedUrlTrusted} from '@lib/richTextProcessor/isWhitelistedUrl';
130130
import getMessageThreadId from '@appManagers/utils/messages/getMessageThreadId';
131-
import wrapMediaSpoiler, {hasMediaSpoiler, onMediaSpoilerClick} from '@components/wrappers/mediaSpoiler';
131+
import wrapMediaSpoiler, {attachLinkSpoilerRevealButton, hasMediaSpoiler, onMediaSpoilerClick, wrapSensitiveStickerSpoiler} from '@components/wrappers/mediaSpoiler';
132132
import liteMode from '@helpers/liteMode';
133133
import getSelectionElementFromTarget from '@components/chat/getSelectionElementFromTarget';
134134
import getMediaDurationFromMessage from '@appManagers/utils/messages/getMediaDurationFromMessage';
@@ -4140,6 +4140,11 @@ export default class ChatBubbles {
41404140
event: e,
41414141
mediaSpoiler
41424142
});
4143+
// * one in a web page's preview lies inside its link, which the click after a touch would follow
4144+
if(findUpClassName(mediaSpoiler, 'webpage')) {
4145+
cancelWebPageLink(e);
4146+
}
4147+
41434148
return;
41444149
}
41454150

@@ -7842,15 +7847,20 @@ export default class ChatBubbles {
78427847
promise,
78437848
middleware,
78447849
attachmentDiv,
7845-
sensitive
7850+
sensitive,
7851+
inLink
78467852
}: {
78477853
media: Photo.photo | MyDocument,
78487854
promise: Promise<any>,
78497855
middleware: Middleware,
78507856
attachmentDiv: HTMLElement,
7851-
sensitive?: boolean
7857+
sensitive?: boolean,
7858+
inLink?: boolean
78527859
}) {
7853-
await promise;
7860+
// * out of sight until it is covered: what shows a message before its promises settle (a poll's link preview)
7861+
// * would show the media first; and covered even if it failed to load
7862+
attachmentDiv.classList.add('is-spoiler-pending');
7863+
const result = await promise.catch(noop);
78547864
if(!middleware()) {
78557865
return;
78567866
}
@@ -7862,6 +7872,8 @@ export default class ChatBubbles {
78627872
height: parseInt(height),
78637873
middleware,
78647874
sensitive,
7875+
inLink,
7876+
video: result?.video instanceof HTMLVideoElement ? result.video : undefined,
78657877
animationGroup: this.chat.animationGroup
78667878
});
78677879

@@ -7870,6 +7882,7 @@ export default class ChatBubbles {
78707882
}
78717883

78727884
attachmentDiv.append(container);
7885+
attachmentDiv.classList.remove('is-spoiler-pending');
78737886
}
78747887

78757888
public wrapSticker(
@@ -7888,7 +7901,8 @@ export default class ChatBubbles {
78887901
eager?: boolean,
78897902
noFadeIn?: boolean,
78907903
width?: number,
7891-
height?: number
7904+
height?: number,
7905+
sensitive?: boolean
78927906
}
78937907
) {
78947908
const {
@@ -7902,7 +7916,8 @@ export default class ChatBubbles {
79027916
noFadeIn,
79037917
doc,
79047918
width,
7905-
height
7919+
height,
7920+
sensitive
79067921
} = options;
79077922
let {boxSize} = options;
79087923
context.bubble.classList.add('sticker');
@@ -7927,6 +7942,10 @@ export default class ChatBubbles {
79277942
});
79287943
context.bubbleContainer.style.minWidth = container.style.width;
79297944
context.bubbleContainer.style.minHeight = container.style.height;
7945+
if(sensitive) {
7946+
this.coverSensitiveSticker(container, context.middleware);
7947+
}
7948+
79307949
const noPremium = (context.messageMedia as MessageMedia.messageMediaDocument)?.pFlags?.nopremium;
79317950
return callbackify(doc, (doc) => {
79327951
if(!context.middleware()) {
@@ -7966,14 +7985,33 @@ export default class ChatBubbles {
79667985
});
79677986

79687987
const effectThumb = getStickerEffectThumb(doc);
7969-
if((effectThumb || isEmoji) && (context.isInUnread || context.isOutgoing)/* || true */) {
7988+
// * a premium one's effect would play it over the chat, out of its cover
7989+
if((effectThumb || isEmoji) && !sensitive && (context.isInUnread || context.isOutgoing)/* || true */) {
79707990
this.observer.observe(context.bubble, this.stickerEffectObserverCallback);
79717991
}
79727992

79737993
return ret;
79747994
});
79757995
}
79767996

7997+
// * it stands blurred and still until it is revealed
7998+
private coverSensitiveSticker(container: HTMLElement, middleware: Middleware) {
7999+
container.classList.add('is-sensitive-sticker', 'is-blurred');
8000+
let release = animationIntersector.holdUnder(container);
8001+
const letGo = () => {
8002+
release?.();
8003+
release = undefined;
8004+
};
8005+
middleware.onClean(letGo);
8006+
container.append(wrapSensitiveStickerSpoiler({
8007+
middleware,
8008+
onReveal: () => {
8009+
container.classList.remove('is-blurred');
8010+
letGo();
8011+
}
8012+
}));
8013+
}
8014+
79778015
public getBubble(peerId: PeerId | string, mid?: number) {
79788016
let fullMid: string;
79798017
if(mid) {
@@ -8060,6 +8098,14 @@ export default class ChatBubbles {
80608098
/** Dispatch the same webpage action used by the chat's delegated listener. */
80618099
public dispatchWebPageClick(webPageContainer: HTMLAnchorElement, event: MouseEvent) {
80628100
const target = event.target instanceof Element ? event.target : undefined;
8101+
// * as in the chat, which sees it first: a spoiler over the preview's media is revealed, the link is not followed
8102+
const mediaSpoiler = target?.closest<HTMLElement>('.media-spoiler-container');
8103+
if(mediaSpoiler) {
8104+
onMediaSpoilerClick({mediaSpoiler, event});
8105+
cancelWebPageLink(event);
8106+
return false;
8107+
}
8108+
80638109
if(target?.closest('.webpage-name-tip')) return false;
80648110
if(target?.closest('.webpage-preview-resizer')) {
80658111
event.preventDefault();
@@ -10651,6 +10697,8 @@ export default class ChatBubbles {
1065110697
}
1065210698

1065310699
const lazyLoadQueue = sponsoredMedia ? undefined : this.lazyLoadQueue;
10700+
// * its photo or video is under a sensitive spoiler
10701+
let coversMedia = false;
1065410702

1065510703
if(doc) {
1065610704
if(doc.type === 'gif' || doc.type === 'video' || doc.type === 'round') {
@@ -10662,7 +10710,7 @@ export default class ChatBubbles {
1066210710
bubble.classList.add('video');
1066310711
}
1066410712

10665-
wrapVideo({
10713+
const p = wrapVideo({
1066610714
doc,
1066710715
container: preview,
1066810716
message: message as Message.message,
@@ -10675,10 +10723,23 @@ export default class ChatBubbles {
1067510723
loadPromises,
1067610724
autoDownload: this.chat.autoDownload,
1067710725
noInfo: message.mid < 0 && !sponsoredMedia,
10726+
noAutoplayAttribute: sensitive,
1067810727
observer: this.observer,
1067910728
onLoad: this.onVideoLoad,
1068010729
setShowControlsOn: bubble
1068110730
});
10731+
10732+
if(sensitive) {
10733+
loadPromises.push(this.wrapMediaSpoiler({
10734+
media: doc,
10735+
promise: p,
10736+
middleware,
10737+
attachmentDiv: preview,
10738+
sensitive,
10739+
inLink: true
10740+
}));
10741+
coversMedia = true;
10742+
}
1068210743
} else {
1068310744
const docDiv = await wrapDocument({
1068410745
message: message as Message.message,
@@ -10779,7 +10840,7 @@ export default class ChatBubbles {
1077910840
node.classList.add('avatar-full');
1078010841
preview.append(node);
1078110842
readyThumbPromise && loadPromises.push(readyThumbPromise);
10782-
} else */wrapPhoto({
10843+
} else */const p = wrapPhoto({
1078310844
photo/* : p */,
1078410845
message,
1078510846
container: preview,
@@ -10792,6 +10853,18 @@ export default class ChatBubbles {
1079210853
withoutPreloader: isSquare,
1079310854
autoDownloadSize: this.chat.autoDownload.photo
1079410855
});
10856+
10857+
if(sensitive) {
10858+
loadPromises.push(this.wrapMediaSpoiler({
10859+
media: photo,
10860+
promise: p,
10861+
middleware,
10862+
attachmentDiv: preview,
10863+
sensitive,
10864+
inLink: true
10865+
}));
10866+
coversMedia = true;
10867+
}
1079510868
}
1079610869

1079710870
if(storyAttribute) {
@@ -10920,6 +10993,10 @@ export default class ChatBubbles {
1092010993
messageDiv.append(box);
1092110994
}
1092210995

10996+
if(coversMedia) {
10997+
attachLinkSpoilerRevealButton(box, middleware);
10998+
}
10999+
1092311000
// * peer-color background-emoji pattern behind the box (like replies/quotes).
1092411001
// out-messages render with the out palette (no index pattern); sponsored carry their
1092511002
// own color override that isn't on the cached peer, so skip them.
@@ -11075,7 +11152,8 @@ export default class ChatBubbles {
1107511152
container: context.attachmentDiv,
1107611153
// a sticker sent from the panel starts where the one it flies from was
1107711154
initFrame: flight?.startFrame,
11078-
eager: !!flight
11155+
eager: !!flight,
11156+
sensitive
1107911157
});
1108011158
} else if(doc.type === 'video' || doc.type === 'gif' || doc.type === 'round'/* && doc.size <= 20e6 */) {
1108111159
// this.log('never get free 2', doc);
@@ -11108,7 +11186,7 @@ export default class ChatBubbles {
1110811186
chat: this.chat,
1110911187
loadPromises,
1111011188
autoDownload: this.chat.autoDownload,
11111-
spoilered: sensitive
11189+
sensitive
1111211190
});
1111311191
} else {
1111411192
const withTail = !IS_ANDROID && !IS_APPLE && !isRound && context.canHaveTail && !withReplies && USE_MEDIA_TAILS;
@@ -11136,7 +11214,7 @@ export default class ChatBubbles {
1113611214
isScheduled: (message as Message.message).pFlags.is_scheduled
1113711215
} : undefined,
1113811216
noInfo: message.mid <= 0,
11139-
noAutoplayAttribute: !!context.messageMedia.pFlags.spoiler,
11217+
noAutoplayAttribute: !!context.messageMedia.pFlags.spoiler || sensitive,
1114011218
observer: this.observer,
1114111219
onLoad: this.onVideoLoad,
1114211220
setShowControlsOn: bubble,
@@ -11145,12 +11223,13 @@ export default class ChatBubbles {
1114511223
}
1114611224
});
1114711225

11148-
if(context.messageMedia.pFlags.spoiler) {
11226+
if(context.messageMedia.pFlags.spoiler || sensitive) {
1114911227
loadPromises.push(this.wrapMediaSpoiler({
1115011228
media: doc,
1115111229
promise: p,
1115211230
middleware,
11153-
attachmentDiv: context.attachmentDiv
11231+
attachmentDiv: context.attachmentDiv,
11232+
sensitive
1115411233
}));
1115511234
}
1115611235
}
@@ -11448,6 +11527,9 @@ export default class ChatBubbles {
1144811527

1144911528
if(innerMedia) {
1145011529
const mediaSize = extendedMedia.length ? mediaSizes.active.extendedInvoice : mediaSizes.active.invoice;
11530+
// * what is not paid for is only a blurred preview, under its price
11531+
const paidSensitive = !isInvoice && isAlreadyPaid && sensitive;
11532+
let innerMediaPromise: Promise<any>;
1145111533
if(Array.isArray(innerMedia)) {
1145211534
bubble.classList.add('is-album', 'photo');
1145311535
wrapAlbum({
@@ -11459,12 +11541,13 @@ export default class ChatBubbles {
1145911541
chat: this.chat,
1146011542
loadPromises,
1146111543
autoDownload: this.chat.autoDownload,
11462-
spoilered: !isAlreadyPaid || sensitive,
11544+
spoilered: !isAlreadyPaid,
11545+
sensitive: paidSensitive,
1146311546
videoTimes,
1146411547
uploadingFileName: (message as Message.message).uploadingFileName
1146511548
});
1146611549
} else if(innerMedia._ === 'document') {
11467-
wrapVideo({
11550+
innerMediaPromise = wrapVideo({
1146811551
doc: innerMedia,
1146911552
container: context.attachmentDiv,
1147011553
withTail: false,
@@ -11479,11 +11562,12 @@ export default class ChatBubbles {
1147911562
observer: this.observer,
1148011563
onLoad: this.onVideoLoad,
1148111564
setShowControlsOn: bubble,
11565+
noAutoplayAttribute: paidSensitive,
1148211566
uploadingFileName: (message as Message.message).uploadingFileName?.[0]
1148311567
});
1148411568
bubble.classList.add('video');
1148511569
} else {
11486-
wrapPhoto({
11570+
innerMediaPromise = wrapPhoto({
1148711571
photo: innerMedia,
1148811572
container: context.attachmentDiv,
1148911573
withTail: false,
@@ -11498,6 +11582,16 @@ export default class ChatBubbles {
1149811582
bubble.classList.add('photo');
1149911583
}
1150011584

11585+
if(innerMediaPromise && paidSensitive) {
11586+
loadPromises.push(this.wrapMediaSpoiler({
11587+
media: innerMedia as Photo.photo | MyDocument,
11588+
promise: innerMediaPromise,
11589+
middleware,
11590+
attachmentDiv: context.attachmentDiv,
11591+
sensitive
11592+
}));
11593+
}
11594+
1150111595
if(priceEl) {
1150211596
if(!extendedMedia.length || (!isInvoice && isAlreadyPaid)) {
1150311597
priceEl.classList.add('video-time');

‎src/components/chat/chat.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -940,7 +940,8 @@ export default class Chat extends EventListenerBase<{
940940
}
941941

942942
public get isSensitive() {
943-
return isSensitive((this.peer as User.user).restriction_reason || []);
943+
// * read when asked: a peer the page does not know yet as the chat opens (its own user) comes later
944+
return isSensitive((usePeer(this.peerId) as User.user)?.restriction_reason || []);
944945
}
945946

946947
public async onChangePeer(options: ChatSetPeerOptions, m: ReturnType<typeof middlewarePromise>) {

‎src/components/popups/pollLink/webPagePreview.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import createMiddleware from '@helpers/solid/createMiddleware';
2+
import {getLinkSpoilerRevealButton} from '@components/wrappers/mediaSpoiler';
23
import {Message, MessageMedia, WebPage} from '@layer';
34
import {useHotReloadGuard} from '@lib/solidjs/hotReloadGuard';
45
import {createEffect, createSignal, onCleanup} from 'solid-js';
@@ -66,7 +67,8 @@ export default function PollLinkWebPagePreview(props: {
6667
box = renderedBox;
6768
makeWebPagePreviewMediaResponsive(box);
6869
box.addEventListener('click', handleClick);
69-
container.replaceChildren(box);
70+
// * with the button that reveals a spoiler over its media from the keyboard, which stands beside the link
71+
container.replaceChildren(...[box, getLinkSpoilerRevealButton(box)].filter(Boolean));
7072
setRendered(true);
7173
}).catch((): undefined => undefined);
7274

0 commit comments

Comments
 (0)