fix(web): code review round 3 -- UI bugs and remaining design fidelity
- FileAttachment never surfaced a failed authenticated download -- the card looked identically clickable whether it worked or not, and clicking while broken silently no-op'd. Added an error state/retry (useAuthedBlobUrl gained a retry() escape hatch for this). ImageAttachment/AudioAttachment already handled this; only the file card didn't. - --shadow-btn was one token serving two different specs: round 2's fix (0 6px 16px, from the 50px auth CTAs) actually regressed the 36px messaging send button (ConversationView/VoiceRecorder), which needs 0 4px 12px per 02-messages.dc.html. Split into --shadow-btn / --shadow-btn-sm. - loadOlder's scroll-restore guard compared topicId by value, which false-positives on an A->B->A round trip within one request's lifetime (same string, but the DOM was torn down and rebuilt by the intervening open/close). Switched to a generation counter, same pattern as topics.ts's leave-race fix. - errorToast wasn't included in ConversationView/DetailColumn's topicId-reset effects -- a failed action's toast could keep showing over a conversation/contact switched to afterward. Also switched those effects (and MessagesLayout's) from useEffect to useLayoutEffect -- since none of these components remount on topicId change, a post-paint effect could let one frame render with the previous topicId's stale UI still showing. - VoiceRecorder never listened for MediaRecorder's own onerror -- a mid-recording failure left the "录音中" timer ticking forever with no indication the recording had already died. - VerificationCodeButton's phone-change countdown reset (round 2) only cleared the button's own state; RegisterPage/ResetPasswordPage's challengeId was still tied to whatever phone the code was actually issued for, with nothing cross-checking it against a since-edited phone number. - Agreement's checkbox used bare accent-color on a native input, which only tints the checked fill and leaves size/radius/border to the browser/OS; replaced with the spec's exact 16x16 border-radius:4px box + check glyph. - Added a catch-all route (App.tsx), TenantFeedbackPage image-picker validation feedback + corrected remove-button style, and a handful of smaller token/spacing corrections against screens/*.dc.html (NavRail font-weight, LoginPage/TenantEntryPage logo size+shadow+color, ChatListColumn row padding/icon size, --shadow-modal alpha). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8ec74dffcc
commit
72b7b79caf
@@ -48,6 +48,10 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
// A stale bookmark/link to a route renamed during this redesign should land somewhere the app
|
||||
// itself resolves (tenant/login/messages, per RequireAuth's own gating), not React Router's
|
||||
// generic unstyled default error boundary.
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
|
||||
@@ -46,7 +46,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||
return (
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
|
||||
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-medium text-white">
|
||||
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
|
||||
{selfInitial}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
|
||||
@@ -68,7 +68,7 @@ export function LoginPage() {
|
||||
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
|
||||
<div className="absolute left-16 top-14 flex items-center gap-2.5">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-lg font-bold text-accent">M</div>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-[22px] font-bold text-accent">M</div>
|
||||
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||
</div>
|
||||
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
|
||||
@@ -77,7 +77,7 @@ export function LoginPage() {
|
||||
<p className="mt-4 max-w-md text-base text-white/80">
|
||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
||||
</p>
|
||||
<div className="absolute bottom-14 left-16 flex gap-5 text-[13px] text-white/75">
|
||||
<div className="absolute bottom-14 left-16 flex gap-7 text-[13px] text-white/75">
|
||||
<span>下载桌面客户端</span>
|
||||
<span>iOS / Android</span>
|
||||
<span>帮助中心</span>
|
||||
@@ -93,8 +93,10 @@ export function LoginPage() {
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<div className="mb-1 flex flex-col items-center gap-3 min-[900px]:hidden">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">M</div>
|
||||
<strong className="text-2xl font-bold text-text-heading">{tenant.name}</strong>
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
|
||||
</div>
|
||||
<div className="hidden flex-col gap-1 min-[900px]:flex">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -41,6 +41,14 @@ export function RegisterPage() {
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -25,6 +25,14 @@ export function ResetPasswordPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
// 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
|
||||
// verifyPasswordResetCode() 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 submit = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
export function TenantEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const setTenant = useSessionStore((s) => s.setTenant);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -18,6 +19,10 @@ export function TenantEntryPage() {
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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 && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -43,10 +48,10 @@ export function TenantEntryPage() {
|
||||
</Link>
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
||||
<div className="mb-2 flex flex-col items-center gap-3">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-2xl font-bold text-text-heading">MeetingIM</strong>
|
||||
<strong className="text-[26px] font-bold text-text-title">MeetingIM</strong>
|
||||
</div>
|
||||
{error ? (
|
||||
<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">
|
||||
|
||||
@@ -25,6 +25,7 @@ export function TenantFeedbackPage() {
|
||||
const addImages = (files: FileList | null) => {
|
||||
const selected = Array.from(files || []).slice(0, 9 - images.length);
|
||||
const valid = selected.filter((f) => f.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(f.type));
|
||||
if (valid.length < selected.length) setError('部分图片未添加:仅支持 JPG/PNG/WebP,且不超过 10MB');
|
||||
setImages((prev) => prev.concat(valid.map((file) => ({ file, url: URL.createObjectURL(file) }))));
|
||||
};
|
||||
|
||||
@@ -92,21 +93,21 @@ export function TenantFeedbackPage() {
|
||||
<span className="self-end text-xs text-text-tertiary-3">{description.length}/500</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{images.map((img, i) => (
|
||||
<div key={img.url} className="relative aspect-square overflow-hidden rounded-lg bg-surface-rail">
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
<div key={img.url} className="relative aspect-square rounded-lg bg-surface-rail">
|
||||
<img src={img.url} alt="" className="h-full w-full rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-black/50 text-white"
|
||||
className="absolute -right-1.5 -top-1.5 grid h-[18px] w-[18px] place-items-center rounded-full bg-white text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="close" className="text-[14px]" />
|
||||
<Icon name="cancel" className="text-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < 9 ? (
|
||||
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3">
|
||||
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2] text-text-tertiary-3">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(e) => addImages(e.target.files)} className="hidden" />
|
||||
<Icon name="add_photo_alternate" className="text-[20px]" />
|
||||
<span className="text-xs">{images.length}/9</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
|
||||
interface AgreementProps {
|
||||
@@ -6,15 +7,23 @@ interface AgreementProps {
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 accent-accent"
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -47,7 +47,12 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
href={href}
|
||||
download={data.name}
|
||||
onClick={(e) => {
|
||||
if (!href) e.preventDefault();
|
||||
if (remote.error) {
|
||||
e.preventDefault();
|
||||
remote.retry();
|
||||
} else if (!href) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
|
||||
>
|
||||
@@ -56,11 +61,15 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} · {remote.loading ? '加载中…' : '点击下载'}
|
||||
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
||||
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
|
||||
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="download" className="ml-auto flex-none text-[20px] text-text-placeholder-2" />
|
||||
<Icon
|
||||
name={remote.error ? 'error_outline' : 'download'}
|
||||
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,10 +73,10 @@ export function ChatListColumn() {
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white px-4 py-2.5 no-underline hover:bg-[#f6f8fb]"
|
||||
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white py-2.5 pl-4 pr-3 no-underline hover:bg-[#f6f8fb]"
|
||||
>
|
||||
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-[22px]" />
|
||||
<Icon name={entry.icon} className="text-2xl" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
|
||||
@@ -105,7 +105,7 @@ export function ChatListColumn() {
|
||||
<Link
|
||||
key={contact.topic}
|
||||
to={`/messages/${contact.topic}`}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item px-4 py-2.5 no-underline ${
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
@@ -72,10 +72,13 @@ export function ConversationView() {
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const streamRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const topicIdRef = useRef(topicId);
|
||||
useEffect(() => {
|
||||
topicIdRef.current = topicId;
|
||||
});
|
||||
// Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
|
||||
// value, which would false-positive if the user opens A, then B, then A again within a single
|
||||
// loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
|
||||
// down and rebuilt by the intervening open/close cycle, so a same-topicId check alone would wrongly
|
||||
// treat that as "still the same open" and apply a scroll-restore computed against content that
|
||||
// isn't there anymore.
|
||||
const openGenerationRef = useRef(0);
|
||||
|
||||
// Every "fire and forget" send/recall/favorite/forward action below used to swallow its own
|
||||
// failure with an empty `.catch(() => {})` (or, for forward, no catch at all) -- e.g. an
|
||||
@@ -91,7 +94,12 @@ export function ConversationView() {
|
||||
};
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
|
||||
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
|
||||
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
|
||||
// list underneath, self-correcting only on the next tick.
|
||||
useLayoutEffect(() => {
|
||||
openGenerationRef.current += 1;
|
||||
setSelectMode(false);
|
||||
setSelectedSeqs(new Set());
|
||||
setMenu(null);
|
||||
@@ -110,6 +118,9 @@ export function ConversationView() {
|
||||
// NEW conversation and silently skips scrolling it to the bottom on open.
|
||||
setLoadingOlder(false);
|
||||
autoScrollRef.current = true;
|
||||
// An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
|
||||
// showing over whichever conversation is now open, misattributing the failure.
|
||||
setErrorToast('');
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -139,7 +150,7 @@ export function ConversationView() {
|
||||
*/
|
||||
const loadOlder = async () => {
|
||||
if (loadingOlder) return;
|
||||
const calledForTopicId = topicId;
|
||||
const calledForGeneration = openGenerationRef.current;
|
||||
const el = streamRef.current;
|
||||
const min = topic.minMsgSeq();
|
||||
const max = topic.maxMsgSeq();
|
||||
@@ -151,12 +162,13 @@ export function ConversationView() {
|
||||
try {
|
||||
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
||||
} finally {
|
||||
// If the user switched to a different conversation while this was in flight, `el` is now
|
||||
// showing that OTHER topic's messages -- prevTop/prevHeight were measured against content
|
||||
// that isn't there anymore, so applying them would jump the NEW conversation's scroll
|
||||
// position to a meaningless spot. Only the topic that actually asked for more history should
|
||||
// ever clear its own loading flag or touch the scroll position.
|
||||
if (topicIdRef.current === calledForTopicId) {
|
||||
// If the conversation was closed and reopened (even back to this SAME topicId) while this was
|
||||
// in flight, `el` now shows content from a different open/close cycle -- prevTop/prevHeight
|
||||
// were measured against content that isn't there anymore, so applying them would jump the
|
||||
// current conversation's scroll to a meaningless spot. Comparing topicId alone would miss an
|
||||
// A->B->A round trip (same string, different generation); only the specific open this request
|
||||
// was made for should ever clear its own loading flag or touch the scroll position.
|
||||
if (openGenerationRef.current === calledForGeneration) {
|
||||
setLoadingOlder(false);
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
@@ -446,7 +458,7 @@ export function ConversationView() {
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -56,10 +56,14 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
|
||||
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
|
||||
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
|
||||
// saved against the NEW one.
|
||||
useEffect(() => {
|
||||
// saved against the NEW one, and a leftover error toast would keep showing over the new contact
|
||||
// misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
|
||||
// this component never remounts on topicId change, so a post-paint effect can let one frame
|
||||
// render with the previous contact's stale edit box still open.
|
||||
useLayoutEffect(() => {
|
||||
setRemarkEditing(false);
|
||||
setRemarkDraft('');
|
||||
setErrorToast('');
|
||||
}, [topicId]);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
@@ -98,7 +102,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
});
|
||||
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user_id === topicId);
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
|
||||
|
||||
const deleteFriendMutation = useMutation({
|
||||
mutationFn: () => deleteFriend(topicId),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
@@ -37,7 +37,7 @@ export function MessagesLayout() {
|
||||
// MessagesLayout is one long-lived instance reused across every conversation (route is
|
||||
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
|
||||
// showing results for whichever topic it was opened against, not the one now on screen.
|
||||
useEffect(() => setHistoryTab(null), [topicId]);
|
||||
useLayoutEffect(() => setHistoryTab(null), [topicId]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
|
||||
@@ -74,6 +74,16 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
// Without this, a mid-recording failure (mic unplugged, OS audio error) auto-transitions
|
||||
// the recorder to 'inactive' with nothing telling the UI -- the "录音中" timer just keeps
|
||||
// ticking forever with no way to know the recording already died until the user manually
|
||||
// hits 取消/发送 and finish()'s own `recorder.state === 'inactive'` check silently no-ops.
|
||||
recorder.onerror = () => {
|
||||
if (finishedRef.current) return;
|
||||
finishedRef.current = true;
|
||||
stopStream();
|
||||
setError('录音失败,请重试');
|
||||
};
|
||||
recorder.start();
|
||||
recorderRef.current = recorder;
|
||||
startedAtRef.current = Date.now();
|
||||
@@ -125,7 +135,7 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
type="button"
|
||||
onClick={() => finish(true)}
|
||||
disabled={!ready}
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled"
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled"
|
||||
>
|
||||
<Icon name="send" className="text-[16px]" />
|
||||
发送
|
||||
|
||||
+5
-1
@@ -103,10 +103,14 @@
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
||||
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
|
||||
--shadow-btn: 0 6px 16px rgba(54, 139, 250, 0.2);
|
||||
/* screens/02-messages.dc.html's 36px 发送 button (ConversationView/VoiceRecorder) -- a smaller
|
||||
button gets a smaller shadow, not the same token as the 50px auth buttons above. */
|
||||
--shadow-btn-sm: 0 4px 12px rgba(54, 139, 250, 0.2);
|
||||
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
||||
* into a blob URL rather than a direct URL. `ref` is anything fileDownloadUrl accepts: a full
|
||||
* "/v0/file/s/xxx" path (Drafty `refurl`), or an absolute URL.
|
||||
*/
|
||||
export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
|
||||
export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean; retry: () => void } {
|
||||
const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref) return undefined;
|
||||
@@ -34,9 +35,9 @@ export function useAuthedBlobUrl(ref: string | undefined): { url: string | null;
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [ref]);
|
||||
}, [ref, retryCount]);
|
||||
|
||||
return state;
|
||||
return { ...state, retry: () => setRetryCount((n) => n + 1) };
|
||||
}
|
||||
|
||||
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */
|
||||
|
||||
Reference in New Issue
Block a user