From 721a4502bb86a0d4927ed09fd305346e234e1e99 Mon Sep 17 00:00:00 2001
From: BeeFox-sys <42090331+BeeFox-sys@users.noreply.github.com>
Date: Thu, 18 Jun 2020 05:31:39 +1000
Subject: [PATCH 01/13] Feature/granular member privacy (#174)

* Some reasons this needs to exist for it to run on my machine? I don't think it would hurt to have it in other machines so

* Add options to member model

* Add Privacy to member embed

* Added member privacy display list

* Update database settings

* apparetnly this is nolonger needed?

* Fix sql call

* Fix more sql errors

* Added in settings control

* Add all subject to system privacy

* Basic API Privacy

* Name privacy in logs

* update todo

* remove CheckReadMemberPermission

* Added name privacy to log embed

* update todo

* Update todo

* Update api to handle privacy

* update todo

* Update systemlist full to respect privacy (as well as system list)

* include colour as option for member privacy subject

* move todo file (why was it there?)

* Update TODO.md

* Update TODO.md

* Update TODO.md

* Deleted to create pr

* Update command usage and add to the command tree

* Make api respect created privacy

* Add editing privacy through the api

* Fix pronoun privacy field in api

* Fix info leak of display name in api

* deprecate privacy field in api

* Deprecate privacy diffrently

* Update API

* Update documentation

* Update documentation

* Remove comment in yml

* Update userguide

* Update migration (fix typo in 5.sql too)

* Sanatize names

* some full stops

* Fix after merge

* update migration

* update schema version

* update edit command

* update privacy filter

* fix a dumb mistake

* clarify on what name privacy does

* make it easier on someone else

* Update docs

* Comment out unused code

* Add aliases for `member privacy all public` and `member privacy all private`
---
 PluralKit.API/Controllers/v1/JsonModelExt.cs  |  54 ++++--
 .../Controllers/v1/SystemController.cs        |   2 +-
 PluralKit.Bot/Commands/CommandTree.cs         |  10 +-
 PluralKit.Bot/Commands/Member.cs              |   2 +-
 PluralKit.Bot/Commands/MemberEdit.cs          | 160 +++++++++++++++---
 PluralKit.Bot/Commands/SystemEdit.cs          |  21 ++-
 PluralKit.Bot/Commands/SystemList.cs          |   2 +-
 PluralKit.Bot/Lists/IListRenderer.cs          |   2 +-
 PluralKit.Bot/Lists/LongRenderer.cs           |  23 +--
 PluralKit.Bot/Lists/ShortRenderer.cs          |   6 +-
 PluralKit.Bot/Services/EmbedService.cs        |  32 ++--
 PluralKit.Core/Database/Database.cs           |   2 +-
 PluralKit.Core/Database/Migrations/8.sql      |  22 +++
 .../Database/Views/DatabaseViewsExt.cs        |   2 +-
 PluralKit.Core/Models/PKMember.cs             |   8 +-
 PluralKit.Core/Services/PostgresDataStore.cs  |   6 +-
 docs/2-user-guide.md                          |  34 +++-
 docs/3-command-list.md                        |   4 +
 docs/4-api-documentation.md                   |  92 ++++++++--
 19 files changed, 389 insertions(+), 95 deletions(-)
 create mode 100644 PluralKit.Core/Database/Migrations/8.sql

diff --git a/PluralKit.API/Controllers/v1/JsonModelExt.cs b/PluralKit.API/Controllers/v1/JsonModelExt.cs
index 1ba2fd15..f4c7b34f 100644
--- a/PluralKit.API/Controllers/v1/JsonModelExt.cs
+++ b/PluralKit.API/Controllers/v1/JsonModelExt.cs
@@ -44,14 +44,13 @@ namespace PluralKit.API
         {
             var o = new JObject();
             o.Add("id", member.Hid);
-            o.Add("name", member.Name);
-            o.Add("color", member.MemberPrivacy.CanAccess(ctx) ? member.Color : null);
-            o.Add("display_name", member.DisplayName);
-            o.Add("birthday", member.MemberPrivacy.CanAccess(ctx) && member.Birthday.HasValue ? DateTimeFormats.DateExportFormat.Format(member.Birthday.Value) : null);
-            o.Add("pronouns", member.MemberPrivacy.CanAccess(ctx) ? member.Pronouns : null);
+            o.Add("name", member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name);
+            o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
+            o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
+            o.Add("birthday", member.BirthdayPrivacy.CanAccess(ctx) && member.Birthday.HasValue ? DateTimeFormats.DateExportFormat.Format(member.Birthday.Value) : null);
+            o.Add("pronouns", member.PronounPrivacy.CanAccess(ctx) ? member.Pronouns : null);
             o.Add("avatar_url", member.AvatarUrl);
-            o.Add("description", member.MemberPrivacy.CanAccess(ctx) ? member.Description : null);
-            o.Add("privacy", ctx == LookupContext.ByOwner ? (member.MemberPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("description", member.DescriptionPrivacy.CanAccess(ctx) ? member.Description : null);
             
             var tagArray = new JArray();
             foreach (var tag in member.ProxyTags) 
@@ -59,7 +58,22 @@ namespace PluralKit.API
             o.Add("proxy_tags", tagArray);
 
             o.Add("keep_proxy", member.KeepProxy);
-            o.Add("created", DateTimeFormats.TimestampExportFormat.Format(member.Created));
+
+            o.Add("privacy", ctx == LookupContext.ByOwner ? (member.MemberVisibility == PrivacyLevel.Private ? "private" : "public") : null);
+
+            o.Add("visibility", ctx == LookupContext.ByOwner ? (member.MemberVisibility == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("name_privacy", ctx == LookupContext.ByOwner ? (member.NamePrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("description_privacy", ctx == LookupContext.ByOwner ? (member.DescriptionPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("birthday_privacy", ctx == LookupContext.ByOwner ? (member.BirthdayPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("pronoun_privacy", ctx == LookupContext.ByOwner ? (member.PronounPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("metadata_privacy", ctx == LookupContext.ByOwner ? (member.MetadataPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+
+            if(member.MetadataPrivacy.CanAccess(ctx))
+                o.Add("created", DateTimeFormats.TimestampExportFormat.Format(member.Created));
+            else
+                o.Add("created", null);
+            
 
             if (member.ProxyTags.Count > 0)
             {
@@ -101,8 +115,28 @@ namespace PluralKit.API
                     .OfType<JObject>().Select(o => new ProxyTag(o.Value<string>("prefix"), o.Value<string>("suffix")))
                     .ToList();
             }
-            
-            if (o.ContainsKey("privacy")) member.MemberPrivacy = o.Value<string>("privacy").ParsePrivacy("member");
+            if(o.ContainsKey("privacy")) //TODO: Deprecate this completely in api v2
+            {
+                var plevel = o.Value<string>("privacy").ParsePrivacy("member");
+                                
+                member.MemberVisibility = plevel;
+                member.NamePrivacy = plevel;
+                member.DescriptionPrivacy = plevel;
+                member.BirthdayPrivacy = plevel;
+                member.PronounPrivacy = plevel;
+                member.ColorPrivacy = plevel;
+                member.MetadataPrivacy = plevel;
+            }
+            else
+            {
+                if (o.ContainsKey("visibility")) member.MemberVisibility = o.Value<string>("visibility").ParsePrivacy("member");
+                if (o.ContainsKey("name_privacy")) member.NamePrivacy = o.Value<string>("name_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("description_privacy")) member.DescriptionPrivacy = o.Value<string>("description_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("birthday_privacy")) member.BirthdayPrivacy = o.Value<string>("birthday_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("pronoun_privacy")) member.PronounPrivacy = o.Value<string>("pronoun_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.Value<string>("color_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("metadata_privacy")) member.MetadataPrivacy = o.Value<string>("metadata_privacy").ParsePrivacy("member");
+            }
         }
 
         private static string BoundsCheckField(this string input, int maxLength, string nameInError)
diff --git a/PluralKit.API/Controllers/v1/SystemController.cs b/PluralKit.API/Controllers/v1/SystemController.cs
index 8129b874..84d26899 100644
--- a/PluralKit.API/Controllers/v1/SystemController.cs
+++ b/PluralKit.API/Controllers/v1/SystemController.cs
@@ -78,7 +78,7 @@ namespace PluralKit.API
 
             var members = _data.GetSystemMembers(system);
             return Ok(await members
-                .Where(m => m.MemberPrivacy.CanAccess(User.ContextFor(system)))
+                .Where(m => m.MemberVisibility.CanAccess(User.ContextFor(system)))
                 .Select(m => m.ToJson(User.ContextFor(system)))
                 .ToListAsync());
         }
diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs
index c52b6249..909ca4f3 100644
--- a/PluralKit.Bot/Commands/CommandTree.cs
+++ b/PluralKit.Bot/Commands/CommandTree.cs
@@ -24,8 +24,8 @@ namespace PluralKit.Bot
         public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)");
         public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history");
         public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown");
-        public static Command SystemPrivacy = new Command("system privacy", "system privacy <description|members|fronter|fronthistory> <public|private>", "Changes your system's privacy settings");
         public static Command SystemPing = new Command("system ping", "system ping <enable|disable>", "Changes your system's ping preferences");
+        public static Command SystemPrivacy = new Command("system privacy", "system privacy <description|members|fronter|fronthistory|all> <public|private>", "Changes your system's privacy settings");
         public static Command Autoproxy = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for this server");
         public static Command MemberInfo = new Command("member", "member <member>", "Looks up information about a member");
         public static Command MemberNew = new Command("member new", "member new <name>", "Creates a new member");
@@ -42,7 +42,7 @@ namespace PluralKit.Bot
         public static Command MemberServerName = new Command("member servername", "member <member> servername [server name]", "Changes a member's display name in the current server");
         public static Command MemberKeepProxy = new Command("member keepproxy", "member <member> keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying");
         public static Command MemberRandom = new Command("random", "random", "Looks up a random member from your system");
-        public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy [on|off]", "Sets whether a member is private or public");
+        public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|color|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
         public static Command Switch = new Command("switch", "switch <member> [member 2] [member 3...]", "Registers a switch");
         public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members");
         public static Command SwitchMove = new Command("switch move", "switch move <date/time>", "Moves the latest switch in time");
@@ -72,7 +72,7 @@ namespace PluralKit.Bot
 
         public static Command[] MemberCommands = {
             MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns,
-            MemberColor, MemberBirthday, MemberProxy, MemberKeepProxy, MemberDelete, MemberAvatar, MemberServerAvatar,
+            MemberColor, MemberBirthday, MemberProxy, MemberKeepProxy, MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy,
             MemberRandom
         };
 
@@ -297,9 +297,9 @@ namespace PluralKit.Bot
                 await ctx.Execute<MemberEdit>(MemberKeepProxy, m => m.KeepProxy(ctx, target));
             else if (ctx.Match("privacy"))
                 await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null));
-            else if (ctx.Match("private", "hidden"))
+            else if (ctx.Match("private", "hidden", "hide"))
                 await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private));
-            else if (ctx.Match("public", "shown"))
+            else if (ctx.Match("public", "shown", "show"))
                 await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public));
             else if (!ctx.HasNext()) // Bare command
                 await ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, target));
diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs
index 0d56ec96..4d62607a 100644
--- a/PluralKit.Bot/Commands/Member.cs
+++ b/PluralKit.Bot/Commands/Member.cs
@@ -59,7 +59,7 @@ namespace PluralKit.Bot
             //Maybe move this somewhere else in the file structure since it doesn't need to get created at every command
 
             // TODO: don't buffer these, find something else to do ig
-            var members = await _data.GetSystemMembers(ctx.System).Where(m => m.MemberPrivacy == PrivacyLevel.Public).ToListAsync();
+            var members = await _data.GetSystemMembers(ctx.System).Where(m => m.MemberVisibility == PrivacyLevel.Public).ToListAsync();
             if (members == null || !members.Any())
                 throw Errors.NoMembersError;
             var randInt = randGen.Next(members.Count);
diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index a79f2f96..8e8fc642 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -1,5 +1,7 @@
 using System.Text.RegularExpressions;
 using System.Threading.Tasks;
+using System;
+
 
 using Dapper;
 
@@ -53,12 +55,6 @@ namespace PluralKit.Bot
             }
         }
 
-        private void CheckReadMemberPermission(Context ctx, PKMember target)
-        {
-            if (!target.MemberPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
-                throw Errors.LookupNotAllowed;
-        }
-
         private void CheckEditMemberPermission(Context ctx, PKMember target)
         {
             if (target.System != ctx.System?.Id) throw Errors.NotOwnMemberError;
@@ -78,7 +74,8 @@ namespace PluralKit.Bot
             } 
             else if (!ctx.HasNext())
             {
-                CheckReadMemberPermission(ctx, target);
+                if (!target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
+                    throw Errors.LookupNotAllowed;
                 if (target.Description == null)
                     if (ctx.System?.Id == target.System)
                         await ctx.Reply($"This member does not have a description set. To set one, type `pk;member {target.Hid} description <description>`.");
@@ -119,7 +116,8 @@ namespace PluralKit.Bot
             } 
             else if (!ctx.HasNext())
             {
-                CheckReadMemberPermission(ctx, target);
+                if (!target.PronounPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
+                    throw Errors.LookupNotAllowed;
                 if (target.Pronouns == null)
                     if (ctx.System?.Id == target.System)
                         await ctx.Reply($"This member does not have pronouns set. To set some, type `pk;member {target.Hid} pronouns <pronouns>`.");
@@ -155,7 +153,8 @@ namespace PluralKit.Bot
             }
             else if (!ctx.HasNext())
             {
-                CheckReadMemberPermission(ctx, target);
+                if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
+                    throw Errors.LookupNotAllowed;
 
                 if (target.Color == null)
                     if (ctx.System?.Id == target.System)
@@ -199,7 +198,8 @@ namespace PluralKit.Bot
             } 
             else if (!ctx.HasNext())
             {
-                CheckReadMemberPermission(ctx, target);
+                if (!target.BirthdayPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
+                    throw Errors.LookupNotAllowed;
                 
                 if (target.Birthday == null)
                     await ctx.Reply("This member does not have a birthdate set."
@@ -365,29 +365,137 @@ namespace PluralKit.Bot
             if (ctx.System == null) throw Errors.NoSystemError;
             if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
 
-            PrivacyLevel newValue;
-            if (ctx.Match("private", "hide", "hidden", "on", "enable", "yes")) newValue = PrivacyLevel.Private;
-            else if (ctx.Match("public", "show", "shown", "displayed", "off", "disable", "no")) newValue = PrivacyLevel.Public;
-            else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"private\" or \"public\".");
-            // If we're getting a value from command (eg. "pk;m <name> private" == always private, "pk;m <name> public == always public"), use that instead of parsing
-            else if (newValueFromCommand != null) newValue = newValueFromCommand.Value;
-            else
+            // Display privacy settings
+            if (!ctx.HasNext() && newValueFromCommand == null)
             {
-                if (target.MemberPrivacy == PrivacyLevel.Public)
-                    await ctx.Reply("This member's privacy is currently set to **public**. This member will show up in member lists and will return all information when queried by other accounts.");
-                else
-                    await ctx.Reply("This member's privacy is currently set to **private**. This member will not show up in member lists and will return limited information when queried by other accounts.");
+                string PrivacyLevelString(PrivacyLevel level) => level switch
+                {
+                    PrivacyLevel.Private => "**Private** (visible only when queried by you)",
+                    PrivacyLevel.Public => "**Public** (visible to everyone)",
+                    _ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
+                };
 
+                var eb = new DiscordEmbedBuilder()
+                    .WithTitle($"Current privacy settings for {target.Name}")
+                    .AddField("Name (replaces name with display name if member has one)",PrivacyLevelString(target.NamePrivacy))
+                    .AddField("Description", PrivacyLevelString(target.DescriptionPrivacy))
+                    .AddField("Birthday", PrivacyLevelString(target.BirthdayPrivacy))
+                    .AddField("Pronouns", PrivacyLevelString(target.PronounPrivacy))
+                    .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
+                    .AddField("Meta (message count, last front, last message)", PrivacyLevelString(target.MetadataPrivacy))
+                    .AddField("Visibility", PrivacyLevelString(target.MemberVisibility))
+                    .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `color`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.");
+                await ctx.Reply(embed: eb.Build());
                 return;
             }
 
-            target.MemberPrivacy = newValue;
-            await _data.SaveMember(target);
+            // Set Privacy Settings
+            PrivacyLevel PopPrivacyLevel(string subject, out string levelStr, out string levelExplanation)
+            {
+                if (ctx.Match("public", "show", "shown", "visible"))
+                {
+                    levelStr = "public";
+                    levelExplanation = "be shown on the member card";
+                    return PrivacyLevel.Public;
+                }
 
-            if (newValue == PrivacyLevel.Private)
-                await ctx.Reply($"{Emojis.Success} Member privacy set to **private**. This member will no longer show up in member lists and will return limited information when queried by other accounts.");
+                if (ctx.Match("private", "hide", "hidden"))
+                {
+                    levelStr = "private";
+                    levelExplanation = "*not* be shown on the member card";
+                    if(subject == "name") levelExplanation += " unless no display name is set";
+                    return PrivacyLevel.Private;
+                }
+
+                if (!ctx.HasNext())
+                    throw new PKSyntaxError($"You must pass a privacy level for `{subject}` (`public` or `private`)");
+                throw new PKSyntaxError($"Invalid privacy level `{ctx.PopArgument().SanitizeMentions()}` (must be `public` or `private`).");
+            }
+
+            string levelStr, levelExplanation, subjectStr;
+            var subjectList = "`name`, `description`, `birthday`, `pronouns`, `color`, `metadata`, `visibility`, or `all`";
+            if(ctx.Match("name"))
+            {
+                subjectStr = "name";
+                target.NamePrivacy = PopPrivacyLevel("name", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("description", "desc", "text", "info"))
+            {
+                subjectStr = "description";
+                target.DescriptionPrivacy = PopPrivacyLevel("description", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("birthday", "birth", "bday"))
+            {
+                subjectStr = "birthday";
+                target.BirthdayPrivacy = PopPrivacyLevel("birthday", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("pronouns", "pronoun"))
+            {
+                subjectStr = "pronouns";
+                target.PronounPrivacy = PopPrivacyLevel("pronouns", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("color","colour"))
+            {
+                subjectStr = "color";
+                target.ColorPrivacy = PopPrivacyLevel("color", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("meta","metadata"))
+            {
+                subjectStr = "metadata (date created, message count, last fronted, and last message)";
+                target.MetadataPrivacy = PopPrivacyLevel("metadata", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("visibility","hidden","shown","visible"))
+            {
+                subjectStr = "visibility";
+                target.MemberVisibility = PopPrivacyLevel("visibility", out levelStr, out levelExplanation);
+            }
+            else if(ctx.Match("all") || newValueFromCommand != null){
+                subjectStr = "all";
+                PrivacyLevel level;
+                if(newValueFromCommand != null)
+                {
+                    if(newValueFromCommand == PrivacyLevel.Public)
+                    {
+                        level = PrivacyLevel.Public;
+                        levelStr = "public";
+                        levelExplanation = "be shown on the member card";
+                    }
+                    else
+                    {
+                        level = PrivacyLevel.Private;
+                        levelStr = "private";
+                        levelExplanation = "*not* be shown on the member card";
+                    }
+                }
+                else
+                    level = PopPrivacyLevel("all", out levelStr, out levelExplanation);
+                target.MemberVisibility = level;
+                target.NamePrivacy = level;
+                target.DescriptionPrivacy = level;
+                target.BirthdayPrivacy = level;
+                target.PronounPrivacy = level;
+                target.ColorPrivacy = level;
+                target.MetadataPrivacy = level;
+            }            
             else
-                await ctx.Reply($"{Emojis.Success} Member privacy set to **public**. This member will now show up in member lists and will return all information when queried by other accounts.");
+                throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList}).");
+
+
+            await _data.SaveMember(target);
+            //Handle "all" subject
+            if(subjectStr == "all"){
+                if(levelStr == "private")
+                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{levelStr}**. Other accounts will now see nothing on the member card.");
+                else 
+                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{levelStr}**. Other accounts will now see everything on the member card.");
+            } 
+            //Handle other subjects
+            else
+                await ctx.Reply($"{target.Name.SanitizeMentions()}'s {subjectStr} has been set to **{levelStr}**. Other accounts will now {levelExplanation}.");
+
+
+
+
         }
         
         public async Task Delete(Context ctx, PKMember target)
diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs
index 477a9122..935e156f 100644
--- a/PluralKit.Bot/Commands/SystemEdit.cs
+++ b/PluralKit.Bot/Commands/SystemEdit.cs
@@ -262,7 +262,7 @@ namespace PluralKit.Bot
                     .AddField("Member list", PrivacyLevelString(ctx.System.MemberListPrivacy))
                     .AddField("Current fronter(s)", PrivacyLevelString(ctx.System.FrontPrivacy))
                     .AddField("Front/switch history", PrivacyLevelString(ctx.System.FrontHistoryPrivacy))
-                    .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy <subject> <level>`\n\n- `subject` is one of `description`, `list`, `front` or `fronthistory`\n- `level` is either `public` or `private`.");
+                    .WithDescription("To edit privacy settings, use the command:\n`pk;system privacy <subject> <level>`\n\n- `subject` is one of `description`, `list`, `front`, `fronthistory`, or `all` \n- `level` is either `public` or `private`.");
                 await ctx.Reply(embed: eb.Build());
                 return;
             }
@@ -289,7 +289,7 @@ namespace PluralKit.Bot
             }
 
             string levelStr, levelExplanation, subjectStr;
-            var subjectList = "`description`, `members`, `front` or `fronthistory`";
+            var subjectList = "`description`, `members`, `front`, `fronthistory`, or `all`";
             if (ctx.Match("description", "desc", "text", "info"))
             {
                 subjectStr = "description";
@@ -310,10 +310,27 @@ namespace PluralKit.Bot
                 subjectStr = "front history";
                 ctx.System.FrontHistoryPrivacy = PopPrivacyLevel("fronthistory", out levelStr, out levelExplanation);
             }
+            else if (ctx.Match("all")){
+                subjectStr = "all";
+                PrivacyLevel level = PopPrivacyLevel("all", out levelStr, out levelExplanation);
+                ctx.System.DescriptionPrivacy = level;
+                ctx.System.MemberListPrivacy = level;
+                ctx.System.FrontPrivacy = level;
+                ctx.System.FrontHistoryPrivacy = level;
+
+            }
             else
                 throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList}).");
 
             await _data.SaveSystem(ctx.System);
+            if(subjectStr == "all"){
+                if(levelStr == "private")
+                    await ctx.Reply($"All of your systems privacy settings have been set to **{levelStr}**. Other accounts will now see nothing on the member card.");
+                else 
+                    await ctx.Reply($"All of your systems privacy have been set to **{levelStr}**. Other accounts will now see everything on the member card.");
+            } 
+            //Handle other subjects
+            else
             await ctx.Reply($"System {subjectStr} privacy has been set to **{levelStr}**. Other accounts will now {levelExplanation} your system {subjectStr}.");
         }
 
diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs
index 3b633353..2a4ca989 100644
--- a/PluralKit.Bot/Commands/SystemList.cs
+++ b/PluralKit.Bot/Commands/SystemList.cs
@@ -33,7 +33,7 @@ namespace PluralKit.Bot
                 (eb, ms) =>
                 {
                     eb.WithFooter($"{opts.CreateFilterString()}. {members.Count} results.");
-                    renderer.RenderPage(eb, ctx.System.Zone, ms);
+                    renderer.RenderPage(eb, ctx.System.Zone, ms, ctx.LookupContextFor(target));
                     return Task.CompletedTask;
                 });
         }
diff --git a/PluralKit.Bot/Lists/IListRenderer.cs b/PluralKit.Bot/Lists/IListRenderer.cs
index 23368882..24bf545d 100644
--- a/PluralKit.Bot/Lists/IListRenderer.cs
+++ b/PluralKit.Bot/Lists/IListRenderer.cs
@@ -11,6 +11,6 @@ namespace PluralKit.Bot
     public interface IListRenderer
     {
         int MembersPerPage { get; }
-        void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members);
+        void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members, LookupContext ctx);
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Bot/Lists/LongRenderer.cs b/PluralKit.Bot/Lists/LongRenderer.cs
index b22684db..5b3b528e 100644
--- a/PluralKit.Bot/Lists/LongRenderer.cs
+++ b/PluralKit.Bot/Lists/LongRenderer.cs
@@ -20,25 +20,26 @@ namespace PluralKit.Bot
             _fields = fields;
         }
 
-        public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members)
+        public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone zone, IEnumerable<ListedMember> members, LookupContext ctx)
         {
             string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(zone));
 
             foreach (var m in members)
             {
                 var profile = $"**ID**: {m.Hid}";
-                if (_fields.ShowDisplayName && m.DisplayName != null) profile += $"\n**Display name**: {m.DisplayName}";
-                if (_fields.ShowPronouns && m.Pronouns != null) profile += $"\n**Pronouns**: {m.Pronouns}";
-                if (_fields.ShowBirthday && m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}";
+                if (_fields.ShowDisplayName && m.DisplayName != null && m.NamePrivacy.CanAccess(ctx)) profile += $"\n**Display name**: {m.DisplayName}";
+                if (_fields.ShowPronouns && m.Pronouns != null && m.PronounPrivacy.CanAccess(ctx)) profile += $"\n**Pronouns**: {m.Pronouns}";
+                if (_fields.ShowBirthday && m.Birthday != null && m.BirthdayPrivacy.CanAccess(ctx)) profile += $"\n**Birthdate**: {m.BirthdayString}";
                 if (_fields.ShowProxyTags && m.ProxyTags.Count > 0) profile += $"\n**Proxy tags:** {m.ProxyTagsString()}";
-                if (_fields.ShowMessageCount && m.MessageCount > 0) profile += $"\n**Message count:** {m.MessageCount}";
-                if (_fields.ShowLastMessage && m.LastMessage != null) profile += $"\n**Last message:** {FormatTimestamp(DiscordUtils.SnowflakeToInstant(m.LastMessage.Value))}";
-                if (_fields.ShowLastSwitch && m.LastSwitchTime != null) profile += $"\n**Last switched in:** {FormatTimestamp(m.LastSwitchTime.Value)}";
-                if (_fields.ShowDescription && m.Description != null) profile += $"\n\n{m.Description}";
-                if (_fields.ShowPrivacy && m.MemberPrivacy == PrivacyLevel.Private)
-                    profile += "\n*(this member is private)*";
+                if (_fields.ShowMessageCount && m.MessageCount > 0 && m.MetadataPrivacy.CanAccess(ctx)) profile += $"\n**Message count:** {m.MessageCount}";
+                if (_fields.ShowLastMessage && m.LastMessage != null && m.MetadataPrivacy.CanAccess(ctx)) profile += $"\n**Last message:** {FormatTimestamp(DiscordUtils.SnowflakeToInstant(m.LastMessage.Value))}";
+                if (_fields.ShowLastSwitch && m.LastSwitchTime != null && m.MetadataPrivacy.CanAccess(ctx)) profile += $"\n**Last switched in:** {FormatTimestamp(m.LastSwitchTime.Value)}";
+                if (_fields.ShowDescription && m.Description != null && m.DescriptionPrivacy.CanAccess(ctx)) profile += $"\n\n{m.Description}";
+                if (_fields.ShowPrivacy && m.MemberVisibility == PrivacyLevel.Private)
+                    profile += "\n*(this member is hidden)*";
 
-                eb.AddField(m.Name, profile.Truncate(1024));
+                var memberName = m.NamePrivacy.CanAccess(ctx) ? m.Name : (m.DisplayName ?? m.Name);
+                eb.AddField(memberName, profile.Truncate(1024));
             }
         }
         
diff --git a/PluralKit.Bot/Lists/ShortRenderer.cs b/PluralKit.Bot/Lists/ShortRenderer.cs
index d2404c32..b2d1084b 100644
--- a/PluralKit.Bot/Lists/ShortRenderer.cs
+++ b/PluralKit.Bot/Lists/ShortRenderer.cs
@@ -13,7 +13,7 @@ namespace PluralKit.Bot
     {
         public int MembersPerPage => 25;
         
-        public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone timezone, IEnumerable<ListedMember> members)
+        public void RenderPage(DiscordEmbedBuilder eb, DateTimeZone timezone, IEnumerable<ListedMember> members, LookupContext ctx)
         {
             string RenderLine(ListedMember m)
             {
@@ -22,8 +22,8 @@ namespace PluralKit.Bot
                     var proxyTagsString = m.ProxyTagsString().SanitizeMentions();
                     if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak?
                         proxyTagsString = "tags too long, see member card";
-
-                    return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({proxyTagsString})*";
+                    var memberName = m.NamePrivacy.CanAccess(ctx) ? m.Name : (m.DisplayName ?? m.Name);
+                    return $"[`{m.Hid}`] **{memberName.SanitizeMentions()}** *({proxyTagsString})*";
                 }
 
                 return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**";
diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs
index 2a2e1ccd..fdddb0cc 100644
--- a/PluralKit.Bot/Services/EmbedService.cs
+++ b/PluralKit.Bot/Services/EmbedService.cs
@@ -26,6 +26,8 @@ namespace PluralKit.Bot {
             _db = db;
         }
 
+
+        
         public async Task<DiscordEmbed> CreateSystemEmbed(DiscordClient client, PKSystem system, LookupContext ctx) {
             var accounts = await _data.GetSystemAccounts(system);
 
@@ -68,8 +70,9 @@ namespace PluralKit.Bot {
         public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) {
             // TODO: pronouns in ?-reacted response using this card
             var timestamp = DiscordUtils.SnowflakeToInstant(messageId);
+            var name = member.NamePrivacy == PrivacyLevel.Public ? member.Name : member.DisplayName ?? member.Name;
             return new DiscordEmbedBuilder()
-                .WithAuthor($"#{channel.Name}: {member.Name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarUrl))
+                .WithAuthor($"#{channel.Name}: {name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarUrl))
                 .WithThumbnailUrl(member.AvatarUrl)
                 .WithDescription(content?.NormalizeLineEndSpacing())
                 .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}")
@@ -79,7 +82,10 @@ namespace PluralKit.Bot {
 
         public async Task<DiscordEmbed> CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx)
         {
-            var name = member.Name;
+
+            // string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
+
+            var name = member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name;
             if (system.Name != null) name = $"{member.Name} ({system.Name})";
 
             DiscordColor color;
@@ -104,11 +110,11 @@ namespace PluralKit.Bot {
             var eb = new DiscordEmbedBuilder()
                 // TODO: add URL of website when that's up
                 .WithAuthor(name, iconUrl: DiscordUtils.WorkaroundForUrlBug(avatar))
-                .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
-                .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}");
+                .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
+                .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}":"")}");
 
             var description = "";
-            if (member.MemberPrivacy == PrivacyLevel.Private) description += "*(this member is private)*\n";
+            if (member.MemberVisibility == PrivacyLevel.Private) description += "*(this member is hidden)*\n";
             if (guildSettings?.AvatarUrl != null)
                 if (member.AvatarUrl != null) 
                     description += $"*(this member has a server-specific avatar set; [click here]({member.AvatarUrl}) to see the global avatar)*\n";
@@ -118,14 +124,18 @@ namespace PluralKit.Bot {
 
             if (avatar != null) eb.WithThumbnailUrl(avatar);
 
-            if (!member.DisplayName.EmptyOrNull()) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true);
+            if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx)) eb.AddField("Display Name", member.DisplayName.Truncate(1024), true);
             if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true);
-            if (member.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true);
-            if (!member.Pronouns.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true);
-            if (member.MessageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", member.MessageCount.ToString(), true);
+            if (member.Birthday != null && member.BirthdayPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true);
+            if (!member.Pronouns.EmptyOrNull() && member.PronounPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true);
+            if (member.MessageCount > 0 && member.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Message Count", member.MessageCount.ToString(), true);
             if (member.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true);
-            if (!member.Color.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
-            if (!member.Description.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false);
+            // --- For when this gets added to the member object itself or however they get added
+            // if (member.LastMessage != null && member.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last message:" FormatTimestamp(DiscordUtils.SnowflakeToInstant(m.LastMessage.Value)));
+            // if (member.LastSwitchTime != null && m.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last switched in:", FormatTimestamp(member.LastSwitchTime.Value));
+            if (!member.Color.EmptyOrNull() && member.ColorPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
+            
+            if (!member.Description.EmptyOrNull() && member.DescriptionPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false);
 
             return eb.Build();
         }
diff --git a/PluralKit.Core/Database/Database.cs b/PluralKit.Core/Database/Database.cs
index b010df62..09e6ff20 100644
--- a/PluralKit.Core/Database/Database.cs
+++ b/PluralKit.Core/Database/Database.cs
@@ -20,7 +20,7 @@ namespace PluralKit.Core
     internal class Database: IDatabase
     {
         private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files
-        private const int TargetSchemaVersion = 7;
+        private const int TargetSchemaVersion = 8;
         
         private readonly CoreConfig _config;
         private readonly ILogger _logger;
diff --git a/PluralKit.Core/Database/Migrations/8.sql b/PluralKit.Core/Database/Migrations/8.sql
new file mode 100644
index 00000000..28439e5f
--- /dev/null
+++ b/PluralKit.Core/Database/Migrations/8.sql
@@ -0,0 +1,22 @@
+-- SCHEMA VERSION 8: 2020-05-13 --
+-- Create new columns --
+alter table members add column description_privacy integer check (description_privacy in (1, 2)) not null default 1;
+alter table members add column name_privacy integer check (name_privacy in (1, 2)) not null default 1;
+alter table members add column birthday_privacy integer check (birthday_privacy in (1, 2)) not null default 1;
+alter table members add column pronoun_privacy integer check (pronoun_privacy in (1, 2)) not null default 1;
+alter table members add column metadata_privacy integer check (metadata_privacy in (1, 2)) not null default 1;
+alter table members add column color_privacy integer check (color_privacy in (1, 2)) not null default 1;
+
+-- Transfer existing settings --
+update members set description_privacy = member_privacy;
+update members set name_privacy = member_privacy;
+update members set birthday_privacy = member_privacy;
+update members set pronoun_privacy = member_privacy;
+update members set metadata_privacy = member_privacy;
+update members set color_privacy = member_privacy;
+
+-- Rename member_privacy to member_visibility --
+alter table members rename column member_privacy to member_visibility;
+
+-- Update Schema Info --
+update info set schema_version = 8;
diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
index b62637af5..df03e43a 100644
--- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
+++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
@@ -17,7 +17,7 @@ namespace PluralKit.Core
             StringBuilder query = new StringBuilder("select * from member_list where system = @system");
 
             if (privacyFilter != null)
-                query.Append($" and member_privacy = {(int) privacyFilter}");
+                query.Append($" and member_visibility = {(int) privacyFilter}");
 
             if (filter != null)
             {
diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs
index c13c9c05..18ba7947 100644
--- a/PluralKit.Core/Models/PKMember.cs
+++ b/PluralKit.Core/Models/PKMember.cs
@@ -23,7 +23,13 @@ namespace PluralKit.Core {
         public Instant Created { get; }
         public int MessageCount { get; }
 
-        public PrivacyLevel MemberPrivacy { get; set; }
+        public PrivacyLevel MemberVisibility { get; set; }
+        public PrivacyLevel DescriptionPrivacy { get; set; }
+        public PrivacyLevel NamePrivacy { get; set; } //ignore setting if no display name is set
+        public PrivacyLevel BirthdayPrivacy { get; set; }
+        public PrivacyLevel PronounPrivacy { get; set; }
+        public PrivacyLevel MetadataPrivacy { get; set; }
+        public PrivacyLevel ColorPrivacy { get; set; }
 
         /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" or "0004" is hidden
         /// Before Feb 10 2020, the sentinel year was 0001, now it is 0004.
diff --git a/PluralKit.Core/Services/PostgresDataStore.cs b/PluralKit.Core/Services/PostgresDataStore.cs
index 84e34df6..a0783b59 100644
--- a/PluralKit.Core/Services/PostgresDataStore.cs
+++ b/PluralKit.Core/Services/PostgresDataStore.cs
@@ -150,7 +150,7 @@ namespace PluralKit.Core {
 
         public async Task SaveMember(PKMember member) {
             using (var conn = await _conn.Obtain())
-                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_privacy = @MemberPrivacy where id = @Id", member);
+                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_visibility = @MemberVisibility, description_privacy = @DescriptionPrivacy,  name_privacy = @NamePrivacy, birthday_privacy = @BirthdayPrivacy, pronoun_privacy = @PronounPrivacy, metadata_privacy = @MetadataPrivacy, color_privacy = @ColorPrivacy where id = @Id", member);
 
             _logger.Information("Updated member {@Member}", member);
         }
@@ -164,8 +164,8 @@ namespace PluralKit.Core {
 
         public async Task<int> GetSystemMemberCount(SystemId id, bool includePrivate)
         {
-            var query = "select count(*) from members where system = @id";
-            if (!includePrivate) query += " and member_privacy = 1"; // 1 = public
+            var query = "select count(*) from members where system = @Id";
+            if (!includePrivate) query += " and member_visibility = 1"; // 1 = public
             
             using (var conn = await _conn.Obtain())
                 return await conn.ExecuteScalarAsync<int>(query, new { id });
diff --git a/docs/2-user-guide.md b/docs/2-user-guide.md
index 6160b5f7..78ac9403 100644
--- a/docs/2-user-guide.md
+++ b/docs/2-user-guide.md
@@ -438,7 +438,7 @@ To update your system privacy settings, use the following commands:
 
     pk;system privacy <subject> <level>
     
-where `<subject>` is either `description`, `fronter`, `fronthistory` or `list`, corresponding to the options above, and `level` is either `public` or `private`.
+where `<subject>` is either `description`, `fronter`, `fronthistory` or `list`, corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.
 
 For example:
 
@@ -449,15 +449,37 @@ For example:
 When the **member list** is **private**, other users will not be able to view the full member list of your system, but they can still query individual members given their 5-letter ID. If **current fronter** is private, but **front history** isn't, someone can still see the current fronter by looking at the history (this combination doesn't make much sense).
 
 ### Member privacy
-There is also an option to mark a specific member as private, using the command:
+There are also nine options for configuring member privacy;
 
-    pk;member <name> private
+- Name
+- Description
+- Birthday
+- Pronouns
+- Colour
+- Date created
+- Message count
+- Visibility
 
-A private member will *not* be displayed in member lists (even if the member list is public), and will show only limited information if looked up by others - namely name, display name and avatar. Other information, such as description, pronouns and birthday will be hidden.
+As with system privacy, each can be set to **public** or **private**. The same rules apply for how they are shown too. When set to **public**, anyone who queries your system (by account or system ID, or through the API), will see this information. When set to **private**, the information will only be shown when *you yourself* query the information. The cards will still be displayed in the channel the commands are run in, so it's still your responsibility not to pull up information in servers where you don't want it displayed.
 
-All of this can only be accessed using the member's 5-letter ID, which is exposed when proxying. So, if you want to keep a member absolutely private, it's recommended you don't proxy with it publicly - that way the ID isn't exposed.
+However there are two catches. When name is set to private, it will be replaced by a members display name, but only if they have one! When visibility is set to private, the member will not show up in the member list unless -all is used in the command (and you are part of the system).
+
+Member info will not be shown in member lists even if someone in the system queries the list, unless the user is part of the system and uses the -all flag.
+
+To update a members privacy you can use the command:
+
+    member <member> privacy <subject> <level>
+
+where `<member>` is the name or the id of a member in your system, `<subject>` is either `name`, `description`, `birthday`, `pronouns`, `color`, `metadata`, or `visiblity` corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.  
+`metatdata` will affect the message count, the date created, the last fronted, and the last message information.
+
+For example:
+
+    pk;member John privacy visibility private
+    pk;member "Craig Johnson" privacy description public
+    pk;member Robert privacy color public
+    pk;member Skyler privacy all private
 
-An example of a private member is `cmpuv` - try looking it up and see what's shown, as well as the corresponding system list (`pk;system exmpl list`).
 ## Moderation commands
 
 ### Log channel
diff --git a/docs/3-command-list.md b/docs/3-command-list.md
index db815301..caebfb8c 100644
--- a/docs/3-command-list.md
+++ b/docs/3-command-list.md
@@ -17,6 +17,8 @@ Words in **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
 - `pk;system rename [new name]` - Changes the name of your system.
 - `pk;system description [description]` - Changes the description of your system.
 - `pk;system avatar [avatar url]` - Changes the avatar of your system.
+- `pk;system privacy` - Displays your system's current privacy settings.
+- `pk;system privacy <subject> <public|private>` - Changes your systems privacy settings.
 - `pk;system tag [tag]` - Changes the system tag of your system.
 - `pk;system timezone [location]` - Changes the time zone of your system.
 - `pk;system proxy [on|off]` - Toggles message proxying for a specific server. 
@@ -42,6 +44,8 @@ Words in **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
 - `pk;member <name> description [description]` - Changes the description of a member.
 - `pk;member <name> avatar <avatar url|@mention>` - Changes the avatar of a member.
 - `pk;member <name> serveravatar <avatar url|@mention>` - Changes the avatar of a member in a specific server.
+- `pk;member <name> privacy` - Displays a members current privacy settings.
+- `pk;member <name> privacy <subject> <public|private>` - Changes a members privacy setting.
 - `pk;member <name> proxy [tags]` - Changes the proxy tags of a member. use below add/remove commands for members with multiple tag pairs.
 - `pk;member <name> proxy add [tags]` - Adds a proxy tag pair to a member.
 - `pk;member <name> proxy remove [tags]` - Removes a proxy tag from a member.
diff --git a/docs/4-api-documentation.md b/docs/4-api-documentation.md
index b2979eb7..f2a0b4e9 100644
--- a/docs/4-api-documentation.md
+++ b/docs/4-api-documentation.md
@@ -59,12 +59,20 @@ The following three models (usually represented in JSON format) represent the va
 |color|color?|Yes|6-char hex (eg. `ff7000`), sans `#`.|
 |avatar_url|url?|Yes|Not validated server-side.|
 |birthday|date?|Yes|ISO-8601 (`YYYY-MM-DD`) format, year of `0001` or `0004` means hidden year. Birthdays set after 2020-02-10 use `0004` as a sentinel year, but both options are recognized as valid.|
-|prefix|string?|Yes|Deprecated. Use `proxy_tags` instead.|
-|suffix|string?|Yes|Deprecated. Use `proxy_tags` instead.|
+|prefix|string?|Yes|**Deprecated.** Use `proxy_tags` instead.|
+|suffix|string?|Yes|**Deprecated.** Use `proxy_tags` instead.|
 |proxy_tags|ProxyTag[]|Yes (entire array)|An array of ProxyTag (see below) objects, each representing a single prefix/suffix pair.|
 |keep_proxy|bool|Yes|Whether to display a member's proxy tags in the proxied message.|
-|created|datetime|No||
-|privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|created|datetime|No|
+|privacy|string?|Yes|**Deprecated.** Use `<subject>_privacy` and `visibility` fields.|
+|visibility|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|name_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|description_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|birthday_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|pronoun_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|color_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|message_count_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|created_timestamp_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 
 #### ProxyTag object
 
@@ -165,7 +173,14 @@ If the request is not authenticated with the system's token, members marked as p
         "proxy_tags": [{"prefix": "[", "suffix": "]"}],
         "keep_proxy": false,
         "created": "2019-01-01T15:00:00.654321Z",
-        "privacy": null
+        "visibility": null,
+        "name_privacy": null,
+        "description_privacy": null,
+        "birthday_privacy": null,
+        "pronoun_privacy": null,
+        "color_privacy": null,
+        "message_count_privacy": null,
+        "created_timestamp_privacy": null
     }
 ]
 ```
@@ -220,6 +235,14 @@ If the system has chosen to hide its current fronters, this will return `403 For
             "description": "I am Craig, example user extraordinaire.",
             "proxy_tags": [{"prefix": "[", "suffix": "]"}],
             "keep_proxy": false,
+            "visibility": null,
+            "name_privacy": null,
+            "description_privacy": null,
+            "birthday_privacy": null,
+            "pronoun_privacy": null,
+            "color_privacy": null,
+            "message_count_privacy": null,
+            "created_timestamp_privacy": null,
             "created": "2019-01-01T15:00:00.654321Z"
         }
     ]
@@ -302,7 +325,14 @@ If this member is marked private, and the request isn't authenticated with the m
     "proxy_tags": [{"prefix": "[", "suffix": "]"}],
     "keep_proxy": false,
     "created": "2019-01-01T15:00:00.654321Z",
-    "privacy": "public"
+    "visibility": "public",
+    "name_privacy": "public",
+    "description_privacy": "private",
+    "birthday_privacy": "private",
+    "pronoun_privacy": "public",
+    "color_privacy": "public",
+    "message_count_privacy": "private",
+    "created_timestamp_privacy": "public"
 }
 ```
 
@@ -324,7 +354,14 @@ Creates a new member with the information given. Missing fields (except for name
     "pronouns": "they/them",
     "description": "I am Craig, cooler example user extraordinaire.",
     "keep_proxy": false,
-    "privacy": "public"
+    "visibility": "public",
+    "name_privacy": "public",
+    "description_privacy": "private",
+    "birthday_privacy": "private",
+    "pronoun_privacy": "public",
+    "color_privacy": "public",
+    "message_count_privacy": "private",
+    "created_timestamp_privacy": "public"
 }
 ```
 (note the absence of a `proxy_tags` field, which is cleared in the response)
@@ -343,7 +380,14 @@ Creates a new member with the information given. Missing fields (except for name
     "proxy_tags": [],
     "keep_proxy": false,
     "created": "2019-01-01T15:00:00.654321Z",
-    "privacy": "public"
+    "visibility": "public",
+    "name_privacy": "public",
+    "description_privacy": "private",
+    "birthday_privacy": "private",
+    "pronoun_privacy": "public",
+    "color_privacy": "public",
+    "message_count_privacy": "private",
+    "created_timestamp_privacy": "public"
 }
 ```
 
@@ -365,7 +409,14 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "pronouns": "they/them",
     "description": "I am Craig, cooler example user extraordinaire.",
     "keep_proxy": false,
-    "privacy": "public"
+    "visibility": "public",
+    "name_privacy": "public",
+    "description_privacy": "private",
+    "birthday_privacy": "private",
+    "pronoun_privacy": "public",
+    "color_privacy": "public",
+    "message_count_privacy": "private",
+    "created_timestamp_privacy": "public"
 }
 ```
 (note the absence of a `proxy_tags` field, which keeps its old value in the response)
@@ -384,7 +435,14 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "proxy_tags": [{"prefix": "[", "suffix": "]"}],
     "keep_proxy": false,
     "created": "2019-01-01T15:00:00.654321Z",
-    "privacy": "public"
+    "visibility": "public",
+    "name_privacy": "public",
+    "description_privacy": "private",
+    "birthday_privacy": "private",
+    "pronoun_privacy": "public",
+    "color_privacy": "public",
+    "message_count_privacy": "private",
+    "created_timestamp_privacy": "public"
 }
 ```
 
@@ -459,12 +517,24 @@ The returned system and member's privacy settings will be respected, and as such
         "description": "I am Craig, example user extraordinaire.",
         "proxy_tags": [{"prefix": "[", "suffix": "]"}],
         "keep_proxy": false,
-        "created": "2019-01-01T15:00:00.654321Z"
+        "created": "2019-01-01T15:00:00.654321Z",
+        "visibility": "public",
+        "name_privacy": "public",
+        "description_privacy": "private",
+        "birthday_privacy": "private",
+        "pronoun_privacy": "public",
+        "color_privacy": "public",
+        "message_count_privacy": "private",
+        "created_timestamp_privacy": "public"
     }
 }
 ```
 
 ## Version history
+<!-- (Update this on official release) -->
+* 2020-05-14
+  * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `color_privacy`, `message_count_privacy`, and `created_timestamp_privacy`. All are strings and accept the values of `public`, `private` and `null`
+  * The `privacy` field has now been deprecated and should not be used. It is a reflection of visibility.
 * 2020-05-07
   * The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
 * 2020-02-10

From e92700f339ba897541fc5e950153dad1934377c7 Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 21:51:40 +0200
Subject: [PATCH 02/13] Comment out color privacy stuff for now (revisit
 later?)

---
 PluralKit.API/Controllers/v1/JsonModelExt.cs |  9 +++++----
 PluralKit.Bot/Commands/CommandTree.cs        |  2 +-
 PluralKit.Bot/Commands/MemberEdit.cs         | 16 ++++++++--------
 PluralKit.Bot/Services/EmbedService.cs       |  6 ++++--
 PluralKit.Core/Database/Migrations/8.sql     |  4 ++--
 PluralKit.Core/Models/PKMember.cs            |  4 ++--
 PluralKit.Core/Services/PostgresDataStore.cs |  2 +-
 7 files changed, 23 insertions(+), 20 deletions(-)

diff --git a/PluralKit.API/Controllers/v1/JsonModelExt.cs b/PluralKit.API/Controllers/v1/JsonModelExt.cs
index f4c7b34f..9cbb7a00 100644
--- a/PluralKit.API/Controllers/v1/JsonModelExt.cs
+++ b/PluralKit.API/Controllers/v1/JsonModelExt.cs
@@ -45,7 +45,8 @@ namespace PluralKit.API
             var o = new JObject();
             o.Add("id", member.Hid);
             o.Add("name", member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name);
-            o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
+            // o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
+            o.Add("color", member.Color);
             o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
             o.Add("birthday", member.BirthdayPrivacy.CanAccess(ctx) && member.Birthday.HasValue ? DateTimeFormats.DateExportFormat.Format(member.Birthday.Value) : null);
             o.Add("pronouns", member.PronounPrivacy.CanAccess(ctx) ? member.Pronouns : null);
@@ -66,7 +67,7 @@ namespace PluralKit.API
             o.Add("description_privacy", ctx == LookupContext.ByOwner ? (member.DescriptionPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("birthday_privacy", ctx == LookupContext.ByOwner ? (member.BirthdayPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("pronoun_privacy", ctx == LookupContext.ByOwner ? (member.PronounPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
-            o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            // o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("metadata_privacy", ctx == LookupContext.ByOwner ? (member.MetadataPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
 
             if(member.MetadataPrivacy.CanAccess(ctx))
@@ -124,7 +125,7 @@ namespace PluralKit.API
                 member.DescriptionPrivacy = plevel;
                 member.BirthdayPrivacy = plevel;
                 member.PronounPrivacy = plevel;
-                member.ColorPrivacy = plevel;
+                // member.ColorPrivacy = plevel;
                 member.MetadataPrivacy = plevel;
             }
             else
@@ -134,7 +135,7 @@ namespace PluralKit.API
                 if (o.ContainsKey("description_privacy")) member.DescriptionPrivacy = o.Value<string>("description_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("birthday_privacy")) member.BirthdayPrivacy = o.Value<string>("birthday_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("pronoun_privacy")) member.PronounPrivacy = o.Value<string>("pronoun_privacy").ParsePrivacy("member");
-                if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.Value<string>("color_privacy").ParsePrivacy("member");
+                // if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.Value<string>("color_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("metadata_privacy")) member.MetadataPrivacy = o.Value<string>("metadata_privacy").ParsePrivacy("member");
             }
         }
diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs
index 909ca4f3..ed73391e 100644
--- a/PluralKit.Bot/Commands/CommandTree.cs
+++ b/PluralKit.Bot/Commands/CommandTree.cs
@@ -42,7 +42,7 @@ namespace PluralKit.Bot
         public static Command MemberServerName = new Command("member servername", "member <member> servername [server name]", "Changes a member's display name in the current server");
         public static Command MemberKeepProxy = new Command("member keepproxy", "member <member> keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying");
         public static Command MemberRandom = new Command("random", "random", "Looks up a random member from your system");
-        public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|color|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
+        public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
         public static Command Switch = new Command("switch", "switch <member> [member 2] [member 3...]", "Registers a switch");
         public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members");
         public static Command SwitchMove = new Command("switch move", "switch move <date/time>", "Moves the latest switch in time");
diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index 8e8fc642..7fd9d887 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -153,8 +153,8 @@ namespace PluralKit.Bot
             }
             else if (!ctx.HasNext())
             {
-                if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
-                    throw Errors.LookupNotAllowed;
+                // if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
+                //     throw Errors.LookupNotAllowed;
 
                 if (target.Color == null)
                     if (ctx.System?.Id == target.System)
@@ -381,10 +381,10 @@ namespace PluralKit.Bot
                     .AddField("Description", PrivacyLevelString(target.DescriptionPrivacy))
                     .AddField("Birthday", PrivacyLevelString(target.BirthdayPrivacy))
                     .AddField("Pronouns", PrivacyLevelString(target.PronounPrivacy))
-                    .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
+                    // .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
                     .AddField("Meta (message count, last front, last message)", PrivacyLevelString(target.MetadataPrivacy))
                     .AddField("Visibility", PrivacyLevelString(target.MemberVisibility))
-                    .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `color`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.");
+                    .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.");
                 await ctx.Reply(embed: eb.Build());
                 return;
             }
@@ -413,7 +413,7 @@ namespace PluralKit.Bot
             }
 
             string levelStr, levelExplanation, subjectStr;
-            var subjectList = "`name`, `description`, `birthday`, `pronouns`, `color`, `metadata`, `visibility`, or `all`";
+            var subjectList = "`name`, `description`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`";
             if(ctx.Match("name"))
             {
                 subjectStr = "name";
@@ -434,11 +434,11 @@ namespace PluralKit.Bot
                 subjectStr = "pronouns";
                 target.PronounPrivacy = PopPrivacyLevel("pronouns", out levelStr, out levelExplanation);
             }
-            else if(ctx.Match("color","colour"))
+            /*else if(ctx.Match("color","colour"))
             {
                 subjectStr = "color";
                 target.ColorPrivacy = PopPrivacyLevel("color", out levelStr, out levelExplanation);
-            }
+            }*/
             else if(ctx.Match("meta","metadata"))
             {
                 subjectStr = "metadata (date created, message count, last fronted, and last message)";
@@ -474,7 +474,7 @@ namespace PluralKit.Bot
                 target.DescriptionPrivacy = level;
                 target.BirthdayPrivacy = level;
                 target.PronounPrivacy = level;
-                target.ColorPrivacy = level;
+                // target.ColorPrivacy = level;
                 target.MetadataPrivacy = level;
             }            
             else
diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs
index fdddb0cc..00f44dc7 100644
--- a/PluralKit.Bot/Services/EmbedService.cs
+++ b/PluralKit.Bot/Services/EmbedService.cs
@@ -110,7 +110,8 @@ namespace PluralKit.Bot {
             var eb = new DiscordEmbedBuilder()
                 // TODO: add URL of website when that's up
                 .WithAuthor(name, iconUrl: DiscordUtils.WorkaroundForUrlBug(avatar))
-                .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
+                // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
+                .WithColor(color)
                 .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}":"")}");
 
             var description = "";
@@ -133,7 +134,8 @@ namespace PluralKit.Bot {
             // --- For when this gets added to the member object itself or however they get added
             // if (member.LastMessage != null && member.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last message:" FormatTimestamp(DiscordUtils.SnowflakeToInstant(m.LastMessage.Value)));
             // if (member.LastSwitchTime != null && m.MetadataPrivacy.CanAccess(ctx)) eb.AddField("Last switched in:", FormatTimestamp(member.LastSwitchTime.Value));
-            if (!member.Color.EmptyOrNull() && member.ColorPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
+            // if (!member.Color.EmptyOrNull() && member.ColorPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
+            if (!member.Color.EmptyOrNull()) eb.AddField("Color", $"#{member.Color}", true);
             
             if (!member.Description.EmptyOrNull() && member.DescriptionPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false);
 
diff --git a/PluralKit.Core/Database/Migrations/8.sql b/PluralKit.Core/Database/Migrations/8.sql
index 28439e5f..366a9309 100644
--- a/PluralKit.Core/Database/Migrations/8.sql
+++ b/PluralKit.Core/Database/Migrations/8.sql
@@ -5,7 +5,7 @@ alter table members add column name_privacy integer check (name_privacy in (1, 2
 alter table members add column birthday_privacy integer check (birthday_privacy in (1, 2)) not null default 1;
 alter table members add column pronoun_privacy integer check (pronoun_privacy in (1, 2)) not null default 1;
 alter table members add column metadata_privacy integer check (metadata_privacy in (1, 2)) not null default 1;
-alter table members add column color_privacy integer check (color_privacy in (1, 2)) not null default 1;
+-- alter table members add column color_privacy integer check (color_privacy in (1, 2)) not null default 1;
 
 -- Transfer existing settings --
 update members set description_privacy = member_privacy;
@@ -13,7 +13,7 @@ update members set name_privacy = member_privacy;
 update members set birthday_privacy = member_privacy;
 update members set pronoun_privacy = member_privacy;
 update members set metadata_privacy = member_privacy;
-update members set color_privacy = member_privacy;
+-- update members set color_privacy = member_privacy;
 
 -- Rename member_privacy to member_visibility --
 alter table members rename column member_privacy to member_visibility;
diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs
index 18ba7947..01eb004c 100644
--- a/PluralKit.Core/Models/PKMember.cs
+++ b/PluralKit.Core/Models/PKMember.cs
@@ -29,8 +29,8 @@ namespace PluralKit.Core {
         public PrivacyLevel BirthdayPrivacy { get; set; }
         public PrivacyLevel PronounPrivacy { get; set; }
         public PrivacyLevel MetadataPrivacy { get; set; }
-        public PrivacyLevel ColorPrivacy { get; set; }
-
+        // public PrivacyLevel ColorPrivacy { get; set; }
+        
         /// Returns a formatted string representing the member's birthday, taking into account that a year of "0001" or "0004" is hidden
         /// Before Feb 10 2020, the sentinel year was 0001, now it is 0004.
         [JsonIgnore] public string BirthdayString
diff --git a/PluralKit.Core/Services/PostgresDataStore.cs b/PluralKit.Core/Services/PostgresDataStore.cs
index a0783b59..cf3490c7 100644
--- a/PluralKit.Core/Services/PostgresDataStore.cs
+++ b/PluralKit.Core/Services/PostgresDataStore.cs
@@ -150,7 +150,7 @@ namespace PluralKit.Core {
 
         public async Task SaveMember(PKMember member) {
             using (var conn = await _conn.Obtain())
-                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_visibility = @MemberVisibility, description_privacy = @DescriptionPrivacy,  name_privacy = @NamePrivacy, birthday_privacy = @BirthdayPrivacy, pronoun_privacy = @PronounPrivacy, metadata_privacy = @MetadataPrivacy, color_privacy = @ColorPrivacy where id = @Id", member);
+                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_visibility = @MemberVisibility, description_privacy = @DescriptionPrivacy,  name_privacy = @NamePrivacy, birthday_privacy = @BirthdayPrivacy, pronoun_privacy = @PronounPrivacy, metadata_privacy = @MetadataPrivacy where id = @Id", member);
 
             _logger.Information("Updated member {@Member}", member);
         }

From 327cd0aafd88ed3808ec4718666288a5d6b1d8d0 Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 21:55:39 +0200
Subject: [PATCH 03/13] Update documentation for privacy changes

---
 docs/2-user-guide.md        | 20 ++++++++---------
 docs/4-api-documentation.md | 45 +++++++++++--------------------------
 2 files changed, 22 insertions(+), 43 deletions(-)

diff --git a/docs/2-user-guide.md b/docs/2-user-guide.md
index 78ac9403..0057f4cd 100644
--- a/docs/2-user-guide.md
+++ b/docs/2-user-guide.md
@@ -449,35 +449,33 @@ For example:
 When the **member list** is **private**, other users will not be able to view the full member list of your system, but they can still query individual members given their 5-letter ID. If **current fronter** is private, but **front history** isn't, someone can still see the current fronter by looking at the history (this combination doesn't make much sense).
 
 ### Member privacy
-There are also nine options for configuring member privacy;
+There are also six options for configuring member privacy;
 
 - Name
 - Description
 - Birthday
 - Pronouns
-- Colour
-- Date created
-- Message count
-- Visibility
+- Metadata *(message count, creation date, etc)*
+- Visibility *(whether the member shows up in member lists)*
 
-As with system privacy, each can be set to **public** or **private**. The same rules apply for how they are shown too. When set to **public**, anyone who queries your system (by account or system ID, or through the API), will see this information. When set to **private**, the information will only be shown when *you yourself* query the information. The cards will still be displayed in the channel the commands are run in, so it's still your responsibility not to pull up information in servers where you don't want it displayed.
+As with system privacy, each can be set to **public** or **private**. The same rules apply for how they are shown, too. When set to **public**, anyone who queries your system (by account or system ID, or through the API), will see this information. When set to **private**, the information will only be shown when *you yourself* query the information. The cards will still be displayed in the channel the commands are run in, so it's still your responsibility not to pull up information in servers where you don't want it displayed.
 
-However there are two catches. When name is set to private, it will be replaced by a members display name, but only if they have one! When visibility is set to private, the member will not show up in the member list unless -all is used in the command (and you are part of the system).
-
-Member info will not be shown in member lists even if someone in the system queries the list, unless the user is part of the system and uses the -all flag.
+However, there are two catches:
+- When the **name** is set to private, it will be replaced by the member's **display name**, but only if they have one! If the member has no display name, **name privacy will not do anything**. PluralKit still needs some way to refer to a member by name :) 
+- When **visibility** is set to private, the member will not show up in member lists unless `-all` is used in the command (and you are part of the system).
 
 To update a members privacy you can use the command:
 
     member <member> privacy <subject> <level>
 
-where `<member>` is the name or the id of a member in your system, `<subject>` is either `name`, `description`, `birthday`, `pronouns`, `color`, `metadata`, or `visiblity` corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.  
+where `<member>` is the name or the id of a member in your system, `<subject>` is either `name`, `description`, `birthday`, `pronouns`, `metadata`, or `visiblity` corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.  
 `metatdata` will affect the message count, the date created, the last fronted, and the last message information.
 
 For example:
 
     pk;member John privacy visibility private
     pk;member "Craig Johnson" privacy description public
-    pk;member Robert privacy color public
+    pk;member Robert privacy birthday public
     pk;member Skyler privacy all private
 
 ## Moderation commands
diff --git a/docs/4-api-documentation.md b/docs/4-api-documentation.md
index f2a0b4e9..89a3bbe2 100644
--- a/docs/4-api-documentation.md
+++ b/docs/4-api-documentation.md
@@ -70,9 +70,7 @@ The following three models (usually represented in JSON format) represent the va
 |description_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |birthday_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |pronoun_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
-|color_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
-|message_count_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
-|created_timestamp_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|metadata_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 
 #### ProxyTag object
 
@@ -178,9 +176,7 @@ If the request is not authenticated with the system's token, members marked as p
         "description_privacy": null,
         "birthday_privacy": null,
         "pronoun_privacy": null,
-        "color_privacy": null,
-        "message_count_privacy": null,
-        "created_timestamp_privacy": null
+        "metadata_privacy": null
     }
 ]
 ```
@@ -240,9 +236,7 @@ If the system has chosen to hide its current fronters, this will return `403 For
             "description_privacy": null,
             "birthday_privacy": null,
             "pronoun_privacy": null,
-            "color_privacy": null,
-            "message_count_privacy": null,
-            "created_timestamp_privacy": null,
+            "metadata_privacy": null,
             "created": "2019-01-01T15:00:00.654321Z"
         }
     ]
@@ -307,7 +301,7 @@ Registers a new switch to your own system given a list of member IDs.
 
 ### GET /m/\<id>
 Queries a member's information by its 5-character member ID. If the member does not exist, will return `404 Not Found`.
-If this member is marked private, and the request isn't authenticated with the member's system's token, some fields (currently only `description`) will contain `null` rather than the true value. Regardless of privacy setting, a non-authenticated request will only receive `null` for the `privacy` field.
+If this member is marked private, and the request isn't authenticated with the member's system's token, some fields will contain `null` rather than the true value (corresponding with the privacy settings). Regardless of privacy setting, a non-authenticated request will only receive `null` for the privacy fields (and `visibility`).
 
 #### Example request
     GET https://api.pluralkit.me/v1/m/qwert
@@ -330,9 +324,7 @@ If this member is marked private, and the request isn't authenticated with the m
     "description_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
-    "color_privacy": "public",
-    "message_count_privacy": "private",
-    "created_timestamp_privacy": "public"
+    "metadata_privacy": "public"
 }
 ```
 
@@ -359,9 +351,7 @@ Creates a new member with the information given. Missing fields (except for name
     "description_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
-    "color_privacy": "public",
-    "message_count_privacy": "private",
-    "created_timestamp_privacy": "public"
+    "metadata_privacy": "private"
 }
 ```
 (note the absence of a `proxy_tags` field, which is cleared in the response)
@@ -385,9 +375,7 @@ Creates a new member with the information given. Missing fields (except for name
     "description_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
-    "color_privacy": "public",
-    "message_count_privacy": "private",
-    "created_timestamp_privacy": "public"
+    "metadata_privacy": "private"
 }
 ```
 
@@ -414,9 +402,7 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "description_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
-    "color_privacy": "public",
-    "message_count_privacy": "private",
-    "created_timestamp_privacy": "public"
+    "metadata_privacy": "private"
 }
 ```
 (note the absence of a `proxy_tags` field, which keeps its old value in the response)
@@ -440,9 +426,7 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "description_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
-    "color_privacy": "public",
-    "message_count_privacy": "private",
-    "created_timestamp_privacy": "public"
+    "metadata_privacy": "private"
 }
 ```
 
@@ -523,18 +507,15 @@ The returned system and member's privacy settings will be respected, and as such
         "description_privacy": "private",
         "birthday_privacy": "private",
         "pronoun_privacy": "public",
-        "color_privacy": "public",
-        "message_count_privacy": "private",
-        "created_timestamp_privacy": "public"
+        "metadata_privacy": "private"
     }
 }
 ```
 
 ## Version history
-<!-- (Update this on official release) -->
-* 2020-05-14
-  * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `color_privacy`, `message_count_privacy`, and `created_timestamp_privacy`. All are strings and accept the values of `public`, `private` and `null`
-  * The `privacy` field has now been deprecated and should not be used. It is a reflection of visibility.
+* 2020-06-17
+  * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`
+  * The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
 * 2020-05-07
   * The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)
 * 2020-02-10

From 244815d6c37cb894bebf274b9d6ecc9ec6958784 Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 22:12:00 +0200
Subject: [PATCH 04/13] Update OpenAPI specification

---
 PluralKit.API/openapi.yaml | 89 ++++++++++++++++++++++++++++++++++++--
 1 file changed, 86 insertions(+), 3 deletions(-)

diff --git a/PluralKit.API/openapi.yaml b/PluralKit.API/openapi.yaml
index 4d1e263c..b1b1775e 100644
--- a/PluralKit.API/openapi.yaml
+++ b/PluralKit.API/openapi.yaml
@@ -3,7 +3,7 @@ openapi: 3.0.0
 
 info:
   title: PluralKit
-  version: "1.0"
+  version: "1.1"
 
   description: |
     This is the API for [PluralKit](https://pluralkit.me/)! :)
@@ -21,6 +21,10 @@ info:
     # Errors
     Errors are just returned as HTTP response codes. Most error responses include a human-readable
     error message as the body, but this should not be relied on. Just read the response codes :)
+    
+    # OpenAPI version history
+    - **1.1**: Granular member privacy
+    - **1.0**: (initial definition version)
 
   license:
     name: Apache 2.0
@@ -573,15 +577,87 @@ components:
             A link to the avatar/icon of the member.
 
             If used for proxying, the image must be at most 1000 pixels in width *and* height, and at most 1 MiB in size. This restriction is on Discord's end and is not verified through the API (it's simply stored as a string).
+            
         privacy:
           allOf:
             - $ref: "#/components/schemas/PrivacySetting"
             - description: |
-                The member's privacy setting, either "public" or "private".
+                This field is deprecated. 
+                
+                At the moment, it's still included in member objects, with a value that mirrors the `visibility` field. Writing to this field will set *every* privacy setting to the written value.
+              example: public
+              deprecated: true
+
+        visibility:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current visibility, either "public" or "private".
 
                 If this is set to "private", the member will not appear in public member lists. It can still be looked up using its 5-character member ID, but will return limited information.
 
-                Specifically, the properties `birthday`, `pronouns` and `description` will always be returned as `null` if a valid token for the system isn't provided, even if the underlying value is present.
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+              example: public
+              
+        name_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current name privacy setting, either "public" or "private".
+
+                If this is set to "private", the member's returned `name` will be replaced by their `display_name` when applicable.
+
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+
+                Because of this, there is no way for an unauthorized user to tell the difference between a private name being replaced by the display name, and an empty display name with the name set - this is intentional.
+              example: public
+              
+        description_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current description privacy setting, either "public" or "private".
+
+                If this is set to "private", the field `description` will be returned as `null` on all requests not authorized with this system's token.
+
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+
+                Because of this, there is no way for an unauthorized user to tell the difference between a private description and a `null` description - this is intentional.
+              example: public
+              
+        pronouns_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current pronouns privacy setting, either "public" or "private".
+
+                If this is set to "private", the field `pronouns` will be returned as `null` on all requests not authorized with this system's token.
+
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+
+                Because of this, there is no way for an unauthorized user to tell the difference between private pronouns and `null` pronouns - this is intentional.
+              example: public
+              
+        birthday_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current birthday privacy setting, either "public" or "private".
+
+                If this is set to "private", the field `birthday` will be returned as `null` on all requests not authorized with this system's token.
+
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+
+                Because of this, there is no way for an unauthorized user to tell the difference between a private birthday and a `null` birthday - this is intentional.
+              example: public
+              
+        metadata_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current metadata privacy setting, either "public" or "private".
+
+                If this is set to "private", the field `created` on all requests not authorized with this system's token.
 
                 In addition, this field will be returned as `null` if the request is not authorized with this system's token.
               example: public
@@ -624,6 +700,13 @@ components:
           default: false
           description: |
             Whether or not to include the used proxy tags in proxied messages.
+            
+        created:
+          type: string
+          format: date-time
+          readOnly: true
+          description: The creation timestamp of the member. May be returned as `null` depending on the value of `metadata_privacy` and the request authorization. 
+          nullable: true
 
     PrivacySetting:
       title: Privacy Setting

From 78f83339ae6282e6c94beae8fbb502a3dbf6bf7a Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 22:13:01 +0200
Subject: [PATCH 05/13] Update reference to OpenAPI specification

---
 docs/4-api-documentation.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/4-api-documentation.md b/docs/4-api-documentation.md
index 89a3bbe2..fefb14ab 100644
--- a/docs/4-api-documentation.md
+++ b/docs/4-api-documentation.md
@@ -6,7 +6,7 @@ description: PluralKit's API documentation.
 nav_order: 4
 ---
 
-**2020-05-07**: The PluralKit API is now documented on Swagger: https://app.swaggerhub.com/apis-docs/xSke/PluralKit/1.0  
+**2020-05-07**: The PluralKit API is now documented on Swagger: https://app.swaggerhub.com/apis-docs/xSke/PluralKit/1.1
 Accompanying it is an [OpenAPI v3.0 definition](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml). It's mostly complete, but is still subject to change - so don't go generating API clients and mock servers with it quite yet. It may still be useful, though :) 
 
 # API documentation
@@ -513,7 +513,7 @@ The returned system and member's privacy settings will be respected, and as such
 ```
 
 ## Version history
-* 2020-06-17
+* 2020-06-17 (v1.1)
   * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`
   * The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
 * 2020-05-07

From dd2690c3f433539230f7435ffee6e71491166089 Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 22:39:54 +0200
Subject: [PATCH 06/13] Get rid of compiler warning

---
 PluralKit.Core/Database/Functions/MessageContext.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/PluralKit.Core/Database/Functions/MessageContext.cs b/PluralKit.Core/Database/Functions/MessageContext.cs
index 062c2f9e..febafdaa 100644
--- a/PluralKit.Core/Database/Functions/MessageContext.cs
+++ b/PluralKit.Core/Database/Functions/MessageContext.cs
@@ -20,7 +20,7 @@ namespace PluralKit.Core
         public ulong? LastMessage { get; }
         public MemberId? LastMessageMember { get; }
         public SwitchId? LastSwitch { get; }
-        public MemberId[] LastSwitchMembers { get; }
+        public MemberId[] LastSwitchMembers { get; } = new MemberId[0];
         public Instant? LastSwitchTimestamp { get; }
         public string? SystemTag { get; }
         public string? SystemAvatar { get; }

From 761270f0c30831f549f042c32c5bf2b92c84163a Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Wed, 17 Jun 2020 23:06:49 +0200
Subject: [PATCH 07/13] Clean up member privacy command

---
 PluralKit.Bot/Commands/MemberEdit.cs | 186 +++++++++++----------------
 PluralKit.Core/Models/Privacy.cs     |   3 +
 PluralKit.Core/Utils/PrivacyUtils.cs |  93 ++++++++++++++
 3 files changed, 172 insertions(+), 110 deletions(-)
 create mode 100644 PluralKit.Core/Utils/PrivacyUtils.cs

diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index 7fd9d887..aa2ccd8f 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -360,6 +360,28 @@ namespace PluralKit.Bot
                 await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying.");
         }
 
+        private DiscordEmbed CreatePrivacyEmbed(PKMember member)
+        {
+            string PrivacyLevelString(PrivacyLevel level) => level switch
+            {
+                PrivacyLevel.Private => "**Private** (visible only when queried by you)",
+                PrivacyLevel.Public => "**Public** (visible to everyone)",
+                _ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
+            };
+
+            var eb = new DiscordEmbedBuilder()
+                .WithTitle($"Current privacy settings for {member.Name}")
+                .AddField("Name (replaces name with display name if member has one)",PrivacyLevelString(member.NamePrivacy))
+                .AddField("Description", PrivacyLevelString(member.DescriptionPrivacy))
+                .AddField("Birthday", PrivacyLevelString(member.BirthdayPrivacy))
+                .AddField("Pronouns", PrivacyLevelString(member.PronounPrivacy))
+                // .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
+                .AddField("Meta (message count, last front, last message)", PrivacyLevelString(member.MetadataPrivacy))
+                .AddField("Visibility", PrivacyLevelString(member.MemberVisibility))
+                .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`."); 
+            return eb.Build();
+        }
+
         public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand)
         {
             if (ctx.System == null) throw Errors.NoSystemError;
@@ -368,134 +390,78 @@ namespace PluralKit.Bot
             // Display privacy settings
             if (!ctx.HasNext() && newValueFromCommand == null)
             {
-                string PrivacyLevelString(PrivacyLevel level) => level switch
-                {
-                    PrivacyLevel.Private => "**Private** (visible only when queried by you)",
-                    PrivacyLevel.Public => "**Public** (visible to everyone)",
-                    _ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
-                };
-
-                var eb = new DiscordEmbedBuilder()
-                    .WithTitle($"Current privacy settings for {target.Name}")
-                    .AddField("Name (replaces name with display name if member has one)",PrivacyLevelString(target.NamePrivacy))
-                    .AddField("Description", PrivacyLevelString(target.DescriptionPrivacy))
-                    .AddField("Birthday", PrivacyLevelString(target.BirthdayPrivacy))
-                    .AddField("Pronouns", PrivacyLevelString(target.PronounPrivacy))
-                    // .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
-                    .AddField("Meta (message count, last front, last message)", PrivacyLevelString(target.MetadataPrivacy))
-                    .AddField("Visibility", PrivacyLevelString(target.MemberVisibility))
-                    .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`.");
-                await ctx.Reply(embed: eb.Build());
+                await ctx.Reply(embed: CreatePrivacyEmbed(target));
                 return;
             }
 
             // Set Privacy Settings
-            PrivacyLevel PopPrivacyLevel(string subject, out string levelStr, out string levelExplanation)
+            PrivacyLevel PopPrivacyLevel(string subjectName)
             {
                 if (ctx.Match("public", "show", "shown", "visible"))
-                {
-                    levelStr = "public";
-                    levelExplanation = "be shown on the member card";
                     return PrivacyLevel.Public;
-                }
 
                 if (ctx.Match("private", "hide", "hidden"))
-                {
-                    levelStr = "private";
-                    levelExplanation = "*not* be shown on the member card";
-                    if(subject == "name") levelExplanation += " unless no display name is set";
                     return PrivacyLevel.Private;
-                }
 
                 if (!ctx.HasNext())
-                    throw new PKSyntaxError($"You must pass a privacy level for `{subject}` (`public` or `private`)");
+                    throw new PKSyntaxError($"You must pass a privacy level for `{subjectName}` (`public` or `private`)");
                 throw new PKSyntaxError($"Invalid privacy level `{ctx.PopArgument().SanitizeMentions()}` (must be `public` or `private`).");
             }
+            
+            // See if we have a subject given
+            PrivacyLevel newLevel;
+            if (PrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject))
+            {
+                // We peeked before, pop it now
+                ctx.PopArgument();
+                
+                // Read the privacy level from args
+                newLevel = PopPrivacyLevel(subject.Name());
+                
+                // Set the level on the given subject
+                target.SetPrivacy(subject, newLevel);
+                await _data.SaveMember(target);
 
-            string levelStr, levelExplanation, subjectStr;
-            var subjectList = "`name`, `description`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`";
-            if(ctx.Match("name"))
-            {
-                subjectStr = "name";
-                target.NamePrivacy = PopPrivacyLevel("name", out levelStr, out levelExplanation);
-            }
-            else if(ctx.Match("description", "desc", "text", "info"))
-            {
-                subjectStr = "description";
-                target.DescriptionPrivacy = PopPrivacyLevel("description", out levelStr, out levelExplanation);
-            }
-            else if(ctx.Match("birthday", "birth", "bday"))
-            {
-                subjectStr = "birthday";
-                target.BirthdayPrivacy = PopPrivacyLevel("birthday", out levelStr, out levelExplanation);
-            }
-            else if(ctx.Match("pronouns", "pronoun"))
-            {
-                subjectStr = "pronouns";
-                target.PronounPrivacy = PopPrivacyLevel("pronouns", out levelStr, out levelExplanation);
-            }
-            /*else if(ctx.Match("color","colour"))
-            {
-                subjectStr = "color";
-                target.ColorPrivacy = PopPrivacyLevel("color", out levelStr, out levelExplanation);
-            }*/
-            else if(ctx.Match("meta","metadata"))
-            {
-                subjectStr = "metadata (date created, message count, last fronted, and last message)";
-                target.MetadataPrivacy = PopPrivacyLevel("metadata", out levelStr, out levelExplanation);
-            }
-            else if(ctx.Match("visibility","hidden","shown","visible"))
-            {
-                subjectStr = "visibility";
-                target.MemberVisibility = PopPrivacyLevel("visibility", out levelStr, out levelExplanation);
-            }
-            else if(ctx.Match("all") || newValueFromCommand != null){
-                subjectStr = "all";
-                PrivacyLevel level;
-                if(newValueFromCommand != null)
+                // Print response
+                var explanation = (subject, newLevel) switch
                 {
-                    if(newValueFromCommand == PrivacyLevel.Public)
-                    {
-                        level = PrivacyLevel.Public;
-                        levelStr = "public";
-                        levelExplanation = "be shown on the member card";
-                    }
-                    else
-                    {
-                        level = PrivacyLevel.Private;
-                        levelStr = "private";
-                        levelExplanation = "*not* be shown on the member card";
-                    }
-                }
-                else
-                    level = PopPrivacyLevel("all", out levelStr, out levelExplanation);
-                target.MemberVisibility = level;
-                target.NamePrivacy = level;
-                target.DescriptionPrivacy = level;
-                target.BirthdayPrivacy = level;
-                target.PronounPrivacy = level;
-                // target.ColorPrivacy = level;
-                target.MetadataPrivacy = level;
-            }            
-            else
-                throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList}).");
-
-
-            await _data.SaveMember(target);
-            //Handle "all" subject
-            if(subjectStr == "all"){
-                if(levelStr == "private")
-                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{levelStr}**. Other accounts will now see nothing on the member card.");
+                    (MemberPrivacySubject.Name, PrivacyLevel.Private) => "This member's name is now hidden from other systems, and will be replaced by the member's display name.",
+                    (MemberPrivacySubject.Description, PrivacyLevel.Private) => "This member's description is now hidden from other systems.",
+                    (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => "This member's birthday is now hidden from other systems.",
+                    (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => "This member's pronouns are now hidden from other systems.",
+                    (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.",
+                    (MemberPrivacySubject.Visibility, PrivacyLevel.Private) => "This member is now hidden from member lists.",
+                    
+                    (MemberPrivacySubject.Name, PrivacyLevel.Public) => "This member's name is no longer hidden from other systems.",
+                    (MemberPrivacySubject.Description, PrivacyLevel.Public) => "This member's description is no longer hidden from other systems.",
+                    (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => "This member's birthday is no longer hidden from other systems.",
+                    (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => "This member's pronouns are no longer hidden other systems.",
+                    (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.",
+                    (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => "This member is no longer hidden from member lists.",
+                };
+                
+                await ctx.Reply($"{Emojis.Success} {target.Name.SanitizeMentions()}'s {subject.Name()} has been set to **{newLevel.Name()}**. {explanation}");
+            }
+            else if (ctx.Match("all") || newValueFromCommand != null)
+            {
+                newLevel = newValueFromCommand ?? PopPrivacyLevel("all");
+                target.SetAllPrivacy(newLevel);
+                await _data.SaveMember(target);
+                
+                if(newLevel == PrivacyLevel.Private)
+                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see nothing on the member card.");
                 else 
-                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{levelStr}**. Other accounts will now see everything on the member card.");
-            } 
-            //Handle other subjects
+                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see everything on the member card.");
+            }
             else
-                await ctx.Reply($"{target.Name.SanitizeMentions()}'s {subjectStr} has been set to **{levelStr}**. Other accounts will now {levelExplanation}.");
-
-
-
-
+            {
+                var subjectList = "`name`, `description`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`";
+                throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList}).");
+            }
+            
+            // Name privacy only works given a display name
+            if (subject == MemberPrivacySubject.Name && newLevel == PrivacyLevel.Private && target.DisplayName == null)
+                await ctx.Reply($"{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**.");
         }
         
         public async Task Delete(Context ctx, PKMember target)
diff --git a/PluralKit.Core/Models/Privacy.cs b/PluralKit.Core/Models/Privacy.cs
index e153e84d..54579a98 100644
--- a/PluralKit.Core/Models/Privacy.cs
+++ b/PluralKit.Core/Models/Privacy.cs
@@ -10,6 +10,9 @@
     {
         public static bool CanAccess(this PrivacyLevel level, LookupContext ctx) =>
             level == PrivacyLevel.Public || ctx == LookupContext.ByOwner;
+
+        public static string Name(this PrivacyLevel level) => 
+            level == PrivacyLevel.Public ? "public" : "private";
     }
 
     public enum LookupContext
diff --git a/PluralKit.Core/Utils/PrivacyUtils.cs b/PluralKit.Core/Utils/PrivacyUtils.cs
new file mode 100644
index 00000000..a1952274
--- /dev/null
+++ b/PluralKit.Core/Utils/PrivacyUtils.cs
@@ -0,0 +1,93 @@
+using System;
+
+namespace PluralKit.Core
+{
+    public enum MemberPrivacySubject {
+        Visibility,
+        Name,
+        Description,
+        Birthday,
+        Pronouns,
+        Metadata
+    }
+    
+    public static class PrivacyUtils
+    {
+        public static string Name(this MemberPrivacySubject subject) => subject switch
+        {
+            MemberPrivacySubject.Name => "name",
+            MemberPrivacySubject.Description => "description",
+            MemberPrivacySubject.Pronouns => "pronouns",
+            MemberPrivacySubject.Birthday => "birthday",
+            MemberPrivacySubject.Metadata => "metadata",
+            MemberPrivacySubject.Visibility => "visibility",
+            _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
+        };
+
+        public static void SetPrivacy(this PKMember member, MemberPrivacySubject subject, PrivacyLevel level)
+        {
+            // what do you mean switch expressions can't be statements >.>
+            _ = subject switch
+            {
+                MemberPrivacySubject.Name => member.NamePrivacy = level,
+                MemberPrivacySubject.Description => member.DescriptionPrivacy = level,
+                MemberPrivacySubject.Pronouns => member.PronounPrivacy = level,
+                MemberPrivacySubject.Birthday => member.BirthdayPrivacy= level,
+                MemberPrivacySubject.Metadata => member.MetadataPrivacy = level,
+                MemberPrivacySubject.Visibility => member.MemberVisibility = level,
+                _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
+            };
+        }
+
+        public static void SetAllPrivacy(this PKMember member, PrivacyLevel level)
+        {
+            member.NamePrivacy = level;
+            member.DescriptionPrivacy = level;
+            member.PronounPrivacy = level;
+            member.BirthdayPrivacy = level;
+            member.MetadataPrivacy = level;
+            member.MemberVisibility = level;
+        }
+        
+        public static bool TryParseMemberPrivacy(string input, out MemberPrivacySubject subject)
+        {
+            switch (input.ToLowerInvariant())
+            {
+                case "name":
+                    subject = MemberPrivacySubject.Name;
+                    break;
+                case "description":
+                case "desc":
+                case "text":
+                case "info":
+                    subject = MemberPrivacySubject.Description;
+                    break;
+                case "birthday":
+                case "birth":
+                case "bday":
+                    subject = MemberPrivacySubject.Birthday;
+                    break;
+                case "pronouns":
+                case "pronoun":
+                    subject = MemberPrivacySubject.Pronouns;
+                    break;
+                case "meta":
+                case "metadata":
+                case "created":
+                    subject = MemberPrivacySubject.Metadata;
+                    break;
+                case "visibility":
+                case "hidden": 
+                case "shown":
+                case "visible":
+                case "list":
+                    subject = MemberPrivacySubject.Visibility;
+                    break;
+                default:
+                    subject = MemberPrivacySubject.Name;
+                    return false;
+            }
+            return true;
+        } 
+    }
+}
\ No newline at end of file

From 56eae82b0a92d31b7e4aa6c6733c5b934dff917c Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Thu, 18 Jun 2020 17:08:36 +0200
Subject: [PATCH 08/13] Move most references to PKMember.Name to go through
 helper extepsions for privacy

---
 PluralKit.API/Controllers/v1/JsonModelExt.cs |  2 +-
 PluralKit.Bot/CommandSystem/Context.cs       |  3 ++
 PluralKit.Bot/Commands/Autoproxy.cs          |  6 +--
 PluralKit.Bot/Commands/Member.cs             |  2 +-
 PluralKit.Bot/Commands/MemberAvatar.cs       |  2 +-
 PluralKit.Bot/Commands/MemberEdit.cs         | 41 +++++++++++---------
 PluralKit.Bot/Commands/MemberProxy.cs        |  2 +-
 PluralKit.Bot/Commands/Switch.cs             | 10 ++---
 PluralKit.Bot/Commands/SystemFront.cs        |  6 +--
 PluralKit.Bot/Commands/SystemList.cs         |  2 +-
 PluralKit.Bot/Errors.cs                      |  6 +--
 PluralKit.Bot/Handlers/ReactionAdded.cs      |  4 +-
 PluralKit.Bot/Lists/LongRenderer.cs          |  3 +-
 PluralKit.Bot/Lists/ShortRenderer.cs         |  5 +--
 PluralKit.Bot/Lists/SortFilterOptions.cs     | 10 ++---
 PluralKit.Bot/Services/EmbedService.cs       | 22 ++++++-----
 PluralKit.Bot/Utils/ModelUtils.cs            | 13 +++++++
 PluralKit.Core/Models/MemberId.cs            |  2 +
 PluralKit.Core/Models/ModelExtensions.cs     |  8 ++++
 PluralKit.Core/Models/SwitchId.cs            |  2 +
 PluralKit.Core/Models/SystemId.cs            |  2 +
 PluralKit.Core/Utils/BulkImporter.cs         |  6 +--
 22 files changed, 97 insertions(+), 62 deletions(-)
 create mode 100644 PluralKit.Bot/Utils/ModelUtils.cs
 create mode 100644 PluralKit.Core/Models/ModelExtensions.cs

diff --git a/PluralKit.API/Controllers/v1/JsonModelExt.cs b/PluralKit.API/Controllers/v1/JsonModelExt.cs
index 9cbb7a00..d8f39a8d 100644
--- a/PluralKit.API/Controllers/v1/JsonModelExt.cs
+++ b/PluralKit.API/Controllers/v1/JsonModelExt.cs
@@ -44,7 +44,7 @@ namespace PluralKit.API
         {
             var o = new JObject();
             o.Add("id", member.Hid);
-            o.Add("name", member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name);
+            o.Add("name", member.NameFor(ctx));
             // o.Add("color", member.ColorPrivacy.CanAccess(ctx) ? member.Color : null);
             o.Add("color", member.Color);
             o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs
index 2502e317..8272178d 100644
--- a/PluralKit.Bot/CommandSystem/Context.cs
+++ b/PluralKit.Bot/CommandSystem/Context.cs
@@ -279,6 +279,9 @@ namespace PluralKit.Bot
         public LookupContext LookupContextFor(SystemId systemId) => 
             System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner;
 
+        public LookupContext LookupContextFor(PKMember target) =>
+            System?.Id == target.System ? LookupContext.ByOwner : LookupContext.ByNonOwner;
+
         public Context CheckSystemPrivacy(PKSystem target, PrivacyLevel level)
         {
             if (level.CanAccess(LookupContextFor(target))) return this;
diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs
index 1c875ec1..e1c20471 100644
--- a/PluralKit.Bot/Commands/Autoproxy.cs
+++ b/PluralKit.Bot/Commands/Autoproxy.cs
@@ -76,7 +76,7 @@ namespace PluralKit.Bot
             ctx.CheckOwnMember(member);
 
             await UpdateAutoproxy(ctx, AutoproxyMode.Member, member.Id);
-            await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.Name}** in this server.");
+            await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server.");
         }
 
         private async Task<DiscordEmbed> CreateAutoproxyStatusEmbed(Context ctx)
@@ -103,14 +103,14 @@ namespace PluralKit.Bot
                     {
                         if (relevantMember == null) 
                             throw new ArgumentException("Attempted to print member autoproxy status, but the linked member ID wasn't found in the database. Should be handled appropriately.");
-                        eb.WithDescription($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.Name.EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
+                        eb.WithDescription($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
                     }
 
                     break;
                 }
                 // AutoproxyMember is never null if Mode is Member, this is just to make the compiler shut up
                 case AutoproxyMode.Member when relevantMember != null: {
-                    eb.WithDescription($"Autoproxy is active for member **{relevantMember.Name}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
+                    eb.WithDescription($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
                     break;
                 }
                 case AutoproxyMode.Latch:
diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs
index 4d62607a..31f36ad3 100644
--- a/PluralKit.Bot/Commands/Member.cs
+++ b/PluralKit.Bot/Commands/Member.cs
@@ -28,7 +28,7 @@ namespace PluralKit.Bot
             // Warn if there's already a member by this name
             var existingMember = await _data.GetMemberByName(ctx.System, memberName);
             if (existingMember != null) {
-                var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.SanitizeMentions()}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?");
+                var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx).SanitizeMentions()}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?");
                 if (!await ctx.PromptYesNo(msg)) throw new PKError("Member creation cancelled.");
             }
 
diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs
index bfdeb736..c8bfbb19 100644
--- a/PluralKit.Bot/Commands/MemberAvatar.cs
+++ b/PluralKit.Bot/Commands/MemberAvatar.cs
@@ -61,7 +61,7 @@ namespace PluralKit.Bot
             }
 
             var eb = new DiscordEmbedBuilder()
-                .WithTitle($"{target.Name.SanitizeMentions()}'s {field}")
+                .WithTitle($"{target.NameFor(ctx).SanitizeMentions()}'s {field}")
                 .WithImageUrl(currentValue);
             if (target.System == ctx.System?.Id)
                 eb.WithDescription($"To clear, use `pk;member {target.Hid} {cmd} clear`.");
diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index aa2ccd8f..4641e031 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -35,7 +35,7 @@ namespace PluralKit.Bot
             // Warn if there's already a member by this name
             var existingMember = await _data.GetMemberByName(ctx.System, newName);
             if (existingMember != null) {
-                var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.SanitizeMentions()}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?");
+                var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx).SanitizeMentions()}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?");
                 if (!await ctx.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
             }
 
@@ -124,7 +124,7 @@ namespace PluralKit.Bot
                     else
                         await ctx.Reply("This member does not have pronouns set.");
                 else
-                    await ctx.Reply($"**{target.Name.SanitizeMentions()}**'s pronouns are **{target.Pronouns.SanitizeMentions()}**."
+                    await ctx.Reply($"**{target.NameFor(ctx).SanitizeMentions()}**'s pronouns are **{target.Pronouns.SanitizeMentions()}**."
                         + (ctx.System?.Id == target.System ? $" To clear them, type `pk;member {target.Hid} pronouns -clear`." : ""));
             }
             else
@@ -223,6 +223,8 @@ namespace PluralKit.Bot
         
         private async Task<DiscordEmbedBuilder> CreateMemberNameInfoEmbed(Context ctx, PKMember target)
         {
+            var lcx = ctx.LookupContextFor(target);
+            
             MemberGuildSettings memberGuildConfig = null;
             if (ctx.Guild != null)
                 memberGuildConfig = await _db.Execute(c => c.QueryOrInsertMemberGuildConfig(ctx.Guild.Id, target.Id));
@@ -231,14 +233,17 @@ namespace PluralKit.Bot
                 .WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name.");
 
             if (target.DisplayName == null && memberGuildConfig?.DisplayName == null)
-                eb.AddField($"Name", $"**{target.Name}**");
+                eb.AddField("Name", $"**{target.NameFor(ctx)}**");
             else
-                eb.AddField("Name", target.Name);
-            
-            if (target.DisplayName != null && memberGuildConfig?.DisplayName == null)
-                eb.AddField($"Display Name", $"**{target.DisplayName}**");
-            else
-                eb.AddField("Display Name", target.DisplayName ?? "*(none)*");
+                eb.AddField("Name", target.NameFor(ctx));
+
+            if (target.NamePrivacy.CanAccess(lcx))
+            {
+                if (target.DisplayName != null && memberGuildConfig?.DisplayName == null)
+                    eb.AddField("Display Name", $"**{target.DisplayName}**");
+                else
+                    eb.AddField("Display Name", target.DisplayName ?? "*(none)*");
+            }
 
             if (ctx.Guild != null)
             {
@@ -272,7 +277,7 @@ namespace PluralKit.Bot
                 
                 target.DisplayName = null;
                 await _data.SaveMember(target);
-                await PrintSuccess($"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name.SanitizeMentions()}\".");
+                await PrintSuccess($"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx).SanitizeMentions()}\".");
             }
             else if (!ctx.HasNext())
             {
@@ -309,7 +314,7 @@ namespace PluralKit.Bot
                 if (target.DisplayName != null)
                     await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their global display name \"{target.DisplayName.SanitizeMentions()}\" in this server ({ctx.Guild.Name.SanitizeMentions()}).");
                 else
-                    await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.Name.SanitizeMentions()}\" in this server ({ctx.Guild.Name.SanitizeMentions()}).");
+                    await ctx.Reply($"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx).SanitizeMentions()}\" in this server ({ctx.Guild.Name.SanitizeMentions()}).");
             }
             else if (!ctx.HasNext())
             {
@@ -360,7 +365,7 @@ namespace PluralKit.Bot
                 await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying.");
         }
 
-        private DiscordEmbed CreatePrivacyEmbed(PKMember member)
+        private DiscordEmbed CreatePrivacyEmbed(Context ctx, PKMember member)
         {
             string PrivacyLevelString(PrivacyLevel level) => level switch
             {
@@ -370,7 +375,7 @@ namespace PluralKit.Bot
             };
 
             var eb = new DiscordEmbedBuilder()
-                .WithTitle($"Current privacy settings for {member.Name}")
+                .WithTitle($"Current privacy settings for {member.NameFor(ctx)}")
                 .AddField("Name (replaces name with display name if member has one)",PrivacyLevelString(member.NamePrivacy))
                 .AddField("Description", PrivacyLevelString(member.DescriptionPrivacy))
                 .AddField("Birthday", PrivacyLevelString(member.BirthdayPrivacy))
@@ -390,7 +395,7 @@ namespace PluralKit.Bot
             // Display privacy settings
             if (!ctx.HasNext() && newValueFromCommand == null)
             {
-                await ctx.Reply(embed: CreatePrivacyEmbed(target));
+                await ctx.Reply(embed: CreatePrivacyEmbed(ctx, target));
                 return;
             }
 
@@ -440,7 +445,7 @@ namespace PluralKit.Bot
                     (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => "This member is no longer hidden from member lists.",
                 };
                 
-                await ctx.Reply($"{Emojis.Success} {target.Name.SanitizeMentions()}'s {subject.Name()} has been set to **{newLevel.Name()}**. {explanation}");
+                await ctx.Reply($"{Emojis.Success} {target.NameFor(ctx).SanitizeMentions()}'s {subject.Name()} has been set to **{newLevel.Name()}**. {explanation}");
             }
             else if (ctx.Match("all") || newValueFromCommand != null)
             {
@@ -449,9 +454,9 @@ namespace PluralKit.Bot
                 await _data.SaveMember(target);
                 
                 if(newLevel == PrivacyLevel.Private)
-                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see nothing on the member card.");
+                    await ctx.Reply($"All {target.NameFor(ctx).SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see nothing on the member card.");
                 else 
-                    await ctx.Reply($"All {target.Name.SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see everything on the member card.");
+                    await ctx.Reply($"All {target.NameFor(ctx).SanitizeMentions()}'s privacy settings have been set to **{newLevel.Name()}**. Other accounts will now see everything on the member card.");
             }
             else
             {
@@ -469,7 +474,7 @@ namespace PluralKit.Bot
             if (ctx.System == null) throw Errors.NoSystemError;
             if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
             
-            await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.Name.SanitizeMentions()}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__");
+            await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx).SanitizeMentions()}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__");
             if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled;
             await _data.DeleteMember(target);
             await ctx.Reply($"{Emojis.Success} Member deleted.");
diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs
index f01f3989..2c6c7d10 100644
--- a/PluralKit.Bot/Commands/MemberProxy.cs
+++ b/PluralKit.Bot/Commands/MemberProxy.cs
@@ -36,7 +36,7 @@ namespace PluralKit.Bot
 
                 if (conflicts.Count <= 0) return true;
 
-                var conflictList = conflicts.Select(m => $"- **{m.Name}**");
+                var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**");
                 var msg = await ctx.Reply(
                     $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?");
                 return await ctx.PromptYesNo(msg);
diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs
index e0086a3e..e32f1825 100644
--- a/PluralKit.Bot/Commands/Switch.cs
+++ b/PluralKit.Bot/Commands/Switch.cs
@@ -62,7 +62,7 @@ namespace PluralKit.Bot
                 var lastSwitchMembers = _data.GetSwitchMembers(lastSwitch);
                 // Make sure the requested switch isn't identical to the last one
                 if (await lastSwitchMembers.Select(m => m.Id).SequenceEqualAsync(members.Select(m => m.Id).ToAsyncEnumerable()))
-                    throw Errors.SameSwitch(members);
+                    throw Errors.SameSwitch(members, ctx.LookupContextFor(ctx.System));
             }
 
             await _data.AddSwitch(ctx.System.Id, members);
