feat: rework group list into member list

This commit is contained in:
rladenson 2022-01-14 22:30:02 -05:00 committed by spiral
parent 0afe031284
commit f3869dbcbe
No known key found for this signature in database
GPG Key ID: A6059F0CA0E1BD31
21 changed files with 374 additions and 126 deletions

View File

@ -57,7 +57,7 @@ public partial class CommandTree
public static Command GroupColor = new Command("group color", "group <group> color [color]", "Changes a group's color"); public static Command GroupColor = new Command("group color", "group <group> color [color]", "Changes a group's color");
public static Command GroupAdd = new Command("group add", "group <group> add <member> [member 2] [member 3...]", "Adds one or more members to a group"); public static Command GroupAdd = new Command("group add", "group <group> add <member> [member 2] [member 3...]", "Adds one or more members to a group");
public static Command GroupRemove = new Command("group remove", "group <group> remove <member> [member 2] [member 3...]", "Removes one or more members from a group"); public static Command GroupRemove = new Command("group remove", "group <group> remove <member> [member 2] [member 3...]", "Removes one or more members from a group");
public static Command GroupPrivacy = new Command("group privacy", "group <group> privacy <description|icon|visibility|all> <public|private>", "Changes a group's privacy settings"); public static Command GroupPrivacy = new Command("group privacy", "group <group> privacy <name|description|icon|metadata|visibility|all> <public|private>", "Changes a group's privacy settings");
public static Command GroupBannerImage = new Command("group banner", "group <group> banner [url]", "Set the group's banner image"); public static Command GroupBannerImage = new Command("group banner", "group <group> banner [url]", "Set the group's banner image");
public static Command GroupIcon = new Command("group icon", "group <group> icon [url|@mention]", "Changes a group's icon"); public static Command GroupIcon = new Command("group icon", "group <group> icon [url|@mention]", "Changes a group's icon");
public static Command GroupDelete = new Command("group delete", "group <group> delete", "Deletes a group"); public static Command GroupDelete = new Command("group delete", "group <group> delete", "Deletes a group");

View File

@ -43,7 +43,7 @@ public static class ContextPrivacyExt
{ {
if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject)) if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject))
throw new PKSyntaxError( throw new PKSyntaxError(
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `icon`, `visibility`, or `all`)."); $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `icon`, `metadata`, `visibility`, or `all`).");
ctx.PopArgument(); ctx.PopArgument();
return subject; return subject;

View File

@ -64,7 +64,7 @@ public class GroupMember
var groups = await _repo.GetMemberGroups(target.Id) var groups = await _repo.GetMemberGroups(target.Id)
.Where(g => g.Visibility.CanAccess(pctx)) .Where(g => g.Visibility.CanAccess(pctx))
.OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) .OrderBy(g => (g.DisplayName ?? g.Name), StringComparer.InvariantCultureIgnoreCase)
.ToListAsync(); .ToListAsync();
var description = ""; var description = "";
@ -97,7 +97,7 @@ public class GroupMember
.ToList(); .ToList();
var existingMembersInGroup = (await _db.Execute(conn => conn.QueryMemberList(target.System, var existingMembersInGroup = (await _db.Execute(conn => conn.QueryMemberList(target.System,
new DatabaseViewsExt.MemberListQueryOptions { GroupFilter = target.Id }))) new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id })))
.Select(m => m.Id.Value) .Select(m => m.Id.Value)
.Distinct() .Distinct()
.ToHashSet(); .ToHashSet();
@ -134,7 +134,7 @@ public class GroupMember
var targetSystem = await GetGroupSystem(ctx, target); var targetSystem = await GetGroupSystem(ctx, target);
ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy); ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy);
var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(target.System)); var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System));
opts.GroupFilter = target.Id; opts.GroupFilter = target.Id;
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in "); var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in ");

View File

