Merge master into feat/file-api

This commit is contained in:
justme committed 2026-10-19 09:56:30 +08:00
commit d3da3597cc
34 files changed
+551 -52

No files matched your search

+5 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"x-backend-contract-sha256": "1b3b8d56bf4262d61d7c2814fcd788b09225ccc94120084673ea46de46c09593",
"x-backend-contract-sha256": "6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964",
"$defs": {
"AccountChannelBody": {
"type": "object",
@@ -1090,6 +1090,9 @@
"joined_at": {
"type": "integer"
},
"last_seen": {
"type": "integer"
},
"online": {
"type": "boolean"
},
@@ -1106,6 +1109,7 @@
"required": [
"devices",
"joined_at",
"last_seen",
"online",
"online_devices",
"role",
@@ -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")}");
@@ -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<Id16, long>();
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);
});
+3
View File
@@ -186,6 +186,9 @@ public sealed class MemberBody
/// <summary>该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。</summary>
[JsonPropertyName("devices")] public required int Devices { get; init; }
[JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; }
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
}
public sealed class AccountProfileRequest
+22 -1
View File
@@ -12,6 +12,10 @@ public sealed class ChannelHub : IChannelEvents
private readonly object _gate = new();
private readonly Dictionary<Id16, Signal> _signals = [];
// 账号级的「有频道前进了」信号:不管有没有人订阅这个频道都会记录,供空间列表上的未读提示用
private readonly Dictionary<Id16, long> _advanced = [];
private TaskCompletionSource _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously);
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _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));
}
/// <summary>先取这个任务、再读 <see cref="AdvancedSeq"/>,最后等待任务:不会漏掉中间的前进。</summary>
public Task AnyAdvanced { get { lock (_gate) return _anyAdvanced.Task; } }
/// <summary>本进程启动以来该频道见到的最大序号;没有见到过为 0。</summary>
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);
@@ -12,7 +12,7 @@ public static class AvatarData
/// <summary>缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB</summary>
public const int MaxThumbStaticChars = 100_000;
public const int MaxThumbGifChars = 500_000;
public const int MaxThumbGifChars = 800_000;
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
@@ -6,6 +6,10 @@ public sealed partial class ChannelService
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
/// <summary>两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放</summary>
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<Id16> AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs));
public IReadOnlyList<ChannelInfo> 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);
});
/// <summary>
/// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。
/// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置)
/// 都会把整份结构返回给他。
/// </summary>
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<IResourceTx, WorkspaceState, Principal> 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);
@@ -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<WebDropException>(() => 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
+2 -1
View File
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit.
// backend-contract-sha256: 1b3b8d56bf4262d61d7c2814fcd788b09225ccc94120084673ea46de46c09593
// backend-contract-sha256: 6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964
export interface AccountChannelBody {
channel_id: string;
@@ -224,6 +224,7 @@ export interface KeyPackageRequest {
export interface MemberBody {
devices: number;
joined_at: number;
last_seen: number;
online: boolean;
online_devices: number;
role: string;
+7
View File
@@ -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<WorkspaceFolder, 'parentId'> & { 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 }
+3
View File
@@ -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<unknown> {
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<typeof sendFiles>[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);
+27 -2
View File
@@ -233,13 +233,21 @@ export async function clearSavedLogin(): Promise<void> {
}
await forgetAccount(); await forgetLoginFields();
}
/** 原图超过这个大小(字符数)就分两步传:先传缩略图,头像立刻生效,原图在后台慢慢传(弱网下 MB 级的动图一次传完要等很久) */
const SPLIT_UPLOAD_CHARS = 600_000;
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const send = async (request: AccountProfileRequest) => {
const { body, headers: encoding } = await jsonBody(request);
return apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
};
const split = change && change !== 'keep' && change.avatar.length > SPLIT_UPLOAD_CHARS;
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
: split ? { displayName, keepAvatar: false, avatar: change.thumb, avatarThumb: change.thumb }
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
const { body, headers: encoding } = await jsonBody(request);
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
const value = await send(request);
stillActive(session);
const previous = session.personal?.avatar;
session.personal = personalProfile(value);
@@ -249,6 +257,23 @@ export async function updatePersonal(displayName: string, change?: AvatarChange)
primeAvatar(value.username, value.modifiedAt, shown);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
if (split) void uploadOriginal(session, displayName, change, send);
}
/** 后台补传原图;失败就保留缩略图当头像(预览时看到的是缩略图),不打扰用户。重试几次,间隔逐步拉长 */
async function uploadOriginal(session: NonNullable<typeof account>, displayName: string, change: { avatar: string; thumb: string }, send: (request: AccountProfileRequest) => Promise<AccountProfile>): Promise<void> {
for (const wait of [0, 5_000, 20_000, 60_000]) {
if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
if (account !== session) return;
try {
const value = await send({ displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb });
if (account !== session) return;
session.personal = { ...personalProfile(value), avatar: change.thumb, hasAvatar: true };
primeAvatar(value.username, value.modifiedAt, change.thumb);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
return;
} catch { /* 弱网或暂时失败:稍后重试 */ }
}
}
export async function listAccountDevices(): Promise<AccountDeviceBody[]> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise<AccountDeviceBody[]>); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
@@ -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<string[]> {
if (resource.mode !== 'enc') return [];
const owner = session.can('owner');
const missing = (keys: Record<string, string>) => 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<boolean> {
if (resource.mode !== 'enc') return true;
let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId);
+2 -2
View File
@@ -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 = () => {};
+64
View File
@@ -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<void> {
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;
}
+1
View File
@@ -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,
@@ -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<void> {
}));
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<void> {
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<string>();
async function loadAvatars(id: string, body: WorkspaceBody): Promise<void> {
@@ -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<WorkspaceBody> {
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<WorkspaceBody>);
// 请求期间换了账号:这份结构是上一个账号的视角,不能再发给界面或写进缓存
if (currentResourceScope() !== scope) throw new Error('账号已切换');
return publish(id, body);
}
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
+2 -2
View File
@@ -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;
@@ -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;
+10 -3
View File
@@ -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<void>; 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<void>; 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<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(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 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : 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 <Fragment key={id}>
{showDate ? <div className={styles.dateDivider} role="separator"><span>{formatDate(item.ts)}</span></div> : null}
{showSeen ? <div className={`${styles.dateDivider} ${styles.seenDivider}`} role="separator"><span>上次看到这里</span></div> : null}
<ChatMessage id={id} compact={grouped} onOpenFile={onOpenFile} onDelete={onDelete} />
</Fragment>;
});
+9 -3
View File
@@ -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 <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
<Avatar person={person} name={name} onOpen={() => setViewing(username)} />
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges></strong>
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges>{groups.map((group) => <em key={group}>{group}</em>)}</strong>
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : 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;
@@ -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;
}
+12 -4
View File
@@ -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 <nav className={styles.rail} aria-label="空间列表">
{channels.map((channel) => {
const name = channel.name || channel.id.slice(0, 8);
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
{channel.avatar ? <img className={styles.avatar} src={channel.avatar} alt="" /> : Array.from(name)[0]?.toLocaleUpperCase()}
</button>;
const fresh = channel.id !== id && channel.lastSeq !== undefined ? Math.max(0, channel.lastSeq - (reads[spaceSeqKey(channel.id)] ?? Infinity)) : 0;
return <div key={channel.id} className={styles.slot}>
<button type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={fresh ? `${name}(${fresh} 条新动静)` : name} onClick={() => navigate(channelPath(channel.id))}>
{channel.avatar ? <img className={styles.avatar} src={channel.avatar} alt="" /> : Array.from(name)[0]?.toLocaleUpperCase()}
</button>
{fresh ? <span className={styles.dot} aria-hidden="true">{fresh > 99 ? '99+' : fresh}</span> : null}
</div>;
})}
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
<button type="button" className={`${styles.space} ${styles.action}`} title="加入空间" aria-label="加入空间" onClick={onJoin}><Icon name="scan" size={20} /></button>
@@ -260,6 +260,24 @@
white-space: nowrap;
}
/* 未读:红底小数字,放在频道名右侧 */
.thread small.unread {
min-width: 16px;
padding: 0 var(--space-1);
border-radius: var(--radius-md);
background: var(--danger);
color: var(--on-danger);
font-size: 10px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.thread:has(.unread) {
color: var(--text);
font-weight: 600;
}
.folder {
padding-inline-start: var(--space-2);
}
+12 -6
View File
@@ -9,6 +9,10 @@ interface Props {
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
selectedThreadId?: string;
/** 每个频道的未读消息数 */
unread: Record<string, number>;
/** 没有密钥的频道:只显示名字,不能打开 */
locked: string[];
/** 能重命名频道、管理分组(空间主,或被授予「管理频道和分组」) */
enabled: boolean;
/** 新建频道涉及密钥,只有空间主 */
@@ -79,7 +83,7 @@ export function ThreadSidebar(props: Props) {
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
</span> : null}
</div>
{!collapsed.has(UNGROUPED) ? <ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
{!collapsed.has(UNGROUPED) ? <ChannelRows threads={ungrouped} unread={props.unread} locked={props.locked} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
</section> : null}
{props.groups.map((group) => {
@@ -91,7 +95,7 @@ export function ThreadSidebar(props: Props) {
{props.canCreate ? <Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} /> : null}
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
</span> : null}</div>
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
{!collapsed.has(group.id) ? <ChannelRows threads={threads} unread={props.unread} locked={props.locked} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
{!collapsed.has(group.id) && !threads.length ? <p className={styles.empty}>此分组还没有频道。</p> : null}
</section>;
@@ -127,12 +131,14 @@ export function ThreadSidebar(props: Props) {
</section>;
}
function ChannelRows({ threads, selectedId, enabled, busy, nested = false, onSelect, onEdit }: {
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; nested?: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
function ChannelRows({ threads, unread, locked, selectedId, enabled, busy, nested = false, onSelect, onEdit }: {
threads: ThreadInfo[]; unread: Record<string, number>; locked: string[]; selectedId?: string; enabled: boolean; busy: boolean; nested?: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
}) {
return <ul className={`${styles.threads} ${nested ? styles.nestedThreads : ''}`}>{threads.map((thread) => <li className={`${styles.threadRow} ${thread.id === selectedId ? styles.activeRow : ''}`} key={thread.id}>
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy} aria-current={thread.id === selectedId ? 'page' : undefined}
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span></button>
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy || locked.includes(thread.id)} aria-current={thread.id === selectedId ? 'page' : undefined} title={locked.includes(thread.id) ? '你还没有这个频道的访问授权' : undefined}
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span>
{locked.includes(thread.id) ? <Icon name="lock" size={12} /> : null}
{unread[thread.id] && thread.id !== selectedId ? <small className={styles.unread} role="img" aria-label={`${unread[thread.id]} 条未读`}>{unread[thread.id]! > 99 ? '99+' : unread[thread.id]}</small> : null}</button>
{enabled ? <Button className={styles.rowActions} variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
</li>)}</ul>;
}
+5 -2
View File
@@ -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 {
+9 -3
View File
@@ -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<number>();
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 = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory}
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} unread={unread} locked={locked} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} lastSeen={marker} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { Item, SpaceEvent } from '@/core-bridge/types';
import { callWorker, subscribeWorker } from '@/core-bridge/workerClient';
import { useAccount } from '@/store/account';
import { useChannels } from '@/store/channels';
import { readKey, spaceSeqKey, useReadState } from '@/store/readState';
import { useWorkspace } from '@/store/workspace';
const isMine = (item: Item, self: string | null | undefined) => 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<string, number> = {};
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<string | undefined>(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<SpaceEvent>('__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); };
}, []);
}
+2 -8
View File
@@ -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<AccountDevice[]>([]);
@@ -61,7 +55,7 @@ export function DeviceSettings() {
{editing === device.id
? <input autoFocus maxLength={80} aria-label={`${device.name} 的设备名称`} value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={() => save(device)} onKeyDown={(event) => keyDown(event, device)} />
: <strong>{device.name}{device.current ? <em className={styles.badge}>本机</em> : null}</strong>}
<small>{device.current ? '正在使用' : `最近使用 ${seenLabel(device.last_seen)}`}</small>
<small>{device.current ? '正在使用' : `最近使用 ${formatSeen(device.last_seen)}`}</small>
</div>
<Button variant="ghost" icon="edit" label={`重命名 ${device.name}`} disabled={busy} onMouseDown={(event) => event.preventDefault()} onClick={() => begin(device)} />
{device.current ? null : <Button variant="ghost" icon="trash" label={`移除设备 ${device.name}`} disabled={busy} onClick={() => remove(device)} />}
+7 -4
View File
@@ -87,12 +87,15 @@ export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, f
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
}
/** 动图的有损缩略图:帧数、颜色数和尺寸都压小(最多约 30 帧,64 色,80 像素),控制在 40 万字符以内。列表、聊天里默认用它 */
/**
* 动图的有损缩略图:尺寸和颜色压小,帧数尽量保持和原图一样(隔帧是最后的手段,会丢掉流畅度)。
* 控制在服务端上限(80 万字符)以内。列表、聊天里默认用它
*/
export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
for (const [size, maxFrames, colors] of [[80, 30, 64], [64, 24, 32], [48, 16, 32]] as const) {
for (const [size, step, colors] of [[80, 1, 64], [64, 1, 48], [64, 1, 32], [48, 1, 32], [64, 2, 32], [48, 2, 32], [40, 3, 16]] as const) {
await new Promise((resolve) => setTimeout(resolve, 0));
const url = encode(frames, crop, from, size, Math.max(1, Math.ceil(frames.length / maxFrames)), colors);
if (url.length <= 400_000) return url;
const url = encode(frames, crop, from, size, step, colors);
if (url.length <= 780_000) return url;
}
throw new Error('动图缩略图生成失败');
}
+6
View File
@@ -53,6 +53,12 @@ export function formatAgo(ts: number, now = Date.now()): string {
return `${Math.round(m / 60)} 小时前`;
}
/** 设备活跃时间:后端有时给 Unix 秒、有时给毫秒;一天内显示「N 分钟前」,更早显示日期 */
export function formatSeen(stamp: number): string {
const ts = stamp < 1e12 ? stamp * 1000 : stamp;
return Date.now() - ts < 24 * 3600_000 ? formatAgo(ts) : formatDate(ts);
}
/** 距离过期还剩多久。到期后返回「已过期」 */
export function formatRemaining(expiresAt: number, now = Date.now()): string {
const m = Math.round((expiresAt - now) / 60000);
+43
View File
@@ -0,0 +1,43 @@
import { create } from 'zustand';
/** 每个频道「读到哪里」,只存在这台设备的浏览器里(只是便利功能,取不到就当全读过) */
const STORAGE_KEY = 'wd.readState';
type Reads = Record<string, number>;
function load(): Reads {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
return parsed && typeof parsed === 'object' ? parsed as Reads : {};
} catch { return {}; }
}
function save(reads: Reads) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(reads)); } catch { /* 隐私模式等:这次会话内仍然有效 */ }
}
/** 空间级别读到的日志序号:别的空间 last_seq 超过它,就说明有新动静 */
export const spaceSeqKey = (spaceId: string) => `seq:${spaceId}`;
export const readKey =(spaceId: string, threadId: string) => `${spaceId}:${threadId}`;
interface ReadState {
reads: Reads;
/** 把频道标记为读到 ts;只前进不后退 */
markRead: (key: string, ts: number) => void;
/** 第一次见到的频道:以此刻为基线,不把历史消息算成未读 */
baseline: (key: string, value?: number) => void;
}
export const useReadState = create<ReadState>((set, get) => ({
reads: load(),
markRead: (key, ts) => {
const current = get().reads[key];
if (current !== undefined && current >= ts) return;
const reads = { ...get().reads, [key]: ts };
save(reads); set({ reads });
},
baseline: (key, value = Date.now()) => {
if (get().reads[key] !== undefined) return;
const reads = { ...get().reads, [key]: value };
save(reads); set({ reads });
},
}));
+5
View File
@@ -16,6 +16,8 @@ export interface WorkspaceData {
connection: ConnectionState;
channel: ChannelInfo | null;
threads: ThreadInfo[];
/** 没有密钥、不能读写的频道 id */
lockedThreads: string[];
groups: ChannelGroupInfo[];
roles: RoleInfo[];
folders: FolderInfo[];
@@ -35,6 +37,7 @@ export const initialData: WorkspaceData = {
connection: 'offline',
channel: null,
threads: [],
lockedThreads: [],
groups: [],
roles: [],
folders: [],
@@ -81,6 +84,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [],
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
}
case 'access':
return { ...s, lockedThreads: e.locked };
case 'reset':
return { ...initialData, connection: s.connection };
case 'error':
+3
View File
@@ -9,6 +9,7 @@
--text-dim: #6b6b6b;
--accent: #1a1a1a;
--on-accent: #fff;
--on-danger: #fff;
--ok: #1f7a4d;
--warn: #b25e00;
@@ -78,6 +79,7 @@
--text-dim: #8a8a8a;
--accent: #ececec;
--on-accent: #141414;
--on-danger: #141414;
--ok: #5bc48a;
--warn: #e0a050;
@@ -107,6 +109,7 @@
--text-dim: #8a8a8a;
--accent: #ececec;
--on-accent: #141414;
--on-danger: #141414;
--ok: #5bc48a;
--warn: #e0a050;