Skip to content

Commit b6d3b05

Browse files
committed
Support chat-specific hashtag and cashtag search
1 parent b3cdc15 commit b6d3b05

10 files changed

Lines changed: 281 additions & 15 deletions

‎src/lib/internalLinkProcessor.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,21 @@ import showAddBotToChat from '@components/popups/addBotToChat';
5252
import getBotAddToChatScope from '@appManagers/utils/bots/getBotAddToChatScope';
5353
import parseBotAdminRights from '@appManagers/utils/bots/parseBotAdminRights';
5454
import isEphemeralMessage from '@appManagers/utils/messages/isEphemeralMessage';
55+
import parseChatSpecificTag from '@lib/richTextProcessor/parseChatSpecificTag';
56+
import searchByTag from '@lib/richTextProcessor/searchByTag';
5557

5658
export class InternalLinkProcessor {
5759
protected managers: AppManagers;
5860
private processingAddAiStyleSlugs: Set<string> = new Set();
61+
private tagSearchVersion = 0;
62+
63+
private showUsernameResolveError(err: ApiError) {
64+
if(err.type === 'USERNAME_NOT_OCCUPIED') {
65+
toastNew({langPackKey: 'NoUsernameFound'});
66+
} else if(err.type === 'USERNAME_INVALID') {
67+
toastNew({langPackKey: 'Alert.UserDoesntExists'});
68+
}
69+
}
5970

6071
public construct(managers: AppManagers) {
6172
this.managers = managers;
@@ -131,7 +142,22 @@ export class InternalLinkProcessor {
131142
return;
132143
}
133144

134-
return appImManager.chat.initSearch({query: '#' + hashtag + ' '});
145+
const search = parseChatSpecificTag(hashtag);
146+
const version = ++this.tagSearchVersion;
147+
return searchByTag({
148+
query: search.query + ' ',
149+
username: search.username,
150+
activateSearch: (query) => appImManager.chat.initSearch({query}),
151+
resolveUsername: (username) => this.managers.appUsersManager.resolveUsername(username),
152+
openPeer: (peer) => appImManager.setInnerPeer({
153+
peerId: peer.id.toPeerId(peer._ !== 'user')
154+
}),
155+
isCurrent: () => version === this.tagSearchVersion,
156+
onResolveError: (err) => {
157+
appImManager.chat.resetSearch();
158+
this.showUsernameResolveError(err as ApiError);
159+
}
160+
});
135161
}
136162
});
137163

@@ -838,11 +864,7 @@ export class InternalLinkProcessor {
838864

839865
await appImManager.openUsername({userName: link.domain});
840866
}, (err: ApiError) => {
841-
if(err.type === 'USERNAME_NOT_OCCUPIED') {
842-
toastNew({langPackKey: 'NoUsernameFound'});
843-
} else if(err.type === 'USERNAME_INVALID') {
844-
toastNew({langPackKey: 'Alert.UserDoesntExists'});
845-
}
867+
this.showUsernameResolveError(err);
846868
});
847869
}
848870

