Skip to content
Merged
128 changes: 64 additions & 64 deletions memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ public static class InboxSettingsTab
public const string TabId = "Inbox";
private const string ResultDataId = "inboxResult";
private const string ListDataId = "inboxList";
private const string SelectedDataId = "inboxSelected";

internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl stack)
{
Expand All @@ -49,13 +48,28 @@ internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl st
.StartWith((UiControl?)Controls.Stack.WithWidth("100%")));

// The inbox — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the grid is
// declared at once and fed by the synced Admin/Inbox query; a mail is archived by SELECTING
// its row, then Archive below the grid.
host.UpdateData(SelectedDataId, MailRow.None(host.Localize("inbox.selectMail")));
// declared at once and fed by the synced Admin/Inbox query; each row carries its own Archive
// button, a ROW-SCOPED action ("Row-scoped actions") — the click says which mail it is.
var meshService = host.Hub.ServiceProvider.GetRequiredService<IMeshService>();
var accessService = host.Hub.ServiceProvider.GetService<AccessService>();

stack = stack.WithView(InboxRowsFeed(host)
stack = stack.WithView(MailGrid(host, InboxRowsFeed(host),
(ctx, row) => Archive(ctx, host, row, meshService, accessService)));

return stack;
}

/// <summary>The column of <see cref="MailGrid"/> that holds each row's Archive button.</summary>
internal const int ArchiveColumn = 5;

/// <summary>
/// The inbox grid over <paramref name="rows"/> — the template half, separate from the feed so its
/// row-scoped action is testable. The Archive column's ONE button hands the row it was clicked in
/// to <paramref name="archive"/>; a click that carries no row does nothing.
/// </summary>
internal static DataGridControl MailGrid(
LayoutAreaHost host, IObservable<IEnumerable<MailRow>> rows, Action<UiActionContext, MailRow> archive)
=> rows
.BindGrid(ListDataId, host.Localize("inbox.empty"), message => host.Localize("inbox.listFailed", message))
.WithColumn(new PropertyColumnControl<string> { Property = nameof(MailRow.From).ToCamelCase() }
.WithTitle(host.Localize("inbox.column.from")))
Expand All @@ -67,65 +81,53 @@ internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl st
.WithTitle(host.Localize("inbox.column.status")))
.WithColumn(new PropertyColumnControl<string> { Property = nameof(MailRow.Preview).ToCamelCase() }
.WithTitle(host.Localize("inbox.column.preview")))
.WithClickAction(ctx =>
{
if (ctx.Payload is DataGridCellClick { Item: { } item }
&& item.As<MailRow>(ctx.Hub.JsonSerializerOptions, what: "inbox row") is { } row)
ctx.Host.UpdateData(SelectedDataId, row);
return Task.CompletedTask;
}));

stack = stack.WithView(Controls.Stack.WithOrientation(Orientation.Horizontal)
.WithStyle("gap: 12px; align-items: center; margin-top: 8px;")
.WithView(new LabelControl(new JsonPointerReference(
LayoutAreaReference.GetDataPointer(SelectedDataId, nameof(MailRow.Display).ToCamelCase())))
.WithStyle("flex: 1;"))
.WithView(Controls.Button(host.Localize("ui.archive"))
.WithAppearance(Appearance.Outline)
.WithClickAction(ctx =>
{
ArchiveSelected(ctx, host, meshService, accessService);
return Task.CompletedTask;
})));

return stack;
}
.WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.archive"))
.WithAppearance(Appearance.Outline)
.WithClickAction(ctx =>
{
if (ctx.RowAs<MailRow>() is { } row)
archive(ctx, row);
return Task.CompletedTask;
}))
.WithTitle(host.Localize("ui.archive")));

