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 {children}; }
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
}
二维码 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
;
}
// 中国大陆手机号: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
;
}
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 ?
好友申请正在等待验证
:
}
{alreadyFriend || alreadyPending || sent ? null :
}
;
}
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
{loadError ? '资料加载失败,请重试' : '加载中…'}
;
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
;
}
function Toggle({checked, onChange, disabled}) { return ; }
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
{(profile && profile.display_name) || ''}
消息传输方式已启动加密传输
置顶聊天 {
// 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 || '设置失败'); });
}} />
{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.