@@ -70,7 +70,7 @@ namespace PluralKit.Bot
             if (members.Count == 0)
                 await ctx.Reply($"{Emojis.Success} Switch-out registered.");
             else
-                await ctx.Reply($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name)).SanitizeMentions()}.");
+                await ctx.Reply($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.NameFor(ctx))).SanitizeMentions()}.");
         }
         
         public async Task SwitchMove(Context ctx)
@@ -102,7 +102,7 @@ namespace PluralKit.Bot
             // Now we can actually do the move, yay!
             // But, we do a prompt to confirm.
             var lastSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[0]);
-            var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync());
+            var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
             var lastSwitchTimeStr = DateTimeFormats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(ctx.System.Zone));
             var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp);
             var newSwitchTimeStr = DateTimeFormats.ZonedDateTimeFormat.Format(time);
@@ -137,7 +137,7 @@ namespace PluralKit.Bot
             if (lastTwoSwitches.Count == 0) throw Errors.NoRegisteredSwitches;
 
             var lastSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[0]);
-            var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync());
+            var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
             var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp);
 
             DiscordMessage msg;
@@ -149,7 +149,7 @@ namespace PluralKit.Bot
             else
             {
                 var secondSwitchMembers = _data.GetSwitchMembers(lastTwoSwitches[1]);
-                var secondSwitchMemberStr = string.Join(", ", await secondSwitchMembers.Select(m => m.Name).ToListAsync());
+                var secondSwitchMemberStr = string.Join(", ", await secondSwitchMembers.Select(m => m.NameFor(ctx)).ToListAsync());
                 var secondSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp);
                 msg = await ctx.Reply(
                     $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.SanitizeMentions()}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr.SanitizeMentions()} ({secondSwitchDeltaStr} ago). Is this okay?");
diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs
index b48d2e69..ed9138d6 100644
--- a/PluralKit.Bot/Commands/SystemFront.cs
+++ b/PluralKit.Bot/Commands/SystemFront.cs
@@ -39,7 +39,7 @@ namespace PluralKit.Bot
             var sw = await _data.GetLatestSwitch(system.Id);
             if (sw == null) throw Errors.NoRegisteredSwitches;
             
-            await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone));
+            await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone, ctx.LookupContextFor(system)));
         }
 
         public async Task SystemFrontHistory(Context ctx, PKSystem system)
@@ -68,7 +68,7 @@ namespace PluralKit.Bot
                         var sw = entry.ThisSwitch;
                         // Fetch member list and format
                         var members = await _data.GetSwitchMembers(sw).ToListAsync();
-                        var membersStr = members.Any() ? string.Join(", ", members.Select(m => m.Name)) : "no fronter";
+                        var membersStr = members.Any() ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "no fronter";
 
                         var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
 
@@ -113,7 +113,7 @@ namespace PluralKit.Bot
             if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
             
             var frontpercent = await _data.GetFrontBreakdown(system, rangeStart.Value.ToInstant(), now);
-            await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system.Zone));
+            await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system.Zone, ctx.LookupContextFor(system)));
         }
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs
index 2a4ca989..57e3e6b2 100644
--- a/PluralKit.Bot/Commands/SystemList.cs
+++ b/PluralKit.Bot/Commands/SystemList.cs
@@ -24,7 +24,7 @@ namespace PluralKit.Bot
             var renderer = GetRendererFor(ctx);
             var opts = GetOptions(ctx, target);
             