/// <summary>
/// Archives the mail selected in the grid, or says that one has to be selected first. The
/// selection is client input (a cell-click payload), so it only says WHICH mail: the target is
/// re-derived from the inbox query itself, read now, and the write — which runs as System — only
/// ever reaches a mail of that query in its CURRENT state. A path outside the inbox, or a mail
/// archived since it was selected, is refused.
/// Archives the mail of the clicked row, or says it is archived already. The row is client
/// input, so it only says WHICH mail: the target is re-derived from the inbox query itself, read
/// now (<see cref="CurrentMail"/>), and the write — which runs as System — only ever reaches a
/// mail that query lists, in its CURRENT state. A path outside the inbox is refused.
/// </summary>
private static void ArchiveSelected(
UiActionContext ctx, LayoutAreaHost host, IMeshService meshService, AccessService? accessService)
=> ctx.Host.Stream.GetDataStream<MailRow>(SelectedDataId).Take(1)
.SelectMany(selected => CurrentMail(host, selected?.Path))
.Subscribe(row =>
private static void Archive(
UiActionContext ctx, LayoutAreaHost host, MailRow clicked, IMeshService meshService, AccessService? accessService)
=> CurrentMail(host, clicked.Path).Subscribe(row =>
{
if (row is null)
{
ctx.Host.UpdateData(ResultDataId, Error(host.Localize("inbox.notInInbox")));
return;
}
if (row.IsArchived)
{
ctx.Host.UpdateData(ResultDataId, Pending(Esc(host.Localize("inbox.alreadyArchived"))));
return;
}
if (accessService is null)
{
if (row is null || row.IsArchived)
{
ctx.Host.UpdateData(ResultDataId, Pending(Esc(host.Localize("inbox.selectMail"))));
return;
}
if (accessService is null)
{
ctx.Host.UpdateData(ResultDataId, Error("No access service is registered; the mail cannot be archived."));
return;
}
ctx.Host.UpdateData(ResultDataId, Pending($"Archiving mail from {Esc(row.FromAddress)}…"));
// The node itself, read once for the write (it exists: the inbox query just listed it).
ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1)
.SelectMany(node => EmailOf(node, ctx.Hub.JsonSerializerOptions) is { } email
? Observable.Using(
() => accessService.ImpersonateAsSystem(),
_ => meshService.UpdateNode(node with { Content = email with { Status = EmailStatus.Archived } }))
: Observable.Throw<MeshNode>(new InvalidOperationException($"{row.Path} holds no email.")))
.Subscribe(
_ => ctx.Host.UpdateData(ResultDataId, Success($"Archived mail from {Esc(row.FromAddress)}.")),
ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message)));
}, ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message)));
ctx.Host.UpdateData(ResultDataId, Error("No access service is registered; the mail cannot be archived."));
return;
}
ctx.Host.UpdateData(ResultDataId, Pending($"Archiving mail from {Esc(row.FromAddress)}…"));
// The node itself, read once for the write (it exists: the inbox query just listed it).
ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1)
.SelectMany(node => EmailOf(node, ctx.Hub.JsonSerializerOptions) is { } email
? Observable.Using(
() => accessService.ImpersonateAsSystem(),
_ => meshService.UpdateNode(node with { Content = email with { Status = EmailStatus.Archived } }))
: Observable.Throw<MeshNode>(new InvalidOperationException($"{row.Path} holds no email.")))
.Subscribe(
_ => ctx.Host.UpdateData(ResultDataId, Success($"Archived mail from {Esc(row.FromAddress)}.")),
ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message)));
}, ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message)));

/// <summary>
/// The row of the inbox query, as it is NOW, whose path is <paramref name="path"/> — or null when
Expand Down Expand Up @@ -163,9 +165,8 @@ internal static IObservable<IReadOnlyList<MailRow>> InboxRowsFeed(LayoutAreaHost
/// <summary>One row of the inbox grid — display text plus what Archive needs.</summary>
internal record MailRow(
string From, string Subject, string Received, string Status, string Preview,
string Path, string FromAddress, bool IsArchived, string Display)
string Path, string FromAddress, bool IsArchived)
{
internal static MailRow None(string hint) => new("", "", "", "", "", "", "", false, hint);

internal static MailRow Of(MeshNode node, MeshWeaver.Mesh.Email email, string status)
{
Expand All @@ -179,8 +180,7 @@ internal static MailRow Of(MeshNode node, MeshWeaver.Mesh.Email email, string st
body.Length > 140 ? body[..140] + "…" : body,
node.Path ?? "",
email.From,
email.Status == EmailStatus.Archived,
$"{from} · {email.Subject}");
email.Status == EmailStatus.Archived);
}
}

Expand Down
83 changes: 40 additions & 43 deletions memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ public static class InvitationsSettingsTab
private const string ResultDataId = "invitationResult";
private const string FormDataId = "invitationForm";
private const string ListDataId = "invitationList";
private const string SelectedDataId = "invitationSelected";

internal static UiControl BuildInvitationsContent(
LayoutAreaHost host, StackControl stack)
Expand Down Expand Up @@ -119,10 +118,26 @@ internal static UiControl BuildInvitationsContent(
"<h3 style=\"margin: 24px 0 12px 0; font-size: 1rem;\">Invitations</h3>"));

// The invitations — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the
// grid is declared at once and fed by the synced Admin/Invitation query; an invitation is
// revoked by SELECTING its row, then Revoke below the grid.
host.UpdateData(SelectedDataId, InvitationRow.None(host.Localize("invitations.selectInvitation")));
stack = stack.WithView(InvitationRowsFeed(host)
// grid is declared at once and fed by the synced Admin/Invitation query; each row carries its
// own Revoke button, a ROW-SCOPED action ("Row-scoped actions") — the click says which
// invitation it is.
stack = stack.WithView(InvitationGrid(host, InvitationRowsFeed(host),
(ctx, row) => Revoke(ctx, host, row, invitationService)));

return stack;
}

/// <summary>The column of <see cref="InvitationGrid"/> that holds each row's Revoke button.</summary>
internal const int RevokeColumn = 8;

