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 cbf2179c..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,16 +58,20 @@
"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",
"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",
@@ -85,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..7b54c09e
--- /dev/null
+++ b/app/src/components/CustomEmoji.tsx
@@ -0,0 +1,112 @@
+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";
+import { gap } from "@architus/facade/theme/spacing";
+
+const Styled = {
+ TooltipName: styled.strong`
+ display: block;
+ `,
+ TooltipElevated: styled.div`
+ opacity: 0.7;
+ font-size: 80%;
+ `,
+ OuterContainer: styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ img {
+ object-fit: cover;
+ width: 100%;
+ height: 90px;
+ margin-right: ${gap.nano};
+ max-width: 300px;
+ }
+ `,
+ InnerContainer: styled.div`
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ `,
+ ImageContainer: styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 100%;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ }
+ `,
+ PreviewContainer: styled.div`
+
+ `,
+};
+
+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.getOrElse("0" as Snowflake)) {
+ 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/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);
}
diff --git a/app/src/data/umami-analytics.ts b/app/src/data/umami-analytics.ts
index ef506c62..b44fdbfe 100644
--- a/app/src/data/umami-analytics.ts
+++ b/app/src/data/umami-analytics.ts
@@ -12,6 +12,9 @@ export type UmamiAnalytics = {
* Gets the site's Umami analytics parameters if they exist
*/
export function useUmamiAnalytics(): Option {
+ // 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`
@@ -27,18 +30,18 @@ export function useUmamiAnalytics(): Option {
}
`);
- 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/actions.ts b/app/src/store/actions.ts
index 7d47c17d..fd0ffd26 100644
--- a/app/src/store/actions.ts
+++ b/app/src/store/actions.ts
@@ -3,3 +3,4 @@ export * from "@app/store/slices/notifications";
export * from "@app/store/slices/loading";
export * from "@app/store/slices/guildCount";
export * from "@app/store/slices/interpret";
+export * from "@app/store/slices/pools";
\ No newline at end of file
diff --git a/app/src/store/api/rest/middleware.ts b/app/src/store/api/rest/middleware.ts
index d2aeaa44..7db5f9d4 100644
--- a/app/src/store/api/rest/middleware.ts
+++ b/app/src/store/api/rest/middleware.ts
@@ -37,7 +37,10 @@ const RestMiddleware: Middleware<{}, Store, ReduxDispatch> = ({
.then((result) => {
const end = performance.now();
const duration = end - start;
+ console.log("hello1");
const { data: response } = result;
+ console.log("hello2");
+ console.log(route);
dispatch(
restSuccess({
...action.payload,
@@ -45,8 +48,10 @@ const RestMiddleware: Middleware<{}, Store, ReduxDispatch> = ({
timing: { start, end, duration },
})
);
+ console.log("hello3");
})
.catch((e) => {
+ console.log(e);
const end = performance.now();
const duration = start - end;
let error: ApiError;
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 86f0cccd..667551d7 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 } 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({
@@ -103,3 +111,65 @@ 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,
+});
+
+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/saga/index.ts b/app/src/store/saga/index.ts
index b0a2b43a..4e84d78b 100644
--- a/app/src/store/saga/index.ts
+++ b/app/src/store/saga/index.ts
@@ -8,13 +8,20 @@ import {
showToast,
signOut,
showNotification,
+ load,
+ LoadPayload,
} from "@app/store/actions";
+import { restSuccess } from "@app/store/api/rest";
+import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
import gatewayFlow from "@app/store/saga/gateway";
import interpret from "@app/store/saga/interpret";
import pools from "@app/store/saga/pools";
import sessionFlow from "@app/store/saga/session";
import { LOCAL_STORAGE_KEY } from "@app/store/slices/session";
import { setLocalStorage } from "@app/utility";
+import { CustomEmoji } from "@app/utility/types";
+import { isRight } from "fp-ts/lib/Either";
+import { Errors } from "io-ts";
/**
* Root saga
@@ -27,6 +34,7 @@ export default function* saga(): SagaIterator {
yield takeEvery(signOut.type, handleSignOut);
yield takeEvery(showNotification.type, autoHideNotification);
+ yield takeEvery(restSuccess.type, testSaga)
}
/**
@@ -54,3 +62,25 @@ function* handleSignOut(action: ReturnType): SagaIterator {
yield put(showToast({ message: "Signed out" }));
}
}
+
+function* testSaga(action: ReturnType): SagaIterator {
+ console.log("test");
+ if (loadCustomEmoji.match(action) || cacheCustomEmoji.match(action)) {
+ console.log(action.payload.response.emoji);
+ const decodeResult = CustomEmoji.decode(action.payload.response.emoji);
+ const entities = [];
+ if (isRight(decodeResult)) {
+ entities.push(decodeResult.right as CustomEmoji);
+ }
+ yield put(load({
+ type: 'customEmoji',
+ guildId: action.payload.routeData.guildId,
+ finished: true,
+ nonexistant: [],
+ entities: entities,
+ method: 'partial',
+ requestId: 200,
+ } as LoadPayload))
+ //yield put(load(action.payload.emoji));
+ }
+}
\ No newline at end of file
diff --git a/app/src/store/slices/emojiManager.ts b/app/src/store/slices/emojiManager.ts
new file mode 100644
index 00000000..1ce26ae7
--- /dev/null
+++ b/app/src/store/slices/emojiManager.ts
@@ -0,0 +1,38 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+import { restSuccess } from "@app/store/api/rest";
+import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
+import { Nil } from "@architus/lib/types";
+import { isDefined } from "@architus/lib/utility";
+import { HoarFrost } from "src/utility/types";
+
+
+
+// ? ====================
+// ? Reducer exports
+// ? ====================
+
+const initialState: any = { };
+const slice = createSlice({
+ name: "statistics",
+ initialState,
+ reducers: {},
+ extraReducers: {
+ [restSuccess.type]: (state, action) => {
+ if (loadCustomEmoji.match(action)) {
+ const { guildId, emojiId } = action.payload.routeData;
+ const decoded = loadCustomEmoji.decode(action.payload);
+ if (decoded.isDefined()) {
+ if (isDefined(state.pools.specific[guildId as string].customEmoji[emojiId as string])) {
+ state.pools.specific[guildId as string].customEmoji[emojiId] = decoded.get;
+ } else {
+ return { };
+ }
+ }
+ }
+ return state;
+ },
+ },
+});
+
+//export default slice.reducer;
diff --git a/app/src/store/slices/index.ts b/app/src/store/slices/index.ts
index 0d9ca088..46a8fcbe 100644
--- a/app/src/store/slices/index.ts
+++ b/app/src/store/slices/index.ts
@@ -7,6 +7,8 @@ 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";
+//import EmojiManager from "@app/store/slices/emojiManager"
const rootReducer = combineReducers({
session: Session,
@@ -16,6 +18,8 @@ const rootReducer = combineReducers({
interpret: Interpret,
gateway: Gateway,
pools: Pools,
+ statistics: Statistics,
+// emojiManager: EmojiManager,
});
export default rootReducer;
diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts
index 4fdc89fb..bc79d3f4 100644
--- a/app/src/store/slices/pools.ts
+++ b/app/src/store/slices/pools.ts
@@ -11,6 +11,8 @@ import {
AutoResponse,
HoarFrost,
Member,
+ Channel,
+ CustomEmoji,
} from "@app/utility/types";
import { Option, None, Some } from "@architus/lib/option";
@@ -19,6 +21,8 @@ export type AllPoolTypes = {
user: User;
autoResponse: AutoResponse;
member: Member;
+ channel: Channel;
+ customEmoji: CustomEmoji;
};
// Runtime io-ts types
@@ -27,10 +31,17 @@ export const AllPoolTypes = {
user: User,
autoResponse: AutoResponse,
member: Member,
+ channel: Channel,
+ customEmoji: CustomEmoji,
};
export const guildAgnosticPools = ["user", "guild"] as const;
-export const guildSpecificPools = ["autoResponse", "member"] as const;
+export const guildSpecificPools = [
+ "autoResponse",
+ "member",
+ "channel",
+ "customEmoji",
+] as const;
export const allPools: PoolType[] = [
...guildAgnosticPools,
...guildSpecificPools,
@@ -795,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/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx
deleted file mode 100644
index f767fe58..00000000
--- a/app/src/tabs/EmojiManager.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 EmojiManager: React.FC = () => (
-
-
- Emoji Manager Coming Soon
-
-
-);
-
-export default EmojiManager;
diff --git a/app/src/tabs/EmojiManager/EmojiChart.tsx b/app/src/tabs/EmojiManager/EmojiChart.tsx
new file mode 100644
index 00000000..5b15f2c7
--- /dev/null
+++ b/app/src/tabs/EmojiManager/EmojiChart.tsx
@@ -0,0 +1,65 @@
+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";
+import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
+
+const Styled = {
+
+};
+
+export type EmojiChartProps = {
+
+};
+
+
+const data = [
+ { name: 'Group A', value: 400 },
+ { name: 'Group B', value: 300 },
+ { name: 'Group C', value: 300 },
+ { name: 'Group D', value: 200 },
+];
+const COLORS = ['#5850ba', '#844ea3', '#ba5095', '#ffbfa7'];
+
+/**
+ * Pie chart for displaying emoji manager usage stats
+ */
+const EmojiChart: React.FC = React.memo(({
+
+}) => (
+
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+));
+
+export default EmojiChart;
diff --git a/app/src/tabs/EmojiManager/EmojiChart2.tsx b/app/src/tabs/EmojiManager/EmojiChart2.tsx
new file mode 100644
index 00000000..a617a4cd
--- /dev/null
+++ b/app/src/tabs/EmojiManager/EmojiChart2.tsx
@@ -0,0 +1,150 @@
+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 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 { down, up } from "@architus/facade/theme/media";
+import { shadow } from "@architus/facade/theme/shadow";
+import { gap } from "@architus/facade/theme/spacing";
+import { Bar, BarChart, ReferenceLine, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts";
+import { load } from "src/store/actions";
+import { CustomRechartsTooltip } from "../Statistics/CustomRechartsTooltip";
+import { transition } from "@architus/facade/theme/motion";
+
+const Styled = {
+ Outer: styled.div`
+ //width: 100%;
+ //height: 100px;
+ //display: flex;
+ justify-content: center;
+ align-items: center;
+ //margin-bottom: ${gap.nano};
+ margin-top: -5px;
+ //padding-right: 58px;
+ ${up("md")} {
+ margin-left: calc(${gap.milli} - 5px);
+ margin-right: calc(${gap.milli} - 5px);
+ }
+
+ ${down("md")} {
+ border-radius: 0;
+ }
+ `,
+ Bar: styled.div`
+ height: 100%;
+ width: 100%;
+ padding: 0 ${gap.milli};
+ background-color: ${color('bg-10')};
+ border-radius: 0 0 10px 10px;
+ //margin: 0 ${gap('nano')} ${gap('milli')} ${gap('milli')};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 50px;
+ //padding: 27px 20px 0px 20px;
+ padding: 0;
+ `,
+ TooltipContainer: 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;
+ z-index: 100;
+ position: relative;
+ `,
+};
+
+const tooltipRenderer = (
+ payload: Array<{ current: number }>,
+ label: string
+): JSX.Element => {
+ if (payload.length === 0) return <>>;
+ return (
+ <>
+ yep
+ >
+ );
+};
+
+
+
+export type EmojiChartProps = {
+ current: number,
+ loaded: number,
+ limit: number | 'unlimited',
+ discordLimit: number,
+};
+
+
+
+const COLORS = ['#5850ba', '#844ea3', '#ba5095', '#ffbfa7'];
+
+/**
+ * Pie chart for displaying emoji manager usage stats
+ */
+const EmojiChart: React.FC = React.memo(({
+ current, loaded, limit, discordLimit
+}) => {
+ var tooltip: string;
+ const CustomTooltip = ({ active, payload }) => {
+ if (!active || !tooltip) return null
+ for (const bar of payload)
+ if (bar.dataKey === tooltip)
+ return { bar.name }
{ bar.value }
{((loaded / discordLimit) * 100).toFixed(1)}% of discord capacity
{capacity}% of architus capacity
+ return null
+ }
+ const architusLimit = limit === 'unlimited' ? Math.round(current / 50) * 50 + 50 : limit;
+ const capacity = limit === 'unlimited' ? 0 : ((current / architusLimit) * 100).toFixed(1);
+ const data = [
+ {
+ name: 'Page A',
+ loaded: loaded,
+ cached: current - loaded,
+ free: architusLimit - current
+ },
+
+ ];
+
+ return (
+
+
+
+
+ tick === discordLimit ? "Discord Limit (50)" : "Architus Limit (200)"}
+ scale="linear"
+ />
+
+
+
+ } isAnimationActive={false}/>
+ tooltip="loaded" }/>
+ tooltip="cached" }/>
+ tooltip="free" }/>
+
+
+
+ );
+});
+
+export default EmojiChart;
diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx
new file mode 100644
index 00000000..77859c3a
--- /dev/null
+++ b/app/src/tabs/EmojiManager/EmojiManager.tsx
@@ -0,0 +1,542 @@
+import { boolean } from "fp-ts";
+import { styled } from "linaria/react";
+import React, { MutableRefObject, useEffect, useMemo, useState } from "react";
+import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa";
+import { useDispatch } from "react-redux";
+
+import { AuthorData, Author } from "../AutoResponses/types";
+import GridHeader, { ViewMode, viewModes } from "./GridHeader";
+import ManagerJumbotron from "./ManagerJumbotron";
+import DataGrid from "@app/components/DataGrid";
+import PageTitle from "@app/components/PageTitle";
+import UserDisplay, { getAvatarUrl } from "@app/components/UserDisplay";
+import { appVerticalPadding, appHorizontalPadding } from "@app/layout";
+import { Dispatch } from "@app/store";
+import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes";
+import { usePool, usePoolEntities } from "@app/store/slices/pools";
+import { useCurrentUser } from "@app/store/slices/session";
+import { TabProps } from "@app/tabs/types";
+import { HoarFrost, Snowflake, CustomEmoji, User } from "@app/utility/types";
+import AutoLink from "@architus/facade/components/AutoLink";
+import Button from "@architus/facade/components/Button";
+import { useColorMode } from "@architus/facade/hooks";
+import { Color, color, hybridColor } from "@architus/facade/theme/color";
+import { up } from "@architus/facade/theme/media";
+import { gap } from "@architus/facade/theme/spacing";
+import { None, Option, Some } from "@architus/lib/option";
+import { isDefined } from "@architus/lib/utility";
+import { Column, SortDirection } from "react-data-grid";
+import { padding } from "polished";
+import { API_BASE } from "@app/api";
+import EmojiChart from "./EmojiChart2";
+import Tooltip from "@architus/facade/components/Tooltip";
+import { CustomEmojiIcon } from "@app/components/CustomEmoji";
+import Dialog from "@architus/facade/components/Dialog";
+
+
+const Styled = {
+ Layout: styled.div`
+ padding: ${appVerticalPadding} ${appHorizontalPadding};
+ `,
+ Title: styled.h2`
+ color: ${color("textStrong")};
+ font-size: 1.9rem;
+ font-weight: 300;
+ margin-bottom: ${gap.nano};
+ `,
+ ImageWrapper: styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ img {
+ max-width: 48px;
+ max-height: 48px;
+ width: auto;
+ height: auto;
+ }
+ `,
+ PageOuter: styled.div`
+ position: relative;
+ display: flex;
+ justify-content: stretch;
+ align-items: stretch;
+ flex-direction: column;
+ height: 100%;
+
+ padding-top: ${gap.milli};
+ `,
+ Header: styled.div`
+ padding: 0 ${gap.milli};
+ h2 {
+ color: ${color("textStrong")};
+ font-size: 1.9rem;
+ font-weight: 300;
+ margin-bottom: ${gap.nano};
+ }
+
+ p {
+ margin-bottom: ${gap.micro};
+ }
+ `,
+ DataGridWrapper: styled.div`
+ position: relative;
+ flex-grow: 1;
+ display: flex;
+ justify-content: stretch;
+ 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`
+ display: flex;
+ align-items: center;
+ height: 100%;
+ `,
+ Avatar: styled(UserDisplay.Avatar)`
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ `,
+ Name: styled.span`
+ margin-left: ${gap.femto};
+ color: text;
+ font-weight: 600;
+ `,
+ ButtonWrapper: styled.div`
+ display: flex;
+ align-items: center;
+ height: 100%;
+ & > :not(:last-child) {
+ margin-right: ${gap("femto")};
+ }
+ `,
+ UsesWrapper: styled.div`
+ display: flex;
+ justify-content: right;
+ height: 100%;
+ padding-right: ${gap('femto')};
+ p {
+ font-size: 1.5em;
+ font-style: bold;
+ text-align: right;
+ }
+ `,
+ IconWrapper: styled.div`
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ color: ${color("success")};
+ font-size: 1.2em;
+ padding: 4px 0;
+`,
+};
+
+function centerHeader(item) {
+ return (
+
+ {item.column.name}
+
+ );
+}
+
+function rightHeader(item: MutableRefObject) {
+ return (
+
+ {item.column.name}
+
+ );
+}
+
+function creatBtn(
+ x: boolean,
+ author: boolean,
+ admin: boolean,
+ dispatch: Dispatch,
+ emojiId: HoarFrost,
+ guildId: Snowflake
+) {
+ const colorMode = useColorMode();
+ if (x == true) {
+ return (
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+function loadedYN(x: boolean) {
+ if (x == true) {
+ return (
+
+
+
+ );
+ }
+ return <>>;
+}
+
+function isAuthor(currentUser: Option, row: CustomEmoji): boolean {
+ if (currentUser.isDefined()) {
+ return currentUser.get.id === row.authorId.getOrElse(-1);
+ }
+ return false;
+}
+
+const EmojiManager: React.FC = ({ guild }) => {
+ const { all: commands, isLoaded: hasLoaded } = usePool({
+ type: "customEmoji",
+ guildId: guild.id,
+ });
+
+ const currentUser: Option = useCurrentUser();
+
+ // 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 [show, setShow] = useState(false);
+
+ const mayManageEmojis = !!(guild.permissions & 1073741824);
+ const colorMode = useColorMode();
+
+ const columns: Column[] = [
+ {
+ key: "loaded",
+ name: "LOADED",
+ width: 10,
+ sortable: true,
+ headerRenderer: centerHeader,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <> {loadedYN(row.discordId.isDefined())}>
+ ),
+ },
+ {
+ key: "url",
+ name: "IMAGE",
+ width: 100,
+ headerRenderer: centerHeader,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+/*
+
+ */
+
+
+
+
+ ),
+ },
+ {
+ key: "download",
+ name: "DOWNLOAD",
+ sortable: true,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>
+
+ {row.name}
+
+ >
+ ),
+ },
+ {
+ key: "authorId",
+ name: "AUTHOR",
+ sortable: true,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+
+
+ {foldAuthorData(row, authorsMap).author}
+
+ ),
+ },
+ {
+ key: "numUses",
+ name: "USES",
+ sortable: true,
+ width: 10,
+ headerRenderer: rightHeader,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>
+
+ {row.numUses}
+
+ >
+ ),
+ },
+ ];
+ const dispatch = useDispatch();
+ if (mayManageEmojis) {
+ columns.push(
+ {
+ key: "btns",
+ name: "MANAGE",
+ width: 150,
+ formatter: ({ row }: { row: CustomEmoji }) => (
+ <>
+
+ {creatBtn(
+ row.discordId.isDefined(),
+ isAuthor(currentUser, row),
+ guild.architus_admin,
+ dispatch,
+ row.id,
+ guild.id
+ )}
+
+
+
+ >
+ ),
+ })
+ }
+
+ const { all: emojiList } = usePool({
+ type: "customEmoji",
+ guildId: guild.id,
+ });
+
+ const [filterSelfAuthored, setFilterSelfAuthored] = useState(false);
+
+ const filteredList = useMemo(() => {
+ return emojiList.filter((value: CustomEmoji, index: number) => {
+ return !filterSelfAuthored || value.authorId.getOrElse(undefined) === currentUser.getOrElse(undefined)?.id;
+ });
+ }, [emojiList, filterSelfAuthored]);
+
+ type ColumnKey = "loaded" | "download" | "authorId" | "numUses" | "priority";
+
+ interface Sort {
+ column: ColumnKey;
+ direction: SortDirection;
+ }
+ const [sort, setSort] = useState