From 7c857471ddeaeb2ec18342bf1c3b6d504765aa76 Mon Sep 17 00:00:00 2001
From: Joseph Azevedo
Date: Thu, 3 Dec 2020 06:18:20 -0500
Subject: [PATCH 01/11] feat: add emoji manager from old repo
Co-authored-by: Mustak Ahmed
---
app/src/data/umami-analytics.ts | 52 ++++++-------
app/src/store/routes/rest.ts | 35 ++++++++-
app/src/store/slices/pools.ts | 9 ++-
app/src/tabs/EmojiManager.tsx | 134 ++++++++++++++++++++++++++++++--
app/src/utility/types.ts | 12 +++
5 files changed, 206 insertions(+), 36 deletions(-)
diff --git a/app/src/data/umami-analytics.ts b/app/src/data/umami-analytics.ts
index ef506c62..0b96caff 100644
--- a/app/src/data/umami-analytics.ts
+++ b/app/src/data/umami-analytics.ts
@@ -12,33 +12,33 @@ export type UmamiAnalytics = {
* Gets the site's Umami analytics parameters if they exist
*/
export function useUmamiAnalytics(): Option {
- const queryResult = useStaticQuery<
- GatsbyTypes.UseUmamiAnalyticsQuery
- >(graphql`
- query UseUmamiAnalytics {
- site {
- siteMetadata {
- umami {
- websiteId
- base
- }
- }
- }
- }
- `);
+ // const queryResult = useStaticQuery<
+ // GatsbyTypes.UseUmamiAnalyticsQuery
+ // >(graphql`
+ // query UseUmamiAnalytics {
+ // site {
+ // siteMetadata {
+ // umami {
+ // websiteId
+ // base
+ // }
+ // }
+ // }
+ // }
+ // `);
- const umami = queryResult.site?.siteMetadata?.umami;
- if (
- isDefined(umami) &&
- isDefined(umami.websiteId) &&
- isDefined(umami.base) &&
- (umami.websiteId.length ?? 0) > 0
- ) {
- return Some({
- websiteId: umami.websiteId,
- base: umami.base,
- });
- }
+ // const umami = queryResult.site?.siteMetadata?.umami;
+ // if (
+ // isDefined(umami) &&
+ // isDefined(umami.websiteId) &&
+ // isDefined(umami.base) &&
+ // (umami.websiteId.length ?? 0) > 0
+ // ) {
+ // return Some({
+ // websiteId: umami.websiteId,
+ // base: umami.base,
+ // });
+ // }
return None;
}
diff --git a/app/src/store/routes/rest.ts b/app/src/store/routes/rest.ts
index 86f0cccd..1cfcdb2d 100644
--- a/app/src/store/routes/rest.ts
+++ b/app/src/store/routes/rest.ts
@@ -4,7 +4,7 @@ import * as t from "io-ts";
import { makeRoute } from "@app/store/api/rest";
import { Errors } from "@app/store/api/rest/types";
import { HttpVerbs } from "@app/utility";
-import { User, Access, Guild } from "@app/utility/types";
+import { User, Access, Guild, Snowflake, HoarFrost } from "@app/utility/types";
export type IdentifySessionResponse = t.TypeOf;
export const IdentifySessionResponse = t.interface({
@@ -103,3 +103,36 @@ export const guilds = makeRoute()({
decode: (response: unknown): Either =>
either.chain(t.object.decode(response), GuildsListResponse.decode),
});
+
+/**
+ * POST /emojis/[guild_id]/[hoar_frost]
+ */
+export const loadCustomEmoji = makeRoute()({
+ label: "custEmoji/load",
+ route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) =>
+ `/emojis/${guildID}/${emojiID}`,
+ method: HttpVerbs.POST,
+ auth: true,
+});
+
+/**
+ * PATCH /emojis/[guild_id]/[hoar_frost]
+ */
+export const cacheCustomEmoji = makeRoute()({
+ label: "custEmoji/cache",
+ route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) =>
+ `/emojis/${guildID}/${emojiID}`,
+ method: HttpVerbs.PATCH,
+ auth: true,
+});
+
+/**
+ * DELETE /emojis/[guild_id]/[hoar_frost]
+ */
+export const deleteCustomEmoji = makeRoute()({
+ label: "custEmoji/delete",
+ route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) =>
+ `/emojis/${guildID}/${emojiID}`,
+ method: HttpVerbs.DELETE,
+ auth: true,
+});
diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts
index 4fdc89fb..294a0777 100644
--- a/app/src/store/slices/pools.ts
+++ b/app/src/store/slices/pools.ts
@@ -11,6 +11,7 @@ import {
AutoResponse,
HoarFrost,
Member,
+ CustomEmoji,
} from "@app/utility/types";
import { Option, None, Some } from "@architus/lib/option";
@@ -19,6 +20,7 @@ export type AllPoolTypes = {
user: User;
autoResponse: AutoResponse;
member: Member;
+ customEmoji: CustomEmoji;
};
// Runtime io-ts types
@@ -27,10 +29,15 @@ export const AllPoolTypes = {
user: User,
autoResponse: AutoResponse,
member: Member,
+ customEmoji: CustomEmoji,
};
export const guildAgnosticPools = ["user", "guild"] as const;
-export const guildSpecificPools = ["autoResponse", "member"] as const;
+export const guildSpecificPools = [
+ "autoResponse",
+ "member",
+ "customEmoji",
+] as const;
export const allPools: PoolType[] = [
...guildAgnosticPools,
...guildSpecificPools,
diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx
index f767fe58..6eac60ce 100644
--- a/app/src/tabs/EmojiManager.tsx
+++ b/app/src/tabs/EmojiManager.tsx
@@ -1,9 +1,15 @@
import { styled } from "linaria/react";
import React from "react";
+import { useDispatch } from "react-redux";
+import DataGrid from "@app/components/DataGrid";
import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
+import { Dispatch } from "@app/store";
+import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
+import { usePool } from "@app/store/slices/pools";
import { TabProps } from "@app/tabs/types";
-import Badge from "@architus/facade/components/Badge";
+import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types";
+import Button from "@architus/facade/components/Button";
import { color } from "@architus/facade/theme/color";
const Styled = {
@@ -15,14 +21,126 @@ const Styled = {
font-size: 1.9rem;
font-weight: 300;
`,
+ PageOuter: styled.div`
+ position: relative;
+ display: flex;
+ justify-content: stretch;
+ align-items: stretch;
+ flex-direction: column;
+ height: 100%;
+ `,
+ Header: styled.div`
+ padding: 6 milli;
+ padding-left: 0 milli;
+ `,
+ DataGridWrapper: styled.div`
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+ flex-grow: 1;
+ `,
};
-const EmojiManager: React.FC = () => (
-
-
- Emoji Manager Coming Soon
-
-
-);
+function creatBtn(
+ x: boolean,
+ dispatch: Dispatch,
+ emojiID: HoarFrost,
+ guildID: Snowflake
+) {
+ if (x == true) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function loadedYN(x: boolean) {
+ if (x == true) {
+ return "Loaded";
+ }
+ return "Cached";
+}
+
+const EmojiManager: React.FC = ({ guild }) => {
+ const columns = [
+ { key: "id", name: "ID" },
+ { key: "name", name: "NAME" },
+ {
+ key: "url",
+ name: "IMAGE",
+ formatter: ({ row }: { row: CustomEmoji }) => (
+
+ ),
+ },
+ {
+ key: "authorId",
+ name: "AUTHOR",
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>{row.authorId.getOrElse(" None ")}>
+ ),
+ },
+ {
+ key: "loaded ",
+ name: "LOADED",
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <> {loadedYN(row.discordId.isDefined())}>
+ ),
+ },
+ { key: "numUses", name: "USES" },
+ {
+ key: "btns",
+ name: "CHANGE",
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>
+ {" "}
+ {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)}
+ >
+ ),
+ },
+ ];
+
+ const { all: emojiList } = usePool({
+ type: "customEmoji",
+ guildId: guild.id,
+ });
+
+ return (
+ <>
+
+
+ Emoji Manager
+
+ Manage the cached and loaded emojis on the server.
+
+
+
+
+ rows={emojiList || []}
+ columns={columns}
+ rowKey="id"
+ />
+
+
+ >
+ );
+};
export default EmojiManager;
diff --git a/app/src/utility/types.ts b/app/src/utility/types.ts
index 9d13c116..c31925e5 100644
--- a/app/src/utility/types.ts
+++ b/app/src/utility/types.ts
@@ -618,3 +618,15 @@ export const Role = t.type({});
export type Channel = t.TypeOf;
export const Channel = t.type({});
+
+const TCustomEmoji = t.type({
+ id: THoarFrost,
+ name: t.string,
+ authorId: option(TSnowflake),
+ numUses: t.number,
+ discordId: option(TSnowflake),
+ priority: t.number,
+ url: t.string,
+});
+export interface CustomEmoji extends t.TypeOf {}
+export const CustomEmoji = alias(TCustomEmoji)();
From 42659cc499351ad8248ac982aaa996c4f9b0ba1a Mon Sep 17 00:00:00 2001
From: Joseph Azevedo
Date: Thu, 3 Dec 2020 06:51:30 -0500
Subject: [PATCH 02/11] fix: use correct option method on path prefix
---
app/src/data/path-prefix.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/data/path-prefix.ts b/app/src/data/path-prefix.ts
index f5826422..671f53c3 100644
--- a/app/src/data/path-prefix.ts
+++ b/app/src/data/path-prefix.ts
@@ -14,5 +14,5 @@ export function usePathPrefix(): Option {
}
`);
- return Option.from(queryResult.site?.pathPrefix);
+ return Option.fromString(queryResult.site?.pathPrefix);
}
From cb108c3ebd5358b5ae3717c0830b2cb3107d0974 Mon Sep 17 00:00:00 2001
From: Seth FroGo
Date: Fri, 12 Mar 2021 18:52:15 -0800
Subject: [PATCH 03/11] Author Display in EmojiManager
---
app/package.json | 3 +-
app/src/tabs/AutoResponses/formatters.tsx | 1 +
app/src/tabs/EmojiManager.tsx | 94 ++++++++++++++++++++++-
3 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/app/package.json b/app/package.json
index cbf2179c..33441e48 100644
--- a/app/package.json
+++ b/app/package.json
@@ -66,7 +66,8 @@
"ts-node": "^8.10.2",
"tsconfig-paths": "^3.9.0",
"twemoji": "^13.0.1",
- "typescript": "^3.9.5"
+ "typescript": "^3.9.5",
+ "yarn": "^1.22.10"
},
"repository": {
"type": "git",
diff --git a/app/src/tabs/AutoResponses/formatters.tsx b/app/src/tabs/AutoResponses/formatters.tsx
index 69ad73b2..af2f38f0 100644
--- a/app/src/tabs/AutoResponses/formatters.tsx
+++ b/app/src/tabs/AutoResponses/formatters.tsx
@@ -265,3 +265,4 @@ export const CountFormatter: React.FC> = ({ row }) => {row.count.toLocaleString()};
+
diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx
index 6eac60ce..1e71bc87 100644
--- a/app/src/tabs/EmojiManager.tsx
+++ b/app/src/tabs/EmojiManager.tsx
@@ -1,16 +1,20 @@
import { styled } from "linaria/react";
-import React from "react";
+import React, { useMemo } from "react";
import { useDispatch } from "react-redux";
import DataGrid from "@app/components/DataGrid";
import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
import { Dispatch } from "@app/store";
import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
-import { usePool } from "@app/store/slices/pools";
import { TabProps } from "@app/tabs/types";
import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types";
import Button from "@architus/facade/components/Button";
import { color } from "@architus/facade/theme/color";
+import { AuthorData, Author } from "./AutoResponses/types";
+import { getAvatarUrl } from "@app/components/UserDisplay";
+import { usePool, usePoolEntities } from "@app/store/slices/pools";
+import UserDisplay from "@app/components/UserDisplay";
+import { gap } from "@architus/facade/theme/spacing";
const Styled = {
Layout: styled.div`
@@ -40,6 +44,21 @@ const Styled = {
justify-content: stretch;
flex-grow: 1;
`,
+ AuthorWrapper: styled.div`
+ display: flex;
+ align-items: center;
+ height: 100%;
+ `,
+ Avatar: styled(UserDisplay.Avatar)`
+ position: relative;
+ display: flex;
+ align-items: center;
+ `,
+ Name: styled.span`
+ margin-left: ${gap.femto};
+ color: text;
+ font-weight: 600;
+ `,
};
function creatBtn(
@@ -80,6 +99,38 @@ function loadedYN(x: boolean) {
}
const EmojiManager: React.FC = ({ guild }) => {
+ const { all: commands, isLoaded: hasLoaded } = usePool({
+ type: "customEmoji",
+ guildId: guild.id,
+ });
+
+ // Load the authors from the commands (call the pool in a staggered manner)
+ const allAuthorIds = useMemo(() => {
+ const ids: Set = new Set();
+ for (const command of commands) {
+ if (command.authorId.isDefined()) {
+ ids.add(command.authorId.get);
+ }
+ }
+ return Array.from(ids);
+ }, [commands]);
+
+ const authorEntries = usePoolEntities({
+ type: "member",
+ guildId: guild.id,
+ ids: allAuthorIds,
+ });
+
+ const authorsMap = useMemo(() => {
+ const authors: Map = new Map();
+ for (const authorEntry of authorEntries) {
+ if (authorEntry.isLoaded && authorEntry.entity.isDefined()) {
+ authors.set(authorEntry.entity.get.id, authorEntry.entity.get);
+ }
+ }
+ return authors;
+ }, [authorEntries]);
+
const columns = [
{ key: "id", name: "ID" },
{ key: "name", name: "NAME" },
@@ -94,7 +145,10 @@ const EmojiManager: React.FC = ({ guild }) => {
key: "authorId",
name: "AUTHOR",
formatter: ({ row }: { row: CustomEmoji }) => (
- <>{row.authorId.getOrElse(" None ")}>
+
+
+ {foldAuthorData(row, authorsMap).author}
+
),
},
{
@@ -144,3 +198,37 @@ const EmojiManager: React.FC = ({ guild }) => {
};
export default EmojiManager;
+
+
+
+/**
+ * Performs the row transformation operation, resolving auto responses to the necessary
+ * fields for display
+ * @param customEmoji - Current row auto response object
+ * @param authors - Map of IDs to User objects to use for fast lookup
+ */
+ function foldAuthorData(
+ customEmoji: CustomEmoji,
+ authors: Map
+): AuthorData {
+ const id = customEmoji.authorId;
+ const authorOption = id.flatMapNil((i) => authors.get(i));
+ if (authorOption.isDefined()) {
+ const { name, discriminator } = authorOption.get;
+ return {
+ author: `${name}#${discriminator}`,
+ avatarUrl: getAvatarUrl({ user: authorOption.get }) ?? "",
+ username: name,
+ discriminator,
+ };
+ }
+
+ return {
+ author: "unknown",
+ username: "unknown",
+ discriminator: "0000",
+ avatarUrl: "/img/unknown.png",
+ };
+}
+
+
From 426ff5cfb7eee4b937df4e6f6e8764606c70edbb Mon Sep 17 00:00:00 2001
From: Jonathan Buchanan
Date: Mon, 13 Sep 2021 02:37:29 -0400
Subject: [PATCH 04/11] add some styles
---
.github/workflows/app-staging.yml | 2 +-
.github/workflows/docs-staging.yml | 2 +-
app/package.json | 5 +
app/src/api.ts | 2 +-
app/src/components/CustomEmoji.tsx | 102 ++++
app/src/components/Mention.tsx | 113 ++++
app/src/components/Timeline.tsx | 170 ++++++
app/src/data/umami-analytics.ts | 31 +-
app/src/store/api/rest/types.ts | 2 +-
app/src/store/routes/rest.ts | 39 +-
app/src/store/slices/index.ts | 2 +
app/src/store/slices/pools.ts | 30 +-
app/src/store/slices/statistics.ts | 58 ++
app/src/tabs/AutoResponses/AutoResponses.tsx | 9 +-
app/src/tabs/AutoResponses/types.ts | 4 +-
.../tabs/{ => EmojiManager}/EmojiManager.tsx | 128 ++--
app/src/tabs/EmojiManager/GridHeader.tsx | 157 +++++
app/src/tabs/EmojiManager/index.ts | 2 +
app/src/tabs/Statistics.tsx | 28 -
app/src/tabs/Statistics/ChannelGraph.tsx | 71 +++
.../tabs/Statistics/CustomRechartsTooltip.tsx | 50 ++
.../Statistics/CustomResponsiveContainer.tsx | 23 +
app/src/tabs/Statistics/GrowthChart.tsx | 157 +++++
app/src/tabs/Statistics/IntegrityAlert.tsx | 68 +++
app/src/tabs/Statistics/MemberGraph.tsx | 70 +++
app/src/tabs/Statistics/MentionsChart.tsx | 59 ++
.../tabs/Statistics/PersonalMessagesChart.tsx | 169 ++++++
app/src/tabs/Statistics/Statistics.tsx | 57 ++
.../tabs/Statistics/StatisticsDashboard.tsx | 570 ++++++++++++++++++
app/src/tabs/Statistics/TimeAreaChart.tsx | 142 +++++
app/src/tabs/Statistics/WordCloud.tsx | 48 ++
app/src/tabs/Statistics/index.ts | 2 +
app/src/tabs/definitions.ts | 2 +-
app/src/utility/discord.ts | 6 +
app/src/utility/types.ts | 16 +-
architus.code-workspace | 3 +
design/src/theme/motion.ts | 8 +
.../features/auto-responses/eval_demo.png | Bin 0 -> 74193 bytes
docs/content/features/auto-responses/index.md | 107 ++++
docs/gatsby-node.ts | 1 +
docs/netlify.toml | 1 +
docs/src/components/TableOfContents.tsx | 18 +-
docs/src/data/umami-analytics.ts | 3 +
docs/src/templates/Docs/frontmatter.ts | 5 +
docs/src/templates/Docs/index.tsx | 4 +-
lib/src/utility/date.ts | 34 ++
lib/src/utility/index.ts | 1 +
lib/src/utility/number.ts | 9 +
package.json | 5 +-
yarn.lock | 342 ++++++++++-
50 files changed, 2830 insertions(+), 107 deletions(-)
create mode 100644 app/src/components/CustomEmoji.tsx
create mode 100644 app/src/components/Mention.tsx
create mode 100644 app/src/components/Timeline.tsx
create mode 100644 app/src/store/slices/statistics.ts
rename app/src/tabs/{ => EmojiManager}/EmojiManager.tsx (69%)
create mode 100644 app/src/tabs/EmojiManager/GridHeader.tsx
create mode 100644 app/src/tabs/EmojiManager/index.ts
delete mode 100644 app/src/tabs/Statistics.tsx
create mode 100644 app/src/tabs/Statistics/ChannelGraph.tsx
create mode 100644 app/src/tabs/Statistics/CustomRechartsTooltip.tsx
create mode 100644 app/src/tabs/Statistics/CustomResponsiveContainer.tsx
create mode 100644 app/src/tabs/Statistics/GrowthChart.tsx
create mode 100644 app/src/tabs/Statistics/IntegrityAlert.tsx
create mode 100644 app/src/tabs/Statistics/MemberGraph.tsx
create mode 100644 app/src/tabs/Statistics/MentionsChart.tsx
create mode 100644 app/src/tabs/Statistics/PersonalMessagesChart.tsx
create mode 100644 app/src/tabs/Statistics/Statistics.tsx
create mode 100644 app/src/tabs/Statistics/StatisticsDashboard.tsx
create mode 100644 app/src/tabs/Statistics/TimeAreaChart.tsx
create mode 100644 app/src/tabs/Statistics/WordCloud.tsx
create mode 100644 app/src/tabs/Statistics/index.ts
create mode 100644 docs/content/features/auto-responses/eval_demo.png
create mode 100644 lib/src/utility/number.ts
diff --git a/.github/workflows/app-staging.yml b/.github/workflows/app-staging.yml
index d073c968..99c54ce6 100644
--- a/.github/workflows/app-staging.yml
+++ b/.github/workflows/app-staging.yml
@@ -104,7 +104,7 @@ jobs:
run: >-
T="$(date +%s)";
- yarn build --prefix-paths;
+ yarn build --prefix-paths --profile;
exit_code=$?;
T="$(($(date +%s)-T))";
diff --git a/.github/workflows/docs-staging.yml b/.github/workflows/docs-staging.yml
index ece18b50..778e7379 100644
--- a/.github/workflows/docs-staging.yml
+++ b/.github/workflows/docs-staging.yml
@@ -105,7 +105,7 @@ jobs:
run: >-
T="$(date +%s)";
- yarn build --prefix-paths;
+ yarn build --prefix-paths --profile;
exit_code=$?;
T="$(($(date +%s)-T))";
diff --git a/app/package.json b/app/package.json
index 33441e48..5e1fd018 100644
--- a/app/package.json
+++ b/app/package.json
@@ -48,6 +48,7 @@
"node-fetch": "^2.6.0",
"polished": "^3.6.5",
"react": "^16.13.1",
+ "react-countup": "^4.3.3",
"react-data-grid": "^7.0.0-canary.17",
"react-dom": "^16.12.0",
"react-helmet": "^6.1.0",
@@ -57,9 +58,12 @@
"react-switch": "^5.0.1",
"react-transition-group": "^4.4.1",
"react-virtualized-auto-sizer": "^1.0.2",
+ "react-wordcloud": "^1.2.7",
+ "recharts": "^1.8.5",
"redux": "^4.0.5",
"redux-batched-actions": "^0.5.0",
"redux-saga": "^1.1.3",
+ "reselect": "^4.0.0",
"s-ago": "^2.2.0",
"shallow-equal": "^1.2.1",
"socket.io-client": "^2.3.0",
@@ -86,6 +90,7 @@
"@types/react-redux": "^7.1.9",
"@types/react-select": "^3.0.14",
"@types/react-virtualized-auto-sizer": "^1.0.0",
+ "@types/recharts": "^1.8.18",
"@types/socket.io-client": "^1.4.33",
"@types/twemoji": "^12.1.1",
"@types/underscore.string": "^0.0.38",
diff --git a/app/src/api.ts b/app/src/api.ts
index edb21465..73afae57 100644
--- a/app/src/api.ts
+++ b/app/src/api.ts
@@ -15,7 +15,7 @@ export const GATEWAY_API_BASE: string = process.env.GATSBY_PRODUCTION
: "https://gateway.develop.archit.us";
/**
- * resolves a path with he optional base path
+ * resolves a path with the optional base path
* @param path - base path with leading /
*/
export function withBasePath(path: string): string {
diff --git a/app/src/components/CustomEmoji.tsx b/app/src/components/CustomEmoji.tsx
new file mode 100644
index 00000000..fb363156
--- /dev/null
+++ b/app/src/components/CustomEmoji.tsx
@@ -0,0 +1,102 @@
+import { styled } from "linaria/react";
+import { transparentize } from "polished";
+import React from "react";
+
+import { OtherColors } from "@app/theme/color";
+import { snowflakeToDate } from "@app/utility/discord";
+import { CustomEmoji, Member, Snowflake } from "@app/utility/types";
+import Tooltip from "@architus/facade/components/Tooltip";
+import {
+ isDefined,
+ formatDateExtraShort,
+ formatNum,
+} from "@architus/lib/utility";
+
+const Styled = {
+ TooltipName: styled.strong`
+ display: block;
+ `,
+ TooltipElevated: styled.div`
+ opacity: 0.7;
+ font-size: 80%;
+ `,
+ OuterContainer: styled.div`
+ display: flex;
+ flex-direction: column;
+ `,
+ ImageContainer: styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 100%;
+ `,
+
+ Image: styled.img`
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ `,
+ Mention: styled.div`
+ max-width: max-content;
+ color: ${OtherColors.Discord};
+ background-color: ${transparentize(0.85, OtherColors.Discord)};
+
+ &:hover {
+ color: white;
+ background-color: ${transparentize(0.25, OtherColors.Discord)};
+ }
+ `,
+};
+
+type CustomEmojiProps = {
+ emoji: CustomEmoji;
+ author?: Member;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/**
+ * Wraps a CustomEmoji object in a formatted tooltip showing name, author name, creation date, and usage.
+ */
+export const CustomEmojiIcon: React.FC = ({
+ emoji,
+ author,
+ style,
+ className,
+}) => {
+ let authorName = null;
+ if (isDefined(author) && author.id === emoji.authorId) {
+ authorName = (
+
+ {author.name}#{author.discriminator}
+
+ );
+ }
+ const architusDate = snowflakeToDate((emoji.id as string) as Snowflake);
+ const discordDate = emoji.discordId.isDefined()
+ ? snowflakeToDate(emoji.discordId.get)
+ : new Date(8640000000000000);
+ const date = formatDateExtraShort(
+ architusDate < discordDate ? architusDate : discordDate
+ );
+
+ return (
+
+ :{emoji.name}:
+ {authorName}
+ {date}
+
+ uses: {formatNum(emoji.numUses)}
+
+
+ }
+ >
+
+
+
+
+ );
+};
diff --git a/app/src/components/Mention.tsx b/app/src/components/Mention.tsx
new file mode 100644
index 00000000..d23fb947
--- /dev/null
+++ b/app/src/components/Mention.tsx
@@ -0,0 +1,113 @@
+import { styled } from "linaria/react";
+import { transparentize } from "polished";
+import React from "react";
+import ago from "s-ago";
+
+import { getAvatarUrl } from "@app/components/UserDisplay";
+import { OtherColors } from "@app/theme/color";
+import { Member } from "@app/utility/types";
+import Tooltip from "@architus/facade/components/Tooltip";
+import { gap } from "@architus/facade/theme/spacing";
+
+const Styled = {
+ Tooltip: styled(Tooltip)``,
+ TooltipName: styled.strong`
+ display: block;
+ `,
+ TooltipElevated: styled.div`
+ opacity: 0.7;
+ font-size: 80%;
+ `,
+ Avatar: styled.img`
+ border-radius: 50%;
+ max-width: 70px;
+ height: auto;
+ margin: ${gap.pico} ${gap.nano} ${gap.pico} 0;
+ `,
+ OuterContainer: styled.div`
+ display: flex;
+ `,
+ InnerContainer: styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ white-space: nowrap;
+ text-align: left;
+ `,
+ NameContainer: styled.div<{ color: string }>`
+ border: 1px solid ${(props): string => props.color};
+ border-radius: 10px;
+ width: fit-content;
+ padding: 0 ${gap.femto} 0 ${gap.nano};
+ margin-bottom: ${gap.femto};
+ `,
+ ColoredCircle: styled.div<{ color: string }>`
+ height: 10px;
+ width: 10px;
+ background-color: ${(props): string => props.color};
+ border-radius: 50%;
+ display: inline-block;
+ margin: 0 ${gap.femto} 0 -${gap.pico};
+ `,
+ Mention: styled.div`
+ max-width: 100%;
+ color: ${OtherColors.Discord};
+ background-color: ${transparentize(0.85, OtherColors.Discord)};
+
+ & > p {
+ max-width: 100%;
+ }
+
+ &:hover {
+ color: white;
+ background-color: ${transparentize(0.25, OtherColors.Discord)};
+ }
+ `,
+};
+
+type MentionProps = {
+ member: Member;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/**
+ * Render a Member object in the style of a discord mention in light or dark mode.
+ * Also wraps in a tooltip showing avatar, username, and join date.
+ */
+export const Mention: React.FC = ({
+ member,
+ style,
+ className,
+}) => {
+ let color = member.color.isDefined() ? member.color.get : "white";
+ if (color === "#000000") {
+ // because discord uses black instead of 'undefined'
+ color = "white";
+ }
+ return (
+
+
+
+
+
+ {member.name}#{member.discriminator}
+
+ Joined {ago(new Date(member.joined_at))}
+
+
+ }
+ >
+
+
+ @{member.nick.isDefined() ? member.nick.get : member.name}
+
+
+
+ );
+};
diff --git a/app/src/components/Timeline.tsx b/app/src/components/Timeline.tsx
new file mode 100644
index 00000000..1a743f9f
--- /dev/null
+++ b/app/src/components/Timeline.tsx
@@ -0,0 +1,170 @@
+import { styled } from "linaria/react";
+import React, { useMemo } from "react";
+import { FaDotCircle } from "react-icons/fa";
+import { IconType } from "react-icons/lib";
+
+import Tooltip from "@architus/facade/components/Tooltip";
+import { mode, ColorMode } from "@architus/facade/theme/color";
+import { down } from "@architus/facade/theme/media";
+import { formatDate, formatDateExtraShort } from "@architus/lib/utility";
+
+const Styled = {
+ Timeline: styled.div`
+ max-width: 100%;
+ display: flex;
+ height: 100%;
+ padding: 20px 0;
+ `,
+ Line: styled.div`
+ width: 2px;
+ margin: 15px 10px 36px 10px;
+ `,
+ List: styled.div`
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: space-between;
+ height: 100%;
+ flex-direction: column;
+ `,
+ Container: styled.div`
+ padding: 10px 10px;
+ ${down("md")} {
+ padding: 5px 10px;
+ }
+ display: flex;
+
+ &:first-child::after {
+ position: absolute;
+ left: 43px;
+ content: "";
+ display: block;
+ height: 170px;
+ width: 2px;
+ ${mode(ColorMode.Light)} {
+ background-color: #676767;
+ }
+ ${mode(ColorMode.Dark)} {
+ background-color: #767e87;
+ }
+ }
+ &:last-child::after {
+ position: absolute;
+ left: 43px;
+ transform: translateY(-160px);
+ content: "";
+ display: block;
+ height: 170px;
+ width: 2px;
+ ${mode(ColorMode.Light)} {
+ background-color: #676767;
+ }
+ ${mode(ColorMode.Dark)} {
+ background-color: #767e87;
+ }
+ }
+ `,
+ Dot: styled.div`
+ content: '';
+ flex 0 0 auto;
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ left: 36px;
+ ${mode(ColorMode.Light)} {
+ background-color: #676767;
+ border: 3px solid #ffffff;
+ }
+ ${mode(ColorMode.Dark)} {
+ background-color: #767e87;
+ border: 3px solid #31363f;
+ }
+ border-radius: 50%;
+ z-index: 1;
+ `,
+ IconContainer: styled.div`
+ padding: 2px;
+ & > * {
+ z-index: 1;
+ position: absolute;
+ left: 36px;
+ ${mode(ColorMode.Light)} {
+ background-color: #ffffff;
+ outline: 4px solid #ffffff;
+ }
+ ${mode(ColorMode.Dark)} {
+ background-color: #31363f;
+ outline: 4px solid #31363f;
+ }
+ }
+ `,
+ Content: styled.div`
+ max-width: fit-content;
+ font-size: 0.875rem;
+ `,
+};
+
+type TimelineProps = {
+ className?: string;
+ style?: React.CSSProperties;
+ innerProps?: Partial>;
+};
+
+type TimelineItemProps = {
+ date: Date;
+ icon?: IconType;
+ dateFormatter?: (date: Date) => string;
+ className?: string;
+ style?: React.CSSProperties;
+ innerProps?: Partial>;
+};
+
+/**
+ * Display a list of TimelineItem ordered by their date.
+ */
+export const Timeline: React.FC = ({
+ style,
+ className,
+ children,
+ innerProps = {},
+}) => {
+ const childrenArray = useMemo(
+ () =>
+ (React.Children.toArray(children) as React.ReactElement[]).sort(
+ (a, b) => a.props.date - b.props.date
+ ),
+ [children]
+ );
+ return (
+
+
+ {childrenArray}
+
+ );
+};
+
+/**
+ * The child of Timeline; takes a date to display as the header and determine order.
+ * Also takes an icon to show on the line and may display a short description.
+ */
+export const TimelineItem: React.FC = ({
+ date,
+ style,
+ icon = FaDotCircle,
+ dateFormatter = formatDateExtraShort,
+ className,
+ children,
+ innerProps = {},
+}) => {
+ const iconElem = React.createElement(icon, {});
+ return (
+
+ {iconElem}
+
+ {formatDate(date)}
}>
+ {dateFormatter(date)}
+ {children}
+
+
+
+ );
+};
diff --git a/app/src/data/umami-analytics.ts b/app/src/data/umami-analytics.ts
index 0b96caff..b44fdbfe 100644
--- a/app/src/data/umami-analytics.ts
+++ b/app/src/data/umami-analytics.ts
@@ -12,20 +12,23 @@ export type UmamiAnalytics = {
* Gets the site's Umami analytics parameters if they exist
*/
export function useUmamiAnalytics(): Option {
- // const queryResult = useStaticQuery<
- // GatsbyTypes.UseUmamiAnalyticsQuery
- // >(graphql`
- // query UseUmamiAnalytics {
- // site {
- // siteMetadata {
- // umami {
- // websiteId
- // base
- // }
- // }
- // }
- // }
- // `);
+ // this seems to be required due to a bug with useStaticQuery in gatsby v2.23.11
+ if (process.env.NODE_ENV !== "production") return None;
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const queryResult = useStaticQuery<
+ GatsbyTypes.UseUmamiAnalyticsQuery
+ >(graphql`
+ query UseUmamiAnalytics {
+ site {
+ siteMetadata {
+ umami {
+ websiteId
+ base
+ }
+ }
+ }
+ }
+ `);
// const umami = queryResult.site?.siteMetadata?.umami;
// if (
diff --git a/app/src/store/api/rest/types.ts b/app/src/store/api/rest/types.ts
index 205da575..b01d79d7 100644
--- a/app/src/store/api/rest/types.ts
+++ b/app/src/store/api/rest/types.ts
@@ -139,7 +139,7 @@ export interface RestSuccess<
}
/**
- * Rest success action payload
+ * Rest failure action payload
*/
export interface RestFailure<
TData extends Record = Record,
diff --git a/app/src/store/routes/rest.ts b/app/src/store/routes/rest.ts
index 1cfcdb2d..d8c68686 100644
--- a/app/src/store/routes/rest.ts
+++ b/app/src/store/routes/rest.ts
@@ -4,7 +4,15 @@ import * as t from "io-ts";
import { makeRoute } from "@app/store/api/rest";
import { Errors } from "@app/store/api/rest/types";
import { HttpVerbs } from "@app/utility";
-import { User, Access, Guild, Snowflake, HoarFrost } from "@app/utility/types";
+import {
+ User,
+ Access,
+ Guild,
+ EpochFromString,
+ Snowflake,
+ HoarFrost,
+ THoarFrost,
+} from "@app/utility/types";
export type IdentifySessionResponse = t.TypeOf;
export const IdentifySessionResponse = t.interface({
@@ -136,3 +144,32 @@ export const deleteCustomEmoji = makeRoute()({
method: HttpVerbs.DELETE,
auth: true,
});
+
+export type StatisticsResponse = t.TypeOf;
+
+export const StatisticsResponse = t.interface({
+ memberCount: t.number,
+ messageCount: t.number,
+ architusCount: t.number,
+ commonWords: t.array(t.tuple([t.string, t.number])),
+ mentionCounts: t.record(t.string, t.number),
+ memberCounts: t.record(t.string, t.number),
+ channelCounts: t.record(t.string, t.number),
+ timeMemberCounts: t.record(EpochFromString, t.record(t.string, t.number)),
+ upToDate: t.boolean,
+ forbidden: t.boolean,
+ lastActivity: EpochFromString,
+ popularEmojis: t.array(THoarFrost),
+});
+
+/**
+ * GET /stats/
+ */
+export const fetchStats = makeRoute()({
+ label: "stats",
+ route: ({ guildId }: { guildId: Snowflake }) => `/stats/${guildId}`,
+ method: HttpVerbs.GET,
+ auth: true,
+ decode: (response: unknown): Either =>
+ either.chain(t.object.decode(response), StatisticsResponse.decode),
+});
diff --git a/app/src/store/slices/index.ts b/app/src/store/slices/index.ts
index 0d9ca088..6263c484 100644
--- a/app/src/store/slices/index.ts
+++ b/app/src/store/slices/index.ts
@@ -7,6 +7,7 @@ import Loading from "@app/store/slices/loading";
import Notifications from "@app/store/slices/notifications";
import Pools from "@app/store/slices/pools";
import Session from "@app/store/slices/session";
+import Statistics from "@app/store/slices/statistics";
const rootReducer = combineReducers({
session: Session,
@@ -16,6 +17,7 @@ const rootReducer = combineReducers({
interpret: Interpret,
gateway: Gateway,
pools: Pools,
+ statistics: Statistics,
});
export default rootReducer;
diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts
index 294a0777..bc79d3f4 100644
--- a/app/src/store/slices/pools.ts
+++ b/app/src/store/slices/pools.ts
@@ -11,6 +11,7 @@ import {
AutoResponse,
HoarFrost,
Member,
+ Channel,
CustomEmoji,
} from "@app/utility/types";
import { Option, None, Some } from "@architus/lib/option";
@@ -20,6 +21,7 @@ export type AllPoolTypes = {
user: User;
autoResponse: AutoResponse;
member: Member;
+ channel: Channel;
customEmoji: CustomEmoji;
};
@@ -29,6 +31,7 @@ export const AllPoolTypes = {
user: User,
autoResponse: AutoResponse,
member: Member,
+ channel: Channel,
customEmoji: CustomEmoji,
};
@@ -36,6 +39,7 @@ export const guildAgnosticPools = ["user", "guild"] as const;
export const guildSpecificPools = [
"autoResponse",
"member",
+ "channel",
"customEmoji",
] as const;
export const allPools: PoolType[] = [
@@ -802,16 +806,28 @@ export function usePoolEntities(
};
// Return the previous provider if it's the same
- return previousProvidersRef.current.isDefined() &&
- shallowEqual(previousProvidersRef.current.get[index], newProvider)
- ? previousProvidersRef.current.get[index]
- : newProvider;
+ if (previousProvidersRef.current.isDefined()) {
+ const previousProviders = previousProvidersRef.current.get;
+ if (index < previousProviders.length) {
+ const previousProvider = previousProviders[index];
+ if (shallowEqual(previousProvider, newProvider)) {
+ return previousProvider;
+ }
+ }
+ }
+
+ return newProvider;
});
// Return the entire old providers array if every element is the same
// (effectively ensures the same array is returned if there are no changes)
- return previousProvidersRef.current.isDefined() &&
+
+ if (
+ previousProvidersRef.current.isDefined() &&
shallowEqual(previousProvidersRef.current.get, providers)
- ? previousProvidersRef.current.get
- : providers;
+ ) {
+ return previousProvidersRef.current.get;
+ }
+ previousProvidersRef.current = Option.from(providers);
+ return providers;
}
diff --git a/app/src/store/slices/statistics.ts b/app/src/store/slices/statistics.ts
new file mode 100644
index 00000000..d89e6319
--- /dev/null
+++ b/app/src/store/slices/statistics.ts
@@ -0,0 +1,58 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+import { restSuccess } from "@app/store/api/rest";
+import { fetchStats } from "@app/store/routes";
+import { Nil } from "@architus/lib/types";
+import { isDefined } from "@architus/lib/utility";
+import { HoarFrost } from "src/utility/types";
+
+/**
+ * Stores statistics for the guilds
+ */
+export interface GuildStatistics {
+ memberCount: number;
+ messageCount: number;
+ architusCount: number;
+ commonWords: Array<[string, number]>;
+ mentionCounts: Record;
+ memberCounts: Record;
+ channelCounts: Record;
+ timeMemberCounts: Record>;
+ upToDate: boolean;
+ forbidden: boolean;
+ lastActivity: string;
+ popularEmojis: Array;
+}
+
+export interface Statistics {
+ statistics: Record | Nil;
+}
+
+// ? ====================
+// ? Reducer exports
+// ? ====================
+
+const initialState: Statistics = { statistics: null };
+const slice = createSlice({
+ name: "statistics",
+ initialState,
+ reducers: {},
+ extraReducers: {
+ [restSuccess.type]: (state, action): Statistics => {
+ if (fetchStats.match(action)) {
+ const { guildId } = action.payload.routeData;
+ const decoded = fetchStats.decode(action.payload);
+ if (decoded.isDefined()) {
+ if (isDefined(state.statistics)) {
+ state.statistics[guildId as string] = decoded.get;
+ } else {
+ return { statistics: { [guildId]: decoded.get } };
+ }
+ }
+ }
+ return state;
+ },
+ },
+});
+
+export default slice.reducer;
diff --git a/app/src/tabs/AutoResponses/AutoResponses.tsx b/app/src/tabs/AutoResponses/AutoResponses.tsx
index 57a0c630..60a16201 100644
--- a/app/src/tabs/AutoResponses/AutoResponses.tsx
+++ b/app/src/tabs/AutoResponses/AutoResponses.tsx
@@ -85,8 +85,7 @@ const AutoResponses: React.FC = (pageProps) => {
return Array.from(ids);
}, [commands]);
const authorEntries = usePoolEntities({
- type: "member",
- guildId: guild.id,
+ type: "user",
ids: allAuthorIds,
});
const authorsMap = useMemo(() => {
@@ -161,11 +160,11 @@ function foldAuthorData(
const id = autoResponse.authorId;
const authorOption = id.flatMapNil((i) => authors.get(i));
if (authorOption.isDefined()) {
- const { name, discriminator } = authorOption.get;
+ const { username, discriminator } = authorOption.get;
return {
- author: `${name}#${discriminator}|${id}`,
+ author: `${username}#${discriminator}|${id}`,
avatarUrl: getAvatarUrl({ user: authorOption.get }) ?? "",
- username: name,
+ username,
discriminator,
};
}
diff --git a/app/src/tabs/AutoResponses/types.ts b/app/src/tabs/AutoResponses/types.ts
index a7116a1b..6b3943e7 100644
--- a/app/src/tabs/AutoResponses/types.ts
+++ b/app/src/tabs/AutoResponses/types.ts
@@ -1,6 +1,6 @@
-import { AutoResponse, Member } from "@app/utility/types";
+import { AutoResponse, User } from "@app/utility/types";
-export type Author = Member;
+export type Author = User;
export type TransformedAutoResponse = AutoResponse & {
authorData: AuthorData;
diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx
similarity index 69%
rename from app/src/tabs/EmojiManager.tsx
rename to app/src/tabs/EmojiManager/EmojiManager.tsx
index 1e71bc87..e9752c1b 100644
--- a/app/src/tabs/EmojiManager.tsx
+++ b/app/src/tabs/EmojiManager/EmojiManager.tsx
@@ -5,16 +5,19 @@ import { useDispatch } from "react-redux";
import DataGrid from "@app/components/DataGrid";
import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
import { Dispatch } from "@app/store";
+import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa";
+import AutoLink from "@architus/facade/components/AutoLink";
import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
import { TabProps } from "@app/tabs/types";
import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types";
-import Button from "@architus/facade/components/Button";
import { color } from "@architus/facade/theme/color";
-import { AuthorData, Author } from "./AutoResponses/types";
+import { AuthorData, Author } from "../AutoResponses/types";
import { getAvatarUrl } from "@app/components/UserDisplay";
import { usePool, usePoolEntities } from "@app/store/slices/pools";
import UserDisplay from "@app/components/UserDisplay";
import { gap } from "@architus/facade/theme/spacing";
+import { up } from "@architus/facade/theme/media";
+import GridHeader from "./GridHeader";
const Styled = {
Layout: styled.div`
@@ -24,6 +27,7 @@ const Styled = {
color: ${color("textStrong")};
font-size: 1.9rem;
font-weight: 300;
+ margin-bottom: ${gap.nano};
`,
PageOuter: styled.div`
position: relative;
@@ -32,33 +36,59 @@ const Styled = {
align-items: stretch;
flex-direction: column;
height: 100%;
+
+ padding-top: ${gap.milli};
`,
Header: styled.div`
- padding: 6 milli;
- padding-left: 0 milli;
+ padding: 0 ${gap.milli};
+
+ p {
+ margin-bottom: ${gap.micro};
+ }
`,
DataGridWrapper: styled.div`
position: relative;
+ flex-grow: 1;
display: flex;
- align-items: stretch;
justify-content: stretch;
- flex-grow: 1;
+ align-items: stretch;
+ flex-direction: column;
+ background-color: ${color("bg")};
+ overflow: hidden;
+
+ ${up("md")} {
+ margin-left: ${gap.milli};
+ border-top-left-radius: 1rem;
+ }
`,
- AuthorWrapper: styled.div`
+ AuthorWrapper: styled.div`
display: flex;
align-items: center;
height: 100%;
`,
- Avatar: styled(UserDisplay.Avatar)`
+ Avatar: styled(UserDisplay.Avatar)`
position: relative;
display: flex;
align-items: center;
`,
- Name: styled.span`
+ Name: styled.span`
margin-left: ${gap.femto};
color: text;
font-weight: 600;
`,
+ ButtonWrapper: styled.div`
+ max-height: 50%;
+ margin: 3px 0;
+ font-size: 2em;
+ `,
+ IconWrapper: styled.div`
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ color: ${color("success")};
+ font-size: 1.5em;
+ `,
};
function creatBtn(
@@ -69,33 +99,33 @@ function creatBtn(
) {
if (x == true) {
return (
-
+ />
+
);
}
return (
-
+
+
+ dispatch(loadCustomEmoji({ routeData: { guildID, emojiID } }))
+ }
+ />
+
);
}
function loadedYN(x: boolean) {
if (x == true) {
- return "Loaded";
+ return
}
- return "Cached";
+ return <>>;
}
const EmojiManager: React.FC = ({ guild }) => {
@@ -132,15 +162,32 @@ const EmojiManager: React.FC = ({ guild }) => {
}, [authorEntries]);
const columns = [
- { key: "id", name: "ID" },
- { key: "name", name: "NAME" },
+ {
+ key: "loaded ",
+ name: "LOADED",
+ width: 10,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <> {loadedYN(row.discordId.isDefined())}>
+ ),
+ },
{
key: "url",
name: "IMAGE",
formatter: ({ row }: { row: CustomEmoji }) => (
-
+
+ ),
+ },
+ {
+ key: "download",
+ name: "DOWNLOAD",
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>
+ {row.name}
+ >
),
},
+ { key: "numUses", name: "USES" },
+
{
key: "authorId",
name: "AUTHOR",
@@ -152,20 +199,23 @@ const EmojiManager: React.FC = ({ guild }) => {
),
},
{
- key: "loaded ",
- name: "LOADED",
+ key: "btns",
+ name: "MANAGE",
formatter: ({ row }: { row: CustomEmoji }) => (
- <> {loadedYN(row.discordId.isDefined())}>
+ <>
+ {" "}
+ {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)}
+ >
),
},
- { key: "numUses", name: "USES" },
{
- key: "btns",
- name: "CHANGE",
+ key: "delete",
+ name: "DELETE",
formatter: ({ row }: { row: CustomEmoji }) => (
<>
- {" "}
- {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)}
+
+
+
>
),
},
@@ -186,7 +236,15 @@ const EmojiManager: React.FC = ({ guild }) => {
+ {}}
+ filterSelfAuthored={false}
+ onChangeFilterSelfAuthored={(newShow: boolean) => {}}
+ addNewRowEnable={true}
+ />
+ rowHeight={65}
rows={emojiList || []}
columns={columns}
rowKey="id"
diff --git a/app/src/tabs/EmojiManager/GridHeader.tsx b/app/src/tabs/EmojiManager/GridHeader.tsx
new file mode 100644
index 00000000..a6c13a42
--- /dev/null
+++ b/app/src/tabs/EmojiManager/GridHeader.tsx
@@ -0,0 +1,157 @@
+import { styled } from "linaria/react";
+import React from "react";
+
+import HelpTooltip from "@app/components/HelpTooltip";
+import Switch from "@app/components/Switch";
+import { sitePadding } from "@app/layout";
+import { StylableButton } from "@architus/facade/components/Button";
+import Tooltip from "@architus/facade/components/Tooltip";
+import Comfy from "@architus/facade/icons/comfy.svg";
+import Compact from "@architus/facade/icons/compact.svg";
+import Sparse from "@architus/facade/icons/sparse.svg";
+import { color } from "@architus/facade/theme/color";
+import { up } from "@architus/facade/theme/media";
+import { shadow } from "@architus/facade/theme/shadow";
+import { gap } from "@architus/facade/theme/spacing";
+
+const BaseButton = StylableButton<"button">();
+const ViewModeButton = styled(BaseButton)`
+ &[data-active="true"] {
+ background-color: ${color("activeOverlay")} !important;
+ box-shadow: inset 0 3px 7px ${color("activeOverlay")} !important;
+ }
+
+ svg {
+ transform: translateY(2px);
+ }
+`;
+const Styled = {
+ GridHeader: styled.div`
+ display: flex;
+ min-height: ${gap.centi};
+ align-items: center;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ z-index: 4;
+
+ box-shadow: ${shadow("z0")};
+ background-color: ${color("bg+10")};
+ padding: ${gap.femto} 0;
+
+ & > * {
+ margin-top: ${gap.femto};
+ margin-bottom: ${gap.femto};
+ }
+
+ ${up("md")} {
+ border-top-left-radius: 1rem;
+ }
+ `,
+ ViewModeButtonGroup: styled.div`
+ padding: 0 0.25rem;
+ border-radius: 0.5rem;
+ margin-left: auto;
+
+ ${up("lg")} {
+ margin-right: ${sitePadding};
+ }
+
+ & > * {
+ &:not(:first-of-type) ${ViewModeButton} {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+
+ &:not(:last-of-type) ${ViewModeButton} {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ }
+ `,
+ ViewModeButton,
+ ViewModeTooltip: styled(Tooltip)`
+ display: inline-block;
+ `,
+ FilterSwitch: styled(Switch)`
+ padding: 0 1rem;
+ `,
+ FilterSelfSwitch: styled(Switch)`
+ padding: 0 1rem;
+ `,
+ SelfAuthorLabel: styled.span`
+ margin-right: ${gap.nano};
+ `,
+};
+
+export type ViewMode = keyof typeof viewModes;
+const viewModeOrder: ViewMode[] = ["Sparse", "Comfy", "Compact"];
+export const viewModes = {
+ Compact: { icon: Compact, label: "Compact", height: 28 },
+ Comfy: { icon: Comfy, label: "Comfy", height: 36 },
+ Sparse: { icon: Sparse, label: "Sparse", height: 44 },
+} as const;
+
+export type GridHeaderProps = {
+ viewMode: ViewMode;
+ setViewMode: (newMode: ViewMode) => void;
+ filterSelfAuthored: boolean;
+ onChangeFilterSelfAuthored: (newShow: boolean) => void;
+ addNewRowEnable: boolean;
+ onAddNewRow: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/**
+ * Renders an elevated header at the top of the data grid,
+ * providing a set of options
+ */
+const GridHeader: React.FC = ({
+ viewMode,
+ setViewMode,
+ filterSelfAuthored,
+ onChangeFilterSelfAuthored,
+ className,
+ style,
+}) => (
+
+
+
+ Filter by self-authored
+
+
+ >
+ }
+ />
+
+ {viewModeOrder.map((key) => {
+ const Icon = viewModes[key].icon;
+ return (
+
+ setViewMode(key as ViewMode)}
+ data-active={viewMode === key ? "true" : undefined}
+ >
+
+
+
+ );
+ })}
+
+
+);
+
+export default GridHeader;
diff --git a/app/src/tabs/EmojiManager/index.ts b/app/src/tabs/EmojiManager/index.ts
new file mode 100644
index 00000000..2d4057bf
--- /dev/null
+++ b/app/src/tabs/EmojiManager/index.ts
@@ -0,0 +1,2 @@
+// Convenience export
+export { default } from "./EmojiManager";
diff --git a/app/src/tabs/Statistics.tsx b/app/src/tabs/Statistics.tsx
deleted file mode 100644
index 9d95d34b..00000000
--- a/app/src/tabs/Statistics.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { styled } from "linaria/react";
-import React from "react";
-
-import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
-import { TabProps } from "@app/tabs/types";
-import Badge from "@architus/facade/components/Badge";
-import { color } from "@architus/facade/theme/color";
-
-const Styled = {
- Layout: styled.div`
- padding: ${appVerticalPadding} ${appHorizontalPadding};
- `,
- Title: styled.h2`
- color: ${color("textStrong")};
- font-size: 1.9rem;
- font-weight: 300;
- `,
-};
-
-const Statistics: React.FC = () => (
-
-
- Statistics Coming Soon
-
-
-);
-
-export default Statistics;
diff --git a/app/src/tabs/Statistics/ChannelGraph.tsx b/app/src/tabs/Statistics/ChannelGraph.tsx
new file mode 100644
index 00000000..5f60ee90
--- /dev/null
+++ b/app/src/tabs/Statistics/ChannelGraph.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import {
+ BarChart,
+ Bar,
+ Legend,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+} from "recharts";
+
+import { CustomRechartsTooltip } from "./CustomRechartsTooltip";
+import CustomResponsiveContainer from "./CustomResponsiveContainer";
+import { Channel } from "@app/utility/types";
+import { isDefined, formatNum } from "@architus/lib/utility";
+
+type ChannelData = {
+ name: string;
+ count: number;
+};
+type ChannelGraphProps = {
+ channelCounts: Record;
+ channels: Map;
+};
+
+const getChannelData = (
+ channelCounts: Record,
+ channels: Map
+): ChannelData[] => {
+ const data: ChannelData[] = [];
+ Object.entries(channelCounts).forEach(([key, value]) => {
+ const channel = channels.get(key);
+ if (isDefined(channel)) {
+ data.push({ name: channel.name, count: value });
+ }
+ });
+ data.sort((a, b) => (a.count < b.count ? 1 : -1));
+ return data.slice(0, 15);
+};
+
+const tooltipRenderer = (
+ payload: Array<{ value: number }>,
+ label: string
+): JSX.Element => {
+ return (
+ <>
+ #{label}
+ {formatNum(payload.length > 0 ? payload[0].value : 0)} messages
+ >
+ );
+};
+
+/**
+ * Render a bar graph displaying the total message count of each channel.
+ */
+export const ChannelGraph: React.FC = React.memo(
+ ({ channelCounts, channels }) => (
+
+
+
+
+
+ }
+ />
+
+
+
+
+ )
+);
diff --git a/app/src/tabs/Statistics/CustomRechartsTooltip.tsx b/app/src/tabs/Statistics/CustomRechartsTooltip.tsx
new file mode 100644
index 00000000..502fa78b
--- /dev/null
+++ b/app/src/tabs/Statistics/CustomRechartsTooltip.tsx
@@ -0,0 +1,50 @@
+import { styled } from "linaria/react";
+import React from "react";
+
+import { color } from "@architus/facade/theme/color";
+import { transition } from "@architus/facade/theme/motion";
+import { shadow } from "@architus/facade/theme/shadow";
+import { isDefined } from "@architus/lib/utility";
+
+const Styled = {
+ Container: styled.div`
+ background-color: ${color("tooltip")};
+ border: 1px solid ${color("tooltip")};
+ box-shadow: ${shadow("z3")};
+ border-radius: 4px;
+ display: flex;
+ color: ${color("light")};
+ flex-direction: column;
+ ${transition(["opacity"])}
+ font-size: 0.9rem;
+ padding: 10px;
+ `,
+};
+
+type CustomTooltipProps = {
+ renderer: (
+ payload: Array<{ value: number; stroke: string; name: string }>,
+ label: string
+ ) => JSX.Element;
+ type?: string;
+ payload?: Array<{ value: number; stroke: string; name: string }>;
+ label?: string;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/**
+ * Architus-style tooltip to be plugged into various rechart components.
+ */
+export const CustomRechartsTooltip: React.FC = ({
+ renderer,
+ payload,
+ label,
+ ...props
+}) => {
+ return (
+
+ {isDefined(payload) && isDefined(label) ? renderer(payload, label) : null}
+
+ );
+};
diff --git a/app/src/tabs/Statistics/CustomResponsiveContainer.tsx b/app/src/tabs/Statistics/CustomResponsiveContainer.tsx
new file mode 100644
index 00000000..ef6e27d1
--- /dev/null
+++ b/app/src/tabs/Statistics/CustomResponsiveContainer.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { ResponsiveContainer } from "recharts";
+
+// From https://github.com/recharts/recharts/issues/1767#issuecomment-598607012
+const CustomResponsiveContainer: React.FC> = (props) => (
+
+);
+
+export default CustomResponsiveContainer;
diff --git a/app/src/tabs/Statistics/GrowthChart.tsx b/app/src/tabs/Statistics/GrowthChart.tsx
new file mode 100644
index 00000000..735ee525
--- /dev/null
+++ b/app/src/tabs/Statistics/GrowthChart.tsx
@@ -0,0 +1,157 @@
+import React, { useMemo } from "react";
+import { AreaChart, Area, XAxis, Tooltip } from "recharts";
+
+import { CustomRechartsTooltip } from "./CustomRechartsTooltip";
+import CustomResponsiveContainer from "./CustomResponsiveContainer";
+import { Snowflake, Member } from "@app/utility/types";
+import { formatNum } from "@architus/lib/utility";
+
+type GrowthChartProps = {
+ members: Map;
+};
+
+const tooltipRenderer = (
+ payload: Array<{ value: number }>,
+ label: string
+): JSX.Element => {
+ return (
+ <>
+
+ {new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ }).format(new Date(label ?? 0))}
+
+ {formatNum(payload.length > 0 ? payload[0].value : 0)} members
+ >
+ );
+};
+
+const dateFormatter = (tick: string): string => {
+ const options = { month: "short", day: "numeric" };
+ return new Date(tick).toLocaleDateString("en-US", options);
+};
+
+type GrowthDataPoint = {
+ count: number;
+ date: number;
+};
+
+// Creates bin number from year/month/day
+const packBin = (year: number, month: number, day: number): number =>
+ year * 500 + month * 40 + day;
+// Creates Unix timestamp from year/month/day
+const unpackBin = (bin: number): number =>
+ new Date(
+ Math.floor(bin / 500),
+ Math.floor((bin % 500) / 40),
+ (bin % 500) % 40
+ ).getTime();
+
+/**
+ * A simple area chart which displays the cumulative number of members of a guild at any point.
+ * Bins the data points to hasten rendering.
+ */
+export const GrowthChart: React.FC = ({ members }) => {
+ const data = useMemo(() => {
+ if (members.size === 0) {
+ return [];
+ }
+
+ // Find the date range of the dataset
+ let minJoinedAt: null | number = null;
+ let maxJoinedAt: null | number = null;
+ members.forEach(({ joined_at: joinedAt }) => {
+ minJoinedAt = Math.min(minJoinedAt ?? joinedAt, joinedAt);
+ maxJoinedAt = Math.max(maxJoinedAt ?? joinedAt, joinedAt);
+ });
+ const range = (maxJoinedAt ?? 0) - (minJoinedAt ?? 0);
+
+ // Determine the adaptive bin width,
+ // and pack the year/month/day of the start of the bin into a single number
+ // Additionally, add a zero bin at the beginning of the guild's history
+ let toBin: (day: Date) => number;
+ let makeZeroBin: (day: Date) => number;
+ if (range <= 2_419_200_000) {
+ // range <= 4 weeks as milliseconds: bin by day
+ // use arbitrary numbers to avoid collisions
+ toBin = (day: Date): number =>
+ packBin(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
+ makeZeroBin = (firstTimestamp: Date): number => {
+ firstTimestamp.setDate(firstTimestamp.getDate() - 1);
+ return toBin(firstTimestamp);
+ };
+ } else if (range <= 10_368_000_000) {
+ // range <= 4 months as milliseconds: bin by week
+ toBin = (day: Date): number =>
+ packBin(
+ day.getUTCFullYear(),
+ day.getUTCMonth(),
+ // Create 1-based day-of-month for the start of the week bin
+ Math.floor((day.getUTCDate() - 1) / 7) * 7 + 1
+ );
+ makeZeroBin = (firstTimestamp: Date): number => {
+ firstTimestamp.setDate(firstTimestamp.getDate() - 7);
+ return toBin(firstTimestamp);
+ };
+ } else {
+ // bin by month
+ toBin = (day: Date): number =>
+ // The start of the bin is always the start of the month
+ packBin(day.getUTCFullYear(), day.getUTCMonth(), 1);
+ makeZeroBin = (firstTimestamp: Date): number => {
+ firstTimestamp.setMonth(firstTimestamp.getMonth() - 1);
+ return toBin(firstTimestamp);
+ };
+ }
+
+ // Bin the data points by the adaptive bin width
+ const memberBins = new Map();
+ members.forEach(({ joined_at: joinedAt }) => {
+ const joinedBin = toBin(new Date(joinedAt));
+ memberBins.set(joinedBin, (memberBins.get(joinedBin) ?? 0) + 1);
+ });
+
+ // Create an unsorted list of data points
+ const dataPoints: GrowthDataPoint[] = [];
+ memberBins.forEach((count, bin) => {
+ dataPoints.push({ count, date: unpackBin(bin) });
+ });
+
+ // Add the zero bin-value
+ const zeroDate = unpackBin(makeZeroBin(new Date(minJoinedAt ?? 0)));
+ dataPoints.push({ count: 0, date: zeroDate });
+
+ // Sort the data points by date
+ dataPoints.sort((a, b) => a.date - b.date);
+
+ // Aggregate the data points in-place
+ let total = 0;
+ dataPoints.forEach((dataPoint) => {
+ total += dataPoint.count;
+ // eslint-disable-next-line no-param-reassign
+ dataPoint.count = total;
+ });
+
+ return dataPoints;
+ }, [members]);
+
+ return (
+
+
+
+
+ }
+ />
+
+
+ );
+};
diff --git a/app/src/tabs/Statistics/IntegrityAlert.tsx b/app/src/tabs/Statistics/IntegrityAlert.tsx
new file mode 100644
index 00000000..3395f7a4
--- /dev/null
+++ b/app/src/tabs/Statistics/IntegrityAlert.tsx
@@ -0,0 +1,68 @@
+import { styled } from "linaria/react";
+import { transparentize } from "polished";
+import React, { useState, useCallback } from "react";
+
+import CloseButton from "@app/components/CloseButton";
+import { color, staticColor } from "@architus/facade/theme/color";
+import { gap } from "@architus/facade/theme/spacing";
+
+const Styled = {
+ Alert: styled.div`
+ display: block;
+ position: relative;
+ margin-top: calc(-1 * ${gap.pico});
+ font-size: 0.95em;
+ padding: ${gap.nano} 48px ${gap.nano} ${gap.micro};
+ color: ${color("textFade")};
+ background-color: ${transparentize(0.85, staticColor("info"))};
+ border: 1px solid ${transparentize(0.5, staticColor("info"))};
+ border-radius: 6px;
+ `,
+ CloseButton: styled(CloseButton)`
+ position: absolute;
+ top: 0;
+ right: 0;
+ `,
+};
+
+export type IntegrityAlertProps = {
+ sKey: string;
+ message: string;
+ enabled: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+/**
+ * Shows an integrity alert to users upon their first visit until they dismiss the alert
+ */
+const IntegrityAlert: React.FC = ({
+ sKey,
+ message,
+ enabled,
+ className,
+ style,
+}) => {
+ const initialValue = window.localStorage.getItem(sKey) !== "true";
+ const [show, setShow] = useState(initialValue);
+ const hide = useCallback((): void => {
+ setShow(false);
+ window.localStorage.setItem(sKey, "true");
+ }, [setShow, sKey]);
+
+ return (
+ <>
+ {enabled && show && (
+
+
+ Notice: {message}
+
+ )}
+ >
+ );
+};
+
+export default IntegrityAlert;
diff --git a/app/src/tabs/Statistics/MemberGraph.tsx b/app/src/tabs/Statistics/MemberGraph.tsx
new file mode 100644
index 00000000..3abfe629
--- /dev/null
+++ b/app/src/tabs/Statistics/MemberGraph.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import {
+ BarChart,
+ Bar,
+ Legend,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+} from "recharts";
+
+import { CustomRechartsTooltip } from "./CustomRechartsTooltip";
+import CustomResponsiveContainer from "./CustomResponsiveContainer";
+import { NormalizedUserLike } from "@app/utility/types";
+import { formatNum, isDefined } from "@architus/lib/utility";
+
+type MemberData = {
+ name: string;
+ count: number;
+};
+type MemberGraphProps = {
+ memberCounts: Record;
+ members: (id: string) => NormalizedUserLike | undefined;
+};
+
+const getMemberData = (memberCounts: Record): MemberData[] => {
+ const data: MemberData[] = [];
+ Object.entries(memberCounts).forEach(([key, value]) => {
+ data.push({ name: key, count: value });
+ });
+ data.sort((a, b) => (a.count < b.count ? 1 : -1));
+ return data.slice(0, 15);
+};
+
+/**
+ * Display a bar chart of the top 15 most active message senders.
+ */
+export const MemberGraph: React.FC = React.memo(
+ ({ memberCounts, members }) => {
+ const tooltipRenderer = (
+ payload: Array<{ value: number }>,
+ label: string
+ ): JSX.Element => {
+ return (
+ <>
+ @{isDefined(members(label)) ? members(label)?.username : label}
+ {formatNum(payload.length > 0 ? payload[0].value : 0)} messages
+ >
+ );
+ };
+ const tickFormatter = (tick: string): string => {
+ const member = members(tick);
+ return isDefined(member) ? member.username : tick;
+ };
+ return (
+
+
+
+
+
+ }
+ />
+
+
+
+
+ );
+ }
+);
diff --git a/app/src/tabs/Statistics/MentionsChart.tsx b/app/src/tabs/Statistics/MentionsChart.tsx
new file mode 100644
index 00000000..b9991f56
--- /dev/null
+++ b/app/src/tabs/Statistics/MentionsChart.tsx
@@ -0,0 +1,59 @@
+import { styled } from "linaria/react";
+import React from "react";
+
+import { Mention } from "@app/components/Mention";
+import { Member, Snowflake } from "@app/utility/types";
+import { isDefined, formatNum } from "@architus/lib/utility";
+
+const Styled = {
+ Container: styled.div`
+ display: flex;
+ font-size: 0.875rem;
+ `,
+ Mention: styled(Mention)`
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 85%;
+ overflow: hidden;
+ `,
+ Dots: styled.div`
+ flex: 1;
+ text-align: right;
+ `,
+};
+
+type MentionsChartProps = {
+ mentionCounts: Record;
+ members: Map;
+};
+
+/**
+ * Takes a map of members and their mention counts and renders the
+ * top 6 in a table as Mention components along with their count.
+ */
+export const MentionsChart: React.FC = ({
+ mentionCounts,
+ members,
+ ...props
+}) => {
+ const counts: Array<{ member: Member; count: number }> = [];
+ Object.entries(mentionCounts).forEach(([id, count]) => {
+ if (isDefined(members.get(id as Snowflake))) {
+ counts.push({ member: members.get(id as Snowflake) as Member, count });
+ }
+ });
+ counts.sort((a, b) => b.count - a.count);
+
+ return (
+ <>
+ {counts.slice(0, 5).map((m) => (
+
+
+
+
+ {formatNum(m.count)}
+
+ ))}
+ >
+ );
+};
diff --git a/app/src/tabs/Statistics/PersonalMessagesChart.tsx b/app/src/tabs/Statistics/PersonalMessagesChart.tsx
new file mode 100644
index 00000000..0cad4ce1
--- /dev/null
+++ b/app/src/tabs/Statistics/PersonalMessagesChart.tsx
@@ -0,0 +1,169 @@
+import { styled } from "linaria/react";
+import { lighten } from "polished";
+import React, { useState } from "react";
+import {
+ ResponsiveContainer,
+ PieChart,
+ Pie,
+ Cell,
+ Legend,
+ Sector,
+} from "recharts";
+
+import { User } from "@app/utility/types";
+import { mode, ColorMode } from "@architus/facade/theme/color";
+import { formatNum } from "@architus/lib/utility";
+
+const Styled = {
+ OuterContainer: styled.div`
+ max-width: 100%;
+ height: 100%;
+ display: flex;
+ `,
+ Label: styled.span`
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+ //display: inline-block;
+ max-width: 75%;
+ `,
+ Tooltip: styled.text`
+ font-weight: bold;
+ font-size: 0.75em;
+ ${mode(ColorMode.Dark)} {
+ text-shadow: 0 0 2px rgba(0, 0, 50, 0.5);
+ }
+ `,
+};
+
+export type PersonalMessagesChartProps = {
+ currentUser: User;
+ totalMessages: number;
+ memberCounts: Record;
+};
+
+type renderActiveShapeProps = {
+ cx: number;
+ cy: number;
+ innerRadius: number;
+ outerRadius: number;
+ startAngle: number;
+ endAngle: number;
+ fill: string;
+ value: number;
+};
+
+/**
+ * Custom render component for the inside of recharts PieChart to display an absolute value in the center.
+ */
+const renderActiveShape = (props: renderActiveShapeProps): JSX.Element => {
+ const {
+ cx,
+ cy,
+ innerRadius,
+ outerRadius,
+ startAngle,
+ endAngle,
+ fill,
+ value,
+ } = props;
+ return (
+
+
+ {formatNum(value)}
+
+
+
+ );
+};
+
+/**
+ * Create a cute little pie chart to display the ratio of the user's messages to the guild's total
+ * Displays the actual values in the middle on mouseover.
+ */
+export const PersonalMessagesChart: React.FC = React.memo(
+ ({ currentUser, totalMessages, memberCounts }) => {
+ const me = memberCounts[currentUser.id as string] ?? 0;
+ const data = [
+ { name: currentUser.username, value: me },
+ { name: "other", value: totalMessages - me },
+ ];
+ const [state, setState] = useState({ activeIndex: -1 });
+
+ const formatter = (value: string, _: unknown): JSX.Element => {
+ return {value};
+ };
+
+ const onPieEnter = (_: unknown, index: number): void => {
+ setState((s) => ({
+ ...s,
+ activeIndex: index,
+ }));
+ };
+
+ const onMouseLeave = (_: unknown): void => {
+ setState((s) => ({
+ ...s,
+ activeIndex: -1,
+ }));
+ };
+ const onMouseEnter = (o: { value: string }): void => {
+ setState((s) => ({ ...s, activeIndex: o.value === "other" ? 1 : 0 }));
+ };
+ return (
+
+
+
+ |
+ |
+
+
+
+
+ );
+ }
+);
diff --git a/app/src/tabs/Statistics/Statistics.tsx b/app/src/tabs/Statistics/Statistics.tsx
new file mode 100644
index 00000000..42ef1e6e
--- /dev/null
+++ b/app/src/tabs/Statistics/Statistics.tsx
@@ -0,0 +1,57 @@
+import React, { useEffect, useMemo } from "react";
+import { createSelectorCreator, defaultMemoize } from "reselect";
+
+import StatisticsDashboard from "./StatisticsDashboard";
+import { useDispatch, useSelector } from "@app/store/hooks";
+import { fetchStats } from "@app/store/routes";
+import { useCurrentUser } from "@app/store/slices/session";
+import { TabProps } from "@app/tabs/types";
+import { User } from "@app/utility/types";
+import { Option } from "@architus/lib/option";
+import { Statistics } from "src/store/slices/statistics";
+
+/**
+ * Bootstrap StatisticsDashboard.
+ * Does a bit of garbage to pull the statistics data and memoize it, etc.
+ */
+const StatisticsProvider: React.FC = (tabProps) => {
+ const dispatch = useDispatch();
+ const { guild } = tabProps;
+ const currentUser: Option = useCurrentUser();
+
+ const createDeepEqualSelector = createSelectorCreator(
+ defaultMemoize,
+ (a, b) => {
+ // eslint-disable-next-line eqeqeq
+ return a == b;
+ }
+ );
+
+ const coolSelecter = createDeepEqualSelector(
+ (state: { statistics: Statistics }) => state.statistics,
+ (statistics) =>
+ statistics.statistics ? statistics.statistics[guild.id as string] : null
+ );
+
+ const storeStatistics = useSelector(coolSelecter);
+ useEffect(() => {
+ dispatch(fetchStats({ routeData: { guildId: guild.id } }));
+ }, [dispatch, guild.id]);
+
+ const guildStats = useMemo(() => {
+ return Option.from(storeStatistics);
+ }, [storeStatistics]);
+
+ if (currentUser.isDefined())
+ return (
+
+ );
+
+ return null;
+};
+export default React.memo(StatisticsProvider);
diff --git a/app/src/tabs/Statistics/StatisticsDashboard.tsx b/app/src/tabs/Statistics/StatisticsDashboard.tsx
new file mode 100644
index 00000000..2d8b7d11
--- /dev/null
+++ b/app/src/tabs/Statistics/StatisticsDashboard.tsx
@@ -0,0 +1,570 @@
+import { css } from "linaria";
+import { styled } from "linaria/react";
+import React, { useMemo } from "react";
+import {
+ FaComments,
+ FaUsers,
+ FaDiscord,
+ FaPlus,
+ FaUserPlus,
+} from "react-icons/fa";
+import ago from "s-ago";
+
+import { ChannelGraph } from "./ChannelGraph";
+import { GrowthChart } from "./GrowthChart";
+import IntegrityAlert from "./IntegrityAlert";
+import { MemberGraph } from "./MemberGraph";
+import { MentionsChart } from "./MentionsChart";
+import { PersonalMessagesChart } from "./PersonalMessagesChart";
+import { TimeAreaChart } from "./TimeAreaChart";
+import { WordCloud, WordData } from "./WordCloud";
+import { CustomEmojiIcon } from "@app/components/CustomEmoji";
+import { Timeline, TimelineItem } from "@app/components/Timeline";
+import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
+import { usePool, usePoolEntities } from "@app/store/slices/pools";
+import { GuildStatistics } from "@app/store/slices/statistics";
+import { TabProps } from "@app/tabs/types";
+import { snowflakeToDate } from "@app/utility/discord";
+import {
+ Channel,
+ CustomEmoji,
+ Member,
+ Snowflake,
+ User,
+ Guild,
+ HoarFrost,
+ NormalizedUserLike,
+ normalizeUserLike,
+} from "@app/utility/types";
+import Card from "@architus/facade/components/Card";
+import Logo from "@architus/facade/components/Logo";
+import { color } from "@architus/facade/theme/color";
+import { down, up } from "@architus/facade/theme/media";
+import { animation } from "@architus/facade/theme/motion";
+import { gap } from "@architus/facade/theme/spacing";
+import { Option } from "@architus/lib/option";
+import { isDefined, formatNum } from "@architus/lib/utility";
+
+const Styled = {
+ PageOuter: styled.div`
+ position: relative;
+ display: flex;
+ justify-content: stretch;
+ align-items: stretch;
+ flex-direction: column;
+ padding: ${appVerticalPadding} ${appHorizontalPadding};
+ ${down("md")} {
+ padding: ${gap.nano} ${gap.nano};
+ }
+ `,
+ Title: styled.h2`
+ color: ${color("textStrong")};
+ font-size: 1.9rem;
+ font-weight: 300;
+ `,
+ IntegrityAlert: styled(IntegrityAlert)`
+ margin: ${gap.nano} 0 0;
+ ${down("md")} {
+ margin: ${gap.femto} 0 0;
+ }
+ `,
+ Logo: styled(Logo.Symbol)`
+ font-size: 2em;
+ fill: ${color("light")};
+ padding: 0px 20px;
+ display: flex;
+ align-items: center;
+ `,
+ HeaderCards: styled.div`
+ display: flex;
+ justify-content: space-around;
+ align-items: stretch;
+ flex-direction: row;
+ //margin: ${gap.pico} 0;
+ //gap: ${gap.nano};
+
+ ${down("lg")} {
+ flex-wrap: wrap;
+ }
+
+ & > * {
+ margin-top: ${gap.nano};
+ ${down("md")} {
+ margin-top: ${gap.femto};
+ }
+
+ opacity: 0;
+ ${animation("fadeIn")}
+ }
+
+ ${[...Array(3)]
+ .map(
+ (_, i) => `& > :nth-child(${i + 1}) {
+ animation: fadeIn 0.5s ${i * 0.05 + 0.05}s linear forwards;
+ }`
+ )
+ .join("\n")}
+ `,
+ CardContainer: styled.div`
+ display: grid;
+ height: 100%;
+ width: 100%;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ grid-auto-rows: 200px;
+ grid-auto-flow: dense;
+ gap: ${gap.nano};
+ justify-items: stretch;
+ margin: ${gap.nano} 0 0;
+
+ ${down("md")} {
+ margin: ${gap.femto} 0 0;
+ grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
+ grid-auto-rows: 150px;
+ gap: ${gap.femto};
+ }
+
+ & > * {
+ opacity: 0;
+ ${animation("fadeIn")}
+ }
+
+ // THIS MUST BE INCREASED IF NEW CARDS ARE ADDED
+ ${[...Array(9)]
+ .map(
+ (_, i) => `& > :nth-child(${i + 1}) {
+ animation: fadeIn 0.5s ${0.05 * i + 0.2}s linear forwards;
+ }`
+ )
+ .join("\n")}
+ `,
+ ContentContainer: styled.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ `,
+ FadeIn: css`
+ animation: fadeIn 2s linear forwards;
+ `,
+ LabelContainer: styled.div`
+ position: relative;
+ font-variant: smallcaps;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-bottom: ${gap.nano};
+ color: ${color("light")};
+ `,
+ Description: styled.p`
+ margin-bottom: 0;
+ font-size: 0.9em;
+ margin-top: -${gap.pico};
+ color: ${color("light")} !important;
+ `,
+ MessageCard: styled(Card)`
+ ${up("lg")} {
+ margin-right: ${gap.nano};
+ }
+ border: none;
+ flex: 1 1 100%;
+ background-color: #5850ba;
+ background-image: linear-gradient(62deg, #5850ba 0%, #844ea3 100%);
+ padding: ${gap.nano};
+ `,
+ ArchitusCard: styled(Card)`
+ border: none;
+ padding: ${gap.nano};
+ flex: 1 1 100%;
+ background-color: #ba5095;
+ background-image: linear-gradient(62deg, #ba5095 0%, #ffbfa7 100%);
+ ${down("lg")} {
+ flex-basis: 40%;
+ }
+ ${down("md")} {
+ flex-basis: 100%;
+ }
+ `,
+ MemberCard: styled(Card)`
+ ${up("md")} {
+ margin-right: ${gap.nano};
+ }
+ border: none;
+ padding: ${gap.nano};
+ flex: 1 1 100%;
+ background-color: #844ea3;
+ background-image: linear-gradient(62deg, #844ea3 0%, #ba5095 100%);
+ ${down("lg")} {
+ flex-basis: 40%;
+ }
+ ${down("md")} {
+ flex-basis: 100%;
+ }
+ `,
+ Card: styled(Card)`
+ grid-column: span auto;
+ grid-row: span auto;
+
+ ${down("md")} {
+ padding: ${gap.micro};
+ }
+
+ & > h4 {
+ margin: 0px;
+ }
+ `,
+ BigCard: styled(Card)`
+ grid-column: span 2;
+ grid-row: span 2;
+ ${down("md")} {
+ padding: ${gap.micro};
+ }
+ `,
+ TallCard: styled(Card)`
+ grid-column: span 1;
+ grid-row: span 2;
+ ${down("md")} {
+ padding: ${gap.micro};
+ }
+ `,
+ LongCard: styled(Card)`
+ grid-column: span 2;
+ grid-row: span 1;
+ ${down("md")} {
+ padding: ${gap.micro};
+ }
+ `,
+ EmojiContainer: styled.div`
+ margin-top: ${gap.nano};
+ display: grid;
+ max-width: 100%;
+ max-height: 100%;
+ grid-template-rows: 48px 48px;
+ grid-template-columns: minmax(48px, 1fr) minmax(48px, 1fr) minmax(48px, 1fr);
+ gap: ${gap.nano};
+ ${down("md")} {
+ grid-template-rows: 36px 36px;
+ grid-template-columns: minmax(36px, 1fr) minmax(36px, 1fr) minmax(
+ 36px,
+ 1fr
+ );
+ gap: ${gap.femto};
+ }
+ `,
+ Image: styled.img`
+ width: 100px;
+ height: auto;
+ `,
+ P: styled.p`
+ font-size: 2.5em;
+ `,
+};
+
+const iconClass = css`
+ font-size: 2em;
+ color: ${color("light")};
+ margin: 0px 20px;
+`;
+
+type StatisticsDashboardProps = {
+ isArchitusAdmin: boolean;
+ currentUser: User;
+ stats: Option;
+ guild: Guild;
+} & TabProps;
+
+const StatisticsDashboard: React.FC = ({
+ stats,
+ currentUser,
+ guild,
+}) => {
+ const memberEntries = usePoolEntities({
+ type: "member",
+ guildId: guild.id,
+ ids: stats.isDefined()
+ ? Object.keys(stats.get.memberCounts).map((id) => id as Snowflake)
+ : [],
+ });
+ const members = useMemo(() => {
+ const membersMap: Map = new Map();
+ for (const memberEntry of memberEntries) {
+ if (memberEntry.isLoaded && memberEntry.entity.isDefined()) {
+ membersMap.set(memberEntry.entity.get.id, memberEntry.entity.get);
+ }
+ }
+ return membersMap;
+ }, [memberEntries]);
+
+ const userEntries = usePoolEntities({
+ type: "user",
+ ids: memberEntries.filter((m) => m.nonexistant).map((m) => m.id),
+ });
+
+ const users = useMemo(() => {
+ const usersMap: Map = new Map();
+ for (const userEntry of userEntries) {
+ if (userEntry.isLoaded && userEntry.entity.isDefined()) {
+ usersMap.set(userEntry.entity.get.id, userEntry.entity.get);
+ }
+ }
+ return usersMap;
+ }, [userEntries]);
+
+ const membersClosure = (id: string): NormalizedUserLike | undefined => {
+ let member: Member | User | undefined = members.get(id as Snowflake);
+ member = member ?? users.get(id as Snowflake);
+ return isDefined(member) ? normalizeUserLike(member) : undefined;
+ };
+
+ const { all: channelsPool } = usePool({
+ type: "channel",
+ guildId: guild.id,
+ });
+
+ const emojiEntries = usePoolEntities({
+ type: "customEmoji",
+ guildId: guild.id,
+ ids: stats.isDefined() ? stats.get.popularEmojis : [],
+ });
+ const emojis = useMemo(() => {
+ const emojisMap: Map = new Map();
+ for (const emojiEntry of emojiEntries) {
+ if (emojiEntry.isLoaded && emojiEntry.entity.isDefined()) {
+ emojisMap.set(emojiEntry.entity.get.id, emojiEntry.entity.get);
+ }
+ }
+ return emojisMap;
+ }, [emojiEntries]);
+
+ const channels = useMemo(() => {
+ const map: Map = new Map();
+ for (const channel of channelsPool) {
+ map.set(channel.id as string, channel);
+ }
+ return map;
+ }, [channelsPool]);
+
+ const memberCount = useMemo((): number => {
+ return stats.isDefined() ? stats.get.memberCount : 0;
+ }, [stats]);
+
+ const messageCount = useMemo((): number => {
+ return stats.isDefined() ? stats.get.messageCount : 0;
+ }, [stats]);
+
+ const architusMessageCount = useMemo((): number => {
+ return stats.isDefined() ? stats.get.architusCount : 0;
+ }, [stats]);
+
+ const lastSeen = useMemo((): Date => {
+ return new Date(stats.isDefined() ? stats.get.lastActivity : 1420070400000);
+ }, [stats]);
+
+ const memberCounts = useMemo(() => {
+ return stats.isDefined() ? stats.get.memberCounts : {};
+ }, [stats]);
+
+ const channelCounts = useMemo(() => {
+ return stats.isDefined() ? stats.get.channelCounts : {};
+ }, [stats]);
+
+ const joinDate = useMemo((): Date => {
+ if (isDefined(currentUser) && isDefined(currentUser.id)) {
+ const member = members.get(currentUser.id as Snowflake);
+ if (isDefined(member)) return new Date(member.joined_at);
+ }
+ return snowflakeToDate(guild.id);
+ }, [currentUser, members, guild]);
+
+ const bestEmoji = useMemo((): CustomEmoji[] => {
+ const urls = [];
+ if (stats.isDefined()) {
+ const { popularEmojis } = stats.get;
+ for (let i = 0; i < 6; i++) {
+ const e = emojis.get(popularEmojis[i]);
+ if (isDefined(e)) {
+ urls.push(e);
+ }
+ }
+ }
+ return urls;
+ }, [stats, emojis]);
+
+ const getWords = (): Array => {
+ return stats.isDefined()
+ ? stats.get.commonWords.map((word) => {
+ return { text: word[0], value: word[1] };
+ })
+ : [];
+ };
+ const memWords = useMemo(getWords, [stats]);
+
+ const timeData = useMemo((): {
+ data: Array<{ [key: string]: number }>;
+ ids: Set;
+ } => {
+ const data: Array<{ [key: string]: number }> = [];
+ const ids: Set = new Set();
+ if (stats.isDefined()) {
+ Object.entries(stats.get.timeMemberCounts).forEach(([date, rec]) => {
+ const obj: { [key: string]: number } = { date: Date.parse(date) };
+ if (obj.date < new Date().getTime() - 30 * 86400000) {
+ return;
+ }
+ Object.entries(rec).forEach(([id, count]) => {
+ obj[id] = count;
+ ids.add(id);
+ });
+ data.push(obj);
+ });
+ }
+ data.sort((a, b) => a.date - b.date);
+ return { data, ids };
+ }, [stats]);
+
+ const mentionsChart = useMemo(() => {
+ if (stats.isDefined()) {
+ return (
+
+ );
+ }
+ return <>no mentions>;
+ }, [stats, members]);
+
+ return (
+
+ Statistics
+
+
+
+
+
+
+
+ {formatNum(messageCount)}
+ Messages Sent
+
+
+
+
+
+
+
+
+ {formatNum(memberCount)}
+ Members
+
+
+
+
+
+
+
+
+ {formatNum(architusMessageCount)}
+ Commands Executed
+
+
+
+
+
+
+
+ Your Messages
+
+
+
+
+ Popular Custom Emoji
+
+ {bestEmoji.map((e) => (
+
+ ))}
+
+
+
+
+ Messages over Time
+
+
+
+
+ Messages by Member
+
+
+
+
+ Timeline
+
+
+ You joined discord
+
+
+ {guild.name} was created
+
+
+ You joined {guild.name}
+
+
+ Last activity in {guild.name}
+
+
+
+
+
+ Server Growth
+
+
+
+
+ Mentions
+ {mentionsChart}
+
+
+
+ Messages by Channel
+
+
+
+ Popular Words
+
+
+
+
+ );
+};
+
+export default StatisticsDashboard;
diff --git a/app/src/tabs/Statistics/TimeAreaChart.tsx b/app/src/tabs/Statistics/TimeAreaChart.tsx
new file mode 100644
index 00000000..89858da6
--- /dev/null
+++ b/app/src/tabs/Statistics/TimeAreaChart.tsx
@@ -0,0 +1,142 @@
+import { mix } from "polished";
+import React from "react";
+import {
+ ResponsiveContainer,
+ XAxis,
+ YAxis,
+ Tooltip,
+ CartesianGrid,
+ Area,
+ AreaChart,
+} from "recharts";
+
+import { CustomRechartsTooltip } from "./CustomRechartsTooltip";
+import { useColorMode } from "@architus/facade/hooks";
+import { ColorMode } from "@architus/facade/theme/color";
+import { isDefined } from "@architus/lib/utility";
+import { NormalizedUserLike } from "src/utility/types";
+
+type TimeAreaChartProps = {
+ ids: Set;
+ data: Array<{ [key: string]: number }>;
+ members: (id: string) => NormalizedUserLike | undefined;
+};
+
+function gradArray(col1: string, col2: string, n: number): Array {
+ const grad = [];
+ for (let i = 0; i < n; i++) {
+ grad[i] = mix(i / n, col1, col2);
+ }
+ return grad;
+}
+
+const dateFormatter = (tick: string): string => {
+ const options = { month: "short", day: "numeric" };
+ return new Date(tick).toLocaleDateString("en-US", options);
+};
+
+/**
+ * Display a pretty stacked area chart of members and their message counts over time.
+ */
+export const TimeAreaChart: React.FC = React.memo(
+ ({ ids, data, members }) => {
+ const colors = gradArray("#ba5095", "#5850ba", ids.size);
+ const accum: React.ReactNode[] = [];
+ const lightMode = useColorMode() === ColorMode.Light;
+ let i = 0;
+ ids.forEach((member) => {
+ accum.push(
+
+ );
+ });
+
+ const tooltipRenderer = (
+ payload: Array<{ value: number; stroke: string; name: string }>,
+ label: string
+ ): JSX.Element => {
+ let sum = 0;
+ const size = isDefined(payload) ? payload.length : 0;
+ const names = [];
+ const large = size > 10;
+ if (large) {
+ payload.sort((a, b) => a.value - b.value);
+ }
+ for (let j = size - 1; j >= 0; j--) {
+ const item = payload[j];
+ const member = members(item.name);
+ if (item.value === 0 || !isDefined(member)) {
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+ sum += payload[j].value;
+ names.push(
+
+
+ {member.username} : {item.value}
+
+
+ );
+ if (j < size - 11) {
+ names.push(
+
+
+ {j} more not shown...
+
+
+ );
+ break;
+ }
+ }
+ return (
+ <>
+
+ {new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ }).format(new Date(label ?? 0))}
+
+ {names}
+ Total: {sum}
+ >
+ );
+ };
+
+ return (
+
+
+
+
+
+ }
+ />
+ {accum}
+
+
+ );
+ }
+);
diff --git a/app/src/tabs/Statistics/WordCloud.tsx b/app/src/tabs/Statistics/WordCloud.tsx
new file mode 100644
index 00000000..ba79caf7
--- /dev/null
+++ b/app/src/tabs/Statistics/WordCloud.tsx
@@ -0,0 +1,48 @@
+import { styled } from "linaria/react";
+import React from "react";
+import AutoSizer from "react-virtualized-auto-sizer";
+import ReactWordcloud, { Options } from "react-wordcloud";
+
+import { color } from "@architus/facade/theme/color";
+
+export type WordData = {
+ text: string;
+ value: number;
+};
+
+type WordCloudProps = {
+ words: Array;
+};
+
+const Styled = {
+ AutoSizer: styled(AutoSizer)`
+ width: 100%;
+ `,
+};
+
+/**
+ * 90-degree word cloud wrapped in an auto sizer. That's pretty much it.
+ */
+export const WordCloud: React.FC = React.memo(({ words }) => {
+ const options: Partial = {
+ rotations: 2,
+ rotationAngles: [-90, 0],
+ fontSizes: [5, 120],
+ enableOptimizations: true,
+ enableTooltip: false,
+ // I would like to use Renner* at some point but it's broken for now.
+ // fontFamily: "Renner*",
+ colors: [color("textStrong")],
+ };
+ return (
+
+ {({ height, width }): JSX.Element => (
+
+ )}
+
+ );
+});
diff --git a/app/src/tabs/Statistics/index.ts b/app/src/tabs/Statistics/index.ts
new file mode 100644
index 00000000..3644ae79
--- /dev/null
+++ b/app/src/tabs/Statistics/index.ts
@@ -0,0 +1,2 @@
+// Convenience export
+export { default } from "./Statistics";
diff --git a/app/src/tabs/definitions.ts b/app/src/tabs/definitions.ts
index 0874e58d..69116e89 100644
--- a/app/src/tabs/definitions.ts
+++ b/app/src/tabs/definitions.ts
@@ -22,7 +22,7 @@ const definitions: TabDefinition[] = [
name: "Statistics",
icon: StatisticsIcon,
component: Statistics,
- tooltip: "Coming soon",
+ tooltip: "View statistics dashboard",
},
{
path: "responses",
diff --git a/app/src/utility/discord.ts b/app/src/utility/discord.ts
index f667ebd1..0c0e21a1 100644
--- a/app/src/utility/discord.ts
+++ b/app/src/utility/discord.ts
@@ -4,6 +4,7 @@ import { Snowflake, MockUser, Guild } from "./types";
const architusId: Snowflake = "448546825532866560" as Snowflake;
const architusAvatar = "99de1e495875fb5c27ba9ac7303b45b7";
+const DISCORD_EPOCH = BigInt(1420070400000);
/**
* Architus bot's mock user to display inside a Discord Mock
@@ -36,3 +37,8 @@ export function isDiscordAdminWithoutArchitus(guild: Guild): boolean {
!guild.has_architus
);
}
+
+export function snowflakeToDate(id: Snowflake): Date {
+ // eslint-disable-next-line no-bitwise
+ return new Date(Number((BigInt(id) >> BigInt(22)) + DISCORD_EPOCH));
+}
diff --git a/app/src/utility/types.ts b/app/src/utility/types.ts
index c31925e5..782b524a 100644
--- a/app/src/utility/types.ts
+++ b/app/src/utility/types.ts
@@ -164,6 +164,17 @@ export const TimeFromString = new t.Type(
(a) => new Date(a).toISOString()
);
+export const EpochFromString = new t.Type(
+ "EpochFromString",
+ (u): u is string => typeof u === "string",
+ (u, c) =>
+ either.chain(t.string.validate(u, c), (n) => {
+ const d = new Date(n);
+ return isNaN(d.getTime()) ? t.failure(u, c) : t.success(d.toISOString());
+ }),
+ (s) => Date.parse(s)
+);
+
/**
* Represents a NodeJS module with HMR enabled
*/
@@ -617,7 +628,10 @@ export type Role = t.TypeOf;
export const Role = t.type({});
export type Channel = t.TypeOf;
-export const Channel = t.type({});
+export const Channel = t.type({
+ id: TSnowflake,
+ name: t.string,
+});
const TCustomEmoji = t.type({
id: THoarFrost,
diff --git a/architus.code-workspace b/architus.code-workspace
index 85adf0df..bea2635a 100644
--- a/architus.code-workspace
+++ b/architus.code-workspace
@@ -19,6 +19,9 @@
{
"name": ".shared",
"path": ".shared"
+ },
+ {
+ "path": ".github"
}
]
}
diff --git a/design/src/theme/motion.ts b/design/src/theme/motion.ts
index 4d119171..cdd8369b 100644
--- a/design/src/theme/motion.ts
+++ b/design/src/theme/motion.ts
@@ -64,6 +64,14 @@ const animationDefinitions = {
background-position: 1000px 0;
}
`,
+ fadeIn: `
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ `,
};
export type AnimationKey = keyof typeof animationDefinitions;
diff --git a/docs/content/features/auto-responses/eval_demo.png b/docs/content/features/auto-responses/eval_demo.png
new file mode 100644
index 0000000000000000000000000000000000000000..d86b6b7a65987c3e0e19a32a42cbb69598a60ff3
GIT binary patch
literal 74193
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