diff --git a/css/base.css b/css/base.css index 382d7707..ecd26ee7 100644 --- a/css/base.css +++ b/css/base.css @@ -10126,6 +10126,15 @@ div.toast.show { text-align: center; } +/* 列表下方的常驻灰色注脚(禁言页的"以上成员不允许发言"),区别于上面的空状态文案。 */ +.group-inline-note { + margin: 0; + padding: 0.9rem 1rem 1.4rem; + color: #969da5; + font-size: 0.85rem; + text-align: center; +} + /* Alphabet-group section header inside MemberPicker/OwnerSelect, same treatment as .group-admin-page h2. */ .group-section-title { margin: 0; diff --git a/src/views/auth-onboarding-view.jsx b/src/views/auth-onboarding-view.jsx index be57e2ff..e1fc79bd 100644 --- a/src/views/auth-onboarding-view.jsx +++ b/src/views/auth-onboarding-view.jsx @@ -422,7 +422,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) { // set a nickname afterwards). const secretAccount = form.username || ('+86' + form.phone); onImRegisterRequest(secretAccount, form.password, form.username || '', - {username: form.username || '', phone: form.phone, invite_code: ''}, + // 邀请码不能在手机号路径上丢掉:后端 user.go:175-181 对邀请码的校验和 RecordUse 都不区分 + // 注册路径(`if inviteCode != ""` 就会校验并记录),而且 user.go:169 的 + // inviteRequired 在短信通道关闭时对手机号注册同样为真。UI 上明明摆着邀请码输入框, + // 原来却硬传空串,用户填了也被静默丢弃,邀请来源统计全丢。 + {username: form.username || '', phone: form.phone, invite_code: form.invite || ''}, {meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}}); } else { onImRegisterRequest(form.username, form.password, form.username, @@ -546,6 +550,20 @@ function IdentityCheck({authenticated}) { setSeconds(data.resend_after_seconds || 60); } catch (err) { setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败')); + // 限流(429)时服务端会连同还没过期的 challenge_id 和剩余冷却秒数一起返回 + // (im_http.go:6361-6367)。原来这里只把错误文案显示出来就完了,challengeId 一直是空串, + // 于是 submit() 开头的 `if (!challengeId) return` 会静默吞掉点击——按钮看着能点, + // 输完 6 位验证码点"确定"毫无反应,也没有任何提示。同文件的 VerificationButton + // 早就处理对了,这里是漏抄。 + if (err.details && err.details.retry_after_seconds) { + setSeconds(err.details.retry_after_seconds); + } + if (err.details && err.details.challenge_id) { + setChallengeId(err.details.challenge_id); + } + if (err.details && err.details.phone_mask) { + setPhoneMask(err.details.phone_mask); + } } finally { setBusy(false); } diff --git a/src/views/contact-management-view.jsx b/src/views/contact-management-view.jsx index e9528b5e..65c376ca 100644 --- a/src/views/contact-management-view.jsx +++ b/src/views/contact-management-view.jsx @@ -148,7 +148,11 @@ function AddressBook({myUserId, onTopicSelected}) { {loading ?

加载中…

: sortedKeys.length === 0 ?

暂无好友

: null} {sortedKeys.map(initial =>

{initial}

{groups[initial].map(person => )}
)} @@ -377,7 +381,7 @@ function Profile({uid, friend, onTopicSelected, onToast}) { function Toggle({checked, onChange}) { return ; } -function Settings({uid, initialBlocked, onToast}) { +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. @@ -390,7 +394,11 @@ function Settings({uid, initialBlocked, onToast}) { const [remarkDraft, setRemarkDraft] = React.useState(''); React.useEffect(() => { if (!uid) return; - getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {}); + // pinBlocked:确认弹窗把这个页面当背景渲染,并用 initialBlocked 摆出"操作之后"的样子。 + // 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来, + // 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗 + // 正在确认的这件事自相矛盾。 + getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.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 @@ -480,7 +488,7 @@ function ConfirmSheet({type, uid, tinode, onToast}) { call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); }) .catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false)); }; - return
+ return

{config.title}

{config.text}

diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 7353fed3..50e78e87 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -24,8 +24,24 @@ const navigate = (view, topic) => { // 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for // viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it // can't go through this file's own `navigate` helper above without clobbering the wrong hash param. -const navigateToProfile = userId => HashNavigation.navigateTo( - HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); +// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子, +// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里 +// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口 +// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。 +const navigateToProfile = (userId, member) => { + if (member && userId) { + writeCachedData('profile-seed:' + userId, { + user_id: userId, + display_name: member.group_nickname || member.display_name || userId, + avatar_file_id: member.avatar_file_id, + ...(member.signature ? {signature: member.signature} : {}), + ...(member.region ? {region: member.region} : {}), + ...(member.gender ? {gender: member.gender} : {}) + }); + } + HashNavigation.navigateTo( + HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId)); +}; export const GROUP_MANAGEMENT_VIEWS = [ 'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests', @@ -60,6 +76,27 @@ function groupBy(list, keyFn) { return Object.keys(groups).sort().map(key => [key, groups[key]]); } +// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的 +// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload +// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、 +// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。 +// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。 +function groupDisplayName(tinode, group) { + if (group.name && group.name !== group.topic) return group.name; + const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null; + const pub = t && t.public; + // 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的 + // groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。 + const local = pub && (pub.name || pub.display_name || pub.fn); + return local || group.name || group.topic; +} + +function groupMemberCount(tinode, group) { + if (group.member_count) return group.member_count; + const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null; + return (t && t.subcnt) || 0; +} + function shortDate(iso) { if (!iso) return ''; const d = new Date(iso); @@ -82,9 +119,11 @@ const GROUPS_DEMO = { }; function Icon({children}) { return ; } -function Header({title, back = 'group-list', backTopic, right, onRight}) { +// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1], +// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。 +function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) { return
- +

{title}

{right ? : }
; @@ -118,7 +157,7 @@ function Toggle({checked, onChange, disabled}) { className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}>; } -function GroupList({onToast}) { +function GroupList({tinode, onToast}) { // Only the unfiltered (query === '') result is worth caching -- caching a search result under the // same key would show stale search results instead of the real list on the next plain visit. const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null}); @@ -163,7 +202,7 @@ function GroupList({onToast}) { current.length ? current.map(group => ) :
groups这里还没有群聊 @@ -268,7 +307,13 @@ function Announcement({topic, myRole, fromChat, onToast}) { // handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical // path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so // "back" can return to the chat (close the sidepanel) instead of always forcing group-settings. - const backView = fromChat ? '' : 'group-settings'; + // 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该 + // 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数, + // path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。 + // 这里显式把 topic 写回 path[1]。 + const backToChat = React.useCallback(() => { + HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic)); + }, [topic]); React.useEffect(() => { if (!topic) return; const cached = readCachedData('group-announcement:' + topic); @@ -286,9 +331,9 @@ function Announcement({topic, myRole, fromChat, onToast}) { setData(d); setEditing(false); onToast('群公告已发布'); }).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false)); }; - if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; + if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; return
-
{canEdit ?