Skip to content

Commit 44c57a3

Browse files
committed
Profile: Show user balance in card (#7459)
1 parent 456eb3d commit 44c57a3

38 files changed

Lines changed: 1310 additions & 114 deletions

‎FRICTION_LOG.md‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,7 @@ 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. 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.
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. In a fresh worktree without `.cache/.eslintcache` it fails with a JavaScript heap out-of-memory error after about 90 seconds. 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.
3430

3531
## Generated icon files change line endings on Windows
3632

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export function buildApiStealthMode(stealthMode: GramJs.TypeStoriesStealthMode):
154154
};
155155
}
156156

157-
function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinates): ApiMediaAreaCoordinates {
157+
export function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinates): ApiMediaAreaCoordinates {
158158
const {
159159
x, y, w, h, rotation, radius,
160160
} = coordinates;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
buildApiProfileTab,
2929
buildAvatarPhotoId,
3030
} from './peers';
31+
import { buildApiWalletCard } from './wallet';
3132

3233
export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUserFullInfo {
3334
const {
@@ -40,7 +41,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
4041
botCanManageEmojiStatus, settings, sendPaidMessagesStars, displayGiftsButton, disallowedGifts,
4142
starsRating, starsMyPendingRating, starsMyPendingRatingDate, mainTab, savedMusic, note,
4243
noforwardsMyEnabled, noforwardsPeerEnabled, unofficialSecurityRisk, privateForwardName,
43-
ttlPeriod, botManagerId,
44+
ttlPeriod, botManagerId, walletCard,
4445
},
4546
users,
4647
} = mtpUserFull;
@@ -88,6 +89,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
8889
noForwardsPeerEnabled: noforwardsPeerEnabled,
8990
isUnofficialSecurityRisk: unofficialSecurityRisk,
9091
botManagerId: botManagerId !== undefined ? buildApiPeerId(botManagerId, 'user') : undefined,
92+
walletCard: walletCard && buildApiWalletCard(walletCard),
9193
};
9294
}
9395

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
ApiOnrampSession,
99
ApiTonConnectRequest,
1010
ApiTonConnectSession,
11+
ApiWalletCard,
12+
ApiWalletCardSticker,
1113
ApiWalletCollectible,
1214
ApiWalletGaslessInfo,
1315
ApiWalletSentTransfer,
@@ -20,8 +22,10 @@ import { toNonBounceableAddress } from '../../../lib/ton/address';
2022
import { getBigIntAbs } from '../../../util/numbers';
2123
import { addWebDocumentToLocalDb } from '../helpers/localDb';
2224
import { serializeBytes } from '../helpers/misc';
25+
import { buildApiStarGift } from './gifts';
2326
import { buildApiWebDocument } from './messageContent';
2427
import { buildApiPeerId } from './peers';
28+
import { buildApiMediaAreaCoordinates } from './stories';
2529

2630
const MODEL_TRAIT = 'Model';
2731
const FRAGMENT_NFT_HOST = 'nft.fragment.com';
@@ -217,6 +221,36 @@ export function buildApiWalletCollectible(item: GramJs.wallet.NftItem): ApiWalle
217221
};
218222
}
219223

