Compare commits
3
Commits
77c9017129
...
ba0b6c258a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0b6c258a | ||
|
|
96f92c2ba9 | ||
|
|
1a4069576b |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"x-backend-contract-sha256": "4e07231f729947308afe7da4fda9441ef0de27552771d85ad0586f06cdad0200",
|
"x-backend-contract-sha256": "a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd",
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"AccountChannelBody": {
|
"AccountChannelBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -294,6 +294,16 @@
|
|||||||
"ChannelInfo": {
|
"ChannelInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"avatar": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"can_manage_structure": {
|
"can_manage_structure": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -411,6 +421,16 @@
|
|||||||
"ChannelSettingsPatch": {
|
"ChannelSettingsPatch": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"avatar": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"delete_policy": {
|
"delete_policy": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,76 +7,78 @@ public static partial class Endpoints
|
|||||||
{
|
{
|
||||||
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
|
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
|
||||||
{
|
{
|
||||||
WorkspaceBody View(WorkspaceState state) => new()
|
// 成员资料:发过内容的作者加上空间里所有成员(没发过内容的成员也要能显示头像和名字)
|
||||||
|
WorkspaceBody View(ChannelService service, Id16 space, WorkspaceState state) => new()
|
||||||
{
|
{
|
||||||
Structure = state,
|
Structure = state,
|
||||||
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
|
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Select(u => u!)
|
||||||
.Select(u => accounts.Profile(u!)).ToList(),
|
.Concat(service.MemberAccounts(space)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(u => accounts.Profile(u)).ToList(),
|
||||||
};
|
};
|
||||||
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
||||||
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
|
Json(View(svc, ParseId(id), svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
|
||||||
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
||||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
|
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
|
||||||
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
||||||
});
|
});
|
||||||
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
||||||
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
|
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
|
||||||
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
|
return Json(new WorkspaceGroupCreatedBody { Workspace = View(svc, ParseId(id), created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
|
||||||
});
|
});
|
||||||
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
|
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
|
||||||
Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
|
Json(View(svc, ParseId(id), svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
|
||||||
api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
||||||
var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions);
|
var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions);
|
||||||
return Json(new WorkspaceRoleCreatedBody { Workspace = View(created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
|
return Json(new WorkspaceRoleCreatedBody { Workspace = View(svc, ParseId(id), created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
|
||||||
});
|
});
|
||||||
api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
|
||||||
return Json(View(svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) =>
|
api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) =>
|
||||||
Json(View(svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
|
Json(View(svc, ParseId(id), svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
|
||||||
api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct);
|
||||||
return Json(View(svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||||
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
return Json(View(svc, ParseId(id), svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
||||||
});
|
});
|
||||||
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
|
||||||
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
|
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,8 @@ public static partial class Endpoints
|
|||||||
|
|
||||||
api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
// 空间头像(动图)比其他设置大得多,单独放宽请求体上限
|
||||||
|
if (ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 4 * 1024 * 1024;
|
||||||
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
|
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
|
||||||
var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
|
var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
|
||||||
return Json(info, ApiJson.Default.ChannelInfo);
|
return Json(info, ApiJson.Default.ChannelInfo);
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ public interface IResourceTx
|
|||||||
void SetCompactedThrough(Id16 id, long seq);
|
void SetCompactedThrough(Id16 id, long seq);
|
||||||
long NextSeq(Id16 id);
|
long NextSeq(Id16 id);
|
||||||
void DeleteChannelRows(Id16 id);
|
void DeleteChannelRows(Id16 id);
|
||||||
|
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
|
||||||
|
string? GetChannelAvatar(Id16 id);
|
||||||
|
void SetChannelAvatar(Id16 id, string? avatar);
|
||||||
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
||||||
|
|
||||||
// 成员
|
// 成员
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace WebDrop.Core;
|
||||||
|
|
||||||
|
/// <summary>头像(账号头像、空间头像)的 data URL 校验。浏览器里已经裁剪好并重新编码,服务端只做格式、大小和文件头检查</summary>
|
||||||
|
public static class AvatarData
|
||||||
|
{
|
||||||
|
/// <summary>静态头像(256×256 WebP/PNG/JPEG)的长度上限(字符数)</summary>
|
||||||
|
public const int MaxStaticChars = 350_000;
|
||||||
|
/// <summary>账号动图头像保留全部帧,约 4.5 MB</summary>
|
||||||
|
public const int MaxAccountGifChars = 6_000_000;
|
||||||
|
/// <summary>空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB</summary>
|
||||||
|
public const int MaxSpaceGifChars = 2_000_000;
|
||||||
|
|
||||||
|
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
|
||||||
|
|
||||||
|
/// <summary>不合法时抛 BadRequest</summary>
|
||||||
|
public static void Validate(string avatar, int maxGifChars)
|
||||||
|
{
|
||||||
|
var prefix = Prefixes.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||||
|
var gif = prefix?.Contains("gif", StringComparison.Ordinal) == true;
|
||||||
|
if (prefix is null || avatar.Length > (gif ? maxGifChars : MaxStaticChars) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
byte[] bytes;
|
||||||
|
try { bytes = Convert.FromBase64String(avatar[prefix.Length..]); }
|
||||||
|
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||||
|
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,9 @@ public sealed partial class ChannelService
|
|||||||
events.MembersChanged(id);
|
events.MembersChanged(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料)</summary>
|
||||||
|
public IReadOnlyList<string> MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList());
|
||||||
|
|
||||||
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public sealed partial class ChannelService(
|
|||||||
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
return ToInfo(channel, tx.CountMembers(id), principal);
|
return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
|
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
|
||||||
@@ -80,6 +80,8 @@ public sealed partial class ChannelService(
|
|||||||
|
|
||||||
var updated = patch.ApplyTo(channel.Settings);
|
var updated = patch.ApplyTo(channel.Settings);
|
||||||
updated.Validate(limits);
|
updated.Validate(limits);
|
||||||
|
if (patch.Avatar is { Length: > 0 } newAvatar) AvatarData.Validate(newAvatar, AvatarData.MaxSpaceGifChars);
|
||||||
|
if (patch.Avatar is not null) tx.SetChannelAvatar(id, patch.Avatar.Length == 0 ? null : patch.Avatar);
|
||||||
|
|
||||||
long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds
|
long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds
|
||||||
? clock.NowMs + updated.TtlSeconds * 1000L
|
? clock.NowMs + updated.TtlSeconds * 1000L
|
||||||
@@ -91,7 +93,7 @@ public sealed partial class ChannelService(
|
|||||||
tx.SetJoinPassword(id, hash, salt);
|
tx.SetJoinPassword(id, hash, salt);
|
||||||
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
|
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
|
||||||
}
|
}
|
||||||
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
|
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatar: tx.GetChannelAvatar(id));
|
||||||
});
|
});
|
||||||
events.SettingsChanged(id);
|
events.SettingsChanged(id);
|
||||||
return info;
|
return info;
|
||||||
@@ -677,8 +679,9 @@ public sealed partial class ChannelService(
|
|||||||
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
|
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatar = null) => new()
|
||||||
{
|
{
|
||||||
|
Avatar = avatar,
|
||||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
|
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
|
||||||
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
||||||
Id = channel.Id.ToBase64Url(),
|
Id = channel.Id.ToBase64Url(),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed partial class ChannelService
|
|||||||
{
|
{
|
||||||
var channel = tx.GetChannel(id)!;
|
var channel = tx.GetChannel(id)!;
|
||||||
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
||||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username));
|
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatar(id));
|
||||||
}).ToList());
|
}).ToList());
|
||||||
private static WorkspaceState NewWorkspace(string username, long now)
|
private static WorkspaceState NewWorkspace(string username, long now)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public sealed class ChannelInfo
|
|||||||
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
|
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
|
||||||
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
|
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
|
||||||
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
|
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
|
||||||
|
/// <summary>空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。</summary>
|
||||||
|
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ public sealed class ChannelSettingsPatch
|
|||||||
[JsonPropertyName("max_items")] public int? MaxItems { get; init; }
|
[JsonPropertyName("max_items")] public int? MaxItems { get; init; }
|
||||||
[JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; }
|
[JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; }
|
||||||
[JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; }
|
[JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; }
|
||||||
|
/// <summary>空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。</summary>
|
||||||
|
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||||
|
|
||||||
public ChannelSettings ApplyTo(ChannelSettings s) => new()
|
public ChannelSettings ApplyTo(ChannelSettings s) => new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,22 +16,10 @@ public sealed partial class SqliteAccountStore
|
|||||||
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// <summary>动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB)</summary>
|
|
||||||
private const int MaxGifAvatarChars = 6_000_000;
|
|
||||||
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
||||||
{
|
{
|
||||||
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
if (avatar is not null)
|
if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars);
|
||||||
{
|
|
||||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
|
||||||
if (prefix is null || avatar.Length > (prefix.Contains("gif") ? MaxGifAvatarChars : 350_000) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = Convert.FromBase64String(avatar[prefix.Length..]);
|
|
||||||
if (prefix.Contains("gif") && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
|
||||||
}
|
|
||||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
|
||||||
}
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
|
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
|
||||||
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||||
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL) WITHOUT ROWID;
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,10 +503,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
|
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
|
||||||
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
|
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
|
||||||
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
|
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
|
||||||
|
Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", key));
|
||||||
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
|
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
|
||||||
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? GetChannelAvatar(Id16 id) => Scalar("SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
|
||||||
|
|
||||||
|
public void SetChannelAvatar(Id16 id, string? avatar)
|
||||||
|
{
|
||||||
|
if (avatar is null) Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray()));
|
||||||
|
else Run("INSERT INTO channel_avatars (space_id, avatar) VALUES (@c,@a) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar", ("@c", id.ToArray()), ("@a", avatar));
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
||||||
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
|
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
// Generated from backend DTOs; do not edit.
|
// Generated from backend DTOs; do not edit.
|
||||||
// backend-contract-sha256: 4e07231f729947308afe7da4fda9441ef0de27552771d85ad0586f06cdad0200
|
// backend-contract-sha256: a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd
|
||||||
|
|
||||||
export interface AccountChannelBody {
|
export interface AccountChannelBody {
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
@@ -72,6 +72,7 @@ export interface ChannelHistoryRequest {
|
|||||||
filesHistory: boolean;
|
filesHistory: boolean;
|
||||||
}
|
}
|
||||||
export interface ChannelInfo {
|
export interface ChannelInfo {
|
||||||
|
avatar?: string | null;
|
||||||
can_manage_structure: boolean;
|
can_manage_structure: boolean;
|
||||||
can_upload: boolean;
|
can_upload: boolean;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@@ -99,6 +100,7 @@ export interface ChannelSettings {
|
|||||||
ui_restrict_copy_download: boolean;
|
ui_restrict_copy_download: boolean;
|
||||||
}
|
}
|
||||||
export interface ChannelSettingsPatch {
|
export interface ChannelSettingsPatch {
|
||||||
|
avatar?: string | null;
|
||||||
delete_policy?: DeletePolicy | null;
|
delete_policy?: DeletePolicy | null;
|
||||||
file_max_downloads?: number | null;
|
file_max_downloads?: number | null;
|
||||||
join_password?: string | null;
|
join_password?: string | null;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ChannelInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** 空间头像(data URL,可以是 GIF 动图) */
|
||||||
|
avatar?: string;
|
||||||
mode: ChannelMode;
|
mode: ChannelMode;
|
||||||
role: 'owner' | 'member';
|
role: 'owner' | 'member';
|
||||||
maxMembers: number;
|
maxMembers: number;
|
||||||
@@ -51,6 +53,8 @@ export interface ChannelSettingsPatch {
|
|||||||
joinPolicy?: 'open' | 'invite';
|
joinPolicy?: 'open' | 'invite';
|
||||||
joinPassword?: string;
|
joinPassword?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** 空字符串表示移除头像 */
|
||||||
|
avatar?: string;
|
||||||
maxMembers?: number;
|
maxMembers?: number;
|
||||||
membersCanUpload?: boolean;
|
membersCanUpload?: boolean;
|
||||||
ttlSeconds?: number;
|
ttlSeconds?: number;
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
|||||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||||
const body: SettingsRequest = {};
|
const body: SettingsRequest = {};
|
||||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||||
|
if (patch.avatar !== undefined) body.avatar = patch.avatar;
|
||||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
const prepared = await prepareObject(resource, session, threadId, 'files');
|
const prepared = await prepareObject(resource, session, threadId, 'files');
|
||||||
const plan = session.encryptFile(file, {}, {
|
const plan = session.encryptFile(file, {}, {
|
||||||
itemId: prepared.itemIdBytes,
|
itemId: prepared.itemIdBytes,
|
||||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
// 小文件(一帧放得下)且不限制次数:不设上限,加入者直接预览。其余都带上每个文件的下载次数(0 = 不限)
|
||||||
|
...(options.directPreview && file.size < maxFrameData - 1024 ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||||
ts, maxFrameData, threadId, folderId,
|
ts, maxFrameData, threadId, folderId,
|
||||||
});
|
});
|
||||||
item = {
|
item = {
|
||||||
@@ -95,9 +96,7 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
...(author ? { author } : {}),
|
...(author ? { author } : {}),
|
||||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
||||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||||
remaining: plan.gated
|
remaining: plan.gated ? options.maxDownloads || null : null,
|
||||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
|
||||||
: null,
|
|
||||||
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
||||||
};
|
};
|
||||||
job.itemId = item.id;
|
job.itemId = item.id;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
|||||||
creatorAccount: raw.creator_account ?? undefined,
|
creatorAccount: raw.creator_account ?? undefined,
|
||||||
joinPolicy, passwordRequired: raw.password_required,
|
joinPolicy, passwordRequired: raw.password_required,
|
||||||
name: raw.settings.name ?? undefined,
|
name: raw.settings.name ?? undefined,
|
||||||
|
avatar: raw.avatar ?? undefined,
|
||||||
mode: raw.mode,
|
mode: raw.mode,
|
||||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||||
canUpload: raw.can_upload,
|
canUpload: raw.can_upload,
|
||||||
|
|||||||
@@ -146,3 +146,37 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatarRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 64px;
|
||||||
|
place-items: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-2);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarFallback {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarSide {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { Icon, type IconName } from '@/ui/Icon';
|
import { Icon, type IconName } from '@/ui/Icon';
|
||||||
import { Sheet } from '@/ui/Sheet';
|
import { Sheet } from '@/ui/Sheet';
|
||||||
|
import { AvatarEditor } from '../home/AvatarEditor';
|
||||||
|
import { checkAvatarSource } from '../home/avatarSource';
|
||||||
|
import { errorMessage } from '@/core-bridge/errors';
|
||||||
|
import { useToast } from '@/store/toast';
|
||||||
import { RolesEditor } from './RolesEditor';
|
import { RolesEditor } from './RolesEditor';
|
||||||
import styles from './ChannelSettingsSheet.module.css';
|
import styles from './ChannelSettingsSheet.module.css';
|
||||||
|
|
||||||
@@ -42,6 +46,10 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
const [joinPassword, setJoinPassword] = useState('');
|
const [joinPassword, setJoinPassword] = useState('');
|
||||||
const [clearPassword, setClearPassword] = useState(false);
|
const [clearPassword, setClearPassword] = useState(false);
|
||||||
const [tab, setTab] = useState<Tab>('basic');
|
const [tab, setTab] = useState<Tab>('basic');
|
||||||
|
const [avatar, setAvatar] = useState(channel.avatar);
|
||||||
|
const [editing, setEditing] = useState<File>();
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const spaceName = name.trim() || channel.name || '空间';
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
|
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
|
||||||
@@ -52,7 +60,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
try {
|
try {
|
||||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -62,7 +70,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirty = name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
async function chooseAvatar(file?: File) {
|
||||||
|
if (!file) return;
|
||||||
|
try { await checkAvatarSource(file); setEditing(file); }
|
||||||
|
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
||||||
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
|
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
|
||||||
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
|
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
|
||||||
|
|
||||||
@@ -74,8 +88,20 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
|
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
|
||||||
|
: <section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
||||||
{tab === 'basic' ? <>
|
{tab === 'basic' ? <>
|
||||||
|
<div className={styles.avatarRow}>
|
||||||
|
{avatar ? <img className={styles.avatar} src={avatar} alt="空间头像" /> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
|
||||||
|
<div className={styles.avatarSide}>
|
||||||
|
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择空间头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
|
||||||
|
<div className={styles.avatarButtons}>
|
||||||
|
<Button icon="image" disabled={!owner} onClick={() => fileInput.current?.click()}>更换空间头像</Button>
|
||||||
|
{avatar && owner ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||||
|
</div>
|
||||||
|
<span className={styles.note}>PNG、JPEG、WebP 或 GIF 动图,所有成员可见</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className={styles.field}><span>空间名称</span>
|
<label className={styles.field}><span>空间名称</span>
|
||||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} /></label>
|
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} /></label>
|
||||||
<p className={styles.note}>空间名称和设置对加入者及服务端可见。</p>
|
<p className={styles.note}>空间名称和设置对加入者及服务端可见。</p>
|
||||||
@@ -108,7 +134,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
||||||
</> : null}
|
</> : null}
|
||||||
{tab === 'roles' ? <RolesEditor /> : null}
|
{tab === 'roles' ? <RolesEditor /> : null}
|
||||||
</section>
|
</section>}
|
||||||
{tab === 'roles' ? null : owner ? (
|
{tab === 'roles' ? null : owner ? (
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
.slot {
|
||||||
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder,
|
||||||
|
.poster {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: zoom-in;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet {
|
||||||
|
max-height: 220px;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive {
|
||||||
|
max-height: 220px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { FileItem } from '@/core-bridge/types';
|
||||||
|
import { formatSize } from '@/lib/format';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import { ArchivePreview } from '../item/previews/ArchivePreview';
|
||||||
|
import { useBlobUrl } from '../item/useBlobUrl';
|
||||||
|
import styles from './ChatAttachment.module.css';
|
||||||
|
|
||||||
|
/** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
/** 不限制次数、不超过这个大小的,滚动到附近就自动加载;更大的显示占位,点一下才加载(整个文件要先解密到内存) */
|
||||||
|
const AUTO = { image: 30 * MB, video: 25 * MB, audio: 25 * MB, archive: 50 * MB, text: 128 * 1024 } as const;
|
||||||
|
/** 再大就不在聊天里展开了,只留文件卡片 */
|
||||||
|
const MAX = { image: 100 * MB, video: 500 * MB, audio: 500 * MB, archive: 200 * MB, text: 128 * 1024 } as const;
|
||||||
|
|
||||||
|
/** 滚动到附近才加载,一屏几十张图时不会一下子全部解密 */
|
||||||
|
function useNearViewport<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const [near, setNear] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element || near) return;
|
||||||
|
if (typeof IntersectionObserver === 'undefined') { setNear(true); return; }
|
||||||
|
const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNear(true); observer.disconnect(); } }, { rootMargin: '300px' });
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [near]);
|
||||||
|
return [ref, near];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不限制下载次数的文件直接预览;有上限的,读内容会消耗一次:不自动加载,点一次预览一次 */
|
||||||
|
const isFree = (item: FileItem) => item.remaining === null;
|
||||||
|
|
||||||
|
function Gate({ item, label, children }: { item: FileItem; label: string; children: () => React.ReactNode }) {
|
||||||
|
const auto = item.size <= (AUTO as Record<string, number>)[item.preview]!;
|
||||||
|
const [ref, near] = useNearViewport<HTMLDivElement>();
|
||||||
|
const [asked, setAsked] = useState(false);
|
||||||
|
const free = isFree(item);
|
||||||
|
return <div ref={ref} className={styles.slot}>
|
||||||
|
{free && auto ? (near ? children() : <div className={styles.placeholder} />)
|
||||||
|
: asked ? children()
|
||||||
|
: <button type="button" className={styles.poster} onClick={() => setAsked(true)}><Icon name="eye" size={20} /><span>{label} · {formatSize(item.size)}{free ? '' : ` · 消耗 1 次${typeof item.remaining === 'number' ? `(剩 ${item.remaining} 次)` : ''}`}</span></button>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Picture({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||||
|
const blob = useBlobUrl(item.id);
|
||||||
|
const [broken, setBroken] = useState(false);
|
||||||
|
if (blob.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
|
||||||
|
if (blob.status === 'error' || broken) return null;
|
||||||
|
return <button type="button" className={styles.picture} aria-label={`查看大图 ${item.name}`} onClick={onOpen}>
|
||||||
|
<img src={blob.url} alt={item.name} onError={() => setBroken(true)} />
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clip({ item }: { item: FileItem }) {
|
||||||
|
const blob = useBlobUrl(item.id);
|
||||||
|
if (blob.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
|
||||||
|
if (blob.status === 'error') return null;
|
||||||
|
return <video className={styles.video} src={blob.url} controls preload="metadata" playsInline />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sound({ item }: { item: FileItem }) {
|
||||||
|
const blob = useBlobUrl(item.id);
|
||||||
|
return blob.status === 'ready' ? <audio className={styles.audio} src={blob.url} controls /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINES = 12;
|
||||||
|
function Snippet({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||||
|
const blob = useBlobUrl(item.id);
|
||||||
|
const [text, setText] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
if (blob.status !== 'ready') return;
|
||||||
|
let active = true;
|
||||||
|
void blob.blob.text().then((value) => { if (active) setText(value); }).catch(() => undefined);
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [blob]);
|
||||||
|
if (text === undefined) return null;
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return <pre className={styles.snippet} onClick={onOpen}>{lines.slice(0, LINES).join('\n')}{lines.length > LINES ? '\n…' : ''}</pre>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个文件能不能在聊天里直接展开(决定下面用简洁的一行还是完整的文件卡片) */
|
||||||
|
export function hasInlinePreview(item: FileItem): boolean {
|
||||||
|
if (item.state.kind !== 'complete') return false;
|
||||||
|
const max = (MAX as Record<string, number>)[item.preview];
|
||||||
|
return max !== undefined && item.size <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聊天消息里的附件直接展开:图片(含 GIF 动图)、视频、音频、压缩包目录、小文本。文件卡片仍在下面,用来下载和打开预览 */
|
||||||
|
export function ChatAttachment({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||||
|
if (item.state.kind !== 'complete') return null;
|
||||||
|
switch (item.preview) {
|
||||||
|
case 'image': return item.size <= MAX.image ? <Gate item={item} label="查看图片">{() => <Picture item={item} onOpen={onOpen} />}</Gate> : null;
|
||||||
|
case 'video': return item.size <= MAX.video ? <Gate item={item} label="播放视频">{() => <Clip item={item} />}</Gate> : null;
|
||||||
|
case 'audio': return item.size <= MAX.audio ? <Gate item={item} label="播放音频">{() => <Sound item={item} />}</Gate> : null;
|
||||||
|
case 'archive': return item.size <= MAX.archive ? <Gate item={item} label="查看压缩包目录">{() => <div className={styles.archive}><ArchivePreview id={item.id} /></div>}</Gate> : null;
|
||||||
|
case 'text': return item.size <= MAX.text ? <Gate item={item} label="查看内容">{() => <Snippet item={item} onOpen={onOpen} />}</Gate> : null;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { TransferProgress } from '@/features/item/TransferProgress';
|
|||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||||
|
import { ChatAttachment, hasInlinePreview } from './ChatAttachment';
|
||||||
import { NameWithBadges } from './RoleBadges';
|
import { NameWithBadges } from './RoleBadges';
|
||||||
import styles from './ChatPane.module.css';
|
import styles from './ChatPane.module.css';
|
||||||
|
|
||||||
@@ -54,14 +55,20 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
|
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
|
||||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||||
: <div className={styles.fileCard}>
|
: <>
|
||||||
|
{ready ? <ChatAttachment item={item} onOpen={() => onOpenFile(id)} /> : null}
|
||||||
|
{ready && hasInlinePreview(item) ? <div className={styles.fileCaption}>
|
||||||
|
<span className={styles.captionName}>{item.name}</span><span className={styles.captionSize}>{formatSize(item.size)}</span>
|
||||||
|
{item.preview === 'archive' || item.preview === 'text' ? <Button variant="ghost" icon="eye" label="预览" onClick={() => onOpenFile(id)} /> : null}
|
||||||
|
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={downloading} onClick={() => { void save(); }} />
|
||||||
|
</div> : <div className={styles.fileCard}>
|
||||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||||
</button>
|
</button>
|
||||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||||
</div>}
|
</div>}</>}
|
||||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <TransferProgress state={item.state} {...(self && item.localId ? { onCancel: () => { void cancelUpload(item.localId!, item.id); } } : {})} />
|
{item.state.kind === 'receiving' && item.kind === 'file' ? <TransferProgress state={item.state} {...(self && item.localId ? { onCancel: () => { void cancelUpload(item.localId!, item.id); } } : {})} />
|
||||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||||
|
|||||||
@@ -309,3 +309,26 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 已经在上面展开预览的文件:只留一行文件名、大小和下载 */
|
||||||
|
.fileCaption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captionName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captionSize {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -92,3 +94,18 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerAvatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
|
|||||||
const name = channel.name || channel.id.slice(0, 8);
|
const name = channel.name || channel.id.slice(0, 8);
|
||||||
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
|
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))}>
|
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
|
||||||
{Array.from(name)[0]?.toLocaleUpperCase()}
|
{channel.avatar ? <img className={styles.avatar} src={channel.avatar} alt="" /> : Array.from(name)[0]?.toLocaleUpperCase()}
|
||||||
</button>;
|
</button>;
|
||||||
})}
|
})}
|
||||||
<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="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
|
||||||
@@ -30,6 +30,7 @@ export function SpaceHeader() {
|
|||||||
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
|
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
|
||||||
return <div className={styles.header}>
|
return <div className={styles.header}>
|
||||||
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
|
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
|
||||||
|
{channel?.avatar ? <img className={styles.headerAvatar} src={channel.avatar} alt="" /> : null}
|
||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -17,35 +17,37 @@ interface Props {
|
|||||||
|
|
||||||
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
||||||
const { info } = useServerSettings();
|
const { info } = useServerSettings();
|
||||||
const [directPreview, setDirectPreview] = useState(true);
|
const [limited, setLimited] = useState(false);
|
||||||
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
|
const choices = withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).filter((n) => n > 0);
|
||||||
|
const [times, setTimes] = useState(defaultMaxDownloads > 0 ? defaultMaxDownloads : 10);
|
||||||
|
|
||||||
|
// 不限制:聊天里直接预览,不消耗次数。限制:预览、下载每次都消耗一次
|
||||||
|
const options: FileUploadOptions = limited ? { directPreview: false, maxDownloads: times } : { directPreview: true, maxDownloads: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet
|
<Sheet
|
||||||
title={`上传 ${files.length} 个文件`}
|
title={`上传 ${files.length} 个文件`}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload(options)}>继续上传</Button>}
|
||||||
>
|
>
|
||||||
<div className={styles.form}>
|
<div className={styles.form} role="radiogroup" aria-label="下载次数">
|
||||||
<label className={styles.check}>
|
<label className={styles.check}>
|
||||||
<input type="checkbox" checked={directPreview} onChange={(e) => setDirectPreview(e.target.checked)} />
|
<input type="radio" name="limit" checked={!limited} onChange={() => setLimited(false)} />
|
||||||
<span>单帧文件加入空间后可直接预览</span>
|
<span>不限制次数 · 在聊天里直接预览</span>
|
||||||
</label>
|
</label>
|
||||||
{directPreview ? (
|
<label className={styles.check}>
|
||||||
<p className={styles.note}>图片、文本等单帧文件会随空间同步,加入者可直接预览且不计下载次数。大文件必须分帧,仍会受空间下载上限限制。</p>
|
<input type="radio" name="limit" checked={limited} onChange={() => setLimited(true)} />
|
||||||
) : (
|
<span>限制次数 · 点击预览或下载才消耗一次</span>
|
||||||
<>
|
</label>
|
||||||
|
{limited ? (
|
||||||
<label className={styles.field}>
|
<label className={styles.field}>
|
||||||
<span>每个文件最多下载次数</span>
|
<span>每个文件最多</span>
|
||||||
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
|
<select value={times} onChange={(e) => setTimes(Number(e.target.value))}>
|
||||||
{withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).map((n) => (
|
{choices.map((n) => <option key={n} value={n}>{n} 次</option>)}
|
||||||
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p className={styles.note}>预览和下载内容会消耗次数。{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
) : null}
|
||||||
</>
|
<p className={styles.note}>{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -141,3 +141,14 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空间头像:方形取景框,四角用圆角压暗 */
|
||||||
|
.maskSquare {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: none;
|
||||||
|
box-shadow: 0 0 0 999px var(--scrim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ interface View { zoom: number; x: number; y: number; quarter: number; fine: numb
|
|||||||
const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 };
|
const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 };
|
||||||
|
|
||||||
/** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */
|
/** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */
|
||||||
function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: number }) {
|
function Preview({ image, crop, size, rounded }: { image: ImageBitmap; crop: Crop; size: number; rounded?: boolean }) {
|
||||||
const ref = useRef<HTMLCanvasElement>(null);
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = ref.current;
|
const canvas = ref.current;
|
||||||
@@ -19,10 +19,15 @@ function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size:
|
|||||||
canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1));
|
canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1));
|
||||||
paintCrop(ctx, image, crop, VIEW, canvas.width);
|
paintCrop(ctx, image, crop, VIEW, canvas.width);
|
||||||
}, [image, crop, size]);
|
}, [image, crop, size]);
|
||||||
return <canvas ref={ref} className={styles.avatar} style={{ width: size, height: size }} aria-hidden="true" />;
|
return <canvas ref={ref} className={`${styles.avatar} ${rounded ? styles.rounded : ''}`} style={{ width: size, height: size }} aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 空间头像的动图上限(data URL 字符数),和服务端 AvatarData.MaxSpaceGifChars 一致并留余量 */
|
||||||
|
export const SPACE_GIF_LIMIT = 1_900_000;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** 个人头像是圆的;空间头像是圆角方块 */
|
||||||
|
kind?: 'person' | 'space';
|
||||||
file: File;
|
file: File;
|
||||||
/** 预览里显示的名称 */
|
/** 预览里显示的名称 */
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,7 +36,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
||||||
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
export function AvatarEditor({ file, name, onCancel, onDone, kind = 'person' }: Props) {
|
||||||
|
const space = kind === 'space';
|
||||||
const [still, setStill] = useState<ImageBitmap>();
|
const [still, setStill] = useState<ImageBitmap>();
|
||||||
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
|
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
|
||||||
const [frames, setFrames] = useState<GifFrame[]>([]);
|
const [frames, setFrames] = useState<GifFrame[]>([]);
|
||||||
@@ -115,7 +121,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!image || busy) return;
|
if (!image || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW) : await exportAvatar(image, crop, VIEW)); }
|
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined) : await exportAvatar(image, crop, VIEW)); }
|
||||||
catch { setFailed(true); setBusy(false); }
|
catch { setFailed(true); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +137,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
<div className={styles.stage} style={{ width: VIEW, height: VIEW }}>
|
<div className={styles.stage} style={{ width: VIEW, height: VIEW }}>
|
||||||
<canvas ref={stage} className={styles.canvas} style={{ width: VIEW, height: VIEW }} aria-label="拖动图片调整头像位置"
|
<canvas ref={stage} className={styles.canvas} style={{ width: VIEW, height: VIEW }} aria-label="拖动图片调整头像位置"
|
||||||
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
||||||
<span className={styles.mask} aria-hidden="true" />
|
<span className={`${styles.mask} ${space ? styles.maskSquare : ''}`} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
|
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
|
||||||
<label className={styles.slider}><span>缩放</span>
|
<label className={styles.slider}><span>缩放</span>
|
||||||
@@ -146,6 +152,16 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.preview} aria-label="头像预览">
|
<div className={styles.preview} aria-label="头像预览">
|
||||||
<h3>预览</h3>
|
<h3>预览</h3>
|
||||||
|
{space ? <>
|
||||||
|
<div className={styles.member}>
|
||||||
|
{image ? <Preview image={image} crop={crop} size={44} rounded /> : <span className={styles.avatarSmall} />}
|
||||||
|
<div><strong>空间列表</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.member}>
|
||||||
|
{image ? <Preview image={image} crop={crop} size={24} rounded /> : <span className={styles.avatarSmall} />}
|
||||||
|
<div><strong>{name}</strong></div>
|
||||||
|
</div>
|
||||||
|
</> : <>
|
||||||
<div className={styles.message}>
|
<div className={styles.message}>
|
||||||
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
|
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
|
||||||
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
|
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
|
||||||
@@ -154,6 +170,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
|||||||
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
|
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
|
||||||
<div><strong>{name}</strong><small>在线</small></div>
|
<div><strong>{name}</strong><small>在线</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -4,19 +4,9 @@ import { useAccount } from '@/store/account';
|
|||||||
import { useToast } from '@/store/toast';
|
import { useToast } from '@/store/toast';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { AvatarEditor } from './AvatarEditor';
|
import { AvatarEditor } from './AvatarEditor';
|
||||||
|
import { checkAvatarSource } from './avatarSource';
|
||||||
import styles from './AccountSettings.module.css';
|
import styles from './AccountSettings.module.css';
|
||||||
|
|
||||||
// 原图只做初步检查;裁剪后会重新编码为 256×256,体积由编辑器控制
|
|
||||||
const MAX_SOURCE_BYTES = 20_000_000;
|
|
||||||
|
|
||||||
async function checkAvatarSource(file: File): Promise<void> {
|
|
||||||
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
|
|
||||||
const bitmap = await createImageBitmap(file);
|
|
||||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
|
||||||
bitmap.close();
|
|
||||||
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileSettings() {
|
export function ProfileSettings() {
|
||||||
const personal = useAccount((s) => s.personal);
|
const personal = useAccount((s) => s.personal);
|
||||||
const username = useAccount((s) => s.username) ?? '';
|
const username = useAccount((s) => s.username) ?? '';
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
|
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
|
||||||
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
|
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number, maxChars = MAX_GIF_DATA_URL): Promise<string> {
|
||||||
for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) {
|
for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) {
|
||||||
// 让出线程,编辑器的「处理中」能先画出来
|
// 让出线程,编辑器的「处理中」能先画出来
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const url = encode(frames, crop, from, size, step);
|
const url = encode(frames, crop, from, size, step);
|
||||||
if (url.length <= MAX_GIF_DATA_URL) return url;
|
if (url.length <= maxChars) return url;
|
||||||
}
|
}
|
||||||
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 原图只做初步检查;裁剪后会重新编码,体积由编辑器控制
|
||||||
|
const MAX_SOURCE_BYTES = 20_000_000;
|
||||||
|
|
||||||
|
export async function checkAvatarSource(file: File): Promise<void> {
|
||||||
|
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
||||||
|
bitmap.close();
|
||||||
|
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ interface ChannelsState {
|
|||||||
join: (link: ParsedChannelLink, password?: string) => Promise<ChannelInfo>;
|
join: (link: ParsedChannelLink, password?: string) => Promise<ChannelInfo>;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
/** 打开着的空间收到最新信息(名称、头像、人数等)时,同步到列表里的那一项 */
|
||||||
|
patch: (info: ChannelInfo) => void;
|
||||||
forget: (id: string) => Promise<void>;
|
forget: (id: string) => Promise<void>;
|
||||||
setPendingLink: (link: ParsedChannelLink | null) => void;
|
setPendingLink: (link: ParsedChannelLink | null) => void;
|
||||||
clearJustCreated: () => void;
|
clearJustCreated: () => void;
|
||||||
@@ -47,6 +49,7 @@ export const useChannels = create<ChannelsState>()((set, get) => ({
|
|||||||
return hydration;
|
return hydration;
|
||||||
},
|
},
|
||||||
refresh: async () => set({ list: await api.listSaved(), hydrated: true }),
|
refresh: async () => set({ list: await api.listSaved(), hydrated: true }),
|
||||||
|
patch: (info) => set((s) => (s.list.some((c) => c.id === info.id) ? { list: s.list.map((c) => (c.id === info.id ? { ...c, ...info, avatar: info.avatar } : c)) } : s)),
|
||||||
forget: async (id) => {
|
forget: async (id) => {
|
||||||
await api.forget(id);
|
await api.forget(id);
|
||||||
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
|
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FileItem, Item } from '@/core-bridge/types';
|
|||||||
import { previewKindOf } from '@/lib/fileKind';
|
import { previewKindOf } from '@/lib/fileKind';
|
||||||
import { errorMessage } from '@/core-bridge/errors';
|
import { errorMessage } from '@/core-bridge/errors';
|
||||||
import { useAccount } from './account';
|
import { useAccount } from './account';
|
||||||
|
import { useChannels } from './channels';
|
||||||
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
|
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
|
||||||
|
|
||||||
/** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */
|
/** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */
|
||||||
@@ -96,7 +97,11 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
|
|||||||
revokeInvitation: (username) => api.revokeInvitation(username),
|
revokeInvitation: (username) => api.revokeInvitation(username),
|
||||||
open: (channelId) => {
|
open: (channelId) => {
|
||||||
useWorkspace.setState(initialData);
|
useWorkspace.setState(initialData);
|
||||||
return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e)));
|
return api.connect(channelId, (e) => {
|
||||||
|
useWorkspace.setState((s) => reduce(s, e));
|
||||||
|
// 空间名称、头像等改了:左侧空间列表是另一份投影,一起更新
|
||||||
|
if (e.t === 'channel') useChannels.getState().patch(e.info);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
sendText: (text, threadId) => {
|
sendText: (text, threadId) => {
|
||||||
const id = newLocalId();
|
const id = newLocalId();
|
||||||
|
|||||||
Reference in new issue
Block a user