import React from 'react'; import QRCode from 'qrcodejs'; import { pinyin } from 'pinyin-pro'; import HashNavigation from '../lib/navigation.js'; import { getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest, deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks, blockUser, unblockUser, getFileUrl, getBootstrap, getConversationContext, getConversationSettings, updateConversationSettings, createUserQRCode, fetchAllPages } from '../lib/im-api.js'; import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cached-data.js'; // Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation -- // e.g. address-book -> friend-profile -> friend-settings all need to know which person this is. const navigate = (view, uid) => { let hash = HashNavigation.setUrlSidePanel(window.location.hash, view); hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid'); HashNavigation.navigateTo(hash); }; export const CONTACT_MANAGEMENT_VIEWS = [ 'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-qr', 'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm', 'clear-chat-confirm' ]; // Server's own `initial` (bootstrap.go displayInitial) is just the first Unicode code point of the // display name -- for Chinese names that's a hanzi, not a Latin letter, so it can't drive the A/B/C // section headers docs/images/通讯录.png actually shows. Group client-side by first-pinyin-letter. function alphaInitial(name) { const ch = (name || '').trim()[0] || '#'; if (/[A-Za-z]/.test(ch)) { return ch.toUpperCase(); } if (/[一-龥]/.test(ch)) { const letter = pinyin(ch, {pattern: 'first', toneType: 'none'}); return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#'; } return '#'; } function Icon({children}) { return ; } function Header({title, back = 'messages-home', backUid, right, onRight}) { return

{title}

{right ? : }
; } // `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch -- // file serving is auth-gated, there's no public URL a plain could point at directly. function Avatar({person, icon}) { const fileId = person && person.avatar_file_id; const [url, setUrl] = React.useState(null); React.useEffect(() => { if (!fileId) { setUrl(null); return undefined; } let live = true; getFileUrl(fileId).then(u => { if (live) setUrl(u); }).catch(() => {}); return () => { live = false; }; }, [fileId]); return {url ? : {icon || 'person'}}; } function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: null; } function Search({value, onChange, placeholder, onSubmit}) { return ; } // `payload` is the real `qr_payload` string from POST /me/qrcodes (already shaped like // `im://qr?token=...&purpose=add_friend` -- same scheme the demo literal used, no format change). function QrCard({payload, profile, loading}) { const ref = React.useRef(null); React.useEffect(() => { if (!ref.current || !payload) return; ref.current.innerHTML = ''; new QRCode(ref.current, {text: payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M}); }, [payload]); return
{(profile && profile.display_name) || ''} {profile && profile.public_account ? '账号 ' + profile.public_account : ''}
{loading ?

正在生成二维码…

:

扫一扫上面的二维码,加我为好友

} 二维码 24 小时内有效
; } function AddressBook({myUserId, onTopicSelected}) { const [query, setQuery] = React.useState(''); // Seeded from cache so re-opening the address book (very much a back-and-forth flow: check // contacts, open a chat, come back) doesn't drop straight back to "加载中…" for a friend list that // hasn't actually changed. // Keyed by myUserId (same convention as the 'bootstrap:'+myUserId cache just below) -- a bare // 'friends' key meant logging out and into a *different* account in the same tab would briefly // paint the previous account's cached friend list on mount, before the fresh getFriends() call // overwrote it moments later. const friendsCacheKey = myUserId ? 'friends:' + myUserId : null; const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []); const [pendingCount, setPendingCount] = React.useState(0); const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey))); const [loadError, setLoadError] = React.useState(false); // Same bootstrap the message center already fetches (and caches under the same key) -- only need // it here for the file assistant's real topic, so a plain read of the shared cache is enough. const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]); const fileAssistantTopic = bootstrap && bootstrap.entries && bootstrap.entries.file_assistant && bootstrap.entries.file_assistant.topic; React.useEffect(() => { // getFriends() defaults to a 50-item page -- a plain single call silently truncated address // books past that size, with no "load more" affordance to recover the rest. fetchAllPages(cursor => getFriends('', cursor)).then(list => { if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); } setFriends(list); }).catch(() => setLoadError(true)).finally(() => setLoading(false)); fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor)) .then(list => setPendingCount(list.length)).catch(() => {}); }, []); const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.toLowerCase().includes(query.toLowerCase())); const groups = visible.reduce((result, item) => { const key = alphaInitial(item.remark || item.display_name); return {...result, [key]: (result[key] || []).concat(item)}; }, {}); const sortedKeys = Object.keys(groups).sort(); const entries = [ {icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: pendingCount || null, tone: 'blue'}, {icon: 'group', label: '群组', route: 'group-list', tone: 'green'}, {icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange', fileAssistant: true}, {icon: 'notifications', label: '系统消息', route: 'system-messages', tone: 'red'} ]; const scrollTo = key => { const el = document.getElementById('people-group-' + key); if (el) el.scrollIntoView({behavior: 'smooth', block: 'start'}); }; // The file assistant is a real topic (from bootstrap), not a route -- same bug/fix pattern as the // "常联系的人" shortcuts in message-center-view.jsx. Falls back to the old demo route only if // bootstrap hasn't resolved a real topic yet, so the entry never becomes a dead click. const openEntry = item => { if (item.fileAssistant && fileAssistantTopic && onTopicSelected) onTopicSelected(fileAssistantTopic); else navigate(item.route); }; return
navigate('add-friend')} />
{entries.map(item => )}