/// <summary>
/// The invitation grid over <paramref name="rows"/> — the template half, separate from the feed so
/// its row-scoped action is testable. The Revoke column's ONE button hands the row it was clicked
/// in to <paramref name="revoke"/>; a click that carries no row does nothing.
/// </summary>
internal static DataGridControl InvitationGrid(
LayoutAreaHost host, IObservable<IEnumerable<InvitationRow>> rows, Action<UiActionContext, InvitationRow> revoke)
=> rows
.BindGrid(ListDataId, host.Localize("invitations.none"), message => host.Localize("invitations.listFailed", message))
.WithColumn(new PropertyColumnControl<string> { Property = nameof(InvitationRow.Email).ToCamelCase() }
.WithTitle(host.Localize("invitations.column.email")))
Expand All @@ -140,46 +155,30 @@ internal static UiControl BuildInvitationsContent(
.WithTitle(host.Localize("invitations.column.space")))
.WithColumn(new PropertyColumnControl<string> { Property = nameof(InvitationRow.Note).ToCamelCase() }
.WithTitle(host.Localize("invitations.column.note")))
.WithClickAction(ctx =>
{
if (ctx.Payload is DataGridCellClick { Item: { } item }
&& item.As<InvitationRow>(ctx.Hub.JsonSerializerOptions, what: "invitation row") is { } row)
ctx.Host.UpdateData(SelectedDataId, row);
return Task.CompletedTask;
}));

stack = stack.WithView(Controls.Stack.WithOrientation(Orientation.Horizontal)
.WithStyle("gap: 12px; align-items: center; margin-top: 8px;")
.WithView(new LabelControl(new JsonPointerReference(
LayoutAreaReference.GetDataPointer(SelectedDataId, nameof(InvitationRow.Display).ToCamelCase())))
.WithStyle("flex: 1;"))
.WithView(Controls.Button(host.Localize("ui.revoke"))
.WithAppearance(Appearance.Outline)
.WithClickAction(ctx =>
{
RevokeSelected(ctx, host, invitationService);
return Task.CompletedTask;
})));

return stack;
}

/// <summary>Revokes the PENDING invitation selected in the grid, or says that one has to be
/// selected first.</summary>
/// <remarks>The selection is client input; the target is re-derived from the invitation query,
/// read now, so only a listed invitation that is STILL pending is revoked.</remarks>
private static void RevokeSelected(UiActionContext ctx, LayoutAreaHost host, InvitationService invitationService)
=> ctx.Host.Stream.GetDataStream<InvitationRow>(SelectedDataId).Take(1)
.SelectMany(selected => CurrentInvitation(host, selected?.Path))
.Subscribe(row =>
.WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.revoke"))
.WithAppearance(Appearance.Outline)
.WithClickAction(ctx =>
{
if (ctx.RowAs<InvitationRow>() is { } row)
revoke(ctx, row);
return Task.CompletedTask;
}))
.WithTitle(host.Localize("ui.revoke")));

/// <summary>Revokes the clicked row's invitation, or says that only a pending invitation can
/// be revoked. The row is client input: the invitation is re-derived from the invitation query,
/// read now (<see cref="CurrentInvitation"/>), so only a listed invitation that is STILL pending
/// is revoked.</summary>
private static void Revoke(UiActionContext ctx, LayoutAreaHost host, InvitationRow clicked, InvitationService invitationService)
=> CurrentInvitation(host, clicked.Path).Subscribe(row =>
{
if (row is null || !row.IsPending)
{
ctx.Host.UpdateData(ResultDataId, PendingHtml(Esc(host.Localize("invitations.selectInvitation"))));
ctx.Host.UpdateData(ResultDataId, PendingHtml(Esc(host.Localize("invitations.notPending"))));
return;
}
ctx.Host.UpdateData(ResultDataId, PendingHtml($"Revoking {Esc(row.Email)}…"));
// The node itself, read once for the write (it exists: the row came from it).
// The node itself, read once for the write (it exists: the invitation query just listed it).
ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1)
.SelectMany(node => InvitationService.TryGetInvitation(node, ctx.Hub.JsonSerializerOptions) is { } inv
? invitationService.Revoke(node, inv)
Expand Down Expand Up @@ -223,9 +222,8 @@ internal static IObservable<IReadOnlyList<InvitationRow>> InvitationRowsFeed(Lay
/// <summary>One row of the invitation grid — display text plus what Revoke needs.</summary>
internal record InvitationRow(
string Email, string Status, string Invited, string InvitedBy, string Emailed, string Accepted,
string Space, string Note, string Path, bool IsPending, string Display)
string Space, string Note, string Path, bool IsPending)
{
internal static InvitationRow None(string hint) => new("", "", "", "", "", "", "", "", "", false, hint);

internal static InvitationRow Of(MeshNode node, Invitation inv, string status) => new(
inv.Email,
Expand All @@ -237,8 +235,7 @@ internal record InvitationRow(
inv.SpacePath ?? "",
inv.Note ?? "",
node.Path ?? "",
inv.Status == InvitationStatus.Pending,
$"{inv.Email} · {status}");
inv.Status == InvitationStatus.Pending);
}

private static string Esc(string s) => System.Web.HttpUtility.HtmlEncode(s);
Expand Down
Loading