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
@@ -5,7 +5,6 @@ import { AppShell } from '@/app/AppShell';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { RegisterPage } from '@/features/auth/RegisterPage';
|
||||
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
|
||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||
import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
|
||||
@@ -34,7 +33,6 @@ const queryClient = new QueryClient();
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'reset-password', element: <ResetPasswordPage /> },
|
||||
{ path: 'enterprise-register', element: <EnterpriseRegisterPage /> },
|
||||
{ path: 'feedback', element: <TenantFeedbackPage /> },
|
||||
|
||||
@@ -70,11 +70,11 @@ export function RequireAuth() {
|
||||
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
|
||||
|
||||
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
|
||||
// of HOW it became known (session restore above, or a fresh login/register that already called
|
||||
// setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire
|
||||
// their own subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly
|
||||
// logged-in user with an empty conversation list and no error on a transient failure. Centralizing
|
||||
// here means it happens exactly once per authenticated mount and surfaces the same Toast either way.
|
||||
// of HOW it became known (session restore above, or a fresh login that already called
|
||||
// setMyUserId before this component ever mounted) -- LoginPage used to fire its own
|
||||
// subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly logged-in user
|
||||
// with an empty conversation list and no error on a transient failure. Centralizing here means it
|
||||
// happens exactly once per authenticated mount and surfaces the same Toast either way.
|
||||
useEffect(() => {
|
||||
if (!myUserId || meSubscribedRef.current) return;
|
||||
meSubscribedRef.current = true;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Agreement } from '@/features/auth/components/Agreement';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
import { getTinode, loginBasic } from '@/lib/tinode/connection';
|
||||
@@ -16,7 +15,6 @@ export function LoginPage() {
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -37,7 +35,7 @@ export function LoginPage() {
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password || !agreed || submitting) return;
|
||||
if (!login.trim() || !password || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -116,18 +114,11 @@ export function LoginPage() {
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || !agreed || submitting}
|
||||
disabled={!login.trim() || !password || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '登录中…' : '登录'}
|
||||
</button>
|
||||
<p className="m-0 text-center text-sm text-text-tertiary-1">
|
||||
没有账户?{' '}
|
||||
<Link to="/register" className="text-accent no-underline">
|
||||
马上注册
|
||||
</Link>
|
||||
</p>
|
||||
<Agreement checked={agreed} onChange={setAgreed} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Agreement } from '@/features/auth/components/Agreement';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
||||
import { verifyRegisterCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getTinode, registerAccount } from '@/lib/tinode/connection';
|
||||
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
|
||||
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
|
||||
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
|
||||
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
|
||||
function normalizePhoneInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
|
||||
return withoutCountryCode.slice(0, 11);
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
phone?: string;
|
||||
code?: string;
|
||||
password?: string;
|
||||
invite?: string;
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [invite, setInvite] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// A challengeId/code pair is only valid for the phone number it was issued for -- without this,
|
||||
// editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that
|
||||
// verifyRegisterCode() has no way to cross-check against the NEW phone in the field.
|
||||
useEffect(() => {
|
||||
setChallengeId('');
|
||||
setCode('');
|
||||
}, [phone]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
|
||||
const usingPhone = !!phone;
|
||||
// `tenants/resolve`'s own real per-tenant password_policy (docs/IM-前后端对接文档.md:534) -- read
|
||||
// off the tenant instead of a hardcoded guess, so this can never drift from ResetPasswordPage's
|
||||
// own minimum (they'd silently diverged to two different hardcoded numbers before this existed).
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const next: FormErrors = {};
|
||||
if (!username && !phone) next.username = '用户名和手机号至少填写一项';
|
||||
if (username && !USERNAME_RE.test(username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
|
||||
if (usingPhone) {
|
||||
if (!PHONE_RE.test(phone)) next.phone = '请输入 11 位手机号';
|
||||
if (!challengeId) next.code = '请先获取验证码';
|
||||
else if (!/^\d{6}$/.test(code)) next.code = '请输入 6 位验证码';
|
||||
} else if (!INVITE_RE.test(invite)) {
|
||||
next.invite = '用户名注册需填写 5 位字母邀请码';
|
||||
}
|
||||
if (password.length < minPasswordLength) next.password = `密码至少 ${minPasswordLength} 位`;
|
||||
setErrors(next);
|
||||
setFormError('');
|
||||
if (Object.keys(next).length || !agreed || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
let verificationToken: string | undefined;
|
||||
try {
|
||||
if (usingPhone) {
|
||||
const verified = await verifyRegisterCode(challengeId, code);
|
||||
verificationToken = verified.verification_token;
|
||||
}
|
||||
} catch (err) {
|
||||
setErrors((e) => ({ ...e, code: err instanceof ImApiError ? err.message : '验证码错误或已过期' }));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ctrl = await registerAccount({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
inviteCode: invite,
|
||||
verificationToken,
|
||||
challengeId: usingPhone ? challengeId : undefined,
|
||||
});
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||
return;
|
||||
}
|
||||
const authToken = getTinode().getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setFormError(registerErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
|
||||
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
|
||||
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</Link>
|
||||
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">注册</strong>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<h1 className="m-0 text-[22px] font-bold text-text-title">创建账号</h1>
|
||||
{formError ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))}
|
||||
placeholder="用户名(1-10位小写字母或数字)"
|
||||
autoComplete="username"
|
||||
className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${
|
||||
errors.username ? 'border border-danger-text' : ''
|
||||
}`}
|
||||
/>
|
||||
{errors.username ? <div className="text-xs text-danger-border">{errors.username}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-text-tertiary-3">
|
||||
<div className="h-px flex-1 bg-border-row" />
|
||||
或
|
||||
<div className="h-px flex-1 bg-border-row" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className={`flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
|
||||
errors.phone ? 'border border-danger-text' : 'has-focus:border-focus-border'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[15px] text-text-tertiary-2">+86</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="请输入手机号"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
{errors.phone ? <div className="text-xs text-danger-border">{errors.phone}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="短信验证码"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${
|
||||
errors.code ? 'border border-danger-text' : ''
|
||||
}`}
|
||||
/>
|
||||
<VerificationCodeButton
|
||||
phone={phone}
|
||||
scene="register"
|
||||
onChallenge={setChallengeId}
|
||||
onError={(msg) => setErrors((e) => ({ ...e, code: msg }))}
|
||||
/>
|
||||
</div>
|
||||
{errors.code ? <div className="text-xs text-danger-border">{errors.code}</div> : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="设置密码"
|
||||
autoComplete="new-password"
|
||||
error={errors.password}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */}
|
||||
<input
|
||||
value={invite}
|
||||
onChange={(e) => setInvite(e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5))}
|
||||
placeholder="邀请码(5位英文字母,区分大小写)"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${
|
||||
errors.invite ? 'border border-danger-text' : ''
|
||||
}`}
|
||||
/>
|
||||
{errors.invite ? <div className="text-xs text-danger-border">{errors.invite}</div> : null}
|
||||
</div>
|
||||
|
||||
{!usingPhone ? (
|
||||
<p className="m-0 text-xs text-text-tertiary-3">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!username && !phone) || !password || !agreed || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '注册中…' : '注册'}
|
||||
</button>
|
||||
<Link to="/login" className="text-center text-sm text-accent no-underline">
|
||||
已有账号?返回登录
|
||||
</Link>
|
||||
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同MeetingIM" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +47,9 @@ export function ResetPasswordPage() {
|
||||
}, [phone]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
// Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded
|
||||
// to 8 here, matching Register's OWN hardcode by coincidence, not because either ever read the
|
||||
// real value; SecuritySettingsPage's in-place change had drifted to a DIFFERENT hardcoded 6.
|
||||
// Real per-tenant policy (docs/IM-前后端对接文档.md:534) -- was hardcoded to 8 here, not because
|
||||
// anything ever read the real value; SecuritySettingsPage's in-place change had drifted to a
|
||||
// DIFFERENT hardcoded 6.
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
@@ -86,7 +86,7 @@ export function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
|
||||
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link
|
||||
scaled down -- same pattern as RegisterPage's own min-[900px]:hidden header. */}
|
||||
scaled down. */}
|
||||
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
|
||||
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
|
||||
interface AgreementProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/** README/`01-account.dc.html`'s exact 16x16 `border-radius:4px` box with a custom check glyph --
|
||||
* `accent-color` alone only tints the native widget's fill and leaves its unchecked border/size/
|
||||
* radius to the browser/OS, which doesn't match this spec on any platform. */
|
||||
export function Agreement({ checked, onChange, prefix = `同意${BASE_APP_NAME}` }: AgreementProps) {
|
||||
return (
|
||||
<label className="flex items-start gap-2 text-[13px] text-text-tertiary-1">
|
||||
<span className="relative mt-0.5 flex-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="peer absolute inset-0 m-0 h-4 w-4 cursor-pointer appearance-none"
|
||||
/>
|
||||
<span className="grid h-4 w-4 place-items-center rounded border-[1.5px] border-[#c5cad2] peer-checked:border-accent peer-checked:bg-accent">
|
||||
{checked ? <Icon name="check" className="text-[12px] text-white" /> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{prefix}{' '}
|
||||
<a href="#agreement" onClick={(e) => e.preventDefault()} className="text-accent no-underline">
|
||||
《用户协议》
|
||||
</a>{' '}
|
||||
和{' '}
|
||||
<a href="#privacy" onClick={(e) => e.preventDefault()} className="text-accent no-underline">
|
||||
《隐私政策》
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const PHONE_RE = /^\d{11}$/;
|
||||
|
||||
interface VerificationCodeButtonProps {
|
||||
phone: string;
|
||||
scene: 'register' | 'reset_password';
|
||||
scene: 'reset_password';
|
||||
onChallenge: (challengeId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from
|
||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
@@ -469,7 +469,12 @@ export function ConversationView() {
|
||||
// actually said first.
|
||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
// A target that isn't the conversation currently open (i.e. almost any real forward target)
|
||||
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
|
||||
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
|
||||
// edge case.
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
for (const seq of orderedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
||||
@@ -479,6 +484,8 @@ export function ConversationView() {
|
||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
setBulkForwardOpen(false);
|
||||
exitSelectMode();
|
||||
@@ -815,9 +822,12 @@ export function ConversationView() {
|
||||
onClose={() => setForwardMsg(null)}
|
||||
onPick={(targetTopics, comment) => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
setForwardMsg(null);
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Contact } from 'tinode-sdk';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 200;
|
||||
|
||||
@@ -24,12 +26,26 @@ interface ForwardPickerModalProps {
|
||||
*/
|
||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [comment, setComment] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||
|
||||
// The list used to mix friends and groups in one flat run -- every other list in this app
|
||||
// (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually
|
||||
// separate, so this was the odd one out and made a specific group hard to spot among dozens of
|
||||
// P2P contacts.
|
||||
const friends = useMemo(
|
||||
() => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()),
|
||||
[filtered, tinode],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()),
|
||||
[filtered, tinode],
|
||||
);
|
||||
|
||||
const toggle = (topic: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -39,11 +55,50 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
});
|
||||
};
|
||||
|
||||
const visibleTopics = useMemo(() => filtered.map((c) => c.topic).filter((t): t is string => !!t), [filtered]);
|
||||
const allVisibleSelected = visibleTopics.length > 0 && visibleTopics.every((t) => selected.has(t));
|
||||
const toggleSelectAll = () => {
|
||||
setSelected((prev) => {
|
||||
if (allVisibleSelected) {
|
||||
// Only drop the ones the CURRENT search actually shows -- a selection made under an earlier,
|
||||
// now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view.
|
||||
const next = new Set(prev);
|
||||
for (const t of visibleTopics) next.delete(t);
|
||||
return next;
|
||||
}
|
||||
return new Set([...prev, ...visibleTopics]);
|
||||
});
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (selected.size === 0) return;
|
||||
onPick([...selected], comment.trim());
|
||||
};
|
||||
|
||||
const renderRow = (c: Contact) => {
|
||||
const isSelected = !!c.topic && selected.has(c.topic);
|
||||
return (
|
||||
<button
|
||||
key={c.topic}
|
||||
type="button"
|
||||
onClick={() => c.topic && toggle(c.topic)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(c.public?.fn)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
|
||||
>
|
||||
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -66,31 +121,31 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
{visibleTopics.length ? (
|
||||
<button type="button" onClick={toggleSelectAll} className="mt-2 text-xs text-accent">
|
||||
{allVisibleSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{filtered.map((c) => {
|
||||
const isSelected = !!c.topic && selected.has(c.topic);
|
||||
return (
|
||||
<button
|
||||
key={c.topic}
|
||||
type="button"
|
||||
onClick={() => c.topic && toggle(c.topic)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(c.public?.fn)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
|
||||
>
|
||||
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{friends.length ? (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-2 text-xs text-text-tertiary-2">好友</div>
|
||||
{friends.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{groups.length ? (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-2 text-xs text-text-tertiary-2">群聊</div>
|
||||
{groups.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{!friends.length && !groups.length ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
|
||||
<Icon name="search_off" className="!text-4xl" />
|
||||
<span className="text-sm">未找到相关联系人</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-none border-t border-border-list-item p-3">
|
||||
<textarea
|
||||
|
||||
+5
-18
@@ -5,32 +5,19 @@ export interface VerificationCodeResponse {
|
||||
resend_after_seconds: number;
|
||||
}
|
||||
|
||||
export function requestVerificationCode(scene: 'register' | 'reset_password', phone: string) {
|
||||
export function requestVerificationCode(scene: 'reset_password', phone: string) {
|
||||
return imPost<VerificationCodeResponse>('/auth/verification-codes', { scene, phone }, { auth: false });
|
||||
}
|
||||
|
||||
export interface VerifyCodeResponse {
|
||||
verification_token: string;
|
||||
}
|
||||
|
||||
/** No Idempotency-Key here, matching the old app's im-api.js -- a plain code check, not a mutation. */
|
||||
export function verifyRegisterCode(challengeId: string, code: string) {
|
||||
return imRequest<VerifyCodeResponse>(`/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`, {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { code },
|
||||
});
|
||||
}
|
||||
|
||||
export interface PasswordResetVerifyResponse {
|
||||
reset_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset verifies through an entirely different endpoint family than registration
|
||||
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though both share the same
|
||||
* `/auth/verification-codes` code-request endpoint via `scene`. No Idempotency-Key, same reasoning
|
||||
* as verifyRegisterCode.
|
||||
* Password reset verifies through an entirely different endpoint family
|
||||
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though it shares the same
|
||||
* `/auth/verification-codes` code-request endpoint above via `scene`. No Idempotency-Key -- a plain
|
||||
* code check, not a mutation.
|
||||
*/
|
||||
export function verifyPasswordResetCode(challengeId: string, code: string) {
|
||||
return imRequest<PasswordResetVerifyResponse>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,8 +28,8 @@ interface SessionState {
|
||||
* it, since a returning user's auto-login still needs it to build the {hi} packet and every REST
|
||||
* call's X-Tenant-Code header. `myUserId` is *not* persisted here -- trusting a stale uid from a
|
||||
* previous session without re-authenticating would be wrong. Instead, app/RequireAuth.tsx re-derives
|
||||
* it on mount via `Tinode.loginToken()` against the reusable session token LoginPage/RegisterPage
|
||||
* write to `sessionStorage`, which is the actual thing that should survive a same-tab reload.
|
||||
* it on mount via `Tinode.loginToken()` against the reusable session token LoginPage writes to
|
||||
* `sessionStorage`, which is the actual thing that should survive a same-tab reload.
|
||||
*/
|
||||
export const useSessionStore = create<SessionState>()(
|
||||
persist(
|
||||
|
||||
Reference in New Issue
Block a user