diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index b792642..361c1fd 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -14,6 +14,8 @@ export interface ChannelInfo { name?: string; /** 空间头像(data URL,可以是 GIF 动图) */ avatar?: string; + /** 空间日志的最新序号,用来判断别的空间有没有新动静 */ + lastSeq?: number; mode: ChannelMode; role: 'owner' | 'member'; maxMembers: number; @@ -170,6 +172,8 @@ export type FolderInfo = Omit & { parentId?: string export type WorkspaceEvent = | { t: 'structure'; state: WorkspaceState; people: AccountProfile[] } + /** 加密空间里这个账号没有密钥的频道(能看到名字,不能读也不能写) */ + | { t: 'access'; locked: string[] } | { t: 'reset' } | { t: 'error'; message: string } | { t: 'ready' } @@ -180,6 +184,9 @@ export type WorkspaceEvent = | { t: 'removed'; id: string } | { t: 'snapshot'; snapshot: Snapshot }; +/** 账号所在空间的日志序号前进了(空间列表的未读提示用) */ +export type SpaceEvent = { t: 'space-seq'; id: string; lastSeq: number }; + export type ShareEvent = | { t: 'ready' } | { t: 'share'; share: Share } diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 83b7104..7ed5d94 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -12,6 +12,7 @@ import { connectShare, getShareBlob, stopShare } from './worker/shareRead'; import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './worker/shareResources'; import { sealShare } from './worker/shareSeal'; import { setEventSink } from './worker/port'; +import { stopWatchingSpaces, watchSpaces } from './worker/spaceWatch'; import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; @@ -88,6 +89,8 @@ async function dispatch(operation: string, input: unknown): Promise { case 'forgetChannel': return forgetChannel(id); case 'connectChannel': void connectChannelResource(id); return true; case 'stopChannel': stopChannel(id); return true; + case 'watchSpaces': watchSpaces(); return true; + case 'stopWatchingSpaces': stopWatchingSpaces(); return true; case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined); case 'cancelUpload': return cancelFileUpload(id, String(payload.localId ?? '')); case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined); diff --git a/web/src/core-bridge/worker/channelDataKeys.ts b/web/src/core-bridge/worker/channelDataKeys.ts index 01fcf46..cd309f0 100644 --- a/web/src/core-bridge/worker/channelDataKeys.ts +++ b/web/src/core-bridge/worker/channelDataKeys.ts @@ -154,6 +154,14 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes const state = states.get(resource.id); if (state) state.objects.push(object); return { itemIdBytes: idBytes, itemId, channelId, domain, epoch }; } +/** 加密空间里没拿到聊天数据域密钥的频道:成员只能看到频道名,读写都会被拒绝。空间主的新频道首次使用时才初始化密钥,不算锁定 */ +export async function lockedThreads(resource: SavedResource, session: ChannelSession, channels: readonly { id: string; chatEpoch: number }[]): Promise { + if (resource.mode !== 'enc') return []; + const owner = session.can('owner'); + const missing = (keys: Record) => channels.filter(c => !(owner && c.chatEpoch === 0) && !keys[domainId(c.id, 'chat', c.chatEpoch)]).map(c => c.id); + const { keys } = await cachedKeys(resource, session, data => missing(data.keys).length === 0); + return missing(keys); +} export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise { if (resource.mode !== 'enc') return true; let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId); diff --git a/web/src/core-bridge/worker/port.ts b/web/src/core-bridge/worker/port.ts index 7639553..610731e 100644 --- a/web/src/core-bridge/worker/port.ts +++ b/web/src/core-bridge/worker/port.ts @@ -1,6 +1,6 @@ -import type { ShareEvent, WorkspaceEvent } from '../types'; +import type { ShareEvent, SpaceEvent, WorkspaceEvent } from '../types'; -type BridgeEvent = WorkspaceEvent | ShareEvent; +type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent; type EventSink = (resourceId: string, event: BridgeEvent, blob?: Blob) => void; let sink: EventSink = () => {}; diff --git a/web/src/core-bridge/worker/spaceWatch.ts b/web/src/core-bridge/worker/spaceWatch.ts new file mode 100644 index 0000000..6271214 --- /dev/null +++ b/web/src/core-bridge/worker/spaceWatch.ts @@ -0,0 +1,64 @@ +import { apiFetch } from './http'; +import { publish } from './port'; +import { delay } from './transports'; + +/** 账号所在空间的实时进展(只有序号):空间列表据此提示「有新动静」,不必逐个空间订阅 */ +export const SPACES_RESOURCE = '__spaces'; +const IDLE_MS = 45_000; + +let running: AbortController | undefined; + +async function readOnce(signal: AbortSignal): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(); + signal.addEventListener('abort', abort, { once: true }); + let timer = setTimeout(abort, IDLE_MS); + try { + const response = await apiFetch('/accounts/spaces/events', { signal: controller.signal }); + if (!response.body) throw new Error('连接失败'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (!signal.aborted) { + const chunk = await reader.read(); + if (chunk.done) break; + clearTimeout(timer); + timer = setTimeout(abort, IDLE_MS); + buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, ''); + let boundary: number; + while ((boundary = buffer.indexOf('\n\n')) !== -1) { + const lines = buffer.slice(0, boundary).split('\n'); + buffer = buffer.slice(boundary + 2); + if (lines.find((line) => line.startsWith('event:'))?.slice(6).trim() !== 'space') continue; + const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n'); + const parsed = JSON.parse(data) as { id?: string; last_seq?: number }; + if (parsed.id && typeof parsed.last_seq === 'number') publish(SPACES_RESOURCE, { t: 'space-seq', id: parsed.id, lastSeq: parsed.last_seq }); + } + } + } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + controller.abort(); + } +} + +export function watchSpaces(): void { + if (running) return; + const controller = running = new AbortController(); + void (async () => { + let wait = 1000; + while (!controller.signal.aborted) { + const started = Date.now(); + try { await readOnce(controller.signal); } catch { /* 断线或未登录:退避后重连 */ } + wait = Date.now() - started > 30_000 ? 1000 : Math.min(wait * 2, 30_000); + await delay(wait, controller.signal); + } + })(); +} + +export function stopWatchingSpaces(): void { + running?.abort(); + running = undefined; +} diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index 70a319e..8210943 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -11,6 +11,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn joinPolicy, passwordRequired: raw.password_required, name: raw.settings.name ?? undefined, avatar: raw.avatar ?? undefined, + lastSeq: raw.last_seq, mode: raw.mode, role: raw.can_manage_structure ? 'owner' : 'member', canUpload: raw.can_upload, diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts index 14a49ff..5e6006a 100644 --- a/web/src/core-bridge/worker/workspaceStructure.ts +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -1,6 +1,6 @@ import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '../generated/api'; import type { FileItem } from '../types'; -import { getResource, cacheResource } from '../database'; +import { getResource, cacheResource, currentResourceScope } from '../database'; import { getSession } from './sessions'; import { apiFetch } from './http'; import { publishWorkspace } from './presentation'; @@ -34,6 +34,17 @@ async function emit(id: string, body: WorkspaceBody): Promise { })); if (current.get(id) !== body) return; publishWorkspace(id, { t: 'structure', state: body.structure, people }); + void publishAccess(id, body); +} + +async function publishAccess(id: string, body: WorkspaceBody): Promise { + try { + const resource = await getResource(id); if (!resource) return; + const { session } = await getSession(id); + const { lockedThreads } = await import('./channelDataKeys'); + const locked = await lockedThreads(resource, session, body.structure.channels); + if (current.get(id) === body) publishWorkspace(id, { t: 'access', locked }); + } catch { /* 账号没解锁或离线:不改变现有状态 */ } } const republishing = new Set(); async function loadAvatars(id: string, body: WorkspaceBody): Promise { @@ -54,8 +65,11 @@ export function cachedPerson(id: string, username: string): WorkspaceBody['peopl return current.get(id)?.people.find(person => person.username === username); } export async function loadWorkspace(id: string): Promise { + const scope = currentResourceScope(); const { session } = await getSession(id); const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise); + // 请求期间换了账号:这份结构是上一个账号的视角,不能再发给界面或写进缓存 + if (currentResourceScope() !== scope) throw new Error('账号已切换'); return publish(id, body); } /** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */ diff --git a/web/src/core-bridge/workerClient.ts b/web/src/core-bridge/workerClient.ts index 7c2a85d..d3a1b53 100644 --- a/web/src/core-bridge/workerClient.ts +++ b/web/src/core-bridge/workerClient.ts @@ -1,6 +1,6 @@ -import type { ShareEvent, WorkspaceEvent } from './types'; +import type { ShareEvent, SpaceEvent, WorkspaceEvent } from './types'; -type BridgeEvent = WorkspaceEvent | ShareEvent; +type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent; type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void }; let worker: Worker | undefined; diff --git a/web/src/features/channel/ChatPane.module.css b/web/src/features/channel/ChatPane.module.css index 0a46372..b31af08 100644 --- a/web/src/features/channel/ChatPane.module.css +++ b/web/src/features/channel/ChatPane.module.css @@ -158,6 +158,17 @@ content: ''; } +/* 「上次看到这里」:同日期分隔线的版式,用危险色标出新消息从哪开始 */ +.seenDivider { + color: var(--danger); +} + +.seenDivider::before, +.seenDivider::after { + background: var(--danger); + opacity: 0.5; +} + .fileCard { display: flex; align-items: center; diff --git a/web/src/features/channel/ChatPane.tsx b/web/src/features/channel/ChatPane.tsx index d7b4cf1..d80c99f 100644 --- a/web/src/features/channel/ChatPane.tsx +++ b/web/src/features/channel/ChatPane.tsx @@ -1,21 +1,23 @@ import { Fragment, useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react'; import { Button } from '@/ui/Button'; +import { useAccount } from '@/store/account'; import { useWorkspace } from '@/store/workspace'; import type { Item } from '@/core-bridge/types'; import { formatDate } from '@/lib/format'; import { ChatMessage } from './ChatMessage'; import styles from './ChatPane.module.css'; -interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void } +interface Props { threadName: string; /** 进入频道时的已读位置:之后别人发的第一条消息前画「上次看到这里」 */ lastSeen?: number; ids: string[]; enabled: boolean; onSend: (text: string) => Promise; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void } const MAX_INPUT_HEIGHT = 160; -export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) { +export function ChatPane({ threadName, lastSeen, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) { const [text, setText] = useState(''); const messages = useRef(null); const input = useRef(null); const fileInput = useRef(null); const byId = useWorkspace((s) => s.byId); + const self = useAccount((s) => s.username); const chronological = ids.slice().reverse(); useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]); @@ -62,6 +64,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil {chronological.length === 0 ?

这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。

: null} {(() => { let previous: Item | undefined; + let seenShown = false; return chronological.map((id) => { const item = byId[id]; if (!item) return null; @@ -69,10 +72,14 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil const previousDate = previous ? new Date(previous.ts).toDateString() : undefined; const showDate = dateKey !== previousDate; const sameSender = previous && previous.author?.username === item.author?.username; - const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000); + // 分隔线只画在「别人发的第一条新消息」前,而且前面得有已读的消息,否则没有意义 + const showSeen = !seenShown && lastSeen !== undefined && item.ts > lastSeen && previous !== undefined && item.localId === undefined && item.author?.username !== self; + if (showSeen) seenShown = true; + const grouped = Boolean(sameSender && !showDate && !showSeen && item.ts - previous!.ts < 5 * 60_000); previous = item; return {showDate ?
{formatDate(item.ts)}
: null} + {showSeen ?
上次看到这里
: null}
; }); diff --git a/web/src/features/channel/MembersPane.tsx b/web/src/features/channel/MembersPane.tsx index 1e12829..2e5fed8 100644 --- a/web/src/features/channel/MembersPane.tsx +++ b/web/src/features/channel/MembersPane.tsx @@ -5,7 +5,8 @@ import { errorMessage } from '@/core-bridge/errors'; import type { SenderInfo } from '@/core-bridge/types'; import { useToast } from '@/store/toast'; import { useWorkspace } from '@/store/workspace'; -import { permissionsOf } from '@/lib/roles'; +import { formatSeen } from '@/lib/format'; +import { OWNER_ROLE_ID, permissionsOf } from '@/lib/roles'; import { useAccount } from '@/store/account'; import { accountApi } from '@/core-bridge/account'; import { Button } from '@/ui/Button'; @@ -32,6 +33,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { const maxMembers = useWorkspace((s) => s.channel?.maxMembers); const people = useWorkspace((s) => s.people); const roles = useWorkspace((s) => s.roles); + const creator = useWorkspace((s) => s.channel?.creatorAccount); const self = useAccount((s) => s.username); const listInvitations = useWorkspace((s) => s.listInvitations); const revoke = useWorkspace((s) => s.revokeInvitation); @@ -69,12 +71,15 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { const online = members.filter((member) => member.online); const offline = members.filter((member) => !member.online); + // 所在的身份组名(空间主在固定的 Owner 组里);待接受的邀请还没有身份 + const groupsOf = (username: string) => roles.filter((role) => (role.id === OWNER_ROLE_ID ? username === creator : role.members.includes(username))).map((role) => role.name); const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => { const person = people[username]; const name = person?.displayName || username; + const groups = invited ? [] : groupsOf(username); return
  • setViewing(username)} /> - {name} + {name}{groups.map((group) => {group})} {detail || name !== username ? {[name !== username ? username : '', detail].filter(Boolean).join(' · ')} : null} {isOnline ? : null} {removable ? : null} @@ -82,8 +87,9 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { }; // 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人 const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove'))); + // 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录) const member = (item: ChannelMember) => row(item.username, - item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined, + !item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined, false, removable(item), item.role === 'owner', item.online); const viewed = viewing ? people[viewing] : undefined; diff --git a/web/src/features/channel/SpaceRail.module.css b/web/src/features/channel/SpaceRail.module.css index f174bb6..a0f459b 100644 --- a/web/src/features/channel/SpaceRail.module.css +++ b/web/src/features/channel/SpaceRail.module.css @@ -109,3 +109,28 @@ border-radius: var(--radius-md); object-fit: cover; } + +.slot { + position: relative; + display: flex; + flex: 0 0 auto; +} + +/* 有新动静的空间:头像右上角的红点 */ +.dot { + position: absolute; + top: -4px; + right: -4px; + min-width: 18px; + height: 18px; + padding: 0 var(--space-1); + border: 2px solid var(--bg); + border-radius: var(--radius-md); + background: var(--danger); + color: var(--on-danger); + font-size: 10px; + font-weight: 600; + line-height: 14px; + text-align: center; + pointer-events: none; +} diff --git a/web/src/features/channel/SpaceRail.tsx b/web/src/features/channel/SpaceRail.tsx index 64489aa..9562890 100644 --- a/web/src/features/channel/SpaceRail.tsx +++ b/web/src/features/channel/SpaceRail.tsx @@ -1,4 +1,6 @@ import { useChannels } from '@/store/channels'; +import { spaceSeqKey, useReadState } from '@/store/readState'; +import { useSpaceReads } from './useThreadReads'; import { useAccount } from '@/store/account'; import { useWorkspace } from '@/store/workspace'; import { channelPath, navigate } from '@/app/navigation'; @@ -11,13 +13,19 @@ interface RailProps { id?: string; onCreate: () => void; onJoin: () => void } export function SpaceRail({ id, onCreate, onJoin }: RailProps) { const channels = useChannels((s) => s.list); const username = useAccount((s) => s.username); + const reads = useReadState((s) => s.reads); + useSpaceReads(id); return
  • )}; } diff --git a/web/src/features/channel/TopBar.module.css b/web/src/features/channel/TopBar.module.css index dc29691..754ee61 100644 --- a/web/src/features/channel/TopBar.module.css +++ b/web/src/features/channel/TopBar.module.css @@ -70,9 +70,12 @@ color: var(--ok); } +/* 明文提示淡化成小字:顶栏底下那条警示色线仍在,不靠这块标签抢眼 */ .badgeWarn { - background: var(--warn-soft); - color: var(--warn); + padding-inline: 0; + color: var(--text-dim); + font-size: 11px; + opacity: 0.8; } .presence { diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx index 1cac62a..b3d20b4 100644 --- a/web/src/features/channel/Workspace.tsx +++ b/web/src/features/channel/Workspace.tsx @@ -19,6 +19,7 @@ import { useAccount } from '@/store/account'; import { permissionsOf } from '@/lib/roles'; import { ThreadSidebar } from './ThreadSidebar'; import { ChatPane } from './ChatPane'; +import { useThreadReads } from './useThreadReads'; import { FileExplorer } from './FileExplorer'; import { FilePaneResize } from './FilePaneResize'; import { InviteSheet } from './InviteSheet'; @@ -78,7 +79,10 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const [memberRevision, setMemberRevision] = useState(0); const [fileWidth, setFileWidth] = useState(); const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>(); - const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1]; + const locked = useWorkspace((s) => s.lockedThreads); + // 没有密钥的频道不能打开:优先用选中的,其次「常规」,再其次最新的可用频道 + const usable = threads.filter((thread) => !locked.includes(thread.id)); + const activeThread = usable.find((thread) => thread.id === threadId) ?? usable.find((thread) => thread.name === '常规') ?? usable[usable.length - 1]; const activeThreadId = activeThread?.id; const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId); const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined; @@ -98,6 +102,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const item = s.byId[id]; return item?.kind === 'file' && item.metadataHidden; }))); + const { unread, marker } = useThreadReads(channel?.id, threads.map((thread) => thread.id), activeThreadId); + const requestFiles = useCallback((incoming: File[], destination?: { folderId?: string }) => { const target = destination ? destination.folderId : activeFolderId; if (incoming.length && activeThreadId && enabled) setUpload({ files: incoming, threadId: activeThreadId, ...(target ? { folderId: target } : {}) }); @@ -131,11 +137,11 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const channelId = channel?.id; useEffect(() => { setDrawerOpen(false); }, [channelId]); const rail = setScanOpen(true)} />; - const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />; const navigation = <>{threadSidebar}{desktop ? : null}; - const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; + const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const explorer = <> item.localId !== undefined || Boolean(self && item.author?.username === self); +const countable = (item: Item) => !(item.kind === 'file' && item.metadataHidden); + +function useVisible(): boolean { + const [visible, setVisible] = useState(() => document.visibilityState === 'visible'); + useEffect(() => { + const update = () => setVisible(document.visibilityState === 'visible'); + document.addEventListener('visibilitychange', update); + return () => document.removeEventListener('visibilitychange', update); + }, []); + return visible; +} + +/** + * 频道未读:别人发的、晚于「读到哪里」的消息算未读。 + * 正在看的频道(页面可见时)随新消息一起标为已读;marker 是进入频道那一刻的旧位置, + * 聊天里据此画「上次看到这里」,离开频道前保持不动。 + */ +export function useThreadReads(spaceId: string | undefined, threadIds: readonly string[], activeThreadId: string | undefined) { + const self = useAccount((s) => s.username); + const reads = useReadState((s) => s.reads); + const visible = useVisible(); + const activeKey = spaceId && activeThreadId ? readKey(spaceId, activeThreadId) : undefined; + const [marker, setMarker] = useState<{ key: string; ts: number }>(); + + const unread = useWorkspace(useShallow((s) => { + const out: Record = {}; + if (!spaceId) return out; + for (const id of s.order) { + const item = s.byId[id]; + if (!item?.threadId || !countable(item) || isMine(item, self)) continue; + const since = reads[readKey(spaceId, item.threadId)]; + if (since !== undefined && item.ts > since) out[item.threadId] = (out[item.threadId] ?? 0) + 1; + } + return out; + })); + const latest = useWorkspace((s) => { + let ts = 0; + for (const id of s.order) { const item = s.byId[id]; if (item && item.threadId === activeThreadId && countable(item) && item.ts > ts) ts = item.ts; } + return ts; + }); + + // 第一次见到的频道以此刻为基线 + const idsKey = threadIds.join(','); + useEffect(() => { + if (!spaceId) return; + for (const id of idsKey ? idsKey.split(',') : []) useReadState.getState().baseline(readKey(spaceId, id)); + }, [spaceId, idsKey]); + + // 进入频道:记住旧位置 + useEffect(() => { + if (!activeKey) { setMarker(undefined); return; } + useReadState.getState().baseline(activeKey); + setMarker({ key: activeKey, ts: useReadState.getState().reads[activeKey] ?? Date.now() }); + }, [activeKey]); + + // 正在看:标为已读 + useEffect(() => { + if (activeKey && visible && latest) useReadState.getState().markRead(activeKey, latest); + }, [activeKey, visible, latest]); + + return { unread, marker: marker && marker.key === activeKey ? marker.ts : undefined }; +} + +/** + * 空间列表上的红点:别的空间的日志序号比上次离开时更大。 + * 序号包含自己在其他设备发的消息和结构变动,所以只做「有新动静」的提示,不数条数。 + * 当前空间随列表刷新一直算已读;离开空间时再刷一次,把刚发的消息也算进去。 + */ +export function useSpaceReads(currentId: string | undefined) { + const channels = useChannels((s) => s.list); + const previous = useRef(undefined); + + useEffect(() => { + for (const channel of channels) { + if (channel.lastSeq !== undefined) useReadState.getState().baseline(spaceSeqKey(channel.id), channel.lastSeq); + if (channel.id === currentId && channel.lastSeq !== undefined) useReadState.getState().markRead(spaceSeqKey(channel.id), channel.lastSeq); + } + }, [channels, currentId]); + + useEffect(() => { + const left = previous.current; + previous.current = currentId; + if (!left || left === currentId) return; + void useChannels.getState().refresh().then(() => { + const seq = useChannels.getState().list.find((channel) => channel.id === left)?.lastSeq; + if (seq !== undefined) useReadState.getState().markRead(spaceSeqKey(left), seq); + }).catch(() => undefined); + }, [currentId]); + + // 实时:服务端把账号所在空间的序号变化推过来,直接改列表里的 lastSeq + useEffect(() => { + const stop = subscribeWorker('__spaces', (event) => { + if (event.t !== 'space-seq') return; + useChannels.setState((state) => ({ list: state.list.map((channel) => (channel.id === event.id && (channel.lastSeq ?? 0) < event.lastSeq ? { ...channel, lastSeq: event.lastSeq } : channel)) })); + }); + void callWorker('watchSpaces').catch(() => undefined); + return () => { stop(); void callWorker('stopWatchingSpaces').catch(() => undefined); }; + }, []); +} diff --git a/web/src/features/home/DeviceSettings.tsx b/web/src/features/home/DeviceSettings.tsx index edee36d..2430002 100644 --- a/web/src/features/home/DeviceSettings.tsx +++ b/web/src/features/home/DeviceSettings.tsx @@ -1,19 +1,13 @@ import { useEffect, useState, type KeyboardEvent } from 'react'; import { accountApi, type AccountDevice } from '@/core-bridge/account'; import { errorMessage } from '@/core-bridge/errors'; -import { formatDate, formatAgo } from '@/lib/format'; +import { formatSeen } from '@/lib/format'; import { useAccount } from '@/store/account'; import { useToast } from '@/store/toast'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './AccountSettings.module.css'; -/** last_seen 是 Unix 秒;一天内显示「N 分钟前」,更早显示日期 */ -function seenLabel(seconds: number): string { - const ts = seconds < 1e12 ? seconds * 1000 : seconds; - return Date.now() - ts < 24 * 3600_000 ? formatAgo(ts) : formatDate(ts); -} - export function DeviceSettings() { const refreshAccount = useAccount((s) => s.refresh); const [devices, setDevices] = useState([]); @@ -61,7 +55,7 @@ export function DeviceSettings() { {editing === device.id ? setDraft(event.target.value)} onBlur={() => save(device)} onKeyDown={(event) => keyDown(event, device)} /> : {device.name}{device.current ? 本机 : null}} - {device.current ? '正在使用' : `最近使用 ${seenLabel(device.last_seen)}`} + {device.current ? '正在使用' : `最近使用 ${formatSeen(device.last_seen)}`}