Files
im_webapp/src/views/contact-management-view.jsx
T
Your NameandClaude Sonnet 5 51e0fc420f 修复发送好友申请后个人资料页仍显示可点击的"添加好友"按钮
现象:搜索陌生人->进入资料页->点"添加好友"->发送验证留言成功后,
无论是验证页800ms后的自动跳转、还是手动点返回,回到这个人的资料页时
仍然显示"添加好友"主按钮(本应显示"好友申请正在等待验证"提示文案),
且这个按钮此时仍可点击,会打开验证页再次发起一次申请(配合后端
好友申请不去重的缺口——新增44——实际造成同一个人收到多条重复申请)。

根因:发送成功后 Verification 组件把 profile-seed 缓存的 relationship
patch 成了 pending_outbound,但没有同步更新 capabilities.can_add_friend
——Profile 组件判断是否展示"添加好友"按钮的 canAdd 变量读的是
profile.capabilities.can_add_friend,不是 relationship 本身,遗留的
can_add_friend:true(原样来自发送前的搜索结果种子缓存)导致按钮继续显示。

修复:发送成功后 patch 缓存时把 capabilities.can_add_friend 一并设为
false,和 relationship 保持同步。

验证:真实浏览器操作,两个全新注册账号之间,搜索->进入资料页->添加好友
->发送验证留言,自动跳转回资料页后正确显示"好友申请正在等待验证",
"添加好友"按钮消失,无法重复发起申请。

第14+轮最小粒度测试 Ch3-4 发现(好友申请气泡状态机场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:35:50 +08:00

656 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', backUid, right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
}
// `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 <img src> 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 <span className="people-avatar">{url ? <img src={url} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2000);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="chat-toast" role="status">{message}</div> : null;
}
function Search({value, onChange, placeholder, onSubmit}) {
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter' && onSubmit) onSubmit(); }} placeholder={placeholder} /></label>;
}
// `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 <section className="people-qr-card">
<div className="people-qr-profile"><Avatar person={profile} /><span><strong>{(profile && profile.display_name) || ''}</strong>
<small>{profile && profile.public_account ? '账号 ' + profile.public_account : ''}</small></span></div>
<div className="people-qrcode" ref={ref} />
{loading ? <p>正在生成二维码…</p> : <p>扫一扫上面的二维码,加我为好友</p>}
<small>二维码 24 小时内有效</small>
</section>;
}
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 <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" />
<main className="people-scroll">
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
<h3 className="people-section-title">好友通讯录</h3>
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">{loadError ? '通讯录加载失败' : '暂无好友'}</p> : null}
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
(im_http.go:8500-8507,且合并守卫会挡住用 peer 真实昵称覆盖),两个字段同值,
原来那句 `person.remark ? <small>{display_name}</small>` 会把同一串字渲染两遍。
后端目前没有单独的原昵称字段,所以拿不到"备注(主)/昵称(副)"的第二行,已记为后端缺口。 */}
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark && person.remark !== person.display_name ? <small>{person.display_name}</small> : null}</span>
</button>)}
</section>)}
</main>
<nav className="people-index">{sortedKeys.map(value => <a href="#" key={value} onClick={e => { e.preventDefault(); scrollTo(value); }}>{value}</a>)}</nav>
</div>;
}
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}) {
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 || '操作失败');
});
};
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">{loadError ? '加载失败,请重试' : '暂无新朋友申请'}</p> : null}
{items.map(item => <article key={item.request_id}>
<Avatar person={item.peer} />
<div><strong>{(item.peer && item.peer.display_name) || item.applicant_id}</strong><p>{item.message || '请求添加您为好友'}</p><small>{new Date(item.created_at).toLocaleString()}</small></div>
{item.status === 'pending' && item.direction === 'inbound' ?
<span><button type="button" onClick={() => handle(item, false)}>拒绝</button><button type="button" className="primary" onClick={() => handle(item, true)}>通过验证</button></span> :
<em>{requestStatusLabel(item)}</em>}
</article>)}
</main></div>;
}
// 中国大陆手机号: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 <div className="people-page"><Header title="添加好友" back="address-book" />
<Search value={query} onChange={v => { setQuery(v); if (!v.trim()) { setSearched(false); setResults([]); } }} placeholder="查找账号" onSubmit={() => runSearch(query)} />
<main>
{searched ? <section className="people-search-result"><h2>查找人</h2>
{searching ? <p className="people-empty-hint">搜索中…</p> : results.length === 0 ? <p className="people-empty-hint">未找到相关用户</p> :
results.map(item => <button type="button" key={item.user_id}
onClick={() => { seedProfile(item); item.relationship === 'friend' ? navigate('friend-profile', item.user_id) : navigate('group-member-profile', item.user_id); }}>
<Avatar person={item} /><span><strong>{item.display_name}</strong></span>
<em className={item.relationship === 'none' ? 'people-add-cta' : ''}>{actionLabel(item)}</em>
</button>)}
</section> : <section className="people-add-actions">
<button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={() => onOpenScanner ? onOpenScanner() : onToast('暂不支持扫码,请使用账号搜索添加好友')}><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button>
<button type="button" onClick={shareCard}><Icon>share</Icon><span><strong>分享给好友</strong><small>分享链接给好友,一起来玩吧</small></span><Icon>chevron_right</Icon></button>
</section>}
</main>
</div>;
}
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 <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard payload={payload} profile={profile} loading={!payload} />
<div className="people-qr-actions"><button type="button" onClick={share}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={save}><Icon>download</Icon><span>保存到相册</span></button></div>
</main></div>;
}
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 <div className="people-page people-qr-page"><Header title="二维码" back="friend-profile" backUid={uid} /><main><QrCard payload={payload} profile={profile} loading={!payload} />
<div className="people-qr-actions"><button type="button" onClick={save}><Icon>download</Icon><span>保存到相册</span></button></div>
</main></div>;
}
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(() => {});
}, [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 <div className="people-page"><Header title="好友验证" back="group-member-profile" backUid={uid} /><main className="people-verify">
<Avatar person={profile} /><h2>{(profile && profile.display_name) || ''}</h2>
{alreadyFriend ? <p className="people-empty-hint">你们已经是好友了</p> :
alreadyPending || sent ? <p className="people-empty-hint">好友申请正在等待验证</p> :
<label><span>验证留言</span><textarea value={message} maxLength="50" placeholder="我是祈万事安好" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label>}
{alreadyFriend || alreadyPending || sent ? null : <div className="people-verify-actions">
<button type="button" onClick={() => navigate('group-member-profile', uid)}>取消</button>
<button type="button" className="people-primary" disabled={sending} onClick={send}>{sending ? '发送中…' : '发送'}</button>
</div>}
</main></div>;
}
function Profile({uid, friend, onTopicSelected, onToast}) {
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
// A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
// loading) so the render below can tell the two apart, same fix as DownloadPage's `loadError`.
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(data => {
writeCachedData('profile-seed:' + uid, data);
setProfile(data);
}).catch(() => {
// Expected for anyone who isn't a friend yet (server-side privacy default) -- if a search/QR
// seed already got us a usable preview, keep showing that instead of erroring the page out.
// Only a total stranger with no seed data at all (e.g. a stale/direct deep link) has nothing to
// show, and that's the one case worth telling the user about.
setProfile(current => {
if (!current) { onToast('资料加载失败'); setLoadError(true); }
return current;
});
});
// getUserProfile 403s outright for someone you've blocked (server-side privacy check) --
// whatever relationship was cached from before the block (typically "friend") then just stays
// stuck, which used to make this page keep showing "发消息/音视频通话" for someone actually
// blocked, entirely missing the blocked_by_me/blocked_by_peer branches cannotAddReason already
// handles below. getConversationContext resolves for a P2P conversation regardless of block
// status and correctly reports the real relationship, so it's used here purely to refresh that
// one field on top of whatever getUserProfile/the seed cache already provided for the rest.
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
}
}).catch(() => {});
}, [uid]);
// group-management-view.jsx 的 navigateToProfile 在跳转之后异步用 getGroupMemberProfile 把
// capabilities/relationship 补进种子缓存(那两个字段 getUserProfile 对非好友会 403 拿不到,
// 群场景权限更宽松能拿到)。这个组件已经挂载了,不会自己重新读缓存,靠这个事件通知它有更新。
React.useEffect(() => {
if (!uid) return undefined;
const onSeedUpdated = e => {
if (e.detail && e.detail.uid === uid) {
const updated = readCachedData('profile-seed:' + uid);
if (updated) setProfile(updated);
}
};
window.addEventListener('profile-seed-updated', onSeedUpdated);
return () => window.removeEventListener('profile-seed-updated', onSeedUpdated);
}, [uid]);
if (!profile) return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} /><main className="people-profile"><p className="people-empty-hint">{loadError ? '资料加载失败,请重试' : '加载中…'}</p></main></div>;
const canAdd = profile.capabilities && profile.capabilities.can_add_friend;
const isFriend = profile.relationship === 'friend';
// canAdd===false covers several real, distinct relationships (found live: searching a friend who
// had blocked me showed this exact page claiming a request was "pending verification" when nothing
// had been sent and nothing ever could be -- they'd blocked me). Only pending_outbound actually
// matches that wording; the others need their own accurate text instead of reusing it as a catch-all.
const cannotAddReason = profile.relationship === 'blocked_by_peer' ? '对方已将你拉黑,无法添加为好友' :
profile.relationship === 'blocked_by_me' ? '你已拉黑对方,需先取消拉黑才能添加为好友' :
profile.relationship === 'pending_inbound' ? '对方已向你发送好友申请,请前往"新朋友"处理' :
'好友申请正在等待验证';
// Real native Tinode p2p topic name (from my side) is just the peer's own user_id ("usrXXXX") --
// NOT `profile.topic` (a separate "p2pXXXX" identifier the custom REST layer uses internally,
// which Tinode.topicType() doesn't recognize, so selecting it left the message pane blank).
const openChat = () => onTopicSelected(uid);
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'}
right={isFriend ? '设置' : null} onRight={() => navigate('friend-settings', uid)} />
<main className="people-profile">
<section><Avatar person={profile} /><div><h2>{profile.display_name}</h2>
{profile.public_account ? <p>用户名:{profile.public_account}</p> : null}
{profile.region ? <p>地区:{profile.region}</p> : null}</div></section>
{/* 参考图 好友个人主页.png 里"二维码"是独立一行(不是贴在头像卡片里的小图标),点击生成
这个好友的二维码名片。对应接口 createUserQRCode() 早就封装好了,原来这个图标没有任何
onClick,是个看着能点、点了没反应的死装饰。 */}
<button type="button" className="people-profile-qr-row" onClick={() => navigate('friend-qr', uid)}>
<span>二维码</span><Icon>qr_code</Icon>
</button>
{/* 后端存的是 male/female/other 枚举(personal-settings-view.jsx:203 的 GENDER_OPTIONS 写进去的),
原来直接原样输出,资料页上显示的是英文 "male"。 */}
<dl>{profile.gender ? <div><dt>性别</dt><dd>{GENDER_LABELS[profile.gender] || profile.gender}</dd></div> : null}
<div><dt>个性签名</dt><dd className={profile.signature ? '' : 'muted'}>{profile.signature || '此人很懒什么都没留下~'}</dd></div>
</dl>
{isFriend ? <div className="people-profile-actions">
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>发消息</button>
<button type="button" onClick={openChat}><Icon>call</Icon>音视频通话</button>
</div> :
canAdd ? <button type="button" className="people-primary" onClick={() => navigate('friend-verification', uid)}><Icon>person_add_alt</Icon>添加好友</button> :
<p className="people-empty-hint">{cannotAddReason}</p>}
</main>
</div>;
}
function Toggle({checked, onChange, disabled}) { return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
// real, current friend (see seedProfile's own comment), which used to leave this page's header
// blank instead of just stuck like Profile, but no less wrong.
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
const [blocked, setBlocked] = React.useState(initialBlocked);
// 置顶/免打扰原来是纯本地 useState,onChange 只是切一下布尔值,从不调用任何接口——
// GET/PATCH /conversations/{topic}/settings 早就封装好了(getConversationSettings/
// updateConversationSettings),只是零调用点。实测接口返回真实的 {pinned, mute_notifications,
// version},PATCH 需要带 version 做乐观锁。打开设置页永远显示"关",点了也只是那次会话内
// 生效、离开页面就丢,和会话列表真正读取的置顶/免打扰状态是两套毫不相关的东西。
const [settingsVersion, setSettingsVersion] = React.useState(null);
const [pinned, setPinned] = React.useState(false);
const [muted, setMuted] = React.useState(false);
const [editingRemark, setEditingRemark] = React.useState(false);
const [remark, setRemark] = React.useState('');
const [remarkDraft, setRemarkDraft] = React.useState('');
React.useEffect(() => {
if (!uid) return;
getConversationSettings(uid).then(s => {
setPinned(!!s.pinned); setMuted(!!s.mute_notifications); setSettingsVersion(s.version);
}).catch(() => {});
// pinBlocked:确认弹窗把这个页面当背景渲染,并用 initialBlocked 摆出"操作之后"的样子。
// 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来,
// 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗
// 正在确认的这件事自相矛盾。
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {
// Same 403-on-blocked-contact gap as Profile above -- this used to leave `blocked` stuck at
// whatever initialBlocked was (usually false, e.g. arriving fresh from the address book)
// whenever the block didn't just happen in this same page session, showing an off "拉黑"
// toggle and the "删除好友" button for someone actually already blocked.
if (!pinBlocked) {
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) { setBlocked(ctx.relationship === 'blocked_by_me'); }
}).catch(() => {});
}
});
// GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the
// friends list endpoint's friendListItemPayload does. Look this friend up there instead.
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
const entry = list.find(item => item.user_id === uid);
if (entry) setRemark(entry.remark || '');
}).catch(() => {});
}, [uid]);
const saveRemark = () => {
const value = remarkDraft.trim();
updateFriendRemark(uid, value).then(() => {
setRemark(value);
setEditingRemark(false); onToast('备注已更新');
}).catch(err => onToast(err.message || '备注更新失败'));
};
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
<section><button type="button" onClick={() => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
<section><div><span>置顶聊天</span><Toggle checked={pinned} disabled={settingsVersion === null} onChange={value => {
// settingsVersion starts null until getConversationSettings' GET resolves (below); a toggle
// clicked inside that window sent version:null over the wire, which the server 400s on as
// MALFORMED_REQUEST -- disabling the switch during that (usually brief, but real on a slow
// connection) gap avoids that avoidable round trip and its "设置失败" toast entirely.
const prev = pinned; setPinned(value);
updateConversationSettings(uid, {pinned: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
.catch(err => { setPinned(prev); onToast(err.message || '设置失败'); });
}} /></div>
<div><span>消息免打扰</span><Toggle checked={muted} disabled={settingsVersion === null} onChange={value => {
const prev = muted; setMuted(value);
updateConversationSettings(uid, {mute_notifications: value, version: settingsVersion}).then(s => setSettingsVersion(s.version))
.catch(err => { setMuted(prev); onToast(err.message || '设置失败'); });
}} /></div>
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
{editingRemark ?
// Backend column is VARCHAR(64) and actually enforces it (65 chars -> 400) -- confirmed
// live, this used to cap input at less than a third of what the server accepts.
<div className="people-remark-edit"><input value={remarkDraft} maxLength="64" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
if (value) { navigate('block-confirm', uid); return; }
unblockUser(uid).then(() => { setBlocked(false); onToast('已移出黑名单'); }).catch(err => onToast(err.message || '操作失败'));
}} /></div>
<button type="button" onClick={() => {
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
// target_id/target_name, so build the hash directly here rather than through it.
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
HashNavigation.navigateTo(hash);
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
</section>
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
</main></div>;
}
function BlockList({onToast}) {
const [people, setPeople] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => setLoadError(true)).finally(() => setLoading(false));
}, []);
const remove = userId => {
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
.catch(err => onToast(err.message || '操作失败'));
};
// The only entry point into this view is personal-settings-view.jsx's "设置" list
// ('android-settings'), not friend-settings -- this page has no friend uid in scope at all.
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
<div className="people-empty"><Icon>person_off</Icon><strong>{loadError ? '黑名单加载失败' : '黑名单为空'}</strong></div>}
</main></div>;
}
function ConfirmSheet({type, uid, tinode, onToast}) {
const [busy, setBusy] = React.useState(false);
const config = {
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除', keepUid: false},
block: {title: '黑名单', text: '拉黑后将不会收到对方的消息,可在"设置"—"黑名单"查看人员名单', action: '确定', route: 'friend-settings-blocked', toast: '已加入黑名单', keepUid: true},
clear: {title: '清除聊天记录', text: '清空后不可找回,确定要清空当前会话的历史记录吗?', action: '确定', route: 'friend-settings', toast: '聊天记录已清除', keepUid: true}
}[type];
const confirm = () => {
setBusy(true);
let call;
if (type === 'delete') call = deleteFriend(uid);
else if (type === 'block') call = blockUser(uid);
else {
// No dedicated "clear my view of history" REST endpoint exists yet -- this is the same native
// Tinode soft-delete-all-for-me the real message-actions menu already uses (context-menu.jsx).
// Native p2p topic name (from my side) is just the peer's own user_id -- NOT the relationship
// context's `topic` field, which is a separate "p2pXXXX" id the REST layer uses internally and
// Tinode.topicType() doesn't recognize (see Profile.openChat for the full story).
const topic = tinode ? tinode.getTopic(uid) : null;
call = topic ? topic.delMessagesAll(false) : Promise.resolve();
}
call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} pinBlocked={true} onToast={onToast} />
<div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>{config.action}</button>
<button type="button" onClick={() => navigate('friend-settings', uid)}>取消</button>
</section></div>
</div>;
}
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
const [toast, setToast] = React.useState('');
const uid = hashParams && hashParams.uid;
let content;
if (view === 'address-book') content = <AddressBook myUserId={myUserId} onTopicSelected={onTopicSelected} />;
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
else if (view === 'friend-qr') content = <FriendQr uid={uid} onToast={setToast} />;
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" uid={uid} tinode={tinode} onToast={setToast} />;
else if (view === 'block-confirm') content = <ConfirmSheet type="block" uid={uid} tinode={tinode} onToast={setToast} />;
else content = <ConfirmSheet type="clear" uid={uid} tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}