feat(web): 下线自主注册与协议勾选,修复转发消息报错,转发选择器分组+全选
按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由 及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件 Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等 死代码;登录不再依赖勾选协议才能提交。 修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/ `sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未 真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正 打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过 预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何 一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时 订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开 过的好友/群转发均成功。 转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个 列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1826d2906f
commit
46b3288880
@@ -110,32 +110,18 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration-only extra fields (username/phone/invite_code) queued by registerAccount() below,
|
||||
* consumed the next time an {acc} packet actually goes out. A module-level slot, not a per-call
|
||||
* argument, because the SDK's own createAccountBasic() has no parameter for them at all -- same
|
||||
* one-shot-queue shape as the old app's `tinode.__pendingAccExtra`.
|
||||
*/
|
||||
let pendingAccExtra: Record<string, unknown> | null = null;
|
||||
|
||||
export function queueAccExtra(extra: Record<string, unknown>) {
|
||||
pendingAccExtra = extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server extends two stock packets with fields the vendored JS SDK has no API for
|
||||
* (server/datamodel.go): {hi}'s `tenant` (resolved via POST /v1/im/tenants/resolve) and {acc}'s
|
||||
* `username`/`phone`/`invite_code` (used by server/user.go's replyCreateUser during registration).
|
||||
* The packet builder is private, so the only usable hook is wrapping the connection's raw sendText
|
||||
* and injecting fields into the already-serialized JSON. Ported as-is from the old app's
|
||||
* tnSetup/installTenantHiPatch -- this is a real backend protocol extension, not an old-codebase-
|
||||
* specific workaround.
|
||||
* The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for
|
||||
* (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private,
|
||||
* so the only usable hook is wrapping the connection's raw sendText and injecting the field into the
|
||||
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is a
|
||||
* real backend protocol extension, not an old-codebase-specific workaround.
|
||||
*/
|
||||
function installTenantHiPatch(t: Tinode) {
|
||||
const conn = t._connection;
|
||||
if (!conn || conn.__tenantPatched) return;
|
||||
const original = conn.sendText.bind(conn);
|
||||
conn.sendText = (msg: unknown) => {
|
||||
if (typeof msg === 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) {
|
||||
if (typeof msg === 'string' && msg.indexOf('"hi":') >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.hi) {
|
||||
@@ -144,10 +130,6 @@ function installTenantHiPatch(t: Tinode) {
|
||||
parsed.hi.tenant = tenant.code;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} else if (parsed.acc && pendingAccExtra) {
|
||||
Object.assign(parsed.acc, pendingAccExtra);
|
||||
pendingAccExtra = null;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON -- send as is.
|
||||
@@ -302,8 +284,8 @@ export function restoreSession(token: string): Promise<string | null> {
|
||||
* Deduped the same way connect()/restoreSession() are above, for the identical reason: LoginPage's
|
||||
* own `submitting` state guard only takes effect once React re-renders with the submit button
|
||||
* disabled, which is a moment too late for a genuine fast double-click/double-Enter to be stopped by
|
||||
* -- every OTHER non-idempotent SDK call in this module (connect/restoreSession/registerAccount) was
|
||||
* already deduped against exactly this race, `loginBasic` was the one left calling straight through.
|
||||
* -- every OTHER non-idempotent SDK call in this module (connect/restoreSession) was already deduped
|
||||
* against exactly this race, `loginBasic` was the one left calling straight through.
|
||||
*/
|
||||
let loginPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
@@ -327,85 +309,3 @@ export function subscribeConnectionStatus(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
export interface RegisterParams {
|
||||
/** `''` when omitted -- valid to leave blank as long as `phone` is filled. */
|
||||
username: string;
|
||||
/** Bare 11-digit phone, no "+86" prefix. `''` on the username-only path (no phone at all). */
|
||||
phone: string;
|
||||
password: string;
|
||||
/** `''` when omitted. Required (and validated) by the caller on the username-only path. */
|
||||
inviteCode: string;
|
||||
/** Only meaningful when `phone` is non-empty -- from verifyRegisterCode()'s response. */
|
||||
verificationToken?: string;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified username-or-phone registration (product Ch.1.4). THREE DIFFERENT phone representations
|
||||
* are involved in one `{acc}` packet and mixing them up throws a 422 "policy violation" with no
|
||||
* further detail (confirmed live against the real backend, then traced to server/user.go's
|
||||
* resolveRegistrationAccount):
|
||||
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
|
||||
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
|
||||
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
|
||||
* also typed, the username itself takes precedence (server/user.go: username!="" && phone!=""
|
||||
* => account=username), matching the old app's fixed bug where a typed username used to get
|
||||
* silently dropped on the phone path.
|
||||
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
|
||||
* with no "+86" prefix.
|
||||
* - `cred[].val` (the `tel` credential itself) is ALSO the bare 11-digit number, not E.164 -- every
|
||||
* one of the doc's own worked examples (docs/IM-前后端对接文档.md, both the 手机号验证方式 and
|
||||
* 仅手机号注册方式 blocks) shows `cred.val` identical to the bare `phone` field even in the exact
|
||||
* same JSON object where `secret` DOES carry the `+86`-prefixed form -- the doc is deliberately
|
||||
* inconsistent between the two, not sloppy. A missed 3rd case: an earlier version of this
|
||||
* function got the first two representations right but still E.164-prefixed this one.
|
||||
*
|
||||
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
|
||||
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
|
||||
* shape, and `typeof null === 'object'` is true in JS, so passing it a real `null` crashes with
|
||||
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
|
||||
* at all (confirmed by reading the old app's own history of this exact bug).
|
||||
*/
|
||||
let registerPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
/** Deduped the same way connect()/restoreSession() are above, for the same class of reason:
|
||||
* createAccountBasic() is non-idempotent (creates a new account per call), so two overlapping
|
||||
* invocations -- e.g. a slow network letting a double-click on "注册" land before the submit
|
||||
* button's own pending-state disables it -- would otherwise send two `{acc}` packets. */
|
||||
export function registerAccount(params: RegisterParams): Promise<LoginCtrl> {
|
||||
if (registerPromise) return registerPromise;
|
||||
registerPromise = registerAccountImpl(params).finally(() => {
|
||||
registerPromise = null;
|
||||
});
|
||||
return registerPromise;
|
||||
}
|
||||
|
||||
async function registerAccountImpl({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
inviteCode,
|
||||
verificationToken,
|
||||
challengeId,
|
||||
}: RegisterParams): Promise<LoginCtrl> {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const usingPhone = !!phone;
|
||||
queueAccExtra({ username: username || '', phone: phone || '', invite_code: inviteCode || '' });
|
||||
|
||||
const secretAccount = usingPhone ? username || `+86${phone}` : username;
|
||||
const cred =
|
||||
usingPhone && verificationToken && challengeId
|
||||
? Tinode.credential({ meth: 'tel', val: phone, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
: undefined;
|
||||
|
||||
const trimmedName = username.trim();
|
||||
return t.createAccountBasic(secretAccount, password, {
|
||||
public: trimmedName ? { fn: trimmedName } : undefined,
|
||||
cred: cred ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,8 +95,8 @@ export function getMessagesSnapshot(topicName: string): StoredMessage[] {
|
||||
|
||||
/** Deduped the same way openConversation/connect are -- subscribing the same `me` topic twice
|
||||
* concurrently is the same non-idempotent-WS-call shape as those (currently reachable from
|
||||
* RequireAuth.tsx/LoginPage.tsx/RegisterPage.tsx, which don't overlap today, but this is cheap
|
||||
* insurance against a future call site that does, e.g. a manual "reload contacts" retry button). */
|
||||
* RequireAuth.tsx/LoginPage.tsx, which don't overlap today, but this is cheap insurance against a
|
||||
* future call site that does, e.g. a manual "reload contacts" retry button). */
|
||||
let inFlightMeSubscribe: Promise<unknown> | null = null;
|
||||
|
||||
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
||||
@@ -214,6 +214,34 @@ export async function closeConversation(topicName: string): Promise<void> {
|
||||
await getTinode().getTopic(topicName).leave(false).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a topic can be published to right now, without disturbing whichever conversation is
|
||||
* actually open. `Topic.publishMessage()` rejects outright unless `_attached` is true, and that's
|
||||
* only reliably the case for the ONE topic openConversation/closeConversation currently has open --
|
||||
* any OTHER topic you've ever actually opened and then navigated away from goes back to detached and
|
||||
* STAYS that way (closeConversation's leave() doesn't restore prefetchPreview's original one-shot
|
||||
* subscription, and prefetchPreview itself no-ops on a topic it's already touched once, attached or
|
||||
* not). That covers forwarding a message to anyone other than the person/group you're currently
|
||||
* chatting with -- i.e. most real forwards -- so this isn't a rare edge case to special-case around,
|
||||
* it's the common one. Reuses `inFlightSubscribe` so a concurrent openConversation() for the same
|
||||
* topic is awaited/shared rather than racing it with a second `{sub}` packet (see openConversation's
|
||||
* own comment above for why that matters). Deliberately left attached afterward rather than re-left --
|
||||
* same "cheap to leave attached, likely reopened soon anyway" call prefetchPreview already makes.
|
||||
*/
|
||||
export function ensureTopicAttached(topicName: string): Promise<Topic> {
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
const pending = inFlightSubscribe.get(topicName);
|
||||
if (pending) return pending;
|
||||
if (topic.isSubscribed()) return Promise.resolve(topic);
|
||||
wireTopicCallbacks(topic);
|
||||
const promise = topic.subscribe().then(() => topic);
|
||||
inFlightSubscribe.set(topicName, promise);
|
||||
promise.finally(() => {
|
||||
if (inFlightSubscribe.get(topicName) === promise) inFlightSubscribe.delete(topicName);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
let chatListSnapshot: Contact[] | null = null;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user