修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、
群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示 - 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为 source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认 search/qr/group,命中default分支返回"invalid friend request source"。 改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道), 用两个全新、此前无任何关系的测试账号验证首次申请200成功 - 解散群聊需要短信二次验证但完整流程从未实现(P0):新增 SecurityChallengeModal通用二次验证弹层(POST /security/challenges→ 验证码→verify→action_token)+ requestActionToken()(stores/ securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让 群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复 imDelete对无body请求不显式传空对象体,导致携带Content-Type: application/json但请求体真正为空时后端JSON解码失败返回400——实测确认 两个问题都解决后DELETE返回200 - 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增 topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调, ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表 - P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认 为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未 聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样 实测未复现 - 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充 FRIEND_REQUEST_CONFLICT - 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会 立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正 超时后才显示该链接,与其上方的提示文案保持一致的显示条件
This commit is contained in:
@@ -87,6 +87,9 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
|
||||
// friends, or a request between the two of you (either direction) is already pending.
|
||||
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
|
||||
+19
-11
@@ -130,9 +130,17 @@ export function leaveGroup(topic: string) {
|
||||
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
|
||||
}
|
||||
|
||||
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
|
||||
export function disbandGroup(topic: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
|
||||
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
|
||||
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
|
||||
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
|
||||
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
|
||||
* a truly empty body under that header as malformed rather than "no optional fields provided". */
|
||||
export function disbandGroup(topic: string, actionToken: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
|
||||
body: {},
|
||||
headers: { 'X-Action-Token': actionToken },
|
||||
});
|
||||
}
|
||||
|
||||
export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
@@ -264,14 +272,14 @@ export function unmuteGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
|
||||
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
|
||||
* that's added. Kept typed so the group-management UI can be built ahead of that. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
|
||||
target_user_id: targetUserId,
|
||||
version,
|
||||
});
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
|
||||
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
|
||||
{ target_user_id: targetUserId, version },
|
||||
{ headers: { 'X-Action-Token': actionToken } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupGovernance(topic: string) {
|
||||
|
||||
@@ -15,3 +15,27 @@ export interface UrlSafetyResult {
|
||||
export function checkUrlSafety(url: string, source: string) {
|
||||
return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
|
||||
}
|
||||
|
||||
/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line).
|
||||
* Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc
|
||||
* text only spells out the two youth-mode scenes by name, but the server also accepts
|
||||
* `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */
|
||||
export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer';
|
||||
|
||||
export interface SecurityChallenge {
|
||||
challenge_id: string;
|
||||
phone_mask: string;
|
||||
expires_in_seconds: number;
|
||||
resend_after_seconds: number;
|
||||
}
|
||||
|
||||
export function requestSecurityChallenge(scene: SecurityChallengeScene) {
|
||||
return imPost<SecurityChallenge>('/security/challenges', { scene, channel: 'sms' });
|
||||
}
|
||||
|
||||
export function verifySecurityChallenge(challengeId: string, code: string) {
|
||||
return imPost<{ action_token: string; action: string; expires_at: string }>(
|
||||
`/security/challenges/${encodeURIComponent(challengeId)}/verify`,
|
||||
{ code },
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -257,6 +257,9 @@ declare module 'tinode-sdk' {
|
||||
onSubsUpdated?: (subs: string[], count?: number) => void;
|
||||
onPres?: (pres: unknown) => void;
|
||||
onAuxUpdated?: (aux: unknown) => void;
|
||||
/** Fired by the SDK's own `Topic#_gone()` once this topic is confirmed deleted (group dissolved,
|
||||
* or this account removed from it) -- clears `public`/`private`/messages just before firing. */
|
||||
onDeleteTopic?: () => void;
|
||||
}
|
||||
|
||||
export class MeTopic extends Topic {
|
||||
|
||||
@@ -64,6 +64,19 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
|
||||
return () => infoListeners.delete(callback);
|
||||
}
|
||||
|
||||
const goneListeners = new Set<(topicName: string) => void>();
|
||||
|
||||
/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) --
|
||||
* the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls
|
||||
* `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this,
|
||||
* a member who stays on a group's conversation screen while someone else dissolves it just keeps
|
||||
* looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots
|
||||
* for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */
|
||||
export function subscribeTopicGone(callback: (topicName: string) => void): () => void {
|
||||
goneListeners.add(callback);
|
||||
return () => goneListeners.delete(callback);
|
||||
}
|
||||
|
||||
function wireTopicCallbacks(topic: Topic) {
|
||||
if (wiredTopics.has(topic)) return;
|
||||
const notify = () => notifyTopic(topic.name);
|
||||
@@ -86,6 +99,10 @@ function wireTopicCallbacks(topic: Topic) {
|
||||
topic.onPres = notify;
|
||||
topic.onAuxUpdated = notify;
|
||||
topic.onAllMessagesReceived = notify;
|
||||
topic.onDeleteTopic = () => {
|
||||
notify();
|
||||
goneListeners.forEach((cb) => cb(topic.name));
|
||||
};
|
||||
wiredTopics.add(topic);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user