-            var members = (await _db.Execute(c => opts.Execute(c, target))).ToList();
+            var members = (await _db.Execute(c => opts.Execute(c, target, ctx.LookupContextFor(target)))).ToList();
             await ctx.Paginate(
                 members.ToAsyncEnumerable(),
                 members.Count,
diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs
index 5156db2e..b25e91d7 100644
--- a/PluralKit.Bot/Errors.cs
+++ b/PluralKit.Bot/Errors.cs
@@ -68,11 +68,11 @@ namespace PluralKit.Bot {
         public static PKError MemberLinkCancelled => new PKError("Member link cancelled.");
         public static PKError MemberUnlinkCancelled => new PKError("Member unlink cancelled.");
 
-        public static PKError SameSwitch(ICollection<PKMember> members)
+        public static PKError SameSwitch(ICollection<PKMember> members, LookupContext ctx)
         {
             if (members.Count == 0) return new PKError("There's already no one in front.");
-            if (members.Count == 1) return new PKError($"Member {members.First().Name.SanitizeMentions()} is already fronting.");
-            return new PKError($"Members {string.Join(", ", members.Select(m => m.Name.SanitizeMentions()))} are already fronting.");
+            if (members.Count == 1) return new PKError($"Member {members.First().NameFor(ctx).SanitizeMentions()} is already fronting.");
+            return new PKError($"Members {string.Join(", ", members.Select(m => m.NameFor(ctx).SanitizeMentions()))} are already fronting.");
         }
 
         public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list.");
diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs
index 34c8005a..8dec0d53 100644
--- a/PluralKit.Bot/Handlers/ReactionAdded.cs
+++ b/PluralKit.Bot/Handlers/ReactionAdded.cs
@@ -105,14 +105,14 @@ namespace PluralKit.Bot
             {
                 // If the system has pings enabled, go ahead
                 var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({evt.Message.JumpLink})");
-                await evt.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.User.Id}>.", embed: embed.Build());
+                await evt.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.User.Id}>.", embed: embed.Build());
             }
             else
             {
                 // If not, tell them in DMs (if we can)
                 try
                 {
-                    await guildUser.SendMessageAsync($"{Emojis.Error} {msg.Member.DisplayName ?? msg.Member.Name}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:");
+                    await guildUser.SendMessageAsync($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:");
                     await guildUser.SendMessageAsync($"`<@{msg.Message.Sender}>`");
                 }
                 catch (UnauthorizedException) { }
diff --git a/PluralKit.Bot/Lists/LongRenderer.cs b/PluralKit.Bot/Lists/LongRenderer.cs
index 5b3b528e..1f9cd318 100644
--- a/PluralKit.Bot/Lists/LongRenderer.cs
+++ b/PluralKit.Bot/Lists/LongRenderer.cs
@@ -38,8 +38,7 @@ namespace PluralKit.Bot
                 if (_fields.ShowPrivacy && m.MemberVisibility == PrivacyLevel.Private)
                     profile += "\n*(this member is hidden)*";
 
-                var memberName = m.NamePrivacy.CanAccess(ctx) ? m.Name : (m.DisplayName ?? m.Name);
-                eb.AddField(memberName, profile.Truncate(1024));
+                eb.AddField(m.NameFor(ctx), profile.Truncate(1024));
             }
         }
         
