Files
im_webapp/src/views/contact-management-view.jsx
T
Your Name ac8f0cf315 补上好友申请前后关系状态聊天页的入口,并把验证留言渲染成真实消息气泡
第3章要求pending_inbound/pending_outbound/none/blocked几种关系状态都能在真实聊天
窗口里看到(参考图 个人对话框(对方的状态)/(我的状态).png、开启沟通页面.png)。
messages-view.jsx里对应的横幅逻辑("以上是打招呼的消息"/"好友申请正在等待处理"等)
本身早就是对的,但排查发现两个问题:

1. 全项目没有任何入口能导航进这个视图——"新朋友"列表(FriendRequests)每一行没有
   onClick;好友个人主页(Profile)非好友分支只显示一段cannotAddReason纯文字,没有
   任何可点击进聊天的按钮。发起方和接收方在成为好友之前都完全看不到这个视图。
2. 验证留言原文(发好友申请时填的那段话,存在im_friend_requests表的message字段,
   不是真实Tinode消息,没有走topic.publish())没有被当作消息渲染出来,参考图里
   明确是作为聊天页第一条消息气泡展示的。

修复:
- contact-management-view.jsx: FriendRequests每一行加onClick,用(onCreateTopic||
  onTopicSelected)跳进对应聊天(同一套Profile.openChat已经验证过的冷启动安全写法,
  接受/拒绝按钮补stopPropagation不受影响);Profile非好友分支(blocked_by_peer/
  blocked_by_me/pending_inbound/pending_outbound)补一个"查看聊天"按钮,跟
  cannotAddReason文字并排展示(不复用people-profile-actions的两列grid,那是给
  发消息+音视频通话两个平级按钮设计的)。ContactManagementView/sidepanel-view.jsx
  早前就已经把onCreateTopic透传到位,这次只是多接一层。
- messages-view.jsx: loadRelationshipContext新增loadPendingRequestMessage,
  relationship是pending_inbound/pending_outbound时,用relationshipContext自带的
  request_id去GET /friend-requests换真实留言文本(没有留言用参考图默认文案"请求
  添加你为好友"兜底),渲染成一条合成的消息气泡插在消息列表最前面(不进
  messageNodes、不调用发消息接口)——pending_inbound左对齐、pending_outbound右
  对齐,气泡样式直接复用chat-message.jsx真实消息已经在用的.bubble/.left/.right
  这套CSS(app里"发出的"气泡实际是这套已实现的浅色而非参考图的蓝色,保持跟同一
  会话里其它真实消息一致,不単独改这一条的颜色去凑旧版参考图)。

验证:真实两个新账号(vcm909641发起→hankqa14b接收)走了一遍完整流程——
从"新朋友"列表点击行正确进入聊天页,留言气泡+"以上是打招呼的消息"横幅正确显示;
从Profile页"查看聊天"进入,留言气泡右对齐+"好友申请正在等待处理"横幅正确显示;
点"通过验证"接受申请后正确留在列表页不误触发导航;接受后重新打开同一会话,
合成气泡正确消失,被真实的"你们已成为好友,现在可以开始聊天了。"系统消息取代,
无残留/无重复。
2026-09-06 17:24:36 +08:00

706 lines
49 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, 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 <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} role="button" tabIndex={0} onClick={() => openChat(item)}>
<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={e => { e.stopPropagation(); handle(item, false); }}>拒绝</button><button type="button" className="primary" onClick={e => { e.stopPropagation(); 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(() => {});
// 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 <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, onCreateTopic, 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.
//
// 同时必须把 ctx.capabilities 一起合并进去,不能只更新 relationship:canAdd 这个渲染判断读的是
// profile.capabilities.can_add_friend,不是 relationship 本身。实测复现:从陌生人搜索结果进来的
// seed 一直带着 capabilities.can_add_friend=true(搜索时 can_add 确实是 true),发送好友申请后
// relationship 变成 pending_outbound,可这里原来只覆盖 relationship 字段,capabilities 原样保留
// 着申请前的 can_add_friend:true——canAdd 分支判断不看 relationship,于是"添加好友"按钮在申请
// 已发出后继续显示、可以重复点击再次发起申请(配合后端不去重的缺口——新增44——实际造成重复申请)。
// ctx.capabilities 本身就是服务端针对当前 relationship 算出来的权威值(此时 can_add_friend 已经
// 是 false),直接一起合并即可,不需要在前端另外推导一套"这些 relationship 不能加好友"的规则。
getConversationContext(uid).then(ctx => {
if (ctx && ctx.relationship) {
setProfile(current => current ? {...current, relationship: ctx.relationship,
capabilities: ctx.capabilities || current.capabilities} : 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).
//
// Must go through onCreateTopic (tinode-web.jsx's handleStartTopicRequest), not the plain
// onTopicSelected -- confirmed live: a freshly-accepted friend with zero prior message history has
// no locally cached p2p topic yet, and onTopicSelected (handleTopicSelected) only *selects* a topic,
// it never sends the native {sub} request that actually creates/joins it. messages-view.jsx's own
// isWriter stays stuck at its default `false` (no ACS ever arrives because nothing subscribed),
// permanently showing "消息已禁用" with no compose box and no way to send the first message --
// 100% reproducible for every real "just became friends, say hi" case, not an edge case.
// handleStartTopicRequest already guards the case this topic *does* have history (isTopicCached),
// delegating straight to handleTopicSelected then, so this is safe to call unconditionally.
const openChat = () => (onCreateTopic || 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> :
// cannotAddReason 覆盖的四种关系(blocked_by_peer/blocked_by_me/pending_inbound/
// pending_outbound)在 messages-view.jsx 里都已经有对应的、真实可用的聊天视图(对方发来的
// 留言气泡+"以上是打招呼的消息"横幅,或"好友申请正在等待处理"横幅,或拉黑后仍可输入、发送
// 失败再展示具体原因)——原来这个非好友分支只显示 cannotAddReason 这段纯文字,没有任何
// 入口能点进去看。同一套 openChat(优先 onCreateTopic 保证冷启动)。按钮不套进
// .people-profile-actions 的两列grid(那是给"发消息+音视频通话"两个平级按钮设计的),
// 单独一个满宽按钮,跟上面纯文字提示分两行。
<>
<p className="people-empty-hint">{cannotAddReason}</p>
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>查看聊天</button>
</>}
</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, onCreateTopic, 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} onTopicSelected={onTopicSelected} onCreateTopic={onCreateTopic} />;
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} onCreateTopic={onCreateTopic} 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('')} /></>;
}