‎src/lib/richTextProcessor/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export const USERNAME_REG_EXP = '[a-zA-Z\\d_]{5,32}';
6161
// export const TIMESTAMP_REG_EXP = '(?:\\s|^)((?:\\d{1,2}:)?(?:[0-5]?[0-9]):(?:[0-5][0-9]))(?:\\s|$)';
6262
export const TIMESTAMP_REG_EXP = '(?:\\s|^)((?:(\\d{1,2}):(?:[0-5]?[0-9])|(?:\\d{1,2}|\\d{3,})):(?:[0-5][0-9]))(?:\\s|$)';
6363
export const BOT_COMMAND_REG_EXP = '\\/([a-zA-Z\\d_]{1,32})(?:@(' + USERNAME_REG_EXP + '))?(\\b|$)';
64-
export const FULL_REG_EXP = new RegExp('(^| )(@)(' + USERNAME_REG_EXP + ')|(' + URL_REG_EXP + ')|(\\n)|(' + emojiRegExp + ')|(^|[\\s\\(\\]])(#[' + ALPHA_NUMERIC_REG_EXP + ']{2,64})|(^|\\s)' + BOT_COMMAND_REG_EXP + '|' + TIMESTAMP_REG_EXP + '', 'i');
64+
const TAG_REG_EXP = '(?:#[' + ALPHA_NUMERIC_REG_EXP + ']{2,64}|\\$[A-Z]{1,8}(?![A-Z\\d_]))(?:@' + USERNAME_REG_EXP + ')?';
65+
export const FULL_REG_EXP = new RegExp('(^| )(@)(' + USERNAME_REG_EXP + ')|(' + URL_REG_EXP + ')|(\\n)|(' + emojiRegExp + ')|(^|[\\s\\(\\]])(' + TAG_REG_EXP + ')|(^|\\s)' + BOT_COMMAND_REG_EXP + '|' + TIMESTAMP_REG_EXP + '', 'i');
6566
export const EMAIL_REG_EXP = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
6667
// const markdownTestRegExp = /[`_*@~]/;
6768
export const MARKDOWN_REG_EXP = /(^|\s|\n)(````?)([\s\S]+?)(````?)([\s\n\.,:?!;]|$)|(^|\s|\x01)(`|~~|\*\*|__|_-_|\|\|)([^\n]+?)\7([\x01\s\.,:?!;]|$)|@(\d+)\s*\((.+?)\)|(\[(.+?)\]\((.+?)\))/m;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {isUsernameValid} from '@lib/richTextProcessor/validators';
2+
3+
export default function parseChatSpecificTag(value: string) {
4+
const prefix = value[0] === '$' ? '$' : '#';
5+
const tag = prefix === '$' ? value.slice(1) : value;
6+
const separatorIndex = tag.indexOf('@');
7+
const username = separatorIndex > 0 ? tag.slice(separatorIndex + 1) : undefined;
8+
9+
if(!username || !isUsernameValid(username)) {
10+
return {query: prefix + tag};
11+
}
12+
13+
return {
14+
query: prefix + tag.slice(0, separatorIndex),
15+
username
16+
};
17+
}

‎src/lib/richTextProcessor/parseEntities.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,17 @@ export default function parseEntities(text: string) {
8484
unicode: unified
8585
});
8686
}
87-
} else if(match[11]) { // Hashtag
88-
entities.push({
89-
_: 'messageEntityHashtag',
90-
offset: matchIndex + (match[10] ? match[10].length : 0),
91-
length: match[11].length
92-
});
87+
} else if(match[11]) { // Hashtag or cashtag
88+
const isCashtag = match[11][0] === '$';
89+
const cashtag = isCashtag ? match[11].split('@', 1)[0] : undefined;
90+
91+
if(!cashtag || cashtag === cashtag.toUpperCase()) {
92+
entities.push({
93+
_: isCashtag ? 'messageEntityCashtag' : 'messageEntityHashtag',
94+
offset: matchIndex + (match[10] ? match[10].length : 0),
95+
length: match[11].length
96+
});
97+
}
9398
} else if(match[13]) { // Bot command
9499
entities.push({
95100
_: 'messageEntityBotCommand',
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default async function searchByTag<T>(options: {
2+
query: string,
3+
username?: string,
4+
activateSearch: (query: string) => void,
5+
resolveUsername: (username: string) => MaybePromise<T>,
6+
openPeer: (peer: T) => MaybePromise<unknown>,
7+
isCurrent: () => boolean,
8+
onResolveError: (err: unknown) => void
9+
}) {
10+
options.activateSearch(options.query);
11+
if(!options.username) {
12+
return;
13+
}
14+
15+
let peer: T;
16+
try {
17+
peer = await options.resolveUsername(options.username);
18+
} catch(err) {
19+
if(options.isCurrent()) {
20+
options.onResolveError(err);
21+
}
22+
23+
return;
24+
}
25+
26+
if(!options.isCurrent()) {
27+
return;
28+
}
29+
30+
await options.openPeer(peer);
31+
if(options.isCurrent()) {
32+
options.activateSearch(options.query);
33+
}
34+
}

‎src/lib/richTextProcessor/wrapRichText.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,13 +621,15 @@ export default function wrapRichText(text: string, options: WrapRichTextOptions
621621
break;
622622
}
623623

624+
case 'messageEntityCashtag':
624625
case 'messageEntityHashtag': {
625626
const contextUrl = !options.noLinks && SITE_HASHTAGS[contextSite];
626627
if(contextUrl) {
627-
const hashtag = fullEntityText.slice(1);
628+
const tag = fullEntityText.slice(1);
629+
const linkTag = entity._ === 'messageEntityCashtag' ? '$' + tag : tag;
628630
element = document.createElement('a');
629631
element.className = 'anchor-hashtag';
630-
(element as HTMLAnchorElement).href = contextUrl.replace('{1}', encodeURIComponent(hashtag));
632+
(element as HTMLAnchorElement).href = contextUrl.replace('{1}', encodeURIComponent(linkTag));
631633
if(contextExternal) {
632634
setBlankToAnchor(element as HTMLAnchorElement);
633635
} else {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {describe, expect, test} from 'vitest';
2+
import parseChatSpecificTag from '@lib/richTextProcessor/parseChatSpecificTag';
3+
4+
describe('parseChatSpecificTag', () => {
5+
test('keeps a regular tag unchanged', () => {
6+
expect(parseChatSpecificTag('tag')).toEqual({query: '#tag'});
7+
expect(parseChatSpecificTag('$TON')).toEqual({query: '$TON'});
8+
});
9+
10+
test('separates the target chat username', () => {
11+
expect(parseChatSpecificTag('tag@public_channel')).toEqual({
12+
query: '#tag',
13+
username: 'public_channel'
14+
});
15+
expect(parseChatSpecificTag('$TON@public_channel')).toEqual({
16+
query: '$TON',
17+
username: 'public_channel'
18+
});
19+
});
20+
21+
test('supports unicode tags', () => {
22+
expect(parseChatSpecificTag('телеграм@тест')).toEqual({query: '#телеграм@тест'});
23+
expect(parseChatSpecificTag('телеграм@telegram')).toEqual({
24+
query: '#телеграм',
25+
username: 'telegram'
26+
});
27+
});
28+
29+
test('does not treat an invalid username suffix as a chat target', () => {
30+
expect(parseChatSpecificTag('tag@invalid_')).toEqual({query: '#tag@invalid_'});
31+
expect(parseChatSpecificTag('$TON@valid@extra')).toEqual({query: '$TON@valid@extra'});
32+
});
33+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {describe, expect, test} from 'vitest';
2+
import parseEntities from '@lib/richTextProcessor/parseEntities';
3+
4+
describe('parseEntities tags', () => {
5+
test('keeps a chat-specific hashtag in one entity', () => {
6+
const text = '#test@username';
7+
8+
expect(parseEntities(text)).toEqual([{
9+
_: 'messageEntityHashtag',
10+
offset: 0,
11+
length: text.length
12+
}]);
13+
});
14+
15+
test('keeps a chat-specific cashtag in one entity', () => {
16+
const text = '$TON@public_channel';
17+
18+
expect(parseEntities(text)).toEqual([{
19+
_: 'messageEntityCashtag',
20+
offset: 0,
21+
length: text.length
22+
}]);
23+
});
24+
25+
test('keeps an adjacent mention separate when it is not part of the tag', () => {
26+
expect(parseEntities('#test @username')).toEqual([{
27+
_: 'messageEntityHashtag',
28+
offset: 0,
29+
length: 5
30+
}, {
31+
_: 'messageEntityMention',
32+
offset: 6,
33+
length: 9
34+
}]);
35+
});
36+
37+
test('only recognizes uppercase cashtags', () => {
38+
expect(parseEntities('$ton@username')).toEqual([]);
39+
});
40+
});

‎src/tests/searchByTag.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {describe, expect, test, vi} from 'vitest';
2+
import searchByTag from '@lib/richTextProcessor/searchByTag';
3+
4+
describe('searchByTag', () => {
5+
test('activates a regular tag search synchronously', async() => {
6+
const activateSearch = vi.fn();
7+
const promise = searchByTag({
8+
query: '$TON ',
9+
activateSearch,
10+
resolveUsername: vi.fn(),
11+
openPeer: vi.fn(),
12+
isCurrent: () => true,
13+
onResolveError: vi.fn()
14+
});
15+
16+
expect(activateSearch).toHaveBeenCalledWith('$TON ');
17+
await promise;
18+
expect(activateSearch).toHaveBeenCalledTimes(1);
19+
});
20+
21+
test('activates before resolving and reactivates after opening the target peer', async() => {
22+
const calls: string[] = [];
23+
let finishResolving: (peer: {id: number}) => void;
24+
const resolving = new Promise<{id: number}>((resolve) => {
25+
finishResolving = resolve;
26+
});
27+
const promise = searchByTag({
28+
query: '#news ',
29+
username: 'telegram',
30+
activateSearch: (query) => calls.push('activate:' + query),
31+
resolveUsername: () => {
32+
calls.push('resolve');
33+
return resolving;
34+
},
35+
openPeer: async() => {
36+
calls.push('open');
37+
},
38+
isCurrent: () => true,
39+
onResolveError: vi.fn()
40+
});
41+
42+
expect(calls).toEqual(['activate:#news ', 'resolve']);
43+
finishResolving({id: 1});
44+
await promise;
45+
expect(calls).toEqual(['activate:#news ', 'resolve', 'open', 'activate:#news ']);
46+
});
47+
48+
test('does not open a peer for a stale resolution', async() => {
49+
let current = true;
50+
const openPeer = vi.fn();
51+
await searchByTag({
52+
query: '#news ',
53+
username: 'telegram',
54+
activateSearch: vi.fn(),
55+
resolveUsername: async() => {
56+
current = false;
57+
return {id: 1};
58+
},
59+
openPeer,
60+
isCurrent: () => current,
61+
onResolveError: vi.fn()
62+
});
63+
64+
expect(openPeer).not.toHaveBeenCalled();
65+
});
66+
});

‎src/tests/wrapRichTextTags.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {describe, expect, test, vi} from 'vitest';
2+
import {MessageEntity} from '@layer';
3+
import wrapRichText from '@lib/richTextProcessor/wrapRichText';
4+
5+
vi.mock('@lib/apiManagerProxy', () => ({default: {addEventListener: () => {}, getState: () => Promise.resolve({})}}));
6+
7+
vi.hoisted(() => {
8+
class IntersectionObserverMock {
9+
public observe() {}
10+
public unobserve() {}
11+
public disconnect() {}
12+
public takeRecords(): IntersectionObserverEntry[] { return []; }
13+
}
14+
15+
Object.defineProperty(globalThis, 'IntersectionObserver', {
16+
configurable: true,
17+
value: IntersectionObserverMock
18+
});
19+
20+
HTMLCanvasElement.prototype.toDataURL = () => 'data:image/png;base64,';
21+
Object.defineProperty(globalThis, 'Worker', {configurable: true, writable: true, value: class Worker {}});
22+
Object.defineProperty(globalThis, 'CSS', {configurable: true, value: {supports: () => true}});
23+
});
24+
25+
function wrapTag(text: string, type: 'messageEntityHashtag' | 'messageEntityCashtag') {
26+
const entities: MessageEntity[] = [{_: type, offset: 0, length: text.length}];
27+
return wrapRichText(text, {entities}).querySelector('a.anchor-hashtag');
28+
}
29+
30+
describe('wrapRichText tags', () => {
31+
test('wraps chat-specific hashtags', () => {
32+
const anchor = wrapTag('#news@telegram', 'messageEntityHashtag');
33+
34+
expect(anchor.textContent).toBe('#news@telegram');
35+
expect(anchor.getAttribute('href')).toBe('tg://search_hashtag?hashtag=news%40telegram');
36+
expect(anchor.getAttribute('onclick')).toBe('searchByHashtag(this)');
37+
});
38+
39+
test('wraps chat-specific cashtags without losing the dollar prefix', () => {
40+
const anchor = wrapTag('$TON@telegram', 'messageEntityCashtag');
41+
42+
expect(anchor.textContent).toBe('$TON@telegram');
43+
expect(anchor.getAttribute('href')).toBe('tg://search_hashtag?hashtag=%24TON%40telegram');
44+
expect(anchor.getAttribute('onclick')).toBe('searchByHashtag(this)');
45+
});
46+
});

0 commit comments

Comments
 (0)