fix(web): 第9轮审查批次1——认证/图标CSS/顶号竞态/消息核心一批问题
认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
min-[900px]:block hidden
- 企业码输入框补maxLength=64
图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立
顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑
通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底
消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
token),改成语义正确的unread token;录音条文字被连带染红,只保留
指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
不是text-tertiary-3)都和设计稿不符,已修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fb1fda689c
commit
ab96ae1fee
@@ -105,7 +105,21 @@ export function EnterpriseRegisterPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!selected) return;
|
||||
// `accept` is only a file-picker hint, not enforcement -- a user can still pick
|
||||
// an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's
|
||||
// own screenshot validation rather than silently uploading and waiting on the
|
||||
// backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check.
|
||||
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
|
||||
setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setFile(selected);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<Icon name="upload_file" className="text-[22px] text-accent" />
|
||||
|
||||
@@ -100,7 +100,7 @@ export function ResetPasswordPage() {
|
||||
<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">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
|
||||
<h1 className="m-0 hidden text-[22px] font-bold text-text-title min-[900px]:block">重置密码</h1>
|
||||
<p className="m-0 text-sm text-text-tertiary-1">
|
||||
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
|
||||
</p>
|
||||
|
||||
@@ -25,8 +25,14 @@ export function TenantEntryPage() {
|
||||
if (!code.trim() || submitting) return;
|
||||
// Reachable by navigating back here while already logged in (no route guard keeps an
|
||||
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
||||
// that live session with no warning at all.
|
||||
if (myUserId && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
|
||||
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
|
||||
// Zustand value that resets to null on any fresh mount of this module (a page reload while
|
||||
// sitting on /tenant, or opening this URL directly in a new tab) even though the session token
|
||||
// is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading
|
||||
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
|
||||
// never mounts for this route. Checking the token directly closes that gap.
|
||||
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
|
||||
if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -88,6 +94,7 @@ export function TenantEntryPage() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入企业码"
|
||||
maxLength={64}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -47,12 +47,20 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
|
||||
|
||||
const click = async () => {
|
||||
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
|
||||
// The phone number this request is actually FOR -- captured now, not read again after the
|
||||
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
|
||||
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
|
||||
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
|
||||
// verification would silently operate on an account the user never intended.
|
||||
const requestedPhone = phone;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await requestVerificationCode(scene, phone);
|
||||
const data = await requestVerificationCode(scene, requestedPhone);
|
||||
if (requestedPhone !== phone) return;
|
||||
onChallenge(data.challenge_id);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
} catch (err) {
|
||||
if (requestedPhone !== phone) return;
|
||||
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
|
||||
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
|
||||
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
|
||||
|
||||
@@ -165,7 +165,7 @@ export function ChatListColumn() {
|
||||
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
|
||||
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
|
||||
{typeof memberCount === 'number' ? (
|
||||
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{memberCount}人</span>
|
||||
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
|
||||
) : null}
|
||||
</span>
|
||||
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
|
||||
|
||||
@@ -161,7 +161,7 @@ export function MessageRow({
|
||||
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
|
||||
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : null}
|
||||
<div
|
||||
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
@@ -182,7 +182,7 @@ export function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="text-xs text-danger-text">
|
||||
<button type="button" onClick={retry} className="text-xs text-unread">
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Outlet, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||
@@ -8,6 +9,7 @@ import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
||||
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
||||
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
@@ -86,7 +88,14 @@ export function MessagesLayout() {
|
||||
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
// `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the
|
||||
// lookup below always misses for it -- same gap ConversationView.tsx's own header already had and
|
||||
// fixed the same way, just computed independently here for this panel's title/detail-column
|
||||
// props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has
|
||||
// already mounted it, not a second request.
|
||||
const isSelf = topicId === 'slf';
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
|
||||
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
|
||||
|
||||
|
||||
@@ -123,8 +123,8 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
|
||||
<span className="flex flex-none items-center gap-2 text-sm font-medium text-danger-text">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
|
||||
<span className="flex flex-none items-center gap-2 text-sm font-medium text-text-body">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-unread" />
|
||||
录音中 {formatSeconds(seconds)}
|
||||
</span>
|
||||
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { logout as revokeServerSession } from '@/lib/api/auth';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
@@ -35,12 +36,7 @@ const MOBILE_SHORTCUTS = [
|
||||
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
|
||||
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
|
||||
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
|
||||
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
|
||||
*/
|
||||
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -56,6 +52,11 @@ export function SettingsLayout() {
|
||||
const logout = async () => {
|
||||
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
|
||||
setLoggingOut(true);
|
||||
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
|
||||
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
|
||||
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
|
||||
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
|
||||
await revokeServerSession().catch(() => {});
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { imPost, imRequest } from './client';
|
||||
|
||||
/**
|
||||
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
|
||||
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
|
||||
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
|
||||
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
|
||||
* 调用失败而跳过后续的本地清理。
|
||||
*/
|
||||
export function logout() {
|
||||
return imPost<void>('/auth/logout');
|
||||
}
|
||||
|
||||
export interface VerificationCodeResponse {
|
||||
challenge_id: string;
|
||||
resend_after_seconds: number;
|
||||
|
||||
+17
-11
@@ -7,6 +7,7 @@
|
||||
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
|
||||
// a real, repeatedly-rediscovered bug in the old codebase).
|
||||
import { API_KEY } from '@/lib/config';
|
||||
import { getKickedInfo } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DEFAULT_API_BASE = '/v1/im';
|
||||
@@ -29,7 +30,7 @@ export class ImApiError extends Error {
|
||||
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
|
||||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
|
||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||
AUTH_FAILED: '账号或密码错误',
|
||||
TOKEN_EXPIRED: '登录已过期,请重新登录',
|
||||
@@ -207,17 +208,22 @@ function forceReauth() {
|
||||
if (reauthTriggered) return;
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// The latch only needs to survive until the hard reload below actually happens -- that reload is
|
||||
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
|
||||
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
|
||||
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `<Navigate>`, which
|
||||
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
|
||||
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
|
||||
// same SPA instance.
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
reauthTriggered = true;
|
||||
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
|
||||
reauthTriggered = true;
|
||||
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
|
||||
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
|
||||
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
|
||||
// network timing, not something this code controls). Without this, a REST 401 that happens to
|
||||
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
|
||||
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
|
||||
// that WS message (usually already in flight over an open connection, typically faster than a
|
||||
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
|
||||
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
|
||||
// by far the common case) just navigate ~200ms later than before -- not perceptible.
|
||||
window.setTimeout(() => {
|
||||
if (getKickedInfo()) return;
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
||||
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
|
||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
|
||||
* own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */
|
||||
@@ -33,7 +33,13 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu
|
||||
*/
|
||||
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
|
||||
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
|
||||
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
|
||||
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
|
||||
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
|
||||
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
|
||||
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
|
||||
// Defer to the server's own supported_actions list when it's present, so a server-side policy
|
||||
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
|
||||
// will just 400 when tapped -- the time-window check below still applies on top of this.
|
||||
|
||||
@@ -66,14 +66,35 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
|
||||
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Topic#videoCall` sends a `{note}` packet, which (unlike `{pub}`) carries no `id` field -- read
|
||||
* the SDK source: with no id to route a response through, a disconnected socket makes the
|
||||
* underlying `sendText()` throw SYNCHRONOUSLY rather than reject a promise. None of this module's
|
||||
* callers in `stores/calls.ts` await or `.catch()` these calls (they're fire-and-forget signals),
|
||||
* so an uncaught synchronous throw here doesn't just fail silently -- it aborts whatever code was
|
||||
* running right after the call site. Concretely: `hangUp()`'s 60s ring-timeout callback calls this,
|
||||
* and if the connection happens to be down at that exact moment (e.g. `resetTinode()` ran because
|
||||
* the account was evicted mid-call), the exception skips every line after it in that function,
|
||||
* including the local Agora `leaveCall()` cleanup and the UI's own end-of-call state transition --
|
||||
* the call window would stay stuck open with a live camera/mic track. These three are the only
|
||||
* `note`-based signals in the app; wrapping here (once) covers every call site in one place.
|
||||
*/
|
||||
function sendNote(topic: Topic, event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>) {
|
||||
try {
|
||||
return topic.videoCall(event, seq, payload);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('ringing', seq, { callId });
|
||||
return sendNote(topic, 'ringing', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('accept', seq, { callId });
|
||||
return sendNote(topic, 'accept', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
|
||||
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
return sendNote(topic, 'hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user