好友通讯录

{loading ?

加载中…

: sortedKeys.length === 0 ?

{loadError ? '通讯录加载失败' : '暂无好友'}

: null} {sortedKeys.map(initial =>

{initial}

{groups[initial].map(person => )}
)}
; } function requestStatusLabel(item) { if (item.status === 'pending') return item.direction === 'outbound' ? '已发送' : null; if (item.status === 'accepted') return '已添加'; if (item.status === 'rejected') return '已拒绝'; if (item.status === 'expired') return '已过期'; return null; } function FriendRequests({onToast, onTopicSelected, onCreateTopic}) { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(false); React.useEffect(() => { fetchAllPages(cursor => getFriendRequests('all', 'all', cursor)) .then(setItems).catch(() => setLoadError(true)).finally(() => setLoading(false)); }, []); const handle = (item, accept) => { const nextStatus = accept ? 'accepted' : 'rejected'; setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: nextStatus} : v)); const call = accept ? acceptFriendRequest(item.request_id) : rejectFriendRequest(item.request_id); call.then(() => onToast(accept ? '已通过好友申请' : '已拒绝好友申请')).catch(err => { setItems(values => values.map(v => v.request_id === item.request_id ? {...v, status: item.status} : v)); onToast(err.message || '操作失败'); }); }; // 参考图 个人对话框(对方的状态)/(我的状态).png:申请的验证留言应该在真实聊天窗口里以消息 // 气泡展示,横幅提示("以上是打招呼的消息"/"好友申请正在等待处理")已经在 messages-view.jsx // 里做对了,但这个列表原来每一行完全没有 onClick,点了毫无反应——点进去看这条留言/横幅的入口 // 一直不存在。跟 Profile 的 openChat 用同一个"优先走 onCreateTopic 保证冷启动也能订阅"的写法。 const openChat = item => (onCreateTopic || onTopicSelected) && (onCreateTopic || onTopicSelected)(item.direction === 'inbound' ? item.applicant_id : item.target_user_id); return
{loading ?

加载中…

: items.length === 0 ?

{loadError ? '加载失败,请重试' : '暂无新朋友申请'}

: null} {items.map(item =>
openChat(item)}>
{(item.peer && item.peer.display_name) || item.applicant_id}

{item.message || '请求添加您为好友'}

{new Date(item.created_at).toLocaleString()}
{item.status === 'pending' && item.direction === 'inbound' ? : {requestStatusLabel(item)}}
)}
; } // 中国大陆手机号:11 位、1 开头。与本文件注册/验证流程用的判断保持一致。 const CN_MOBILE = /^1[3-9]\d{9}$/; function classifySearchInput(text) { if (text.startsWith('+')) { // 已经是国际格式,原样按手机号查 return {query: text.replace(/[\s-]/g, ''), type: 'phone'}; } const digits = text.replace(/[\s-]/g, ''); if (CN_MOBILE.test(digits)) { return {query: '+86' + digits, type: 'phone'}; } return {query: text, type: 'exact_account'}; } // 与 personal-settings-view.jsx 的 GENDER_OPTIONS 一一对应,两处写的是同一套后端枚举 const GENDER_LABELS = {male: '男', female: '女', other: '其他'}; // `GET /users/{id}/profile` (getUserProfile) 403s for anyone who isn't already a friend, by design // (server's default `profile_visibility` is "friends"-only, server/im_http.go's imPrivacyAllowsProfile). // But the two places that hand you a *stranger* to look at -- an account search hit and a scanned // QR code -- already returned the exact same fields (display_name/avatar_file_id/gender/region/ // public_account/signature, both go through userSearchItemPayload/copyOptionalProfileFields) *before* // that gate, unconditionally. Profile used to throw that object away and re-fetch via the gated // endpoint, which 403s and leaves the page stuck on "加载中…" forever with no way forward -- this // stashes what we already have so Profile can render immediately, and only uses getUserProfile as a // best-effort background refresh (for when the two really are/become friends, or the peer's privacy // setting is "everyone"). function seedProfile(item) { if (!item || !item.user_id) return; writeCachedData('profile-seed:' + item.user_id, { ...item, capabilities: {can_add_friend: !!item.can_add} }); } function AddFriend({result = false, onToast, onOpenScanner}) { const [query, setQuery] = React.useState(''); const [searching, setSearching] = React.useState(false); const [searched, setSearched] = React.useState(result); const [results, setResults] = React.useState([]); const runSearch = q => { const text = q.trim(); if (!text) { setSearched(false); setResults([]); return; } setSearching(true); setSearched(true); // 按输入形态选搜索类型。exact_account 只比对 profile.public_account / u.username / u.id // (tenant_adapter.go:9170-9172),**不匹配 u.phone** —— 参考图 5.5 里输的恰恰是手机号 // 18826827724,一直写死 exact_account 的话这个场景必然搜不到。手机号要走 type=phone。 // 另外 phone 分支服务端不做 E.164 归一化(只有模糊的 account 分支才调 normalizeUserPhone), // 而库里存的是 +86 开头的 E.164,所以裸 11 位号码由前端补上区号——注册流程也是这么拼的。 const {query, type} = classifySearchInput(text); searchUsers(query, type).then(data => setResults(data.items || [])).catch(() => setResults([])).finally(() => setSearching(false)); }; // 原来复制到剪贴板这条分支不给任何反馈,点了界面上毫无动静(同页的 QrPage.share 是有 toast 的); // 生成二维码失败也被 .catch(() => {}) 静默吞掉,用户同样什么都看不到。 const shareCard = () => { createMyQRCode().then(data => { const text = `加我为好友:${data.qr_payload}`; if (navigator.share) { return navigator.share({title: '添加好友', text}).catch(() => {}); } if (navigator.clipboard) { return navigator.clipboard.writeText(text).then(() => onToast('邀请链接已复制')) .catch(() => onToast('复制失败,请手动分享二维码')); } onToast('当前浏览器不支持分享,请使用二维码'); return undefined; }).catch(err => onToast(err.message || '生成邀请链接失败')); }; // Real search results carry a relationship for blocked pairs too (blocked_by_me/blocked_by_peer), // which previously fell through to the same generic "添加" as an untouched stranger -- misleading // since tapping through leads to a profile page whose own cannotAddReason (see the Profile // component below) already refuses to let the request go anywhere. Matches that component's // wording, just condensed to fit this one-line list item. const actionLabel = item => item.relationship === 'friend' ? '已是好友' : item.relationship === 'pending_outbound' ? '已申请' : item.relationship === 'pending_inbound' ? '待通过' : item.relationship === 'blocked_by_me' ? '已拉黑' : item.relationship === 'blocked_by_peer' ? '无法添加' : '添加'; return
{ setQuery(v); if (!v.trim()) { setSearched(false); setResults([]); } }} placeholder="查找账号" onSubmit={() => runSearch(query)} />
{searched ?

查找人

{searching ?

搜索中…

: results.length === 0 ?

未找到相关用户

: results.map(item => )}
:
}
; } function QrPage({onToast}) { const [payload, setPayload] = React.useState(null); const [profile, setProfile] = React.useState(null); React.useEffect(() => { createMyQRCode().then(data => { setPayload(data.qr_payload); setProfile(data.display_profile); }).catch(() => onToast('二维码生成失败')); }, []); const share = () => { if (navigator.share && payload) { navigator.share({title: '添加好友', text: payload}).then(() => onToast('二维码名片已分享')).catch(() => {}); } else if (navigator.clipboard && payload) { navigator.clipboard.writeText(payload); onToast('链接已复制'); } }; const save = () => { const canvas = document.querySelector('.people-qrcode canvas'); if (!canvas) { onToast('二维码尚未生成'); return; } const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = '我的二维码.png'; a.click(); onToast('二维码已保存到相册'); }; return
; } function FriendQr({uid, onToast}) { const [payload, setPayload] = React.useState(null); const [profile, setProfile] = React.useState(null); React.useEffect(() => { if (!uid) return; createUserQRCode(uid).then(data => { setPayload(data.qr_payload); setProfile(data.display_profile); }).catch(() => onToast('二维码生成失败')); }, [uid]); const save = () => { const canvas = document.querySelector('.people-qrcode canvas'); if (!canvas) { onToast('二维码尚未生成'); return; } const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = '好友二维码.png'; a.click(); onToast('二维码已保存到相册'); }; return
; } function Verification({uid, onToast}) { // Same seed-then-best-effort-refresh as Profile -- reached right after it (via its "添加好友" // button), so the cache this reads is the one Profile itself just kept warm. const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null)); const [message, setMessage] = React.useState(''); const [sending, setSending] = React.useState(false); const [sent, setSent] = React.useState(false); React.useEffect(() => { if (!uid) return; getUserProfile(uid).then(data => { writeCachedData('profile-seed:' + uid, data); setProfile(data); }).catch(() => {}); // getUserProfile 403s for anyone who isn't currently a friend (server privacy default) -- same // gap Profile already works around below. Without this, a `profile-seed` left over from *earlier // in the same session* (e.g. this pair really were friends before one side unfriended them, or a // request was sent and then rejected) stayed on screen forever: alreadyFriend/alreadyPending read // straight off `profile.relationship`, and the 403 catch above has nothing to correct it with. // Confirmed live: unfriend someone, then re-open this exact page for them (still reachable via // their still-cached, now-stale seed) -- it kept claiming "你们已经是好友了" and hid the compose // box entirely, with no way to send a new request short of a full page reload to drop the cache. // getConversationContext isn't gated on friendship and reports the true current relationship. getConversationContext(uid).then(ctx => { if (ctx && ctx.relationship) { setProfile(current => current ? {...current, relationship: ctx.relationship} : current); } }).catch(() => {}); }, [uid]); const alreadyPending = profile && (profile.relationship === 'pending_outbound' || profile.relationship === 'pending_inbound'); const alreadyFriend = profile && profile.relationship === 'friend'; const send = () => { setSending(true); createFriendRequest(uid, message.trim(), 'search').then(() => { onToast('好友申请已发送'); setSent(true); // Patch the seed Profile will re-read on remount -- its own background getUserProfile refresh // can't do this for us, since a stranger-turned-pending peer still fails that gated endpoint // the exact same way a stranger does (server only exempts an established "friend"). // Must also flip capabilities.can_add_friend to false here, not just relationship: Profile's // canAdd render check (contact-management-view.jsx ~line 444) reads capabilities.can_add_friend, // not relationship, directly. Leaving the stale can_add_friend:true (copied verbatim from the // pre-request search-result seed) meant Profile kept showing an active "添加好友" button after // a request was already sent -- confirmed live: send request, navigate back to this profile // (the verification screen's own auto-redirect 800ms later, or its back button, both land here), // and the button was still clickable, capable of firing a second, backend-undeduped request // (see 新增44 in the backend issue list) purely because of this front-end staleness. const priorSeed = readCachedData('profile-seed:' + uid) || {}; writeCachedData('profile-seed:' + uid, {...priorSeed, relationship: 'pending_outbound', capabilities: {...priorSeed.capabilities, can_add_friend: false}}); window.setTimeout(() => navigate('group-member-profile', uid), 800); }).catch(err => onToast(err.message || '发送失败')).finally(() => setSending(false)); }; return

{(profile && profile.display_name) || ''}

{alreadyFriend ?

你们已经是好友了

: alreadyPending || sent ?

好友申请正在等待验证

: