第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页"查看聊天"进入,留言气泡右对齐+"好友申请正在等待处理"横幅正确显示; 点"通过验证"接受申请后正确留在列表页不误触发导航;接受后重新打开同一会话, 合成气泡正确消失,被真实的"你们已成为好友,现在可以开始聊天了。"系统消息取代, 无残留/无重复。
706 lines
49 KiB
React
706 lines
49 KiB
React
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('')} /></>;
|
||
}
|