From f71b4510512493563ab0615272263900a6a931bc Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 6 Nov 2025 11:36:35 +0100 Subject: [PATCH] Support note for contact (#6413) --- src/api/gramjs/apiBuilders/appConfig.ts | 2 + src/api/gramjs/apiBuilders/users.ts | 5 +- src/api/gramjs/methods/users.ts | 18 +- src/api/types/misc.ts | 1 + src/api/types/users.ts | 3 +- src/assets/font-icons/note.svg | 1 + src/assets/localization/fallback.strings | 4 + .../common/profile/ChatExtra.module.scss | 43 +++ src/components/common/profile/ChatExtra.tsx | 69 +++- src/components/main/NewContactModal.scss | 5 + src/components/main/NewContactModal.tsx | 75 +++-- .../right/management/ManageUser.tsx | 85 +++-- .../right/management/Management.scss | 5 + src/global/actions/api/users.ts | 28 +- src/global/types/actions.ts | 5 + src/hooks/element/useCollapsibleLines.ts | 11 +- src/lib/gramjs/tl/apiTl.ts | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/limits.ts | 1 + src/styles/icons.css | 295 +++++++++--------- src/styles/icons.scss | 289 ++++++++--------- src/styles/icons.woff | Bin 37236 -> 37368 bytes src/styles/icons.woff2 | Bin 31104 -> 31120 bytes src/types/icons/font.ts | 1 + src/types/language.d.ts | 3 + 25 files changed, 616 insertions(+), 335 deletions(-) create mode 100644 src/assets/font-icons/note.svg diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index c41f55135..7cc39a647 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -118,6 +118,7 @@ export interface GramJsAppConfig extends LimitsConfig { verify_age_bot_username?: string; verify_age_country?: string; verify_age_min?: number; + contact_note_length_limit?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -187,6 +188,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp maxReactions: getLimit(appConfig, 'reactions_user_max', 'maxReactions'), moreAccounts: DEFAULT_LIMITS.moreAccounts, }, + contactNoteLimit: appConfig.contact_note_length_limit, hash, storyViewersExpirePeriod: appConfig.story_viewers_expire_period, storyChangelogUserId: appConfig.stories_changelog_user_id?.toString(), diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 66092ffbe..3aaa35ae2 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -14,7 +14,7 @@ import { toJSNumber } from '../../../util/numbers'; import { buildApiBotInfo } from './bots'; import { buildApiBusinessIntro, buildApiBusinessLocation, buildApiBusinessWorkHours } from './business'; import { - buildApiPhoto, buildApiUsernames, + buildApiFormattedText, buildApiPhoto, buildApiUsernames, } from './common'; import { buildApiDisallowedGiftsSettings } from './gifts'; import { omitVirtualClassFields } from './helpers'; @@ -36,7 +36,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse contactRequirePremium, businessWorkHours, businessLocation, businessIntro, birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount, botVerification, botCanManageEmojiStatus, settings, sendPaidMessagesStars, displayGiftsButton, disallowedGifts, - starsRating, starsMyPendingRating, starsMyPendingRatingDate, mainTab, + starsRating, starsMyPendingRating, starsMyPendingRatingDate, mainTab, note, }, users, } = mtpUserFull; @@ -76,6 +76,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse paidMessagesStars: toJSNumber(sendPaidMessagesStars), settings: buildApiPeerSettings(settings), mainTab: mainTab && buildApiProfileTab(mainTab), + note: note && buildApiFormattedText(note), }; } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 7cad07924..d660990d2 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -1,6 +1,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; -import type { ApiEmojiStatusType, ApiPeer, ApiUser, +import type { + ApiEmojiStatusType, ApiFormattedText, ApiPeer, ApiUser, } from '../../types'; import { toJSNumber } from '../../../util/numbers'; @@ -12,6 +13,7 @@ import { buildInputContact, buildInputEmojiStatus, buildInputPeer, + buildInputTextWithEntities, buildInputUser, buildMtpPeerId, DEFAULT_PRIMITIVES, @@ -212,6 +214,7 @@ export function updateContact({ firstName = DEFAULT_PRIMITIVES.STRING, lastName = DEFAULT_PRIMITIVES.STRING, shouldSharePhoneNumber = false, + note, }: { id: string; accessHash?: string; @@ -219,6 +222,7 @@ export function updateContact({ firstName?: string; lastName?: string; shouldSharePhoneNumber?: boolean; + note?: ApiFormattedText; }) { return invokeRequest(new GramJs.contacts.AddContact({ id: buildInputUser(id, accessHash), @@ -226,6 +230,7 @@ export function updateContact({ lastName, phone: phoneNumber, addPhonePrivacyException: shouldSharePhoneNumber || undefined, + note: note ? buildInputTextWithEntities(note) : undefined, }), { shouldReturnTrue: true, }); @@ -364,3 +369,14 @@ export function saveCloseFriends(userIds: string[]) { shouldReturnTrue: true, }); } + +export function updateContactNote(user: ApiUser, note: ApiFormattedText) { + const { id, accessHash } = user; + + return invokeRequest(new GramJs.contacts.UpdateContactNote({ + id: buildInputUser(id, accessHash), + note: buildInputTextWithEntities(note), + }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index c30fb3fe9..26c4a5197 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -274,6 +274,7 @@ export interface ApiAppConfig { verifyAgeBotUsername?: string; verifyAgeCountry?: string; verifyAgeMin?: number; + contactNoteLimit?: number; } export interface ApiConfig { diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 6277a7643..268d88dd9 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -1,7 +1,7 @@ import type { API_CHAT_TYPES } from '../../config'; import type { ApiBotInfo } from './bots'; import type { ApiBusinessIntro, ApiBusinessLocation, ApiBusinessWorkHours } from './business'; -import type { ApiDocument, ApiPhoto } from './messages'; +import type { ApiDocument, ApiFormattedText, ApiPhoto } from './messages'; import type { ApiBotVerification, ApiEmojiStatusType, @@ -82,6 +82,7 @@ export interface ApiUserFullInfo { paidMessagesStars?: number; settings?: ApiPeerSettings; mainTab?: ApiProfileTab; + note?: ApiFormattedText; } export type ApiUserType = 'userTypeBot' | 'userTypeRegular' | 'userTypeDeleted' | 'userTypeUnknown'; diff --git a/src/assets/font-icons/note.svg b/src/assets/font-icons/note.svg new file mode 100644 index 000000000..432f99ee2 --- /dev/null +++ b/src/assets/font-icons/note.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 1d94fb201..1aa5f2806 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2293,3 +2293,7 @@ "TitleGiftLocked" = "Gift Locked"; "GiftLockedMessage" = "This gift is currently only available to earlier Telegram users. It will unlock for your account in about **{relativeDate}**."; "QuickPreview" = "Quick Preview"; +"UserNoteTitle" = "Notes"; +"UserNoteHint" = "only visible to you"; +"EditUserNoteHint" = "Notes are only visible to you."; + diff --git a/src/components/common/profile/ChatExtra.module.scss b/src/components/common/profile/ChatExtra.module.scss index b29bcf679..38a3ce293 100644 --- a/src/components/common/profile/ChatExtra.module.scss +++ b/src/components/common/profile/ChatExtra.module.scss @@ -1,3 +1,5 @@ +@use '../../../styles/mixins'; + .businessLocation { flex-shrink: 0; @@ -68,3 +70,44 @@ margin-top: 0.5rem; margin-bottom: 0.5rem; } + +.noteSubtitle { + display: flex !important; + align-items: center; +} + +.noteListItemIcon { + align-self: flex-start; + padding-top: 1rem; +} + +.noteHint { + margin-left: auto; +} + +.noteText { + overflow: hidden; + display: inline-block; + width: 100%; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + transition: max-height 0.3s ease; +} + +.noteTextCollapsed { + @include mixins.gradient-border-bottom(1rem); +} + +.noteCollapseIcon { + margin-inline-start: 0.125rem; + font-size: 0.9375rem; + line-height: 0.9375rem; + transition: transform 0.3s ease; +} + +.expandedIcon { + transform: rotate(-180deg); +} + +.clickable { + cursor: var(--custom-cursor, pointer); +} diff --git a/src/components/common/profile/ChatExtra.tsx b/src/components/common/profile/ChatExtra.tsx index 999004b21..71f7a9d11 100644 --- a/src/components/common/profile/ChatExtra.tsx +++ b/src/components/common/profile/ChatExtra.tsx @@ -1,5 +1,5 @@ import { - memo, useMemo, + memo, useMemo, useRef, } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; @@ -46,7 +46,9 @@ import { extractCurrentThemeParams } from '../../../util/themeStyle'; import { ChatAnimationTypes } from '../../left/main/hooks'; import formatUsername from '../helpers/formatUsername'; import renderText from '../helpers/renderText'; +import { renderTextWithEntities } from '../helpers/renderTextWithEntities'; +import useCollapsibleLines from '../../../hooks/element/useCollapsibleLines'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; @@ -60,6 +62,7 @@ import ListItem from '../../ui/ListItem'; import Skeleton from '../../ui/placeholder/Skeleton'; import Switcher from '../../ui/Switcher'; import CustomEmoji from '../CustomEmoji'; +import Icon from '../icons/Icon'; import SafeLink from '../SafeLink'; import BusinessHours from './BusinessHours'; import UserBirthday from './UserBirthday'; @@ -102,6 +105,7 @@ const DEFAULT_MAP_CONFIG = { }; const BOT_VERIFICATION_ICON_SIZE = 16; +const MAX_LINES = 3; const ChatExtra = ({ chatOrUserId, @@ -154,10 +158,19 @@ const ChatExtra = ({ businessWorkHours, personalChannelMessageId, birthday, + note, } = userFullInfo || {}; const oldLang = useOldLang(); const lang = useLang(); + const noteTextRef = useRef(); + + const { + isCollapsed: isNoteCollapsed, + isCollapsible: isNoteCollapsible, + setIsCollapsed: setIsNoteCollapsed, + } = useCollapsibleLines(noteTextRef, MAX_LINES, undefined); + useEffectWithPrevDeps(([prevPeerId]) => { if (!peerId || prevPeerId === peerId) return; if (user || (chat && isChatChannel(chat))) { @@ -241,6 +254,16 @@ const ChatExtra = ({ openSavedDialog({ chatId: chatOrUserId }); }); + const canExpandNote = isNoteCollapsible && isNoteCollapsed; + + const handleExpandNote = useLastCallback(() => { + setIsNoteCollapsed(false); + }); + + const handleToggleNote = useLastCallback(() => { + setIsNoteCollapsed((prev) => !prev); + }); + function copy(text: string, entity: string) { copyTextToClipboard(text); showNotification({ message: `${entity} was copied` }); @@ -458,6 +481,50 @@ const ChatExtra = ({ {oldLang('BusinessProfileLocation')} )} + {note && ( + +
+ {renderTextWithEntities({ + text: note.text, + entities: note.entities, + })} +
+
+ {lang('UserNoteTitle')} + + {lang('UserNoteHint')} + {isNoteCollapsible && ( + + )} +
+
+ )} {hasSavedMessages && !isOwnProfile && !isInSettings && ( {oldLang('SavedMessagesTab')} diff --git a/src/components/main/NewContactModal.scss b/src/components/main/NewContactModal.scss index 4765d1e0f..ed1b75484 100644 --- a/src/components/main/NewContactModal.scss +++ b/src/components/main/NewContactModal.scss @@ -40,6 +40,7 @@ font-size: 1.5rem; } + &__note-description, &__help-text { font-size: 0.9375rem; color: var(--color-text-secondary); @@ -47,5 +48,9 @@ &__negative { margin-top: -1rem; } + + &__edit { + margin-top: -0.875rem; + } } } diff --git a/src/components/main/NewContactModal.tsx b/src/components/main/NewContactModal.tsx index 22ddc3055..10627a751 100644 --- a/src/components/main/NewContactModal.tsx +++ b/src/components/main/NewContactModal.tsx @@ -8,13 +8,15 @@ import { getActions, withGlobal } from '../../global'; import type { ApiCountryCode, ApiUser, ApiUserStatus } from '../../api/types'; import { getUserStatus } from '../../global/helpers'; -import { selectUser, selectUserStatus } from '../../global/selectors'; +import { selectUser, selectUserFullInfo, selectUserStatus } from '../../global/selectors'; import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment'; import { formatPhoneNumberWithCode } from '../../util/phoneNumber'; +import { DEFAULT_MAX_NOTE_LENGTH } from '../../limits'; import renderText from '../common/helpers/renderText'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useFlag from '../../hooks/useFlag'; +import useLang from '../../hooks/useLang'; import useOldLang from '../../hooks/useOldLang'; import Avatar from '../common/Avatar'; @@ -22,6 +24,7 @@ import Button from '../ui/Button'; import Checkbox from '../ui/Checkbox'; import InputText from '../ui/InputText'; import Modal from '../ui/Modal'; +import TextArea from '../ui/TextArea'; import './NewContactModal.scss'; @@ -37,6 +40,8 @@ type StateProps = { user?: ApiUser; userStatus?: ApiUserStatus; phoneCodeList: ApiCountryCode[]; + contactNoteLimit: number; + noteText?: string; }; const NewContactModal: FC = ({ @@ -46,10 +51,13 @@ const NewContactModal: FC = ({ user, userStatus, phoneCodeList, + contactNoteLimit, + noteText, }) => { const { updateContact, importContact, closeNewContactDialog } = getActions(); - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); const renderingUser = useCurrentOrPrev(user); const renderingIsByPhoneNumber = useCurrentOrPrev(isByPhoneNumber); const inputRef = useRef(); @@ -58,18 +66,23 @@ const NewContactModal: FC = ({ const [firstName, setFirstName] = useState(renderingUser?.firstName ?? ''); const [lastName, setLastName] = useState(renderingUser?.lastName ?? ''); const [phone, setPhone] = useState(renderingUser?.phoneNumber ?? ''); + const [note, setNote] = useState(''); const [shouldSharePhoneNumber, setShouldSharePhoneNumber] = useState(true); const canBeSubmitted = Boolean(firstName && (!isByPhoneNumber || phone)); + const noteSymbolsLeft = contactNoteLimit - note.length; + const noteRef = useRef(); + useEffect(() => { if (isOpen) { markIsShown(); setFirstName(renderingUser?.firstName ?? ''); setLastName(renderingUser?.lastName ?? ''); setPhone(renderingUser?.phoneNumber ?? ''); + setNote(noteText ?? ''); setShouldSharePhoneNumber(true); } - }, [isOpen, markIsShown, renderingUser?.firstName, renderingUser?.lastName, renderingUser?.phoneNumber]); + }, [isOpen, markIsShown, noteText, renderingUser?.firstName, renderingUser?.lastName, renderingUser?.phoneNumber]); useEffect(() => { if (!IS_TOUCH_ENV && isShown) { @@ -91,14 +104,21 @@ const NewContactModal: FC = ({ setLastName(e.target.value); }, []); + const handleNoteChange = useCallback((e: React.ChangeEvent) => { + setNote(e.target.value); + }, []); + const handleClose = useCallback(() => { closeNewContactDialog(); setFirstName(''); setLastName(''); setPhone(''); + setNote(''); }, [closeNewContactDialog]); const handleSubmit = useCallback(() => { + const noteToSend = note.trim() ? { text: note, entities: [] } : undefined; + if (isByPhoneNumber || !userId) { importContact({ firstName, @@ -111,9 +131,10 @@ const NewContactModal: FC = ({ firstName, lastName, shouldSharePhoneNumber, + note: noteToSend, }); } - }, [firstName, importContact, isByPhoneNumber, lastName, phone, shouldSharePhoneNumber, updateContact, userId]); + }, [firstName, importContact, isByPhoneNumber, lastName, note, phone, shouldSharePhoneNumber, updateContact, userId]); if (!isOpen && !isShown) { return undefined; @@ -122,7 +143,7 @@ const NewContactModal: FC = ({ function renderAddContact() { return ( <> -
+
= ({

{renderingUser?.phoneNumber ? formatPhoneNumberWithCode(phoneCodeList, renderingUser.phoneNumber) - : lang('MobileHidden')} + : oldLang('MobileHidden')}

- {getUserStatus(lang, renderingUser!, userStatus)} + {getUserStatus(oldLang, renderingUser!, userStatus)}
+