@ -175,6 +175,8 @@ public class Groups
await _repo.UpdateGroup(target.Id, patch); await _repo.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group display name cleared."); await ctx.Reply($"{Emojis.Success} Group display name cleared.");
if (target.NamePrivacy == PrivacyLevel.Private)
await ctx.Reply($"{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**.");
} }
else else
{ {
@ -431,49 +433,33 @@ public class Groups
ctx.CheckSystemPrivacy(system.Id, system.GroupListPrivacy); ctx.CheckSystemPrivacy(system.Id, system.GroupListPrivacy);
// TODO: integrate with the normal "search" system // explanation of privacy lookup here:
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
// - RenderGroupList checks the indivual privacy for each member (NameFor, etc)
// the own system is always allowed to look up their list
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id));
await ctx.RenderGroupList(
ctx.LookupContextFor(system.Id),
system.Id,
GetEmbedTitle(system, opts),
system.Color,
opts
);
}
// TODO: integrate with privacy config settings private string GetEmbedTitle(PKSystem target, ListOptions opts)
var pctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
{ {
if (system.Id == ctx.System.Id) var title = new StringBuilder("Groups of ");
pctx = LookupContext.ByOwner;
if (target.Name != null)
title.Append($"{target.Name} (`{target.Hid}`)");
else else
throw Errors.LookupNotAllowed; title.Append($"`{target.Hid}`");
}
var groups = (await _db.Execute(conn => conn.QueryGroupList(system.Id))) if (opts.Search != null)
.Where(g => g.Visibility.CanAccess(pctx)) title.Append($" matching **{opts.Search}**");
.OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase)
.ToList();
if (groups.Count == 0) return title.ToString();
{
if (system.Id == ctx.System?.Id)
await ctx.Reply("This system has no groups. To create one, use the command `pk;group new <name>`.");
else
await ctx.Reply("This system has no groups.");
return;
}
var title = system.Name != null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`";
await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, 25, title, system.Color, Renderer);
Task Renderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
{
eb.WithSimpleLineContent(page.Select(g =>
{
if (g.DisplayName != null)
return
$"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({g.DisplayName.EscapeMarkdown()}) ({"member".ToQuantity(g.MemberCount)})";
return $"[`{g.Hid}`] **{g.Name.EscapeMarkdown()}** ({"member".ToQuantity(g.MemberCount)})";
}));
eb.Footer(new Embed.EmbedFooter($"{groups.Count} total."));
return Task.CompletedTask;
}
} }
public async Task ShowGroupCard(Context ctx, PKGroup target) public async Task ShowGroupCard(Context ctx, PKGroup target)
@ -490,12 +476,14 @@ public class Groups
{ {
await ctx.Reply(embed: new EmbedBuilder() await ctx.Reply(embed: new EmbedBuilder()
.Title($"Current privacy settings for {target.Name}") .Title($"Current privacy settings for {target.Name}")
.Field(new Embed.Field("Name", target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Icon", target.IconPrivacy.Explanation())) .Field(new Embed.Field("Icon", target.IconPrivacy.Explanation()))
.Field(new Embed.Field("Member list", target.ListPrivacy.Explanation())) .Field(new Embed.Field("Member list", target.ListPrivacy.Explanation()))
.Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation()))
.Field(new Embed.Field("Visibility", target.Visibility.Explanation())) .Field(new Embed.Field("Visibility", target.Visibility.Explanation()))
.Description( .Description(
$"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **<subject>** **<level>**\n\n- `subject` is one of `description`, `icon`, `members`, `visibility`, or `all`\n- `level` is either `public` or `private`.") $"To edit privacy settings, use the command:\n> pk;group **{target.Reference()}** privacy **<subject>** **<level>**\n\n- `subject` is one of `name`, `description`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
.Build()); .Build());
return; return;
} }
@ -518,30 +506,40 @@ public class Groups
var subjectName = subject switch var subjectName = subject switch
{ {
GroupPrivacySubject.Name => "name privacy",
GroupPrivacySubject.Description => "description privacy", GroupPrivacySubject.Description => "description privacy",
GroupPrivacySubject.Icon => "icon privacy", GroupPrivacySubject.Icon => "icon privacy",
GroupPrivacySubject.List => "member list", GroupPrivacySubject.List => "member list",
GroupPrivacySubject.Metadata => "metadata",
GroupPrivacySubject.Visibility => "visibility", GroupPrivacySubject.Visibility => "visibility",
_ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
}; };
var explanation = (subject, level) switch var explanation = (subject, level) switch
{ {
(GroupPrivacySubject.Name, PrivacyLevel.Private) =>
"This group's name is now hidden from other systems, and will be replaced by the group's display name.",
(GroupPrivacySubject.Description, PrivacyLevel.Private) => (GroupPrivacySubject.Description, PrivacyLevel.Private) =>
"This group's description is now hidden from other systems.", "This group's description is now hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Private) => (GroupPrivacySubject.Icon, PrivacyLevel.Private) =>
"This group's icon is now hidden from other systems.", "This group's icon is now hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Private) => (GroupPrivacySubject.Visibility, PrivacyLevel.Private) =>
"This group is now hidden from group lists and member cards.", "This group is now hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Private) =>
"This group's metadata (eg. creation date) is now hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Private) => (GroupPrivacySubject.List, PrivacyLevel.Private) =>
"This group's member list is now hidden from other systems.", "This group's member list is now hidden from other systems.",
(GroupPrivacySubject.Name, PrivacyLevel.Public) =>
"This group's name is no longer hidden from other systems.",
(GroupPrivacySubject.Description, PrivacyLevel.Public) => (GroupPrivacySubject.Description, PrivacyLevel.Public) =>
"This group's description is no longer hidden from other systems.", "This group's description is no longer hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Public) => (GroupPrivacySubject.Icon, PrivacyLevel.Public) =>
"This group's icon is no longer hidden from other systems.", "This group's icon is no longer hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Public) => (GroupPrivacySubject.Visibility, PrivacyLevel.Public) =>
"This group is no longer hidden from group lists and member cards.", "This group is no longer hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Public) =>
"This group's metadata (eg. creation date) is no longer hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Public) => (GroupPrivacySubject.List, PrivacyLevel.Public) =>
"This group's member list is no longer hidden from other systems.", "This group's member list is no longer hidden from other systems.",
@ -550,6 +548,10 @@ public class Groups
await ctx.Reply( await ctx.Reply(
$"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"); $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}");
if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null)
await ctx.Reply(
$"{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**.");
} }
if (ctx.Match("all") || newValueFromCommand != null) if (ctx.Match("all") || newValueFromCommand != null)

View File

@ -13,9 +13,9 @@ namespace PluralKit.Bot;
public static class ContextListExt public static class ContextListExt
{ {
public static MemberListOptions ParseMemberListOptions(this Context ctx, LookupContext lookupCtx) public static ListOptions ParseListOptions(this Context ctx, LookupContext lookupCtx)
{ {
var p = new MemberListOptions(); var p = new ListOptions();
// Short or long list? (parse this first, as it can potentially take a positional argument) // Short or long list? (parse this first, as it can potentially take a positional argument)
var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full");
@ -71,7 +71,7 @@ public static class ContextListExt
p.IncludeMessageCount = true; p.IncludeMessageCount = true;
if (ctx.MatchFlag("with-created", "wc")) if (ctx.MatchFlag("with-created", "wc"))
p.IncludeCreated = true; p.IncludeCreated = true;
if (ctx.MatchFlag("with-avatar", "with-image", "wa", "wi", "ia", "ii", "img")) if (ctx.MatchFlag("with-avatar", "with-image", "with-icon", "wa", "wi", "ia", "ii", "img"))
p.IncludeAvatar = true; p.IncludeAvatar = true;
if (ctx.MatchFlag("with-pronouns", "wp")) if (ctx.MatchFlag("with-pronouns", "wp"))
p.IncludePronouns = true; p.IncludePronouns = true;
@ -87,7 +87,7 @@ public static class ContextListExt
} }
public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx,
SystemId system, string embedTitle, string color, MemberListOptions opts) SystemId system, string embedTitle, string color, ListOptions opts)
{ {
// We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime
// We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout)
@ -248,4 +248,134 @@ public static class ContextListExt
} }
} }
} }
public static async Task RenderGroupList(this Context ctx, LookupContext lookupCtx,
SystemId system, string embedTitle, string color, ListOptions opts)
{
// We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime
// We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout)
var groups = (await ctx.Database.Execute(conn => conn.QueryGroupList(system, opts.ToQueryOptions())))
.SortByGroupListOptions(opts, lookupCtx)
.ToList();
var itemsPerPage = opts.Type == ListType.Short ? 25 : 5;
await ctx.Paginate(groups.ToAsyncEnumerable(), groups.Count, itemsPerPage, embedTitle, color, Renderer);
// Base renderer, dispatches based on type
Task Renderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
{
// Add a global footer with the filter/sort string + result count
eb.Footer(new Embed.EmbedFooter($"{opts.CreateFilterString()}. {"result".ToQuantity(groups.Count)}."));
// Then call the specific renderers
if (opts.Type == ListType.Short)
ShortRenderer(eb, page);
else
LongRenderer(eb, page);
return Task.CompletedTask;
}
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
{
// We may end up over the description character limit
// so run it through a helper that "makes it work" :)
eb.WithSimpleLineContent(page.Select(g =>
{
var ret = $"[`{g.Hid}`] **{g.NameFor(ctx)}** ";
switch (opts.SortProperty)
{
case SortProperty.DisplayName:
{
if (g.NamePrivacy.CanAccess(lookupCtx) && g.DisplayName != null)
ret += $"({g.DisplayName})";
break;
}
case SortProperty.CreationDate:
{
if (g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created))
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
break;
}
default:
{
if (opts.IncludeCreated &&
g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created))
{
ret += $"(created at <t:{created.ToUnixTimeSeconds()}>)";
}
else if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatarUrl)
{
ret += $"([avatar URL]({avatarUrl}))";
}
else
{
// -priv/-pub and listprivacy affects whether count is shown
// -all and visibility affects what the count is
if (ctx.DirectLookupContextFor(system) == LookupContext.ByOwner)
{
if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner)
{
if (ctx.MatchFlag("all", "a"))
{
ret += $"({"member".ToQuantity(g.TotalMemberCount)})";
}
else
{
ret += $"({"member".ToQuantity(g.PublicMemberCount)})";
}
}
}
else
{
if (g.ListPrivacy == PrivacyLevel.Public)
{
ret += $"({"member".ToQuantity(g.PublicMemberCount)})";
}
}
}
break;
}
}
return ret;
}));
}
void LongRenderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
{
foreach (var g in page)
{
var profile = new StringBuilder($"**ID**: {g.Hid}");
if (g.DisplayName != null && g.NamePrivacy.CanAccess(lookupCtx))
profile.Append($"\n**Display name**: {g.DisplayName}");
if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner)
{
if (ctx.MatchFlag("all", "a") && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner)
profile.Append($"\n**Member Count:** {g.TotalMemberCount}");
else
profile.Append($"\n**Member Count:** {g.PublicMemberCount}");
}
if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) &&
g.MetadataPrivacy.TryGet(lookupCtx, g.Created, out var created))
profile.Append($"\n**Created on:** {created.FormatZoned(ctx.Zone)}");
if (opts.IncludeAvatar && g.IconFor(lookupCtx) is { } avatar)
profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}");
if (g.DescriptionFor(lookupCtx) is { } desc)
profile.Append($"\n\n{desc}");
if (g.Visibility == PrivacyLevel.Private)
profile.Append("\n*(this member is hidden)*");
eb.Field(new Embed.Field(g.NameFor(ctx), profile.ToString().Truncate(1024)));
}
}
}
} }

View File

@ -7,7 +7,7 @@ using PluralKit.Core;
#nullable enable #nullable enable
namespace PluralKit.Bot; namespace PluralKit.Bot;
public class MemberListOptions public class ListOptions
{ {
public SortProperty SortProperty { get; set; } = SortProperty.Name; public SortProperty SortProperty { get; set; } = SortProperty.Name;
public bool Reverse { get; set; } public bool Reverse { get; set; }
@ -32,8 +32,8 @@ public class MemberListOptions
if (SortProperty != SortProperty.Random) str.Append("by "); if (SortProperty != SortProperty.Random) str.Append("by ");
str.Append(SortProperty switch str.Append(SortProperty switch
{ {
SortProperty.Name => "member name", SortProperty.Name => "name",
SortProperty.Hid => "member ID", SortProperty.Hid => "ID",
SortProperty.DisplayName => "display name", SortProperty.DisplayName => "display name",
SortProperty.CreationDate => "creation date", SortProperty.CreationDate => "creation date",
SortProperty.LastMessage => "last message", SortProperty.LastMessage => "last message",
@ -52,8 +52,8 @@ public class MemberListOptions
str.Append(PrivacyFilter switch str.Append(PrivacyFilter switch
{ {
null => ", showing all members", null => ", showing all items",
PrivacyLevel.Private => ", showing only private members", PrivacyLevel.Private => ", showing only private items",
PrivacyLevel.Public => "", // (default, no extra line needed) PrivacyLevel.Public => "", // (default, no extra line needed)
_ => new ArgumentOutOfRangeException( _ => new ArgumentOutOfRangeException(
$"Couldn't find readable string for privacy filter {PrivacyFilter}") $"Couldn't find readable string for privacy filter {PrivacyFilter}")
@ -62,7 +62,7 @@ public class MemberListOptions
return str.ToString(); return str.ToString();
} }
public DatabaseViewsExt.MemberListQueryOptions ToQueryOptions() => public DatabaseViewsExt.ListQueryOptions ToQueryOptions() =>
new() new()
{ {
PrivacyFilter = PrivacyFilter, PrivacyFilter = PrivacyFilter,
@ -72,10 +72,10 @@ public class MemberListOptions
}; };
} }
public static class MemberListOptionsExt public static class ListOptionsExt
{ {
public static IEnumerable<ListedMember> SortByMemberListOptions(this IEnumerable<ListedMember> input, public static IEnumerable<ListedMember> SortByMemberListOptions(this IEnumerable<ListedMember> input,
MemberListOptions opts, LookupContext ctx) ListOptions opts, LookupContext ctx)
{ {
IComparer<T> ReverseMaybe<T>(IComparer<T> c) => IComparer<T> ReverseMaybe<T>(IComparer<T> c) =>
opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c; opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c;
@ -89,26 +89,23 @@ public static class MemberListOptionsExt
// We want nulls last no matter what, even if orders are reversed // We want nulls last no matter what, even if orders are reversed
SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)), SortProperty.Hid => input.OrderBy(m => m.Hid, ReverseMaybe(culture)),
SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)), SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)),
SortProperty.CreationDate => input SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)),
.OrderByDescending(m => m.MetadataPrivacy.CanAccess(ctx)) SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount,
.ThenBy(m => m.MetadataPrivacy.Get(ctx, m.Created, default), ReverseMaybe(Comparer<Instant>.Default)), ReverseMaybe(Comparer<int>.Default)),
SortProperty.MessageCount => input
.OrderByDescending(m => m.MessageCount != 0 && m.MetadataPrivacy.CanAccess(ctx))
.ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.MessageCount, 0), ReverseMaybe(Comparer<int>.Default)),
SortProperty.DisplayName => input SortProperty.DisplayName => input
.OrderByDescending(m => m.DisplayName != null && m.NamePrivacy.CanAccess(ctx)) .OrderByDescending(m => m.DisplayName != null)
.ThenBy(m => m.NamePrivacy.Get(ctx, m.DisplayName), ReverseMaybe(culture)), .ThenBy(m => m.DisplayName, ReverseMaybe(culture)),
SortProperty.Birthdate => input SortProperty.Birthdate => input
.OrderByDescending(m => m.AnnualBirthday.HasValue && m.BirthdayPrivacy.CanAccess(ctx)) .OrderByDescending(m => m.AnnualBirthday.HasValue)
.ThenBy(m => m.BirthdayPrivacy.Get(ctx, m.AnnualBirthday), ReverseMaybe(Comparer<AnnualDate?>.Default)), .ThenBy(m => m.AnnualBirthday, ReverseMaybe(Comparer<AnnualDate?>.Default)),
SortProperty.LastMessage => throw new PKError( SortProperty.LastMessage => throw new PKError(
"Sorting by last message is temporarily disabled due to database issues, sorry."), "Sorting by last message is temporarily disabled due to database issues, sorry."),
// SortProperty.LastMessage => input // SortProperty.LastMessage => input
// .OrderByDescending(m => m.LastMessage.HasValue) // .OrderByDescending(m => m.LastMessage.HasValue)
// .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)), // .ThenByDescending(m => m.LastMessage, ReverseMaybe(Comparer<ulong?>.Default)),
SortProperty.LastSwitch => input SortProperty.LastSwitch => input
.OrderByDescending(m => m.LastSwitchTime.HasValue && m.MetadataPrivacy.CanAccess(ctx)) .OrderByDescending(m => m.LastSwitchTime.HasValue)
.ThenByDescending(m => m.MetadataPrivacy.Get(ctx, m.LastSwitchTime), ReverseMaybe(Comparer<Instant?>.Default)), .ThenByDescending(m => m.LastSwitchTime, ReverseMaybe(Comparer<Instant?>.Default)),
SortProperty.Random => input SortProperty.Random => input
.OrderBy(m => randGen.Next()), .OrderBy(m => randGen.Next()),
_ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}") _ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}")
@ -116,6 +113,33 @@ public static class MemberListOptionsExt
// Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values) // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values)
.ThenBy(m => m.NameFor(ctx), culture); .ThenBy(m => m.NameFor(ctx), culture);
} }
public static IEnumerable<ListedGroup> SortByGroupListOptions(this IEnumerable<ListedGroup> input,
ListOptions opts, LookupContext ctx)
{
IComparer<T> ReverseMaybe<T>(IComparer<T> c) =>
opts.Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c;
var randGen = new global::System.Random();
var culture = StringComparer.InvariantCultureIgnoreCase;
return (opts.SortProperty switch
{
// As for the OrderByDescending HasValue calls: https://www.jerriepelser.com/blog/orderby-with-null-values/
// We want nulls last no matter what, even if orders are reversed
SortProperty.Hid => input.OrderBy(g => g.Hid, ReverseMaybe(culture)),
SortProperty.Name => input.OrderBy(g => g.NameFor(ctx), ReverseMaybe(culture)),
SortProperty.CreationDate => input.OrderBy(g => g.Created, ReverseMaybe(Comparer<Instant>.Default)),
SortProperty.DisplayName => input
.OrderByDescending(g => g.DisplayName != null)
.ThenBy(g => g.DisplayName, ReverseMaybe(culture)),
SortProperty.Random => input
.OrderBy(g => randGen.Next()),
_ => throw new ArgumentOutOfRangeException($"Unknown sort property {opts.SortProperty}")
})
// Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values)
.ThenBy(m => m.NameFor(ctx), culture);
}
} }
public enum SortProperty public enum SortProperty

View File

@ -415,7 +415,10 @@ public class MemberEdit
await _repo.UpdateMember(target.Id, patch); await _repo.UpdateMember(target.Id, patch);
await PrintSuccess( await PrintSuccess(
$"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\"."); $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name}\".");
if (target.NamePrivacy == PrivacyLevel.Private)
await ctx.Reply($"{Emojis.Warn} Since this member no longer has a display name set, their name privacy **can no longer take effect**.");
} }
else else
{ {

View File

@ -41,9 +41,9 @@ public class Random
{ {
ctx.CheckSystem(); ctx.CheckSystem();
var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); var groups = await _repo.GetSystemGroups(ctx.System.Id).ToListAsync();
if (!ctx.MatchFlag("all", "a")) if (!ctx.MatchFlag("all", "a"))
groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); groups = groups.Where(g => g.Visibility == PrivacyLevel.Public).ToList();
if (groups == null || !groups.Any()) if (groups == null || !groups.Any())
throw new PKError( throw new PKError(
@ -57,7 +57,7 @@ public class Random
{ {
ctx.CheckOwnGroup(group); ctx.CheckOwnGroup(group);
var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(group.System)); var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System));
opts.GroupFilter = group.Id; opts.GroupFilter = group.Id;
await using var conn = await _db.Obtain(); await using var conn = await _db.Obtain();

View File

@ -12,10 +12,10 @@ public class SystemList
ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy); ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy);
// explanation of privacy lookup here: // explanation of privacy lookup here:
// - ParseMemberListOptions checks list access privacy and sets the privacy filter (which members show up in list) // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
// - RenderMemberList checks the indivual privacy for each member (NameFor, etc) // - RenderMemberList checks the indivual privacy for each member (NameFor, etc)
// the own system is always allowed to look up their list // the own system is always allowed to look up their list
var opts = ctx.ParseMemberListOptions(ctx.DirectLookupContextFor(target.Id)); var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id));
await ctx.RenderMemberList( await ctx.RenderMemberList(
ctx.LookupContextFor(target.Id), ctx.LookupContextFor(target.Id),
target.Id, target.Id,
@ -25,7 +25,7 @@ public class SystemList
); );
} }
private string GetEmbedTitle(PKSystem target, MemberListOptions opts) private string GetEmbedTitle(PKSystem target, ListOptions opts)
{ {
var title = new StringBuilder("Members of "); var title = new StringBuilder("Members of ");

View File

@ -245,7 +245,7 @@ public class EmbedService
var memberCount = await _repo.GetGroupMemberCount(target.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public); var memberCount = await _repo.GetGroupMemberCount(target.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
var nameField = target.Name; var nameField = target.NamePrivacy.Get(pctx, target.Name, target.DisplayName ?? target.Name);
if (system.Name != null) if (system.Name != null)
nameField = $"{nameField} ({system.Name})"; nameField = $"{nameField} ({system.Name})";
@ -262,14 +262,14 @@ public class EmbedService
var eb = new EmbedBuilder() var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx))) .Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx)))
.Color(color) .Color(color);
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(ctx.Zone)}"));
if (target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
if (target.DescriptionPrivacy.CanAccess(pctx))
eb.Image(new Embed.EmbedImage(target.BannerImage)); eb.Image(new Embed.EmbedImage(target.BannerImage));
if (target.DisplayName != null) if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null)
eb.Field(new Embed.Field("Display Name", target.DisplayName, true)); eb.Field(new Embed.Field("Display Name", target.DisplayName, true));
if (!target.Color.EmptyOrNull()) eb.Field(new Embed.Field("Color", $"#{target.Color}", true)); if (!target.Color.EmptyOrNull()) eb.Field(new Embed.Field("Color", $"#{target.Color}", true));
@ -281,8 +281,12 @@ public class EmbedService
eb.Field(new Embed.Field("Members (0)", eb.Field(new Embed.Field("Members (0)",
$"Add one with `pk;group {target.Reference()} add <member>`!")); $"Add one with `pk;group {target.Reference()} add <member>`!"));
else else
eb.Field(new Embed.Field($"Members ({memberCount})", {
$"(see `pk;group {target.Reference()} list`)")); var name = pctx == LookupContext.ByOwner
? target.Reference()
: target.Hid;
eb.Field(new Embed.Field($"Members ({memberCount})", $"(see `pk;group {name} list`)"));
}
} }
if (target.DescriptionFor(pctx) is { } desc) if (target.DescriptionFor(pctx) is { } desc)

View File

@ -9,9 +9,15 @@ public static class ModelUtils
public static string NameFor(this PKMember member, Context ctx) => public static string NameFor(this PKMember member, Context ctx) =>
member.NameFor(ctx.LookupContextFor(member.System)); member.NameFor(ctx.LookupContextFor(member.System));
public static string NameFor(this PKGroup group, Context ctx) =>
group.NameFor(ctx.LookupContextFor(group.System));
public static string AvatarFor(this PKMember member, Context ctx) => public static string AvatarFor(this PKMember member, Context ctx) =>
member.AvatarFor(ctx.LookupContextFor(member.System)).TryGetCleanCdnUrl(); member.AvatarFor(ctx.LookupContextFor(member.System)).TryGetCleanCdnUrl();
public static string IconFor(this PKGroup group, Context ctx) =>
group.IconFor(ctx.LookupContextFor(group.System)).TryGetCleanCdnUrl();
public static string DisplayName(this PKMember member) => public static string DisplayName(this PKMember member) =>
member.DisplayName ?? member.Name; member.DisplayName ?? member.Name;

View File

@ -0,0 +1,7 @@
-- schema version 25
-- group name privacy
alter table groups add column name_privacy integer check (name_privacy in (1, 2)) not null default 1;
alter table groups add column metadata_privacy integer check (metadata_privacy in (1, 2)) not null default 1;
update info set schema_version = 25;

View File

@ -10,11 +10,39 @@ public static class DatabaseViewsExt
public static Task<IEnumerable<SystemFronter>> QueryCurrentFronters(this IPKConnection conn, SystemId system) => public static Task<IEnumerable<SystemFronter>> QueryCurrentFronters(this IPKConnection conn, SystemId system) =>
conn.QueryAsync<SystemFronter>("select * from system_fronters where system = @system", new { system }); conn.QueryAsync<SystemFronter>("select * from system_fronters where system = @system", new { system });
public static Task<IEnumerable<ListedGroup>> QueryGroupList(this IPKConnection conn, SystemId system) => public static Task<IEnumerable<ListedGroup>> QueryGroupList(this IPKConnection conn, SystemId system,
conn.QueryAsync<ListedGroup>("select * from group_list where system = @System", new { System = system }); ListQueryOptions opts)
{
StringBuilder query = new StringBuilder("select * from group_list where system = @system");
if (opts.PrivacyFilter != null)
query.Append($" and visibility = {(int)opts.PrivacyFilter}");
if (opts.Search != null)
{
static string Filter(string column) =>
$"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";
query.Append($" and ({Filter("name")} or {Filter("display_name")}");
if (opts.SearchDescription)
{
// We need to account for the possibility of description privacy when searching
// If we're looking up from the outside, only search "public_description" (defined in the view; null if desc is private)
// If we're the owner, just search the full description
var descriptionColumn =
opts.Context == LookupContext.ByOwner ? "description" : "public_description";
query.Append($"or {Filter(descriptionColumn)}");
}
query.Append(")");
}
return conn.QueryAsync<ListedGroup>(
query.ToString(),
new { system, filter = opts.Search });
}
public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system, public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system,
MemberListQueryOptions opts) ListQueryOptions opts)
{ {
StringBuilder query; StringBuilder query;
if (opts.GroupFilter == null) if (opts.GroupFilter == null)
@ -49,7 +77,7 @@ public static class DatabaseViewsExt
new { system, filter = opts.Search, groupFilter = opts.GroupFilter }); new { system, filter = opts.Search, groupFilter = opts.GroupFilter });
} }
public struct MemberListQueryOptions public struct ListQueryOptions
{ {
public PrivacyLevel? PrivacyFilter; public PrivacyLevel? PrivacyFilter;
public string? Search; public string? Search;

View File

@ -2,5 +2,6 @@ namespace PluralKit.Core;
public class ListedGroup: PKGroup public class ListedGroup: PKGroup
{ {
public int MemberCount { get; } public int PublicMemberCount { get; }
public int TotalMemberCount { get; }
} }

View File

@ -64,5 +64,12 @@ select groups.*,
inner join members on group_members.member_id = members.id inner join members on group_members.member_id = members.id
where where
group_members.group_id = groups.id and members.member_visibility = 1 group_members.group_id = groups.id and members.member_visibility = 1
) as member_count ) as public_member_count,
-- Find private group member count
(
select count(*) from group_members
inner join members on group_members.member_id = members.id
where
group_members.group_id = groups.id
) as total_member_count
from groups; from groups;

View File

@ -43,9 +43,11 @@ public class PKGroup
public string? BannerImage { get; private set; } public string? BannerImage { get; private set; }
public string? Color { get; private set; } public string? Color { get; private set; }
public PrivacyLevel NamePrivacy { get; private set; }
public PrivacyLevel DescriptionPrivacy { get; private set; } public PrivacyLevel DescriptionPrivacy { get; private set; }
public PrivacyLevel IconPrivacy { get; private set; } public PrivacyLevel IconPrivacy { get; private set; }
public PrivacyLevel ListPrivacy { get; private set; } public PrivacyLevel ListPrivacy { get; private set; }
public PrivacyLevel MetadataPrivacy { get; private set; }
public PrivacyLevel Visibility { get; private set; } public PrivacyLevel Visibility { get; private set; }
public Instant Created { get; private set; } public Instant Created { get; private set; }
@ -53,12 +55,18 @@ public class PKGroup
public static class PKGroupExt public static class PKGroupExt
{ {
public static string? NameFor(this PKGroup group, LookupContext ctx) =>
group.NamePrivacy.Get(ctx, group.Name, group.DisplayName ?? group.Name);
public static string? DescriptionFor(this PKGroup group, LookupContext ctx) => public static string? DescriptionFor(this PKGroup group, LookupContext ctx) =>
group.DescriptionPrivacy.Get(ctx, group.Description); group.DescriptionPrivacy.Get(ctx, group.Description);
public static string? IconFor(this PKGroup group, LookupContext ctx) => public static string? IconFor(this PKGroup group, LookupContext ctx) =>
group.IconPrivacy.Get(ctx, group.Icon?.TryGetCleanCdnUrl()); group.IconPrivacy.Get(ctx, group.Icon?.TryGetCleanCdnUrl());
public static Instant? CreatedFor(this PKGroup group, LookupContext ctx) =>
group.MetadataPrivacy.Get(ctx, (Instant?)group.Created);
public static JObject ToJson(this PKGroup group, LookupContext ctx, string? systemStr = null, public static JObject ToJson(this PKGroup group, LookupContext ctx, string? systemStr = null,
bool needsMembersArray = false) bool needsMembersArray = false)
{ {
@ -66,18 +74,18 @@ public static class PKGroupExt
o.Add("id", group.Hid); o.Add("id", group.Hid);
o.Add("uuid", group.Uuid.ToString()); o.Add("uuid", group.Uuid.ToString());
o.Add("name", group.Name); o.Add("name", group.NameFor(ctx));
if (systemStr != null) if (systemStr != null)
o.Add("system", systemStr); o.Add("system", systemStr);
o.Add("display_name", group.DisplayName); o.Add("display_name", group.NamePrivacy.CanAccess(ctx) ? group.DisplayName : null);
o.Add("description", group.DescriptionPrivacy.Get(ctx, group.Description)); o.Add("description", group.DescriptionPrivacy.Get(ctx, group.Description));
o.Add("icon", group.IconFor(ctx)); o.Add("icon", group.IconFor(ctx));
o.Add("banner", group.DescriptionPrivacy.Get(ctx, group.BannerImage)); o.Add("banner", group.DescriptionPrivacy.Get(ctx, group.BannerImage));
o.Add("color", group.Color); o.Add("color", group.Color);
o.Add("created", group.Created.FormatExport()); o.Add("created", group.CreatedFor(ctx)?.FormatExport());
if (needsMembersArray) if (needsMembersArray)
o.Add("members", new JArray()); o.Add("members", new JArray());
@ -86,9 +94,11 @@ public static class PKGroupExt
{ {
var p = new JObject(); var p = new JObject();
p.Add("name_privacy", group.NamePrivacy.ToJsonString());
p.Add("description_privacy", group.DescriptionPrivacy.ToJsonString()); p.Add("description_privacy", group.DescriptionPrivacy.ToJsonString());
p.Add("icon_privacy", group.IconPrivacy.ToJsonString()); p.Add("icon_privacy", group.IconPrivacy.ToJsonString());
p.Add("list_privacy", group.ListPrivacy.ToJsonString()); p.Add("list_privacy", group.ListPrivacy.ToJsonString());
p.Add("metadata_privacy", group.MetadataPrivacy.ToJsonString());
p.Add("visibility", group.Visibility.ToJsonString()); p.Add("visibility", group.Visibility.ToJsonString());
o.Add("privacy", p); o.Add("privacy", p);

View File

@ -15,9 +15,11 @@ public class GroupPatch: PatchObject
public Partial<string?> BannerImage { get; set; } public Partial<string?> BannerImage { get; set; }
public Partial<string?> Color { get; set; } public Partial<string?> Color { get; set; }
public Partial<PrivacyLevel> NamePrivacy { get; set; }
public Partial<PrivacyLevel> DescriptionPrivacy { get; set; } public Partial<PrivacyLevel> DescriptionPrivacy { get; set; }
public Partial<PrivacyLevel> IconPrivacy { get; set; } public Partial<PrivacyLevel> IconPrivacy { get; set; }
public Partial<PrivacyLevel> ListPrivacy { get; set; } public Partial<PrivacyLevel> ListPrivacy { get; set; }
public Partial<PrivacyLevel> MetadataPrivacy { get; set; }
public Partial<PrivacyLevel> Visibility { get; set; } public Partial<PrivacyLevel> Visibility { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
@ -28,9 +30,11 @@ public class GroupPatch: PatchObject
.With("icon", Icon) .With("icon", Icon)
.With("banner_image", BannerImage) .With("banner_image", BannerImage)
.With("color", Color) .With("color", Color)
.With("name_privacy", NamePrivacy)
.With("description_privacy", DescriptionPrivacy) .With("description_privacy", DescriptionPrivacy)
.With("icon_privacy", IconPrivacy) .With("icon_privacy", IconPrivacy)
.With("list_privacy", ListPrivacy) .With("list_privacy", ListPrivacy)
.With("metadata_privacy", MetadataPrivacy)
.With("visibility", Visibility) .With("visibility", Visibility)
); );
@ -74,6 +78,9 @@ public class GroupPatch: PatchObject
{ {
var privacy = o.Value<JObject>("privacy"); var privacy = o.Value<JObject>("privacy");
if (privacy.ContainsKey("name_privacy"))
patch.NamePrivacy = patch.ParsePrivacy(privacy, "name_privacy");
if (privacy.ContainsKey("description_privacy")) if (privacy.ContainsKey("description_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy"); patch.DescriptionPrivacy = patch.ParsePrivacy(privacy, "description_privacy");
@ -83,6 +90,9 @@ public class GroupPatch: PatchObject
if (privacy.ContainsKey("list_privacy")) if (privacy.ContainsKey("list_privacy"))
patch.ListPrivacy = patch.ParsePrivacy(privacy, "list_privacy"); patch.ListPrivacy = patch.ParsePrivacy(privacy, "list_privacy");
if (privacy.ContainsKey("metadata_privacy"))
patch.MetadataPrivacy = patch.ParsePrivacy(privacy, "metadata_privacy");
if (privacy.ContainsKey("visibility")) if (privacy.ContainsKey("visibility"))
patch.Visibility = patch.ParsePrivacy(privacy, "visibility"); patch.Visibility = patch.ParsePrivacy(privacy, "visibility");
} }
@ -110,14 +120,19 @@ public class GroupPatch: PatchObject
o.Add("color", Color.Value); o.Add("color", Color.Value);
if ( if (
DescriptionPrivacy.IsPresent NamePrivacy.IsPresent
|| DescriptionPrivacy.IsPresent
|| IconPrivacy.IsPresent || IconPrivacy.IsPresent
|| ListPrivacy.IsPresent || ListPrivacy.IsPresent
|| MetadataPrivacy.IsPresent
|| Visibility.IsPresent || Visibility.IsPresent
) )
{ {
var p = new JObject(); var p = new JObject();
if (NamePrivacy.IsPresent)
p.Add("name_privacy", NamePrivacy.Value.ToJsonString());
if (DescriptionPrivacy.IsPresent) if (DescriptionPrivacy.IsPresent)
p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString()); p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString());
@ -127,6 +142,9 @@ public class GroupPatch: PatchObject
if (ListPrivacy.IsPresent) if (ListPrivacy.IsPresent)
p.Add("list_privacy", ListPrivacy.Value.ToJsonString()); p.Add("list_privacy", ListPrivacy.Value.ToJsonString());
if (MetadataPrivacy.IsPresent)
p.Add("metadata_privacy", MetadataPrivacy.Value.ToJsonString());
if (Visibility.IsPresent) if (Visibility.IsPresent)
p.Add("visibility", Visibility.Value.ToJsonString()); p.Add("visibility", Visibility.Value.ToJsonString());

View File

@ -2,9 +2,11 @@ namespace PluralKit.Core;
public enum GroupPrivacySubject public enum GroupPrivacySubject
{ {
Name,
Description, Description,
Icon, Icon,
List, List,
Metadata,
Visibility Visibility
} }
@ -15,9 +17,11 @@ public static class GroupPrivacyUtils
// what do you mean switch expressions can't be statements >.> // what do you mean switch expressions can't be statements >.>
_ = subject switch _ = subject switch
{ {
GroupPrivacySubject.Name => group.NamePrivacy = level,
GroupPrivacySubject.Description => group.DescriptionPrivacy = level, GroupPrivacySubject.Description => group.DescriptionPrivacy = level,
GroupPrivacySubject.Icon => group.IconPrivacy = level, GroupPrivacySubject.Icon => group.IconPrivacy = level,
GroupPrivacySubject.List => group.ListPrivacy = level, GroupPrivacySubject.List => group.ListPrivacy = level,
GroupPrivacySubject.Metadata => group.MetadataPrivacy = level,
GroupPrivacySubject.Visibility => group.Visibility = level, GroupPrivacySubject.Visibility => group.Visibility = level,
_ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
}; };
@ -36,6 +40,9 @@ public static class GroupPrivacyUtils
{ {
switch (input.ToLowerInvariant()) switch (input.ToLowerInvariant())
{ {
case "name":
subject = GroupPrivacySubject.Name;
break;
case "description": case "description":
case "desc": case "desc":
case "text": case "text":
@ -54,6 +61,11 @@ public static class GroupPrivacyUtils
case "visible": case "visible":
subject = GroupPrivacySubject.Visibility; subject = GroupPrivacySubject.Visibility;
break; break;
case "meta":
case "metadata":
case "created":
subject = GroupPrivacySubject.Metadata;
break;
case "list": case "list":
case "listing": case "listing":
case "members": case "members":

View File

@ -76,7 +76,7 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID.
|color|string|6-character hex code, no `#` at the beginning| |color|string|6-character hex code, no `#` at the beginning|
|privacy|?group privacy object|| |privacy|?group privacy object||
* Group privacy keys: `description_privacy`, `icon_privacy`, `list_privacy`, `visibility` * Group privacy keys: `name_privacy`, `description_privacy`, `icon_privacy`, `list_privacy`, `metadata_privacy`, `visibility`
### Switch model ### Switch model