diff --git a/PluralKit.Bot/Lists/ShortRenderer.cs b/PluralKit.Bot/Lists/ShortRenderer.cs
index b2d1084b..e35a133e 100644
--- a/PluralKit.Bot/Lists/ShortRenderer.cs
+++ b/PluralKit.Bot/Lists/ShortRenderer.cs
@@ -22,11 +22,10 @@ namespace PluralKit.Bot
                     var proxyTagsString = m.ProxyTagsString().SanitizeMentions();
                     if (proxyTagsString.Length > 100) // arbitrary threshold for now, tweak?
                         proxyTagsString = "tags too long, see member card";
-                    var memberName = m.NamePrivacy.CanAccess(ctx) ? m.Name : (m.DisplayName ?? m.Name);
-                    return $"[`{m.Hid}`] **{memberName.SanitizeMentions()}** *({proxyTagsString})*";
+                    return $"[`{m.Hid}`] **{m.NameFor(ctx).SanitizeMentions()}** *({proxyTagsString})*";
                 }
 
-                return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**";
+                return $"[`{m.Hid}`] **{m.NameFor(ctx).SanitizeMentions()}**";
             }
 
             var buf = new StringBuilder();
diff --git a/PluralKit.Bot/Lists/SortFilterOptions.cs b/PluralKit.Bot/Lists/SortFilterOptions.cs
index 69fd2d47..e3644a74 100644
--- a/PluralKit.Bot/Lists/SortFilterOptions.cs
+++ b/PluralKit.Bot/Lists/SortFilterOptions.cs
@@ -52,10 +52,10 @@ namespace PluralKit.Bot
             return str.ToString();
         }
         
-        public async Task<IEnumerable<ListedMember>> Execute(IPKConnection conn, PKSystem system)
+        public async Task<IEnumerable<ListedMember>> Execute(IPKConnection conn, PKSystem system, LookupContext ctx)
         {
             var filtered = await QueryWithFilter(conn, system);
-            return Sort(filtered);
+            return Sort(filtered, ctx);
         }
 
         private Task<IEnumerable<ListedMember>> QueryWithFilter(IPKConnection conn, PKSystem system) =>
@@ -67,7 +67,7 @@ namespace PluralKit.Bot
                 _ => throw new ArgumentOutOfRangeException($"Unknown privacy filter {PrivacyFilter}")
             }, Filter, SearchInDescription);
 
-        private IEnumerable<ListedMember> Sort(IEnumerable<ListedMember> input)
+        private IEnumerable<ListedMember> Sort(IEnumerable<ListedMember> input, LookupContext ctx)
         {
             IComparer<T> ReverseMaybe<T>(IComparer<T> c) =>
                 Reverse ? Comparer<T>.Create((a, b) => c.Compare(b, a)) : c;
@@ -78,7 +78,7 @@ namespace PluralKit.Bot
                 // 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(m => m.Hid, ReverseMaybe(culture)),
-                SortProperty.Name => input.OrderBy(m => m.Name, ReverseMaybe(culture)),
+                SortProperty.Name => input.OrderBy(m => m.NameFor(ctx), ReverseMaybe(culture)),
                 SortProperty.CreationDate => input.OrderBy(m => m.Created, ReverseMaybe(Comparer<Instant>.Default)),
                 SortProperty.MessageCount => input.OrderByDescending(m => m.MessageCount, ReverseMaybe(Comparer<int>.Default)),
                 SortProperty.DisplayName => input
@@ -96,7 +96,7 @@ namespace PluralKit.Bot
                 _ => throw new ArgumentOutOfRangeException($"Unknown sort property {SortProperty}")
             })
                 // Lastly, add a by-name fallback order for collisions (generally hits w/ lots of null values)
-                .ThenBy(m => m.Name, culture);
+                .ThenBy(m => m.NameFor(ctx), culture);
         }
 
         public static SortFilterOptions FromFlags(Context ctx)
diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs
index 00f44dc7..eaf48dea 100644
--- a/PluralKit.Bot/Services/EmbedService.cs
+++ b/PluralKit.Bot/Services/EmbedService.cs
@@ -47,7 +47,7 @@ namespace PluralKit.Bot {
                 var switchMembers = await _data.GetSwitchMembers(latestSwitch).ToListAsync();
                 if (switchMembers.Count > 0)
                     eb.AddField("Fronter".ToQuantity(switchMembers.Count(), ShowQuantityAs.None),
-                        string.Join(", ", switchMembers.Select(m => m.Name)));
+                        string.Join(", ", switchMembers.Select(m => m.NameFor(ctx))));
             }
 
             if (system.Tag != null) eb.AddField("Tag", system.Tag.EscapeMarkdown());
