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:
Your Name
2026-09-10 19:04:59 +08:00
co-authored by Claude Sonnet 5
parent 1826d2906f
commit 46b3288880
13 changed files with 161 additions and 474 deletions
+2 -11
View File
@@ -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>
-243
View File
@@ -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>
);
}
+4 -4
View File
@@ -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;
}
+24 -14
View File
@@ -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,16 +469,23 @@ export function ConversationView() {
// actually said first.
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
for (const targetTopic of targetTopics) {
const target = tinode.getTopic(targetTopic);
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
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
// with a misleading "forwarded from" tag rather than the message the user actually picked.
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
}
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
// 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
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
// with a misleading "forwarded from" tag rather than the message the user actually picked.
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);
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
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