View File

@ -89,7 +89,7 @@ Some arguments indicate the use of specific Discord features. These include:
- `pk;group <group> description [description]` - Shows or changes a group's description. - `pk;group <group> description [description]` - Shows or changes a group's description.
- `pk;group <group> add <member> [member 2] [member 3...]` - Adds one or more members to a group. - `pk;group <group> add <member> [member 2] [member 3...]` - Adds one or more members to a group.
- `pk;group <group> remove <member> [member 2] [member 3...]` - Removes one or more members from a group. - `pk;group <group> remove <member> [member 2] [member 3...]` - Removes one or more members from a group.
- `pk;group <group> privacy <description|icon|visibility|all> <public|private>` - Changes a group's privacy settings. - `pk;group <group> privacy <name|description|icon|visibility|metadata|all> <public|private>` - Changes a group's privacy settings.
- `pk;group <group> icon [icon url|@mention|upload]` - Shows or changes a group's icon. - `pk;group <group> icon [icon url|@mention|upload]` - Shows or changes a group's icon.
- `pk;group <group> banner [image url|upload]` - Shows or changes a group's banner image. - `pk;group <group> banner [image url|upload]` - Shows or changes a group's banner image.
- `pk;group <group> delete` - Deletes a group. - `pk;group <group> delete` - Deletes a group.