224+
export function buildApiWalletCard(card: GramJs.wallet.Card): ApiWalletCard {
225+
const {
226+
address, balance, stickers, bgNftItem,
227+
} = card;
228+
229+
return {
230+
address: toNonBounceableAddress(address),
231+
balance,
232+
stickers: stickers?.map(buildApiWalletCardSticker).filter(Boolean),
233+
backgroundGift: bgNftItem && buildUniqueGiftFromNft(bgNftItem),
234+
};
235+
}
236+
237+
// A sticker shows the model of a collectible gift, so an NFT of another kind has nothing to draw
238+
function buildApiWalletCardSticker({ coordinates, nft }: GramJs.wallet.CardSticker): ApiWalletCardSticker | undefined {
239+
const gift = buildUniqueGiftFromNft(nft);
240+
if (!gift) return undefined;
241+
242+
return {
243+
coordinates: buildApiMediaAreaCoordinates(coordinates),
244+
gift,
245+
};
246+
}
247+
248+
function buildUniqueGiftFromNft(nft: GramJs.wallet.NftItem) {
249+
const gift = nft.gift && buildApiStarGift(nft.gift);
250+
251+
return gift?.type === 'starGiftUnique' ? gift : undefined;
252+
}
253+
220254
function getCollectibleType(metadataUrl?: string, imageUrl?: string): ApiWalletCollectible['type'] {
221255
for (const url of [metadataUrl, imageUrl]) {
222256
const type = url && FRAGMENT_TYPES.find((item) => url.startsWith(`https://${FRAGMENT_NFT_HOST}/${item}/`));

‎src/api/types/users.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ApiTypePeerColor,
1212
} from './peers';
1313
import type { ApiSavedStarGift, ApiStarsRating } from './stars';
14+
import type { ApiWalletCard } from './wallet';
1415

1516
export interface ApiUser {
1617
id: string;
@@ -95,6 +96,7 @@ export interface ApiUserFullInfo {
9596
noForwardsPeerEnabled?: boolean;
9697
isUnofficialSecurityRisk?: boolean;
9798
botManagerId?: string;
99+
walletCard?: ApiWalletCard;
98100
}
99101

100102
export type ApiUserType = 'userTypeBot' | 'userTypeRegular' | 'userTypeDeleted' | 'userTypeUnknown';

‎src/api/types/wallet.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { ApiWebDocument } from './bots';
2+
import type { ApiStarGiftUnique } from './stars';
3+
import type { ApiMediaAreaCoordinates } from './stories';
24

35
export interface ApiTonConnectManifest {
46
url: string;
@@ -196,6 +198,19 @@ export type ApiWalletCollectible = {
196198
| { type: 'username' | 'number' | 'other' }
197199
);
198200

201+
export interface ApiWalletCardSticker {
202+
coordinates: ApiMediaAreaCoordinates;
203+
gift: ApiStarGiftUnique;
204+
}
205+
206+
export interface ApiWalletCard {
207+
address: string;
208+
// Nanotons
209+
balance: bigint;
210+
stickers?: ApiWalletCardSticker[];
211+
backgroundGift?: ApiStarGiftUnique;
212+
}
213+
199214
export interface ApiOnrampProvider {
200215
id: string;
201216
name: string;

‎src/assets/localization/fallback.strings‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3166,6 +3166,8 @@
31663166
"WalletReceiveBuy" = "Buy with cash or crypto";
31673167
"WalletReceiveAddressCopied" = "Address copied";
31683168
"WalletReceiveShowQr" = "Show my QR";
3169+
"WalletReceivePeerShowQr" = "Show QR";
3170+
"WalletReceivePeerNote" = "Send GRAM to this address or scan this QR code.";
31693171
"WalletBuyTitle" = "Buy Grams";
31703172
"WalletBuyPayWith" = "Pay with";
31713173
"WalletBuyCurrency" = "Currency";

‎src/bundles/wallet.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default as WalletModal } from '../components/modals/wallet/WalletModal';
2+
export { default as WalletCardModal } from '../components/modals/wallet/WalletCardModal';

‎src/components/common/icons/GramIcon.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ type OwnProps = {
1212
style?: string;
1313
isAppIcon?: boolean;
1414
isMono?: boolean;
15+
withWhiteStar?: boolean;
1516
onClick?: VoidFunction;
1617
};
1718

1819
/* eslint-disable @stylistic/max-len */
19-
// Gem combined with the inner star; `evenodd` punches the star out so the shape can be tinted via `currentColor`
20-
const LOGO_PATH = 'M51.09 0H25.71c-3.38 0-5.07 0-6.598.473a10.6 10.6 0 0 0-3.693 2.015c-1.224 1.03-2.138 2.452-3.966 5.296l-8.067 12.55C2.178 22.211 1.574 23.15 1.41 24.14a4.86 4.86 0 0 0 .278 2.587c.37.931 1.16 1.721 2.74 3.3l29.977 29.978c1.398 1.398 2.098 2.098 2.904 2.36.709.23 1.473.23 2.182 0 .806-.262 1.505-.962 2.904-2.36l29.978-29.978c1.579-1.579 2.368-2.369 2.739-3.3a4.86 4.86 0 0 0 .278-2.587c-.164-.989-.768-1.928-1.976-3.807l-8.067-12.55C63.519 4.94 62.605 3.52 61.38 2.489A10.6 10.6 0 0 0 57.688.473C56.16 0 54.47 0 51.09 0 M46.286 9.9c.412-1.114 1.988-1.114 2.4 0l2.85 7.7c.171.463.536.828.998 1l7.701 2.849c1.114.412 1.114 1.988 0 2.4l-7.7 2.85a1.7 1.7 0 0 0-.999.998l-2.85 7.7c-.412 1.115-1.988 1.115-2.4 0l-2.85-7.7a1.7 1.7 0 0 0-.998-.998l-7.7-2.85c-1.115-.412-1.115-1.988 0-2.4l7.7-2.85a1.7 1.7 0 0 0 .998-.998z';
20+
const GEM_PATH = 'M51.09 0H25.71c-3.38 0-5.07 0-6.598.473a10.6 10.6 0 0 0-3.693 2.015c-1.224 1.03-2.138 2.452-3.966 5.296l-8.067 12.55C2.178 22.211 1.574 23.15 1.41 24.14a4.86 4.86 0 0 0 .278 2.587c.37.931 1.16 1.721 2.74 3.3l29.977 29.978c1.398 1.398 2.098 2.098 2.904 2.36.709.23 1.473.23 2.182 0 .806-.262 1.505-.962 2.904-2.36l29.978-29.978c1.579-1.579 2.368-2.369 2.739-3.3a4.86 4.86 0 0 0 .278-2.587c-.164-.989-.768-1.928-1.976-3.807l-8.067-12.55C63.519 4.94 62.605 3.52 61.38 2.489A10.6 10.6 0 0 0 57.688.473C56.16 0 54.47 0 51.09 0';
21+
const STAR_PATH = 'M46.286 9.9c.412-1.114 1.988-1.114 2.4 0l2.85 7.7c.171.463.536.828.998 1l7.701 2.849c1.114.412 1.114 1.988 0 2.4l-7.7 2.85a1.7 1.7 0 0 0-.999.998l-2.85 7.7c-.412 1.115-1.988 1.115-2.4 0l-2.85-7.7a1.7 1.7 0 0 0-.998-.998l-7.7-2.85c-1.115-.412-1.115-1.988 0-2.4l7.7-2.85a1.7 1.7 0 0 0 .998-.998z';
2122
/* eslint-enable @stylistic/max-len */
23+
// Gem combined with the inner star; `evenodd` punches the star out so the shape can be tinted via `currentColor`
24+
const LOGO_PATH = `${GEM_PATH} ${STAR_PATH}`;
2225

2326
const GramIcon = ({
24-
className, style, isAppIcon, isMono, onClick,
27+
className, style, isAppIcon, isMono, withWhiteStar, onClick,
2528
}: OwnProps) => {
2629
if (isMono) {
2730
return (
@@ -31,7 +34,8 @@ const GramIcon = ({
3134
style={style}
3235
onClick={onClick}
3336
>
34-
<path fill="currentColor" fill-rule="evenodd" d={LOGO_PATH} />
37+
<path fill="currentColor" fill-rule="evenodd" d={withWhiteStar ? GEM_PATH : LOGO_PATH} />
38+
{withWhiteStar && <path fill="#fff" d={STAR_PATH} />}
3539
</svg>
3640
);
3741
}

‎src/components/common/profile/ProfileInfo.module.scss‎

Lines changed: 127 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
.root {
44
--profile-info-bg: var(--color-background-secondary);
5+
--music-band-height: 0rem;
56
--rating-outline-color: #000000;
67
--rating-text-color: #000000;
78

@@ -33,7 +34,7 @@
3334
}
3435

3536
.plain.minimized {
36-
padding-block: 1.5rem 0;
37+
padding-block: 1.5rem var(--music-band-height);
3738
color: var(--color-text);
3839

3940
.info {
@@ -398,11 +399,17 @@
398399
text-align: center;
399400
}
400401

402+
// A collapsed header with the music band is taller than a narrow column is wide, and a square would end above the
403+
// band. The square stays otherwise, so the pattern is not redrawn while the header height animates.
401404
.radialPatternBackground {
402405
pointer-events: none;
406+
403407
position: absolute;
404408
inset: 0;
409+
405410
aspect-ratio: 1 / 1;
411+
width: 100%;
412+
min-height: 100%;
406413
}
407414

408415
.standaloneAvatar {
@@ -420,8 +427,8 @@
420427
}
421428

422429
.minimized {
423-
height: 18rem;
424-
padding-block: 4.25rem 1rem;
430+
height: calc(18rem + var(--music-band-height));
431+
padding-block: 4.25rem calc(1rem + var(--music-band-height));
425432

426433
.status, .title, .info {
427434
align-self: center;
@@ -538,32 +545,136 @@
538545
}
539546
}
540547

548+
// The music band lies along the bottom edge. The header grows by it, so everything else keeps its place above it.
549+
.hasMusic {
550+
--music-band-height: 1.5rem;
551+
}
552+
553+
.hasMusic:not(.minimized) .info {
554+
padding-bottom: calc(0.5rem + var(--music-band-height));
555+
}
556+
557+
.musicBand {
558+
position: absolute;
559+
z-index: 2;
560+
inset-inline: 0;
561+
bottom: 0;
562+
563+
height: var(--music-band-height);
564+
}
565+
566+
// The music band and the balance pill share one background, as on iOS. Over a profile color the tint blends into it
567+
// with `overlay`, like iOS `blendOver`.
568+
.root {
569+
--profile-button-background: rgb(255 255 255 / 10%);
570+
}
571+
572+
.minimized {
573+
--profile-button-tint: rgb(255 255 255 / 35%);
574+
--profile-button-background: linear-gradient(var(--profile-button-tint) 0 0) var(--profile-button-base, transparent);
575+
}
576+
577+
:global(.theme-dark) .minimized {
578+
--profile-button-tint: rgb(0 0 0 / 25%);
579+
}
580+
581+
.plain.minimized {
582+
--profile-button-background: var(--color-background);
583+
}
584+
541585
.hasMusic .info {
542586
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
543587
transition: padding-bottom 0.25s;
544588
}
545589

546-
.hasMusic:not(.minimized) .info {
547-
padding-bottom: 2.25rem;
590+
.hasWalletBalance.minimized {
591+
height: calc(19.75rem + var(--music-band-height));
548592
}
549593

550-
.hasMusic.minimized {
551-
padding-bottom: 2.75rem;
594+
.walletBalance {
595+
pointer-events: auto;
596+
cursor: var(--custom-cursor, pointer);
597+
598+
position: relative;
599+
z-index: 1;
600+
601+
overflow: hidden;
602+
display: flex;
603+
gap: 0.25rem;
604+
align-items: center;
605+
align-self: center;
606+
607+
height: 1.25rem;
608+
margin-top: 0.5rem;
609+
padding: 0 0.5rem;
610+
border: none;
611+
border-radius: 0.625rem;
612+
613+
font-size: 0.75rem;
614+
line-height: 1rem;
615+
color: inherit;
616+
white-space: nowrap;
617+
618+
background: var(--profile-button-background);
619+
background-blend-mode: overlay;
620+
outline: none;
621+
622+
transition: filter 0.15s;
623+
624+
&:hover,
625+
&:focus-visible {
626+
filter: brightness(1.1);
627+
}
628+
629+
@include mixins.with-vt-type('profileAvatar');
552630
}
553631

554-
.hasMusic.plain {
555-
padding-bottom: 2.75rem;
632+
// Over the photo the pill lines up under the status along the start edge, with the name and the status
633+
.hasWalletBalance:not(.minimized) .walletBalance {
634+
align-self: flex-start;
556635
}
557636

558-
.hasMusic {
559-
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
560-
transition: height 0.25s;
637+
.walletLabel,
638+
.walletAmount {
639+
display: flex;
640+
gap: 0.25rem;
641+
align-items: center;
642+
}
643+
644+
// The wallet card rolls the content up out of the pill while it comes out of it
645+
.walletLabel,
646+
.walletChevron {
647+
translate: 0 var(--wallet-card-shutter, 0);
648+
}
649+
650+
.walletAmount {
651+
gap: 0.125rem;
652+
font-weight: var(--font-weight-semibold);
653+
}
654+
655+
// `GramIcon` is `1em` high, with side margins that make the gem box as wide as on iOS
656+
.walletIcon {
657+
font-size: 0.5625rem;
658+
color: var(--color-white);
659+
}
660+
661+
.minimized .walletIcon {
662+
color: rgb(255 255 255 / 30%);
561663
}
562664

563-
.hasMusic.plain.minimized:not(.withBackground) .standaloneAvatar {
564-
margin-top: 2.75rem;
665+
.plain.minimized .walletIcon {
666+
color: var(--color-primary);
565667
}
566668

567-
.musicOverlay {
568-
bottom: 0.375rem;
669+
// A smaller font with the inherited line height would sit lower and make the pill taller
670+
.walletFraction {
671+
font-size: 0.5625rem;
672+
line-height: 1;
673+
}
674+
675+
// The glyph sits in the middle of a wider icon box, and its empty sides would widen the gaps around it
676+
.walletChevron {
677+
flex-shrink: 0;
678+
margin-inline: -0.1875rem;
679+
font-size: 0.875rem;
569680
}

0 commit comments

Comments
 (0)