Skip to content

Commit 456eb3d

Browse files
committed
Add support for channel and telegramGift as wallet transaction peer (#7460)
1 parent 4e298b3 commit 456eb3d

12 files changed

Lines changed: 126 additions & 41 deletions

File tree

‎FRICTION_LOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ Contributors wanting special elements must work within these representations or
2626

2727
[`tsconfig.json`](tsconfig.json) covers app source and excludes tests and selected GramJS generators, while [`tsconfig.script.json`](tsconfig.script.json) covers tests, development scripts, plugins, and configuration files. However, [`eslint.config.js`](eslint.config.js) applies the same broad Teact rules and both TypeScript projects to JavaScript and TypeScript files across the repository. The script config also inherits DOM/Web Worker libraries and bundler module resolution from [`tsconfig.base.json`](tsconfig.base.json). The split therefore does not clearly separate the rules and environment assumptions for browser code from tooling running locally in Node.
2828

29-
[`check:ts`](package.json) runs `tsc` against the app config, then repository-wide ESLint; it does not explicitly type-check the script project with a separate `tsc` invocation. This makes the compiler and lint coverage harder to reason about and maintain. With lockfile dependencies installed, the compiler succeeds on Windows, but a repository-wide ESLint run exceeds 4 GB of working memory after more than six minutes and is stopped without results; linting the changed audio/video files separately succeeds. The source of this full-run memory cost is not profiled. The preference is a clear app configuration and a separate local tests/helpers configuration, retaining browser types where tests need them: [Vitest](vitest.config.ts) runs in Node with a jsdom environment, and tests import app code. Shared settings still need to stay consistent without blurring those boundaries.
29+
[`check:ts`](package.json) runs `tsc` against the app config, then repository-wide ESLint; it does not explicitly type-check the script project with a separate `tsc` invocation. This makes the compiler and lint coverage harder to reason about and maintain. With lockfile dependencies installed, the compiler succeeds on Windows, but a repository-wide ESLint run exceeds 4 GB of working memory after more than six minutes and is stopped without results; linting the changed audio/video files separately succeeds. On macOS with Node 26.12.1, the same full ESLint run aborts the process (`Abort trap: 6`) after `tsc` passes, while linting the changed files separately succeeds. The source of this full-run memory cost is not profiled. The preference is a clear app configuration and a separate local tests/helpers configuration, retaining browser types where tests need them: [Vitest](vitest.config.ts) runs in Node with a jsdom environment, and tests import app code. Shared settings still need to stay consistent without blurring those boundaries.
30+
31+
## Currency symbol test depends on the Node ICU data
32+
33+
On Node 26.12.1, [`walletAmountEntry.test.ts`](tests/walletAmountEntry.test.ts) fails in `gives no currency symbol where the code is the only one`: `getCurrencySymbol('CHF', 'en')` returns `Fr.`, while the test expects `undefined`. The test covers only currency formatting, yet it keeps the whole wallet test run failing locally. Whether other Node versions return a different symbol is not checked; the ICU data bundled with Node is the suspected cause.
3034

3135
## Generated icon files change line endings on Windows
3236

‎src/api/gramjs/apiBuilders/wallet.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,23 @@ export function buildApiWalletStateResult(state: GramJs.TypeWalletState): ApiWal
8787
export function buildApiWalletTransaction(transaction: GramJs.WalletTransaction): ApiWalletTransaction {
8888
// With the flag set, `comment` holds the base64 payload
8989
const isCommentEncrypted = transaction.commentEncrypted;
90+
const peer = buildApiWalletTransactionPeer(transaction.peer);
91+
// Telegram writes a service reference such as `Ref#qpK1nO11N` into the comment of a gift it moves. The native apps
92+
// do not show it.
93+
const comment = peer.type === 'telegramGift' ? undefined : transaction.comment;
9094

9195
return {
9296
id: transaction.id,
9397
isIncoming: Boolean(transaction.incoming),
9498
isFailed: transaction.failed,
9599
isGasless: transaction.gasless,
96100
isKeyChange: transaction.keyChange,
97-
peer: buildApiWalletTransactionPeer(transaction.peer),
101+
peer,
98102
// The server signs the amount by direction, but `ApiWalletTransaction` keeps it positive
99103
amount: getBigIntAbs(transaction.amount),
100104
fee: transaction.fee,
101-
comment: isCommentEncrypted ? undefined : transaction.comment,
102-
encryptedComment: isCommentEncrypted ? transaction.comment : undefined,
105+
comment: isCommentEncrypted ? undefined : comment,
106+
encryptedComment: isCommentEncrypted ? comment : undefined,
103107
txHash: transaction.txHash,
104108
timestamp: transaction.date,
105109
collectible: transaction.nft && buildApiWalletCollectible(transaction.nft),
@@ -129,6 +133,19 @@ function buildApiWalletTransactionPeer(peer: GramJs.TypeWalletTransactionPeer):
129133
};
130134
}
131135

136+
if (peer instanceof GramJs.WalletTransactionPeerChannel) {
137+
return {
138+
type: 'channel',
139+
channelId: buildApiPeerId(peer.channelId, 'channel'),
140+
address: toNonBounceableAddress(peer.address),
141+
domain: peer.domain,
142+
};
143+
}
144+
145+
if (peer instanceof GramJs.WalletTransactionPeerTelegramGift) {
146+
return { type: 'telegramGift', isOwnProfile: peer.ownProfile };
147+
}
148+
132149
return { type: 'unsupported' };
133150
}
134151