@@ -70,7 +70,7 @@ namespace PluralKit.Bot {
         public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) {
             // TODO: pronouns in ?-reacted response using this card
             var timestamp = DiscordUtils.SnowflakeToInstant(messageId);
-            var name = member.NamePrivacy == PrivacyLevel.Public ? member.Name : member.DisplayName ?? member.Name;
+            var name = member.NameFor(LookupContext.ByNonOwner); 
             return new DiscordEmbedBuilder()
                 .WithAuthor($"#{channel.Name}: {name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarUrl))
                 .WithThumbnailUrl(member.AvatarUrl)
@@ -85,8 +85,8 @@ namespace PluralKit.Bot {
 
             // string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
 
-            var name = member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name;
-            if (system.Name != null) name = $"{member.Name} ({system.Name})";
+            var name = member.NameFor(ctx);
+            if (system.Name != null) name = $"{name} ({system.Name})";
 
             DiscordColor color;
             try
@@ -142,19 +142,21 @@ namespace PluralKit.Bot {
             return eb.Build();
         }
 
-        public async Task<DiscordEmbed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
+        public async Task<DiscordEmbed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx)
         {
             var members = await _data.GetSwitchMembers(sw).ToListAsync();
             var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
             return new DiscordEmbedBuilder()
                 .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray)
-                .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*")
+                .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.NameFor(ctx))) : "*(no fronter)*")
                 .AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)")
                 .Build();
         }
 
         public async Task<DiscordEmbed> CreateMessageInfoEmbed(DiscordClient client, FullMessage msg)
         {
+            var ctx = LookupContext.ByNonOwner;
+            
             var channel = await client.GetChannelAsync(msg.Message.Channel);
             var serverMsg = channel != null ? await channel.GetMessageAsync(msg.Message.Mid) : null;
 
@@ -177,12 +179,12 @@ namespace PluralKit.Bot {
 
             // Put it all together
             var eb = new DiscordEmbedBuilder()
-                .WithAuthor(msg.Member.Name, iconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarUrl))
+                .WithAuthor(msg.Member.NameFor(ctx), iconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarUrl))
                 .WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*")
                 .WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url)
                 .AddField("System",
                     msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)
-                .AddField("Member", $"{msg.Member.Name} (`{msg.Member.Hid}`)", true)
+                .AddField("Member", $"{msg.Member.NameFor(ctx)} (`{msg.Member.Hid}`)", true)
                 .AddField("Sent by", userStr, inline: true)
                 .WithTimestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset());
 
@@ -193,7 +195,7 @@ namespace PluralKit.Bot {
             return eb.Build();
         }
 
-        public Task<DiscordEmbed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
+        public Task<DiscordEmbed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz, LookupContext ctx)
         {
             var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart;
             var eb = new DiscordEmbedBuilder()
@@ -212,7 +214,7 @@ namespace PluralKit.Bot {
             foreach (var pair in membersOrdered)
             {
                 var frac = pair.Value / actualPeriod;
-                eb.AddField(pair.Key?.Name ?? "*(no fronter)*", $"{frac*100:F0}% ({DateTimeFormats.DurationFormat.Format(pair.Value)})");
+                eb.AddField(pair.Key?.NameFor(ctx) ?? "*(no fronter)*", $"{frac*100:F0}% ({DateTimeFormats.DurationFormat.Format(pair.Value)})");
             }
 
             if (membersOrdered.Count > maxEntriesToDisplay)
diff --git a/PluralKit.Bot/Utils/ModelUtils.cs b/PluralKit.Bot/Utils/ModelUtils.cs
new file mode 100644
index 00000000..7e90042e
--- /dev/null
+++ b/PluralKit.Bot/Utils/ModelUtils.cs
@@ -0,0 +1,13 @@
+using PluralKit.Core;
+
+namespace PluralKit.Bot
+{
+    public static class ModelUtils
+    {
+        public static string NameFor(this PKMember member, Context ctx) =>
+            member.NameFor(ctx.LookupContextFor(member));
+
+        public static string DisplayName(this PKMember member) =>
+            member.DisplayName ?? member.Name;
+    }
+}
\ No newline at end of file
diff --git a/PluralKit.Core/Models/MemberId.cs b/PluralKit.Core/Models/MemberId.cs
index 2fa90ba6..7bf27ab7 100644
--- a/PluralKit.Core/Models/MemberId.cs
+++ b/PluralKit.Core/Models/MemberId.cs
@@ -20,5 +20,7 @@
         public static bool operator !=(MemberId left, MemberId right) => !left.Equals(right);
 
         public int CompareTo(MemberId other) => Value.CompareTo(other.Value);
+        
+        public override string ToString() => $"Member #{Value}";
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Core/Models/ModelExtensions.cs b/PluralKit.Core/Models/ModelExtensions.cs
new file mode 100644
index 00000000..55856bf9
--- /dev/null
+++ b/PluralKit.Core/Models/ModelExtensions.cs
@@ -0,0 +1,8 @@
+namespace PluralKit.Core
+{
+    public static class ModelExtensions
+    {
+        public static string NameFor(this PKMember member, LookupContext ctx) => 
+            member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name;
+    }
+}
\ No newline at end of file
diff --git a/PluralKit.Core/Models/SwitchId.cs b/PluralKit.Core/Models/SwitchId.cs
index 11a775a0..6c6f98c3 100644
--- a/PluralKit.Core/Models/SwitchId.cs
+++ b/PluralKit.Core/Models/SwitchId.cs
@@ -20,5 +20,7 @@
         public static bool operator !=(SwitchId left, SwitchId right) => !left.Equals(right);
 
         public int CompareTo(SwitchId other) => Value.CompareTo(other.Value);
+        
+        public override string ToString() => $"Switch #{Value}";
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Core/Models/SystemId.cs b/PluralKit.Core/Models/SystemId.cs
index 42c1336a..a09afdca 100644
--- a/PluralKit.Core/Models/SystemId.cs
+++ b/PluralKit.Core/Models/SystemId.cs
@@ -20,5 +20,7 @@
         public static bool operator !=(SystemId left, SystemId right) => !left.Equals(right);
 
         public int CompareTo(SystemId other) => Value.CompareTo(other.Value);
+
+        public override string ToString() => $"System #{Value}";
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Core/Utils/BulkImporter.cs b/PluralKit.Core/Utils/BulkImporter.cs
index 4defb17a..32edad9c 100644
--- a/PluralKit.Core/Utils/BulkImporter.cs
+++ b/PluralKit.Core/Utils/BulkImporter.cs
@@ -137,7 +137,7 @@ namespace PluralKit.Core
                     
                     // Otherwise, write to importer
                     await importer.StartRowAsync();
-                    await importer.WriteAsync(_systemId, NpgsqlDbType.Integer);
+                    await importer.WriteAsync(_systemId.Value, NpgsqlDbType.Integer);
                     await importer.WriteAsync(sw.Timestamp, NpgsqlDbType.Timestamp);
                     
                     // Note that we've imported a switch with this timestamp
@@ -170,8 +170,8 @@ namespace PluralKit.Core
                             throw new Exception($"Attempted to import switch with member identifier {memberIdentifier} but could not find an entry in the id map for this! :/");
                         
                         await importer.StartRowAsync();
-                        await importer.WriteAsync(justAddedSwitch.Id, NpgsqlDbType.Integer);
-                        await importer.WriteAsync(memberId, NpgsqlDbType.Integer);
+                        await importer.WriteAsync(justAddedSwitch.Id.Value, NpgsqlDbType.Integer);
+                        await importer.WriteAsync(memberId.Value, NpgsqlDbType.Integer);
                     }
                 }
 

From dd9cc3ef0c74e90cb75bc8bd5b2d6c2d66d7a8eb Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Thu, 18 Jun 2020 17:33:37 +0200
Subject: [PATCH 09/13] Respect description privacy when searching members

---
 PluralKit.Bot/Lists/SortFilterOptions.cs          |  6 +++---
 PluralKit.Core/Database/Views/DatabaseViewsExt.cs | 13 ++++++++++---
 PluralKit.Core/Database/Views/views.sql           |  9 ++++++++-
 3 files changed, 21 insertions(+), 7 deletions(-)

diff --git a/PluralKit.Bot/Lists/SortFilterOptions.cs b/PluralKit.Bot/Lists/SortFilterOptions.cs
index e3644a74..08560d1c 100644
--- a/PluralKit.Bot/Lists/SortFilterOptions.cs
+++ b/PluralKit.Bot/Lists/SortFilterOptions.cs
@@ -54,12 +54,12 @@ namespace PluralKit.Bot
         
         public async Task<IEnumerable<ListedMember>> Execute(IPKConnection conn, PKSystem system, LookupContext ctx)
         {
-            var filtered = await QueryWithFilter(conn, system);
+            var filtered = await QueryWithFilter(conn, system, ctx);
             return Sort(filtered, ctx);
         }
 
-        private Task<IEnumerable<ListedMember>> QueryWithFilter(IPKConnection conn, PKSystem system) =>
-            conn.QueryMemberList(system.Id, PrivacyFilter switch
+        private Task<IEnumerable<ListedMember>> QueryWithFilter(IPKConnection conn, PKSystem system, LookupContext ctx) =>
+            conn.QueryMemberList(system.Id, ctx, PrivacyFilter switch
             {
                 PrivacyFilter.PrivateOnly => PrivacyLevel.Private,
                 PrivacyFilter.PublicOnly => PrivacyLevel.Public,
diff --git a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
index df03e43a..97e59efa 100644
--- a/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
+++ b/PluralKit.Core/Database/Views/DatabaseViewsExt.cs
@@ -12,7 +12,7 @@ namespace PluralKit.Core
         public static Task<IEnumerable<SystemFronter>> QueryCurrentFronters(this IPKConnection conn, SystemId system) =>
             conn.QueryAsync<SystemFronter>("select * from system_fronters where system = @system", new {system});
 
-        public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system, PrivacyLevel? privacyFilter = null, string? filter = null, bool includeDescriptionInNameFilter = false)
+        public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system, LookupContext ctx, PrivacyLevel? privacyFilter = null, string? filter = null, bool includeDescriptionInNameFilter = false)
         {
             StringBuilder query = new StringBuilder("select * from member_list where system = @system");
 
@@ -21,10 +21,17 @@ namespace PluralKit.Core
 
             if (filter != null)
             {
-                static string Filter(string column) => $"position(lower(@filter) in lower(coalesce({column}, ''))) > 0"; 
+                static string Filter(string column) => $"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)"; 
 
                 query.Append($" and ({Filter("name")} or {Filter("display_name")}");
-                if (includeDescriptionInNameFilter) query.Append($" or {Filter("description")}");
+                if (includeDescriptionInNameFilter)
+                {
+                    // 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 = ctx == LookupContext.ByOwner ? "description" : "public_description";
+                    query.Append($"or {Filter(descriptionColumn)}");
+                }
                 query.Append(")");
             }
             
diff --git a/PluralKit.Core/Database/Views/views.sql b/PluralKit.Core/Database/Views/views.sql
index 87ffbe86..344c05b1 100644
--- a/PluralKit.Core/Database/Views/views.sql
+++ b/PluralKit.Core/Database/Views/views.sql
@@ -46,5 +46,12 @@ select members.*,
                4,
                extract(month from members.birthday)::integer,
                extract(day from members.birthday)::integer
-           ) end as birthday_md
+           ) end as birthday_md,
+
+        -- Extract member description as seen by "the public"
+        case 
+            -- Privacy '1' = public; just return description as normal
+            when members.description_privacy = 1 then members.description
+            -- Any other privacy (rn just '2'), return null description (missing case = null in SQL)
+        end as public_description
 from members;
\ No newline at end of file

From 27c8100cac08b1091fb738809dbf2a848715d8f6 Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Thu, 18 Jun 2020 17:34:03 +0200
Subject: [PATCH 10/13] Get rid of switch exhaustiveness compiler warning