View File

@ -32,47 +32,43 @@ PluralKit has a couple of useful command shorthands to reduce the typing:
There are a number of option flags that can be added to the `pk;system list` command. There are a number of option flags that can be added to the `pk;system list` command.
### Sorting options ### Sorting options
|Flag|Aliases|Description| |Flag|Aliases|Lists|Description|
|---|---|---| |---|---|---|---|
|-by-name|-bn|Sort by member name (default)| |-by-name|-bn|Member, Group|Sort by name (default)|
|-by-display-name|-bdn|Sort by display name| |-by-display-name|-bdn|Member, Group|Sort by display name|
|-by-id|-bid|Sort by member ID| |-by-id|-bid|Member, Group|Sort by ID|
|-by-message-count|-bmc|Sort by message count (members with the most messages will appear near the top)| |-by-message-count|-bmc|Member|Sort by message count (members with the most messages will appear near the top)|
|-by-created|-bc|Sort by creation date (members least recently created will appear near the top)| |-by-created|-bc|Member, Group|Sort by creation date (least recently created will appear near the top)|
|-by-last-fronted|-by-last-front, -by-last-switch, -blf, -bls|Sort by most recently fronted| |-by-last-fronted|-by-last-front, -by-last-switch, -blf, -bls|Member|Sort by most recently fronted|
|-by-last-message|-blm, -blp|Sort by last message time (members who most recently sent a proxied message will appear near the top)| |-by-last-message|-blm, -blp|Member|Sort by last message time (members who most recently sent a proxied message will appear near the top)|
|-by-birthday|-by-birthdate, -bbd|Sort by birthday (members whose birthday is in January will appear near the top)| |-by-birthday|-by-birthdate, -bbd|Member|Sort by birthday (members whose birthday is in January will appear near the top)|
|-reverse|-rev, -r|Reverse previously chosen sorting order| |-reverse|-rev, -r|Member, Group|Reverse previously chosen sorting order|
|-random||Sort randomly| |-random||Member, Group|Sort randomly|
### Filter options ### Filter options
|Flag|Aliases|Description| |Flag|Aliases|Lists|Description|
|---|---|---| |---|---|---|---|
|-all|-a|Show all members, including private members| |-all|-a|Member, Group|Show all members/groups, including private members/groups|
|-private-only|-po|Only show private members| |-private-only|-po|Member, Group|Only show private members/groups|
::: warning ::: warning
You cannot look up private members of another system. You cannot look up private members or groups of another system.
::: :::
### Additional fields to include in the search results ### Additional fields to include in the search results
|Flag|Aliases|Description| |Flag|Aliases|Lists|Description|
|---|---|---| |---|---|---|---|
|-with-last-switch|-with-last-fronted, -with-last-front, -wls, -wlf|Show each member's last switch date| |-with-last-switch|-with-last-fronted, -with-last-front, -wls, -wlf|Member|Show each member's last switch date|
|-with-last-message|-with-last-proxy, -wlm, -wlp|Show each member's last message date| |-with-last-message|-with-last-proxy, -wlm, -wlp|Member|Show each member's last message date|
|-with-message-count|-wmc|Show each member's message count| |-with-message-count|-wmc|Member|Show each member's message count|
|-with-created|-wc|Show each member's creation date| |-with-created|-wc|Member, Group|Show each item's creation date|
|-with-avatar|-wa, -wi, -ia, -ii, -img|Show each member's avatar URL| |-with-avatar|-wa, -wi, -ia, -ii, -img|Member, Group|Show each item's avatar URL|
|-with-pronouns|-wp|Show each member's pronouns in the short list (shown by default in full list)| |-with-pronouns|-wp|Member|Show each member's pronouns in the short list (shown by default in full list)|
::: warning
These flags only work with the full member list (`pk;system list full`).
:::
## Miscellaneous flags ## Miscellaneous flags
|Command|Flag|Aliases|Description| |Command|Flag|Aliases|Description|
|---|---|---|---| |---|---|---|---|
|pk;system list|-search-description|-sd|Search inside descriptions instead of member names| |List commands|-search-description|-sd|Search inside descriptions instead of member/group names|
|pk;system frontpercent|-fronters-only|-fo|Show the system's frontpercent without the "no fronter" entry| |pk;system frontpercent|-fronters-only|-fo|Show the system's frontpercent without the "no fronter" entry|
|pk;system frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%| |pk;system frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%|
|pk;group \<group> frontpercent|-fronters-only|-fo|Show a group's frontpercent without the "no fronter" entry| |pk;group \<group> frontpercent|-fronters-only|-fo|Show a group's frontpercent without the "no fronter" entry|