‎src/api/types/wallet.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export type ApiWalletTransactionPeer =
102102
| { type: 'user'; userId: string; address: string; domain?: string }
103103
| { type: 'address'; address: string; domain?: string }
104104
| { type: 'onramp'; address: string; domain?: string; providerName: string }
105+
| { type: 'channel'; channelId: string; address: string; domain?: string }
106+
// The other side is Telegram itself; `isOwnProfile` marks a gift moved between the wallet and the user's profile
107+
| { type: 'telegramGift'; isOwnProfile?: boolean }
105108
| { type: 'unsupported' };
106109

107110
export interface ApiWalletTransaction {

‎src/assets/localization/fallback.strings‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3255,6 +3255,8 @@
32553255
"WalletTransactionKeyChange" = "Key Update";
32563256
"WalletTransactionIncomingCollectible" = "Incoming collectible";
32573257
"WalletTransactionOutgoingCollectible" = "Outgoing collectible";
3258+
"WalletTransactionMovedToProfile" = "Moved to profile";
3259+
"WalletTransactionMovedFromProfile" = "Moved from profile";
32583260
"WalletCollectibleGift" = "Collectible Gift";
32593261
"WalletCollectibleUsername" = "Collectible Username";
32603262
"WalletCollectibleNumber" = "Collectible Number";

‎src/components/modals/wallet/WalletMainScreen.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ type StateProps = {
6666
transactions?: ApiWalletTransaction[];
6767
transferMinNanos?: bigint;
6868
collectibles?: ApiWalletCollectible[];
69-
usersById: Record<string, ApiUser>;
7069
existingWaltBalanceUrl?: string;
7170
savedWallets?: ApiWalletSavedWallet[];
7271
earningsNanos?: bigint;
@@ -83,7 +82,7 @@ const WalletMainScreen = ({
8382
activeTab, scrollContainerClosest,
8483
status, hasStateError, canEnableBackup, hasCloudPassword, address, currentUser, balance, tonUsdRate, currency,
8584
currencyRate,
86-
transactions, transferMinNanos, collectibles, usersById,
85+
transactions, transferMinNanos, collectibles,
8786
existingWaltBalanceUrl, savedWallets, earningsNanos,
8887
onProtect, onProtectAccount, onOpenSettings, onOpenEarnings, onReceive, onSend, onSwitchTab,
8988
onTransactionClick, onCollectibleClick,
@@ -248,7 +247,6 @@ const WalletMainScreen = ({
248247
<WalletTransactionItem
249248
key={transaction.localId ?? id}
250249
transaction={transaction}
251-
user={transaction.peer.type === 'user' ? usersById[transaction.peer.userId] : undefined}
252250
className={HISTORY_ITEM_CLASS}
253251
onClick={onTransactionClick}
254252
/>
@@ -364,7 +362,6 @@ export default memo(withGlobal<OwnProps>(
364362
transactions: selectWalletTransactions(global),
365363
transferMinNanos: global.appConfig.walletTransferMinNanos,
366364
collectibles: global.wallet?.collectibles,
367-
usersById: global.users.byId,
368365
existingWaltBalanceUrl: global.wallet?.existingWaltBalanceUrl,
369366
savedWallets: global.wallet?.savedWallets,
370367
earningsNanos: global.ton?.balance.amount,

‎src/components/modals/wallet/WalletTransactionItem.tsx‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
import { memo, useRef } from '../../../lib/teact/teact';
1+
import { memo, useCallback, useRef } from '../../../lib/teact/teact';
22

33
import type {
4-
ApiUser, ApiWalletCollectible, ApiWalletTransaction,
4+
ApiPeer, ApiWalletCollectible, ApiWalletTransaction, ApiWalletTransactionPeer,
55
} from '../../../api/types';
6+
import type { GlobalState } from '../../../global/types';
67
import type { RegularLangKey } from '../../../types/language';
8+
import type { LangFn } from '../../../util/localization';
79

810
import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom';
9-
import { getUserFullName } from '../../../global/helpers';
11+
import { getPeerFullTitle } from '../../../global/helpers/peers';
12+
import { getWalletGiftMoveKey, getWalletPeerId } from '../../../global/helpers/wallet';
13+
import { selectPeer } from '../../../global/selectors';
1014
import buildClassName from '../../../util/buildClassName';
1115
import { formatDateTime, secondsToDate } from '../../../util/localization/dateFormat';
1216
import { shortenAddress } from '../../../util/tonAddress';
1317
import renderText from '../../common/helpers/renderText';
1418
import { sampleFrames, SETTLE_EASING } from './helpers/motion';
1519
import { ROW_DIAMOND_SIZE } from './helpers/sendFlight';
1620

21+
import useSelector from '../../../hooks/data/useSelector';
1722
import useClickable from '../../../hooks/useClickable';
1823
import useFlag from '../../../hooks/useFlag';
1924
import useLang from '../../../hooks/useLang';
@@ -34,7 +39,6 @@ import styles from './WalletTransactionItem.module.scss';
3439

3540
type OwnProps = {
3641
transaction: ApiWalletTransaction;
37-
user?: ApiUser;
3842
className?: string;
3943
onClick: (transaction: ApiWalletTransaction) => void;
4044
};
@@ -60,14 +64,20 @@ const IMPACT_FRAMES = sampleFrames(IMPACT_DURATION, (time) => {
6064
});
6165

6266
const WalletTransactionItem = ({
63-
transaction, user, className, onClick,
67+
transaction, className, onClick,
6468
}: OwnProps) => {
6569
const {
6670
isIncoming, peer, amount, comment, isPending, isFailed, timestamp, collectible,
6771
} = transaction;
6872

6973
const lang = useLang();
7074

75+
const peerId = getWalletPeerId(peer);
76+
const counterpartySelector = useCallback((global: GlobalState) => {
77+
return peerId ? selectPeer(global, peerId) : undefined;
78+
}, [peerId]);
79+
const counterparty = useSelector(counterpartySelector);
80+
7181
const ref = useRef<HTMLDivElement>();
7282
const gramIconRef = useRef<HTMLSpanElement>();
7383
const [isSendRow, markSendRow] = useFlag();
@@ -106,13 +116,7 @@ const WalletTransactionItem = ({
106116
});
107117

108118
const onrampIcon = peer.type === 'onramp' ? getOnrampProviderIcon(peer.providerName).icon : undefined;
109-
const title = user
110-
? getUserFullName(lang, user)
111-
: peer.type === 'onramp'
112-
? lang(onrampIcon === 'walt' ? 'WalletTransactionTopUpCrypto' : 'WalletTransactionTopUp')
113-
: peer.type !== 'unsupported'
114-
? peer.domain || shortenAddress(peer.address)
115-
: lang('WalletTransactionUnsupportedPeer');
119+
const title = getTitle(lang, peer, counterparty);
116120
const description = comment
117121
|| (peer.type === 'onramp' ? capitalizeName(peer.providerName) : lang(getDescriptionKey(transaction)));
118122
const status = isPending ? lang('WalletTransactionSending') : isFailed ? lang('WalletTransactionFailed') : undefined;
@@ -137,8 +141,8 @@ const WalletTransactionItem = ({
137141
<WalletRowOverlay originRef={gramIconRef} onEnd={markBurstDone} />
138142
)}
139143
<div className={styles.preview}>
140-
{user ? (
141-
<Avatar peer={user} size={AVATAR_SIZE} />
144+
{counterparty ? (
145+
<Avatar peer={counterparty} size={AVATAR_SIZE} />
142146
) : (
143147
<div className={buildClassName(styles.direction, isIncoming ? styles.directionIn : styles.directionOut)}>
144148
{onrampIcon ? (
@@ -223,6 +227,23 @@ const WalletTransactionItem = ({
223227
);
224228
};
225229

230+
function getTitle(lang: LangFn, peer: ApiWalletTransactionPeer, counterparty?: ApiPeer) {
231+
if (counterparty) return getPeerFullTitle(lang, counterparty);
232+
233+
switch (peer.type) {
234+
case 'onramp':
235+
return lang(getOnrampProviderIcon(peer.providerName).icon === 'walt'
236+
? 'WalletTransactionTopUpCrypto'
237+
: 'WalletTransactionTopUp');
238+
case 'telegramGift':
239+
return lang('Telegram');
240+
case 'unsupported':
241+
return lang('WalletTransactionUnsupportedPeer');
242+
default:
243+
return peer.domain || shortenAddress(peer.address);
244+
}
245+
}
246+
226247
function getCollectibleTypeKey(type: ApiWalletCollectible['type']): RegularLangKey {
227248
switch (type) {
228249
case 'gift':
@@ -241,9 +262,11 @@ function capitalizeName(name: string) {
241262
return name.charAt(0).toUpperCase() + name.slice(1);
242263
}
243264

244-
function getDescriptionKey({
245-
encryptedComment, isKeyChange, collectible, peer, isIncoming,
246-
}: ApiWalletTransaction): RegularLangKey {
265+
function getDescriptionKey(transaction: ApiWalletTransaction): RegularLangKey {
266+
const { encryptedComment, isKeyChange, collectible, peer, isIncoming } = transaction;
267+
const giftMoveKey = getWalletGiftMoveKey(transaction);
268+
if (giftMoveKey) return giftMoveKey;
269+
247270
if (encryptedComment) return 'WalletCommentEncrypted';
248271

249272
if (isKeyChange) return 'WalletTransactionKeyChange';

‎src/components/modals/wallet/WalletTransactionScreen.tsx‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { memo, useEffect, useMemo } from '../../../lib/teact/teact';
22
import { getActions, withGlobal } from '../../../global';
33

4-
import type { ApiUser, ApiWalletTransaction } from '../../../api/types';
4+
import type { ApiPeer, ApiWalletTransaction } from '../../../api/types';
55

6-
import { getUserFullName } from '../../../global/helpers';
6+
import { getPeerFullTitle } from '../../../global/helpers/peers';
7+
import { getWalletGiftMoveKey } from '../../../global/helpers/wallet';
78
import {
8-
selectTabState, selectUser, selectWalletCurrency, selectWalletCurrencyRate, selectWalletResolvedUser,
9+
selectTabState, selectWalletCounterparty, selectWalletCurrency, selectWalletCurrencyRate,
910
} from '../../../global/selectors';
1011
import buildClassName from '../../../util/buildClassName';
1112
import { copyTextToClipboard } from '../../../util/clipboard';
@@ -47,7 +48,7 @@ type StateProps = {
4748
currency: string;
4849
tonUsdRate?: number;
4950
currencyRate?: number;
50-
user?: ApiUser;
51+
counterparty?: ApiPeer;
5152
decryptedComment?: string;
5253
isDecryptingComment?: boolean;
5354
freeTransfersPerDay?: number;
@@ -64,7 +65,7 @@ type DomainCellOwnProps = {
6465
const DIAMOND_SIZE = 120;
6566

6667
const WalletTransactionScreen = ({
67-
transaction, isRoot, currency, tonUsdRate, currencyRate, user, decryptedComment, isDecryptingComment,
68+
transaction, isRoot, currency, tonUsdRate, currencyRate, counterparty, decryptedComment, isDecryptingComment,
6869
freeTransfersPerDay, onDecryptComment, onSend, onDone,
6970
}: OwnProps & StateProps) => {
7071
const { loadWalletUserAddresses } = getActions();
@@ -101,21 +102,23 @@ const WalletTransactionScreen = ({
101102
);
102103

103104
const peerLabel = lang(isIncoming ? 'WalletTransactionSender' : 'WalletTransactionRecipient');
104-
const domain = peer.type === 'unsupported' ? undefined : peer.domain;
105+
const domain = 'address' in peer ? peer.domain : undefined;
105106

106-
if (user) {
107+
if (counterparty) {
107108
rows.push([
108109
peerLabel,
109110
<span className={styles.peer}>
110-
<Avatar peer={user} size="small" />
111-
<span className={styles.peerText}>{renderText(getUserFullName(lang, user))}</span>
111+
<Avatar peer={counterparty} size="small" />
112+
<span className={styles.peerText}>{renderText(getPeerFullTitle(lang, counterparty))}</span>
112113
{sendToAddress && sendButton(sendToAddress, peer.type === 'user' ? peer.userId : undefined)}
113114
</span>,
114115
]);
115116

116117
if (domain) {
117118
rows.push([lang('WalletTransactionDomain'), <DomainCell domain={domain} />]);
118119
}
120+
} else if (peer.type === 'telegramGift') {
121+
rows.push([peerLabel, lang('Telegram')]);
119122
} else if (domain) {
120123
// The button sends to the address behind the domain, which the form takes without resolving it again
121124
rows.push([
@@ -131,12 +134,12 @@ const WalletTransactionScreen = ({
131134
rows.push([lang('WalletTransactionProvider'), peer.providerName]);
132135
}
133136

134-
if (peer.type !== 'unsupported') {
137+
if ('address' in peer) {
135138
rows.push([
136139
lang('WalletTransactionAddress'),
137140
<span className={styles.peer}>
138141
<AddressCell address={peer.address} />
139-
{sendToAddress && !user && !domain && sendButton(sendToAddress)}
142+
{sendToAddress && !counterparty && !domain && sendButton(sendToAddress)}
140143
</span>,
141144
]);
142145
}
@@ -194,11 +197,12 @@ const WalletTransactionScreen = ({
194197

195198
return rows;
196199
}, [
197-
user, peer, isIncoming, isKeyChange, fee, fiatFee, isFeeFree, timestamp, comment, lang, decryptedComment,
200+
counterparty, peer, isIncoming, isKeyChange, fee, fiatFee, isFeeFree, timestamp, comment, lang, decryptedComment,
198201
encryptedComment, isDecryptingComment, canShowFeesInfo, onDecryptComment, onSend,
199202
]);
200203

201204
const fiatAmount = formatTonInFiat(amount, tonUsdRate, currency, currencyRate, lang.code);
205+
const giftMoveKey = getWalletGiftMoveKey(transaction);
202206

203207
const sign = amount === 0n ? '' : isIncoming ? '+' : '−';
204208

@@ -215,6 +219,7 @@ const WalletTransactionScreen = ({
215219
<>
216220
<WalletCollectibleImage collectible={collectible} isFull className={styles.collectible} />
217221
<div className={styles.title}>{collectible.name ?? lang('WalletCollectibleTitle')}</div>
222+
{giftMoveKey && <div className={styles.status}>{lang(giftMoveKey)}</div>}
218223
</>
219224
) : (
220225
<>
@@ -286,8 +291,7 @@ export default memo(withGlobal<OwnProps>(
286291
currency: selectWalletCurrency(global),
287292
tonUsdRate: global.appConfig.tonUsdRate,
288293
currencyRate: selectWalletCurrencyRate(global),
289-
user: peer.type === 'user' ? selectUser(global, peer.userId)
290-
: peer.type === 'address' ? selectWalletResolvedUser(global, peer.address) : undefined,
294+
counterparty: selectWalletCounterparty(global, peer),
291295
decryptedComment: global.wallet?.decryptedComments?.[transaction.id],
292296
isDecryptingComment: decryption?.transactionId === transaction.id && decryption.status === 'pending',
293297
freeTransfersPerDay: global.appConfig.walletGaslessDailyTransfers,

‎src/global/actions/api/wallet.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1564,7 +1564,7 @@ addActionHandler('decryptWalletComment', async (global, actions, payload): Promi
15641564
const ownAddress = selectWalletAddress(global);
15651565
const transaction = selectWalletTransactions(global)?.find(({ id }) => id === transactionId)
15661566
|| Object.values(global.wallet?.loadedTransactions || {}).find(({ id }) => id === transactionId);
1567-
if (!ownAddress || !transaction?.encryptedComment || transaction.peer.type === 'unsupported') return;
1567+
if (!ownAddress || !transaction?.encryptedComment || !('address' in transaction.peer)) return;
15681568

15691569
// The comment is salted with the sender's address, which is ours for an outgoing transfer
15701570
const saltAddress = transaction.isIncoming ? transaction.peer.address : ownAddress;

‎src/global/cache.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,10 @@ function reduceChats<T extends GlobalState>(global: T): GlobalState['chats'] {
723723
...currentChatIds,
724724
...messagesChatIds,
725725
...topPeerIds,
726+
// The cached wallet history shows its channels by name. They go before the folders, whose long lists are cut by
727+
// the chat limit.
728+
...selectWalletTransactions(global)?.slice(0, WALLET_TRANSACTIONS_CACHE_LIMIT)
729+
.flatMap(({ peer }) => (peer.type === 'channel' ? [peer.channelId] : [])) || [],
726730
...global.recentlyFoundChatIds || [],
727731
...getOrderedIds(ARCHIVED_FOLDER_ID)?.slice(0, GLOBAL_STATE_CACHE_ARCHIVED_CHAT_LIST_LIMIT) || [],
728732
...getOrderedIds(ALL_FOLDER_ID) || [],

0 commit comments

Comments
 (0)