---
 PluralKit.Bot/Commands/MemberEdit.cs | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index 4641e031..07db4880 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -443,6 +443,8 @@ namespace PluralKit.Bot
                     (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => "This member's pronouns are no longer hidden other systems.",
                     (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.",
                     (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => "This member is no longer hidden from member lists.",
+                    
+                    _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {newLevel})")
                 };
                 
                 await ctx.Reply($"{Emojis.Success} {target.NameFor(ctx).SanitizeMentions()}'s {subject.Name()} has been set to **{newLevel.Name()}**. {explanation}");

From ae4e8f97d0e687755178325790b1e4bc22cf574c Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Sat, 20 Jun 2020 16:00:50 +0200
Subject: [PATCH 11/13] Add member avatar privacy

---
 PluralKit.API/Controllers/v1/JsonModelExt.cs |  5 ++++-
 PluralKit.API/openapi.yaml                   | 13 +++++++++++++
 PluralKit.Bot/Commands/MemberAvatar.cs       |  3 ++-
 PluralKit.Bot/Commands/MemberEdit.cs         |  7 +++++--
 PluralKit.Bot/Services/EmbedService.cs       | 10 +++++-----
 PluralKit.Bot/Utils/ModelUtils.cs            |  3 +++
 PluralKit.Core/Database/Migrations/8.sql     |  2 ++
 PluralKit.Core/Models/ModelExtensions.cs     |  3 +++
 PluralKit.Core/Models/PKMember.cs            |  1 +
 PluralKit.Core/Services/PostgresDataStore.cs |  2 +-
 PluralKit.Core/Utils/PrivacyUtils.cs         | 10 ++++++++++
 docs/2-user-guide.md                         |  7 ++++---
 docs/4-api-documentation.md                  |  9 ++++++++-
 13 files changed, 61 insertions(+), 14 deletions(-)

diff --git a/PluralKit.API/Controllers/v1/JsonModelExt.cs b/PluralKit.API/Controllers/v1/JsonModelExt.cs
index d8f39a8d..ee2c4132 100644
--- a/PluralKit.API/Controllers/v1/JsonModelExt.cs
+++ b/PluralKit.API/Controllers/v1/JsonModelExt.cs
@@ -50,7 +50,7 @@ namespace PluralKit.API
             o.Add("display_name", member.NamePrivacy.CanAccess(ctx) ? member.DisplayName : null);
             o.Add("birthday", member.BirthdayPrivacy.CanAccess(ctx) && member.Birthday.HasValue ? DateTimeFormats.DateExportFormat.Format(member.Birthday.Value) : null);
             o.Add("pronouns", member.PronounPrivacy.CanAccess(ctx) ? member.Pronouns : null);
-            o.Add("avatar_url", member.AvatarUrl);
+            o.Add("avatar_url", member.AvatarPrivacy.CanAccess(ctx) ? member.AvatarUrl : null);
             o.Add("description", member.DescriptionPrivacy.CanAccess(ctx) ? member.Description : null);
             
             var tagArray = new JArray();
@@ -67,6 +67,7 @@ namespace PluralKit.API
             o.Add("description_privacy", ctx == LookupContext.ByOwner ? (member.DescriptionPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("birthday_privacy", ctx == LookupContext.ByOwner ? (member.BirthdayPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("pronoun_privacy", ctx == LookupContext.ByOwner ? (member.PronounPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
+            o.Add("avatar_privacy", ctx == LookupContext.ByOwner ? (member.AvatarPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             // o.Add("color_privacy", ctx == LookupContext.ByOwner ? (member.ColorPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
             o.Add("metadata_privacy", ctx == LookupContext.ByOwner ? (member.MetadataPrivacy == PrivacyLevel.Private ? "private" : "public") : null);
 
@@ -122,6 +123,7 @@ namespace PluralKit.API
                                 
                 member.MemberVisibility = plevel;
                 member.NamePrivacy = plevel;
+                member.AvatarPrivacy = plevel;
                 member.DescriptionPrivacy = plevel;
                 member.BirthdayPrivacy = plevel;
                 member.PronounPrivacy = plevel;
@@ -133,6 +135,7 @@ namespace PluralKit.API
                 if (o.ContainsKey("visibility")) member.MemberVisibility = o.Value<string>("visibility").ParsePrivacy("member");
                 if (o.ContainsKey("name_privacy")) member.NamePrivacy = o.Value<string>("name_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("description_privacy")) member.DescriptionPrivacy = o.Value<string>("description_privacy").ParsePrivacy("member");
+                if (o.ContainsKey("avatar_privacy")) member.AvatarPrivacy = o.Value<string>("avatar_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("birthday_privacy")) member.BirthdayPrivacy = o.Value<string>("birthday_privacy").ParsePrivacy("member");
                 if (o.ContainsKey("pronoun_privacy")) member.PronounPrivacy = o.Value<string>("pronoun_privacy").ParsePrivacy("member");
                 // if (o.ContainsKey("color_privacy")) member.ColorPrivacy = o.Value<string>("color_privacy").ParsePrivacy("member");
diff --git a/PluralKit.API/openapi.yaml b/PluralKit.API/openapi.yaml
index b1b1775e..33653f3c 100644
--- a/PluralKit.API/openapi.yaml
+++ b/PluralKit.API/openapi.yaml
@@ -625,6 +625,19 @@ components:
                 Because of this, there is no way for an unauthorized user to tell the difference between a private description and a `null` description - this is intentional.
               example: public
               
+        avatar_privacy:
+          allOf:
+            - $ref: "#/components/schemas/PrivacySetting"
+            - description: |
+                The member's current avatar privacy setting, either "public" or "private".
+  
+                If this is set to "private", the field `avatar_url` will be returned as `null` on all requests not authorized with this system's token.
+  
+                In addition, this field will be returned as `null` if the request is not authorized with this system's token.
+  
+                Because of this, there is no way for an unauthorized user to tell the difference between a private avatar and a `null` avatar - this is intentional.
+              example: public
+              
         pronouns_privacy:
           allOf:
             - $ref: "#/components/schemas/PrivacySetting"
diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs
index c8bfbb19..b2b8dbcc 100644
--- a/PluralKit.Bot/Commands/MemberAvatar.cs
+++ b/PluralKit.Bot/Commands/MemberAvatar.cs
@@ -47,7 +47,8 @@ namespace PluralKit.Bot
             var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar";
             
             var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl;
-            if (string.IsNullOrEmpty(currentValue))
+            var canAccess = location != AvatarLocation.Member || target.AvatarPrivacy.CanAccess(ctx.LookupContextFor(target));
+            if (string.IsNullOrEmpty(currentValue) && !canAccess)
             {
                 if (location == AvatarLocation.Member)
                 {
diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index 07db4880..38219eef 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -378,12 +378,13 @@ namespace PluralKit.Bot
                 .WithTitle($"Current privacy settings for {member.NameFor(ctx)}")
                 .AddField("Name (replaces name with display name if member has one)",PrivacyLevelString(member.NamePrivacy))
                 .AddField("Description", PrivacyLevelString(member.DescriptionPrivacy))
+                .AddField("Avatar", PrivacyLevelString(member.AvatarPrivacy))
                 .AddField("Birthday", PrivacyLevelString(member.BirthdayPrivacy))
                 .AddField("Pronouns", PrivacyLevelString(member.PronounPrivacy))
                 // .AddField("Color", PrivacyLevelString(target.ColorPrivacy))
                 .AddField("Meta (message count, last front, last message)", PrivacyLevelString(member.MetadataPrivacy))
                 .AddField("Visibility", PrivacyLevelString(member.MemberVisibility))
-                .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`."); 
+                .WithDescription("To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `avatar`, `birthday`, `pronouns`, `created`, `messages`, `visibility`, or `all`\n- `level` is either `public` or `private`."); 
             return eb.Build();
         }
 
@@ -432,6 +433,7 @@ namespace PluralKit.Bot
                 {
                     (MemberPrivacySubject.Name, PrivacyLevel.Private) => "This member's name is now hidden from other systems, and will be replaced by the member's display name.",
                     (MemberPrivacySubject.Description, PrivacyLevel.Private) => "This member's description is now hidden from other systems.",
+                    (MemberPrivacySubject.Avatar, PrivacyLevel.Private) => "This member's avatar is now hidden from other systems.",
                     (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => "This member's birthday is now hidden from other systems.",
                     (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => "This member's pronouns are now hidden from other systems.",
                     (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.",
@@ -439,6 +441,7 @@ namespace PluralKit.Bot
                     
                     (MemberPrivacySubject.Name, PrivacyLevel.Public) => "This member's name is no longer hidden from other systems.",
                     (MemberPrivacySubject.Description, PrivacyLevel.Public) => "This member's description is no longer hidden from other systems.",
+                    (MemberPrivacySubject.Avatar, PrivacyLevel.Public) => "This member's avatar is no longer hidden from other systems.",
                     (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => "This member's birthday is no longer hidden from other systems.",
                     (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => "This member's pronouns are no longer hidden other systems.",
                     (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.",
@@ -462,7 +465,7 @@ namespace PluralKit.Bot
             }
             else
             {
-                var subjectList = "`name`, `description`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`";
+                var subjectList = "`name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`";
                 throw new PKSyntaxError($"Invalid privacy subject `{ctx.PopArgument().SanitizeMentions()}` (must be {subjectList}).");
             }
             
diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs
index eaf48dea..4443a4f5 100644
--- a/PluralKit.Bot/Services/EmbedService.cs
+++ b/PluralKit.Bot/Services/EmbedService.cs
@@ -72,8 +72,8 @@ namespace PluralKit.Bot {
             var timestamp = DiscordUtils.SnowflakeToInstant(messageId);
             var name = member.NameFor(LookupContext.ByNonOwner); 
             return new DiscordEmbedBuilder()
-                .WithAuthor($"#{channel.Name}: {name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarUrl))
-                .WithThumbnailUrl(member.AvatarUrl)
+                .WithAuthor($"#{channel.Name}: {name}", iconUrl: DiscordUtils.WorkaroundForUrlBug(member.AvatarFor(LookupContext.ByNonOwner)))
+                .WithThumbnailUrl(member.AvatarFor(LookupContext.ByNonOwner))
                 .WithDescription(content?.NormalizeLineEndSpacing())
                 .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}")
                 .WithTimestamp(timestamp.ToDateTimeOffset())
@@ -103,7 +103,7 @@ namespace PluralKit.Bot {
             
             var guildSettings = guild != null ? await _db.Execute(c => c.QueryOrInsertMemberGuildConfig(guild.Id, member.Id)) : null;
             var guildDisplayName = guildSettings?.DisplayName;
-            var avatar = guildSettings?.AvatarUrl ?? member.AvatarUrl;
+            var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx);
 
             var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`{t.ProxyString}`"));
 
@@ -117,7 +117,7 @@ namespace PluralKit.Bot {
             var description = "";
             if (member.MemberVisibility == PrivacyLevel.Private) description += "*(this member is hidden)*\n";
             if (guildSettings?.AvatarUrl != null)
-                if (member.AvatarUrl != null) 
+                if (member.AvatarFor(ctx) != null) 
                     description += $"*(this member has a server-specific avatar set; [click here]({member.AvatarUrl}) to see the global avatar)*\n";
                 else
                     description += "*(this member has a server-specific avatar set)*\n";
@@ -179,7 +179,7 @@ namespace PluralKit.Bot {
 
             // Put it all together
             var eb = new DiscordEmbedBuilder()
-                .WithAuthor(msg.Member.NameFor(ctx), iconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarUrl))
+                .WithAuthor(msg.Member.NameFor(ctx), iconUrl: DiscordUtils.WorkaroundForUrlBug(msg.Member.AvatarFor(ctx)))
                 .WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*")
                 .WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url)
                 .AddField("System",
diff --git a/PluralKit.Bot/Utils/ModelUtils.cs b/PluralKit.Bot/Utils/ModelUtils.cs
index 7e90042e..28dc3927 100644
--- a/PluralKit.Bot/Utils/ModelUtils.cs
+++ b/PluralKit.Bot/Utils/ModelUtils.cs
@@ -7,6 +7,9 @@ namespace PluralKit.Bot
         public static string NameFor(this PKMember member, Context ctx) =>
             member.NameFor(ctx.LookupContextFor(member));
 
+        public static string AvatarFor(this PKMember member, Context ctx) =>
+            member.AvatarFor(ctx.LookupContextFor(member));
+
         public static string DisplayName(this PKMember member) =>
             member.DisplayName ?? member.Name;
     }
diff --git a/PluralKit.Core/Database/Migrations/8.sql b/PluralKit.Core/Database/Migrations/8.sql
index 366a9309..0a56dfaa 100644
--- a/PluralKit.Core/Database/Migrations/8.sql
+++ b/PluralKit.Core/Database/Migrations/8.sql
@@ -2,6 +2,7 @@
 -- Create new columns --
 alter table members add column description_privacy integer check (description_privacy in (1, 2)) not null default 1;
 alter table members add column name_privacy integer check (name_privacy in (1, 2)) not null default 1;
+alter table members add column avatar_privacy integer check (avatar_privacy in (1, 2)) not null default 1;
 alter table members add column birthday_privacy integer check (birthday_privacy in (1, 2)) not null default 1;
 alter table members add column pronoun_privacy integer check (pronoun_privacy in (1, 2)) not null default 1;
 alter table members add column metadata_privacy integer check (metadata_privacy in (1, 2)) not null default 1;
@@ -10,6 +11,7 @@ alter table members add column metadata_privacy integer check (metadata_privacy
 -- Transfer existing settings --
 update members set description_privacy = member_privacy;
 update members set name_privacy = member_privacy;
+update members set avatar_privacy = member_privacy;
 update members set birthday_privacy = member_privacy;
 update members set pronoun_privacy = member_privacy;
 update members set metadata_privacy = member_privacy;
diff --git a/PluralKit.Core/Models/ModelExtensions.cs b/PluralKit.Core/Models/ModelExtensions.cs
index 55856bf9..47d2cc61 100644
--- a/PluralKit.Core/Models/ModelExtensions.cs
+++ b/PluralKit.Core/Models/ModelExtensions.cs
@@ -4,5 +4,8 @@
     {
         public static string NameFor(this PKMember member, LookupContext ctx) => 
             member.NamePrivacy.CanAccess(ctx) ? member.Name : member.DisplayName ?? member.Name;
+
+        public static string AvatarFor(this PKMember member, LookupContext ctx) =>
+            member.AvatarPrivacy.CanAccess(ctx) ? member.AvatarUrl : null;
     }
 }
\ No newline at end of file
diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs
index 01eb004c..97c620ef 100644
--- a/PluralKit.Core/Models/PKMember.cs
+++ b/PluralKit.Core/Models/PKMember.cs
@@ -25,6 +25,7 @@ namespace PluralKit.Core {
 
         public PrivacyLevel MemberVisibility { get; set; }
         public PrivacyLevel DescriptionPrivacy { get; set; }
+        public PrivacyLevel AvatarPrivacy { get; set; }
         public PrivacyLevel NamePrivacy { get; set; } //ignore setting if no display name is set
         public PrivacyLevel BirthdayPrivacy { get; set; }
         public PrivacyLevel PronounPrivacy { get; set; }
diff --git a/PluralKit.Core/Services/PostgresDataStore.cs b/PluralKit.Core/Services/PostgresDataStore.cs
index cf3490c7..fd4bc579 100644
--- a/PluralKit.Core/Services/PostgresDataStore.cs
+++ b/PluralKit.Core/Services/PostgresDataStore.cs
@@ -150,7 +150,7 @@ namespace PluralKit.Core {
 
         public async Task SaveMember(PKMember member) {
             using (var conn = await _conn.Obtain())
-                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_visibility = @MemberVisibility, description_privacy = @DescriptionPrivacy,  name_privacy = @NamePrivacy, birthday_privacy = @BirthdayPrivacy, pronoun_privacy = @PronounPrivacy, metadata_privacy = @MetadataPrivacy where id = @Id", member);
+                await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, proxy_tags = @ProxyTags, keep_proxy = @KeepProxy, member_visibility = @MemberVisibility, description_privacy = @DescriptionPrivacy,  name_privacy = @NamePrivacy, avatar_privacy = @AvatarPrivacy, birthday_privacy = @BirthdayPrivacy, pronoun_privacy = @PronounPrivacy, metadata_privacy = @MetadataPrivacy where id = @Id", member);
 
             _logger.Information("Updated member {@Member}", member);
         }
diff --git a/PluralKit.Core/Utils/PrivacyUtils.cs b/PluralKit.Core/Utils/PrivacyUtils.cs
index a1952274..8cbe079b 100644
--- a/PluralKit.Core/Utils/PrivacyUtils.cs
+++ b/PluralKit.Core/Utils/PrivacyUtils.cs
@@ -6,6 +6,7 @@ namespace PluralKit.Core
         Visibility,
         Name,
         Description,
+        Avatar,
         Birthday,
         Pronouns,
         Metadata
@@ -17,6 +18,7 @@ namespace PluralKit.Core
         {
             MemberPrivacySubject.Name => "name",
             MemberPrivacySubject.Description => "description",
+            MemberPrivacySubject.Avatar => "avatar",
             MemberPrivacySubject.Pronouns => "pronouns",
             MemberPrivacySubject.Birthday => "birthday",
             MemberPrivacySubject.Metadata => "metadata",
@@ -31,6 +33,7 @@ namespace PluralKit.Core
             {
                 MemberPrivacySubject.Name => member.NamePrivacy = level,
                 MemberPrivacySubject.Description => member.DescriptionPrivacy = level,
+                MemberPrivacySubject.Avatar => member.AvatarPrivacy = level,
                 MemberPrivacySubject.Pronouns => member.PronounPrivacy = level,
                 MemberPrivacySubject.Birthday => member.BirthdayPrivacy= level,
                 MemberPrivacySubject.Metadata => member.MetadataPrivacy = level,
@@ -43,6 +46,7 @@ namespace PluralKit.Core
         {
             member.NamePrivacy = level;
             member.DescriptionPrivacy = level;
+            member.AvatarPrivacy = level;
             member.PronounPrivacy = level;
             member.BirthdayPrivacy = level;
             member.MetadataPrivacy = level;
@@ -62,6 +66,12 @@ namespace PluralKit.Core
                 case "info":
                     subject = MemberPrivacySubject.Description;
                     break;
+                case "avatar":
+                case "pfp":
+                case "pic":
+                case "icon":
+                    subject = MemberPrivacySubject.Avatar;
+                    break;
                 case "birthday":
                 case "birth":
                 case "bday":
diff --git a/docs/2-user-guide.md b/docs/2-user-guide.md
index 0057f4cd..db14fd5b 100644
--- a/docs/2-user-guide.md
+++ b/docs/2-user-guide.md
@@ -449,10 +449,11 @@ For example:
 When the **member list** is **private**, other users will not be able to view the full member list of your system, but they can still query individual members given their 5-letter ID. If **current fronter** is private, but **front history** isn't, someone can still see the current fronter by looking at the history (this combination doesn't make much sense).
 
 ### Member privacy
-There are also six options for configuring member privacy;
+There are also seven options for configuring member privacy;
 
 - Name
 - Description
+- Avatar
 - Birthday
 - Pronouns
 - Metadata *(message count, creation date, etc)*
@@ -468,8 +469,8 @@ To update a members privacy you can use the command:
 
     member <member> privacy <subject> <level>
 
-where `<member>` is the name or the id of a member in your system, `<subject>` is either `name`, `description`, `birthday`, `pronouns`, `metadata`, or `visiblity` corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.  
-`metatdata` will affect the message count, the date created, the last fronted, and the last message information.
+where `<member>` is the name or the id of a member in your system, `<subject>` is either `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, or `visiblity` corresponding to the options above, and `<level>` is either `public` or `private`. `<subject>` can also be `all` in order to change all subjects at once.  
+`metadata` will affect the message count, the date created, the last fronted, and the last message information.
 
 For example:
 
diff --git a/docs/4-api-documentation.md b/docs/4-api-documentation.md
index fefb14ab..5c059bc7 100644
--- a/docs/4-api-documentation.md
+++ b/docs/4-api-documentation.md
@@ -68,6 +68,7 @@ The following three models (usually represented in JSON format) represent the va
 |visibility|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |name_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |description_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
+|avatar_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |birthday_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |pronoun_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
 |metadata_privacy|string?|Yes|Patching with `private` will set it to private; `public` or `null` will set it to public.|
@@ -234,6 +235,7 @@ If the system has chosen to hide its current fronters, this will return `403 For
             "visibility": null,
             "name_privacy": null,
             "description_privacy": null,
+            "avatar_privacy": null,
             "birthday_privacy": null,
             "pronoun_privacy": null,
             "metadata_privacy": null,
@@ -322,6 +324,7 @@ If this member is marked private, and the request isn't authenticated with the m
     "visibility": "public",
     "name_privacy": "public",
     "description_privacy": "private",
+    "avatar_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
     "metadata_privacy": "public"
@@ -349,6 +352,7 @@ Creates a new member with the information given. Missing fields (except for name
     "visibility": "public",
     "name_privacy": "public",
     "description_privacy": "private",
+    "avatar_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
     "metadata_privacy": "private"
@@ -400,6 +404,7 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "visibility": "public",
     "name_privacy": "public",
     "description_privacy": "private",
+    "avatar_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
     "metadata_privacy": "private"
@@ -424,6 +429,7 @@ Edits a member's information. Missing fields will keep their current values. Wil
     "visibility": "public",
     "name_privacy": "public",
     "description_privacy": "private",
+    "avatar_privacy": "private",
     "birthday_privacy": "private",
     "pronoun_privacy": "public",
     "metadata_privacy": "private"
@@ -505,6 +511,7 @@ The returned system and member's privacy settings will be respected, and as such
         "visibility": "public",
         "name_privacy": "public",
         "description_privacy": "private",
+        "avatar_privacy": "private",
         "birthday_privacy": "private",
         "pronoun_privacy": "public",
         "metadata_privacy": "private"
@@ -514,7 +521,7 @@ The returned system and member's privacy settings will be respected, and as such
 
 ## Version history
 * 2020-06-17 (v1.1)
-  * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`
+  * The API now has values for granular member privacy. The new fields are as follows: `visibility`, `name_privacy`, `description_privacy`, `avatar_privacy`, `birthday_privacy`, `pronoun_privacy`, `metadata_privacy`. All are strings and accept the values of `public`, `private` and `null`.
   * The `privacy` field has now been deprecated and should not be used. It's still returned (mirroring the `visibility` field), and writing to it will write to *all privacy options*.
 * 2020-05-07
   * The API (v1) is now formally(ish) defined with OpenAPI v3.0. [The definition file can be found here.](https://github.com/xSke/PluralKit/blob/master/PluralKit.API/openapi.yaml)

From c428d95479e45ac37c3a5b5330783b7a74ad277d Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Sat, 20 Jun 2020 16:10:22 +0200
Subject: [PATCH 12/13] Fix auth checking when looking up member avatar

---
 PluralKit.Bot/Commands/MemberAvatar.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs
index b2b8dbcc..ba8800fb 100644
--- a/PluralKit.Bot/Commands/MemberAvatar.cs
+++ b/PluralKit.Bot/Commands/MemberAvatar.cs
@@ -48,7 +48,7 @@ namespace PluralKit.Bot
             
             var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl;
             var canAccess = location != AvatarLocation.Member || target.AvatarPrivacy.CanAccess(ctx.LookupContextFor(target));
-            if (string.IsNullOrEmpty(currentValue) && !canAccess)
+            if (string.IsNullOrEmpty(currentValue) || !canAccess)
             {
                 if (location == AvatarLocation.Member)
                 {

From 368320abf49ac9621e2c41ef89453959a2c465ec Mon Sep 17 00:00:00 2001
From: Ske <voltasalt@gmail.com>
Date: Sat, 20 Jun 2020 16:10:36 +0200
Subject: [PATCH 13/13] Add warning when setting avatar privacy with no server
 avatar

---
 PluralKit.Bot/Commands/MemberEdit.cs | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs
index 38219eef..50dd44cd 100644
--- a/PluralKit.Bot/Commands/MemberEdit.cs
+++ b/PluralKit.Bot/Commands/MemberEdit.cs
@@ -399,6 +399,11 @@ namespace PluralKit.Bot
                 await ctx.Reply(embed: CreatePrivacyEmbed(ctx, target));
                 return;
             }
+            
+            // Get guild settings (mostly for warnings and such)
+            MemberGuildSettings guildSettings = null;
+            if (ctx.Guild != null)
+                guildSettings = await _db.Execute(c => c.QueryOrInsertMemberGuildConfig(ctx.Guild.Id, target.Id));
 
             // Set Privacy Settings
             PrivacyLevel PopPrivacyLevel(string subjectName)
@@ -472,6 +477,10 @@ namespace PluralKit.Bot
             // Name privacy only works given a display name
             if (subject == MemberPrivacySubject.Name && newLevel == PrivacyLevel.Private && target.DisplayName == null)
                 await ctx.Reply($"{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**.");
+            // Avatar privacy doesn't apply when proxying if no server avatar is set
+            if (subject == MemberPrivacySubject.Avatar && newLevel == PrivacyLevel.Private &&
+                guildSettings?.AvatarUrl == null)
+                await ctx.Reply($"{Emojis.Warn} This member does not have a server avatar set, so *proxying* will **still show the member avatar**. If you want to hide your avatar when proxying here, set a server avatar: `pk;member {target.Hid} serveravatar`");
         }
         
         public async Task Delete(Context ctx, PKMember target)