From 78b4e2aa633a659a063ddd8c7077524c7a153af1 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 21:31:15 +0800 Subject: [PATCH 1/6] fix(server): trim every workspace structure response to the caller's grants and restrict avatars to shared spaces - structure mutations returned the full structure to any member who could call them (moving an item after sending a message exposed channels the member has no access to) - moving items now requires write access to the destination channel; new folders require an existing channel - account avatars are only readable by the owner or accounts sharing a space --- server/src/WebDrop.Api/Endpoints.Accounts.cs | 6 ++-- server/src/WebDrop.Core/ChannelWorkspace.cs | 36 ++++++++++++++++--- .../WebDrop.Core.Tests/ChannelCryptoTests.cs | 27 ++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index 299b23e..bae5618 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -14,9 +14,11 @@ public static partial class Endpoints return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile); }); // 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号 - api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx) => + api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx, ChannelService svc) => { - if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired); + if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired); + // 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取 + if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound); var profile = accounts.Profile(NormalizeUsername(username)); var full = ctx.Request.Query["size"] == "full"; return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}"); diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 7129723..6787d6b 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -6,6 +6,10 @@ public sealed partial class ChannelService { foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id); } + /// 两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放 + public bool SharesSpace(string username, string other) => username == other + || store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Any(id => tx.GetChannelMember(id, other) is not null)); + public IReadOnlyList AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs)); public IReadOnlyList AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id => { var channel = tx.GetChannel(id)!; @@ -35,9 +39,18 @@ public sealed partial class ChannelService var (space, principal) = Authenticate(tx, id, creds, Membership.Required); var state = tx.GetWorkspace(id) ?? new WorkspaceState(); EnsureDefaultRole(state, clock.NowMs); + return ViewFor(tx, space, principal, state); + }); + /// + /// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。 + /// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置) + /// 都会把整份结构返回给他。 + /// + private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state) + { if (principal.AccountName != space.CreatorAccount) { - var crypto = tx.GetWorkspaceCrypto(id); + var crypto = tx.GetWorkspaceCrypto(space.Id); var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList(); var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet(); state.Channels.RemoveAll(c => !ids.Contains(c.Id)); @@ -49,9 +62,9 @@ public sealed partial class ChannelService state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId)); state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId)); } - state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList(); + state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(space.Id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList(); return state; - }); + } private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action change, bool requireAccount = true, string? permission = null) { var state = store.Write(tx => @@ -68,7 +81,8 @@ public sealed partial class ChannelService ValidateWorkspace(current); current.Revision++; tx.PutWorkspace(id, current); - return current; + // 先落库完整的结构,再按调用者裁剪后返回 + return ViewFor(tx, channel, principal, current); }); events.SettingsChanged(id); return state; @@ -166,12 +180,24 @@ public sealed partial class ChannelService }); private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound); public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) => - state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) })); + { + FindChannel(state, threadId); + state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }); + }); public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) => (state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name)); public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) => { if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest); + // 目的频道必须存在,而且成员只能往自己有写入授权的频道放条目 + if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound); + var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id); + // 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权 + if (principal.Role != Role.Owner && principal.AccountName != space.CreatorAccount && (space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0)) + { + var grant = crypto.Access.Find(a => a.ChannelId == threadId && a.Username == principal.AccountName); + if (grant is null || !(grant.ChatWrite || grant.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden); + } foreach (var itemId in itemIds) { if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest); diff --git a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs index 6530b33..13e8455 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelCryptoTests.cs @@ -114,6 +114,33 @@ public sealed class ChannelCryptoTests Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event); } + [Fact] + public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites() + { + using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep); + var defaultId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id; + Bootstrap(env, space, owner, defaultId); + var secret = env.Service.CreateWorkspaceChannelResult(space.Id, owner, "secret", null).Channel.Id; + Bootstrap(env, space, owner, secret); + var hidden = TestEnv.NewId().ToBase64Url(); + env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hidden, ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "k" }); + + var plan = env.Service.InvitationPlan(space.Id, owner, "member"); + var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope + { ChannelId = plan.ChannelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList(); + env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages); + var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member); + + // 结构里没有没授权的频道;密钥包、对象登记里也没有它的任何东西 + Assert.Equal(new[] { defaultId }, env.Service.GetWorkspace(space.Id, member).Channels.Select(c => c.Id)); + var crypto = env.Service.ReadCrypto(space.Id, member); + Assert.DoesNotContain(crypto.Envelopes, e => e.ChannelId == secret); + Assert.DoesNotContain(crypto.Objects, o => o.ChannelId == secret); + Assert.DoesNotContain(crypto.Access, a => a.ChannelId == secret); + // 也不能往没授权的频道登记对象 + Assert.Throws(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "x" })); + } + private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId) { foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest From 5043c798ff78f806aa0c2415e4bbdce349732031 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 21:31:15 +0800 Subject: [PATCH 2/6] feat(server): member last seen and a live per-account space activity stream --- server/src/WebDrop.Api/ApiJson.cs | 3 ++ server/src/WebDrop.Api/ChannelHub.cs | 23 +++++++++- .../WebDrop.Api/Endpoints.ChannelAccess.cs | 42 ++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 801fd30..8224927 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -159,6 +159,9 @@ public sealed class MemberBody /// 该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。 [JsonPropertyName("devices")] public required int Devices { get; init; } [JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; } + + /// 该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。 + [JsonPropertyName("last_seen")] public required long LastSeen { get; init; } } public sealed class AccountProfileRequest diff --git a/server/src/WebDrop.Api/ChannelHub.cs b/server/src/WebDrop.Api/ChannelHub.cs index 61ca094..2ea60a0 100644 --- a/server/src/WebDrop.Api/ChannelHub.cs +++ b/server/src/WebDrop.Api/ChannelHub.cs @@ -12,6 +12,10 @@ public sealed class ChannelHub : IChannelEvents private readonly object _gate = new(); private readonly Dictionary _signals = []; + // 账号级的「有频道前进了」信号:不管有没有人订阅这个频道都会记录,供空间列表上的未读提示用 + private readonly Dictionary _advanced = []; + private TaskCompletionSource _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously); + // 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。 private readonly Dictionary>> _presence = []; @@ -66,7 +70,24 @@ public sealed class ChannelHub : IChannelEvents public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke(); } - public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq)); + public void Advanced(Id16 channelId, long seq) + { + TaskCompletionSource old; + lock (_gate) + { + _advanced[channelId] = Math.Max(_advanced.GetValueOrDefault(channelId), seq); + old = _anyAdvanced; + _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + old.TrySetResult(); + Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq)); + } + + /// 先取这个任务、再读 ,最后等待任务:不会漏掉中间的前进。 + public Task AnyAdvanced { get { lock (_gate) return _anyAdvanced.Task; } } + + /// 本进程启动以来该频道见到的最大序号;没有见到过为 0。 + public long AdvancedSeq(Id16 channelId) { lock (_gate) return _advanced.GetValueOrDefault(channelId); } public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++); public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++); public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true); diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs index 772df9a..dcd0050 100644 --- a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -12,6 +12,44 @@ public static partial class Endpoints if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo); }); + // 空间列表的实时提示:账号所在空间有新内容就推一条 {id,last_seq}。只含序号,不含内容 + api.MapGet("/accounts/spaces/events", async (HttpContext ctx, ChannelService svc, ChannelHub hub) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + var ct = ctx.RequestAborted; + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; + await ctx.Response.StartAsync(ct); + var sent = new Dictionary(); + try + { + while (!ct.IsCancellationRequested) + { + // 每次唤醒都重新核对登录:退出登录、被移除设备后,这条流立刻结束 + if (!TryAccount(ctx, accounts, out var current) || current != username) + { + await ctx.Response.WriteAsync("event: error\ndata: {\"error\":\"account_required\"}\n\n", ct); + break; + } + var changed = hub.AnyAdvanced; + foreach (var id in svc.AccountSpaceIds(username)) + { + var seq = hub.AdvancedSeq(id); + // 第一次只记下当前值:连接建立前的进展客户端已经从列表里拿到了 + if (!sent.TryGetValue(id, out var known)) { sent[id] = seq; continue; } + if (seq <= known) continue; + sent[id] = seq; + await ctx.Response.WriteAsync($"event: space\ndata: {{\"id\":\"{id.ToBase64Url()}\",\"last_seq\":{seq}}}\n\n", ct); + } + await ctx.Response.Body.FlushAsync(ct); + var idle = Task.Delay(TimeSpan.FromSeconds(20), ct); + // 久无变化就发心跳,客户端据此判断连接还活着 + if (await Task.WhenAny(changed, idle) == idle) { await ctx.Response.WriteAsync("event: ping\ndata: 1\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } + } + } + catch (OperationCanceledException) { } + return Results.Empty; + }); api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); @@ -54,8 +92,10 @@ public static partial class Endpoints { var (online, onlineDevices) = hub.Presence(channelId, member.Username); // 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备 + var devices = accounts.Devices(member.Username, null); return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt, - Online = online, Devices = Math.Max(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices }; + Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices, + LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen) }; }).ToList(); return Json(members, ApiJson.Default.ListMemberBody); }); From d1ff8bab7d6e64f603098bc4c4b5e76a5f36d004 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 21:31:15 +0800 Subject: [PATCH 3/6] feat(web): role groups, last seen and unread markers in members, channels and spaces; lock channels without keys --- web/src/core-bridge/types.ts | 7 ++ web/src/core-bridge/worker.ts | 3 + web/src/core-bridge/worker/channelDataKeys.ts | 8 ++ web/src/core-bridge/worker/port.ts | 4 +- web/src/core-bridge/worker/spaceWatch.ts | 64 ++++++++++ web/src/core-bridge/worker/wire.ts | 1 + .../core-bridge/worker/workspaceStructure.ts | 16 ++- web/src/core-bridge/workerClient.ts | 4 +- web/src/features/channel/ChatPane.module.css | 11 ++ web/src/features/channel/ChatPane.tsx | 13 ++- web/src/features/channel/MembersPane.tsx | 12 +- web/src/features/channel/SpaceRail.module.css | 25 ++++ web/src/features/channel/SpaceRail.tsx | 16 ++- .../features/channel/ThreadSidebar.module.css | 18 +++ web/src/features/channel/ThreadSidebar.tsx | 18 ++- web/src/features/channel/TopBar.module.css | 7 +- web/src/features/channel/Workspace.tsx | 12 +- web/src/features/channel/useThreadReads.ts | 109 ++++++++++++++++++ web/src/features/home/DeviceSettings.tsx | 10 +- web/src/lib/format.ts | 6 + web/src/store/readState.ts | 43 +++++++ web/src/store/reduce.ts | 5 + web/src/styles/tokens.css | 3 + 23 files changed, 381 insertions(+), 34 deletions(-) create mode 100644 web/src/core-bridge/worker/spaceWatch.ts create mode 100644 web/src/features/channel/useThreadReads.ts create mode 100644 web/src/store/readState.ts 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)}`}